Merge remote-tracking branch 'origin/master' into xtr/session-model-selection

This commit is contained in:
_Kerman committed 2026-10-21 13:57:15 +08:00
commit 4b36aebb97
106 files changed
+1394 -1674

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an
- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.
+1 -1
View File
@@ -155,7 +155,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
- **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs.
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)).
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
+1
View File
@@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
| [`birpc`](https://github.com/antfu-collective/birpc) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
+8 -8
View File
@@ -85,8 +85,8 @@ describe('web e2e: Agent Teams panel', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
onTestFinished(() => { projectionReads.mockRestore() })
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click()
const action = page.getByRole('dialog', { name: 'Agent team', exact: true })
await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor()
await action.getByText('lead').waitFor()
expect(projectionReads).not.toHaveBeenCalled()
@@ -119,8 +119,8 @@ describe('web e2e: Agent Teams panel', () => {
it('keeps the panel inside the viewport and closes on outside click or Escape', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard'))
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const viewport = page.viewportSize()!
await trigger.click()
await panel.getByText('lead', { exact: true }).waitFor()
@@ -162,8 +162,8 @@ describe('web e2e: Agent Teams panel', () => {
})
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
await trigger.hover()
await panel.waitFor()
await panel.hover()
@@ -182,8 +182,8 @@ describe('web e2e: Agent Teams panel', () => {
writeScopes: [],
})
const viewport = page.viewportSize()!
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent team', exact: true })
const card = panel.locator('article').filter({ hasText: 'Responsive task' })
await trigger.click()
await card.waitFor()
+4 -4
View File
@@ -80,15 +80,15 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
expect(await card.getByRole('button', { name: /Accessibility permission|Screen Recording permission/ }).count()).toBe(0)
await page.setViewportSize({ width: 1680, height: 600 })
const plugins = page.locator('[data-plugin-panel]')
const openDetails = page.getByRole('button', { name: 'View Computer Use', exact: true })
const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true })
await openDetails.scrollIntoViewIfNeeded()
expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
await openDetails.click()
const panel = page.locator('[data-cua-settings]')
const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true })
await accessibility.getByRole('button', { name: 'Request permission', exact: true }).waitFor()
expect(await panel.getByRole('region', { name: 'Authorize Computer Use', exact: true }).getByRole('group').count()).toBe(4)
expect(await panel.getByRole('region', { name: 'Manage applications Computer Use can access', exact: true }).count()).toBe(1)
expect(await panel.getByRole('region', { name: 'Authorize computer use', exact: true }).getByRole('group').count()).toBe(4)
expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1)
await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0)
expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
@@ -186,7 +186,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
})
try {
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true })
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer use', exact: true })
for (const width of [1680, 390]) {
await page.setViewportSize({ width, height: 1000 })
if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' })
@@ -1,8 +1,8 @@
- text: Waiting for approval
- group "Approval details":
- strong: Fixture
- text: Allow Computer Use to launch, read, capture and control this application?
- paragraph: Session access lasts until DSH restarts or Computer Use is disabled.
- text: Allow computer use to launch, read, capture and control this application?
- paragraph: Session access lasts until DSH restarts or computer use is disabled.
- button "Reject"
- button "Allow for this session"
- button "Always allow"
@@ -1,8 +1,8 @@
- region "Authorize Computer Use":
- heading "Authorize Computer Use" [level=3]
- region "Authorize computer use":
- heading "Authorize computer use" [level=3]
- group "Default mode for new sessions":
- strong: Default mode for new sessions
- paragraph: Computer Use is available only in PTC mode.
- paragraph: Computer use is available only in PTC mode.
- button "Set to PTC"
- group "PTC execution mode":
- strong: PTC execution mode
@@ -17,8 +17,8 @@
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
- button "Request permission"
- paragraph: Manage permissions in System Settings for the application named by macOS.
- region "Manage applications Computer Use can access":
- heading "Manage applications Computer Use can access" [level=3]
- region "Manage applications computer use can access":
- heading "Manage applications computer use can access" [level=3]
- heading "Always-allowed applications" [level=4]
- list:
- listitem:
@@ -1,9 +1,9 @@
- bundle: Developer Tools
- bundle: Developer tools
description: Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.
- component: NodeJS Inspector
- component: NodeJS inspector
description: Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.
- component: Session Log
- component: Session log
description: Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.
- session sidebar: Session Log
- session sidebar: Session log
description: Analyze raw logs and grouped chat messages for the current session in the sidebar.
- NodeJS bottom: NodeJS Inspector
- NodeJS bottom: NodeJS inspector
+1 -1
View File
@@ -35,7 +35,7 @@ it('opens bottom DevTools after the Developer Tools bundle is enabled on an alre
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
const plugins = page.locator('[data-plugin-panel]')
const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true })
const toggle = plugins.getByRole('switch', { name: 'Enable Developer tools', exact: true })
await toggle.waitFor()
expect(await toggle.getAttribute('aria-checked')).toBe('false')
const panel = page.locator('[data-inspector-panel]')
+4 -4
View File
@@ -923,14 +923,14 @@ describe('web e2e: plugin manager', () => {
expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1)
try {
await setLanguage('en')
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
await child.getByText('Team Tools', { exact: true }).waitFor()
await child.getByText('Team tools', { exact: true }).waitFor()
expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1)
// Schedule ships in the delivered composition, so no bundle card carries it in either language.
expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0)
// The official configuration pages follow the language too, from their own dictionary.
+1 -1
View File
@@ -776,7 +776,7 @@ async function openSessionInspector(page: Page): Promise<void> {
// The showcase opens with a file tab in the right sidebar; a new tab shows the guide entries.
await page.getByRole('button', { name: 'New tab', exact: true }).click()
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click({ timeout: 30_000 })
await page.getByRole('table', { name: 'Raw Log', exact: true }).waitFor({ timeout: 15_000 })
await page.getByRole('table', { name: 'Raw log', exact: true }).waitFor({ timeout: 15_000 })
expect(await setBundle(page, INSPECTOR_BUNDLE, false)).toBe('applied')
}
+7 -7
View File
@@ -46,12 +46,12 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click()
const mode = page.getByRole('combobox', { name: 'Presentation', exact: true })
expect(await mode.inputValue()).toBe('session-log')
const raw = page.getByRole('table', { name: 'Raw Log', exact: true })
const raw = page.getByRole('table', { name: 'Raw log', exact: true })
await raw.getByRole('button', { name: 'Filter types', exact: true }).click()
await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('user/message')
await page.getByRole('option', { name: 'user/message', exact: true }).click()
await raw.getByRole('button', { name: 'user/message', exact: true }).first().click()
const panel = page.locator('section[aria-label="Raw Log"]')
const panel = page.locator('section[aria-label="Raw log"]')
const details = panel.getByRole('separator').locator('..').locator('ul').first()
expect(await details.innerText()).toContain(prompts[0])
expect(await details.locator('details:not([open])').count()).toBe(0)
@@ -64,14 +64,14 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C
const replacements = [...new Set(times)].filter(value => /^\d{2}:\d{2}:\d{2}\.\d{3}$/.test(value))
.map(value => [value, '{{time}}'] as const)
await compareOrRefreshGolden(join(directory, 'log.expected.md'),
await captureStableAria(page, 'section[aria-label="Raw Log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode())
await captureStableAria(page, 'section[aria-label="Raw log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode())
await mode.selectOption('chat-node')
const groups = page.getByRole('table', { name: 'Chat Group', exact: true })
const groups = page.getByRole('table', { name: 'Chat group', exact: true })
expect(await groups.getByRole('columnheader', { name: 'Time (UTC)' }).count()).toBe(0)
await groups.getByRole('button', { name: 'assistant-step', exact: true }).first().click()
await page.getByRole('navigation', { name: 'Raw data navigation', exact: true }).waitFor()
const groupPanel = page.locator('section[aria-label="Chat Group"]')
await groupPanel.locator('summary').filter({ hasText: 'Node Data' }).click()
const groupPanel = page.locator('section[aria-label="Chat group"]')
await groupPanel.locator('summary').filter({ hasText: 'Node data' }).click()
const blocks = groupPanel.getByText('blocks:', { exact: true }).locator('..')
await blocks.click()
await blocks.locator('..').getByText('Other properties:', { exact: true }).waitFor()
@@ -104,7 +104,7 @@ it.skipIf(webSnapshotMode() === 'record')('highlights nearby Chat content when t
await page.getByText('DONE', { exact: true }).waitFor()
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click()
const table = page.getByRole('table', { name: 'Raw Log', exact: true })
const table = page.getByRole('table', { name: 'Raw log', exact: true })
await table.getByRole('button', { name: 'Filter types', exact: true }).click()
await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('tool/call')
await page.getByRole('button', { name: 'Apply filter', exact: true }).click()
+5 -5
View File
@@ -42,7 +42,7 @@ it('toggles bottom DevTools from the page and iframe without a selected Session'
const close = panel.getByRole('button', { name: 'Collapse', exact: true })
expect(await close.textContent()).toBe('')
expect(await close.locator('svg').count()).toBe(1)
const divider = panel.getByRole('separator', { name: 'Resize NodeJS Inspector panel' })
const divider = panel.getByRole('separator', { name: 'Resize NodeJS inspector panel' })
const drag = async (deltaY: number): Promise<void> => {
const box = (await divider.boundingBox())!
const x = box.x + box.width / 2
@@ -93,10 +93,10 @@ it.each([
openSidebar: '打开右侧边栏', sidebarDescription: '在侧边栏分析当前会话原始日志和聊天分组数据',
},
{
locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer Tools', bundle: 'Developer Tools',
locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer tools', bundle: 'Developer tools',
description: 'Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.',
inspector: 'NodeJS Inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.',
session: 'Session Log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.",
inspector: 'NodeJS inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.',
session: 'Session log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.",
openSidebar: 'Open right sidebar', sidebarDescription: 'Analyze raw logs and grouped chat messages for the current session in the sidebar.',
},
])('shows the Inspector bundle and component copy in $locale', async (copy) => {
@@ -150,7 +150,7 @@ it.each([
` description: ${await sessionGuide.getByText(copy.sidebarDescription, { exact: true }).innerText()}`,
`- NodeJS bottom: ${await bottomTitle.innerText()}`)
await sessionGuide.click()
await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw Log', exact: true }).waitFor()
await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw log', exact: true }).waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL(`./expected/shared-inspector/metadata-${copy.file}.expected.md`, import.meta.url)),
snapshot.join('\n'), webSnapshotMode())
})
@@ -1,4 +1,4 @@
- dialog "Agent Team":
- dialog "Agent team":
- heading "Members" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 2" [level=3]
@@ -1,4 +1,4 @@
- dialog "Agent Team":
- dialog "Agent team":
- heading "Members" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 1" [level=3]
+4 -4
View File
@@ -70,9 +70,9 @@ it('persists the Git Worktrees switch and removes its capability while retaining
}
let panel = await openPlugins()
let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor()
await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor()
expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1)
let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await selectedBundles()).not.toContain(BUNDLE)
expect(rows()).toEqual([])
@@ -92,7 +92,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.reload({ waitUntil: 'load' })
panel = await openPlugins()
card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
expect(tools()).toContain('create_worktree')
await toggle.click()
@@ -108,7 +108,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.reload({ waitUntil: 'load' })
panel = await openPlugins()
await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true })
.getAttribute('aria-checked')).toBe('false')
expect(tools()).not.toContain('create_worktree')
expect(await selectedBundles()).not.toContain(BUNDLE)
+1 -1
View File
@@ -2604,7 +2604,7 @@ export interface Config {
maxOutputBytes?: number
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
+1 -1
View File
@@ -2606,7 +2606,7 @@ export interface Config {
maxOutputBytes?: number
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0894bd9f35430753
zh: 2d28090e71134edd
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
en: ed501a72ab8ba6e7
zh: 8ad775b735887a10
en: cb799f5d561dd917
zh: 7739af36ef8ffb3f
/cookbook-adding-a-workspace-package/6-verify:
en: 80c7fb90b1175aae
zh: 2ba37962dbc57299
+1 -1
View File
@@ -136,7 +136,7 @@ Merge these entries into `package.json`, retaining existing runtime exports and
}
```
Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic.
Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic.
Fields fall back independently before view-specific name formatting, using the existing locale language chain first:
+1 -1
View File
@@ -136,7 +136,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
}
```
语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。
语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。
各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链:
@@ -380,7 +380,7 @@ export const en = {
installGuideFill: 'Use example',
installGuideFillAria: 'Use the example {example}',
installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.',
installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.',
installUpgradeNotice: 'On-demand official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.',
registryToggle: 'Registry',
registryLegend: 'The npm registry the plugin is downloaded from',
registryDefault: 'Default registry',
@@ -1,11 +1,11 @@
![A conversation titled Add rate limiting with its Agent Team button, and the panel it opens: two members and two shared tasks](preview.svg)
![A conversation titled Add rate limiting with its Agent team button, and the panel it opens: two members and two shared tasks](preview.svg)
Ask the agent you are talking to, the lead, to split a job. It creates named teammates, puts the work on a shared task board, waits for their results, and reports back.
## How to use
- Ask in plain words, for example: "Use Agent Teams to split this task between two teammates, wait, and summarize."
- Click **Agent Team** next to the conversation title to see **Members** and **Shared tasks**. Each task shows its status, **Owner**, **Write scopes**, and the tasks it is **Blocked by**.
- Ask in plain words, for example: "Use agent teams to split this task between two teammates, wait, and summarize."
- Click **Agent team** next to the conversation title to see **Members** and **Shared tasks**. Each task shows its status, **Owner**, **Write scopes**, and the tasks it is **Blocked by**.
- Click a teammate to open its conversation and talk to it there.
- The panel is read-only. To change tasks or teammates, ask the lead.
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Agent Teams",
"title": "Agent teams",
"description": "Enable team collaboration, team tools, the member roster, and the shared task board.",
"detail": "docs/DETAIL.md"
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Team Collaboration",
"title": "Team collaboration",
"description": "Manage team members, member messages, and shared tasks."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Auto Authorization Review",
"title": "Auto authorization review",
"description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call.",
"detail": "docs/DETAIL.md"
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Browser Use (Chrome DevTools)",
"title": "Browser use (Chrome DevTools)",
"description": "Let the model inspect and operate web pages through Chrome DevTools MCP in a launched Chrome."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Browser Use (Playwright)",
"title": "Browser use (Playwright)",
"description": "Let the model browse and operate web pages through Playwright MCP in a launched Chromium."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Team Panel",
"title": "Team panel",
"description": "View team members and shared tasks, and navigate to member conversations."
}
}
@@ -37,10 +37,10 @@ export type TeamKey = keyof typeof zh
/** English dictionary checked against the Chinese key set. */
export const en = {
trigger: 'Agent Team',
loading: 'Loading Team…',
trigger: 'Agent team',
loading: 'Loading team…',
unavailable: 'Team is unavailable',
failure: 'Invalid persisted Team record: {message}',
failure: 'Invalid persisted team record: {message}',
empty: 'No shared tasks yet. Create them through the conversation.',
roster: 'Members',
tasks: 'Shared tasks',
@@ -82,7 +82,7 @@ describe('ui-team browser plugin', () => {
locale: 'agent-team',
})
const t = b.ctx.locale.bind('agent-team')
expect(t('trigger')).toBe('Agent Team')
expect(t('trigger')).toBe('Agent team')
expect(b.navigation).toEqual([])
@@ -7,10 +7,10 @@ export const NS = 'cotTranslation'
/** English copy and key source. */
export const en = {
provider: 'Translation service', google: 'Google', bing: 'Bing',
deepseekAccount: 'DeepSeek Flash — Account (paid)', deepseekOfficial: 'DeepSeek Flash — Official API (paid)',
deepseekAccount: 'DeepSeek Flash — account (paid)', deepseekOfficial: 'DeepSeek Flash — official API (paid)',
providerUnavailableOption: '{provider} — unavailable',
providerUnavailable: 'This provider is unavailable. Configure your DeepSeek account or Official API provider, or choose an available service.',
paidNotice: 'Each uncached fragment sends a separate paid DeepSeek Flash request through the selected account or Official API credentials. Reopening reasoning reuses saved results; changing the provider, language, source text, or request settings may send a new paid request.',
providerUnavailable: 'This provider is unavailable. Configure your DeepSeek account or official API provider, or choose an available service.',
paidNotice: 'Each uncached fragment sends a separate paid DeepSeek Flash request through the selected account or official API credentials. Reopening reasoning reuses saved results; changing the provider, language, source text, or request settings may send a new paid request.',
anonymousNotice: 'Bing and Google need no translation login or API key. Their anonymous endpoints may reject or limit requests.', targetLanguage: 'Target language',
targetLanguageHint: 'auto follows the UI language; language codes such as zh, en, and ja also work.',
privacy: 'Expanded reasoning is sent to the selected service. It may contain private code or conversation details.',
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Computer Use (Cua Driver MCP)",
"title": "Computer use (Cua Driver MCP)",
"description": "Let the model operate the desktop through a separately installed cua-driver executable."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Computer Use (Cua Driver)",
"title": "Computer use (Cua Driver)",
"description": "Let the model operate the desktop with the bundled Cua Driver runtime; needs desktop permissions for the app that launches dsh."
}
}
@@ -12,7 +12,7 @@ Expand a **Think** row to read its reasoning in your interface language. Paragra
- The reasoning you expand can contain your code and conversation details.
- Bing is the default; Google is also available. Neither needs an account or API key, but neither guarantees availability or a free quota.
- With a DeepSeek account or Official API set up, **DeepSeek Flash — Account (paid)** and **DeepSeek Flash — Official API (paid)** also appear. Each text piece without a saved translation is then a paid request.
- With a DeepSeek account or official API set up, **DeepSeek Flash — account (paid)** and **DeepSeek Flash — official API (paid)** also appear. Each text piece without a saved translation is then a paid request.
- Translations are saved in the session and reused when you reopen it. The original reasoning and the model's input stay unchanged.
- Collapsed summaries stay in the original language. Translation can change Markdown formatting or code.
- Turning the plugin off removes its buttons and cancels pending translations; saved translations stay in the session.
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Computer Use",
"description": "Currently supports macOS; Windows support is in development. Loads Computer Use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.",
"title": "Computer use",
"description": "Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.",
"detail": "docs/DETAIL.md"
}
}
@@ -17,7 +17,7 @@ export type NativeArchitecture = 'arm64' | 'x64'
* @throws When the current host has no native implementation.
*/
export function nativeHostArchitecture(platform: string, architecture: string): NativeArchitecture {
if (platform !== 'darwin' || (architecture !== 'arm64' && architecture !== 'x64')) throw new Error('Computer Use currently requires macOS on Apple silicon or Intel. Windows support is in development')
if (platform !== 'darwin' || (architecture !== 'arm64' && architecture !== 'x64')) throw new Error('Computer use currently requires macOS on Apple silicon or Intel. Windows support is in development')
return architecture
}
@@ -91,7 +91,7 @@ async function compileNative(spec: NativeBuildSpec, signal: AbortSignal, executa
signal.throwIfAborted()
if (outcome.exitCode !== 0 || outcome.signal !== null) {
const diagnostics = `${handle.collected.stdout?.readFrom(0).text ?? ''}${handle.collected.stderr?.readFrom(0).text ?? ''}`
throw new Error(`Computer Use native preparation failed (${String(outcome.exitCode)}, ${String(outcome.signal)}): ${diagnostics}`)
throw new Error(`Computer use native preparation failed (${String(outcome.exitCode)}, ${String(outcome.signal)}): ${diagnostics}`)
}
const built = await readFile(join(scratch, 'release', 'dsh-cua-native'))
let previous: Buffer | undefined
@@ -107,7 +107,7 @@ async function compileNative(spec: NativeBuildSpec, signal: AbortSignal, executa
try { retained = await readFile(runtime) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (retained && !retained.equals(built)) throw new Error('Computer Use retained executable does not match its content digest')
if (retained && !retained.equals(built)) throw new Error('Computer use retained executable does not match its content digest')
signal.throwIfAborted()
if (!retained) await writeFileAtomic(runtime, built, { mode: 0o755 })
await access(runtime, constants.X_OK)
+1 -1
View File
@@ -18,7 +18,7 @@
en: ceb54d964dc355b6
zh: 0892066fbceef7b9
/deepseek-ai-dsh-cua/use-this-package/permission-controls:
en: be53792edc381ea1
en: 7315ac8d68cf5d02
zh: 037407055d9f34a0
/deepseek-ai-dsh-cua/understand-the-implementation:
en: a32743fc5b545ab3
+1 -1
View File
@@ -43,7 +43,7 @@ Actions return `dispatched:true, verified:false`; read fresh state to verify the
### Permission controls
The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize Computer Use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications Computer Use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed.
The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize computer use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications computer use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed.
System-permission operations affect the computer running DSH. When accessing DSH remotely, complete authorization on that computer.
@@ -146,16 +146,16 @@ export class ApplicationAccess {
let result: AskUserQuestionAnswer
try {
result = await questions.ask({ agent, signal, questions: [{ id: 'cua-app-access',
question: `Allow Computer Use to launch if needed, read, capture, and control ${identity.name}?`,
detail: 'Session access lasts until DSH restarts or Computer Use is disabled.',
question: `Allow computer use to launch if needed, read, capture, and control ${identity.name}?`,
detail: 'Session access lasts until DSH restarts or computer use is disabled.',
options, intent: {
kind: 'approval', approve: 'Allow for this session', reject: 'Deny',
...(identity.persistable && this.options.save ? { always: 'Always allow' } : {}),
subject: { name: identity.name, ...(icon === undefined ? {} : { icon }) },
display: {
question: { en: 'Allow Computer Use to launch, read, capture and control this application?',
question: { en: 'Allow computer use to launch, read, capture and control this application?',
zh: '允许 Computer Use 启动、读取、截图和控制此应用吗?' },
detail: { en: 'Session access lasts until DSH restarts or Computer Use is disabled.',
detail: { en: 'Session access lasts until DSH restarts or computer use is disabled.',
zh: '会话授权在 DSH 重启或关闭 Computer Use 后失效。' },
approve: { en: 'Allow for this session', zh: '本会话允许' },
},
@@ -2,13 +2,13 @@
export const NS = 'cuaPermissions'
/** English permission-page dictionary. */
export const en = {
title: 'Authorize Computer Use',
applicationsTitle: 'Manage applications Computer Use can access',
setupTitle: 'Set up Computer Use before use',
setupBody: 'Computer Use is enabled. Before using it, switch to PTC mode and grant Accessibility and Screen Recording permissions. Open the plugin details to complete setup.',
setupQueryFailed: 'Computer Use is enabled, but system permissions could not be checked. Open the plugin details to see the error and retry.',
title: 'Authorize computer use',
applicationsTitle: 'Manage applications computer use can access',
setupTitle: 'Set up computer use before use',
setupBody: 'Computer use is enabled. Before using it, switch to PTC mode and grant Accessibility and Screen Recording permissions. Open the plugin details to complete setup.',
setupQueryFailed: 'Computer use is enabled, but system permissions could not be checked. Open the plugin details to see the error and retry.',
defaultTitle: 'Default mode for new sessions',
defaultBody: 'Computer Use is available only in PTC mode.',
defaultBody: 'Computer use is available only in PTC mode.',
defaultConfirm: 'Set to PTC', defaultSet: 'PTC is the default',
defaultUnavailable: 'Could not read the default mode. Check it in Settings.',
defaultReadOnly: 'Default-mode settings are read-only.',
@@ -75,13 +75,13 @@ it.each([
const question = request.questions[0]!
expect(question).toMatchObject({
id: 'cua-app-access',
question: 'Allow Computer Use to launch if needed, read, capture, and control Test application?',
detail: 'Session access lasts until DSH restarts or Computer Use is disabled.',
question: 'Allow computer use to launch if needed, read, capture, and control Test application?',
detail: 'Session access lasts until DSH restarts or computer use is disabled.',
intent: { kind: 'approval', approve: 'Allow for this session', reject: 'Deny',
subject: { name: 'Test application' }, display: {
question: { en: 'Allow Computer Use to launch, read, capture and control this application?',
question: { en: 'Allow computer use to launch, read, capture and control this application?',
zh: '允许 Computer Use 启动、读取、截图和控制此应用吗?' },
detail: { en: 'Session access lasts until DSH restarts or Computer Use is disabled.',
detail: { en: 'Session access lasts until DSH restarts or computer use is disabled.',
zh: '会话授权在 DSH 重启或关闭 Computer Use 后失效。' },
approve: { en: 'Allow for this session', zh: '本会话允许' },
},
@@ -9,16 +9,16 @@
en: a83fef9afaa2d5c3
zh: 95ff381e7be3afe6
/deepseek-ai-dsh-experimental-inspector-profile/summary:
en: 959f9bb18e5e99ad
en: 42e5984090888b60
zh: f0b00539b5ab40fd
/deepseek-ai-dsh-experimental-inspector-profile/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-experimental-inspector-profile/use-this-package:
en: 919c4baad08a10a6
en: 25cfe986a0ff91e1
zh: 0f0c82abda2f3630
/deepseek-ai-dsh-experimental-inspector-profile/understand-the-implementation:
en: c9afee7b01eff431
en: cc0adef84da59698
zh: c2d1209c14a42088
/deepseek-ai-dsh-experimental-inspector-profile/model-experience:
en: 315bc90149039d2d
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Enable this optional bundle to inspect Session data in the Sidebar and open NodeJS Inspector in the bottom panel. It enables both inspectors and Host fetch capture; the bundle itself is off by default in Plugin Manager.
Enable this optional bundle to inspect Session data in the Sidebar and open NodeJS inspector in the bottom panel. It enables both inspectors and Host fetch capture; the bundle itself is off by default in Plugin Manager.
## Table of Contents
@@ -24,11 +24,11 @@ Enable this optional bundle to inspect Session data in the Sidebar and open Node
<a id="use-this-package"></a>
## Use this package
Enable **Developer Tools** in Plugin Manager's official group. This selects `@deepseek-ai/dsh-experimental-inspector-profile` for the current Web profile.
Enable **Developer tools** in Plugin Manager's official group. This selects `@deepseek-ai/dsh-experimental-inspector-profile` for the current Web profile.
Open **Session Log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw Log and Chat Group presentations; Raw Log is selected initially.
Open **Session log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw log and Chat group presentations; Raw log is selected initially.
Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS Inspector** in the bottom panel with the packaged DevTools frontend. Enabling this bundle starts the local debugging backend and unredacted Host fetch capture; no user override or debugging argument is required.
Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS inspector** in the bottom panel with the packaged DevTools frontend. Enabling this bundle starts the local debugging backend and unredacted Host fetch capture; no user override or debugging argument is required.
-----
@@ -38,7 +38,7 @@ Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS Inspector** in the botto
<details>
<summary>Implementation internals — click to expand</summary>
[`cordis.patch.yml`](cordis.patch.yml) enables Session Inspector and the NodeJS Inspector backend with `captureFetch: true`, independently of debugging flags. See the Inspector [README](../inspector/README.md) for debugging and capture behavior. Each plugin owns its behavior and lifetime.
[`cordis.patch.yml`](cordis.patch.yml) enables Session Inspector and the NodeJS inspector backend with `captureFetch: true`, independently of debugging flags. See the Inspector [README](../inspector/README.md) for debugging and capture behavior. Each plugin owns its behavior and lifetime.
</details>
@@ -1,13 +1,13 @@
![Session Log table in the sidebar and the NodeJS Inspector console](preview.svg)
![Session log table in the sidebar and the NodeJS inspector console](preview.svg)
Use it when troubleshooting: check exactly what DSH recorded for a session, or debug DSH itself in Chrome DevTools. It does not change how the agent works.
## How to use
- In the right sidebar, click **+** (**New tab**), then choose **Session Log** on the **Start** page that opens.
- **Raw Log** lists the session's events grouped by turn and step; click a row to see its full data. Switch the selector to **Chat Group** to see the chat messages and their groups instead.
- In the right sidebar, click **+** (**New tab**), then choose **Session log** on the **Start** page that opens.
- **Raw log** lists the session's events grouped by turn and step; click a row to see its full data. Switch the selector to **Chat group** to see the chat messages and their groups instead.
- Click the crosshair button next to the selector, then click a message in the chat to find its record. Press Esc to cancel.
- Press **Ctrl+Shift+.** (**Cmd+Shift+.** on macOS) to open or collapse **NodeJS Inspector** at the bottom of the window: Chrome DevTools, in English, connected to the DSH backend.
- Press **Ctrl+Shift+.** (**Cmd+Shift+.** on macOS) to open or collapse **NodeJS inspector** at the bottom of the window: Chrome DevTools, in English, connected to the DSH backend.
## Good to know
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Developer Tools",
"title": "Developer tools",
"description": "Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.",
"detail": "docs/DETAIL.md"
}
@@ -9,13 +9,13 @@
en: 1060bee3ef37134d
zh: 7a7b0096714f4279
/deepseek-ai-dsh-experimental-inspector/summary:
en: e2a633d32f00aa99
en: 4a540c3df0359524
zh: 1d48f445800e3a35
/deepseek-ai-dsh-experimental-inspector/table-of-contents:
en: 331f844c32631a82
zh: a9bc365f7d61d9ed
/deepseek-ai-dsh-experimental-inspector/use-this-package:
en: 6d290aabc543f878
en: 7d4d718b1a6f92ae
zh: 7ad283b159ae2c29
/deepseek-ai-dsh-experimental-inspector/runtime-layout:
en: 7db0c435e07d5005
+2 -2
View File
@@ -11,7 +11,7 @@ English | [中文](README.zh.md)
Inspect one running dsh Host and its browser Clients in Chrome DevTools: Host and Client Console contexts, Host Sources and debugging, captured Host fetches, and a shared Cordis tree, with all CDP state in a Worker.
The optional [Developer Tools bundle](../inspector-profile/README.md) enables both NodeJS Inspector and Session Log, including Host fetch capture. Web startup does not require or accept `--inspect`.
The optional [Developer tools bundle](../inspector-profile/README.md) enables both NodeJS inspector and Session log, including Host fetch capture. Web startup does not require or accept `--inspect`.
## Table of Contents
@@ -35,7 +35,7 @@ For trusted local development, `pnpm run demo:inspector` explicitly mounts the s
The bundle sets `experimental-inspector` to `disabled: false` with `captureFetch: true`. The Host's optional `--inspect` argument hook only opens an external Chrome window; it does not control whether the Inspector runs.
When this component is enabled, **Ctrl/Cmd+Shift+.** toggles **NodeJS Inspector** in the full-width bottom panel without requiring a selected Session. Drag its top edge to resize it, taking space from the content above; the selected height is retained across collapse and navigation. The focused divider also accepts Up/Down, Home and End. Its iframe loads on the first open and remains mounted while collapsed and across Session or main-panel navigation; unloading the component resets it. The X button and shortcut restore the opening control's focus when that control remains connected. The iframe automatically connects to this Host and opens Console; the manual Connection panel is omitted. Each embedded frontend shows Host and only its embedding page's Client in Console, Sources and the Cordis tree. The printed `devtools://` URL shows all Clients.
When this component is enabled, **Ctrl/Cmd+Shift+.** toggles **NodeJS inspector** in the full-width bottom panel without requiring a selected Session. Drag its top edge to resize it, taking space from the content above; the selected height is retained across collapse and navigation. The focused divider also accepts Up/Down, Home and End. Its iframe loads on the first open and remains mounted while collapsed and across Session or main-panel navigation; unloading the component resets it. The X button and shortcut restore the opening control's focus when that control remains connected. The iframe automatically connects to this Host and opens Console; the manual Connection panel is omitted. Each embedded frontend shows Host and only its embedding page's Client in Console, Sources and the Cordis tree. The printed `devtools://` URL shows all Clients.
The Host serves the mirrored DevTools frontend at the application-relative `inspector/devtools/devtools_app.html` path, using the existing Web login. The `inspector/devtools` prefix redirects to that entry; GET and HEAD serve files, missing resources return 404, and other methods return 405. Its `cdp` WebSocket route authenticates the same browser session and forwards only to this Inspector's Worker page target. The bottom panel supplies its claimed `clientSourceId` to the entry and automatic WebSocket endpoint; the selection follows that logical Client across reconnects. An absent selector includes all Clients, an unavailable selected Client leaves Host visible, and empty or repeated selectors reject the upgrade with 400. Explicit `ws` or `wss` query parameters override the automatic endpoint, including its selection.
@@ -1,6 +1,6 @@
{
"meta": {
"title": "NodeJS Inspector",
"title": "NodeJS inspector",
"description": "Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging."
}
}
@@ -1,4 +1,4 @@
/** Localized labels for the NodeJS Inspector bottom panel and command. */
/** Localized labels for the NodeJS inspector bottom panel and command. */
export const zh = {
title: 'NodeJS 诊断',
toggle: '展开或收起 NodeJS 诊断',
@@ -9,11 +9,11 @@ export const zh = {
/** English labels checked against the bottom-panel dictionary. */
export const en: Record<keyof typeof zh, string> = {
title: 'NodeJS Inspector',
toggle: 'Toggle NodeJS Inspector',
title: 'NodeJS inspector',
toggle: 'Toggle NodeJS inspector',
close: 'Collapse',
resize: 'Resize NodeJS Inspector panel',
frameTitle: 'NodeJS Inspector',
resize: 'Resize NodeJS inspector panel',
frameTitle: 'NodeJS inspector',
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -50,7 +50,7 @@ it('opens on Mod+Shift+Period without a Session or Sidebar and retains the ifram
expect(press(window, { ctrlKey: false }).defaultPrevented).toBe(false)
expect(body.container.querySelector('iframe')).toBeNull()
expect(press().defaultPrevented).toBe(true)
const frame = body.view.getByTitle('NodeJS Inspector')
const frame = body.view.getByTitle('NodeJS inspector')
expect(frame.getAttribute('src')).toBe('inspector/devtools/devtools_app.html?disableLocaleInfoBar=true&clientSourceId=client-page')
expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(frame.getAttribute('name')).toBe('dsh-nodejs-inspector')
@@ -62,7 +62,7 @@ it('opens on Mod+Shift+Period without a Session or Sidebar and retains the ifram
press()
expect(body.container.querySelector('section')?.hidden).toBe(true)
press()
expect(body.view.getByTitle('NodeJS Inspector')).toBe(frame)
expect(body.view.getByTitle('NodeJS inspector')).toBe(frame)
expect(body.container.querySelector('section')?.hidden).toBe(false)
})
@@ -70,8 +70,8 @@ it('resizes with the owned pointer, bounds the height, and retains it across col
const { body } = await setup()
press()
const panel = body.container.querySelector('section')!
const frame = body.view.getByTitle('NodeJS Inspector')
const divider = body.view.getByRole('separator', { name: 'Resize NodeJS Inspector panel' })
const frame = body.view.getByTitle('NodeJS inspector')
const divider = body.view.getByRole('separator', { name: 'Resize NodeJS inspector panel' })
let captured: number | undefined
Object.assign(divider, {
setPointerCapture: (id: number) => { captured = id },
@@ -113,7 +113,7 @@ it('resizes with the owned pointer, bounds the height, and retains it across col
press()
press()
expect(height()).toBe(20)
expect(body.view.getByTitle('NodeJS Inspector')).toBe(frame)
expect(body.view.getByTitle('NodeJS inspector')).toBe(frame)
})
it('resizes the horizontal divider with arrow keys and Home or End', async () => {
@@ -137,7 +137,7 @@ it('routes frame input through the effective binding and restores opener focus',
try {
opener.focus()
press(opener)
const frame = body.view.getByTitle<HTMLIFrameElement>('NodeJS Inspector')
const frame = body.view.getByTitle<HTMLIFrameElement>('NodeJS inspector')
const document = frame.contentDocument!
frame.focus()
expect(press(document).defaultPrevented).toBe(true)
@@ -168,7 +168,7 @@ it('keeps modal arbitration and updates locale without replacing the iframe', as
expect(body.container.querySelector('iframe')).toBeNull()
} finally { modal.remove() }
press()
const frame = body.view.getByTitle('NodeJS Inspector')
const frame = body.view.getByTitle('NodeJS inspector')
act(() => { locale.setLocale('zh') })
expect(body.view.getByTitle('NodeJS 诊断')).toBe(frame)
act(() => { body.view.getByRole('button', { name: '收起' }).click() })
@@ -178,7 +178,7 @@ it('keeps modal arbitration and updates locale without replacing the iframe', as
it('removes the command and frame on unload and starts a fresh closed panel on reload', async () => {
const { runtime, locale, provider, body, plugin } = await setup()
press()
const frame = body.view.getByTitle('NodeJS Inspector')
const frame = body.view.getByTitle('NodeJS inspector')
await provider.dispose()
expect(runtime.ctx.shortcuts.catalog.getSnapshot().find(row => row.id === ID)).toBeUndefined()
expect(frame.isConnected).toBe(false)
@@ -186,5 +186,5 @@ it('removes the command and frame on unload and starts a fresh closed panel on r
await runtime.mount(plugin)
expect(body.container.querySelector('iframe')).toBeNull()
press()
expect(body.view.getByTitle('NodeJS Inspector')).not.toBe(frame)
expect(body.view.getByTitle('NodeJS inspector')).not.toBe(frame)
})
@@ -9,13 +9,13 @@
en: 151ad1f553a733d7
zh: b2992178863a80c7
/deepseek-ai-dsh-experimental-session-inspector/summary:
en: 9ba1ce1f7f3c36ff
en: da1f12910d37aa6e
zh: a6aaf26eb598a80a
/deepseek-ai-dsh-experimental-session-inspector/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-experimental-session-inspector/use-this-package:
en: a54a88121496d7d0
en: f8ee16e89f8b1d07
zh: 85fb112b26ec0ccb
/deepseek-ai-dsh-experimental-session-inspector/understand-the-implementation:
en: 6d71dcef4dca024b
@@ -27,7 +27,7 @@
en: 258d6659c6cb3789
zh: a6517f274c596a7a
/deepseek-ai-dsh-experimental-session-inspector/known-limitations-and-deferred-work:
en: bf2b68c4be0d9d6f
en: 042b2cc65c722176
zh: 1530b5fa68f2baa1
/deepseek-ai-dsh-experimental-session-inspector/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Inspect raw Session logs and Chat structures through the optional [Developer Tools bundle](../inspector-profile/README.md). Open **Session Log** from the Sidebar's new-tab menu or guide, then choose **Raw Log** or **Chat Group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer.
Inspect raw Session logs and Chat structures through the optional [Developer tools bundle](../inspector-profile/README.md). Open **Session log** from the Sidebar's new-tab menu or guide, then choose **Raw log** or **Chat group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer.
## Table of Contents
@@ -24,39 +24,39 @@ Inspect raw Session logs and Chat structures through the optional [Developer Too
<a id="use-this-package"></a>
## Use this package
The Developer Tools bundle mounts this plugin as `session-inspector`. It registers the `session-inspector-log` Sidebar page for the Sidebar's current Session, without a Conversation View or header label. The selector initially shows Session Log, with the current row count alongside it. Changing presentation resets local selection, type filtering, and scroll state. This plugin requires no debugging argument, accepts no configuration, and changes no Session data.
The Developer tools bundle mounts this plugin as `session-inspector`. It registers the `session-inspector-log` Sidebar page for the Sidebar's current Session, without a Conversation View or header label. The selector initially shows Raw log, with the current row count alongside it. Changing presentation resets local selection, type filtering, and scroll state. This plugin requires no debugging argument, accepts no configuration, and changes no Session data.
Click the filter icon in the Type header to enter a type fragment. Suggestions update asynchronously from all loaded records. Enter or **Apply filter** confirms the fragment; clicking a suggestion or selecting it with arrow keys and Enter confirms that type. Matching ignores case and uses containment: `delta` and `*delta*` both match reasoning and tool-call deltas. Editing alone does not change the table. Escape, Cancel, or clicking outside discards the draft; clearing and confirming restores all types.
Filtered tables retain matching rows and their ancestor context, initially expanding folded ancestors. Context-only types appear muted and do not count toward the matching-row total. New and paginated rows use the confirmed filter. Confirming resets selection, disclosure choices, and reserved trailing space, and returns to the top. Following an object reference or picking a Chat element clears the filter if its target row is excluded, then reveals that row.
Session Log groups records between `turn/start` and `turn/end`, with nested `step/start`–`step/end` ranges. The start record is the sticky group header; the end remains a raw child at the group's tail. Turn and Step groups stay expanded after completion and can be folded manually. Records between Steps belong to the Turn; records outside a loaded range remain ungrouped. Missing starts are not synthesized: loading an earlier start adds ancestry to the existing rows. Assistant streams retain their own nesting inside the Step.
Raw log groups records between `turn/start` and `turn/end`, with nested `step/start`–`step/end` ranges. The start record is the sticky group header; the end remains a raw child at the group's tail. Turn and Step groups stay expanded after completion and can be folded manually. Records between Steps belong to the Turn; records outside a loaded range remain ungrouped. Missing starts are not synthesized: loading an earlier start adds ancestry to the existing rows. Assistant streams retain their own nesting inside the Step.
Assistant logs group original timed chunks beneath their matching `block-start`, including text, reasoning, and tool-call argument deltas. Unfinished blocks expand by default; `block-end` automatically collapses its block, which can then be reopened manually. Historical closed blocks start collapsed. Live chunks share an attempt row until settlement replaces them with the durable event. Chat Group nests actual group members below their group. Hidden or unlisted materialized Nodes remain root rows, inserted by event anchor between the existing roots instead of appended after the last Turn; the Inspector does not invent group membership. Inserts and updates briefly highlight unless the system requests reduced motion.
Assistant logs group original timed chunks beneath their matching `block-start`, including text, reasoning, and tool-call argument deltas. Unfinished blocks expand by default; `block-end` automatically collapses its block, which can then be reopened manually. Historical closed blocks start collapsed. Live chunks share an attempt row until settlement replaces them with the durable event. Chat group nests actual group members below their group. Hidden or unlisted materialized Nodes remain root rows, inserted by event anchor between the existing roots instead of appended after the last Turn; the Inspector does not invent group membership. Inserts and updates briefly highlight unless the system requests reduced motion.
Use the disclosure arrow to fold children and select a row's type or data to inspect raw details. Horizontal data previews omit `type`, `seq`, and `sequence`, plus top-level `time`. Chat Node also omits top-level `key` and `kind`, which already have their own columns. When one field remains, only its value appears. Tool-call delta previews show only the argument fragment; reasoning delta previews show only the text. Preview filtering does not remove fields from raw details. Drag the detail panel's upper separator, or use its Up/Down keys, to resize it; selecting another row retains its height.
A folded reasoning `block-start` omits `index` and shows `blockType: reasoning` followed by its joined reasoning deltas, within the normal preview length. Interleaved blocks keep separate summaries. Expanding the block restores its ordinary fields and original child rows; raw details retain the original block-start without synthesized text.
Chat details show a lazy object tree rather than JSON conversion. Maps retain object keys and values, Sets and arrays show members, and class instances retain their names and own fields. Collections expand in pages; getters are labelled without execution, and WeakMap/WeakSet contents remain opaque. The inspected root expands inline; unregistered cycles show their ancestor path. Expanded fields are cached until a Conversation publication changes, so mutable Turn/Step Data refreshes without resetting disclosure state or repeating enumeration for UI-only renders.
Chat details show a lazy object tree rather than JSON conversion. Maps retain object keys and values, Sets and arrays show members, and class instances retain their names and own fields. Collections expand in pages; getters are labelled without execution, and WeakMap/WeakSet contents remain opaque. The inspected root expands inline; unregistered cycles show their ancestor path. Expanded fields are cached until a Conversation publication changes, so mutable Turn/Step data refreshes without resetting disclosure state or repeating enumeration for UI-only renders.
Arrays and TypedArrays read only the disclosed index prefix, including sparse slots marked separately from explicit `undefined`. **Other properties** opens their non-index and symbol fields without invoking getters. The initial numeric page does not enumerate the whole collection; opening Other properties explicitly scans its own keys.
Nested Node, Group, Turn, Step, and Data objects have reference buttons. Activating a reference opens its current loaded value and adds a breadcrumb; selecting an earlier breadcrumb returns to that level and discards the later path. The first breadcrumb retains the original row even when reference navigation selects another table row. A reference with a row unfolds its enclosing Inspector group, scrolls to the row, and flashes it. Node Data also has an inline disclosure arrow: expanding it stays in the current tree, while clicking its title navigates. Repeated Node Data ancestors remain links rather than recursively expanding. Selecting a table row or a picked Chat element starts a new breadcrumb path; Session Log keeps JSON details without reference navigation.
Nested Node, Group, Turn, Step, and Data objects have reference buttons. Activating a reference opens its current loaded value and adds a breadcrumb; selecting an earlier breadcrumb returns to that level and discards the later path. The first breadcrumb retains the original row even when reference navigation selects another table row. A reference with a row unfolds its enclosing Inspector group, scrolls to the row, and flashes it. Node data also has an inline disclosure arrow: expanding it stays in the current tree, while clicking its title navigates. Repeated Node data ancestors remain links rather than recursively expanding. Selecting a table row or a picked Chat element starts a new breadcrumb path; Raw log keeps JSON details without reference navigation.
Selecting a Chat row or following a reference uses the Inspector's [DOM matcher](src/client/views/chat-node/dom.ts) to find existing Node, call, Group, or Turn attributes in the same Session's displayed main Chat. Inspector dispatches the existing `beforematch` behavior to searchable folded ancestors, then uses native scrolling and a body-level highlight overlay without adding elements to Chat content. A visible target only flashes; an offscreen target scrolls smoothly before flashing, with immediate scrolling and no flash for reduced motion. ID columns remain narrow; hover or focus an ID to read its complete value in a tooltip.
Missing, empty, or still-hidden occurrences do not stop navigation. When event coordinates are available, Inspector tries nearby loaded content in the same Step, then the same Turn, then other loaded Turns, ordered by event distance. Existing Group and Turn occurrences also provide fallback positions. If no candidate can render, Inspector highlights the visible Chat column without moving it.
Selecting a Session Log row passes the [original log coordinates](src/client/views/session-log/chat-target.ts) through the same DOM lookup while retaining the log presentation and JSON details. Inspector reads the existing Chat snapshot and prefers tool call identity, exact event anchors, and the owning Assistant step before applying the same fallback selection. Stream children inherit their owning event and prefer reasoning or response parts when available. Missing targets do not load history or switch the active View; an empty Chat has nothing to highlight.
Selecting a Raw log row passes the [original log coordinates](src/client/views/session-log/chat-target.ts) through the same DOM lookup while retaining the log presentation and JSON details. Inspector reads the existing Chat snapshot and prefers tool call identity, exact event anchors, and the owning Assistant step before applying the same fallback selection. Stream children inherit their owning event and prefer reasoning or response parts when available. Missing targets do not load history or switch the active View; an empty Chat has nothing to highlight.
The crosshair beside the presentation selector installs a transparent click-catching mask over the displayed main Chat. Inspector uses `elementsFromPoint()` to look below its mask and preview, then matches existing data attributes. A click selects that record in the current Inspector presentation, reveals its table ancestors, and opens its details without activating the underlying Chat control. Wheel movement is forwarded to the underlying Chat scrollport. Escape, the crosshair button, changing presentation or Session, hiding or closing the Inspector, or leaving the window cancels picking. Each plugin instance permits one active picker; separate instances do not cancel one another. Plugin unload releases its picker without calling panel callbacks and prevents old panels from restarting it.
Chat picking prefers the exact Node and reasoning/response part, then its Group, Step, or Turn. Session Log picking prefers a matching tool call or Node log anchor, then its Assistant, Step, or Turn record; a reasoning part selects its first reasoning block when available. Picking stays active with a notice if the loaded table has no match. Text similarity is not used. The [element and Chat-row matchers](src/client/views/chat-node/pick-match.ts) and the [log model's picker](src/client/views/session-log/model.ts) own these adjustable rules; nested tool calls may resolve to their root Node or log anchor.
Chat picking prefers the exact Node and reasoning/response part, then its Group, Step, or Turn. Raw log picking prefers a matching tool call or Node log anchor, then its Assistant, Step, or Turn record; a reasoning part selects its first reasoning block when available. Picking stays active with a notice if the loaded table has no match. Text similarity is not used. The [element and Chat-row matchers](src/client/views/chat-node/pick-match.ts) and the [log model's picker](src/client/views/session-log/model.ts) own these adjustable rules; nested tool calls may resolve to their root Node or log anchor.
Session Log uses the same field renderer for parsed JSON, with every nested object and array initially expanded. Containers can be collapsed individually; there are no reference buttons, breadcrumbs, collection-property disclosures, or collection paging. JSON retains `[Circular]` markers and readable bigint strings. Inspection failures appear locally without disabling the table. Preview filtering never removes fields from either detail presentation.
Raw log uses the same field renderer for parsed JSON, with every nested object and array initially expanded. Containers can be collapsed individually; there are no reference buttons, breadcrumbs, collection-property disclosures, or collection paging. JSON retains `[Circular]` markers and readable bigint strings. Inspection failures appear locally without disabling the table. Preview filtering never removes fields from either detail presentation.
Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow Latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw Log shows UTC timestamps with the server's millisecond precision; Chat Group has no time column.
Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw log shows UTC timestamps with the server's millisecond precision; Chat group has no time column.
-----
@@ -88,7 +88,7 @@ None; the package does not modify model requests.
<a id="known-limitations-and-deferred-work"></a>
- **Loaded history** — tables inspect the Session's loaded window; older records require pagination. Live-to-durable row identities persist across window rebuilds and are released when their rows leave the window.
- **Projection controls** — Session Log does not display projection control-stream updates.
- **Projection controls** — Session log does not display projection control-stream updates.
- **Publication cadence** — highlighting follows published Node changes, not every internal mutation between publications. Raw data is unredacted.
- **Large windows** — each publication still copies the immutable row-reference array; filtering and table layout also scan the loaded rows. Appends do not regroup or recreate historical rows. Opening ordinary-object fields or Other properties scans all own keys.
- **Live suggestions** — refreshing type candidates resets keyboard selection, even for an unchanged candidate list; reselect the candidate or click it before confirming.
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Session Log",
"title": "Session log",
"description": "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar."
}
}
@@ -71,16 +71,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** English labels, checked against the Chinese key set. */
export const en: Record<SessionInspectorKey, string> = {
'tab.title': 'Session Log',
'tab.title': 'Session log',
'tab.description': 'Analyze raw logs and grouped chat messages for the current session in the sidebar.',
'view.presentation': 'Presentation',
'view.sessionLog': 'Raw Log',
'view.chatNode': 'Chat Group',
'picker.pick': 'Pick an element in Chat View',
'view.sessionLog': 'Raw log',
'view.chatNode': 'Chat group',
'picker.pick': 'Pick an element in the chat view',
'picker.cancel': 'Cancel picking (Esc)',
'picker.instructions': 'Hover in the main Chat to preview, then click to select. Esc cancels.',
'picker.instructions': 'Hover in the main chat to preview, then click to select. Esc cancels.',
'picker.noMatch': 'No matching record in this table. Pick another element or press Esc to cancel.',
'picker.noChat': 'Open this Session in the main Chat before picking.',
'picker.noChat': 'Open this session in the main chat before picking.',
'table.id': 'Index',
'table.type': 'Type',
'filter.title': 'Filter types',
@@ -100,7 +100,7 @@ export const en: Record<SessionInspectorKey, string> = {
'table.data': 'Data',
'table.older': 'Load earlier',
'table.loading': 'Loading…',
'table.latest': 'Follow Latest',
'table.latest': 'Follow latest',
'table.empty': 'No records',
'table.raw': 'Raw data',
'table.rawError': 'This record could not be serialized. Select another record to continue.',
@@ -110,14 +110,14 @@ export const en: Record<SessionInspectorKey, string> = {
'table.collapse': 'Collapse children',
'table.removed': 'This record was removed or settled. Select a current record.',
'object.node': 'Node',
'object.nodeData': 'Node Data',
'object.nodeData': 'Node data',
'object.group': 'Group',
'object.groupData': 'Group Data',
'object.groupData': 'Group data',
'object.turn': 'Turn',
'object.turnData': 'Turn Data',
'object.turnData': 'Turn data',
'object.step': 'Step',
'object.stepData': 'Step Data',
'object.accessor': '[Getter / Setter, not evaluated]',
'object.stepData': 'Step data',
'object.accessor': '[Getter / setter, not evaluated]',
'object.empty': 'Empty',
'object.absent': '[Empty slot]',
'object.properties': 'Other properties',
@@ -106,8 +106,8 @@ export function InspectorJsonTree({ text, t }: PropsLocale<'session-inspector'>
}
/**
* Inspect the root and nested Node Data inline; other registered objects remain navigation links.
* Node Data titles also navigate, while repeated ancestors never expand recursively.
* Inspect the root and nested Node data inline; other registered objects remain navigation links.
* Node data titles also navigate, while repeated ancestors never expand recursively.
* Expanded fields are cached until their value or Conversation publication changes.
* @param props - Original object, weak identity lookup, locale, and reference activation.
* @returns A lazily expanded, typed tree that retains Map keys and collection identities.
@@ -143,7 +143,7 @@ it('expands Node Data inline but keeps its title navigable and cyclic references
target: { nodeKey: 'node-1' }, read: () => data }
props.index.set(data, reference)
const ui = render(<InspectorObjectTree {...props} value={{ data }} />)
const link = ui.getByRole('button', { name: '↗ Node Data · 1' })
const link = ui.getByRole('button', { name: '↗ Node data · 1' })
const branch = link.closest('details')!
expect(branch.open).toBe(false)
expect(ui.queryByText('"inline node data"')).toBeNull()
@@ -151,7 +151,7 @@ it('expands Node Data inline but keeps its title navigable and cyclic references
fireEvent(branch, new Event('toggle'))
expect(ui.getByText('"inline node data"')).toBeTruthy()
expect(props.navigate).not.toHaveBeenCalled()
expect(ui.getAllByRole('button', { name: '↗ Node Data · 1' })).toHaveLength(2)
expect(ui.getAllByRole('button', { name: '↗ Node data · 1' })).toHaveLength(2)
expect(ui.container.querySelectorAll('details')).toHaveLength(2)
fireEvent.click(link)
expect(props.navigate).toHaveBeenCalledExactlyOnceWith(reference)
@@ -3,7 +3,7 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/session-search/README.md
/:
en: ac0fb71830f58337
en: c8e3f38d9f1a4e2f
zh: 0ece950cf48261a1
/deepseek-ai-dsh-experimental-session-search:
en: f4f9c83d1dd36580
@@ -1,5 +1,5 @@
---
description: "Give Agents tools to search and read earlier sessions from the current workspace."
description: "Give agents tools to search and read earlier sessions from the current workspace."
kind: "package-bundle"
---
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Session search",
"description": "Give Agents tools to search and read earlier sessions from the current workspace.",
"description": "Give agents tools to search and read earlier sessions from the current workspace.",
"detail": "docs/DETAIL.md"
}
}
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-experimental-session-search",
"description": "Give Agents tools to search and read earlier sessions from the current workspace.",
"description": "Give agents tools to search and read earlier sessions from the current workspace.",
"version": "0.2.1-rc.2",
"icon": "./icon.svg",
"publishConfig": {
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Team Tools",
"title": "Team tools",
"description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks."
}
}
@@ -9,13 +9,13 @@
en: db61f6b6ad671f45
zh: 9f3083d7f5605cbb
/deepseek-ai-dsh-experimental-tool-worktree/summary:
en: f0df470fd16d8d29
en: addc1cb3414df6be
zh: 5f2d371d844b0568
/deepseek-ai-dsh-experimental-tool-worktree/table-of-contents:
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-tool-worktree/use-this-package:
en: f69d3fdeed949ff5
en: 06074f9f6989911b
zh: 05c9a85b7cdcfbad
/deepseek-ai-dsh-experimental-tool-worktree/understand-the-implementation:
en: c03f875211ab69a8
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Add Git Worktrees from the GUI plugin manager to let a model create and enter a new Git worktree with one call. This optional bundle is installed with Harness and stays off until selected. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation.
Add Git worktrees from the GUI plugin manager to let a model create and enter a new Git worktree with one call. This optional bundle is installed with Harness and stays off until selected. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation.
## Table of Contents
@@ -25,7 +25,7 @@ Add Git Worktrees from the GUI plugin manager to let a model create and enter a
<a id="use-this-package"></a>
## Use this package
In the GUI, open **Plugins → Official → Git Worktrees** and turn on the switch. The bundle loads the [worktree runtime](../worktree/README.md) and this tool together; it is off in every shipped profile. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available.
In the GUI, open **Plugins → Official → Git worktrees** and turn on the switch. The bundle loads the [worktree runtime](../worktree/README.md) and this tool together; it is off in every shipped profile. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available.
For an explicit composition, mount this function plugin after the worktree runtime and tool registry.
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Git Worktrees",
"title": "Git worktrees",
"description": "Let the agent create a new Git branch and worktree, then continue there.",
"detail": "docs/DETAIL.md"
}
@@ -30,7 +30,7 @@ A Host-only bundle needs no dependencies, install scripts, or build tool:
Plugin Manager and Settings read `meta.title` and `meta.description` from exported locale JSON and an icon from exported resources without activating plugins. Complete this checklist before installation:
- Write a recognizable title and a description of the capability in `locale/en.json` under `meta.title` and `meta.description`. Add the same fields for the user's language and other supported locales, such as `locale/zh.json`; do not leave template copy unrelated to the delivered plugin.
- Write a recognizable title and a description of the capability in `locale/en.json` under `meta.title` and `meta.description`. Add the same fields for the user's language and other supported locales, such as `locale/zh.json`; do not leave template copy unrelated to the delivered plugin. Write English titles and other UI labels in sentence case: capitalize only the first word and proper nouns, as in `Git worktrees`.
- Create an original icon or use artwork whose license permits redistribution, retaining any required attribution.
- Export `./locale/*.json` and `./icon`; retain existing runtime exports. Keep the patch, locales, icon, and every runtime file in `files`, updating it whenever you add modules or assets. For a packed or published bundle, verify the actual packed file list. Local directory installation links the checkout instead, so check the files directly; `files` does not filter a linked directory.
- Check that the icon is a valid image of the declared format and satisfies the path and size limits below. An accepted filename alone does not establish that its bytes render.
@@ -38,7 +38,7 @@ Plugin Manager and Settings read `meta.title` and `meta.description` from export
For example, the locale file contains:
```json
{ "meta": { "title": "My Decoration", "description": "Draws a badge under the composer." } }
{ "meta": { "title": "My decoration", "description": "Draws a badge under the composer." } }
```
```json
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Composer Decoration",
"title": "Composer decoration",
"description": "Displays a blue circle below the message composer."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Demo MCP Connection",
"title": "Demo MCP connection",
"description": "Connects a remote MCP server to make its tools available to the agent."
}
}
@@ -18,8 +18,8 @@
en: 246231cf2a529550
zh: 5d27b8e444ecd0f7
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/configuration:
en: 161f3b32c782bab4
zh: bd923e3d31261032
en: 7e332cfe9cf45896
zh: 82cfb08dce3425ab
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/execution-and-results:
en: c79baab0c0a49ca7
zh: b90b78d0d75280fb
@@ -30,20 +30,20 @@
en: ad72b8bdba6a3966
zh: 28a20fb5fb7c622d
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures:
en: 7682e50f9dcfa25c
zh: ddcb3502e12be06f
en: 0ba661c7030f368c
zh: dc2be3553eda447d
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation:
en: 9020b491a39e4bf5
zh: bcd1b2b1a3bde7f3
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control:
en: 894cf1c1fdbe0092
zh: c1e1179391e89a3f
en: 94e08458a48b8c16
zh: 48087aaa4ef028b6
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps:
en: e7251a2f7cb8f134
zh: f68c08c507992969
en: f9d796233bfb3b79
zh: f7c5803a6ccdf9c2
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map:
en: 2a702f70cac410d1
zh: f275136c6019b56d
en: 6534d1c3a1e40c65
zh: 2aae909d97915ece
/deepseek-ai-dsh-ptc-runtime-node/further-exploration:
en: ac2e2ae13d3f1394
zh: 5d08b9c658fcf83a
@@ -54,8 +54,8 @@
en: 40294c1c31b74824
zh: d91e7b78c0619c7e
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work:
en: 7b8d67207e35bbb3
zh: 246cd770e79078ac
en: c99f20872d7694d0
zh: b3a3193572afd8f3
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work/dev-note:
en: 6373aba4e08f7205
zh: 2a8c33d1f2839df4
@@ -51,7 +51,7 @@ Configure the provider row after its required services are available:
| `maxTimeoutMs` | `600,000` | Elapsed deadline ceiling applied by the resolver |
| `maxOutputBytes` | `67,108,864` | Combined serialized logs and completion or diagnostic budget |
| `maxOldGenerationSizeMb` | `512` | V8 old-generation heap limit in MiB |
| `maxMessageBytes` | `134,217,728` | Limit for a control frame, outstanding argument bytes and queued control writes |
| `maxMessageBytes` | `134,217,728` | Limit for a control message, outstanding binding-argument bytes and queued control writes |
| `maxPendingCalls` | `128` | Maximum simultaneous host binding calls |
| `graceMs` | `3,000` | Managed termination and output-drain grace |
| `launch` | Local carrier | Explicit `{ kind: "node-script", executable, bootstrapPath? }` or `{ kind: "embedded", executable }` in the subprocess world |
@@ -93,7 +93,7 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v
### Failures
Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; malformed or excessive control traffic is `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse.
Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse.
-----
@@ -109,11 +109,13 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
For per-call execution, the host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds each line and the native write queue and rejects pending calls when the pipe closes; birpc answers calls to methods an endpoint does not own with an error, and runs no method after the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval.
Per-call completion leaves unfinished binding waits pending and stops binding calls and captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
### Source and built bootstraps
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file.
Source execution loads erasable TypeScript and its installed birpc dependency without relying on sibling built exports. Built process scripts bundle birpc so a preinstalled bootstrap needs no adjacent dependency installation. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file.
### Source map
@@ -127,7 +129,8 @@ Source execution loads an erasable-only bootstrap closure without relying on sib
| [`src/launch.ts`](src/launch.ts) | Executable/bootstrap arguments and execution-world asset mapping |
| [`src/process.ts`](src/process.ts) | Child handshake, environment clearing and program lifecycle |
| [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture |
| [`src/channel.ts`](src/channel.ts) | Framing, bounded writes and protocol failures |
| [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime |
| [`src/protocol.ts`](src/protocol.ts) | Host and program methods, and validation of untrusted program results |
| [`src/output-ledger.ts`](src/output-ledger.ts) | Host accounting for the outer result |
</details>
@@ -168,6 +171,7 @@ These limits qualify the execution guarantees and retained output.
- **In-memory environments retain one process per live owner** — idle owners keep their process and heap until explicit release, owner disposal or failure. Fan-out can retain many processes; idle expiration remains deferred.
- **Output caps reject rather than retain every byte** — spill can preserve only the bounded result delivered by this provider.
- **Bindings are bounded at transport admission** — control limits do not bound the memory a host binding allocates while producing its result.
- **The control protocol has no version handshake** — a preinstalled `launch.bootstrapPath` or SSH helper must come from the client's release; another release's bootstrap fails at its first control message.
- **The console shim has five methods** — `log`, `info`, `warn`, `error` and `debug`.
<a id="dev-note"></a>
@@ -51,7 +51,7 @@ kind: "package-reference"
| `maxTimeoutMs` | `600,000` | 解析器应用的经过时间截止上限 |
| `maxOutputBytes` | `67,108,864` | 序列化日志与完成值或诊断的合计预算 |
| `maxOldGenerationSizeMb` | `512` | V8 老生代堆上限,单位 MiB |
| `maxMessageBytes` | `134,217,728` | 控制帧、未完成参数字节和排队控制写入的上限 |
| `maxMessageBytes` | `134,217,728` | 控制消息、未完成绑定参数字节和排队控制写入的上限 |
| `maxPendingCalls` | `128` | 同时进行的 Host 绑定调用数量上限 |
| `graceMs` | `3,000` | 受管终止与输出排空宽限时间 |
| `launch` | 本地载体 | 子进程环境中显式的 `{ kind: "node-script", executable, bootstrapPath? }` 或 `{ kind: "embedded", executable }` |
@@ -93,7 +93,7 @@ PTC 消费方按 [dsh-tools](../../core/tools/README.zh.md#ptc-mode) 的说明
### 失败
程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;畸形或超量控制通信为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。
程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。
-----
@@ -109,11 +109,13 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制每行消息和原生写入队列的大小,并在管道关闭时拒绝待完成调用;对端点自身没有的方法,birpc 以错误回复,管道关闭后不再执行任何方法。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。
单次执行完成后,尚未完成的绑定等待保持挂起,并停止绑定调用和捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
### 源代码与构建后 bootstrap
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。
源代码执行加载可擦除的 TypeScript 及已安装的 birpc 依赖,不依赖同级包的构建后导出。构建后的进程脚本将 birpc 打包在内,因此预安装的 bootstrap 无需在旁边安装依赖。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。
### 源码索引
@@ -127,7 +129,8 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
| [`src/launch.ts`](src/launch.ts) | 可执行文件/bootstrap 参数与执行世界资源映射 |
| [`src/process.ts`](src/process.ts) | 子进程握手、环境清空与程序生命周期 |
| [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 |
| [`src/channel.ts`](src/channel.ts) | 分帧、有界写入与协议失败 |
| [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 |
| [`src/protocol.ts`](src/protocol.ts) | Host 与程序方法,以及不可信程序结果的校验 |
| [`src/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 |
</details>
@@ -168,6 +171,7 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
- **内存保留环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
- **控制协议没有版本握手**——预装的 `launch.bootstrapPath` 或 SSH helper 必须来自与客户端相同的版本;其他版本的 bootstrap 会在第一条控制消息时失败。
- **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。
<a id="dev-note"></a>
@@ -45,7 +45,8 @@
},
"dependencies": {
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~"
"@deepseek-ai/schemastery": "workspace:~",
"birpc": "4.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -2,8 +2,7 @@
import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime'
import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import { encodePtcJsonWire } from './json-wire.ts'
import type { ReplyMessage } from './protocol.ts'
import { encodePtcJsonWire, type PtcJsonWire } from './json-wire.ts'
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* Reject unusable namespaces before starting a process.
@@ -54,19 +53,14 @@ export function validateBindings(request: PtcRunRequest): Map<string, PtcBinding
}
/**
* Invoke a host binding and encode its lossless result or bounded-channel rejection.
* @param id - Program-issued correlation identifier.
* @param binding - Current evaluation's declared function, absent if it was removed.
* Invoke a host binding and encode its lossless result.
* @param binding - Current evaluation's function, absent if it was removed.
* @param args - Validated JSON arguments detached from the program.
* @returns A reply suitable for the runtime's bounded control channel.
* @returns Flat lossless JSON; unavailable bindings and invalid results reject.
*/
export async function replyToBinding(id: number, binding: PtcBindingFunction | undefined, args: PtcJsonValue): Promise<ReplyMessage> {
try {
if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool')
const value = snapshotJsonValue(await binding(args))
if (value === undefined) throw new Error('binding resolution must be lossless JSON')
return { type: 'reply', id, ok: true, value: encodePtcJsonWire(value) }
} catch (error: unknown) {
return { type: 'reply', id, ok: false, message: error instanceof Error ? error.message : String(error) }
}
export async function invokeBinding(binding: PtcBindingFunction | undefined, args: PtcJsonValue): Promise<PtcJsonWire> {
if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool')
const value = snapshotJsonValue(await binding(args))
if (value === undefined) throw new Error('binding resolution must be lossless JSON')
return encodePtcJsonWire(value)
}
@@ -4,7 +4,7 @@
*/
import { inspect } from 'node:util'
import type { DoneMessage, ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
import type { HostMethods, ProgramResult, ProgramBootData } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodePtcJsonWire, encodePtcJsonWire, snapshotPtcJsonValue } from './json-wire.ts'
@@ -20,12 +20,6 @@ function defineBindingErrorField(error: Error, key: string, value: string): void
capturedObjectDefineProperty(error, key, attributes)
}
/** Program messages and host replies transported by the private process channel. */
export interface BootstrapPort {
postMessage(message: ProgramToHost): void
on(event: 'message', listener: (message: ReplyMessage) => void): void
}
/**
* A writable stream's `write` slot, as the bootstrap patches it (see
* {@link captureStreamWrites}). Method-typed so the real
@@ -38,7 +32,7 @@ export interface PatchableStream {
/**
* Ordered text capture under the shared outer JSON-byte budget, delivered to
* a sink as each item lands (the real sink streams text over the port eagerly,
* a sink as each item lands (the real sink sends text to the host eagerly,
* so captured output survives a mid-run termination). It includes the log
* array syntax and string escaping in its accounting. Once exhausted it emits
* the fitting prefix and reports the limit once; the host turns that condition
@@ -151,7 +145,7 @@ export function captureStreamWrites(logs: Pick<LogBuffer, 'push'>, stream: Patch
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/**
* Prepare the program's completion value for the done message. Only lossless
* Prepare the program's completion value for the run result. Only lossless
* JSON crosses, and a value that does not fit the remaining combined outer
* budget reports `output-limit`; the host revalidates hostile traffic and
* remains authoritative for native pipe writes the program shim cannot observe.
@@ -159,13 +153,13 @@ const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000
* @param value - the program's completion value.
* @param remainingOutputBytes - exact bytes left after captured logs.
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
* @returns the result fragment: `{}` for `undefined`, else a flat wire `{ value }`.
*/
export function prepareCompletion(
value: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
): ProgramResult {
if (value === undefined) return {}
let snapshot: ReturnType<typeof snapshotPtcJsonValue>
try {
@@ -188,7 +182,7 @@ export function prepareCompletion(
}
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
function outputLimit(maxOutputBytes: number): ProgramResult {
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
}
@@ -198,7 +192,7 @@ function prepareFailure(
message: string,
remainingOutputBytes: number,
maxOutputBytes: number,
): Omit<DoneMessage, 'type'> {
): ProgramResult {
if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
return { error: { kind, message } }
}
@@ -215,7 +209,7 @@ export function prepareException(
error: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
): ProgramResult {
let message: string
try {
const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error
@@ -226,12 +220,6 @@ export function prepareException(
return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
}
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
export interface PendingCall {
resolve(value: unknown): void
reject(error: Error): void
}
/** Constructor type for one program-visible binding rejection class. */
export type BindingErrorConstructor = new (memberName: string, message: string) => Error
@@ -273,48 +261,15 @@ export function makeBindingErrorClasses(
}
/**
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
* to an id already settled) is ignored. Shared wiring between
* {@link runProgram} and the tests that exercise {@link makeNamespaces}
* standalone.
* @param port - the port whose `message` events carry the replies.
* @param pending - the id-keyed map of unsettled binding calls.
*/
export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
port.on('message', (message: ReplyMessage) => {
const entry = pending.get(message.id)
if (!entry) return
pending.delete(message.id)
if (message.ok) {
const value = decodePtcJsonWire(message.value)
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
else entry.resolve(value)
} else {
entry.reject(new CapturedError(message.message))
}
})
}
/**
* Build the binding namespace objects the program sees: one null-prototype global per
* namespace, each declared name an own enumerable async function that bridges over the port
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
* Lossy arguments reject before posting; clone failures and host failure
* replies reject only the corresponding call.
*
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
* @param pending - the id-keyed map each posted call parks its handles in.
* @param nextId - the shared mutable id counter (program-issued correlation ids).
* @param errorClasses - Namespace constructors shared with program globals, resolved for each invocation.
* @returns one namespace object per declaration, in declaration order.
* Build null-prototype binding globals with namespace-specific rejection classes.
* @param data - Namespace declarations injected into the program.
* @param host - Evaluation-scoped remote binding calls.
* @param errorClasses - Constructors shared with program globals, resolved per invocation.
* @returns One namespace object per declaration; arguments and results must be lossless JSON.
*/
export function makeNamespaces(
data: Pick<ProgramBootData, 'namespaces'>,
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
host: Pick<HostMethods, 'invoke'>,
errorClasses: Pick<ReadonlyMap<string, BindingErrorConstructor>, 'get'> = makeBindingErrorClasses(data),
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
@@ -322,34 +277,21 @@ export function makeNamespaces(
for (const name of names) {
Object.defineProperty(namespace, name, {
enumerable: true,
value: (args: unknown): Promise<unknown> => {
value: async (args: unknown): Promise<unknown> => {
const errorClass = errorClasses.get(global)
let detached: ReturnType<typeof snapshotPtcJsonValue>
try {
detached = snapshotPtcJsonValue(args)
} catch {
detached = undefined
}
if (detached === undefined) {
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
}
return new Promise((resolve, reject) => {
const id = nextId.value
pending.set(id, {
resolve,
reject: (error) => {
reject(bindingFailure(errorClass, name, error.message))
},
})
try {
port.postMessage({ type: 'call', id, global, name, args: encodePtcJsonWire(detached) })
nextId.value += 1
} catch (error: unknown) {
pending.delete(id)
const message = `binding call could not be sent: ${error instanceof CapturedError ? error.message : String(error)}`
reject(bindingFailure(errorClass, name, message))
let detached: ReturnType<typeof snapshotPtcJsonValue>
try { detached = snapshotPtcJsonValue(args) } catch {
// Model-defined getters can fail while arguments are detached.
detached = undefined
}
})
if (detached === undefined) throw new CapturedError('binding arguments must be lossless JSON')
const result = decodePtcJsonWire(await host.invoke(global, name, encodePtcJsonWire(detached)))
if (result === undefined) throw new CapturedError('binding resolution must be lossless JSON')
return result
} catch (error: unknown) {
throw bindingFailure(errorClass, name, error instanceof CapturedError ? error.message : String(error))
}
},
})
}
@@ -358,32 +300,27 @@ export function makeNamespaces(
}
/**
* Run one strict async-function body, allowing top-level `await` and `return`, and post exactly
* one terminal {@link DoneMessage}; a thrown program error becomes its `error` field.
* @param port - host message port or test double.
* Run one strict async-function body with captured output and host binding proxies.
* @param host - Evaluation-scoped host methods.
* @param data - the boot payload the host sent.
* @param streams - stdout/stderr objects captured as program logs.
* @returns after posting the done message.
* @returns Completion or a bounded program failure.
*/
export async function runProgram(
port: BootstrapPort,
host: Omit<HostMethods, 'ready'>,
data: ProgramBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
): Promise<ProgramResult> {
const logs = new LogBuffer(
data.maxOutputBytes,
(text) => { port.postMessage({ type: 'log', text }) },
() => { port.postMessage({ type: 'output-limit' }) },
(text) => { host.log(text) },
() => { host.outputLimit() },
)
captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr)
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const nextId = { value: 1 }
const errorClasses = makeBindingErrorClasses(data)
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
const namespaces = makeNamespaces(data, host, errorClasses)
const errorClassParameters: string[] = []
const errorClassValues: BindingErrorConstructor[] = []
for (const namespace of data.namespaces) {
@@ -396,7 +333,6 @@ export async function runProgram(
}
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
try {
// The async function constructor, reached through an instance because
// `AsyncFunction` is not a global. The program body is strict-mode.
@@ -409,15 +345,8 @@ export async function runProgram(
`'use strict';\n${data.code}`,
)
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
done = {
type: 'done',
...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
}
return prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)
} catch (error: unknown) {
done = {
type: 'done',
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
}
return prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)
}
port.postMessage(done)
}
@@ -1,140 +0,0 @@
/** Length-prefixed JSON transport with bounded input and queued writes. */
import { Buffer } from 'node:buffer'
import type { Duplex } from 'node:stream'
import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime'
import { jsonValueBytesUpTo } from './output-json.ts'
const stringify = JSON.stringify
const parse = JSON.parse
interface PendingWrite {
promise: Promise<void>
finish(error?: Error): void
}
/** One co-shipped process channel; the consumer owns frame validation and terminal outcomes. */
export class JsonChannel {
private readonly stream: Duplex
private readonly maxBytes: number
private readonly receive: (message: unknown, bytes: number) => void
private readonly failure: (error: Error, kind: 'io' | 'protocol') => void
private readonly header = Buffer.alloc(4)
private headerBytes = 0
private payload: Buffer | undefined
private payloadBytes = 0
private queuedBytes = 0
private closed = false
private readonly writes = new Set<PendingWrite>()
constructor(
stream: Duplex,
maxBytes: number,
receive: (message: unknown, bytes: number) => void,
failure: (error: Error, kind: 'io' | 'protocol') => void,
) {
this.stream = stream
this.maxBytes = maxBytes
this.receive = receive
this.failure = failure
stream.on('data', this.onData)
stream.on('error', this.onError)
stream.on('end', this.onEnd)
stream.on('close', this.onClose)
}
private readonly onError = (error: Error): void => {
this.finishWrites(error)
if (!this.closed) this.failure(error, 'io')
}
private readonly onClose = (): void => { this.finishWrites(new Error('control channel closed during a write')) }
private readonly onEnd = (): void => { if (!this.closed) this.failure(new Error('control channel ended before the program settled'), 'io') }
private readonly onData = (chunk: Buffer): void => {
if (this.closed) return
try {
let offset = 0
// receive can synchronously close the channel while dispatching a preceding frame.
// oxlint-disable-next-line typescript/no-unnecessary-condition
while (offset < chunk.length && !this.closed) {
if (this.payload === undefined) {
const bytes = Math.min(4 - this.headerBytes, chunk.length - offset)
chunk.copy(this.header, this.headerBytes, offset, offset + bytes)
this.headerBytes += bytes
offset += bytes
if (this.headerBytes !== 4) continue
const length = this.header.readUInt32BE(0)
if (length === 0 || length > this.maxBytes) throw new Error(`control frame exceeds ${this.maxBytes} bytes or is empty`)
this.payload = Buffer.allocUnsafe(length)
this.payloadBytes = 0
this.headerBytes = 0
}
const bytes = Math.min(this.payload.length - this.payloadBytes, chunk.length - offset)
chunk.copy(this.payload, this.payloadBytes, offset, offset + bytes)
this.payloadBytes += bytes
offset += bytes
if (this.payloadBytes !== this.payload.length) continue
const frame = this.payload
this.payload = undefined
this.payloadBytes = 0
this.receive(parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)), frame.length)
}
} catch (error: unknown) {
this.failure(error instanceof Error ? error : new Error(String(error)), 'protocol')
}
}
/**
* Submit a bounded frame immediately and await the stream's write receipt.
* @param message - JSON-only co-shipped protocol value.
* @returns Resolves when this frame has been written, or rejects after transport failure.
*/
send(message: unknown): Promise<void> {
if (this.closed) return Promise.reject(new Error('control channel is closed'))
const size = jsonValueBytesUpTo(message as PtcJsonValue, this.maxBytes)
if (size === undefined || this.queuedBytes + size > this.maxBytes) {
return Promise.reject(new Error(`control output exceeds ${this.maxBytes} queued bytes`))
}
const body = Buffer.from(stringify(message), 'utf8')
const header = Buffer.alloc(4)
header.writeUInt32BE(body.length)
this.queuedBytes += body.length
const completion = Promise.withResolvers<void>()
const write: PendingWrite = {
promise: completion.promise,
finish: (error) => {
if (!this.writes.delete(write)) return
this.queuedBytes -= body.length
if (error) completion.reject(error)
else completion.resolve()
},
}
this.writes.add(write)
try {
// Writable preserves frame order; a Promise queue would delay logs behind a model hot loop.
this.stream.cork()
this.stream.write(header)
this.stream.write(body, (error) => { write.finish(error ?? undefined) })
this.stream.uncork()
} catch (error: unknown) {
write.finish(error instanceof Error ? error : new Error(String(error)))
}
return completion.promise
}
private finishWrites(error: Error): void {
for (const write of this.writes) write.finish(error)
}
/** Stop reads and close the owned endpoint; pending writes reject on closure. */
close(): void {
if (this.closed) return
this.closed = true
this.payload = undefined
this.finishWrites(new Error('control channel is closed'))
this.stream.off('data', this.onData)
this.stream.off('end', this.onEnd)
this.stream.destroy()
}
/** Wait for accepted writes to finish or fail. */
async drain(): Promise<void> { await Promise.allSettled([...this.writes].map(write => write.promise)) }
}
@@ -17,7 +17,7 @@ export interface Config {
maxOutputBytes?: number
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
@@ -55,7 +55,7 @@ export function validateConfig(config: ResolvedConfig): void {
if (config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`)
}
if (!Number.isSafeInteger(config.maxOutputBytes) || config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4')
if (!Number.isSafeInteger(config.maxMessageBytes) || config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
if (!Number.isSafeInteger(config.maxMessageBytes)) throw new Error('ptc-runtime-node: maxMessageBytes must be an integer')
if (!Number.isSafeInteger(config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
if (!Number.isSafeInteger(config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
}
@@ -11,8 +11,9 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepsee
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-fs'
import { replyToBinding, validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import type { BirpcReturn } from 'birpc'
import { invokeBinding, validateBindings } from './bindings.ts'
import { createProcessRpc } from './rpc.ts'
import { bootstrapArgs, resolveLaunch } from './launch.ts'
import { NodeConfig, validateConfig } from './config.ts'
import { PolicyProcesses } from './policy-processes.ts'
@@ -24,14 +25,13 @@ import { OutputLedger } from './output-ledger.ts'
import { terminateProcess } from './output-stream.ts'
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
import { decodePtcJsonWire } from './json-wire.ts'
import type { ProgramBootData } from './protocol.ts'
import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts'
interface LiveRun { controller: AbortController; finished: Promise<void> }
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
const STRIP_SUFFIX = '\n}'
function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
/** Node execution under file policy, with optional owner-scoped retained memory. */
export class NodePtcRuntime extends PtcRuntime {
@@ -135,14 +135,14 @@ export class NodePtcRuntime extends PtcRuntime {
bindings: Map<string, PtcBindingNamespace>,
controller: AbortController,
): Promise<PtcRunResult> {
const replyBinding = AsyncLocalStorage.bind(replyToBinding)
const callBinding = AsyncLocalStorage.bind(invokeBinding)
const output = new OutputLedger(this.config.maxOutputBytes)
const logs: string[] = []
const sandbox: PtcRunSandbox = { mode: policy.mode, denied: false }
const result = Promise.withResolvers<PtcRunResult>()
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
let handle: SubprocessHandle | undefined
let channel: JsonChannel | undefined
let channel: BirpcReturn<ProgramMethods, HostMethods> | undefined
let confined: ConfinedArgv | undefined
let settled = false
let timedOut = false
@@ -167,7 +167,7 @@ export class NodePtcRuntime extends PtcRuntime {
settled = true
clearTimeout(wallTimer)
signal.removeEventListener('abort', onAbort)
channel?.close()
channel?.$close()
void (async () => {
try {
const drained = await stop()
@@ -263,7 +263,6 @@ export class NodePtcRuntime extends PtcRuntime {
})
handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
let ready = false
let nextId = 1
let pending = 0
let pendingBytes = 0
const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) }
@@ -273,66 +272,55 @@ export class NodePtcRuntime extends PtcRuntime {
? { kind: 'sandbox-unavailable', message: stderr }
: { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` })
}
const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => {
if (!record(raw)) { protocolFailure('invalid control frame'); return }
if (!ready) {
if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return }
// Report a protocol failure and abandon the handler; the closed endpoint sends no response.
const refuse = (message: string): never => { protocolFailure(message); throw new Error(message) }
const transport = createProcessRpc<ProgramMethods, HostMethods>(launched.control, this.config.maxMessageBytes, {
ready: () => {
if (ready) { protocolFailure('duplicate program readiness'); return }
ready = true
void transport.send({ type: 'boot', data }).catch((error: unknown) => { protocolFailure(messageOf(error)) })
return
}
switch (raw.type) {
case 'log':
if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return }
admit(raw.text)
return
case 'output-limit': outputOverflow = true; finish(); return
case 'done': {
if (raw.error !== undefined) {
if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return }
const failure = { kind: raw.error.kind, message: raw.error.message } as PtcRunFailure
if (confined !== undefined) {
sandbox.denied = confined.denialSignatures.some(signature =>
failure.message.toLowerCase().includes(signature.toLowerCase()))
}
if (failure.kind === 'output-limit') outputOverflow = true
finish(failure)
return
void transport.run(spec.cwd, data).then((raw) => {
// A message delivered beside the result can select another outcome first.
if (settled) return
const { error, value } = readProgramResult(raw)
if (error !== undefined) {
if (confined !== undefined) sandbox.denied = confined.denialSignatures.some(signature =>
error.message.toLowerCase().includes(signature.toLowerCase()))
if (error.kind === 'output-limit') outputOverflow = true
}
const value = raw.value === undefined ? undefined : decodePtcJsonWire(raw.value)
if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
else finish(undefined, value)
return
finish(error, value)
}).catch((error: unknown) => { protocolFailure(messageOf(error)) })
},
log: (text: unknown) => {
if (typeof text === 'string') admit(text)
else protocolFailure('invalid program log')
},
outputLimit: () => {
outputOverflow = true
finish()
},
invoke: async (global, name, wire) => {
// Wire identities that are not strings fail these lookups.
const functions = bindings.get(global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined
if (typeof fn !== 'function') return refuse('program requested an undeclared binding')
const args = decodePtcJsonWire(wire)
if (args === undefined) return refuse('binding arguments must be lossless JSON')
const bytes = Buffer.byteLength(JSON.stringify(wire))
if (pending >= this.config.maxPendingCalls || pendingBytes + bytes > this.config.maxMessageBytes) {
return refuse('pending binding calls exceed configured limits')
}
case 'call': {
if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return }
nextId += 1
const functions = bindings.get(raw.global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined
if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return }
const args = decodePtcJsonWire(raw.args)
if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return }
if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return }
const id = raw.id
void (async () => {
const reply = await replyBinding(id, fn, args).finally(() => {
pending -= 1
pendingBytes -= bytes
})
if (!settled) await transport.send(reply)
})().catch((error: unknown) => { protocolFailure(messageOf(error)) })
return
}
default: protocolFailure('unknown control message')
}
}, (error, kind) => {
pending += 1
pendingBytes += bytes
try { return await callBinding(fn, args) } finally { pending -= 1; pendingBytes -= bytes }
},
}, [], (error, kind) => {
if (kind === 'protocol') protocolFailure(messageOf(error))
else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) })
else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
})
channel = transport
void launched.done.then((outcome) => {
// Allow queued control-frame callbacks to run before classifying a command exit.
// Allow queued control messages to run before classifying a command exit.
setImmediate(() => { processFinished(outcome) })
}, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
} catch (error: unknown) {
@@ -1,5 +1,5 @@
/**
* Lossless-JSON snapshots for the dependency-free source bootstrap closure.
* Lossless-JSON snapshots shared by the host and the source bootstrap closure.
* @module @deepseek-ai/dsh-ptc-runtime-node/json-wire
*/
@@ -37,7 +37,7 @@ export function resolveLaunch(config?: LaunchConfig): LaunchConfig {
* Select explicit arguments without inheriting host loader or inspector flags.
* @param fs - Filesystem mapping host bootstrap assets into the process world.
* @param config - resolved worker invocation.
* @param maxMessageBytes - Validated frame and queued-write limit.
* @param maxMessageBytes - Validated message and queued-write limit.
* @param persistent - Select the in-memory REPL bootstrap instead of the one-shot evaluator.
* @returns Arguments following the resolved Node executable.
*/
@@ -2,10 +2,10 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { start } from 'node:repl'
import { PassThrough, Writable, type Duplex } from 'node:stream'
import { JsonChannel } from './channel.ts'
import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException, wireReplies } from './bootstrap.ts'
import type { BindingErrorConstructor, PendingCall, PatchableStream } from './bootstrap.ts'
import type { DoneMessage, ProgramBootData, ProgramToHost, ReplyMessage } from './protocol.ts'
import { createProcessRpc } from './rpc.ts'
import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException } from './bootstrap.ts'
import type { BindingErrorConstructor, PatchableStream } from './bootstrap.ts'
import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts'
import { clearProgramEnvironment } from './environment.ts'
/** Process state owned by the REPL child; tests supply instance-local streams and environment. */
@@ -17,30 +17,26 @@ export interface PersistentProcess {
}
interface Evaluation {
id: number
logs: LogBuffer
maxOutputBytes: number
active: boolean
finish(result: Omit<DoneMessage, 'type'>): void
finish(result: ProgramResult): void
}
/**
* Serve sequential evaluations until the owning host closes the control channel.
* @param stream - Inherited private control endpoint.
* @param maxMessageBytes - Maximum control frame and queued output bytes.
* @param maxMessageBytes - Maximum control message and queued output bytes.
* @param processState - Child environment, output and directory control, bound only in the REPL context.
* @returns After the channel closes and the REPL releases its streams.
*/
export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, processState: PersistentProcess): Promise<void> {
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit')
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit')
clearProgramEnvironment(processState)
const context = new AsyncLocalStorage<Evaluation>()
const closed = Promise.withResolvers<void>()
const pending = new Map<number, PendingCall>()
const nextId = { value: 1 }
const errors = new Map<string, { name: string; memberNameProperty: string; constructor: BindingErrorConstructor }>()
const errorLookup = { get: (name: string) => errors.get(name)?.constructor }
const replies: Array<(message: ReplyMessage) => void> = []
const input = new PassThrough()
const output = new Writable({ write(chunk: Buffer, _encoding, callback) {
const current = context.getStore()
@@ -54,83 +50,76 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number,
Object.defineProperty(repl.context, name, { configurable: true, enumerable: true, writable: true, value: undefined })
}
let current: Evaluation | undefined
const send = (id: number, payload: ProgramToHost): void => {
void channel.send({ id, payload }).catch(() => { channel.close(); closed.resolve() })
}
const port = {
postMessage(payload: ProgramToHost): void {
const host: Pick<HostMethods, 'invoke'> = {
invoke: (global, name, args) => {
const evaluation = context.getStore()
if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation')
send(evaluation.id, payload)
return connection.invoke(global, name, args)
},
on(_event: 'message', listener: (message: ReplyMessage) => void): void { replies.push(listener) },
}
wireReplies(port, pending)
const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } }
repl.context.console = makeConsoleShim(logSink)
Object.defineProperty(repl.context, 'process', { configurable: true, enumerable: true, writable: true, value: processState })
captureStreamWrites(logSink, processState.stdout)
captureStreamWrites(logSink, processState.stderr)
const channel = new JsonChannel(stream, maxMessageBytes, (raw) => {
// The host owns these frames. Model-written traffic is validated on the host.
const message = raw as { type: 'run'; id: number; cwd: string; data: ProgramBootData } | ReplyMessage
if (message.type === 'reply') {
for (const listener of replies) listener(message)
return
}
if (current?.active) { channel.close(); closed.resolve(); return }
const { id, data } = message
const logs = new LogBuffer(data.maxOutputBytes, (text) => { send(id, { type: 'log', text }) }, () => { send(id, { type: 'output-limit' }) })
const evaluation: Evaluation = {
id, logs, maxOutputBytes: data.maxOutputBytes, active: true,
finish(result) {
if (!evaluation.active) return
evaluation.active = false
for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed'))
pending.clear()
send(id, { type: 'done', ...result })
},
}
current = evaluation
context.run(evaluation, () => {
try {
processState.chdir(message.cwd)
for (const namespace of data.namespaces) {
const descriptor = namespace.errorClass
if (!descriptor) { errors.delete(namespace.global); continue }
const previous = errors.get(namespace.global)
if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue
const constructor = makeBindingErrorClass(descriptor)
errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor })
}
const namespaces = makeNamespaces(data, port, pending, nextId, errorLookup)
for (const [index, namespace] of data.namespaces.entries()) {
Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] })
if (namespace.errorClass) {
Object.defineProperty(repl.context, namespace.errorClass.name, { configurable: true, value: errorLookup.get(namespace.global) })
}
}
// Node's REPL treats a leading brace as an object expression before transforming await.
const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code
repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => {
evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)
: prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes))
})
} catch (error: unknown) {
evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes))
const connection = createProcessRpc<HostMethods, ProgramMethods>(stream, maxMessageBytes, {
async run(cwd, data) {
const completion = Promise.withResolvers<ProgramResult>()
const logs = new LogBuffer(data.maxOutputBytes, (text) => { void connection.log(text) }, () => { void connection.outputLimit() })
const evaluation: Evaluation = {
logs, maxOutputBytes: data.maxOutputBytes, active: true,
finish(result) {
if (!evaluation.active) return
evaluation.active = false
void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) })
completion.resolve(result)
},
}
})
}, () => { channel.close(); closed.resolve() })
stream.once('close', () => { closed.resolve() })
current = evaluation
context.run(evaluation, () => {
try {
processState.chdir(cwd)
for (const namespace of data.namespaces) {
const descriptor = namespace.errorClass
if (!descriptor) { errors.delete(namespace.global); continue }
const previous = errors.get(namespace.global)
if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue
const constructor = makeBindingErrorClass(descriptor)
errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor })
}
const namespaces = makeNamespaces(data, host, errorLookup)
for (const [index, namespace] of data.namespaces.entries()) {
Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] })
if (namespace.errorClass) {
Object.defineProperty(repl.context, namespace.errorClass.name, {
configurable: true, value: errorLookup.get(namespace.global),
})
}
}
// Node's REPL treats a leading brace as an object expression before transforming await.
const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code
repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => {
evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)
: prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes))
})
} catch (error: unknown) {
evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes))
}
})
return await completion.promise
},
}, ['ready', 'log', 'outputLimit'], () => {
// An inactive evaluation sends nothing more over the closed endpoint.
if (current) current.active = false
closed.resolve()
})
try {
await channel.send({ type: 'ready' })
await connection.ready()
await closed.promise
} finally {
if (current) current.active = false
repl.close()
input.destroy()
output.destroy()
channel.close()
context.disable()
}
}
@@ -8,19 +8,19 @@ import { clampTimeout } from '@deepseek-ai/dsh-timeout'
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import type { BirpcReturn } from 'birpc'
import type { PolicyProcesses } from './policy-processes.ts'
import type { ResolvedConfig } from './config.ts'
import { replyToBinding, validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import { invokeBinding, validateBindings } from './bindings.ts'
import { createProcessRpc } from './rpc.ts'
import { bootstrapArgs } from './launch.ts'
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
import { decodePtcJsonWire } from './json-wire.ts'
import { OutputLedger } from './output-ledger.ts'
import { drainOutput, terminateProcess } from './output-stream.ts'
import type { ProgramBootData } from './protocol.ts'
import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts'
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error)
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
function persistentOutput(maxBytes: number): OutputLedger {
return new OutputLedger(maxBytes, `retained variables were cleared; outer output exceeded ${maxBytes} bytes`)
@@ -40,11 +40,10 @@ function sameAdditionalRoots(left: readonly string[] | undefined, right: readonl
}
interface Evaluation {
id: number
data: ProgramBootData
spec: PtcRunSpec
bindings: Map<string, PtcBindingNamespace>
reply: typeof replyToBinding
invoke: typeof invokeBinding
pending: number
pendingBytes: number
admit(text: string): void
@@ -56,14 +55,12 @@ class Kernel {
private readonly unregister: (() => void) | undefined
private confined: ConfinedArgv | undefined
private handle?: SubprocessHandle
private channel?: JsonChannel
private channel?: BirpcReturn<ProgramMethods, HostMethods>
private active: Evaluation | undefined
/** Admission remains reserved until the result includes completed process cleanup. */
private running = false
private cleanupPending = false
private ready = false
private sequence = 0
private nextCall = 1
private stderr = ''
private stopped: Promise<boolean> | undefined
private starting?: Promise<void>
@@ -117,11 +114,10 @@ class Kernel {
}, spec.timeoutMs)
const onAbort = (): void => { evaluation.finish({ kind: 'abort', message: 'execution canceled; retained variables were cleared' }, undefined, true) }
const evaluation: Evaluation = {
id: ++this.sequence,
data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({
global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {},
})) },
spec, bindings, reply: AsyncLocalStorage.bind(replyToBinding), pending: 0, pendingBytes: 0,
spec, bindings, invoke: AsyncLocalStorage.bind(invokeBinding), pending: 0, pendingBytes: 0,
admit: (text) => {
if (!output.admit(text, logs)) {
logs.push(text)
@@ -164,7 +160,7 @@ class Kernel {
this.closed = true
this.cleanupPending = true
this.controller.abort()
this.channel?.close()
this.channel?.$close()
this.stopped = (async () => {
await this.starting?.catch(() => {})
const handle = this.handle
@@ -187,8 +183,11 @@ class Kernel {
}
private dispatch(evaluation: Evaluation): void {
void this.channel?.send({ type: 'run', id: evaluation.id, cwd: evaluation.spec.cwd, data: evaluation.data })
.catch((error: unknown) => { this.fail('protocol', messageOf(error)) })
void this.channel?.run(evaluation.spec.cwd, evaluation.data).then((raw) => {
const { error, value } = readProgramResult(raw)
// Raw stdio can be queued beside the RPC result on separate pipes.
setImmediate(() => { evaluation.finish(error, value) })
}).catch((error: unknown) => { if (this.active === evaluation && !this.channel?.$closed) this.fail('protocol', messageOf(error)) })
}
private exitMessage(outcome: SubprocessOutcome): string {
@@ -217,79 +216,59 @@ class Kernel {
})
stream.on('error', (error: Error) => { this.fail('worker-exit', error.message) })
}
this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes,
(raw, bytes) => { this.receive(raw, bytes) }, (error, kind) => {
if (kind === 'protocol') this.fail('protocol', error.message)
else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() })
})
// Report a protocol failure and abandon the handler; the closed endpoint sends no response.
const refuse = (message: string): never => { this.fail('protocol', message); throw new Error(message) }
this.channel = createProcessRpc<ProgramMethods, HostMethods>(handle.control, this.config.maxMessageBytes, {
ready: () => {
if (this.ready || !this.active) { this.fail('protocol', 'unexpected persistent PTC readiness'); return }
this.ready = true
this.dispatch(this.active)
},
log: (text: unknown) => {
if (this.active === undefined || typeof text !== 'string') this.fail('protocol', 'invalid persistent PTC log')
else this.active.admit(text)
},
outputLimit: () => {
if (this.active === undefined) this.fail('protocol', 'idle persistent PTC output limit')
else this.active.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true)
},
invoke: async (global, name, wire) => {
const evaluation = this.active
if (evaluation === undefined) return refuse('idle persistent PTC binding call')
const args = decodePtcJsonWire(wire)
if (args === undefined) return refuse('binding arguments must be lossless JSON')
const bytes = Buffer.byteLength(JSON.stringify(wire))
if (evaluation.pending >= this.config.maxPendingCalls || evaluation.pendingBytes + bytes > this.config.maxMessageBytes) {
return refuse('pending binding calls exceed configured limits')
}
// A retained function can name a binding this evaluation no longer declares; invokeBinding rejects it.
const functions = evaluation.bindings.get(global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined
evaluation.pending += 1
evaluation.pendingBytes += bytes
try {
const value = await evaluation.invoke(fn, args)
// The child already rejected this wait; a late result must not fill the pipe shared with the next evaluation.
if (this.active !== evaluation) throw new Error('PTC evaluation ended before the binding completed')
return value
} finally {
evaluation.pending -= 1
evaluation.pendingBytes -= bytes
}
},
}, [], (error, kind) => {
if (kind === 'protocol') this.fail('protocol', error.message)
else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() })
})
void handle.done.then(async (outcome) => {
this.exitOutcome = outcome
await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)])
// The terminal control frame can already be queued when process exit arrives.
// The run result can already be queued when process exit arrives.
setImmediate(() => {
if (!this.closed) this.fail(this.confined !== undefined && classifyRunnerFailure(outcome.exitCode, this.stderr, this.confined.runnerFailureRules) !== undefined ? 'sandbox-unavailable' : 'worker-exit', this.exitMessage(outcome))
})
}, (error: unknown) => { this.fail('worker-exit', messageOf(error)) })
}
private receive(raw: unknown, bytes: number): void {
if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return }
const evaluation = this.active
if (!evaluation) { this.fail('protocol', 'frame arrived without an active PTC evaluation'); return }
if (!this.ready) {
if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return }
this.ready = true
this.dispatch(evaluation)
return
}
if (raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return }
const payload = raw.payload
switch (payload.type) {
case 'log':
if (typeof payload.text === 'string') evaluation.admit(payload.text)
else this.fail('protocol', 'invalid persistent PTC log')
return
case 'output-limit': evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true); return
case 'done': {
if (payload.error !== undefined) {
const error = payload.error
if (!record(error) || typeof error.message !== 'string' || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) {
this.fail('protocol', 'invalid persistent PTC failure'); return
}
const failure = { kind: error.kind, message: error.message } as PtcRunFailure
setImmediate(() => { evaluation.finish(failure, undefined, failure.kind === 'output-limit') })
} else {
const value = payload.value === undefined ? undefined : decodePtcJsonWire(payload.value)
const failure: PtcRunFailure | undefined = payload.value !== undefined && value === undefined
? { kind: 'invalid-output', message: 'program completion must be lossless JSON' } : undefined
// Raw stdio may be queued beside the terminal control frame on separate pipes.
setImmediate(() => { evaluation.finish(failure, value) })
}
return
}
case 'call': {
if (payload.id !== this.nextCall || typeof payload.global !== 'string' || typeof payload.name !== 'string') { this.fail('protocol', 'invalid persistent PTC binding identity'); return }
this.nextCall += 1
const args = decodePtcJsonWire(payload.args)
if (args === undefined) { this.fail('protocol', 'binding arguments must be lossless JSON'); return }
if (++evaluation.pending > this.config.maxPendingCalls || (evaluation.pendingBytes += bytes) > this.config.maxMessageBytes) {
this.fail('protocol', 'pending binding calls exceed configured limits'); return
}
const functions = evaluation.bindings.get(payload.global)?.functions
const fn = functions && Object.hasOwn(functions, payload.name) ? functions[payload.name] : undefined
const id = payload.id
void (async () => {
const reply = await evaluation.reply(id, fn, args).finally(() => {
evaluation.pending -= 1
evaluation.pendingBytes -= bytes
})
if (this.active === evaluation) await this.channel?.send(reply)
})().catch((error: unknown) => { this.fail('protocol', messageOf(error)) })
return
}
default: this.fail('protocol', 'unknown persistent PTC message')
}
}
}
/** Sandboxed, best-effort retained state; restart, disposal, and interrupted execution clear it. */
@@ -1,10 +1,10 @@
/** Node child execution over an inherited control channel; no Harness services run here. */
/** Node child execution through typed host and program RPC methods. */
import type { Duplex } from 'node:stream'
import { JsonChannel } from './channel.ts'
import { createProcessRpc } from './rpc.ts'
import { runProgram } from './bootstrap.ts'
import { clearProgramEnvironment } from './environment.ts'
import type { PatchableStream } from './bootstrap.ts'
import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
import type { HostMethods, ProgramMethods } from './protocol.ts'
/** Process-owned environment and output streams consumed by the child bootstrap. */
export interface ProgramProcess {
@@ -15,72 +15,38 @@ export interface ProgramProcess {
}
/**
* Run one host-supplied program after the control handshake.
* Serve one run and retain the endpoint until the host receives its result and closes it.
* @param stream - Inherited, already-adopted control endpoint.
* @param maxMessageBytes - Host-validated maximum frame and queued-write bytes.
* @param maxMessageBytes - Host-validated maximum message and queued-write bytes.
* @param processState - Environment, output streams and exit status of this Node child.
* @returns After control output flushes and host shutdown is observed, or transport failure closes the channel.
* @returns After host shutdown or a transport failure closes the endpoint.
*/
export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise<void> {
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit')
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit')
clearProgramEnvironment(processState)
const boot = Promise.withResolvers<ProgramBootData>()
const listeners: Array<(message: ReplyMessage) => void> = []
let started = false
let failed = false
let terminalSent = false
const hostClosed = Promise.withResolvers<void>()
const onClose = (): void => { hostClosed.resolve() }
stream.once('close', onClose)
const channel = new JsonChannel(stream, maxMessageBytes, (raw) => {
if (!started) {
started = true
if (typeof raw !== 'object' || raw === null || (raw as { type?: unknown }).type !== 'boot') {
boot.reject(new Error('expected program boot frame'))
return
}
boot.resolve((raw as { data: ProgramBootData }).data)
return
}
if (terminalSent) return
for (const listener of listeners) listener(raw as ReplyMessage)
}, (error, kind) => {
if (!terminalSent || kind === 'protocol') {
failed = true
boot.reject(error)
channel.close()
processState.exitCode = 1
}
// Set once the result is returned or the endpoint closes; binding calls then never settle and output is dropped.
let ended = false
const unsettled = new Promise<never>(() => {})
const connection = createProcessRpc<HostMethods, ProgramMethods>(stream, maxMessageBytes, {
async run(_cwd, data) {
const result = await runProgram({
invoke: (global, name, args) => ended ? unsettled : connection.invoke(global, name, args).catch((error: unknown) => {
// Closure rejects outstanding calls; an unawaited one would crash the child into the result logs.
if (ended) return unsettled
throw error
}),
log: (text) => { if (!ended) void connection.log(text) },
outputLimit: () => { if (!ended) void connection.outputLimit() },
}, data, processState)
ended = true
return result
},
}, ['ready', 'log', 'outputLimit'], (_error, kind) => {
if (!ended || kind === 'protocol') processState.exitCode = 1
ended = true
hostClosed.resolve()
})
const pending = new Set<Promise<void>>()
const send = (message: ProgramToHost): void => {
if (terminalSent) return
if (message.type === 'done') terminalSent = true
const task = channel.send(message).catch(() => {
failed = true
channel.close()
processState.exitCode = 1
hostClosed.resolve()
}).finally(() => { pending.delete(task) })
pending.add(task)
}
try {
await channel.send({ type: 'ready' })
const data = await boot.promise
await runProgram({
postMessage: send,
on: (_event, listener) => { listeners.push(listener) },
}, data, { stdout: processState.stdout, stderr: processState.stderr })
while (pending.size > 0) await Promise.all(pending)
await channel.drain()
// Host binding replies can race the terminal frame; the host owns channel shutdown.
await hostClosed.promise
} finally {
stream.off('close', onClose)
channel.close()
// Transport callbacks can set failed while the awaited program executes.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (failed) processState.exitCode = 1
}
await connection.ready()
await hostClosed.promise
}
@@ -1,67 +1,77 @@
/**
* JSON message vocabulary carried by the bounded process channel. The host validates every
* message because model code can write directly to the inherited descriptor.
* @module @deepseek-ai/dsh-ptc-runtime-node/src/protocol
*/
/** Typed methods shared by the host and its co-shipped Node execution process, and validation of untrusted program results. */
import type { PtcJsonValue, PtcRunFailure } from '@deepseek-ai/dsh-ptc-runtime'
import { decodePtcJsonWire, type PtcJsonWire } from './json-wire.ts'
import type { PtcJsonWire } from './json-wire.ts'
/** What the host hands the program at spawn, after the readiness handshake. */
/** Inputs for one program; host functions remain in the parent process. */
export interface ProgramBootData {
/** The type-stripped (plain JS) program body. */
/** Type-stripped JavaScript source. */
code: string
/** Binding namespaces to materialize; functions themselves stay host-side. */
/** Globals and callable names injected into the program. */
namespaces: {
global: string
names: string[]
errorClass?: { name: string; memberNameProperty: string }
}[]
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
/** Combined serialized log and completion budget. */
maxOutputBytes: number
}
/** Program → host: one bridged binding call. */
interface CallMessage {
type: 'call'
/** Program-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
/** The namespace global the call targets. */
global: string
/** The function name within the namespace. */
name: string
/** The single argument as a flat lossless-JSON wire value. */
args: PtcJsonWire
}
/** Program → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
interface LogMessage {
type: 'log'
text: string
}
/** Program → host: program-side capture or completion measurement exceeded the outer cap. */
interface OutputLimitMessage {
type: 'output-limit'
}
/**
* Program → host: the program settled. `error` carries a program exception,
* invalid completion, or output overflow (budgets, aborts, and substrate death
* are observed host-side). `value` is present only on a clean completion that
* produced one, as a flat wire value already lossless and admitted against
* the remaining combined output cap. Logs are NOT carried here — they streamed
* eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'
/** Program completion; logs are delivered separately before this result. */
export interface ProgramResult {
value?: PtcJsonWire
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
}
/** Every message the program sends. */
export type ProgramToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
/** Host operations for the current evaluation; one pipe delivers its messages before its result. */
export interface HostMethods {
/** Announce that the process can accept its first evaluation. */
ready(): void
/**
* Invoke a binding under its originating host execution context.
* @param global - Namespace global.
* @param name - Function name within the namespace.
* @param args - Flat lossless JSON arguments.
* @returns Flat lossless JSON result; binding failures reject the call.
*/
invoke(global: string, name: string, args: PtcJsonWire): Promise<PtcJsonWire>
/**
* Admit captured output before completion or interruption.
* @param text - Captured text.
*/
log(text: string): void
/** Report exhausted program-side output accounting. */
outputLimit(): void
}
/** Host → program: the answer to one {@link CallMessage}. */
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: PtcJsonWire }
| { type: 'reply'; id: number; ok: false; message: string }
/** Operations implemented by either the per-call child or retained REPL. */
export interface ProgramMethods {
/**
* Evaluate code with the supplied binding declarations and output budget.
* @param cwd - Execution-world working directory.
* @param data - Program and injected declarations.
* @returns Completion or a bounded program failure, after captured output is sent.
*/
run(cwd: string, data: ProgramBootData): Promise<ProgramResult>
}
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
/**
* Decode a remote run result without trusting model-written control traffic.
* @param result - Untrusted result returned by the program process.
* @returns Decoded completion or failure; malformed envelopes throw a protocol error.
*/
export function readProgramResult(result: unknown): { value?: PtcJsonValue; error?: PtcRunFailure } {
if (!record(result)) throw new Error('invalid program result')
if (result.error !== undefined) {
const error = result.error
if (!record(error) || typeof error.message !== 'string'
|| (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) {
throw new Error('invalid terminal error')
}
return { error: { kind: error.kind, message: error.message } }
}
if (result.value === undefined) return {}
const value = decodePtcJsonWire(result.value)
return value === undefined ? { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } } : { value }
}
@@ -0,0 +1,88 @@
/** birpc over newline-delimited JSON on the bounded private process channel. */
import { Buffer } from 'node:buffer'
import type { Duplex } from 'node:stream'
import { createBirpc, type BirpcReturn } from 'birpc'
const stringify = JSON.stringify
const parse = JSON.parse
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error)
/**
* Connect typed methods over one control endpoint; execution owns all deadlines.
* @param stream - Owned control endpoint; `$close()` destroys it and rejects outstanding remote calls.
* @param maxMessageBytes - Maximum incoming JSON line and queued outgoing bytes.
* @param functions - Only these own methods may be called by the peer.
* @param eventNames - Remote methods sent as notifications without a response.
* @param failure - Receives the first I/O or malformed-message failure after it closes the endpoint.
* @returns Typed remote calls and birpc lifetime controls.
*/
export function createProcessRpc<Remote extends object, Local extends object>(
stream: Duplex,
maxMessageBytes: number,
functions: Local,
eventNames: (keyof Remote)[],
failure: (error: Error, kind: 'io' | 'protocol') => void,
): BirpcReturn<Remote, Local> {
const fail = (error: unknown, kind: 'io' | 'protocol'): void => {
if (rpc.$closed) return
rpc.$close()
// Streams, the JSON parser and the line checks fail only with Error objects.
failure(error as Error, kind)
}
let receive: ((line: string) => void) | undefined
const rpc = createBirpc<Remote, Local>(functions, {
timeout: -1,
eventNames,
on: (listener) => { receive = listener },
off: () => { receive = undefined; stream.destroy() },
// JSON drops Error fields; the peer receives only the diagnostic.
serialize: (message: { e?: unknown }) => `${stringify(message.e === undefined ? message : { ...message, e: { message: messageOf(message.e) } })}\n`,
post: (line: string) => {
// A binding can settle after the endpoint closes; the Writable owns already queued bytes.
if (rpc.$closed) return
if (stream.writableLength + Buffer.byteLength(line) > maxMessageBytes) fail(new Error(`control output exceeds ${maxMessageBytes} queued bytes`), 'protocol')
else stream.write(line)
},
deserialize: (line: string) => {
// A line that parses after the leading-brace check is a JSON object.
const message = parse(line) as { e?: { message?: unknown } }
if (message.e) message.e = new Error(String(message.e.message))
return message
},
// Only own methods are callable, and none runs once the endpoint closes,
// including calls that arrived in the same chunk as the closing message.
resolver: (name, fn) => Object.hasOwn(functions, name)
? (...args: unknown[]) => rpc.$closed ? undefined : fn.apply(functions, args)
: undefined,
})
let parts: Buffer[] = []
let size = 0
const buffer = (part: Buffer): void => {
// Every message is a JSON object; another release's length-prefixed frame would otherwise wait for a newline.
if (size === 0 && part.length > 0 && part[0] !== 0x7b) throw new Error('control message is not a JSON object; the Node bootstrap may be from another release')
size += part.length
if (size > maxMessageBytes) throw new Error(`RPC message exceeds ${maxMessageBytes} bytes`)
parts.push(part)
}
stream.on('data', (chunk: Buffer) => {
try {
let start = 0
for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) {
buffer(chunk.subarray(start, end))
const line = Buffer.concat(parts, size).toString('utf8')
parts = []
size = 0
start = end + 1
void Promise.resolve(receive?.(line)).catch((error: unknown) => { fail(error, 'protocol') })
}
buffer(chunk.subarray(start))
} catch (error: unknown) { fail(error, 'protocol') }
})
stream.on('end', () => {
if (size === 0) fail(new Error('control channel ended before the program settled'), 'io')
else fail(new Error('incomplete RPC message'), 'protocol')
})
stream.on('error', (error: Error) => { fail(error, 'io') })
stream.on('close', () => { fail(new Error('control channel closed'), 'io') })
return rpc
}
@@ -1,71 +1,24 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, ProgramToHost } from '../src/protocol.ts'
import { describe, expect, it, vi } from 'vitest'
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts'
import type { PatchableStream } from '../src/bootstrap.ts'
import type { HostMethods } from '../src/protocol.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
* HOST side — inspect what the bootstrap posted, feed replies back — so
* every line of worker-side logic runs under coverage without spawning an
* isolate (real-worker behavior is pinned by runtime.spec.ts).
*/
class FakePort implements BootstrapPort {
sent: ProgramToHost[] = []
private readonly emitter = new EventEmitter()
/** Host-scripted responder; return undefined to leave the call pending. */
respond: (message: ProgramToHost) => ReplyMessage | undefined = () => undefined
postMessage(message: ProgramToHost): void {
this.sent.push(message)
const reply = this.respond(message)
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
}
on(event: 'message', listener: (message: ReplyMessage) => void): void {
this.emitter.on(event, listener)
}
deliver(message: ReplyMessage): void {
this.emitter.emit('message', message)
}
logs(): string[] {
return this.sent.filter(message => message.type === 'log').map(message => message.text)
}
done(): ProgramToHost | undefined {
return this.sent.find(message => message.type === 'done')
}
doneValue(): unknown {
const done = this.done()
return done?.type === 'done' && done.value !== undefined ? decodePtcJsonWire(done.value) : undefined
function host(): Omit<HostMethods, 'ready'> & { logs: string[]; limits: number } {
const calls = {
logs: [] as string[], limits: 0,
invoke: vi.fn<HostMethods['invoke']>(async () => encodePtcJsonWire(42)),
log: (text: string) => { calls.logs.push(text) },
outputLimit: () => { calls.limits += 1 },
}
return calls
}
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
return { stdout: { write: () => true }, stderr: { write: () => true } }
}
/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
try {
await promise
return undefined
} catch (error: unknown) {
return error
}
}
const BOOT = { maxOutputBytes: 65_536 }
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
/** One worker declaration for the PTC mode tools namespace. */
function toolNamespace(names: string[]) {
return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
}
function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } }
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
@@ -204,241 +157,98 @@ describe('prepareException', () => {
})
describe('makeNamespaces', () => {
it('rejects a malformed success reply instead of resolving a lossy binding value', async () => {
const port = new FakePort()
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const result = new Promise<unknown>((resolve, reject) => { pending.set(1, { resolve, reject }) })
port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never })
await expect(result).rejects.toThrow('binding resolution must be lossless JSON')
})
it('exposes prototype-colliding names as ordinary own properties', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(`${message.name}-ok`) }
: undefined
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
it('rejects malformed remote results and keeps prototype-colliding function names callable', async () => {
const remote = host()
remote.invoke = vi.fn(async (_global, name) => name === 'bad' ? [undefined] as never : encodePtcJsonWire(`${name}-ok`))
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['bad', '__proto__', 'constructor', 'toString'] }] }, remote) as [Record<string, (args: unknown) => Promise<unknown>>]
expect(Object.getPrototypeOf(tools)).toBeNull()
await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
for (const name of ['__proto__', 'constructor', 'toString']) await expect(tools[name]?.({})).resolves.toBe(`${name}-ok`)
await expect(tools.bad?.({})).rejects.toThrow('binding resolution must be lossless JSON')
})
it('rejects a postMessage clone failure without leaking the pending entry', async () => {
let firstCall = true
const throwingPort: BootstrapPort = {
// First call throws an Error (the real DataCloneError shape), the
// second a bare string — the rejection renders both.
postMessage: () => {
if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
throw 'raw-clone-failure'
},
on: () => {},
}
const pending = new Map<number, PendingCall>()
it('uses the declared constructor for remote and argument failures', async () => {
const remote = host()
const data = { namespaces: [toolNamespace(['x'])] }
const errorClasses = makeBindingErrorClasses(data)
const ToolCallError = errorClasses.get('tools')
const [tools] = makeNamespaces(
data,
throwingPort,
pending,
{ value: 1 },
errorClasses,
) as [Record<string, (args: unknown) => Promise<unknown>>]
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
expect(first).toBeInstanceOf(ToolCallError)
expect(second).toBeInstanceOf(ToolCallError)
expect((first as Error).message).toMatch(/DataCloneError-ish/)
expect((second as Error).message).toMatch(/raw-clone-failure/)
expect(pending.size).toBe(0)
})
it('rejects lossy arguments before posting or allocating a call id', async () => {
let posts = 0
const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} }
const pending = new Map<number, PendingCall>()
const nextId = { value: 1 }
const [tools] = makeNamespaces(
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
) as [Record<string, (args: unknown) => Promise<unknown>>]
const classes = makeBindingErrorClasses(data)
const [tools] = makeNamespaces(data, remote, classes) as [Record<string, (args: unknown) => Promise<unknown>>]
const decorated = [1]
Object.defineProperty(decorated, 'extra', { value: true })
const throwing = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => { throw new Error('getter exploded') },
})
const throwing = Object.defineProperty({}, 'value', { enumerable: true, get() { throw new Error('getter failed') } })
for (const value of [() => 1, new Date(), decorated, throwing]) {
const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve())
expect(failure).toMatchObject({
name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON',
})
await expect(tools.x?.(value)).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON' })
}
expect(vi.mocked(remote.invoke).mock.calls).toEqual([])
for (const failure of [new Error('denied'), 'raw failure']) {
remote.invoke = async () => { throw failure }
const result = tools.x?.({})
await expect(result).rejects.toBeInstanceOf(classes.get('tools'))
await expect(result).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: failure instanceof Error ? failure.message : failure })
}
expect(posts).toBe(0)
expect(pending.size).toBe(0)
expect(nextId.value).toBe(1)
})
it('uses ordinary Error for non-tools namespace failures', async () => {
const deniedPort = new FakePort()
deniedPort.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
: undefined
const deniedPending = new Map<number, PendingCall>()
wireReplies(deniedPort, deniedPending)
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
expect(denied).toBeInstanceOf(Error)
expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
expect(denied).not.toHaveProperty('toolName')
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
expect(invalid).toBeInstanceOf(Error)
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
expect(cloneFailure).toBeInstanceOf(Error)
expect(cloneFailure).not.toHaveProperty('toolName')
it('uses ordinary Error when the namespace declares no rejection class', async () => {
const remote = host()
remote.invoke = async () => { throw new Error('helper denied') }
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, remote) as [Record<string, (args: unknown) => Promise<unknown>>]
await expect(helpers.x?.({})).rejects.toMatchObject({ name: 'Error', message: 'helper denied' })
await expect(helpers.x?.(() => 1)).rejects.toThrow('binding arguments must be lossless JSON')
})
})
describe('runProgram', () => {
it('runs a program end-to-end: bindings, console, return value', async () => {
const port = new FakePort()
port.respond = (message) => {
if (message.type !== 'call') return undefined
const args = decodePtcJsonWire(message.args) as { n: number }
return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(args.n * 2) }
it('returns the completion after streaming logs and invoking host functions', async () => {
const remote = host()
remote.invoke = async (_global, _name, wire) => {
const args = decodePtcJsonWire(wire) as { n: number }
return encodePtcJsonWire(args.n * 2)
}
await runProgram(port, {
const result = await runProgram(remote, { ...BOOT, code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', namespaces: [{ global: 'tools', names: ['double'] }] }, fakeStreams())
expect(remote.logs).toEqual(['got 42'])
expect(decodePtcJsonWire(result.value)).toEqual({ doubled: 42 })
})
it('reports captured output overflow before completing', async () => {
const remote = host()
const result = await runProgram(remote, { maxOutputBytes: 4, code: 'console.log("12345"); return null', namespaces: [] }, fakeStreams())
expect(remote.logs).toEqual([])
expect(remote.limits).toBe(1)
expect(result).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' } })
})
it.each([
['throw new Error("boom")', 'boom'],
['throw "raw-throw"', 'raw-throw'],
['const e = new Error("bare"); e.stack = undefined; throw e', 'bare'],
])('returns bounded program exceptions for %s', async (code, message) => {
const result = await runProgram(host(), { ...BOOT, code, namespaces: [] }, fakeStreams())
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toContain(message)
expect(result.value).toBeUndefined()
})
it.each(['throw "x".repeat(1_000_000)', 'throw new Error("x".repeat(1_000_000))'])('bounds large diagnostics for %s', async (code) => {
expect(await runProgram(host(), { maxOutputBytes: 64, code, namespaces: [] }, fakeStreams())).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
})
it.each([
['tools', 'ToolCallError', 'toolName'],
['helpers', 'HelperCallError', 'helperName'],
])('injects the declared rejection class for %s', async (global, name, memberNameProperty) => {
const remote = host()
remote.invoke = async () => { throw new Error('denied by host') }
const result = await runProgram(remote, {
...BOOT,
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }],
code: `try { await ${global}.x({}) } catch (error) { return { caught: error instanceof ${name}, name: error.name, member: error.${memberNameProperty}, message: error.message } }`,
namespaces: [{ global, names: ['x'], errorClass: { name, memberNameProperty } }],
}, fakeStreams())
expect(port.logs()).toEqual(['got 42'])
expect(port.doneValue()).toEqual({ doubled: 42 })
expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' })
})
it('reports worker-side log capture overflow before completing', async () => {
const port = new FakePort()
await runProgram(port, {
maxOutputBytes: 4,
code: 'console.log("12345"); return null',
namespaces: [],
}, fakeStreams())
expect(port.logs()).toEqual([])
expect(port.sent).toContainEqual({ type: 'output-limit' })
expect(port.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
})
})
it('reports a thrown program error on the done message', async () => {
const port = new FakePort()
await runProgram(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
const done = port.done()
expect(done?.type).toBe('done')
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
})
it('renders non-Error throws and stack-less Errors on the done message', async () => {
const rawPort = new FakePort()
await runProgram(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
const barePort = new FakePort()
await runProgram(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
})
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
const rawPort = new FakePort()
await runProgram(rawPort, {
maxOutputBytes: 64,
code: 'throw "x".repeat(1_000_000)',
namespaces: [],
}, fakeStreams())
expect(rawPort.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
const stackPort = new FakePort()
await runProgram(stackPort, {
maxOutputBytes: 64,
code: 'throw new Error("x".repeat(1_000_000))',
namespaces: [],
}, fakeStreams())
expect(stackPort.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
await runProgram(port, {
...BOOT,
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
namespaces: [toolNamespace(['x'])],
}, fakeStreams())
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
})
it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
: undefined
await runProgram(port, {
...BOOT,
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
namespaces: [{
global: 'helpers',
names: ['x'],
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
}],
}, fakeStreams())
expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
})
it('ignores replies for unknown pending ids', async () => {
const port = new FakePort()
port.respond = (message) => {
if (message.type !== 'call') return undefined
// Deliver a stray reply first; the real one follows.
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodePtcJsonWire('stray') })
return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire('real') }
}
await runProgram(port, {
...BOOT,
code: 'return await tools.x({})',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.doneValue()).toBe('real')
})
it('captures raw stream writes through the patched process streams', async () => {
const port = new FakePort()
it('leaves the owned output streams connected to their capture sink', async () => {
const remote = host()
const streams = fakeStreams()
await runProgram(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
streams.stdout.write('never seen — already restored? no: patch persists in worker')
// The patch stays installed for the worker's lifetime; writes during the
// program landed in order. Here the program wrote nothing via streams, so
// only the post-run write above went through the patched slot.
expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
await runProgram(remote, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
streams.stdout.write('captured')
expect(remote.logs).toEqual(['captured'])
})
})
@@ -1,166 +0,0 @@
import { Duplex, PassThrough } from 'node:stream'
import { describe, expect, it, onTestFinished } from 'vitest'
import { JsonChannel } from '../src/channel.ts'
function pair() {
const left = new PassThrough()
const right = new PassThrough()
const a = Duplex.from({ readable: left, writable: right })
const b = Duplex.from({ readable: right, writable: left })
// Duplex.from forwards peer destruction as ABORT_ERR; channel owners observe their own failures.
a.on('error', () => {})
b.on('error', () => {})
onTestFinished(() => { a.destroy(); b.destroy() })
return { a, b }
}
function frame(value: unknown): Buffer {
const body = Buffer.from(JSON.stringify(value))
const header = Buffer.alloc(4)
header.writeUInt32BE(body.length)
return Buffer.concat([header, body])
}
describe('bounded process frames', () => {
it('reassembles split binary headers and UTF-8 bodies', async () => {
const { a, b } = pair()
const received = Promise.withResolvers<unknown>()
const channel = new JsonChannel(a, 1000, (value) => { received.resolve(value) }, (error) => { received.reject(error) })
onTestFinished(() => { channel.close() })
const bytes = frame({ text: '你好🙂' })
for (const byte of bytes) b.write(Buffer.from([byte]))
expect(await received.promise).toEqual({ text: '你好🙂' })
})
it('accepts consecutive frames and writes complete responses', async () => {
const { a, b } = pair()
const received: unknown[] = []
const done = Promise.withResolvers<undefined>()
const channel = new JsonChannel(a, 1000, (value) => {
received.push(value)
if (received.length === 2) done.resolve(undefined)
}, (error) => { done.reject(error) })
const peer = new JsonChannel(b, 1000, () => {}, (error) => { done.reject(error) })
onTestFinished(() => { channel.close(); peer.close() })
await Promise.all([peer.send({ n: 1 }), peer.send({ n: 2 })])
await done.promise
expect(received).toEqual([{ n: 1 }, { n: 2 }])
await peer.drain()
})
it.each([0, 65])('rejects a declared %i-byte frame before accepting its body', async (length) => {
const { a, b } = pair()
const failure = Promise.withResolvers<Error>()
const channel = new JsonChannel(a, 64, () => { throw new Error('must not dispatch') }, (error) => { failure.resolve(error) })
onTestFinished(() => { channel.close() })
const header = Buffer.alloc(4)
header.writeUInt32BE(length)
b.write(header)
expect((await failure.promise).message).toContain('control frame')
})
it('contains malformed JSON, invalid UTF-8 and receiver failures', async () => {
for (const payload of [Buffer.from('{'), Buffer.from([0xff]), Buffer.from('{}')]) {
const { a, b } = pair()
const failure = Promise.withResolvers<Error>()
const channel = new JsonChannel(a, 64, () => { throw new Error('receiver failed') }, (error) => { failure.resolve(error) })
const header = Buffer.alloc(4)
header.writeUInt32BE(payload.length)
b.write(Buffer.concat([header, payload]))
expect(await failure.promise).toBeInstanceOf(Error)
channel.close()
}
})
it('rejects oversized writes and closed channels', async () => {
const { a } = pair()
const channel = new JsonChannel(a, 20, () => {}, () => {})
await expect(channel.send({ text: 'x'.repeat(30) })).rejects.toThrow('queued bytes')
channel.close()
channel.close()
await expect(channel.send({})).rejects.toThrow('closed')
})
it('reports peer EOF while a program is active', async () => {
const { a, b } = pair()
const failure = Promise.withResolvers<Error>()
const channel = new JsonChannel(a, 64, () => {}, (error) => { failure.resolve(error) })
onTestFinished(() => { channel.close() })
b.end()
expect((await failure.promise).message).toContain('ended')
})
})
it('retains a partial header and partial payload across distinct stream deliveries', () => {
const { a } = pair()
const values: unknown[] = []
const channel = new JsonChannel(a, 64, (value) => { values.push(value) }, (error) => { throw error })
const bytes = frame({ value: 1 })
a.emit('data', bytes.subarray(0, 2))
a.emit('data', bytes.subarray(2, 6))
a.emit('data', bytes.subarray(6))
expect(values).toEqual([{ value: 1 }])
channel.close()
})
it('contains a receiver throwing a non-Error value', () => {
const { a } = pair()
const failures: string[] = []
const channel = new JsonChannel(a, 64, () => { throw 'receiver failure' }, (error) => { failures.push(error.message) })
a.emit('data', frame({}))
expect(failures).toEqual(['receiver failure'])
channel.close()
})
it('ignores callbacks already captured by an emission when an earlier listener closes the channel', () => {
for (const event of ['data', 'end']) {
const { a } = pair()
let failures = 0
const channel = new JsonChannel(a, 64, () => { throw new Error('closed channel dispatched') }, () => { failures += 1 })
a.prependOnceListener(event, () => { channel.close() })
a.emit(event, frame({}))
a.emit('error', new Error('late closed stream error'))
expect(failures).toBe(0)
}
})
it('rejects pending write receipts when the owner closes', async () => {
const { a } = pair()
const channel = new JsonChannel(a, 64, () => {}, () => {})
const pending = channel.send({ n: 1 })
channel.close()
await expect(pending).rejects.toThrow('closed')
await channel.drain()
})
it('bounds queued frames while a receiver is not reading', async () => {
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} })
const channel = new JsonChannel(stream, 32, () => {}, () => {})
const pending = channel.send({ value: '1234567890' })
await expect(channel.send({ value: '1234567890' })).rejects.toThrow('queued bytes')
const draining = channel.drain()
channel.close()
await expect(pending).rejects.toThrow('closed')
await draining
})
it.each(['error', 'close'])('settles a blocked write when the stream emits %s', async (event) => {
const entered = Promise.withResolvers<undefined>()
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } })
let failure: Error | undefined
const channel = new JsonChannel(stream, 64, () => {}, (error) => { failure = error })
const pending = channel.send({ value: 1 })
await entered.promise
stream.emit(event, new Error('transport failed'))
await expect(pending).rejects.toThrow(event === 'error' ? 'transport failed' : 'closed')
if (event === 'error') expect(failure?.message).toBe('transport failed')
channel.close()
})
it.each([new Error('write failed'), 'write failed'])('rejects a synchronous transport write failure: %s', async (failure) => {
const stream = new Duplex({ read() {}, write() { throw failure } })
const channel = new JsonChannel(stream, 64, () => {}, () => {})
onTestFinished(() => { channel.close() })
await expect(channel.send({ value: 1 })).rejects.toThrow('write failed')
})
@@ -0,0 +1,96 @@
/** A scripted Node child whose control traffic tests read and forge without starting a process. */
import { Duplex, PassThrough } from 'node:stream'
import type { Context } from '@deepseek-ai/cordis'
import type { PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import { onTestFinished, vi } from 'vitest'
import type NodeRuntime from '../src/index.ts'
import { rawPeer } from './raw-peer.ts'
/** Pass as `first` to start a run without sending readiness. */
export const NO_INITIAL_FRAME = Symbol('no initial frame')
/**
* Replace process launch with an in-memory child whose control pipe the test drives.
* @param ctx - Context whose subprocess service is stubbed.
* @param runtime - Provider under test.
* @param request - Default request for `start`.
* @returns Process handles, recorded messages, and helpers that forge child traffic.
*/
export function fakeChild(ctx: Context, runtime: NodeRuntime, request: PtcRunRequest) {
const control = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (childControl.destroyed) { callback(new Error('peer closed')); return }
childControl.push(chunk)
callback()
},
})
const childControl = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (control.destroyed) { callback(new Error('peer closed')); return }
control.push(chunk)
callback()
},
})
const stdout = new PassThrough()
const stderr = new PassThrough()
const direct = Promise.withResolvers<SubprocessOutcome>()
const messages: unknown[] = []
const terminate = vi.fn(() => {
stdout.end()
stderr.end()
direct.resolve({ exitCode: 0, signal: null })
})
const waitForExit = vi.fn(async () => true)
const handle: SubprocessHandle = {
stdin: undefined,
stdout,
stderr,
control,
collected: {},
done: direct.promise,
terminate,
waitForExit,
}
const writes = new Set<Promise<void>>()
let runId = ''
let receive: (message: unknown) => void = () => {}
const peer = rawPeer(childControl, (message) => {
if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q'
&& 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i
messages.push(message)
receive(message)
})
const emit = (message: unknown): void => {
const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
writes.add(write)
}
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
onTestFinished(async () => {
peer.close()
control.destroy()
stdout.destroy()
stderr.destroy()
direct.resolve({ exitCode: 0, signal: null })
await Promise.all(writes)
})
const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => {
const result = runtime.run(runtime.resolve(input))
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
return result
}
const onBoot = (callback: () => void): void => {
receive = (message) => {
if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback()
}
}
return {
handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages,
spawn, resolveExecutable, emit, start, onBoot,
complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) },
receive: (callback: typeof receive) => { receive = callback },
}
}
@@ -1,101 +1,28 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { Duplex, PassThrough } from 'node:stream'
import { setImmediate } from 'node:timers/promises'
import { SessionId } from '@deepseek-ai/dsh-session'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { Config } from '../src/index.ts'
import { JsonChannel } from '../src/channel.ts'
import { fakeChild, NO_INITIAL_FRAME } from './fake-child.ts'
import { encodePtcJsonWire } from '../src/json-wire.ts'
import { mountRuntime } from './setup.ts'
const request: PtcRunRequest = { program: 'return 1', bindings: [] }
const NO_INITIAL_FRAME = Symbol('no initial frame')
async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-access' = 'danger-full-access') {
const ctx = new Context()
const { runtime } = await mountRuntime(ctx, config, { mode, workspaceRoot: process.cwd() })
const control = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (childControl.destroyed) { callback(new Error('peer closed')); return }
childControl.push(chunk)
callback()
},
})
const childControl = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (control.destroyed) { callback(new Error('peer closed')); return }
control.push(chunk)
callback()
},
})
const stdout = new PassThrough()
const stderr = new PassThrough()
const direct = Promise.withResolvers<SubprocessOutcome>()
const messages: unknown[] = []
const terminate = vi.fn(() => {
stdout.end()
stderr.end()
direct.resolve({ exitCode: 0, signal: null })
})
const waitForExit = vi.fn(async () => true)
const handle: SubprocessHandle = {
stdin: undefined,
stdout,
stderr,
control,
collected: {},
done: direct.promise,
terminate,
waitForExit,
}
const writes = new Set<Promise<void>>()
let receive: (message: unknown) => void = () => {}
const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
messages.push(message)
receive(message)
}, () => {})
const emit = (message: unknown): void => {
const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
writes.add(write)
}
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
onTestFinished(async () => {
peer.close()
control.destroy()
stdout.destroy()
stderr.destroy()
direct.resolve({ exitCode: 0, signal: null })
await Promise.all(writes)
})
const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => {
const result = runtime.run(runtime.resolve(input))
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
return result
}
const onBoot = (callback: () => void): void => {
receive = (message) => {
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback()
}
}
return {
ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages,
spawn, resolveExecutable, emit, start, onBoot,
receive: (callback: typeof receive) => { receive = callback },
}
return { ctx, runtime, ...fakeChild(ctx, runtime, request) }
}
function call(id: number, value: string = '') {
return { type: 'call', id, global: 'tools', name: 'test', args: encodePtcJsonWire(value) }
return { t: 'q', i: String(id), m: 'invoke', a: ['tools', 'test', encodePtcJsonWire(value)] }
}
function withBinding(fn: PtcBindingFunction): PtcRunRequest {
@@ -117,7 +44,6 @@ describe('Node runtime host failures', () => {
[{ maxOutputBytes: 3 }, 'maxOutputBytes'],
[{ maxOutputBytes: 4.5 }, 'maxOutputBytes'],
[{ maxMessageBytes: 1.5 }, 'maxMessageBytes'],
[{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
[{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
[{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
[{ launch: { kind: 'node-script', executable: '' } }, 'launch.executable'],
@@ -149,14 +75,14 @@ describe('Node runtime host failures', () => {
it('keeps partial enforcement separate from a successful program', async () => {
const h = await setup({}, 'read-only')
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) })
expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
})
it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
const h = await setup({}, 'read-only')
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
h.onBoot(() => { h.complete({ error: { kind: 'exception', message } }) })
const result = await h.start()
expect(result.error).toEqual({ kind: 'exception', message })
expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' })
@@ -296,45 +222,57 @@ describe('Node runtime host failures', () => {
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
})
it('fails at once when a bootstrap from another release sends a length-prefixed frame', async () => {
const h = await setup()
const body = Buffer.from(JSON.stringify({ type: 'ready' }))
const header = Buffer.alloc(4)
header.writeUInt32BE(body.length)
h.spawn.mockImplementation(() => {
queueMicrotask(() => { h.control.push(Buffer.concat([header, body])) })
return h.handle
})
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
kind: 'protocol', message: 'control message is not a JSON object; the Node bootstrap may be from another release',
})
})
it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
const h = await setup()
h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
h.onBoot(() => { h.control.push(Buffer.from([0xff, 10])) })
expect((await h.start()).error?.kind).toBe('protocol')
})
it('rejects traffic before readiness without invoking host bindings', async () => {
const h = await setup()
const binding = vi.fn(async () => null)
const result = await h.start(withBinding(binding), call(1))
expect(result.error?.message).toContain('before bootstrap readiness')
expect(binding).not.toHaveBeenCalled()
})
it.each<[unknown, string]>([
[null, 'invalid control frame'],
[{ type: 'log', text: 1 }, 'invalid log frame'],
[{ type: 'done', error: null }, 'invalid terminal error'],
[{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'],
[{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'],
[{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'],
[{ ...call(1), name: 'constructor' }, 'undeclared binding'],
[{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'],
[{ type: 'unknown' }, 'unknown control message'],
])('rejects malformed program frame %j', async (frame, message) => {
[null, 'not a JSON object'],
[{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'],
[{ t: 'q', m: 'log', a: [1] }, 'invalid program log'],
[{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'],
[{ t: 'q', m: 'invoke', a: ['tools', 'constructor', []], i: 'call' }, 'undeclared binding'],
[{ t: 'q', m: 'invoke', a: ['tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'],
])('rejects malformed program traffic %j', async (frame, message) => {
const h = await setup()
const binding = vi.fn(async () => null)
h.onBoot(() => { h.emit(frame) })
const result = await h.start(withBinding(binding))
expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) as unknown })
expect(result.error?.kind).toBe('protocol')
expect(result.error?.message).toContain(message)
expect(binding).not.toHaveBeenCalled()
})
it('refuses a repeated call id instead of dispatching it twice', async () => {
it('answers calls to unknown methods without failing the run', async () => {
const h = await setup()
const binding = vi.fn(async () => null)
h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) })
expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol')
expect(binding).toHaveBeenCalledOnce()
h.onBoot(() => {
h.emit({ t: 'q', i: 'unknown', m: 'constructor', a: [] })
h.complete({ value: encodePtcJsonWire(42) })
})
expect((await h.start()).value).toBe(42)
expect(h.messages).toContainEqual({ t: 's', i: 'unknown', e: { message: '[birpc] function "constructor" not found' } })
})
it.each([null, { error: null }, { error: { kind: 'exception', message: 1 } }, { error: { kind: 'timeout', message: 'forged' } }])('rejects malformed run results %j', async (raw) => {
const h = await setup()
h.onBoot(() => { h.complete(raw) })
expect((await h.start()).error?.kind).toBe('protocol')
})
it.each<Config>([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => {
@@ -351,37 +289,37 @@ describe('Node runtime host failures', () => {
it('rejects an untransferable completion instead of returning a substituted value', async () => {
const h = await setup()
h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) })
h.onBoot(() => { h.complete({ value: { invalid: true } }) })
expect((await h.start()).error?.kind).toBe('invalid-output')
})
it('returns non-lossless binding resolutions as program-visible binding failures', async () => {
const h = await setup()
h.receive((message) => {
if (typeof message !== 'object' || message === null || !('type' in message)) return
if (message.type === 'boot') h.emit(call(1))
if (message.type === 'reply') h.emit({ type: 'done' })
if (typeof message !== 'object' || message === null || !('t' in message)) return
if ('m' in message && message.m === 'run') h.emit(call(1))
if (message.t === 's') h.complete({})
})
expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined()
expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') as unknown })
expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'binding resolution must be lossless JSON' } })
})
it('does not publish a late binding reply after the program has settled', async () => {
const h = await setup()
const release = Promise.withResolvers<null>()
onTestFinished(() => { release.resolve(null) })
h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
h.onBoot(() => { h.emit(call(1)); h.complete({}) })
expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined()
release.resolve(null)
await setImmediate()
expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' }))
expect(h.messages).not.toContainEqual(expect.objectContaining({ t: 's' }))
expect(h.control.destroyed).toBe(true)
})
it('reports failed managed cleanup even when the program returns successfully', async () => {
const h = await setup()
vi.mocked(h.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) })
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
})
@@ -397,11 +335,26 @@ describe('Node runtime host failures', () => {
expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
})
it('keeps an output overflow delivered beside the program result', async () => {
const h = await setup({ maxOutputBytes: 64 }, 'read-only')
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
h.receive((message) => {
const run = message as { m?: string; i?: string }
if (run.m !== 'run') return
const result = { t: 's', i: run.i, r: { error: { kind: 'exception', message: 'EACCES: blocked' } } }
const log = { t: 'q', m: 'log', a: ['x'.repeat(100)] }
h.control.push(Buffer.from(`${JSON.stringify(result)}\n${JSON.stringify(log)}\n`))
})
const result = await h.start()
expect(result.error?.kind).toBe('output-limit')
expect(result.sandbox?.denied).toBe(false)
})
it('retains admitted logs when the program reports its own output limit', async () => {
const h = await setup()
h.onBoot(() => {
h.emit({ type: 'log', text: 'retained' })
h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } })
h.emit({ t: 'q', m: 'log', a: ['retained'] })
h.complete({ error: { kind: 'output-limit', message: 'child ledger exhausted' } })
})
const result = await h.start()
expect(result.error?.kind).toBe('output-limit')
@@ -413,7 +366,7 @@ describe('Node runtime host failures', () => {
const cleaning = Promise.withResolvers<undefined>()
vi.mocked(h.terminate).mockImplementation(() => { h.direct.resolve({ exitCode: 0, signal: null }) })
vi.mocked(h.waitForExit).mockImplementation(async () => { cleaning.resolve(undefined); return true })
h.onBoot(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) })
h.onBoot(() => { h.complete(failure === undefined ? {} : { error: failure }) })
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const pending = h.start()
@@ -434,7 +387,7 @@ describe('Node runtime host failures', () => {
stream.write(Buffer.from([0x82, 0xac]))
stream.end(Buffer.from([0xe2]))
}
h.emit({ type: 'done' })
h.complete({})
})
const result = await h.start()
expect(result.error).toBeUndefined()
@@ -489,7 +442,7 @@ describe('Node runtime host failures', () => {
vi.stubEnv('TMP', 'fixture-tmp-second')
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
h.onBoot(() => { h.emit({ type: 'done' }) })
h.onBoot(() => { h.complete({}) })
expect((await h.start()).error).toBeUndefined()
const env = h.spawn.mock.calls[0]?.[0].env ?? {}
expect(Object.hasOwn(env, 'TEMP')).toBe(false)
@@ -501,7 +454,7 @@ describe('Node runtime host failures', () => {
it('selects the remote embedded bootstrap without leaking ambient environment', async () => {
const h = await setup({ launch: { kind: 'embedded', executable: process.execPath } })
h.onBoot(() => { h.emit({ type: 'done' }) })
h.onBoot(() => { h.complete({}) })
expect((await h.start()).error).toBeUndefined()
const spec = h.spawn.mock.calls[0]?.[0]
expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1')
@@ -516,7 +469,7 @@ it('keeps failed per-call cleanup registered until process observation succeeds'
const h = await setup()
const session = h.ctx.sessions.create(SessionId('per-call-cleanup-retry'))
h.waitForExit.mockRejectedValue(new Error('range cannot be observed'))
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) })
const result = await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
expect(result.error?.kind).toBe('worker-exit')
setSandboxMode(session, 'read-only')
@@ -541,9 +494,9 @@ it('preserves execution context for bindings delivered by an external control st
const session = h.ctx.sessions.create(SessionId('external-binding-context'))
const booted = Promise.withResolvers<undefined>()
h.receive((raw) => {
const message = raw as { type: string; ok?: boolean; value?: unknown }
if (message.type === 'boot') booted.resolve(undefined)
if (message.type === 'reply') h.emit({ type: 'done', value: message.value })
const message = raw as { t: string; m?: string; r?: unknown }
if ('m' in message && message.m === 'run') booted.resolve(undefined)
if (message.t === 's') h.complete({ value: message.r })
})
const context = new AsyncLocalStorage<string>()
const pending = context.run('binding context', () => h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }),
@@ -1,97 +1,23 @@
import { Duplex, PassThrough } from 'node:stream'
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import type { Config } from '../src/index.ts'
import { mountRuntime } from './setup.ts'
import { JsonChannel } from '../src/channel.ts'
import { fakeChild, NO_INITIAL_FRAME } from './fake-child.ts'
import { encodePtcJsonWire } from '../src/json-wire.ts'
const request: PtcRunRequest = { program: '1', bindings: [], stateOwner: {} }
const NO_INITIAL_FRAME = Symbol('no initial frame')
async function setup(config: Config = {}) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const { runtime, fiber } = await mountRuntime(ctx, { ...config, executionMode: config.executionMode ?? 'persistent-in-memory' })
const control = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (childControl.destroyed) { callback(new Error('peer closed')); return }
childControl.push(chunk)
callback()
},
})
const childControl = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
if (control.destroyed) { callback(new Error('peer closed')); return }
control.push(chunk)
callback()
},
})
const stdout = new PassThrough()
const stderr = new PassThrough()
const direct = Promise.withResolvers<SubprocessOutcome>()
const messages: unknown[] = []
const terminate = vi.fn(() => {
stdout.end()
stderr.end()
direct.resolve({ exitCode: 0, signal: null })
})
const waitForExit = vi.fn(async () => true)
const handle: SubprocessHandle = {
stdin: undefined,
stdout,
stderr,
control,
collected: {},
done: direct.promise,
terminate,
waitForExit,
}
const writes = new Set<Promise<void>>()
let receive: (message: unknown) => void = () => {}
const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
messages.push(message)
receive(message)
}, () => {})
const emit = (message: unknown): void => {
const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
writes.add(write)
}
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
onTestFinished(async () => {
peer.close()
control.destroy()
stdout.destroy()
stderr.destroy()
direct.resolve({ exitCode: 0, signal: null })
await Promise.all(writes)
})
const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => {
const result = runtime.run(runtime.resolve(input))
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
return result
}
const onBoot = (callback: () => void): void => {
receive = (message) => {
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'run') callback()
}
}
return {
ctx, fiber, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages,
spawn, resolveExecutable, emit, start, onBoot,
receive: (callback: typeof receive) => { receive = callback },
}
return { ctx, fiber, runtime, ...fakeChild(ctx, runtime, request) }
}
function frame(payload: unknown, id = 1) { return { id, payload } }
function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return frame({ type: 'call', id, global: 'tools', name: 'test', args }) }
function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: ['tools', 'test', args] } }
const bound: PtcRunRequest = { ...request, bindings: [{ global: 'tools', functions: { test: async () => 42 } }] }
it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when its Session policy changes before process launch', async (executionMode) => {
@@ -100,10 +26,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when i
const policy = h.ctx.sandboxPolicy.resolve({ session })
expect(policy.mode).toBe('danger-full-access')
h.receive((message) => {
if (typeof message !== 'object' || message === null || !('type' in message)) return
const done = { type: 'done', value: encodePtcJsonWire(1) }
if (message.type === 'run') h.emit(frame(done))
else if (message.type === 'boot') h.emit(done)
if (typeof message !== 'object' || message === null || !('t' in message)) return
const done = { value: encodePtcJsonWire(1) }
if ('m' in message && message.m === 'run') h.complete(done)
})
const pending = h.start({
@@ -126,10 +51,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps an approved wider
setSandboxMode(session, 'read-only')
const standing = h.ctx.sandboxPolicy.resolve({ session })
h.receive((message) => {
if (typeof message !== 'object' || message === null || !('type' in message)) return
const done = { type: 'done', value: encodePtcJsonWire(1) }
if (message.type === 'run') h.emit(frame(done))
else if (message.type === 'boot') h.emit(done)
if (typeof message !== 'object' || message === null || !('t' in message)) return
const done = { value: encodePtcJsonWire(1) }
if ('m' in message && message.m === 'run') h.complete(done)
})
const pending = h.start({
program: executionMode === 'per-call' ? 'return 1' : '1',
@@ -149,8 +73,8 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled
const cleaning = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<boolean>()
h.receive((message) => {
if (typeof message === 'object' && message !== null && 'type' in message
&& (message.type === 'run' || message.type === 'boot')) booted.resolve(undefined)
if (typeof message === 'object' && message !== null && 'm' in message
&& message.m === 'run') booted.resolve(undefined)
})
h.waitForExit.mockImplementation(() => { cleaning.resolve(undefined); return cleanup.promise })
const first = h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
@@ -177,7 +101,7 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled
it('rejects a retained call when its provider unloads during earlier policy cleanup', async () => {
const h = await setup()
const session = h.ctx.sessions.create(SessionId('unloading-admission'))
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
const cleaning = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<boolean>()
@@ -208,12 +132,7 @@ it('rejects a retained call when its provider unloads during earlier policy clea
describe('persistent PTC host protocol', () => {
it.each([
null,
{ id: 99, payload: {} },
frame({ type: 'other' }),
frame({ type: 'log', text: 1 }),
frame({ type: 'done', error: { kind: 'other', message: '' } }),
frame({ type: 'done', error: { kind: 'exception', message: 1 } }),
call(2),
{ t: 'q', m: 'log', a: [1] },
call(1, 'not a wire value'),
])('rejects invalid process traffic %j', async (raw) => {
const h = await setup()
@@ -223,9 +142,15 @@ describe('persistent PTC host protocol', () => {
expect(h.waitForExit).toHaveBeenCalledTimes(1)
})
it.each([{ error: { kind: 'other', message: '' } }, { error: { kind: 'exception', message: 1 } }])('rejects malformed results %j', async (raw) => {
const h = await setup()
h.onBoot(() => { h.complete(raw) })
expect((await h.start()).error?.kind).toBe('protocol')
})
it('requires the readiness handshake and all requested pipes', async () => {
const h = await setup()
expect((await h.start(request, { type: 'other' })).error?.kind).toBe('protocol')
expect((await h.start(request, null)).error?.kind).toBe('protocol')
const missing = await setup()
missing.spawn.mockReturnValue({ ...missing.handle, control: undefined })
expect((await missing.start(request, NO_INITIAL_FRAME)).error?.message).toContain('did not supply')
@@ -233,16 +158,16 @@ describe('persistent PTC host protocol', () => {
it.each(['exception', 'invalid-output', 'output-limit'])('accepts a terminal %s failure', async (kind) => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind, message: 'example' } })) })
h.onBoot(() => { h.complete({ error: { kind, message: 'example' } }) })
expect((await h.start()).error?.kind).toBe(kind)
})
it('rejects lossy terminal values and bounds complete results', async () => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done', value: 'bad' })) })
h.onBoot(() => { h.complete({ value: 'bad' }) })
expect((await h.start()).error?.kind).toBe('invalid-output')
const limited = await setup({ maxOutputBytes: 30 })
limited.onBoot(() => { limited.emit(frame({ type: 'log', text: 'x'.repeat(100) })) })
limited.onBoot(() => { limited.emit({ t: 'q', m: 'log', a: ['x'.repeat(100)] }) })
expect((await limited.start()).error?.kind).toBe('output-limit')
})
@@ -256,14 +181,14 @@ describe('persistent PTC host protocol', () => {
const h = await setup()
h.receive((raw) => {
const message = raw as Record<string, unknown>
if (message.type === 'run') h.emit(call())
else if (message.type === 'reply') h.emit(frame({ type: 'done', value: encodePtcJsonWire(message.ok === true) }))
if (message.m === 'run') h.emit(call())
else if (message.t === 's') h.complete({ value: encodePtcJsonWire(message.e === undefined) })
})
const result = await h.start({ ...request, bindings: [{ global: 'tools', functions }] })
expect(result.error).toBeUndefined()
const reply = h.messages.find(raw => (raw as { type: string }).type === 'reply') as { ok: boolean; message?: string }
expect(result.value).toBe(reply.ok)
if (!reply.ok) expect(reply.message).toMatch(/denied|unavailable/)
const reply = h.messages.find(raw => (raw as { t: string }).t === 's') as { e?: { message: string } }
expect(result.value).toBe(reply.e === undefined)
if (reply.e) expect(reply.e.message).toMatch(/denied|unavailable/)
}
})
@@ -275,7 +200,7 @@ describe('persistent PTC host protocol', () => {
expect((await h.start({ ...request, bindings: [{ global: 'tools', functions: { test: () => pending.promise } }] })).error?.kind).toBe('protocol')
} finally { pending.resolve(null) }
await setImmediate()
expect(h.messages.some(raw => (raw as { type: string }).type === 'reply')).toBe(false)
expect(h.messages.some(raw => (raw as { t: string }).t === 's')).toBe(false)
})
it('cleans up after launch failure and cancellation during executable resolution', async () => {
@@ -298,19 +223,19 @@ describe('persistent PTC host protocol', () => {
h.onBoot(() => {
h.stdout.write('stdout')
h.stderr.write('stderr')
h.emit(frame({ type: 'done' }))
h.complete({})
})
expect(await h.start()).toMatchObject({ logs: ['stdout', 'stderr'] })
h.stdout.write('idle stdout')
h.stderr.write('idle stderr')
h.onBoot(() => { h.emit(frame({ type: 'done' }, 2)) })
h.onBoot(() => { h.complete({}) })
expect(await h.start(request, NO_INITIAL_FRAME)).toMatchObject({ logs: [] })
expect(h.terminate).not.toHaveBeenCalled()
})
it('clears retained resources when final diagnostic accounting detects an overflow', async () => {
const h = await setup({ maxOutputBytes: 100 })
h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind: 'exception', message: 'x'.repeat(1000) } })) })
h.onBoot(() => { h.complete({ error: { kind: 'exception', message: 'x'.repeat(1000) } }) })
const result = await h.start()
expect(result.error?.kind).toBe('output-limit')
expect(result.error?.message).toContain('retained variables were cleared')
@@ -320,7 +245,7 @@ describe('persistent PTC host protocol', () => {
it('keeps the first outcome when raw output overflows beside a terminal frame', async () => {
const h = await setup({ maxOutputBytes: 100 })
h.onBoot(() => {
h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) }))
h.complete({ value: encodePtcJsonWire(42) })
queueMicrotask(() => { h.stdout.write('x'.repeat(1000)) })
})
expect((await h.start()).error?.kind).toBe('output-limit')
@@ -342,6 +267,31 @@ describe('persistent PTC host protocol', () => {
expect(result.error?.message).toContain('fatal output')
})
it('does not send a late binding result into the next evaluation', async () => {
const h = await setup({ maxMessageBytes: 1024 })
const release = Promise.withResolvers<string>()
onTestFinished(() => { release.resolve('') })
const slow = { ...request, bindings: [{ global: 'tools', functions: { test: () => release.promise } }] }
h.onBoot(() => { h.emit(call()); h.complete({}) })
expect((await h.start(slow)).error).toBeUndefined()
const booted = Promise.withResolvers<undefined>()
h.onBoot(() => { booted.resolve(undefined) })
const next = h.start(slow, NO_INITIAL_FRAME)
await booted.promise
release.resolve('x'.repeat(2000))
await setImmediate()
h.complete({ value: encodePtcJsonWire(2) })
expect(await next).toMatchObject({ value: 2 })
expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'PTC evaluation ended before the binding completed' } })
})
it('terminates the process when the control pipe fails during an evaluation', async () => {
const h = await setup()
h.onBoot(() => { h.control.emit('error', new Error('control transport failed')) })
expect((await h.start()).error?.kind).toBe('worker-exit')
expect(h.terminate).toHaveBeenCalled()
})
it('terminates when a binding result exceeds the control write budget', async () => {
const h = await setup({ maxMessageBytes: 1024 })
h.onBoot(() => { h.emit(call()) })
@@ -353,7 +303,7 @@ describe('persistent PTC host protocol', () => {
it.each(['exit', 'abort', 'cleanup'] as const)('does not dispatch to an exited idle process during %s handling', async (outcome) => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
h.onBoot(() => { h.stdout.end(); h.stderr.end() })
h.direct.resolve({ exitCode: 9, signal: null })
@@ -364,7 +314,8 @@ describe('persistent PTC host protocol', () => {
const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal }))
expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit')
expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)')
expect(h.messages).toEqual([expect.objectContaining({ type: 'run', id: 1 })])
expect(h.messages).toHaveLength(1)
expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' })
expect(h.spawn).toHaveBeenCalledTimes(1)
expect(h.terminate).toHaveBeenCalledTimes(1)
expect(h.waitForExit).toHaveBeenCalledTimes(1)
@@ -387,7 +338,7 @@ describe('persistent PTC host protocol', () => {
it('reports failed managed cleanup and waits for both exit and pipe drain', async () => {
const h = await setup()
h.waitForExit.mockRejectedValue(new Error('cleanup failed'))
h.onBoot(() => { h.emit(frame({ type: 'output-limit' })) })
h.onBoot(() => { h.emit({ t: 'q', m: 'outputLimit', a: [] }) })
expect((await h.start()).error?.message).toContain('cleanup failed')
})
@@ -396,7 +347,7 @@ describe('persistent PTC host protocol', () => {
{ maxOutputBytes: 4, kind: 'output-limit' },
])('bounds cleanup failure after parse diagnostics overflow a $maxOutputBytes-byte budget', async ({ maxOutputBytes, kind }) => {
const h = await setup({ maxOutputBytes })
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
expect(await h.start()).toMatchObject({ logs: [] })
h.waitForExit.mockRejectedValue(new Error('x'))
const result = await h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' }))
@@ -415,7 +366,7 @@ describe('persistent PTC host protocol', () => {
await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved inputs')
await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: Infinity })).rejects.toThrow('resolved inputs')
for (const config of [
{ timeoutMs: 0 }, { maxMessageBytes: 0x1_0000_0000 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 },
{ timeoutMs: 0 }, { maxMessageBytes: 1.5 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 },
{ launch: { kind: 'node-script', executable: '' } },
{ launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: 'relative' } },
] satisfies Config[]) {
@@ -425,7 +376,7 @@ describe('persistent PTC host protocol', () => {
it('waits for another environment to stop before reporting a failed cleanup', async () => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
const release: PromiseWithResolvers<string> = Promise.withResolvers()
@@ -457,15 +408,19 @@ describe('persistent PTC host protocol', () => {
it('rejects corrupt framed traffic before it reaches the evaluator', async () => {
const h = await setup()
h.onBoot(() => { h.control.emit('data', Buffer.from([0, 0, 0, 1, 255])) })
h.onBoot(() => { h.control.emit('data', Buffer.from([255, 10])) })
expect((await h.start()).error?.kind).toBe('protocol')
})
it('discards unsolicited idle traffic and awaits the retained process exit', async () => {
it.each([
{ t: 'q', m: 'log', a: ['late'] },
{ t: 'q', m: 'outputLimit', a: [] },
call(),
])('discards unsolicited idle traffic %j and awaits the retained process exit', async (raw) => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
expect((await h.start()).error).toBeUndefined()
h.emit(frame({ type: 'log', text: 'late' }))
h.emit(raw)
await h.direct.promise
expect(h.terminate).toHaveBeenCalledTimes(1)
})
@@ -475,7 +430,7 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked
const cleanup = Promise.withResolvers<true>()
const stopped = Promise.withResolvers<boolean>()
h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise })
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
let closing: Promise<void> | undefined
try {
await h.start()
@@ -496,11 +451,41 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked
} finally { stopped.resolve(true); await closing }
})
it('logs unexpected idle readiness and its failed cleanup', async () => {
const h = await setup()
const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {})
try {
h.onBoot(() => { h.complete({}) })
await h.start()
h.waitForExit.mockRejectedValueOnce(new Error('idle cleanup failed'))
h.emit({ t: 'q', m: 'ready', a: [] })
await vi.waitFor(() => { expect(reported).toHaveBeenCalledTimes(2) })
expect(reported.mock.calls).toEqual([['unexpected persistent PTC readiness'], ['idle cleanup failed']])
} finally { reported.mockRestore() }
})
it('does not log a launch failure after cancellation closed the environment', async () => {
const h = await setup()
const entered = Promise.withResolvers<undefined>()
const lookup = Promise.withResolvers<string>()
h.resolveExecutable.mockImplementation(() => { entered.resolve(undefined); return lookup.promise })
const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {})
try {
const controller = new AbortController()
const result = h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)
await entered.promise
controller.abort()
lookup.reject(new Error('lookup aborted'))
expect((await result).error?.kind).toBe('abort')
expect(reported).not.toHaveBeenCalled()
} finally { reported.mockRestore() }
})
it('logs an idle process failure with its bounded stderr tail', async () => {
const h = await setup({ maxOutputBytes: 100 })
const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {})
try {
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
h.stdout.end()
h.stderr.end('x'.repeat(200) + 'idle fatal diagnostic')
@@ -513,7 +498,7 @@ it('logs an idle process failure with its bounded stderr tail', async () => {
it('retries process observation before releasing an owner after cleanup fails', async () => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
const failure = new Error('release cleanup failed')
h.waitForExit.mockRejectedValue(failure)
@@ -533,7 +518,7 @@ it('reserves the owner while parse-output overflow releases its environment', as
const h = await setup({ maxOutputBytes: 32 })
const cleanup = Promise.withResolvers<true>()
const stopped = Promise.withResolvers<boolean>()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise })
const pending = h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' }))
@@ -582,7 +567,7 @@ it('reports a sandbox runner refusal before the REPL is ready', async () => {
it('releases a confirmed exited process even when its output pipe did not drain', async () => {
const h = await setup()
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
h.onBoot(() => { h.complete({}) })
await h.start()
h.stdout.destroy()
const warn = vi.spyOn(h.ctx.logger, 'warn').mockImplementation(() => {})
@@ -596,8 +581,8 @@ it('releases a confirmed exited process even when its output pipe did not drain'
it('preserves logs in a result-level failure when temporary process pipes do not drain', async () => {
const h = await setup()
h.onBoot(() => {
h.emit(frame({ type: 'log', text: 'captured before cleanup' }))
h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) }))
h.emit({ t: 'q', m: 'log', a: ['captured before cleanup'] })
h.complete({ value: encodePtcJsonWire(42) })
h.stdout.destroy()
})
const result = await h.start({ program: '1', bindings: [] })
@@ -1,9 +1,9 @@
import { Duplex, PassThrough } from 'node:stream'
import { expect, it, onTestFinished } from 'vitest'
import { JsonChannel } from '../src/channel.ts'
import { createProcessRpc } from '../src/rpc.ts'
import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
import type { ProgramBootData } from '../src/protocol.ts'
import type { ProgramBootData, HostMethods, ProgramMethods } from '../src/protocol.ts'
async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) {
const first = new PassThrough()
@@ -15,31 +15,29 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) {
const state: PersistentProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir }
const nativeEnvironment = state.env
const ready = Promise.withResolvers<true>()
const pending = new Map<number, ReturnType<typeof Promise.withResolvers<Record<string, unknown>>>>()
const seen: Record<string, unknown>[] = []
let sequence = 0
let onCall: (id: number, payload: Record<string, unknown>) => void = (_id, payload) => {
void peer.send({ type: 'reply', id: payload.id, ok: true, value: encodePtcJsonWire(42) })
const methods: HostMethods = {
ready: () => { ready.resolve(true) },
invoke: async (global, name, args) => {
seen.push({ type: 'call', global, name, args })
return encodePtcJsonWire(42)
},
log: (text) => { seen.push({ type: 'log', text }) },
outputLimit: () => { seen.push({ type: 'output-limit' }) },
}
const peer = new JsonChannel(host, limit, (raw) => {
const message = raw as { type?: string; id: number; payload: Record<string, unknown> }
if (message.type === 'ready') { ready.resolve(true); return }
seen.push(message.payload)
if (message.payload.type === 'call') onCall(message.id, message.payload)
if (message.payload.type === 'done') pending.get(message.id)?.resolve(message.payload)
}, () => {})
const peer = createProcessRpc<ProgramMethods, HostMethods>(host, limit, methods, [], () => {})
const main = runPersistentMain(child, limit, state)
const close = async () => { peer.close(); host.destroy(); child.destroy(); await main }
const close = async () => { peer.$close(); host.destroy(); child.destroy(); await main }
onTestFinished(close)
await ready.promise
const run = (code: string, namespaces: ProgramBootData['namespaces'] = [], maxOutputBytes = 1024) => {
const id = ++sequence
const result = Promise.withResolvers<Record<string, unknown>>()
pending.set(id, result)
void peer.send({ type: 'run', id, cwd: '/', data: { code, namespaces, maxOutputBytes } })
return result.promise
return peer.run('/', { code, namespaces, maxOutputBytes }).then((result) => {
seen.push({ type: 'done', ...result })
return result
})
}
return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: typeof onCall) => { onCall = handler } }
return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: HostMethods['invoke']) => { methods.invoke = handler } }
}
it('keeps an injected process local to the REPL while it runs and after it closes', async () => {
@@ -60,7 +58,7 @@ it('keeps an injected process local to the REPL while it runs and after it close
it('keeps an environment while capturing each evaluation and binding replies separately', async () => {
const h = await setup()
const declarations = [{ global: 'tools', names: ['echo'], errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }]
expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({ type: 'done' })
expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({})
const result = await h.run('count++; process.stdout.write("raw"); console.log("value", count); await saved({})', declarations)
expect(decodePtcJsonWire(result.value)).toBe(42)
expect(h.seen.filter(item => item.type === 'log').map(item => item.text)).toEqual(['raw', 'value 2'])
@@ -107,16 +105,9 @@ it('contains directory failures and output overflow in the active evaluation', a
expect(h.seen.some(item => item.type === 'output-limit')).toBe(true)
})
it('refuses overlapping host evaluations and settles transport shutdown', async () => {
const h = await setup()
h.setCall(() => { void h.peer.send({ type: 'run', id: 2, cwd: '/', data: { code: '1', namespaces: [], maxOutputBytes: 1000 } }) })
void h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }])
await h.main
})
it('closes the process channel when a terminal result cannot fit its write budget', async () => {
const h = await setup(1024)
void h.run('"x".repeat(2000)', [], 8000)
await expect(h.run('"x".repeat(2000)', [], 8000)).rejects.toThrow()
await h.main
})
@@ -128,17 +119,19 @@ it('ignores output and rejects tool calls from a completed evaluation', async ()
expect(decodePtcJsonWire(result.value)).toContain('evaluation has ended')
expect(h.seen.some(item => item.type === 'log' || item.type === 'call')).toBe(false)
expect(decodePtcJsonWire((await h.run('await tools.echo({})', declarations)).value)).toBe(42)
expect(h.seen.find(item => item.type === 'call')?.id).toBe(1)
expect(h.seen.filter(item => item.type === 'call')).toHaveLength(1)
})
it('rejects unfinished binding waits when a program returns', async () => {
const h = await setup()
h.setCall(() => {})
const pending = Promise.withResolvers<ReturnType<typeof encodePtcJsonWire>>()
onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) })
h.setCall(() => pending.promise)
await h.run('let rejected; void tools.echo({}).catch(error => { rejected = error.message });', [{ global: 'tools', names: ['echo'] }])
expect(decodePtcJsonWire((await h.run('rejected')).value)).toContain('ended before the binding completed')
})
it.each([0, 1.5, 4294967296])('rejects an invalid control limit %s', async (limit) => {
it.each([0, 1.5])('rejects an invalid control limit %s', async (limit) => {
const stream = new PassThrough()
try {
await expect(runPersistentMain(stream, limit, { env: {}, stdout: { write: () => true }, stderr: { write: () => true }, chdir: () => {} })).rejects.toThrow('invalid control message limit')
@@ -49,6 +49,14 @@ describe('persistent Node REPL', () => {
expect(spawn).not.toHaveBeenCalled()
})
it('keeps the environment when an evaluation ends with an unawaited binding call', async () => {
const { run } = await setup()
const release = Promise.withResolvers<null>()
onTestFinished(() => { release.resolve(null) })
expect((await run('var kept = 1; void tools.value({}); kept', { bindings: bindings(() => release.promise) })).value).toBe(1)
expect((await run('kept')).value).toBe(1)
})
it('retains declarations, closures, object identity, and imports across evaluations', async () => {
const { run, cwd } = await setup()
expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toMatchObject({ logs: [] })
@@ -1,9 +1,9 @@
import { Duplex, PassThrough } from 'node:stream'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { expect, it, onTestFinished } from 'vitest'
import { JsonChannel } from '../src/channel.ts'
import { runNodeMain } from '../src/process.ts'
import type { ProgramProcess } from '../src/process.ts'
import { expect, it, onTestFinished, vi } from 'vitest'
import { createProcessRpc } from '../src/rpc.ts'
import { runNodeMain, type ProgramProcess } from '../src/process.ts'
import type { HostMethods, ProgramMethods } from '../src/protocol.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
function endpoints() {
@@ -18,126 +18,132 @@ function endpoints() {
}
function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } }
it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => {
async function setup(limit = 4096) {
const { child, host } = endpoints()
const state = processState()
const nativeEnvironment = state.env
nativeEnvironment.SystemRoot = 'C:\\Windows'
nativeEnvironment.PATH = '/native/bin'
nativeEnvironment.TMP = 'C:\\sandbox-temp'
nativeEnvironment.TEMP = 'C:\\sandbox-temp'
const messages: Record<string, unknown>[] = []
const peer = new JsonChannel(host, 4096, (raw) => {
const message = raw as Record<string, unknown>
messages.push(message)
if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } })
if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) })
if (message.type === 'done') host.end()
}, () => {})
onTestFinished(() => { peer.close() })
await runNodeMain(child, 4096, state)
expect(state.env).toEqual({})
expect(state.env).not.toBe(nativeEnvironment)
expect(Object.getPrototypeOf(state.env)).toBeNull()
expect(nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' })
expect(state.exitCode).toBeUndefined()
expect(decodePtcJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42)
})
it('keeps the control pipe open for a late binding reply until the host closes it', async () => {
const { child, host } = endpoints()
const state = processState()
const terminal = Promise.withResolvers<unknown>()
const messages: string[] = []
let callId: unknown
const peer = new JsonChannel(host, 4096, (raw) => {
const message = raw as Record<string, unknown>
messages.push(String(message.type))
if (message.type === 'ready') void peer.send({ type: 'boot', data: {
code: 'void tools.echo({}).then(() => { throw new Error("late reply resumed the program") }); setImmediate(() => console.log("late timer")); return 42;',
namespaces: [{ global: 'tools', names: ['echo'] }],
maxOutputBytes: 1024,
} })
if (message.type === 'call') callId = message.id
if (message.type === 'done') terminal.resolve(decodePtcJsonWire(message.value))
}, (error) => { terminal.reject(error) })
const main = runNodeMain(child, 4096, state)
try {
expect(await terminal.promise).toBe(42)
// Settle the terminal write callbacks while the host still owns the open pipe.
await nextTurn()
expect(child.destroyed).toBe(false)
await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) })
await nextTurn()
expect(messages).toEqual(['ready', 'call', 'done'])
} finally {
peer.close()
await main
Object.assign(nativeEnvironment, { SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' })
const ready = Promise.withResolvers<undefined>()
const logs: string[] = []
const methods: HostMethods = {
ready: () => { ready.resolve(undefined) },
invoke: async () => encodePtcJsonWire(42),
log: (text) => { logs.push(text) },
outputLimit: () => {},
}
expect(state.exitCode).toBeUndefined()
const peer = createProcessRpc<ProgramMethods, HostMethods>(host, limit, methods, [], () => {})
const main = runNodeMain(child, limit, state)
onTestFinished(async () => { peer.$close(); child.destroy(); await main })
await ready.promise
const run = (code: string, maxOutputBytes = 1024) => peer.run('/', {
code, namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes,
})
return { child, host, state, nativeEnvironment, methods, peer, main, logs, run }
}
it('clears the environment and returns a binding result after captured output', async () => {
const h = await setup()
const result = await h.run('console.log("ready"); return await tools.echo({});')
expect(decodePtcJsonWire(result.value)).toBe(42)
expect(h.logs).toEqual(['ready'])
expect(h.state.env).toEqual({})
expect(h.state.env).not.toBe(h.nativeEnvironment)
expect(Object.getPrototypeOf(h.state.env)).toBeNull()
expect(h.nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' })
h.peer.$close()
await h.main
expect(h.state.exitCode).toBeUndefined()
})
it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => {
it('keeps unfinished calls pending and suppresses later logs while awaiting host shutdown', async () => {
const h = await setup()
const release = Promise.withResolvers<ReturnType<typeof encodePtcJsonWire>>()
h.methods.invoke = () => release.promise
try {
expect(decodePtcJsonWire((await h.run('void tools.echo({}); void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42)
await nextTurn()
expect(h.child.destroyed).toBe(false)
release.resolve(encodePtcJsonWire(42))
await nextTurn()
expect(h.logs).toEqual([])
} finally { release.resolve(encodePtcJsonWire(null)) }
})
it('rejects a binding call that the host fails during the run', async () => {
const h = await setup()
h.methods.invoke = async () => { throw new Error('denied by host') }
const result = await h.run('try { await tools.echo({}) } catch (error) { return error.message }')
expect(decodePtcJsonWire(result.value)).toBe('denied by host')
})
it('keeps an unawaited call pending when the host closes after the result', async () => {
const h = await setup()
const pending = Promise.withResolvers<ReturnType<typeof encodePtcJsonWire>>()
onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) })
h.methods.invoke = () => pending.promise
expect(decodePtcJsonWire((await h.run('void tools.echo({}); return 42')).value)).toBe(42)
h.peer.$close()
await h.main
await nextTurn()
expect(h.state.exitCode).toBeUndefined()
})
it('reports exhausted output accounting during the run', async () => {
const h = await setup()
const limited = vi.fn()
h.methods.outputLimit = limited
expect((await h.run('console.log("x".repeat(100)); return 1', 64)).error?.kind).toBe('output-limit')
await vi.waitFor(() => { expect(limited).toHaveBeenCalledOnce() })
})
it('sends no binding calls or output notifications after returning its result', async () => {
const h = await setup()
const invoked = vi.fn(async () => encodePtcJsonWire(1))
const limited = vi.fn()
h.methods.invoke = invoked
h.methods.outputLimit = limited
const result = await h.run('setImmediate(() => { void tools.echo({}).catch(() => {}); console.log("x".repeat(2000)) }); return 1', 64)
expect(decodePtcJsonWire(result.value)).toBe(1)
await nextTurn()
await nextTurn()
expect(invoked).not.toHaveBeenCalled()
expect(limited).not.toHaveBeenCalled()
expect(h.logs).toEqual([])
})
it.each([0, -1, 1.5])('rejects an invalid control message limit %s', async (limit) => {
const { child } = endpoints()
await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit')
})
it('rejects an unexpected first control frame', async () => {
const { child, host } = endpoints()
const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {})
onTestFinished(() => { peer.close() })
await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot')
it('records malformed host traffic before evaluation as failure', async () => {
const h = await setup()
h.host.write(Buffer.from('null\n'))
await h.main
expect(h.state.exitCode).toBe(1)
})
it('records an I/O failure before the boot frame', async () => {
const { child, host } = endpoints()
const state = processState()
const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {})
await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error)
expect(state.exitCode).toBe(1)
it('records I/O failure before evaluation', async () => {
const h = await setup()
h.peer.$close()
await h.main
expect(h.state.exitCode).toBe(1)
})
it('contains program writes that exceed queued control output', async () => {
const { child, host } = endpoints()
const state = processState()
const peer = new JsonChannel(host, 1024, (raw) => {
if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } })
}, () => {})
onTestFinished(() => { peer.close() })
await runNodeMain(child, 1024, state)
expect(state.exitCode).toBe(1)
it.each([
'console.log("x".repeat(2000));',
'return "x".repeat(2000)',
])('closes on control output overflow: %s', async (code) => {
const h = await setup(1024)
await expect(h.run(code, 8000)).rejects.toThrow()
await h.main
expect(h.state.exitCode).toBe(1)
})
it('records a terminal frame that cannot fit the control write budget as failure', async () => {
const { child, host } = endpoints()
const state = processState()
const peer = new JsonChannel(host, 1024, (raw) => {
if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: {
code: 'return "x".repeat(2000)', namespaces: [], maxOutputBytes: 8000,
} })
}, () => {})
onTestFinished(() => { peer.close() })
await runNodeMain(child, 1024, state)
expect(state.exitCode).toBe(1)
})
it('retains a malformed host frame failure after the terminal frame', async () => {
const { child, host } = endpoints()
const state = processState()
const terminal = Promise.withResolvers<undefined>()
const peer = new JsonChannel(host, 1024, (raw) => {
const message = raw as { type: string }
if (message.type === 'ready') void peer.send({ type: 'boot', data: {
code: 'return 42', namespaces: [], maxOutputBytes: 1000,
} })
if (message.type === 'done') terminal.resolve(undefined)
}, () => {})
onTestFinished(() => { peer.close() })
const main = runNodeMain(child, 1024, state)
try {
await terminal.promise
host.write(Buffer.from([0, 0, 0, 1, 0xff]))
await main
expect(state.exitCode).toBe(1)
} finally { peer.close(); await main }
it('records malformed host traffic after completion', async () => {
const h = await setup()
await h.run('return 42')
h.host.write(Buffer.from([0xff, 10]))
await h.main
expect(h.state.exitCode).toBe(1)
})
@@ -1,19 +1,21 @@
import { spawn } from 'node:child_process'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { copyFile, cp, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Duplex } from 'node:stream'
import { expect, it, onTestFinished } from 'vitest'
import { JsonChannel } from '../src/channel.ts'
import { createProcessRpc } from '../src/rpc.ts'
import type { HostMethods, ProgramMethods } from '../src/protocol.ts'
import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts'
it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-'))
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) {
for (const file of ['process.ts', 'bootstrap.ts', 'rpc.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) {
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
}
await cp(dirname(dirname(fileURLToPath(import.meta.resolve('birpc')))), join(directory, 'node_modules/birpc'), { recursive: true })
const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);`
const child = spawn(process.execPath, ['--input-type=module', '--eval', source], {
env: { PLACEHOLDER_SECRET: 'fixture-only' },
@@ -27,24 +29,26 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep
child.once('exit', (code) => { if (code !== 0) completed.reject(new Error(`child exit ${code}: ${stderr}`)) })
const control = Array.from(child.stdio)[7]
if (!(control instanceof Duplex)) { child.kill(); await finished; throw new Error('missing child control channel') }
const channel = new JsonChannel(control, 100_000, (raw) => {
const message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown }
if (message.type === 'ready') {
void channel.send({ type: 'boot', data: {
const channel = createProcessRpc<ProgramMethods, HostMethods>(control, 100_000, {
ready() {
void channel.run(directory, {
code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }',
namespaces: [{ global: 'tools', names: ['echo'] }],
maxOutputBytes: 10_000,
} }).catch((error: unknown) => { completed.reject(error) })
} else if (message.type === 'call') {
expect(decodePtcJsonWire(message.args)).toEqual({ n: 21 })
void channel.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) }).catch((error: unknown) => { completed.reject(error) })
} else if (message.type === 'done') {
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
else completed.resolve(decodePtcJsonWire(message.value))
}
}, (error) => { completed.reject(error) })
onTestFinished(async () => { channel.close(); child.kill(); await finished })
}).then((result) => {
if (result.error) completed.reject(new Error(result.error.message))
else completed.resolve(decodePtcJsonWire(result.value))
}, (error: unknown) => { completed.reject(error) })
},
async invoke(_global, _name, args) {
expect(decodePtcJsonWire(args)).toEqual({ n: 21 })
return encodePtcJsonWire(42)
},
log() {},
outputLimit() {},
}, [], (error) => { completed.reject(error) })
onTestFinished(async () => { channel.$close(); child.kill(); await finished })
expect(await completed.promise).toEqual({ answer: 42, env: {} })
channel.close()
channel.$close()
await finished
})
@@ -0,0 +1,22 @@
/** Raw newline JSON peer for testing hostile RPC traffic without birpc validation. */
import { createInterface } from 'node:readline'
import type { Duplex } from 'node:stream'
/**
* Observe and forge JSON messages over a test-owned stream.
* @param stream - Test endpoint.
* @param receive - Raw JSON message observer.
* @returns Write receipts and endpoint cleanup.
*/
export function rawPeer(stream: Duplex, receive: (message: unknown) => void) {
const lines = createInterface({ input: stream, crlfDelay: Infinity })
lines.on('line', (line) => { receive(JSON.parse(line)) })
// The endpoint under test can destroy the shared stream pair; it reports that failure itself.
lines.on('error', (error: Error) => { void error })
return {
send: (message: unknown): Promise<void> => new Promise((resolve, reject) => {
stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() })
}),
close: (): void => { lines.close(); stream.destroy() },
}
}
@@ -0,0 +1,183 @@
import { Duplex, PassThrough } from 'node:stream'
import { expect, it, onTestFinished, vi } from 'vitest'
import { createProcessRpc } from '../src/rpc.ts'
import { rawPeer } from './raw-peer.ts'
function endpoints() {
const inbound = new PassThrough()
const outbound = new PassThrough()
const local = Duplex.from({ readable: inbound, writable: outbound })
const remote = Duplex.from({ readable: outbound, writable: inbound })
local.on('error', () => {})
remote.on('error', () => {})
onTestFinished(() => { local.destroy(); remote.destroy() })
return { local, remote }
}
it('matches concurrent bidirectional results and preserves remote rejection messages', async () => {
const { local, remote } = endpoints()
const release = Promise.withResolvers<string>()
const entered = Promise.withResolvers<undefined>()
const failures = vi.fn()
const left = createProcessRpc<{ slow(): Promise<string>; fail(): never }, { double(value: number): number }>(local, 4096,
{ double: value => value * 2 }, [], failures)
const right = createProcessRpc<{ double(value: number): number }, { slow(): Promise<string>; fail(): never }>(remote, 4096, {
slow: () => { entered.resolve(undefined); return release.promise },
fail: () => { throw new Error('binding denied') },
}, [], failures)
onTestFinished(() => { release.resolve('cleanup'); left.$close(); right.$close() })
const pending = left.slow()
await entered.promise
expect(await right.double(21)).toBe(42)
await expect(left.fail()).rejects.toThrow('binding denied')
release.resolve('completed')
expect(await pending).toBe('completed')
expect(failures).not.toHaveBeenCalled()
})
it('delivers a non-Error rejection as its text', async () => {
const thrown: unknown = 'denied'
const { local, remote } = endpoints()
const left = createProcessRpc<{ fail(): never }, Record<string, never>>(local, 4096, {}, [], () => {})
const right = createProcessRpc<Record<string, never>, { fail(): never }>(remote, 4096, { fail: () => { throw thrown } }, [], () => {})
onTestFinished(() => { left.$close(); right.$close() })
await expect(left.fail()).rejects.toThrow(String(thrown))
})
it('does not invoke a method received beside traffic that closed the endpoint', async () => {
const { local, remote } = endpoints()
const failure = Promise.withResolvers<string>()
const allowed = vi.fn()
const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) })
onTestFinished(() => { endpoint.$close() })
remote.write(`{\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`)
expect(await failure.promise).toBe('protocol')
await new Promise(resolve => setImmediate(resolve))
expect(allowed).not.toHaveBeenCalled()
})
it('lets execution own the deadline and rejects outstanding calls when the transport closes', async () => {
const { local, remote } = endpoints()
const received = Promise.withResolvers<undefined>()
const raw = rawPeer(remote, () => { received.resolve(undefined) })
const endpoint = createProcessRpc<{ wait(): Promise<void> }, Record<string, never>>(local, 4096, {}, [], () => {})
onTestFinished(() => { endpoint.$close(); raw.close() })
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const pending = endpoint.wait()
const rejected = expect(pending).rejects.toThrow('rpc is closed')
await received.promise
const settled = vi.fn()
void pending.then(settled, settled)
await vi.advanceTimersByTimeAsync(120_000)
expect(settled).not.toHaveBeenCalled()
endpoint.$close()
await rejected
await expect(endpoint.wait()).rejects.toThrow('rpc is closed')
} finally { vi.useRealTimers() }
})
it('sends notifications without allocating response identifiers', async () => {
const { local, remote } = endpoints()
const received = Promise.withResolvers<unknown>()
const raw = rawPeer(remote, (message) => { received.resolve(message) })
const endpoint = createProcessRpc<{ log(text: string): void }, Record<string, never>>(local, 4096, {}, ['log'], () => {})
onTestFinished(() => { endpoint.$close(); raw.close() })
await endpoint.log('visible')
expect(await received.promise).toEqual({ t: 'q', m: 'log', a: ['visible'] })
})
it('ignores a response for another call and returns the matching result', async () => {
const { local, remote } = endpoints()
const endpoint = createProcessRpc<{ value(): string }, Record<string, never>>(local, 4096, {}, [], () => {})
const raw = rawPeer(remote, (request) => {
const { i } = request as { i: string }
void raw.send({ t: 's', i: 'unrelated', r: 'wrong' })
void raw.send({ t: 's', i, r: 'right' })
})
onTestFinished(() => { endpoint.$close(); raw.close() })
expect(await endpoint.value()).toBe('right')
})
it('answers calls to methods it does not own and ignores unknown messages', async () => {
const { local, remote } = endpoints()
const failures = vi.fn()
const allowed = vi.fn()
const replies: unknown[] = []
const answered = Promise.withResolvers<undefined>()
const endpoint = createProcessRpc(local, 4096, { allowed }, [], failures)
const raw = rawPeer(remote, (message) => {
replies.push(message)
if (replies.length === 2) answered.resolve(undefined)
})
onTestFinished(() => { endpoint.$close(); raw.close() })
for (const message of [{ t: 'unknown' }, { t: 's', i: 'stray', e: 'unstructured' }, { t: 'q', i: 'a', m: 'constructor', a: [] }, { t: 'q', i: 'b', m: 'toString', a: [] }]) {
await raw.send(message)
}
await answered.promise
expect(replies).toEqual(['constructor', 'toString'].map((name, index) => ({ t: 's', i: 'ab'[index], e: { message: `[birpc] function "${name}" not found` } })))
expect(allowed).not.toHaveBeenCalled()
expect(failures).not.toHaveBeenCalled()
expect(endpoint.$closed).toBe(false)
})
it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => {
const { local, remote } = endpoints()
const received: string[] = []
const complete = Promise.withResolvers<undefined>()
const endpoint = createProcessRpc(local, 1024, { log(text: string) {
received.push(text)
if (received.length === 2) complete.resolve(undefined)
} }, [], (error) => { complete.reject(error) })
onTestFinished(() => { endpoint.$close() })
const bytes = Buffer.from(['你好🙂\ninside', 'next'].map(text => JSON.stringify({ t: 'q', m: 'log', a: [text] }) + '\n').join(''))
for (const byte of bytes) remote.write(Buffer.from([byte]))
await complete.promise
expect(received).toEqual(['你好🙂\ninside', 'next'])
})
it.each([
[Buffer.from('{\n'), false],
[Buffer.from('null\n'), false],
[Buffer.from([0xff, 10]), false],
[Buffer.from('{'), true],
[Buffer.from([0xe2]), true],
[Buffer.from(`{${'x'.repeat(64)}`), false],
[Buffer.from([0, 0, 0, 2, 123, 125]), false],
])('rejects invalid or oversized JSON lines before dispatch (%j, EOF %s)', async (bytes, eof) => {
const { local, remote } = endpoints()
const failed = Promise.withResolvers<string>()
const endpoint = createProcessRpc(local, 64, {}, [], (_error, kind) => { failed.resolve(kind) })
onTestFinished(() => { endpoint.$close() })
if (eof) remote.end(bytes)
else remote.write(bytes)
expect(await failed.promise).toBe('protocol')
})
it('bounds the native writable queue and rejects all outstanding calls on overflow', async () => {
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} })
const failed = Promise.withResolvers<Error>()
const endpoint = createProcessRpc<{ invoke(value: string): Promise<string> }, Record<string, never>>(stream, 256, {}, [], (error) => {
failed.resolve(error)
})
onTestFinished(() => { endpoint.$close() })
const calls = Array.from({ length: 4 }, () => endpoint.invoke('x'.repeat(64)))
const results = Promise.allSettled(calls)
expect((await failed.promise).message).toContain('queued bytes')
expect((await results).every(result => result.status === 'rejected')).toBe(true)
})
it.each(['error', 'close'] as const)('rejects a pending call on transport %s', async (event) => {
const entered = Promise.withResolvers<undefined>()
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } })
const failed = Promise.withResolvers<string>()
const endpoint = createProcessRpc<{ wait(): Promise<void> }, Record<string, never>>(stream, 256, {}, [], (_error, kind) => {
failed.resolve(kind)
})
onTestFinished(() => { endpoint.$close() })
const rejected = expect(endpoint.wait()).rejects.toThrow('closed')
await entered.promise
stream.emit(event, new Error('transport failed'))
expect(await failed.promise).toBe('io')
await rejected
})
@@ -103,6 +103,15 @@ describe('Node program process', () => {
expect(result.value).toEqual({ value: { n: 42 }, rejected: [true, 'ToolCallError', 'fail', 'denied'] })
})
it('returns cleanly while an unawaited binding call is still running', async () => {
const { run } = await setup()
const release = Promise.withResolvers<null>()
onTestFinished(() => { release.resolve(null) })
const result = await run({ program: 'void tools.slow({}); return 1', bindings: bindings({ slow: () => release.promise }) })
expect(result).toMatchObject({ logs: [], value: 1 })
expect(result.error).toBeUndefined()
})
it('retains raw native stdout and stderr separately from control frames', async () => {
const { run } = await setup()
const result = await run({ program: 'const fs = await import("node:fs"); fs.writeSync(1,"native-out你好"); fs.writeSync(2,"native-err🙂"); return 42;', bindings: [] })
@@ -235,10 +244,10 @@ describe('Node program process', () => {
})
it.each([
'const fs = await import("node:fs"); const b=Buffer.alloc(4); b.writeUInt32BE(4294967295); fs.writeSync(7,b); await new Promise(()=>{});',
'const fs = await import("node:fs"); const body=Buffer.from(JSON.stringify({type:"call",id:1,global:"tools",name:"undeclared",args:[]})); const h=Buffer.alloc(4); h.writeUInt32BE(body.length); fs.writeSync(7,Buffer.concat([h,body])); await new Promise(()=>{});',
'const fs = await import("node:fs"); fs.writeSync(7,"x".repeat(2048)); await new Promise(()=>{});',
'const fs = await import("node:fs"); fs.writeSync(7,JSON.stringify({t:"q",i:"forged",m:"invoke",a:[1,"tools","undeclared",[]]})+"\\n"); await new Promise(()=>{});',
])('refuses hostile program control traffic', async (program) => {
const { run } = await setup()
const { run } = await setup({ maxMessageBytes: 1024 })
expect((await run({ program, bindings: [] })).error?.kind).toBe('protocol')
})
@@ -3,6 +3,6 @@ import { defineConfig } from 'tsdown'
/** The Node provider and its private process bootstraps are published bundles. */
export default defineConfig([
{ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false },
{ entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] },
{ entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] },
{ entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] },
{ entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] },
])
+2 -2
View File
@@ -6,8 +6,8 @@ import type { Readable, Writable } from 'node:stream'
import { z } from 'zod'
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Wire version shared by the installed helper and client package. */
export const SSH_PROTOCOL_VERSION = 2
/** Wire version shared by the client package, the installed helper and its embedded PTC worker. */
export const SSH_PROTOCOL_VERSION = 3
/** Maximum prepared or running process handles owned by one helper. */
export const SSH_MAX_PROCESS_HANDLES = 128
@@ -68,7 +68,7 @@ async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward'
if (method === 'heartbeat') { heartbeatCalls++; return holdHeartbeat ? heartbeat.promise : null }
if (method === 'ordinary') { ordinaryEntered.resolve(undefined); return ordinary.promise }
if (method !== 'hello') throw new Error(`unexpected helper operation: ${method}`)
return { protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
return { protocol: 3, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
})
const rawSocket = new PendingSocket()
const secureSocket = new PendingSocket()
+1 -1
View File
@@ -19,7 +19,7 @@ export async function createHelperHarness(handshake = true, leaseMs = 30_000, ki
void serving.catch(() => {})
const client = new SshRpcPeer(output, input, 64 * 1024 * 1024, 128)
const sockets = new Set<Socket>()
const hello = () => client.request('hello', { protocol: 2, workspace: root, leaseMs }, helloSchema)
const hello = () => client.request('hello', { protocol: 3, workspace: root, leaseMs }, helloSchema)
let closing: Promise<void> | undefined
const close = () => {
closing ??= (async () => {
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more