From bb18ce6a17cfaa9c57d922d9be8e35de3df96414 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 21:56:16 +0000 Subject: [PATCH 01/11] test(rsc): replace watched sources atomically in the provider fixture An in-place write is truncate-then-append, which a loaded watcher observes as two change events and compiles twice; the duplicate attempt supersedes the generation that ordinal-pinned assertions expect, which is exactly how CI committed generation-3 where the equivalent-revision test pinned generation-2. The fixture's three source mutations now go through one same-directory rename so a change is one event and one compile on any machine. --- .../tests/dev-provider.integration.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 21cf69a23..ac971022b 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -1,6 +1,6 @@ -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { expect, test } from '@rstest/core'; import type { createRsbuild, StartDevServerResult } from '@rsbuild/core'; @@ -98,25 +98,41 @@ const startContext = (input: Readonly<{ const copyProviderExample = async (): Promise => copyExample(exampleRoot, { linkPackages: true, prefix: 'rsc-agent-runtime-provider-' }); -const changeDefinition = async (projectRoot: string, replacement: string): Promise => { - const path = join(projectRoot, 'src', 'definition.ts'); +/** + * Replaces source atomically through a same-directory rename. An in-place + * write is truncate-then-append, which a loaded watcher observes as two + * change events and compiles twice; the duplicate attempt supersedes the + * generation that ordinal-pinned assertions expect to commit. + */ +const replaceSource = async (path: string, replace: (source: string) => string): Promise => { const source = await readFile(path, 'utf8'); - await writeFile(path, source.replace('Read the current shared runtime state.', replacement)); + const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + await writeFile(temporary, replace(source)); + await rename(temporary, path); +}; + +const changeDefinition = async (projectRoot: string, replacement: string): Promise => { + await replaceSource( + join(projectRoot, 'src', 'definition.ts'), + (source) => source.replace('Read the current shared runtime state.', replacement), + ); }; const changeWorkerImplementation = async (projectRoot: string, marker: string): Promise => { - const path = join(projectRoot, 'src', 'rsc', 'worker.tsx'); - const source = await readFile(path, 'utf8'); - await writeFile(path, source.replace( - /RSC worker received an invalid event(?: [^']*)?/u, - `RSC worker received an invalid event ${marker}`, - )); + await replaceSource( + join(projectRoot, 'src', 'rsc', 'worker.tsx'), + (source) => source.replace( + /RSC worker received an invalid event(?: [^']*)?/u, + `RSC worker received an invalid event ${marker}`, + ), + ); }; const introduceWorkerSyntaxError = async (projectRoot: string): Promise => { - const path = join(projectRoot, 'src', 'rsc', 'worker.tsx'); - const source = await readFile(path, 'utf8'); - await writeFile(path, `${source}\nconst = ;\n`); + await replaceSource( + join(projectRoot, 'src', 'rsc', 'worker.tsx'), + (source) => `${source}\nconst = ;\n`, + ); }; test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => { From 0ae35b50609029a62979b558a85dd779398ecbd9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 21:57:07 +0000 Subject: [PATCH 02/11] chore(examples): sync example pins with the bumped lockfile The example suites self-repair their pinned manifests and README version table against the installed lockfile; running them locally surfaced the drift left by the merged dependency bumps (@rstest/core 0.11.10, Rsbuild 2.2.1, react-server-dom-rspack 0.1.0). --- examples/audiobook-curator/package.json | 2 +- examples/rsc-agent-runtime/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/audiobook-curator/package.json b/examples/audiobook-curator/package.json index 9248f80e0..b9c00e636 100644 --- a/examples/audiobook-curator/package.json +++ b/examples/audiobook-curator/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@rslib/core": "0.23.2", - "@rstest/core": "0.11.9", + "@rstest/core": "0.11.10", "@types/react": "19.2.18", "agent-bundle": "workspace:*" } diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index ede4dd0a9..8d18e9024 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -229,7 +229,7 @@ For ordinary MCP Apps, prefer Agent Bundle's standard non-RSC `mcp.servers. Date: Fri, 28 Aug 2026 22:01:58 +0000 Subject: [PATCH 03/11] chore(deps): sync workspace pins with the toolchain bumps pnpm's install-time verification and the example self-repair moved the workspace manifests onto the already-merged toolchain line (@rstest/* 0.11.10, Rsbuild 2.2.1, Rspack 2.2.1, react-server-dom-rspack 0.1.0); every suite in this branch ran against these installed versions. --- examples/rsc-agent-runtime/package.json | 6 +- package.json | 12 +- packages/agent-bundle/package.json | 4 +- packages/rsc-runtime/package.json | 2 +- packages/workbench/package.json | 2 +- pnpm-lock.yaml | 511 ++++++++++++++++++++---- pnpm-workspace.yaml | 2 + 7 files changed, 438 insertions(+), 101 deletions(-) diff --git a/examples/rsc-agent-runtime/package.json b/examples/rsc-agent-runtime/package.json index 59fc8af2d..228aebc70 100644 --- a/examples/rsc-agent-runtime/package.json +++ b/examples/rsc-agent-runtime/package.json @@ -19,13 +19,13 @@ "proper-lockfile": "^4.1.2", "react": "19.2.8", "react-dom": "19.2.8", - "react-server-dom-rspack": "0.0.3", + "react-server-dom-rspack": "0.1.0", "zod": "4.4.3" }, "devDependencies": { - "@rsbuild/core": "2.1.13", + "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", - "@rstest/core": "0.11.8", + "@rstest/core": "0.11.10", "@types/express": "5.0.6", "@types/proper-lockfile": "^4.1.4", "@types/react": "19.2.18", diff --git a/package.json b/package.json index c1733f9d8..d3db2a7e5 100644 --- a/package.json +++ b/package.json @@ -45,15 +45,15 @@ "@changesets/cli": "2.29.7", "@arethetypeswrong/cli": "0.18.5", "@modelcontextprotocol/server": "2.0.0", - "@rsbuild/core": "2.1.13", + "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.1", - "@rstest/adapter-rslib": "0.11.9", - "@rstest/browser": "0.11.9", - "@rstest/browser-react": "0.11.9", - "@rstest/core": "0.11.9", - "@rstest/playwright": "0.11.9", + "@rstest/adapter-rslib": "0.11.10", + "@rstest/browser": "0.11.10", + "@rstest/browser-react": "0.11.10", + "@rstest/core": "0.11.10", + "@rstest/playwright": "0.11.10", "@types/node": "26.2.0", "agent-bundle": "workspace:*", "commander": "15.0.0", diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 91ef7eee1..f73e302f3 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -59,11 +59,11 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/node": "2.0.0", "@modelcontextprotocol/server": "2.0.0", - "@rsbuild/core": "2.1.13", + "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.1", - "@rspack/core": "2.1.10", + "@rspack/core": "2.2.1", "@rstackjs/load-config": "0.1.2", "acorn": "8.18.0", "ajv": "8.20.0", diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 00e93c815..6a8b6a62f 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -54,7 +54,7 @@ }, "devDependencies": { "@rslib/core": "0.23.2", - "@rstest/core": "0.11.9", + "@rstest/core": "0.11.10", "@types/react": "19.2.18", "agent-bundle": "workspace:*", "react": "19.2.8" diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 93e91d15b..50ab37ab7 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -28,7 +28,7 @@ "zod": "4.4.3" }, "devDependencies": { - "@rsbuild/core": "2.1.13", + "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b810608df..d91430f75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,11 +18,11 @@ importers: specifier: 2.0.0 version: 2.0.0 '@rsbuild/core': - specifier: 2.1.13 - version: 2.1.13 + specifier: 2.2.1 + version: 2.2.1 '@rsbuild/plugin-react': specifier: 2.1.0 - version: 2.1.0(@rsbuild/core@2.1.13)(@rspack/core@2.1.10(@swc/helpers@0.5.23)) + version: 2.1.0(@rsbuild/core@2.2.1)(@rspack/core@2.2.1(@swc/helpers@0.5.23)) '@rslib/core': specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) @@ -30,20 +30,20 @@ importers: specifier: 0.8.1 version: 0.8.1(jiti@2.7.0) '@rstest/adapter-rslib': - specifier: 0.11.9 - version: 0.11.9(@rslib/core@0.23.2(typescript@7.0.2))(@rstest/core@0.11.9)(typescript@7.0.2) + specifier: 0.11.10 + version: 0.11.10(@rslib/core@0.23.2(typescript@7.0.2))(@rstest/core@0.11.10)(typescript@7.0.2) '@rstest/browser': - specifier: 0.11.9 - version: 0.11.9(@rstest/core@0.11.9)(playwright@1.62.1) + specifier: 0.11.10 + version: 0.11.10(@rstest/core@0.11.10)(playwright@1.62.1) '@rstest/browser-react': - specifier: 0.11.9 - version: 0.11.9(@rstest/core@0.11.9)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 0.11.10 + version: 0.11.10(@rstest/core@0.11.10)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rstest/core': - specifier: 0.11.9 - version: 0.11.9 + specifier: 0.11.10 + version: 0.11.10 '@rstest/playwright': - specifier: 0.11.9 - version: 0.11.9(@rstest/core@0.11.9)(playwright@1.62.1) + specifier: 0.11.10 + version: 0.11.10(@rstest/core@0.11.10)(playwright@1.62.1) '@types/node': specifier: 26.2.0 version: 26.2.0 @@ -88,8 +88,8 @@ importers: specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) '@rstest/core': - specifier: 0.11.9 - version: 0.11.9 + specifier: 0.11.10 + version: 0.11.10 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -142,21 +142,21 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) react-server-dom-rspack: - specifier: 0.0.3 - version: 0.0.3(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 0.1.0 + version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: specifier: 4.4.3 version: 4.4.3 devDependencies: '@rsbuild/core': - specifier: 2.1.13 - version: 2.1.13 + specifier: 2.2.1 + version: 2.2.1 '@rsbuild/plugin-react': specifier: 2.1.0 - version: 2.1.0(@rsbuild/core@2.1.13)(@rspack/core@2.1.10(@swc/helpers@0.5.23)) + version: 2.1.0(@rsbuild/core@2.2.1)(@rspack/core@2.2.1(@swc/helpers@0.5.23)) '@rstest/core': - specifier: 0.11.8 - version: 0.11.8 + specifier: 0.11.10 + version: 0.11.10 '@types/express': specifier: 5.0.6 version: 5.0.6 @@ -177,7 +177,7 @@ importers: version: 1.62.1 rsbuild-plugin-rsc: specifier: 0.1.1 - version: 0.1.1(@rsbuild/core@2.1.13)(react-server-dom-rspack@0.0.3(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + version: 0.1.1(@rsbuild/core@2.2.1)(react-server-dom-rspack@0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) examples/skills-starter: devDependencies: @@ -197,11 +197,11 @@ importers: specifier: 2.0.0 version: 2.0.0 '@rsbuild/core': - specifier: 2.1.13 - version: 2.1.13 + specifier: 2.2.1 + version: 2.2.1 '@rsbuild/plugin-react': specifier: 2.1.0 - version: 2.1.0(@rsbuild/core@2.1.13)(@rspack/core@2.1.10(@swc/helpers@0.5.23)) + version: 2.1.0(@rsbuild/core@2.2.1)(@rspack/core@2.2.1(@swc/helpers@0.5.23)) '@rslib/core': specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) @@ -209,8 +209,8 @@ importers: specifier: 0.8.1 version: 0.8.1(jiti@2.7.0) '@rspack/core': - specifier: 2.1.10 - version: 2.1.10(@swc/helpers@0.5.23) + specifier: 2.2.1 + version: 2.2.1(@swc/helpers@0.5.23) '@rstackjs/load-config': specifier: 0.1.2 version: 0.1.2(jiti@2.7.0) @@ -271,8 +271,8 @@ importers: specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) '@rstest/core': - specifier: 0.11.9 - version: 0.11.9 + specifier: 0.11.10 + version: 0.11.10 '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -329,11 +329,11 @@ importers: version: 4.4.3 devDependencies: '@rsbuild/core': - specifier: 2.1.13 - version: 2.1.13 + specifier: 2.2.1 + version: 2.2.1 '@rsbuild/plugin-react': specifier: 2.1.0 - version: 2.1.0(@rsbuild/core@2.1.13)(@rspack/core@2.1.10(@swc/helpers@0.5.23)) + version: 2.1.0(@rsbuild/core@2.2.1)(@rspack/core@2.2.1(@swc/helpers@0.5.23)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -649,6 +649,26 @@ packages: core-js: optional: true + '@rsbuild/core@2.2.0': + resolution: {integrity: sha512-UnBBfxWIDKVdLz2BUBq7hFBatwLclJ4moFhlDFg+pFBPPJ1g34MmCbGUC0c9Mo1DhPGdYJG69qMIldh5MvC74w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rsbuild/core@2.2.1': + resolution: {integrity: sha512-JcGtG4bo7PBihj6fBL6gaxaJihqLf7nGWW/t4zEmXpMJkV6XNdr1jAo9B0xI4mLAOmtFeva8cUbn9SE2ZEmAIw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + '@rsbuild/plugin-react@2.1.0': resolution: {integrity: sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA==} peerDependencies: @@ -728,81 +748,241 @@ packages: cpu: [arm64] os: [darwin] + '@rspack/binding-darwin-arm64@2.2.0': + resolution: {integrity: sha512-KAVVT7hp3NBjtc/RY2UtOjzzc8i+s4pIhW1p52UV+Aev6ywQCu3dXwkHTonpPvJO3hqLXc4zIMH5l4HbMqBm4g==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-arm64@2.2.1': + resolution: {integrity: sha512-Y/Naw/7V76QiUYdYRuBzBZtzRjt/3fjDUuF8GK0+/BO7BP1RrpY4tk1ln+iiqegRUD8u9uGn08fi8No1rwfyUg==} + cpu: [arm64] + os: [darwin] + '@rspack/binding-darwin-x64@2.1.10': resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==} cpu: [x64] os: [darwin] + '@rspack/binding-darwin-x64@2.2.0': + resolution: {integrity: sha512-rzyJCX99aFwl540trsVMNZOgK4+IFm2d5+YeP+RdNo9Uprxloz8vHz0J4dYtaq6MRiCAyM60dAwEa3wJMwqWAQ==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.2.1': + resolution: {integrity: sha512-rTIG/xZIW7RbEEuMR9hnNn5dv3fDBpX0N4FAQUwfhUYy3tN2+3vibTTq/Nj1Sd9Vn4yWydbWIEwBX6m/aGejig==} + cpu: [x64] + os: [darwin] + '@rspack/binding-linux-arm64-gnu@2.1.10': resolution: {integrity: sha512-laevn9g+E5PAUEGqiKe6Ju5KApsuQYp+bPI17XS3Lkl8eqL5pS/BmHYU7QMlst4GzV8+wlruVTMh//+st6Vqzg==} cpu: [arm64] os: [linux] libc: [glibc] + '@rspack/binding-linux-arm64-gnu@2.2.0': + resolution: {integrity: sha512-0t8QOiOMcBV7RvPSsTJ5DQ4QCK6FIyUZy77qbxnS6asGTOXPZZn7V5cL26IxEv/wuHdQ6tQOXheau1fi+gGyBQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-gnu@2.2.1': + resolution: {integrity: sha512-53rAMU6Hqiat21IMU1hTt4Si2F33h+ZbXOt+Y18W39AFjcwmleIBId2u7beqJeGXLJuBgIAm31jcbJj/PN7mWQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-arm64-musl@2.1.10': resolution: {integrity: sha512-V71+Qz5G72+ROZXrJn5zxOszdG1AEbO8pcC/itXXtf4yRR6a3bVHKNKGhipBNxb8eI6cnD/01FH1h3ZG655jLw==} cpu: [arm64] os: [linux] libc: [musl] + '@rspack/binding-linux-arm64-musl@2.2.0': + resolution: {integrity: sha512-BAvCukqcuHxUdE294ITCohvhVkEklW8RbkKkR36Uo0WyIiMPGrnvPjARPn0/4Q4xMAz7lUmC60sZrvJHlAOKMw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-arm64-musl@2.2.1': + resolution: {integrity: sha512-o7zFiWkt4MqSfSdTbxUdF27fcrWKpRizcuVB8H3yd2G6xW9V2OfYbhVGQnOlbKi+GK74RkCmoJtANB+QboIqKQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-ppc64-gnu@2.1.10': resolution: {integrity: sha512-U7HlNzHcDtZ+LYOtOJmtx67kHEybZzUUAaP7aEXjGYO5WTCgh/176sW2UYP0rmZLrgUNFUuzn+B98RLaClNaVg==} cpu: [ppc64] os: [linux] libc: [glibc] + '@rspack/binding-linux-ppc64-gnu@2.2.0': + resolution: {integrity: sha512-nCHqZLv/E8nm2ccGkb00F5DQtXxzGy3W3X73ArA+N0+zXJUnzRcSRSwr7AE8pVgP/FYfX4yMFgUXy0g0YxYGRA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-ppc64-gnu@2.2.1': + resolution: {integrity: sha512-pvx1oeg1z7cr8OcNt7PAt1SJTHzdsN/pvu7HkGnfks2fWE7GDs9DLL2KvN7tUkzQvJm6bGgUwqSCOrqV4uSwAg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.1.10': resolution: {integrity: sha512-GMGTJpy9/ecE+5F5IfxZH4bXv0Wx/b2TiehTlCbTksbL+pKpLHYy0rwGdjWDKbmBkhxMMqPiC7PDnn9LbdnnLA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.2.0': + resolution: {integrity: sha512-CA3WEqKFDI6FAZTnCho2n9pmdPWZYAW/S8mqgxd0cx2Jix43at3VyLxhCC7ED5A9WBSFn/AdHaIbVtgoQHVhWA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-gnu@2.2.1': + resolution: {integrity: sha512-WQ6P94Wz2tgwOsgifTWP2/bV63iZH5+rkxngQwF22FC8KmIXXy/Yug7lyMH1ld8Hzg4p1qXjBBr4i1cCyJTR+w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-musl@2.1.10': resolution: {integrity: sha512-rkurnAWc04vIbzG1QCrPBWSJadZvaOt1mazFH3EdiJO8VUiu0I1T9zdiwuDOPrd50lOKIZlcTXbd5aaAkWEnvQ==} cpu: [riscv64] os: [linux] libc: [musl] + '@rspack/binding-linux-riscv64-musl@2.2.0': + resolution: {integrity: sha512-kHB960oClkoPRPZ6sdkhRvqbdRIlbpIMYd/Tbxfmn3DWQahiCk1pkUFJbOtFq3EgESxZISV4THl442W2Y57HvQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-riscv64-musl@2.2.1': + resolution: {integrity: sha512-GEFUFHQkjKU7OYyHXnrIo8wWcUHM7jeKov5z6Lxd3i+3hKo11yBOgb7b+uD94Rx2tPuWF4jyFdWmcNu46Njb/A==} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-s390x-gnu@2.1.10': resolution: {integrity: sha512-X+DyxkriZEAF/wihI7ERDv+CAS0mbMv36aEuQ+vXzTlvS6cSmpou/r29AHbvIF3NlG1UeAbDVlOs9QrMBZjpUQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@rspack/binding-linux-s390x-gnu@2.2.0': + resolution: {integrity: sha512-lVBdiffVo1jq0P0jT36jNou2suLB4ueQI4aWUs+HM+h67YPBtVKWu/mo5Wh59+8nowgcZmYaFM5hdH69963I9w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-s390x-gnu@2.2.1': + resolution: {integrity: sha512-Cqw2UjSmFGZaw1EjQXClKDud86ThynGEEIazy2PZfPzZG4WjICK1WjdeFCJd7xWsSbUQ3jGyzb7/EHiDVa+sKA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.1.10': resolution: {integrity: sha512-Fat09V6jUuyo9qG7Wyj9cQ31VDfLmokXyBtGqKxY5OvSWHereB7QUub5btbPXHwbp6Iq4aAQyUbbLTzvR1YaBw==} cpu: [x64] os: [linux] libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.2.0': + resolution: {integrity: sha512-M49UaWspE0YJ3268DsquD8idEQTfjBDMvO/I8qccV/Z5T+Q98FJ+kIs5liUaTWb48OIbDEK+8ZKx5QzLbfVN6g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-gnu@2.2.1': + resolution: {integrity: sha512-QX+gRxg2CS9ri2fUG5eNurdsrOCWoJ56SsD2O7kcVGiRm+S2+muNb/QhkdkAdt/u/m++7Ve5TMIpHTnaKYCpCw==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-musl@2.1.10': resolution: {integrity: sha512-lhHOnIJ4ClpIlA1f1L8aoxEZivYLjnjq5A6jKKz7BKsm+cHK8kqqEm6lO5KqA5xQT0Lonq1o28bmKHEj6JHInw==} cpu: [x64] os: [linux] libc: [musl] + '@rspack/binding-linux-x64-musl@2.2.0': + resolution: {integrity: sha512-YYbs0wmey+5blhEQDE4Dax3TwJtqfGwe2QBm3OLphlBHo/fcZVvimzKkMV0/pVrZTLy2z5ZAwNhGMY64bNr77w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-x64-musl@2.2.1': + resolution: {integrity: sha512-mBZQl1NdGbEB3y5M9d0tkuF7RL1GLz3Hb3gqFa3QRZBymP9PCV83Jiji8s2PJimNUJIVcdZRIw8+VGYs/35c2A==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rspack/binding-wasm32-wasi@2.1.10': resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} cpu: [wasm32] + '@rspack/binding-wasm32-wasi@2.2.0': + resolution: {integrity: sha512-rerLPTN/HD4EvLNWs3O2N+Eb37eGvLRIP3dXXc3n+UzTebOepAsahNn44vXeRBsE4m/pHkpDJjwgWTytgQ2gBw==} + cpu: [wasm32] + + '@rspack/binding-wasm32-wasi@2.2.1': + resolution: {integrity: sha512-/d2ImKDS+lT+FJ07MxKBeUkTat84tr2Nm2+nIRt8HmZK7/N8odkQml9vb4MHr8E7oYOp4B+jm6W5frdRzKrkJQ==} + cpu: [wasm32] + '@rspack/binding-win32-arm64-msvc@2.1.10': resolution: {integrity: sha512-z4GWzMLofaDGpAt9Z+MlN88LlUBDm+zM6R2GdOOPM6/4g/h3/+47OP7casmSL3AwTGYBEJqogwt08sRSosB6Cg==} cpu: [arm64] os: [win32] + '@rspack/binding-win32-arm64-msvc@2.2.0': + resolution: {integrity: sha512-JUAmnbOQYGTRyX28vls/MOMonZWcmcCi5YtEq6YMc8Xqh3Qx0HUwaLM/I1xr/N9BX3b8CV0dQDOpNuBc2ei+CA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-arm64-msvc@2.2.1': + resolution: {integrity: sha512-TfmaKPF3KC7uoZb6A+8ZUbLS8g8P5EdeXFGLCaJ+UgdkJ20TsapcfJXZXWNnKzFEkV8dUO/t5oNxNLYy2URusw==} + cpu: [arm64] + os: [win32] + '@rspack/binding-win32-ia32-msvc@2.1.10': resolution: {integrity: sha512-7qcWdsZ+GuGtzKjqgy7wTN7Dso/ezIY8yhx1r2yIbcczdmXj4FhaEampMDp/25HwtKwIGBBoh6HHSt3JWxpTUg==} cpu: [ia32] os: [win32] + '@rspack/binding-win32-ia32-msvc@2.2.0': + resolution: {integrity: sha512-wOmQRUaOG0eWH/fnfslA9yK9xKfaq9X+3Xa1TdTJnTqlo0ARJYs6A+Lzjbs7cxdY/o1f12Xe00BG3nQozReUOg==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.2.1': + resolution: {integrity: sha512-rdBXayngvpQFMSUIC4b71FDZrSBJszSHNEkln/Nis3a17DMAE7IkgJcqe7SqLWbk2OPF/N920AOWmBEDQQCaMg==} + cpu: [ia32] + os: [win32] + '@rspack/binding-win32-x64-msvc@2.1.10': resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==} cpu: [x64] os: [win32] + '@rspack/binding-win32-x64-msvc@2.2.0': + resolution: {integrity: sha512-v6/3bFr9+i7hRpgulL9b5qCvZL0VgR4vQGQNqOWezUzZmPUj9LYpvB0L9xZIVwDQ2ug/xBiA58bfg5IbESgoyw==} + cpu: [x64] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.2.1': + resolution: {integrity: sha512-l3K4s7nrQJc+3LacPFjZGjX8Jk1sf8Q4TK6IXAsdSucOjTqYSpD8PMl2NUXCBDXOl8T2V4v323z1LfxwW8BPmA==} + cpu: [x64] + os: [win32] + '@rspack/binding@2.1.10': resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} + '@rspack/binding@2.2.0': + resolution: {integrity: sha512-nxZzJqqB0EmEKp6qjzFNkBb/SgGt0k0DSENrLvAJgvVvrm3waVsubD0cfxtPlZY/rd5SzadzxWGEHRyFcds5nA==} + + '@rspack/binding@2.2.1': + resolution: {integrity: sha512-56TqztuEMd+aHGv1jDXnkJQGSLTb4NoO146flFxJqPG8931UdXPO2pNR9M0Q2Pz+GvmO0fLHGPLYBHoRVrRlHw==} + '@rspack/core@2.1.10': resolution: {integrity: sha512-YSS2/Xxz8uiG/KXDkqOoA3dTetNo/vysk7bAexQOrU8iuq7JuzDTTAwLKvWZnwmvME8M8m5wcM4YvfIwYmidHA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -815,6 +995,30 @@ packages: '@swc/helpers': optional: true + '@rspack/core@2.2.0': + resolution: {integrity: sha512-3W7oX0BAHbK4VlknH3lfyfRvupzxdZtyEa+DfKmdjzmIAcqYtHnFd0nLqp5dzitDPyDI1TIKkDhpB0AZJn0pVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + + '@rspack/core@2.2.1': + resolution: {integrity: sha512-EHFX2oWCY1HkHJkG/Ev8HXCcl4gQzgETyairdIlBkKaypuW8i7kowBxUFzpHHg/ObF70vB3focToel9Wwg4MRA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + '@rspack/plugin-react-refresh@2.0.2': resolution: {integrity: sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==} peerDependencies: @@ -832,8 +1036,8 @@ packages: jiti: optional: true - '@rstest/adapter-rslib@0.11.9': - resolution: {integrity: sha512-qdkgl3bxgb1twAEAcNK1yB+ZoheGAlQd1O1Ziuk+AtTQxqPZN82y5dctlMn/gsbNHZxezMWs+5i+xWHJC9iMGg==} + '@rstest/adapter-rslib@0.11.10': + resolution: {integrity: sha512-yocKR4QBzerK21J+xJkg4Ixv5Z3N8Jul64nX70ErbRruXyTJm4EMqggzfxX9a7EpR0XnCzE/fNWxZug8/CECeg==} peerDependencies: '@rslib/core': '>=0.18.6 || ^1.0.0-0' '@rstest/core': ^0.11.0 @@ -842,26 +1046,26 @@ packages: typescript: optional: true - '@rstest/browser-react@0.11.9': - resolution: {integrity: sha512-H9WOLvPUhEWUCifFLwxF4xTl1sooJTdKpsm38ZwH2+RX/ZGZ2aLlBwdsNSlyYU6+NDK0KXhdk+jCtr7sVLkahA==} + '@rstest/browser-react@0.11.10': + resolution: {integrity: sha512-LFMjeUfMmfM2HnEk/5YHGXNXLMQCwKdA0fHcNzQztYYeTVb9FB0SPwv6FrqduNXZUAyNyHryxS2TO4jDYaYWvg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rstest/core': ^0.11.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@rstest/browser@0.11.9': - resolution: {integrity: sha512-UCWn7fRS7/Uz5olkzGSe+5pAqPJ5ihibBVDjN7FXaPaHFCOdmvw1eue4Rij5PmNG2v4eYFbH9D/cUlUerweS6w==} + '@rstest/browser@0.11.10': + resolution: {integrity: sha512-Ic9QD8uA2aDaUaFeDgXZoEwEJerV4pwcHo2113hS/UfgpdIgi7n1ygG/tsrRUCzHPDPL3JrN+YYMcntDACAxow==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - '@rstest/core': 0.11.9 + '@rstest/core': 0.11.10 playwright: ^1.49.1 peerDependenciesMeta: playwright: optional: true - '@rstest/core@0.11.8': - resolution: {integrity: sha512-XworMa277b5Cf4/Box18frFjWGP4dO/NIals+Ck/Q8nhe1z1t8j0P67zh6rv5xTdE3CCEfItICGvdjzz6VRhLg==} + '@rstest/core@0.11.10': + resolution: {integrity: sha512-x/PNGPdyKQWbiVhpoOQco8xXevh4P/QamuyJ0/YdTrdEiUcUglT6tGSnxaudwyrQr8e/5gwWuOt6zykZKWmSUg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -873,21 +1077,8 @@ packages: jsdom: optional: true - '@rstest/core@0.11.9': - resolution: {integrity: sha512-bU8jL1TruGsqHs0innQ4CheBmCLclBkifQ/6KhjofZe6ZQNv9/lsDUarnvUjF7ElByDju2rVyCuiQeDTQRatFg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - happy-dom: ^20.8.3 - jsdom: '>=15.0.0' - peerDependenciesMeta: - happy-dom: - optional: true - jsdom: - optional: true - - '@rstest/playwright@0.11.9': - resolution: {integrity: sha512-Dseeyu/RRAp0g/N+e7QOG3WUTX34igyZPEW/xWUhzW4x+ELEPTwxWqsX3YNKvgVUH9V0aSeDQqyn2CMgtpfbeA==} + '@rstest/playwright@0.11.10': + resolution: {integrity: sha512-GY8aE9oNavl7m5hbdpxvO5ot0cH3rSISnzeZU5IeO3lgd7+2OszfWS/EbohwjEq5qr6W64ByQ3sDZBboq6vMEQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rstest/core': ^0.11.9 @@ -2241,11 +2432,11 @@ packages: '@types/react': optional: true - react-server-dom-rspack@0.0.3: - resolution: {integrity: sha512-V+sf4LO12QdQ+Ao6xxweJqGKNU5wVBAGZmL4jkKbBIvJ5MaNo0TxndXXIVdDHQPXJrkzvBPiwbzaFDK2eOBLkg==} + react-server-dom-rspack@0.1.0: + resolution: {integrity: sha512-KqDzmxBUZEcAphwg/PnEHOBkqTJjesTmLoBygLHie1gkiLINiZuVKPRccS6qDzfdj9ccYCaJ743IDYVh0wPf/w==} engines: {node: '>=0.10.0'} peerDependencies: - '@rspack/core': ^2.0.0-0 + '@rspack/core': ^2.2.0-0 react: ^19.1.0 react-dom: ^19.1.0 @@ -3100,12 +3291,26 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsbuild/plugin-react@2.1.0(@rsbuild/core@2.1.13)(@rspack/core@2.1.10(@swc/helpers@0.5.23))': + '@rsbuild/core@2.2.0': dependencies: - '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-refresh@0.18.0) + '@rspack/core': 2.2.0(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rsbuild/core@2.2.1': + dependencies: + '@rspack/core': 2.2.1(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rsbuild/plugin-react@2.1.0(@rsbuild/core@2.2.1)(@rspack/core@2.2.1(@swc/helpers@0.5.23))': + dependencies: + '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-refresh@0.18.0) react-refresh: 0.18.0 optionalDependencies: - '@rsbuild/core': 2.1.13 + '@rsbuild/core': 2.2.1 transitivePeerDependencies: - '@rspack/core' @@ -3161,33 +3366,93 @@ snapshots: '@rspack/binding-darwin-arm64@2.1.10': optional: true + '@rspack/binding-darwin-arm64@2.2.0': + optional: true + + '@rspack/binding-darwin-arm64@2.2.1': + optional: true + '@rspack/binding-darwin-x64@2.1.10': optional: true + '@rspack/binding-darwin-x64@2.2.0': + optional: true + + '@rspack/binding-darwin-x64@2.2.1': + optional: true + '@rspack/binding-linux-arm64-gnu@2.1.10': optional: true + '@rspack/binding-linux-arm64-gnu@2.2.0': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.2.1': + optional: true + '@rspack/binding-linux-arm64-musl@2.1.10': optional: true + '@rspack/binding-linux-arm64-musl@2.2.0': + optional: true + + '@rspack/binding-linux-arm64-musl@2.2.1': + optional: true + '@rspack/binding-linux-ppc64-gnu@2.1.10': optional: true + '@rspack/binding-linux-ppc64-gnu@2.2.0': + optional: true + + '@rspack/binding-linux-ppc64-gnu@2.2.1': + optional: true + '@rspack/binding-linux-riscv64-gnu@2.1.10': optional: true + '@rspack/binding-linux-riscv64-gnu@2.2.0': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.2.1': + optional: true + '@rspack/binding-linux-riscv64-musl@2.1.10': optional: true + '@rspack/binding-linux-riscv64-musl@2.2.0': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.2.1': + optional: true + '@rspack/binding-linux-s390x-gnu@2.1.10': optional: true + '@rspack/binding-linux-s390x-gnu@2.2.0': + optional: true + + '@rspack/binding-linux-s390x-gnu@2.2.1': + optional: true + '@rspack/binding-linux-x64-gnu@2.1.10': optional: true + '@rspack/binding-linux-x64-gnu@2.2.0': + optional: true + + '@rspack/binding-linux-x64-gnu@2.2.1': + optional: true + '@rspack/binding-linux-x64-musl@2.1.10': optional: true + '@rspack/binding-linux-x64-musl@2.2.0': + optional: true + + '@rspack/binding-linux-x64-musl@2.2.1': + optional: true + '@rspack/binding-wasm32-wasi@2.1.10': dependencies: '@emnapi/core': 1.11.3 @@ -3195,15 +3460,47 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true + '@rspack/binding-wasm32-wasi@2.2.0': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@rspack/binding-wasm32-wasi@2.2.1': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + '@rspack/binding-win32-arm64-msvc@2.1.10': optional: true + '@rspack/binding-win32-arm64-msvc@2.2.0': + optional: true + + '@rspack/binding-win32-arm64-msvc@2.2.1': + optional: true + '@rspack/binding-win32-ia32-msvc@2.1.10': optional: true + '@rspack/binding-win32-ia32-msvc@2.2.0': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.2.1': + optional: true + '@rspack/binding-win32-x64-msvc@2.1.10': optional: true + '@rspack/binding-win32-x64-msvc@2.2.0': + optional: true + + '@rspack/binding-win32-x64-msvc@2.2.1': + optional: true + '@rspack/binding@2.1.10': optionalDependencies: '@rspack/binding-darwin-arm64': 2.1.10 @@ -3221,39 +3518,85 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.1.10 '@rspack/binding-win32-x64-msvc': 2.1.10 + '@rspack/binding@2.2.0': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.0 + '@rspack/binding-darwin-x64': 2.2.0 + '@rspack/binding-linux-arm64-gnu': 2.2.0 + '@rspack/binding-linux-arm64-musl': 2.2.0 + '@rspack/binding-linux-ppc64-gnu': 2.2.0 + '@rspack/binding-linux-riscv64-gnu': 2.2.0 + '@rspack/binding-linux-riscv64-musl': 2.2.0 + '@rspack/binding-linux-s390x-gnu': 2.2.0 + '@rspack/binding-linux-x64-gnu': 2.2.0 + '@rspack/binding-linux-x64-musl': 2.2.0 + '@rspack/binding-wasm32-wasi': 2.2.0 + '@rspack/binding-win32-arm64-msvc': 2.2.0 + '@rspack/binding-win32-ia32-msvc': 2.2.0 + '@rspack/binding-win32-x64-msvc': 2.2.0 + + '@rspack/binding@2.2.1': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.1 + '@rspack/binding-darwin-x64': 2.2.1 + '@rspack/binding-linux-arm64-gnu': 2.2.1 + '@rspack/binding-linux-arm64-musl': 2.2.1 + '@rspack/binding-linux-ppc64-gnu': 2.2.1 + '@rspack/binding-linux-riscv64-gnu': 2.2.1 + '@rspack/binding-linux-riscv64-musl': 2.2.1 + '@rspack/binding-linux-s390x-gnu': 2.2.1 + '@rspack/binding-linux-x64-gnu': 2.2.1 + '@rspack/binding-linux-x64-musl': 2.2.1 + '@rspack/binding-wasm32-wasi': 2.2.1 + '@rspack/binding-win32-arm64-msvc': 2.2.1 + '@rspack/binding-win32-ia32-msvc': 2.2.1 + '@rspack/binding-win32-x64-msvc': 2.2.1 + '@rspack/core@2.1.10(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.1.10 optionalDependencies: '@swc/helpers': 0.5.23 - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/core@2.2.0(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.0 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rspack/core@2.2.1(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.1 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: - '@rspack/core': 2.1.10(@swc/helpers@0.5.23) + '@rspack/core': 2.2.1(@swc/helpers@0.5.23) '@rstackjs/load-config@0.1.2(jiti@2.7.0)': optionalDependencies: jiti: 2.7.0 - '@rstest/adapter-rslib@0.11.9(@rslib/core@0.23.2(typescript@7.0.2))(@rstest/core@0.11.9)(typescript@7.0.2)': + '@rstest/adapter-rslib@0.11.10(@rslib/core@0.23.2(typescript@7.0.2))(@rstest/core@0.11.10)(typescript@7.0.2)': dependencies: '@rslib/core': 0.23.2(typescript@7.0.2) - '@rstest/core': 0.11.9 + '@rstest/core': 0.11.10 optionalDependencies: typescript: 7.0.2 - '@rstest/browser-react@0.11.9(@rstest/core@0.11.9)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@rstest/browser-react@0.11.10(@rstest/core@0.11.10)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@rstest/core': 0.11.9 + '@rstest/core': 0.11.10 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@rstest/browser@0.11.9(@rstest/core@0.11.9)(playwright@1.62.1)': + '@rstest/browser@0.11.10(@rstest/core@0.11.10)(playwright@1.62.1)': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@rstest/core': 0.11.9 + '@rstest/core': 0.11.10 convert-source-map: 2.0.0 open-editor: 6.0.0 pathe: 2.0.3 @@ -3265,25 +3608,17 @@ snapshots: - bufferutil - utf-8-validate - '@rstest/core@0.11.8': + '@rstest/core@0.11.10': dependencies: - '@rsbuild/core': 2.1.13 + '@rsbuild/core': 2.2.0 '@types/chai': 5.2.3 transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js - '@rstest/core@0.11.9': + '@rstest/playwright@0.11.10(@rstest/core@0.11.10)(playwright@1.62.1)': dependencies: - '@rsbuild/core': 2.1.13 - '@types/chai': 5.2.3 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - core-js - - '@rstest/playwright@0.11.9(@rstest/core@0.11.9)(playwright@1.62.1)': - dependencies: - '@rstest/core': 0.11.9 + '@rstest/core': 0.11.10 playwright: 1.62.1 '@sec-ant/readable-stream@0.4.1': {} @@ -4769,9 +5104,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-server-dom-rspack@0.0.3(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-server-dom-rspack@0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@rspack/core': 2.1.10(@swc/helpers@0.5.23) + '@rspack/core': 2.2.1(@swc/helpers@0.5.23) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -4886,10 +5221,10 @@ snapshots: optionalDependencies: typescript: 7.0.2 - rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.1.13)(react-server-dom-rspack@0.0.3(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): + rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.1)(react-server-dom-rspack@0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): dependencies: - '@rsbuild/core': 2.1.13 - react-server-dom-rspack: 0.0.3(@rspack/core@2.1.10(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@rsbuild/core': 2.2.1 + react-server-dom-rspack: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) run-applescript@7.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 04d6f9f41..fa88c1a9e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ packages: allowBuilds: '@google/genai': false protobufjs: false +minimumReleaseAgeExclude: + - '@rsbuild/core@2.2.1' From 55923e90331318f6e31061d4095c6207cf8d20f0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 22:01:58 +0000 Subject: [PATCH 04/11] fix(dev): stop manufacturing MCP timeouts on two-core runners The parallelism audit inverted the oversubscription hypothesis: rstest already resolves to one worker on a two-core CI runner, so the flakes come from fixed deadlines tuned on many-core machines applied to tests that are inherently multi-process. The sharpest edge was the MCP session layer stamping a five-second timeout on every request - an rsbuild compile or Chrome startup saturates both CI cores for longer than that, tripping -32001 - so the default rises to thirty seconds, still half the MCP SDK's own default. Fixed browser and per-test budgets across the heavy suites now scale four-fold under CI, which costs nothing on green runs since polling assertions return on success, and plugin-bundle.test.ts moves to the serialized integration pool per that list's own admission rule (it runs real builds and spawns node children). --- examples/rsc-agent-runtime/rsbuild.config.ts | 5 ++++ .../src/dev/mcp-session/mcp-session.ts | 6 ++++- .../tests/mcp-session-service.test.ts | 6 ++--- .../tests/script-playground-service.test.ts | 25 ++++++++++--------- .../agent-bundle/tests/support/time-scale.ts | 8 ++++++ ...parisons-page-client-scope-browser.test.ts | 7 +++--- .../workbench/tests/mcp-app-real.e2e.test.ts | 7 +++--- .../tests/mcp-session-timeout.e2e.test.ts | 3 ++- .../tests/packed-release.e2e.test.ts | 7 +++--- .../tests/support/example-acceptance.ts | 3 ++- rstest.integration-tests.ts | 1 + 11 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 packages/agent-bundle/tests/support/time-scale.ts diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index cffbea86f..d85acca9d 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -218,6 +218,11 @@ export const createRscRuntimeRsbuildConfig = ( manifest: 'runtime-assets.json', target: 'node', }, + // Rsbuild 2.2 enabled sync chunk splitting for node targets by + // default. Worker-spawning modules here resolve sibling entries from + // their own preserved `import.meta.url`, so hoisting them into a + // shared chunk at the dist root breaks those relative paths. + splitChunks: false, }, widget: { source: { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts index d7fac9bf3..144f69725 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session.ts @@ -49,7 +49,11 @@ import type { StdioTransport, } from './mcp-session-types.ts'; -const defaultTimeoutMs = 5_000; +// A session request can legitimately sit behind an rsbuild compile or Chrome +// startup on a two-core machine; a five-second ceiling manufactured request +// timeouts there. Thirty seconds stays interactive while remaining well under +// the MCP SDK's own sixty-second default. +const defaultTimeoutMs = 30_000; const maxStderrBytes = 1_000_000; const maxRetainedEvents = 512; const maxRetainedFrames = 512; diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 79fbd5dbe..a4e066a34 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -332,7 +332,7 @@ it('uses the admitted session timeout for initialization, catalog, operations, a }); const defaultSession = await service.open({ epochId: 'epoch-timeout', serverName: 'fixture', target: 'portable' }); - expect((defaultSession as unknown as { readonly timeoutMs?: number }).timeoutMs).toBe(5_000); + expect((defaultSession as unknown as { readonly timeoutMs?: number }).timeoutMs).toBe(30_000); await defaultSession.listTools(); await defaultSession.close(); @@ -352,8 +352,8 @@ it('uses the admitted session timeout for initialization, catalog, operations, a ); expect(observed).toEqual([ - ['connect', 5_000], - ['listTools', 5_000], + ['connect', 30_000], + ['listTools', 30_000], ['connect', 12_345], ['listTools', 12_345], ['listResources', 12_345], diff --git a/packages/agent-bundle/tests/script-playground-service.test.ts b/packages/agent-bundle/tests/script-playground-service.test.ts index 86444325c..9ea13c2c1 100644 --- a/packages/agent-bundle/tests/script-playground-service.test.ts +++ b/packages/agent-bundle/tests/script-playground-service.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { ScriptPlaygroundService } from '../src/dev/playground/script-playground-service.ts'; +import { timeScale } from './support/time-scale.ts'; const temporaryScript = async (source: string): Promise Promise; readonly path: string }>> => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-script-playground-test-')); @@ -178,7 +179,7 @@ it('preserves timeout and cancellation identity when workspace release fails', a } finally { await Promise.allSettled([emitted.close(), rm(workspace, { force: true, recursive: true })]); } -}, 10_000); +}, 10_000 * timeScale); it('terminates a script after the combined stdout and stderr cap is exceeded', async () => { const emitted = await temporaryScript("process.stdout.write('x'.repeat(512));\nsetInterval(() => undefined, 1_000);\n"); @@ -198,7 +199,7 @@ it('terminates a script after the combined stdout and stderr cap is exceeded', a stdout: 'x'.repeat(128), }); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('terminates a script that exceeds its server-owned timeout with partial evidence', async () => { const emitted = await temporaryScript("process.stdout.write('before timeout'); process.stderr.write('timeout stderr'); setInterval(() => undefined, 1_000);\n"); @@ -218,7 +219,7 @@ it('terminates a script that exceeds its server-owned timeout with partial evide stdout: 'before timeout', }); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('does not settle cancellation until its final process-tree cleanup attempt completes', async () => { const emitted = await temporaryScript('setInterval(() => undefined, 1_000);\n'); @@ -267,7 +268,7 @@ it('does not settle cancellation until its final process-tree cleanup attempt co finalCleanup.resolve(); await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('reports a stable cleanup failure when Windows taskkill cannot finish', async () => { const emitted = await temporaryScript('setInterval(() => undefined, 1_000);\n'); @@ -295,7 +296,7 @@ it('reports a stable cleanup failure when Windows taskkill cannot finish', async }); expect(taskkillCalls).toBeGreaterThan(0); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('accepts an already-absent final Windows taskkill after successful TERM cleanup', async () => { const emitted = await temporaryScript('setInterval(() => undefined, 1_000);\n'); @@ -321,7 +322,7 @@ it('accepts an already-absent final Windows taskkill after successful TERM clean } as unknown as Parameters[0])).rejects.toMatchObject({ code: 'timeout' }); expect(taskkillCalls).toBe(2); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('accepts forced Windows cleanup after a failed TERM taskkill', async () => { const emitted = await temporaryScript('setInterval(() => undefined, 1_000);\n'); @@ -346,7 +347,7 @@ it('accepts forced Windows cleanup after a failed TERM taskkill', async () => { } as unknown as Parameters[0])).rejects.toMatchObject({ code: 'timeout' }); expect(taskkillCalls).toBe(2); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('bounds a stalled Windows taskkill attempt as a stable cleanup failure', async () => { const emitted = await temporaryScript('setInterval(() => undefined, 1_000);\n'); @@ -367,7 +368,7 @@ it('bounds a stalled Windows taskkill attempt as a stable cleanup failure', asyn message: 'Script process tree cleanup could not be confirmed.', }); } finally { await emitted.close(); } -}, 10_000); +}, 10_000 * timeScale); it('reports a stable interpreter-unavailable failure without exposing a command path', async () => { const service = new ScriptPlaygroundService({ @@ -428,7 +429,7 @@ it('cancels and drains the emitted script process group before its workspace is } finally { await Promise.allSettled([emitted.close(), rm(root, { force: true, recursive: true }), rm(workspace, { force: true, recursive: true })]); } -}, 10_000); +}, 10_000 * timeScale); it('keeps SIGKILL process-group cleanup alive after the direct child closes', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-script-playground-stubborn-tree-')); @@ -468,7 +469,7 @@ it('keeps SIGKILL process-group cleanup alive after the direct child closes', as } await Promise.allSettled([emitted.close(), rm(root, { force: true, recursive: true })]); } -}, 10_000); +}, 10_000 * timeScale); const assertStubbornDescendantIsGoneAtSettlement = async ( trigger: 'output-limit' | 'timeout', @@ -515,8 +516,8 @@ const assertStubbornDescendantIsGoneAtSettlement = async ( it('drains a TERM-ignoring descendant before timeout settlement', async () => { await assertStubbornDescendantIsGoneAtSettlement('timeout'); -}, 10_000); +}, 10_000 * timeScale); it('drains a TERM-ignoring descendant before output-limit settlement', async () => { await assertStubbornDescendantIsGoneAtSettlement('output-limit'); -}, 10_000); +}, 10_000 * timeScale); diff --git a/packages/agent-bundle/tests/support/time-scale.ts b/packages/agent-bundle/tests/support/time-scale.ts new file mode 100644 index 000000000..cf6e3d663 --- /dev/null +++ b/packages/agent-bundle/tests/support/time-scale.ts @@ -0,0 +1,8 @@ +/** + * Fixed test budgets are tuned on many-core development machines, while CI + * runners have two cores and share them between Chrome, dev servers, child + * processes, and rsbuild compiles inside a single test. Scaling the budgets + * costs nothing on green runs - polling assertions return on success - and + * the workflow-level timeout-minutes still bounds real hangs. + */ +export const timeScale = process.env['CI'] === undefined ? 1 : 4; diff --git a/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts b/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts index 1e56684e8..eccfdeffa 100644 --- a/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts +++ b/packages/workbench/tests/comparisons-page-client-scope-browser.test.ts @@ -10,10 +10,11 @@ import { pluginReact } from '@rsbuild/plugin-react'; import { closeServer } from './support/http.ts'; import { workbenchBrowserAliases } from './support/workbench-browser-modules.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; const workspaceRoot = process.cwd(); const comparisonsPage = join(workspaceRoot, 'packages', 'workbench', 'src', 'comparisons', 'comparisons-page.tsx'); -const browserTimeout = 8_000; +const browserTimeout = 8_000 * timeScale; const e2e = test.extend({ playwright: { @@ -107,7 +108,7 @@ const mountedComparisonsFixture = async (): Promise<{ readonly close: () => Prom }; }; -e2e('aborts and hides a stale comparison synchronously when its client is replaced', { timeout: 45_000 }, async ({ page }) => { +e2e('aborts and hides a stale comparison synchronously when its client is replaced', { timeout: 45_000 * timeScale }, async ({ page }) => { const fixture = await mountedComparisonsFixture(); const pageErrors: Error[] = []; page.on('pageerror', (error) => pageErrors.push(error)); @@ -160,7 +161,7 @@ e2e('aborts and hides a stale comparison synchronously when its client is replac } }); -e2e('aborts an active comparison when only its Eval client is replaced', { timeout: 45_000 }, async ({ page }) => { +e2e('aborts an active comparison when only its Eval client is replaced', { timeout: 45_000 * timeScale }, async ({ page }) => { const fixture = await mountedComparisonsFixture(); try { await page.goto(fixture.url); diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 4da8b5852..ebfe95b3b 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -11,10 +11,11 @@ import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; const workspaceRoot = process.cwd(); const workbenchAssets = join(workspaceRoot, 'packages', 'workbench', 'dist'); -const browserTimeout = 8_000; +const browserTimeout = 8_000 * timeScale; const execFile = promisify(executeFile); const e2e = test.extend({ @@ -207,7 +208,7 @@ const requestBody = (body: string | null): unknown => { } }; -e2e('runs a generated SDK-v2 App through the real foreground session and separate-origin sandbox', { timeout: 90_000 }, async ({ page }) => { +e2e('runs a generated SDK-v2 App through the real foreground session and separate-origin sandbox', { timeout: 90_000 * timeScale }, async ({ page }) => { let project: Awaited> | undefined; let server: Awaited> | undefined; let testFailure: unknown; @@ -1618,7 +1619,7 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on } }); -e2e('renders a compiler-bundled App template through the canonical sandbox URL', { timeout: 90_000 }, async ({ page }) => { +e2e('renders a compiler-bundled App template through the canonical sandbox URL', { timeout: 90_000 * timeScale }, async ({ page }) => { let project: Awaited> | undefined; let server: Awaited> | undefined; let testFailure: unknown; diff --git a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts index bc6ae5477..0c5e2bc0b 100644 --- a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts +++ b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts @@ -8,8 +8,9 @@ import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { buildWorkbench, e2e, workbenchAssets } from './support/workbench-e2e.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; -const browserTimeout = 8_000; +const browserTimeout = 8_000 * timeScale; const writeTimeoutProject = async (root: string): Promise => { await Promise.all([ diff --git a/packages/workbench/tests/packed-release.e2e.test.ts b/packages/workbench/tests/packed-release.e2e.test.ts index cc5a1cb5c..dd21828ee 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -14,13 +14,14 @@ import { validateOutageLedger, type ConsoleErrorRecord, } from './support/packed-outage-ledger.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; const execFile = promisify((await import('node:child_process')).execFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle'); const fixtureRoot = join(workspaceRoot, 'fixtures', 'integration', 'packed-release'); -const browserTimeout = 12_000; -const packedServerStartupBudget = 45_000; +const browserTimeout = 12_000 * timeScale; +const packedServerStartupBudget = 45_000 * timeScale; const productTemporaryRootPrefixes = [ 'agent-bundle-hook-playground-', 'agent-bundle-mcp-', @@ -219,7 +220,7 @@ const isAppRoute = (url: URL): boolean => url.pathname.startsWith('/api/mcp/apps/') || /^\/api\/mcp\/sessions\/[^/]+\/apps$/u.test(url.pathname); -e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 }, async ({ page }) => { +e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * timeScale }, async ({ page }) => { await buildPackage(); const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-release-')); const forbiddenStagedPackage = join(consumer, 'staged-package'); diff --git a/packages/workbench/tests/support/example-acceptance.ts b/packages/workbench/tests/support/example-acceptance.ts index 528d0d93b..ea9740af8 100644 --- a/packages/workbench/tests/support/example-acceptance.ts +++ b/packages/workbench/tests/support/example-acceptance.ts @@ -6,6 +6,7 @@ import { expect } from '@rstest/playwright'; import type { Page, Request } from 'playwright-core'; import { workspaceRoot } from './workbench-e2e.ts'; +import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; export type ExampleName = 'hooks-and-scripts' | 'mcp-app' | 'skills-starter'; @@ -34,7 +35,7 @@ export interface ExampleErrorLedger { readonly pageErrors: string[]; } -const browserTimeout = 15_000; +const browserTimeout = 15_000 * timeScale; const captureRoot = process.env['AGENT_BUNDLE_EXAMPLE_SCREENSHOT_DIR']; const captures: ExampleCapture[] = []; diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 19a404b6c..83346d35c 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -38,6 +38,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-consumer.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', + 'packages/agent-bundle/tests/plugin-bundle.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', 'packages/agent-bundle/tests/release-audit.test.ts', 'packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts', From 4f1fb5940eb3cfd3bf2c94cbf0151a80593ff960 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 22:08:05 +0000 Subject: [PATCH 05/11] test(workbench): scale the inline teardown poll budgets too --- .../workbench/tests/mcp-app-real.e2e.test.ts | 146 +++++++++--------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index ebfe95b3b..6fd47145b 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -555,10 +555,10 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', }); try { await page.goto(`${fixture.url}#runtime`); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 * timeScale }); const runtimeIdentity = page.locator('[data-runtime-provider-session]'); const runtimeSurface = page.getByLabel('Runtime surface'); - await expect(runtimeIdentity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: 15_000 }); + await expect(runtimeIdentity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: 15_000 * timeScale }); await runtimeSurface.selectOption('mcp.edit-timeline'); clientSurface = await fixture.openRuntimeClientSurface('mcp.edit-timeline'); if (clientSurface === undefined) throw new Error('Runtime client surface was not available.'); @@ -610,7 +610,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await page.getByRole('button', { name: 'Run', exact: true }).click(); const [createdRequest, createdResponse] = await Promise.all([createRequest, createResponse]); const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - await expect(history).toHaveCount(1, { timeout: 15_000 }); + await expect(history).toHaveCount(1, { timeout: 15_000 * timeScale }); const runId = await history.first().getAttribute('data-runtime-run-id'); const expectedGenerationId = await runtimeIdentity.getAttribute('data-runtime-generation'); if (runId === null || expectedGenerationId === null) throw new Error('Expected selected Runtime run identity.'); @@ -646,10 +646,10 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', }); const outerFrame = page.locator('.runtime-stage .mcp-app-preview iframe'); - await expect(outerFrame).toBeVisible({ timeout: 15_000 }); + await expect(outerFrame).toBeVisible({ timeout: 15_000 * timeScale }); await expect(outerFrame).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin'); await expect(outerFrame).toHaveAttribute('referrerpolicy', 'no-referrer'); - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 }).toBe('1'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); expect(runtimePreviewSockets).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}/rsbuild-hmr`]); const runtimeAppFrame = async () => { for (const frame of page.frames()) { @@ -657,7 +657,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', } return undefined; }; - await expect.poll(runtimeAppFrame, { timeout: 15_000 }).toBeDefined(); + await expect.poll(runtimeAppFrame, { timeout: 15_000 * timeScale }).toBeDefined(); let appFrame = await runtimeAppFrame(); if (appFrame === undefined) throw new Error('Runtime App frame was unavailable.'); let controllerFrame = appFrame.parentFrame(); @@ -829,7 +829,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect.poll(async () => currentController.evaluate(() => { const nested = [...document.querySelectorAll('iframe')]; return Object.freeze({ nestedCount: nested.length, nestedSandbox: nested[0]?.getAttribute('sandbox') ?? undefined }); - }), { timeout: 15_000 }).toEqual({ nestedCount: 1, nestedSandbox: 'allow-scripts' }); + }), { timeout: 15_000 * timeScale }).toEqual({ nestedCount: 1, nestedSandbox: 'allow-scripts' }); expect(await currentFrame.evaluate(() => Object.freeze({ origin: window.origin, parentDom: (() => { @@ -882,7 +882,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', .sort((left, right) => left.index - right.index) .map(({ name }) => name); }; - await expect.poll(protocolOrder, { timeout: 15_000 }).toEqual([ + await expect.poll(protocolOrder, { timeout: 15_000 * timeScale }).toEqual([ 'ui/initialize request', 'ui/initialize result', 'ui/notifications/initialized', @@ -914,14 +914,14 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', expect(runtimePreviewHmrRoutes).toHaveLength(1); const initialInitializeCount = initializeRequests().length; await runtimePreviewHmrRoutes[0]!.close(); - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 }).toBe('0'); - await expect.poll(() => runtimePreviewHmrRoutes.length, { timeout: 15_000 }).toBe(2); - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 }).toBe('1'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('0'); + await expect.poll(() => runtimePreviewHmrRoutes.length, { timeout: 15_000 * timeScale }).toBe(2); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); runtimePreviewHmrRoutes[1]!.send(JSON.stringify({ type: 'full-reload' })); - await expect.poll(() => initializeRequests().length, { timeout: 15_000 }).toBe(initialInitializeCount + 1); + await expect.poll(() => initializeRequests().length, { timeout: 15_000 * timeScale }).toBe(initialInitializeCount + 1); await expect(outerFrame).toHaveCount(1); - await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps').length, { timeout: 15_000 }).toBe(1); - await expect.poll(runtimeAppFrame, { timeout: 15_000 }).toBeDefined(); + await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps').length, { timeout: 15_000 * timeScale }).toBe(1); + await expect.poll(runtimeAppFrame, { timeout: 15_000 * timeScale }).toBeDefined(); appFrame = await runtimeAppFrame(); if (appFrame === undefined) throw new Error('Runtime App frame did not reinitialize after HMR recovery.'); @@ -971,19 +971,19 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', scope: 'action', summary: 'Call MCP App tool', }); - await expect(page.getByRole('dialog', { name: 'Runtime App consent' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('dialog', { name: 'Runtime App consent' })).toBeVisible({ timeout: 15_000 * timeScale }); const runtimeConsentDialog = page.getByRole('dialog', { name: 'Runtime App consent' }); const denyRuntimeConsent = runtimeConsentDialog.getByRole('button', { name: 'Deny' }); const allowRuntimeConsent = runtimeConsentDialog.getByRole('button', { name: 'Allow once' }); await expect(runtimeConsentDialog).toHaveAttribute('aria-modal', 'true'); await expect(page.locator('.workbench-shell')).toHaveAttribute('inert', ''); - await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 }); + await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Tab'); - await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 }); + await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Tab'); - await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 }); + await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Shift+Tab'); - await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 }); + await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await expect.poll(() => consentResponses('action')).toHaveLength(1); const consentCreated = consentResponses('action')[0]; const challenge = (consentCreated?.response as Readonly<{ readonly challenge?: Readonly<{ readonly id?: unknown }> }> | undefined)?.challenge; @@ -1005,10 +1005,10 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await allowRuntimeConsent.click(); const decisionPath = `${consentPath}/${encodeURIComponent(challenge.id)}`; - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === decisionPath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === decisionPath), { timeout: 15_000 * timeScale }).toHaveLength(1); const consentDecision = runtimeAppRequests.find((entry) => entry.method === 'POST' && entry.path === decisionPath); expect(consentDecision?.body).toEqual({ decision: 'allow-once' }); - await expect.poll(() => runtimeAppResponses.find((entry) => entry.method === 'POST' && entry.path === decisionPath), { timeout: 15_000 }).toBeDefined(); + await expect.poll(() => runtimeAppResponses.find((entry) => entry.method === 'POST' && entry.path === decisionPath), { timeout: 15_000 * timeScale }).toBeDefined(); const consentDecided = runtimeAppResponses.find((entry) => entry.method === 'POST' && entry.path === decisionPath); const grant = (consentDecided?.response as Readonly<{ readonly grant?: Readonly<{ readonly authorizationId?: unknown }> }> | undefined)?.grant; if (typeof grant?.authorizationId !== 'string') throw new Error('Runtime App consent decision response omitted its authorization identity.'); @@ -1030,7 +1030,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const operationResponses = (kind: string): readonly RuntimeAppRouteResponse[] => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === operationPath && entry.body !== null && typeof entry.body === 'object' && (entry.body as Readonly<{ readonly kind?: unknown }>).kind === kind); - await expect.poll(() => operationRequests('tools/call'), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => operationRequests('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(1); const operation = operationRequests('tools/call')[0]; expect(operation?.body).toEqual({ arguments: { limit: 10 }, @@ -1052,7 +1052,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', vector: created.preview.binding.runVector, }); const implementationEvidence = page.getByLabel('Executed by current implementation'); - await expect(implementationEvidence).toBeVisible({ timeout: 15_000 }); + await expect(implementationEvidence).toBeVisible({ timeout: 15_000 * timeScale }); const operationId = (operationResult as Readonly<{ readonly operationId?: unknown }>).operationId; if (typeof operationId !== 'string') throw new Error('Runtime App operation result omitted its public operation identity.'); expect(await implementationEvidence.locator('dd').allTextContents()).toEqual([ @@ -1074,7 +1074,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', method: 'tools/call', params: { _meta: { progressToken: 1 }, arguments: { limit: 10 }, name: 'render_edit_timeline' }, }); - await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === 1 && Object.hasOwn(message, 'result')), { timeout: 15_000 }).toBeDefined(); + await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === 1 && Object.hasOwn(message, 'result')), { timeout: 15_000 * timeScale }).toBeDefined(); const refreshResult = messageFor(controllerOrigin, fixture.url, (message) => message.id === 1 && Object.hasOwn(message, 'result')); expect(refreshResult?.message).toEqual({ jsonrpc: '2.0', id: 1, result: (operationResult as Readonly<{ readonly value: unknown }>).value }); await expect(appFrame.getByText('State version 0')).toBeVisible(); @@ -1087,7 +1087,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', new URL(entry.href).origin === fixture.url && entry.senderOrigin === controllerOrigin && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'tools/call'); await appFrame.getByRole('button', { name: 'Refresh' }).click(); - await expect.poll(() => consentRequests('action'), { timeout: 15_000 }).toHaveLength(2); + await expect.poll(() => consentRequests('action'), { timeout: 15_000 * timeScale }).toHaveLength(2); const deniedConsentCreate = consentRequests('action')[1]; expect(deniedConsentCreate?.body).toEqual({ actionFingerprint: 'runtime-app:call-tool:v1', @@ -1112,26 +1112,26 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', }, }, }); - await expect(page.getByRole('dialog', { name: 'Runtime App consent' })).toBeVisible({ timeout: 15_000 }); - await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 }); + await expect(page.getByRole('dialog', { name: 'Runtime App consent' })).toBeVisible({ timeout: 15_000 * timeScale }); + await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Escape'); const deniedDecisionPath = `${consentPath}/${encodeURIComponent(deniedChallenge.id)}`; - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath), { timeout: 15_000 * timeScale }).toHaveLength(1); expect(runtimeAppRequests.find((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath)?.body).toEqual({ decision: 'deny' }); - await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath), { timeout: 15_000 * timeScale }).toHaveLength(1); const deniedDecision = runtimeAppResponses.find((entry) => entry.method === 'POST' && entry.path === deniedDecisionPath)?.response; expect(deniedDecision).toMatchObject({ documentPolicy: expect.any(Object) }); expect(deniedDecision).not.toHaveProperty('grant'); - await expect(runtimeConsentDialog).toBeHidden({ timeout: 15_000 }); + await expect(runtimeConsentDialog).toBeHidden({ timeout: 15_000 * timeScale }); await expect(page.locator('.workbench-shell')).not.toHaveAttribute('inert', ''); - await expect(outerFrame).toBeFocused({ timeout: 15_000 }); - await expect.poll(toolCallRequests, { timeout: 15_000 }).toHaveLength(2); + await expect(outerFrame).toBeFocused({ timeout: 15_000 * timeScale }); + await expect.poll(toolCallRequests, { timeout: 15_000 * timeScale }).toHaveLength(2); const deniedToolCall = toolCallRequests()[1]; const deniedToolCallId = deniedToolCall?.message !== null && typeof deniedToolCall?.message === 'object' ? (deniedToolCall.message as Readonly>).id : undefined; if (typeof deniedToolCallId !== 'string' && typeof deniedToolCallId !== 'number') throw new Error('Denied Runtime App tool request omitted its JSON-RPC id.'); - await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === deniedToolCallId && Object.hasOwn(message, 'error')), { timeout: 15_000 }).toBeDefined(); + await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === deniedToolCallId && Object.hasOwn(message, 'error')), { timeout: 15_000 * timeScale }).toBeDefined(); expect(messageFor(controllerOrigin, fixture.url, (message) => message.id === deniedToolCallId && Object.hasOwn(message, 'error'))?.message).toMatchObject({ error: { code: expect.any(Number), message: expect.any(String) }, id: deniedToolCallId, @@ -1172,10 +1172,10 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await appFrame.evaluate(({ id, uri }) => { window.parent.postMessage({ id, jsonrpc: '2.0', method: 'resources/read', params: { uri } }, '*'); }, { id: resourceRequestId, uri: resourceUri }); - await expect.poll(() => operationRequests('resources/read'), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => operationRequests('resources/read'), { timeout: 15_000 * timeScale }).toHaveLength(1); const resourceOperation = operationRequests('resources/read')[0]; expect(resourceOperation?.body).toEqual({ kind: 'resources/read', uri: resourceUri }); - await expect.poll(() => operationResponses('resources/read'), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => operationResponses('resources/read'), { timeout: 15_000 * timeScale }).toHaveLength(1); const resourceOperationResult = (operationResponses('resources/read')[0]?.response as Readonly<{ readonly result?: unknown }> | undefined)?.result; expect(resourceOperationResult).toMatchObject({ operationId: expect.any(String), @@ -1206,7 +1206,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', method: 'resources/read', params: { uri: resourceUri }, }); - await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === resourceRequestId && Object.hasOwn(message, 'result')), { timeout: 15_000 }).toBeDefined(); + await expect.poll(() => messageFor(controllerOrigin, fixture.url, (message) => message.id === resourceRequestId && Object.hasOwn(message, 'result')), { timeout: 15_000 * timeScale }).toBeDefined(); const resourceResponse = messageFor(controllerOrigin, fixture.url, (message) => message.id === resourceRequestId && Object.hasOwn(message, 'result')); expect(resourceResponse?.message).toEqual({ id: resourceRequestId, @@ -1220,11 +1220,11 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const sourceFrameHref = controllerFrame.url(); const sourceBindingId = created.preview.binding.id; await page.getByRole('button', { name: 'Open in MCP playground' }).click({ timeout: browserTimeout }); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: 15_000 * timeScale }); const teardownRequestForSource = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === sourceFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); - await expect.poll(teardownRequestForSource, { timeout: 15_000 }).toBeDefined(); + await expect.poll(teardownRequestForSource, { timeout: 15_000 * timeScale }).toBeDefined(); const teardownRequest = teardownRequestForSource(); const teardownId = teardownRequest?.message !== null && typeof teardownRequest?.message === 'object' ? (teardownRequest.message as Readonly>).id @@ -1232,29 +1232,29 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', if (typeof teardownId !== 'string' && typeof teardownId !== 'number') throw new Error('Runtime App teardown request omitted its JSON-RPC id.'); const teardownAcknowledgementForSource = () => messageFor(fixture.url, controllerOrigin, (message) => message.id === teardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))); - await expect.poll(teardownAcknowledgementForSource, { timeout: 15_000 }).toBeDefined(); + await expect.poll(teardownAcknowledgementForSource, { timeout: 15_000 * timeScale }).toBeDefined(); const teardownAcknowledgement = teardownAcknowledgementForSource(); expect(teardownAcknowledgement?.message).toEqual({ id: teardownId, jsonrpc: '2.0', result: {} }); const sourceDeletePath = `/api/runtime/apps/${encodeURIComponent(sourceBindingId)}`; - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === sourceDeletePath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === sourceDeletePath), { timeout: 15_000 * timeScale }).toHaveLength(1); const sourceDelete = runtimeAppRequests.find((entry) => entry.method === 'DELETE' && entry.path === sourceDeletePath); const lifecycleIndex = (kind: RuntimeAppLifecycleEvent['kind'], value: RuntimeAppMessage | RuntimeAppRouteRequest): number => runtimeAppLifecycleEvents.findIndex((entry) => entry.kind === kind && entry.value === value); expect(lifecycleIndex('message', teardownRequest!)).toBeGreaterThan(-1); expect(lifecycleIndex('message', teardownAcknowledgement!)).toBeGreaterThan(lifecycleIndex('message', teardownRequest!)); expect(lifecycleIndex('request', sourceDelete!)).toBeGreaterThan(lifecycleIndex('message', teardownAcknowledgement!)); - await expect(outerFrame).toHaveCount(0, { timeout: 15_000 }); + await expect(outerFrame).toHaveCount(0, { timeout: 15_000 * timeScale }); const runtimeCreates = (): readonly RuntimeAppRouteRequest[] => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps'); - await expect.poll(runtimeCreates, { timeout: 15_000 }).toHaveLength(2); + await expect.poll(runtimeCreates, { timeout: 15_000 * timeScale }).toHaveLength(2); const destinationCreate = runtimeCreates()[1]; expect(destinationCreate?.body).toEqual({ expectedGenerationId, profileId: 'portable', runId }); const sourceDeleteIndex = runtimeAppRequests.indexOf(sourceDelete!); const destinationCreateIndex = runtimeAppRequests.indexOf(destinationCreate!); expect(sourceDeleteIndex).toBeGreaterThan(-1); expect(destinationCreateIndex).toBeGreaterThan(sourceDeleteIndex); - await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps'), { timeout: 15_000 }).toHaveLength(2); + await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps'), { timeout: 15_000 * timeScale }).toHaveLength(2); const destinationResponse = runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps')[1]?.response as Readonly<{ readonly preview?: Readonly<{ readonly binding?: Readonly<{ readonly id?: unknown; readonly sessionId?: unknown; readonly sessionRevision?: unknown }>; readonly clientSurface?: Readonly<{ readonly origin?: unknown }>; @@ -1270,14 +1270,14 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', sessionRevision: created.preview.binding.sessionRevision, }); expect(destinationBinding.id).not.toBe(sourceBindingId); - await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(1, { timeout: 15_000 }); + await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(1, { timeout: 15_000 * timeScale }); await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(0); await expect.poll(() => appMessages.filter((entry) => new URL(entry.href).origin === fixture.url && entry.senderOrigin === destinationOrigin && entry.message !== null && typeof entry.message === 'object' && - (entry.message as Readonly>).method === 'ui/initialize').length, { timeout: 15_000 }).toBe(controllerOrigin === destinationOrigin ? 2 : 1); + (entry.message as Readonly>).method === 'ui/initialize').length, { timeout: 15_000 * timeScale }).toBe(controllerOrigin === destinationOrigin ? 2 : 1); await page.setViewportSize({ height: 900, width: 390 }); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), { timeout: 15_000 }).toBe(true); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), { timeout: 15_000 * timeScale }).toBe(true); const destinationAppFrame = async () => { for (const frame of page.frames()) { @@ -1286,7 +1286,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', } return undefined; }; - await expect.poll(destinationAppFrame, { timeout: 15_000 }).toBeDefined(); + await expect.poll(destinationAppFrame, { timeout: 15_000 * timeScale }).toBeDefined(); const destinationFrame = await destinationAppFrame(); if (destinationFrame === undefined) throw new Error('Destination Runtime App frame was unavailable.'); const destinationController = destinationFrame.parentFrame(); @@ -1298,7 +1298,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const teardownRequestForDestination = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === destinationFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); - await expect.poll(teardownRequestForDestination, { timeout: 15_000 }).toBeDefined(); + await expect.poll(teardownRequestForDestination, { timeout: 15_000 * timeScale }).toBeDefined(); const destinationTeardown = teardownRequestForDestination(); const destinationTeardownId = destinationTeardown?.message !== null && typeof destinationTeardown?.message === 'object' ? (destinationTeardown.message as Readonly>).id @@ -1306,25 +1306,25 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', if (typeof destinationTeardownId !== 'string' && typeof destinationTeardownId !== 'number') throw new Error('Destination Runtime App teardown request omitted its JSON-RPC id.'); const destinationAcknowledgement = () => messageFor(fixture.url, destinationOrigin, (message) => message.id === destinationTeardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))); - await expect.poll(destinationAcknowledgement, { timeout: 15_000 }).toBeDefined(); + await expect.poll(destinationAcknowledgement, { timeout: 15_000 * timeScale }).toBeDefined(); expect(destinationAcknowledgement()?.message).toEqual({ id: destinationTeardownId, jsonrpc: '2.0', result: {} }); - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === destinationDeletePath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === destinationDeletePath), { timeout: 15_000 * timeScale }).toHaveLength(1); const destinationDelete = runtimeAppRequests.find((entry) => entry.method === 'DELETE' && entry.path === destinationDeletePath); - await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: 15_000 * timeScale }); await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(0); expect(runtimeCreates()).toHaveLength(2); await page.evaluate(() => { window.location.hash = '#runtime'; }); - await expect.poll(runtimeCreates, { timeout: 15_000 }).toHaveLength(3); + await expect.poll(runtimeCreates, { timeout: 15_000 * timeScale }).toHaveLength(3); const thirdCreate = runtimeCreates()[2]; expect(thirdCreate?.body).toEqual({ expectedGenerationId, profileId: 'portable', runId }); expect(lifecycleIndex('message', destinationTeardown!)).toBeGreaterThan(-1); expect(lifecycleIndex('message', destinationAcknowledgement()!)).toBeGreaterThan(lifecycleIndex('message', destinationTeardown!)); expect(lifecycleIndex('request', destinationDelete!)).toBeGreaterThan(lifecycleIndex('message', destinationAcknowledgement()!)); expect(lifecycleIndex('request', thirdCreate!)).toBeGreaterThan(lifecycleIndex('request', destinationDelete!)); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 * timeScale }); await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(0); - await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(1, { timeout: 15_000 }); - await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps'), { timeout: 15_000 }).toHaveLength(3); + await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(1, { timeout: 15_000 * timeScale }); + await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps'), { timeout: 15_000 * timeScale }).toHaveLength(3); const thirdResponse = runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps')[2]?.response as Readonly<{ readonly preview?: Readonly<{ readonly binding?: Readonly<{ readonly id?: unknown; readonly sessionId?: unknown; readonly sessionRevision?: unknown }>; readonly clientSurface?: Readonly<{ readonly origin?: unknown }>; @@ -1345,7 +1345,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', } return undefined; }; - await expect.poll(thirdAppFrame, { timeout: 15_000 }).toBeDefined(); + await expect.poll(thirdAppFrame, { timeout: 15_000 * timeScale }).toBeDefined(); const thirdFrame = await thirdAppFrame(); if (thirdFrame === undefined) throw new Error('Third Runtime App frame was unavailable.'); const thirdController = thirdFrame.parentFrame(); @@ -1356,7 +1356,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const teardownRequestForThird = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === thirdFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); - await expect.poll(teardownRequestForThird, { timeout: 15_000 }).toBeDefined(); + await expect.poll(teardownRequestForThird, { timeout: 15_000 * timeScale }).toBeDefined(); const thirdTeardown = teardownRequestForThird(); const thirdTeardownId = thirdTeardown?.message !== null && typeof thirdTeardown?.message === 'object' ? (thirdTeardown.message as Readonly>).id @@ -1364,18 +1364,18 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', if (typeof thirdTeardownId !== 'string' && typeof thirdTeardownId !== 'number') throw new Error('Third Runtime App teardown request omitted its JSON-RPC id.'); const thirdAcknowledgement = () => messageFor(fixture.url, thirdOrigin, (message) => message.id === thirdTeardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))); - await expect.poll(thirdAcknowledgement, { timeout: 15_000 }).toBeDefined(); - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === thirdDeletePath), { timeout: 15_000 }).toHaveLength(1); + await expect.poll(thirdAcknowledgement, { timeout: 15_000 * timeScale }).toBeDefined(); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === thirdDeletePath), { timeout: 15_000 * timeScale }).toHaveLength(1); const thirdDelete = runtimeAppRequests.find((entry) => entry.method === 'DELETE' && entry.path === thirdDeletePath); expect(lifecycleIndex('message', thirdAcknowledgement()!)).toBeGreaterThan(lifecycleIndex('message', thirdTeardown!)); expect(lifecycleIndex('request', thirdDelete!)).toBeGreaterThan(lifecycleIndex('message', thirdAcknowledgement()!)); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: 15_000 * timeScale }); await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(0); await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(0); await expect(page.getByLabel('Runtime-bound MCP session')).toContainText(`${created.preview.binding.sessionId} · revision ${created.preview.binding.sessionRevision}`); await expect(page.getByRole('region', { name: 'Invocation history' })).toHaveText(destinationHistory ?? ''); expect(runtimeCreates()).toHaveLength(3); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), { timeout: 15_000 }).toBe(true); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), { timeout: 15_000 * timeScale }).toBe(true); expect(artifactMcpSessionRequests).toEqual([]); expect(runtimeMcpSessionRequests).toEqual([]); @@ -1432,11 +1432,11 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on }); try { await page.goto(`${fixture.url}#runtime`); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: 15_000 * timeScale }); const runtimeIdentity = page.locator('[data-runtime-provider-session]'); const runtimeSurface = page.getByLabel('Runtime surface'); const runtimeProfile = page.getByLabel('Runtime profile'); - await expect(runtimeIdentity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: 15_000 }); + await expect(runtimeIdentity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: 15_000 * timeScale }); await runtimeSurface.selectOption('mcp.render_edit_timeline'); await page.getByLabel('Runtime target').selectOption('portable'); await expect(runtimeProfile).toHaveValue('portable'); @@ -1446,7 +1446,7 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on await page.locator('#runtime-input-raw').fill('{}'); await page.getByRole('button', { name: 'Run', exact: true }).click(); const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - await expect(history).toHaveCount(1, { timeout: 15_000 }); + await expect(history).toHaveCount(1, { timeout: 15_000 * timeScale }); const runId = await history.first().getAttribute('data-runtime-run-id'); const expectedGenerationId = await runtimeIdentity.getAttribute('data-runtime-generation'); if (runId === null || expectedGenerationId === null) throw new Error('Runtime profile matrix did not expose the selected run authority.'); @@ -1496,23 +1496,23 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on const teardown = () => appMessages.find((entry) => entry.href === retiring.controllerHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); - await expect.poll(teardown, { timeout: 15_000 }).toBeDefined(); + await expect.poll(teardown, { timeout: 15_000 * timeScale }).toBeDefined(); const teardownId = teardown()?.message !== null && typeof teardown()?.message === 'object' ? (teardown()!.message as Readonly>).id : undefined; if (typeof teardownId !== 'string' && typeof teardownId !== 'number') throw new Error('Retiring Runtime App teardown omitted its JSON-RPC id.'); await expect.poll(() => messageFor(fixture.url, retiring.origin, (message) => - message.id === teardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))), { timeout: 15_000 }).toBeDefined(); + message.id === teardownId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))), { timeout: 15_000 * timeScale }).toBeDefined(); const deletePath = `/api/runtime/apps/${encodeURIComponent(retiring.bindingId)}`; - await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === deletePath), { timeout: 15_000 }).toHaveLength(1); - await expect.poll(creates, { timeout: 15_000 }).toHaveLength(index + 1); + await expect.poll(() => runtimeAppRequests.filter((entry) => entry.method === 'DELETE' && entry.path === deletePath), { timeout: 15_000 * timeScale }).toHaveLength(1); + await expect.poll(creates, { timeout: 15_000 * timeScale }).toHaveLength(index + 1); const replacement = creates()[index]; const retired = runtimeAppRequests.find((entry) => entry.method === 'DELETE' && entry.path === deletePath); if (replacement === undefined || retired === undefined) throw new Error('Runtime profile replacement routes were not recorded.'); expect(runtimeAppRequests.indexOf(retired)).toBeLessThan(runtimeAppRequests.indexOf(replacement)); } - await expect.poll(createResponses, { timeout: 15_000 }).toHaveLength(index + 1); + await expect.poll(createResponses, { timeout: 15_000 * timeScale }).toHaveLength(index + 1); const create = creates()[index]; expect(create?.body).toEqual({ expectedGenerationId, profileId: profile.id, runId }); const snapshot = responseFor(index)?.preview; @@ -1555,8 +1555,8 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on expect(registeredText).not.toContain(hidden); } - await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(1, { timeout: 15_000 }); - await expect.poll(() => runtimeFrameFor(origin), { timeout: 15_000 }).toBeDefined(); + await expect(page.locator('.runtime-stage .mcp-app-preview iframe')).toHaveCount(1, { timeout: 15_000 * timeScale }); + await expect.poll(() => runtimeFrameFor(origin), { timeout: 15_000 * timeScale }).toBeDefined(); const appFrame = await runtimeFrameFor(origin); if (appFrame === undefined) throw new Error(`Runtime ${profile.id} profile App frame was unavailable.`); const controller = appFrame.parentFrame(); @@ -1592,12 +1592,12 @@ e2e('keeps Portable, ChatGPT, and Claude simulated App profiles isolated over on const resourceRequests = (): readonly RuntimeAppRouteRequest[] => runtimeAppRequests.filter((entry) => entry.method === 'POST' && entry.path === operationPath && entry.body !== null && typeof entry.body === 'object' && (entry.body as Readonly<{ readonly kind?: unknown }>).kind === 'resources/read'); - await expect.poll(resourceRequests, { timeout: 15_000 }).toHaveLength(1); + await expect.poll(resourceRequests, { timeout: 15_000 * timeScale }).toHaveLength(1); expect(resourceRequests()[0]?.body).toEqual({ kind: 'resources/read', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }); const resourceResponses = (): readonly RuntimeAppRouteResponse[] => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === operationPath && entry.body !== null && typeof entry.body === 'object' && (entry.body as Readonly<{ readonly kind?: unknown }>).kind === 'resources/read'); - await expect.poll(resourceResponses, { timeout: 15_000 }).toHaveLength(1); + await expect.poll(resourceResponses, { timeout: 15_000 * timeScale }).toHaveLength(1); expect((resourceResponses()[0]?.response as Readonly<{ readonly result?: unknown }> | undefined)?.result).toMatchObject({ sessionId: binding.sessionId, sessionRevision: binding.sessionRevision, From 1484ad9dfa33bd3b175e1764c55344d4a0ae7b8e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 22:27:48 +0000 Subject: [PATCH 06/11] fix(workbench): resolve the vendored inspector as a real package The unit harness had no @inspector aliases, and rstest silently downgrades an unresolvable specifier to a runtime CJS require - so every unit-pool bundle of the runtime playground carried a require of '@inspector/core/json/xMcpHeader.js' that threw on each execution. React's Suspense boundary absorbed the throw on green runs and let it escape as an unhandled rejection under CI timing, which is the 'Cannot find module' flake. Instead of copying alias blocks into a fourth config, the vendored inspector core is now a private workspace package whose exports map serves the .js specifiers from the .ts sources, so every resolver - rsbuild, every rstest config, the browser test harnesses, and plain Node - finds it through node_modules; all five alias declarations are deleted. The runtime contract-compile test also stops racing the worker's post-file unhandled-rejection check: its stub answers every route with the status wrapper, so the deliberately rejecting bootstrap fan-out is now awaited and asserted instead of left dangling. --- .../examples/rsc-agent-runtime/README.md | 250 ++ .../rsc-agent-runtime/agent-bundle.config.ts | 41 + .../examples/rsc-agent-runtime/node_modules | 1 + .../examples/rsc-agent-runtime/package.json | 37 + .../claude/.claude-plugin/plugin.json | 7 + .../packaging/claude/.mcp.json | 9 + .../packaging/claude/hooks/hooks.json | 16 + .../codex/.agents/plugins/marketplace.json | 12 + .../packaging/codex/.codex-plugin/plugin.json | 18 + .../packaging/codex/.mcp.json | 10 + .../packaging/codex/hooks/hooks.json | 16 + .../rsc-agent-runtime/rsbuild.config.ts | 291 ++ .../rsc-agent-runtime/rstest.config.ts | 7 + .../scripts/capture-widget.mjs | 231 ++ .../scripts/eval-evidence.mjs | 241 ++ .../scripts/eval-host-environment.mjs | 49 + .../scripts/eval-host-paths.mjs | 4 + .../rsc-agent-runtime/scripts/eval-hosts.mjs | 169 + .../scripts/package-hosts.mjs | 75 + .../src/build/emit-artifacts.ts | 67 + .../src/build/serialize-definition.ts | 43 + .../rsc-agent-runtime/src/definition.ts | 97 + .../src/dev/definition-entry.ts | 23 + .../src/dev/generation-materializer.ts | 1027 ++++++ .../src/dev/inspection-security.ts | 29 + .../src/dev/invocation-worker.ts | 236 ++ .../rsc-agent-runtime/src/dev/provider.ts | 13 + .../src/dev/rsbuild-runtime-session.ts | 2830 +++++++++++++++++ .../src/dev/serialize-inspection.ts | 249 ++ .../src/flight/request-render.ts | 190 ++ .../rsc-agent-runtime/src/hook/cli.ts | 86 + .../rsc-agent-runtime/src/hook/normalize.ts | 93 + .../src/mcp/create-server.ts | 76 + .../rsc-agent-runtime/src/mcp/handlers.ts | 33 + .../src/mcp/host-metadata.ts | 93 + .../src/mcp/http-security.ts | 84 + .../rsc-agent-runtime/src/mcp/http.ts | 55 + .../src/mcp/resolve-state.ts | 51 + .../rsc-agent-runtime/src/mcp/stdio.ts | 14 + .../src/rsc/client-anchor.ts | 3 + .../rsc-agent-runtime/src/rsc/components.tsx | 41 + .../rsc-agent-runtime/src/rsc/routes.tsx | 20 + .../rsc-agent-runtime/src/rsc/worker.tsx | 149 + .../src/runtime/contracts.ts | 216 ++ .../src/runtime/request-context.ts | 17 + .../src/runtime/state-file-core.ts | 781 +++++ .../src/runtime/state-file-test-support.ts | 101 + .../src/runtime/state-file.ts | 58 + .../src/types/mcp-ext-apps-react.d.ts | 19 + .../src/types/react-server-dom-rspack.d.ts | 24 + .../rsc-agent-runtime/src/types/styles.d.ts | 1 + .../rsc-agent-runtime/src/widget/App.tsx | 202 ++ .../src/widget/host-adapters.ts | 74 + .../rsc-agent-runtime/src/widget/index.tsx | 11 + .../rsc-agent-runtime/src/widget/styles.css | 238 ++ .../tests/dev-invocation.integration.test.ts | 2155 +++++++++++++ .../tests/dev-provider.integration.test.ts | 1683 ++++++++++ .../tests/docs-contract.test.ts | 67 + .../tests/eval-evidence.test.ts | 592 ++++ .../tests/fixtures/state-lock-owner.mjs | 31 + .../tests/fixtures/state-settlement-exit.ts | 27 + .../tests/generation-materializer.test.ts | 983 ++++++ .../tests/host-artifacts.test.ts | 307 ++ .../tests/host-extensions.test.tsx | 86 + .../tests/http-security.test.ts | 25 + .../tests/mcp-lowering.test.tsx | 175 + .../tests/mcp-transports.integration.test.ts | 426 +++ .../tests/micro-eval.spot.test.ts | 87 + .../tests/rsc-hook.integration.test.ts | 331 ++ .../tests/runtime-artifact-manifest.test.ts | 73 + .../tests/state-and-definition.test.ts | 1083 +++++++ .../tests/support/copy-example.ts | 34 + .../tests/tsconfig-coverage.test.ts | 15 + .../tests/widget-accessibility.test.tsx | 16 + .../examples/rsc-agent-runtime/tsconfig.json | 13 + .runtime-playground-vH2Kdl/node_modules | 1 + .runtime-playground-vH2Kdl/packages | 1 + .runtime-playground-vH2Kdl/tsconfig.base.json | 15 + .runtime-playground-vH2Kdl/tsconfig.json | 14 + packages/workbench/package.json | 1 + packages/workbench/rsbuild.config.ts | 9 - .../src/inspector/vendor/core/package.json | 11 + .../tests/runtime-contract-compile.test.ts | 6 +- .../support/workbench-browser-modules.ts | 4 - pnpm-lock.yaml | 5 + pnpm-workspace.yaml | 1 + rstest.runtime-playground.browser.config.ts | 4 - rstest.runtime-playground.config.ts | 4 - 88 files changed, 17091 insertions(+), 22 deletions(-) create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts create mode 120000 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx create mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json create mode 120000 .runtime-playground-vH2Kdl/node_modules create mode 120000 .runtime-playground-vH2Kdl/packages create mode 100644 .runtime-playground-vH2Kdl/tsconfig.base.json create mode 100644 .runtime-playground-vH2Kdl/tsconfig.json create mode 100644 packages/workbench/src/inspector/vendor/core/package.json diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md new file mode 100644 index 000000000..8d18e9024 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md @@ -0,0 +1,250 @@ +# RSC Agent Runtime demo + +This private, opt-in example shows one React Server Components (RSC) runtime shared by native file-edit hooks, MCP tools, and an MCP App timeline. It is an architecture experiment, not an `agent-bundle` public API. + +## Four planes + +| Plane | Responsibility | Lifetime | +| --- | --- | --- | +| Definition | Static hook matchers, tool schemas, resource URIs, and metadata | Build/startup | +| Kernel | Append-only JSONL events and snapshots | Cross-process | +| RSC render | Hook and MCP result component trees, lowered from Flight | One request | +| MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance | + +Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker, lowers the Flight result, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls. + +```tsx +// A Hook JSX route reads request-scoped context. +import { Hook } from '@agent-bundle/rsc-runtime'; +import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; + +export function AfterFileEdit() { + const edit = useEdit(); + const snapshot = useRuntimeSnapshot(); + return ( + + + Recorded {edit.path}; {snapshot.edits.length} edits exist. + + + ); +} +``` + +```tsx +// An MCP JSX route describes protocol blocks, not browser HTML. +import { Mcp } from '@agent-bundle/rsc-runtime'; + +export function RenderTimeline({ snapshot }: { snapshot: { edits: unknown[]; stateVersion: number } }) { + return ( + + {`Showing ${snapshot.edits.length} edits.`} + + ); +} +``` + +## Run locally + +From the repository root: + +```bash +pnpm --filter @agent-bundle/rsc-agent-runtime-demo build +pnpm --filter @agent-bundle/rsc-agent-runtime-demo test +pnpm --filter @agent-bundle/rsc-agent-runtime-demo typecheck +pnpm --filter @agent-bundle/rsc-agent-runtime-demo capture:widget -- --output /tmp/rsc-agent-runtime-widget.png +pnpm docs:runtime-topology +``` + +For contributor Workbench/HMR evidence, use the repository fixture rather than +the published package: + +```bash +node packages/workbench/scripts/capture-runtime-playground.mjs \ + --desktop "$PWD/docs/assets/rsc-runtime-workbench/desktop.png" \ + --mobile "$PWD/docs/assets/rsc-runtime-workbench/mobile.png" \ + --hmr-before "$PWD/docs/assets/rsc-runtime-workbench/hmr-before.png" \ + --hmr-after "$PWD/docs/assets/rsc-runtime-workbench/hmr-after.png" \ + --compile-error "$PWD/docs/assets/rsc-runtime-workbench/compile-error.png" \ + --recovered "$PWD/docs/assets/rsc-runtime-workbench/recovered.png" \ + --evidence /tmp/rsc-runtime-delivery/evidence.json +``` + +The published Agent Bundle library is built with Rslib. This example's separate +production RSC/runtime artifacts are built by its explicit Rsbuild production +command (`pnpm --filter @agent-bundle/rsc-agent-runtime-demo build`); its provider +uses a separate long-lived Rsbuild development/HMR session only when an +`agent-bundle dev` project opts into `dev.runtime.provider`. Installing +`agent-bundle` alone does not install or activate this example provider. See +[the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) +for the full ownership boundary. + +The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and two self-contained native plugin artifacts under `dist/plugins`. It runs `package:hosts` automatically; it can also be run directly: + +```bash +pnpm --filter @agent-bundle/rsc-agent-runtime-demo package:hosts +``` + +To exercise one hook manually, give it an explicit state file and native Claude-shaped JSON: + +```bash +AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \ + node examples/rsc-agent-runtime/dist/runtime/hook/index.js --host claude <.apps` compiler. It compiles self-contained HTML and exposes it through the virtual `agent-bundle/mcp-apps` resource lane without React/RSC runtime requirements. Opt into this paired RSC runtime only when hooks or MCP tool results genuinely need RSC Flight and shared runtime behavior. + +## Limits and opt-in boundary + +The demo kernel is append-only JSONL: it is appropriate for a small local example, not concurrent/distributed production storage. The RSC-facing packages are exact pins because their framework-facing surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`, `react-server-dom-rspack` `0.1.0`, Rsbuild `2.2.1`, and `rsbuild-plugin-rsc` `0.1.1`. + +Existing Agent Bundle skills, static MCPs, evaluations, and normal hooks neither require nor activate this runtime. Nothing under `packages/agent-bundle` imports the example or React/RSC runtime packages. + +`PlaygroundService` is the landed, provider-neutral durable whole-plugin +authoring timeline foundation. Runtime Playground history is deliberately +provider-session-scoped and ephemeral in this example; wiring a provider +adapter, authenticated API, timeline UI, durable Runtime export, or evaluation +promotion onto that history is an explicit non-goal of this demo. + +## Sources + +- [Rsbuild React Server Components plugin](https://www.npmjs.com/package/rsbuild-plugin-rsc) +- [MCP Apps patterns and host context](https://apps.extensions.modelcontextprotocol.io/api/documents/Patterns.html) +- [OpenAI plugin UI / ChatGPT MCP Apps guidance](https://developers.openai.com/plugins/build/chatgpt-ui) +- [Claude MCP Apps cross-compatibility](https://claude.com/docs/connectors/building/mcp-apps/cross-compatibility) and [design guidance](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines) +- [Claude Code hooks](https://code.claude.com/docs/en/hooks) +- [Codex CLI documentation](https://developers.openai.com/codex/cli) +- [Codex 0.147.0 `apply_patch` PostToolUse payload](https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/core/src/tools/handlers/apply_patch.rs#L2237-L2264) and [analogous hook issue #26729](https://github.com/openai/codex/issues/26729) diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts new file mode 100644 index 000000000..5f8a67e75 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'agent-bundle/config'; + +export default defineConfig({ + claude: {}, + codex: {}, + dev: { runtime: { provider: './src/dev/provider.ts' } }, + hooks: { + afterTool: { + handler: './src/hook/cli.ts', + targets: ['claude', 'codex'], + tools: ['file.write'], + }, + }, + mcp: { + servers: { + timeline: { + apps: { + timeline: { + _meta: { + 'openai/widgetDescription': 'Interactive timeline of recorded file edits.', + }, + entry: './src/widget/index.tsx', + resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + targets: ['portable', 'claude', 'codex'], + }, + }, + entry: './src/mcp/stdio.ts', + targets: ['portable', 'claude', 'codex'], + transport: 'stdio', + }, + }, + }, + portable: {}, + plugin: { + description: 'React Server Components agent runtime demonstration.', + name: 'rsc-agent-runtime-demo', + version: '1.0.0', + }, + skills: [], + targets: ['portable', 'claude', 'codex'], +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules new file mode 120000 index 000000000..c20969271 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules @@ -0,0 +1 @@ +/fast/projects/agent-bundle/examples/rsc-agent-runtime/node_modules \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json new file mode 100644 index 000000000..228aebc70 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json @@ -0,0 +1,37 @@ +{ + "name": "@agent-bundle/rsc-agent-runtime-demo", + "private": true, + "type": "module", + "scripts": { + "build": "rsbuild build --mode production && pnpm package:hosts", + "package:hosts": "node scripts/package-hosts.mjs", + "test": "rstest --config rstest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "check": "pnpm build && pnpm test && pnpm typecheck", + "eval:hosts": "node scripts/eval-hosts.mjs", + "capture:widget": "node scripts/capture-widget.mjs" + }, + "dependencies": { + "@agent-bundle/rsc-runtime": "workspace:*", + "@modelcontextprotocol/ext-apps": "1.7.5", + "@modelcontextprotocol/sdk": "1.30.0", + "express": "5.2.1", + "proper-lockfile": "^4.1.2", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-server-dom-rspack": "0.1.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@rsbuild/core": "2.2.1", + "@rsbuild/plugin-react": "2.1.0", + "@rstest/core": "0.11.10", + "@types/express": "5.0.6", + "@types/proper-lockfile": "^4.1.4", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "agent-bundle": "workspace:*", + "playwright-core": "1.62.1", + "rsbuild-plugin-rsc": "0.1.1" + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json new file mode 100644 index 000000000..7d1cc63ba --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "rsc-agent-runtime", + "version": "0.1.0", + "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", + "author": { "name": "Agent Bundle" }, + "hooks": "./hooks/hooks.json" +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json new file mode 100644 index 000000000..086579eb1 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "rsc-agent-runtime": { + "type": "stdio", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js"] + } + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json new file mode 100644 index 000000000..ceb2b1e19 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/runtime/hook/index.js\" --host claude", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json new file mode 100644 index 000000000..196c9eac0 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "rsc-agent-runtime-marketplace", + "interface": { "displayName": "RSC Agent Runtime" }, + "plugins": [ + { + "name": "rsc-agent-runtime", + "category": "Productivity", + "source": { "source": "local", "path": "./" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" } + } + ] +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json new file mode 100644 index 000000000..60b1fd87c --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "rsc-agent-runtime", + "version": "0.1.0", + "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", + "author": { "name": "Agent Bundle" }, + "interface": { + "displayName": "RSC Agent Runtime", + "shortDescription": "Shared-state RSC hooks and MCP runtime demo.", + "longDescription": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", + "developerName": "Agent Bundle", + "category": "Productivity", + "capabilities": ["mcp", "hooks"], + "defaultPrompt": ["Show the recent RSC runtime edit timeline."] + }, + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", + "skills": "./skills/" +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json new file mode 100644 index 000000000..1a9819efe --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "rsc-agent-runtime": { + "type": "stdio", + "command": "node", + "args": ["./runtime/mcp/stdio.js"], + "cwd": "./" + } + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json new file mode 100644 index 000000000..fda568697 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "apply_patch", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/runtime/hook/index.js\" --host codex", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts new file mode 100644 index 000000000..d85acca9d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts @@ -0,0 +1,291 @@ +import { createHash } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { defineConfig, type RsbuildConfig, type RsbuildDevServer, type RsbuildPlugin } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { Layers, pluginRSC } from 'rsbuild-plugin-rsc'; + +import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js'; + +export interface RscRuntimeCompileSnapshot { + readonly acceptCompilerAssetCheckpoint?: () => void; + readonly attemptId: string; + readonly candidateId: string; + readonly discardCompilerAssetCheckpoint?: () => void; + readonly preparedRevision: string; + readonly rscCohortRevision: number; + readonly sourceRevision: string; +} + +export type RscRuntimeActivationOutcome = 'activated' | 'failed'; +export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build'; + +export interface RscRuntimeRsbuildConfigOptions { + readonly compilerRoot?: string; + readonly mode: 'development' | 'production'; + /** Receives the App environment's server-only Rsbuild HMR credential. */ + readonly onAppWebSocketToken?: (token: string) => void; + readonly onCompile?: Readonly<{ + beforeAttempt(): string; + capture(input: { + readonly attemptId: string; + readonly cohortChanged: boolean; + readonly hasErrors: boolean; + readonly sourceRevision: string; + }): Promise; + /** Queues provider activation but never blocks the Rsbuild compile hook. */ + enqueue(snapshot: RscRuntimeCompileSnapshot): unknown; + failAttempt(attemptId: string, error: unknown, kind: RscRuntimeCompileFailureKind): void; + }>; +} + +const runtimeAppHmrTokenPlugin = ( + capture: NonNullable, +): RsbuildPlugin => { + let devServer: RsbuildDevServer | undefined; + let lastAppCompilation: object | string | undefined; + return { + name: 'agent-bundle:rsc-runtime-app-hmr-token', + setup(api) { + api.onAfterCreateCompiler(({ environments }) => { + const token = environments.app?.webSocketToken; + if (typeof token !== 'string') throw new Error('RSC runtime App compiler did not expose an HMR credential.'); + capture(token); + }); + api.onBeforeStartDevServer(({ server }) => { + devServer = server; + lastAppCompilation = undefined; + }); + api.onCloseDevServer(() => { + devServer = undefined; + lastAppCompilation = undefined; + }); + api.onAfterEnvironmentCompile(({ environment, isFirstCompile, stats }) => { + if (devServer === undefined || environment.name !== 'app' || stats === undefined || stats.hasErrors()) return; + const compilation = typeof stats.hash === 'string' && stats.hash.length > 0 ? stats.hash : stats; + if (lastAppCompilation === compilation) return; + lastAppCompilation = compilation; + if (isFirstCompile) return; + devServer?.environments.app.hot.send('full-reload'); + }); + }, + }; +}; + +const emitRuntimeManifest = (): RsbuildPlugin => ({ + apply: 'build', + name: 'emit-rsc-agent-runtime-manifest', + setup(api) { + api.onBeforeBuild(async ({ environments }) => { + await rm(dirname(environments.rsc.distPath), { force: true, recursive: true }); + }); + api.onAfterBuild(async ({ environments }) => { + await emitRuntimeArtifacts(environments.rsc.distPath); + }); + }, +}); + +const runtimeCompileObserverPlugin = ( + observer: NonNullable, +): RsbuildPlugin => { + const pendingAttemptIds: string[] = []; + let capturedCohort: Readonly<{ readonly activationSequence: number; readonly sourceRevision: string }> | undefined; + let nextActivationSequence = 0; + return { + name: 'agent-bundle:rsc-runtime-compile-observer', + setup(api) { + api.onBeforeDevCompile(() => { + pendingAttemptIds.push(observer.beforeAttempt()); + }); + api.onAfterDevCompile(async ({ stats }) => { + const attemptId = pendingAttemptIds.shift(); + if (attemptId === undefined) { + throw new Error('RSC runtime compile completed without a matching attempt.'); + } + let snapshot: RscRuntimeCompileSnapshot | undefined; + try { + if (stats.hasErrors()) { + capturedCohort = undefined; + observer.failAttempt(attemptId, new Error('RSC runtime compile reported errors.'), 'source-build'); + return; + } + const json = stats.toJson({ all: false, children: true, hash: true }); + const cohortHashes = new Map<'rsc' | 'widget', string>(); + for (const child of json.children ?? []) { + if (child.name !== 'rsc' && child.name !== 'widget') continue; + if (typeof child.hash !== 'string' || child.hash.length === 0) { + throw new Error(`RSC runtime ${child.name} compilation has no hash.`); + } + if (cohortHashes.has(child.name)) { + throw new Error(`RSC runtime compile contains duplicate ${child.name} stats.`); + } + cohortHashes.set(child.name, child.hash); + } + if (cohortHashes.size !== 2 || !cohortHashes.has('rsc') || !cohortHashes.has('widget')) { + throw new Error('RSC runtime compile requires exactly one RSC and widget stats child.'); + } + const hashes = (['rsc', 'widget'] as const).map((name) => [name, cohortHashes.get(name) as string]); + const sourceRevision = createHash('sha256').update(JSON.stringify(hashes)).digest('hex'); + snapshot = await observer.capture({ + attemptId, + cohortChanged: sourceRevision !== capturedCohort?.sourceRevision, + hasErrors: false, + sourceRevision, + }); + if (snapshot !== undefined) { + const activationSequence = ++nextActivationSequence; + capturedCohort = Object.freeze({ activationSequence, sourceRevision }); + let queued: unknown; + try { + queued = observer.enqueue(snapshot); + } catch (error) { + if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; + throw error; + } + const completion = queued instanceof Promise + ? queued as Promise + : Promise.resolve(undefined); + snapshot.acceptCompilerAssetCheckpoint?.(); + void completion.then((outcome) => { + if (outcome === 'activated' || outcome === undefined) return; + if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; + }, () => { + if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; + }); + } + } catch (error) { + try { + snapshot?.discardCompilerAssetCheckpoint?.(); + } catch { + // The original capture/enqueue error remains the attempted failure cause. + } + observer.failAttempt(attemptId, error, 'provider-lifecycle'); + } + }); + }, + }; +}; + +export const createRscRuntimeRsbuildConfig = ( + options: RscRuntimeRsbuildConfigOptions, +): RsbuildConfig => { + const development = options.mode === 'development'; + if (development && options.compilerRoot === undefined) { + throw new TypeError('Development RSC runtime config requires compilerRoot.'); + } + const root = (name: 'rsc' | 'widget' | 'app', productionRoot: string): string => + development ? join(options.compilerRoot as string, name) : productionRoot; + + return { + ...(development ? { + dev: { writeToDisk: true }, + server: { host: '127.0.0.1', printUrls: false }, + } : {}), + plugins: [ + pluginReact(), + pluginRSC({ environments: { server: 'rsc', client: 'widget' } }), + emitRuntimeManifest(), + ...(options.onAppWebSocketToken === undefined ? [] : [runtimeAppHmrTokenPlugin(options.onAppWebSocketToken)]), + ...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]), + ], + environments: { + rsc: { + source: { + entry: { + ...(development ? { 'dev/definition': './src/dev/definition-entry.ts' } : {}), + ...(development ? { 'dev/invoke': './src/dev/invocation-worker.ts' } : {}), + 'hook/index': './src/hook/cli.ts', + 'rsc/index': { import: './src/rsc/worker.tsx', layer: Layers.rsc }, + 'mcp/stdio': './src/mcp/stdio.ts', + 'mcp/http': './src/mcp/http.ts', + }, + }, + tools: { + rspack: { + module: { + rules: [{ + parser: { importMeta: { url: false } }, + test: /[\\/]src[\\/]flight[\\/]request-render\.ts$/, + }], + }, + }, + }, + output: { + cleanDistPath: false, + distPath: { js: './', jsAsync: 'chunks', root: root('rsc', 'dist/runtime') }, + filename: { js: '[name].js' }, + manifest: 'runtime-assets.json', + target: 'node', + }, + // Rsbuild 2.2 enabled sync chunk splitting for node targets by + // default. Worker-spawning modules here resolve sibling entries from + // their own preserved `import.meta.url`, so hoisting them into a + // shared chunk at the dist root breaks those relative paths. + splitChunks: false, + }, + widget: { + source: { + entry: { + ...(development ? { 'dev/definition': './src/rsc/client-anchor.ts' } : {}), + ...(development ? { 'dev/invoke': './src/rsc/client-anchor.ts' } : {}), + 'hook/index': './src/rsc/client-anchor.ts', + 'rsc/index': './src/rsc/client-anchor.ts', + 'mcp/stdio': './src/rsc/client-anchor.ts', + 'mcp/http': './src/rsc/client-anchor.ts', + }, + }, + output: { + cleanDistPath: false, + distPath: { root: root('widget', 'dist/widget') }, + filename: { js: '[name].js' }, + target: 'web', + }, + }, + app: { + ...(development ? { + dev: { + // The trusted runtime-surface outer document owns the one HMR + // socket. The compiler App itself runs in an opaque srcdoc child + // and must never receive a browser HMR credential or connection. + hmr: false, + liveReload: false, + }, + } : {}), + html: { inject: 'body' }, + output: { + cleanDistPath: false, + distPath: { + ...(development ? {} : { js: './' }), + root: root('app', 'dist/app'), + }, + ...(development ? {} : { + filename: { + assets: '[name][ext]', + css: '[name].css', + js: '[name].js', + }, + filenameHash: false, + legalComments: 'linked', + }), + inlineScripts: true, + inlineStyles: true, + target: 'web', + }, + source: { + entry: { + 'edit-timeline-v1': './src/widget/index.tsx', + standalone: './src/widget/index.tsx', + }, + }, + tools: { + rspack: { + module: { parser: { javascript: { dynamicImportMode: 'eager' } } }, + }, + }, + }, + }, + }; +}; + +export default defineConfig(createRscRuntimeRsbuildConfig({ mode: 'production' })); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts new file mode 100644 index 000000000..df37962ff --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['tests/**/*.test.{ts,tsx}'], + pool: { maxWorkers: 1 }, + testEnvironment: 'node', +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs new file mode 100644 index 000000000..07bcfc1f8 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs @@ -0,0 +1,231 @@ +/* global URL, document, HTMLElement, getComputedStyle, process */ + +import { createServer } from 'node:http'; +import { access, mkdir, readFile } from 'node:fs/promises'; +import { dirname, extname, join, resolve } from 'node:path'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { chromium } from 'playwright-core'; + +const exec = promisify(execFile); +const exampleRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const appRoot = join(exampleRoot, 'dist', 'app'); + +const timeline = (stateVersion) => ({ + edits: [ + { eventId: 'concept-1', host: 'claude', path: 'src/runtime/state.ts', recordedAt: '2026-08-14T10:24:31.000Z', sessionId: 'concept', toolName: 'Write' }, + { eventId: 'concept-2', host: 'codex', path: 'src/widget/App.tsx', recordedAt: '2026-08-14T10:21:07.000Z', sessionId: 'concept', toolName: 'Edit' }, + { eventId: 'concept-3', host: 'claude', path: 'README.md', recordedAt: '2026-08-14T10:17:42.000Z', sessionId: 'concept', toolName: 'Read' }, + ], + stateVersion, +}); + +const withHeadScript = (html, script) => html.replace('', ``); + +const openAiBootstrap = ` + window.openai = { + get widgetState() { + try { return JSON.parse(sessionStorage.getItem('rsc-agent-runtime-widget-state') || '{}'); } catch { return {}; } + }, + setWidgetState(state) { + sessionStorage.setItem('rsc-agent-runtime-widget-state', JSON.stringify(state)); + } + }; +`; + +const hostHarness = () => ` + + +`; + +const parseArguments = (argv) => { + const outputIndex = argv.indexOf('--output'); + const output = outputIndex === -1 ? undefined : argv[outputIndex + 1]; + if (output === undefined || output.trim() === '' || argv.length !== 2) { + throw new Error('Usage: node scripts/capture-widget.mjs --output '); + } + return resolve(output); +}; + +const findChrome = async () => { + const candidates = [process.env.CHROME_PATH, 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].filter(Boolean); + for (const candidate of candidates) { + if (candidate.includes('/')) { + try { + await access(candidate); + return candidate; + } catch { + continue; + } + } + try { + const { stdout } = await exec('which', [candidate]); + return stdout.trim(); + } catch { + // Try the next installed browser name. + } + } + throw new Error('Could not locate an installed Chrome executable. Set CHROME_PATH to use capture:widget.'); +}; + +const sibling = (output, suffix) => { + const extension = extname(output) || '.png'; + return join(dirname(output), `${output.slice(output.lastIndexOf('/') + 1, -extension.length)}${suffix}${extension}`); +}; + +const listen = (documents) => new Promise((resolvePromise, reject) => { + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; + const document = documents.get(path); + if (document === undefined) { + response.writeHead(404).end('Not found'); + return; + } + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end(document); + }); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Could not allocate a loopback capture port.')); + return; + } + resolvePromise({ port: address.port, server }); + }); +}); + +const closeServer = (server) => new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise()))); + +const waitForState = async (pageOrFrame, version) => { + await pageOrFrame.waitForFunction( + (expected) => document.querySelector('footer')?.textContent === `State version ${expected}`, + version, + ); +}; + +const run = async () => { + const output = parseArguments(process.argv.slice(2)); + await mkdir(dirname(output), { recursive: true }); + const [standalone, editTimeline] = await Promise.all([ + readFile(join(appRoot, 'standalone.html'), 'utf8'), + readFile(join(appRoot, 'edit-timeline-v1.html'), 'utf8'), + ]); + const chrome = await findChrome(); + const documents = new Map([ + ['/standalone.html', standalone], + ['/openai.html', withHeadScript(standalone, openAiBootstrap)], + ['/context-widget.html', editTimeline], + ]); + const listener = await listen(documents); + documents.set('/claude-context.html', hostHarness()); + const baseUrl = `http://127.0.0.1:${listener.port}`; + let browser; + try { + browser = await chromium.launch({ executablePath: chrome, headless: true }); + const desktop = await browser.newPage({ viewport: { width: 760, height: 500 } }); + await desktop.goto(`${baseUrl}/standalone.html`); + await waitForState(desktop, 3); + await desktop.screenshot({ path: output }); + await desktop.getByRole('button', { name: 'Refresh' }).click(); + await waitForState(desktop, 4); + + const mobilePath = sibling(output, '-mobile'); + const mobile = await browser.newPage({ viewport: { width: 360, height: 640 } }); + await mobile.goto(`${baseUrl}/standalone.html`); + await waitForState(mobile, 3); + await mobile.screenshot({ path: mobilePath }); + + const openAiPath = sibling(output, '-openai'); + const openAi = await browser.newPage({ viewport: { width: 760, height: 500 } }); + await openAi.goto(`${baseUrl}/openai.html`); + await waitForState(openAi, 3); + await openAi.locator('.timeline__event').nth(1).click(); + await openAi.waitForFunction(() => document.querySelectorAll('.timeline__event')[1]?.getAttribute('aria-pressed') === 'true'); + await openAi.reload(); + await waitForState(openAi, 3); + await openAi.waitForFunction(() => document.querySelectorAll('.timeline__event')[1]?.getAttribute('aria-pressed') === 'true'); + await openAi.screenshot({ path: openAiPath }); + + const contextPath = sibling(output, '-claude-context'); + const context = await browser.newPage({ viewport: { width: 360, height: 640 } }); + await context.goto(`${baseUrl}/claude-context.html`); + const frame = context.frames().find((candidate) => candidate.url().endsWith('/context-widget.html')); + if (frame === undefined) { + throw new Error('Claude-compatible host fixture did not load its MCP Apps frame.'); + } + await waitForState(frame, 3); + await frame.getByRole('button', { name: 'Refresh' }).click(); + await waitForState(frame, 4); + const contextProof = await frame.evaluate(() => { + const timeline = document.querySelector('.timeline'); + const refresh = document.querySelector('button'); + if (!(timeline instanceof HTMLElement) || !(refresh instanceof HTMLElement)) throw new Error('Expected timeline controls.'); + const computed = getComputedStyle(timeline); + const box = refresh.getBoundingClientRect(); + return { + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + nestedVerticalOverflow: computed.overflowY === 'auto' || computed.overflowY === 'scroll', + refreshHeight: box.height, + refreshWidth: box.width, + safeAreaTop: computed.getPropertyValue('--timeline-safe-area-top').trim(), + theme: document.documentElement.getAttribute('data-theme'), + }; + }); + if ( + contextProof.horizontalOverflow || + contextProof.nestedVerticalOverflow || + contextProof.refreshWidth < 44 || + contextProof.refreshHeight < 44 || + contextProof.safeAreaTop !== '12px' || + contextProof.theme !== 'dark' + ) { + throw new Error('Claude-compatible host fixture did not apply safe areas, styles, or usable Refresh sizing.'); + } + await context.screenshot({ path: contextPath }); + + process.stdout.write(`${JSON.stringify({ + claudeContext: contextPath, + desktop: output, + mobile: mobilePath, + openai: openAiPath, + refreshChangedVersion: true, + restoredOpenAiSelection: true, + })}\n`); + } finally { + await browser?.close(); + await closeServer(listener.server); + } +}; + +run().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs new file mode 100644 index 000000000..d260411f5 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs @@ -0,0 +1,241 @@ +const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +const jsonEvents = (output) => output.split('\n').flatMap((line) => { + try { + const value = JSON.parse(line); + return isRecord(value) ? [value] : []; + } catch { + return []; + } +}); + +const markerOnOwnLine = (value, marker) => + typeof value === 'string' && value.split(/\r?\n/).some((line) => line.trim() === marker); + +const MAX_RESULT_CONTENT_CHARACTERS = 16_384; +const MAX_RESULT_CONTENT_BLOCKS = 20; + +const boundedText = (value) => typeof value === 'string' && value.length <= MAX_RESULT_CONTENT_CHARACTERS + ? value + : undefined; + +const boundedClaudeResultContent = (value) => { + const text = boundedText(value); + if (text !== undefined) return text; + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_RESULT_CONTENT_BLOCKS) return undefined; + const blocks = []; + let length = 0; + for (const block of value) { + if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') return undefined; + length += block.text.length + (blocks.length === 0 ? 0 : 1); + if (length > MAX_RESULT_CONTENT_CHARACTERS) return undefined; + blocks.push(block.text); + } + return blocks.join('\n'); +}; + +const claudeToolUses = (event) => { + if (event.type !== 'assistant' || !isRecord(event.message) || event.message.role !== 'assistant' || !Array.isArray(event.message.content)) { + return []; + } + return event.message.content.flatMap((content) => + isRecord(content) && + content.type === 'tool_use' && + typeof content.id === 'string' && + typeof content.name === 'string' + ? [content] + : [], + ); +}; + +const claudeToolResults = (event) => { + if (event.type !== 'user' || !isRecord(event.message) || event.message.role !== 'user' || !Array.isArray(event.message.content)) { + return []; + } + return event.message.content.flatMap((content) => + isRecord(content) && + content.type === 'tool_result' && + typeof content.tool_use_id === 'string' + ? [{ + content: content.is_error === true ? undefined : boundedClaudeResultContent(content.content), + id: content.tool_use_id, + succeeded: content.is_error !== true, + }] + : [], + ); +}; + +// Claude Code 2.1.250 names plugin MCP tools mcp__plugin____; +// the 2.1.232-era short form stays accepted for hosts at the supported floor. +const claudeRuntimeToolNames = (toolName) => [ + `mcp__rsc-agent-runtime__${toolName}`, + `mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__${toolName}`, +]; +const isClaudeRuntimeTool = (candidate, toolName) => claudeRuntimeToolNames(toolName).includes(candidate); + +const completedClaudeToolUses = (events) => { + const uses = events.flatMap(claudeToolUses); + const useCounts = new Map(); + for (const toolUse of uses) useCounts.set(toolUse.id, (useCounts.get(toolUse.id) ?? 0) + 1); + + const results = new Map(); + const duplicateResults = new Set(); + for (const result of events.flatMap(claudeToolResults)) { + if (results.has(result.id)) duplicateResults.add(result.id); + else results.set(result.id, result); + } + + return uses.flatMap((toolUse) => { + const result = results.get(toolUse.id); + if (useCounts.get(toolUse.id) !== 1 || duplicateResults.has(toolUse.id) || result?.succeeded !== true || result.content === undefined) { + return []; + } + return [{ content: result.content, toolUse }]; + }); +}; + +const stateHasMarker = (host, records, marker) => + typeof marker === 'string' && marker.length > 0 && Array.isArray(records) && records.some((record) => + isRecord(record) && + record.kind === 'edit' && + isRecord(record.event) && + record.event.host === host && + typeof record.event.path === 'string' && + record.event.path.includes(marker)); + +const claudeEvidence = (events, marker, finalMarker) => { + const completed = completedClaudeToolUses(events); + const recentEdits = completed.filter(({ toolUse }) => isClaudeRuntimeTool(toolUse.name, 'recent_edits')); + const renderTimeline = completed.filter(({ toolUse }) => isClaudeRuntimeTool(toolUse.name, 'render_edit_timeline')); + const finalMarkerObserved = events.some( + (event) => event.type === 'result' && event.is_error === false && markerOnOwnLine(event.result, finalMarker), + ); + + return { + eventCounts: { hook: 0, json: events.length, mcp: recentEdits.length, rscRender: renderTimeline.length }, + finalMarkerObserved, + mcpReadMarkerObserved: typeof marker === 'string' && recentEdits.some(({ content }) => content.includes(marker)), + mcpReadObserved: recentEdits.length > 0, + rscRenderToolObserved: renderTimeline.length > 0, + }; +}; + +const distinct = (values) => [...new Set(values)].sort(); + +const terminalHostCanRenderIframe = false; +const evidence = (condition, observedBasis, unavailableBasis) => ({ + basis: condition ? observedBasis : unavailableBasis, + evidence: condition ? 'observed' : 'unavailable', +}); + +const observed = (result, key) => isRecord(result) && result[key] === true; + +/** Converts bounded native-run observations into explicit, non-browser host claims. */ +export const classifyNativeEvidence = (host, result, { capturedAt }) => { + const hostAvailable = isRecord(result) && typeof result.version === 'string'; + const unavailableBasis = hostAvailable ? 'selected native run did not produce the required evidence' : 'installed host/version/session unavailable'; + const packageActivated = hostAvailable && observed(result, 'sessionAvailable') && observed(result, 'finalMarkerObserved'); + const hookDispatched = host === 'claude' && hostAvailable && observed(result, 'editObservedByHook'); + const mcpRead = hostAvailable && observed(result, 'mcpReadObserved'); + const rscRender = hostAvailable && observed(result, 'rscRenderToolObserved'); + const sharedHookState = host === 'claude' && hookDispatched && observed(result, 'sharedHookStateObserved'); + const iframeBasis = host === 'claude' + ? 'Claude Code CLI is not an MCP Apps iframe host' + : 'Codex CLI is not an MCP Apps iframe host'; + + return { + capturedAt, + claims: [ + { id: 'package-activation', ...evidence(packageActivated, 'native terminal marker and loaded plugin session', unavailableBasis) }, + { + id: 'hook-dispatch', + ...evidence( + hookDispatched, + 'value-free hook launch probe exited 0', + host === 'codex' && hostAvailable ? 'Codex exec --ephemeral does not prove native hook dispatch' : unavailableBasis, + ), + }, + { id: 'mcp-read', ...evidence(mcpRead, 'completed recent_edits call with native success result', unavailableBasis) }, + { id: 'rsc-render', ...evidence(rscRender, 'completed render_edit_timeline call with native success result', unavailableBasis) }, + { + id: 'shared-hook-mcp-state', + ...evidence( + sharedHookState, + 'hook-recorded state was returned by recent_edits', + host === 'codex' && hostAvailable ? 'Codex exec --ephemeral has no native hook-recorded state correlation' : unavailableBasis, + ), + }, + { id: 'mcp-app-iframe', ...evidence(terminalHostCanRenderIframe, 'terminal host iframe rendering is not supported', iframeBasis) }, + ], + host, + hostVersion: hostAvailable ? result.version : 'unavailable', + }; +}; + +/** Reduces hook probe records to key/type/exit-status evidence without returning input values. */ +export const summarizeHookProbe = (records) => { + const probeRecords = Array.isArray(records) ? records.filter(isRecord) : []; + return { + commandLaunched: probeRecords.some((record) => record.commandLaunched === true), + exitStatuses: distinct(probeRecords.map((record) => record.exitStatus).filter((value) => Number.isInteger(value))), + launches: probeRecords.filter((record) => record.commandLaunched === true).length, + toolInputKeySets: distinct(probeRecords.map((record) => JSON.stringify(record.toolInputKeys ?? []))), + toolNames: distinct(probeRecords.map((record) => record.toolName).filter((value) => typeof value === 'string')), + topLevelKeySets: distinct(probeRecords.map((record) => JSON.stringify(record.topLevelKeys ?? []))), + valueTypeSets: distinct(probeRecords.map((record) => JSON.stringify({ + toolInput: record.toolInputValueTypes ?? {}, + topLevel: record.topLevelValueTypes ?? {}, + }))), + }; +}; + +export const hookEvidenceFromProbe = (summary) => + isRecord(summary) && summary.commandLaunched === true && Array.isArray(summary.exitStatuses) && summary.exitStatuses.includes(0); + +const isCodexMcpCall = (event, toolName) => + event.type === 'item.completed' && + isRecord(event.item) && + event.item.type === 'mcp_tool_call' && + event.item.status === 'completed' && + event.item.is_error !== true && + !(isRecord(event.item.result) && event.item.result.is_error === true) && + event.item.server === 'rsc-agent-runtime' && + event.item.tool === toolName; + +const codexEvidence = (events, finalMarker) => { + const recentEdits = events.filter((event) => isCodexMcpCall(event, 'recent_edits')).length; + const renderTimeline = events.filter((event) => isCodexMcpCall(event, 'render_edit_timeline')).length; + const finalMarkerObserved = events.some( + (event, index) => + event.type === 'item.completed' && + isRecord(event.item) && + event.item.type === 'agent_message' && + markerOnOwnLine(event.item.text, finalMarker) && + events[index + 1]?.type === 'turn.completed', + ); + + return { + eventCounts: { hook: 0, json: events.length, mcp: recentEdits, rscRender: renderTimeline }, + finalMarkerObserved, + mcpReadMarkerObserved: false, + mcpReadObserved: recentEdits > 0, + rscRenderToolObserved: renderTimeline > 0, + }; +}; + +/** Parses only known JSONL event discriminants and returns no host-supplied values. */ +export const evidenceFromTranscript = (host, transcript, correlation = {}) => { + const events = jsonEvents(transcript); + const safeCorrelation = isRecord(correlation) ? correlation : {}; + const finalMarker = typeof safeCorrelation.finalMarker === 'string' + ? safeCorrelation.finalMarker + : `HOST_EVAL_FINAL host=${host} path=host-created.txt`; + const evidence = host === 'claude' + ? claudeEvidence(events, safeCorrelation.marker, finalMarker) + : codexEvidence(events, finalMarker); + return { + ...evidence, + sharedHookStateObserved: host === 'claude' && evidence.mcpReadMarkerObserved && stateHasMarker(host, safeCorrelation.stateRecords, safeCorrelation.marker), + stateMarkerObserved: stateHasMarker(host, safeCorrelation.stateRecords, safeCorrelation.marker), + }; +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs new file mode 100644 index 000000000..5f5ff0043 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs @@ -0,0 +1,49 @@ +const ordinarySessionKeys = [ + 'PATH', + 'HOME', + 'USERPROFILE', + 'XDG_CONFIG_HOME', + 'CLAUDE_CONFIG_DIR', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TERM', + 'COLORTERM', + 'NO_COLOR', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SYSTEMROOT', + 'WINDIR', + 'PATHEXT', + 'COMSPEC', + 'SHELL', +]; + +const sensitiveEnvironmentKey = (key) => + /_API_KEY$/iu.test(key) || + /(?:^|_)(?:AUTH|AUTH_TOKEN|ACCESS_TOKEN|TOKEN|SECRET|PASSWORD|CREDENTIAL|BASE_URL|API_BASE|USE_BEDROCK|USE_FOUNDRY|USE_VERTEX)$/iu.test(key); + +const ownString = (environment, key) => { + const descriptor = Object.getOwnPropertyDescriptor(environment, key); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string' ? descriptor.value : undefined; +}; + +/** Returns the sole child environment allowed for native-host evaluation. */ +export const sanitizedHostEnvironment = (environment, owned = {}) => { + const child = {}; + for (const key of ordinarySessionKeys) { + if (sensitiveEnvironmentKey(key)) continue; + const value = ownString(environment, key); + if (value !== undefined) child[key] = value; + } + const ownedKeys = [ + ['AGENT_RUNTIME_HOOK_PROBE_FILE', owned.hookProbeFile], + ['AGENT_RUNTIME_STATE_FILE', owned.stateFile], + ['CODEX_HOME', owned.codexHome], + ]; + for (const [key, value] of ownedKeys) { + if (typeof value === 'string') child[key] = value; + } + return child; +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs new file mode 100644 index 000000000..5041dd69d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs @@ -0,0 +1,4 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const exampleRootFromModule = (moduleUrl) => resolve(dirname(fileURLToPath(moduleUrl)), '..'); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs new file mode 100644 index 000000000..3aeebe067 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs @@ -0,0 +1,169 @@ +/* global URL, process */ + +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { once } from 'node:events'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { classifyNativeEvidence, evidenceFromTranscript, hookEvidenceFromProbe, summarizeHookProbe } from './eval-evidence.mjs'; +import { sanitizedHostEnvironment } from './eval-host-environment.mjs'; +import { exampleRootFromModule } from './eval-host-paths.mjs'; + +const exampleRoot = exampleRootFromModule(import.meta.url); +const expectedVersions = { claude: '2.1.250', codex: '0.147.0' }; + +const parseHost = (argv) => { + const hostIndex = argv.indexOf('--host'); + const host = hostIndex === -1 ? 'all' : argv[hostIndex + 1]; + if (!['claude', 'codex', 'all'].includes(host) || argv.length !== (hostIndex === -1 ? 0 : 2)) { + throw new Error('Usage: node scripts/eval-hosts.mjs [--host claude|codex|all]'); + } + return host; +}; + +const runProcess = async (command, args, options = {}) => { + const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const [exitCode, signal] = await once(child, 'close'); + return { exitCode, signal, stderr, stdout }; +}; + +const cliVersion = async (host, environment) => { + const result = await runProcess(host, ['--version'], { env: environment }); + const version = result.stdout.trim() || result.stderr.trim(); + if (result.exitCode !== 0 || !version.includes(expectedVersions[host])) { + throw new Error(`${host} ${expectedVersions[host]} is not installed`); + } + return expectedVersions[host]; +}; + +const opaqueCodexAuthCopy = async (temporaryCodexHome) => { + const sourceHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'); + const source = join(sourceHome, 'auth.json'); + try { + const sourceStat = await stat(source); + await copyFile(source, join(temporaryCodexHome, 'auth.json')); + await chmod(join(temporaryCodexHome, 'auth.json'), sourceStat.mode & 0o777); + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') return false; + throw error; + } + return true; +}; + +const hookProbeSummary = async (probeFile) => { + const records = await readFile(probeFile, 'utf8') + .then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line))) + .catch(() => []); + return summarizeHookProbe(records); +}; + +const evidenceFrom = async (host, fixture, stateFile, probeFile, transcript, correlation) => { + const stateRecords = await readFile(stateFile, 'utf8') + .then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line))) + .catch(() => []); + const transcriptEvidence = evidenceFromTranscript(host, transcript, { ...correlation, stateRecords }); + const editObserved = await stat(join(fixture, correlation.editPath)).then(() => true).catch(() => false); + const hookProbe = await hookProbeSummary(probeFile); + return { + editObservedByHook: editObserved && transcriptEvidence.stateMarkerObserved && hookEvidenceFromProbe(hookProbe), + eventCounts: { ...transcriptEvidence.eventCounts, hook: hookProbe.launches, state: stateRecords.length }, + finalMarkerObserved: transcriptEvidence.finalMarkerObserved, + hookProbe, + mcpReadObserved: transcriptEvidence.mcpReadObserved, + rscRenderToolObserved: transcriptEvidence.rscRenderToolObserved, + sharedHookStateObserved: transcriptEvidence.sharedHookStateObserved, + }; +}; + +const promptFor = (host, { editPath, finalMarker }) => { + const nativeEdit = host === 'codex' + ? 'Use the apply_patch tool for that file edit; do not use a shell command.' + : 'Use the Write tool for that file edit.'; + return `In this workspace, create exactly one file named ${editPath} containing the word ${host}. ${nativeEdit} Then call the rsc-agent-runtime MCP tool recent_edits, pass its snapshot to render_edit_timeline, and finish with this exact marker on its own line: ${finalMarker}. Do not create any other files.`; +}; + +const evaluateHost = async (host, capturedAt) => { + const nativeEnvironment = sanitizedHostEnvironment(process.env); + const version = await cliVersion(host, nativeEnvironment); + const pluginRoot = join(exampleRoot, 'dist', 'plugins', host); + await stat(pluginRoot); + const fixture = await mkdtemp(join(tmpdir(), `rsc-agent-runtime-${host}-fixture-`)); + const marker = `rsc-eval-${randomBytes(16).toString('hex')}`; + const correlation = { + editPath: `host-created-${marker}.txt`, + finalMarker: `HOST_EVAL_FINAL host=${host} marker=${marker}`, + marker, + }; + const stateFile = join(fixture, '.agent-runtime-demo', 'events.jsonl'); + const probeFile = join(fixture, 'hook-probe.jsonl'); + const sharedEnv = sanitizedHostEnvironment(process.env, { hookProbeFile: probeFile, stateFile }); + let temporaryCodexHome; + try { + await runProcess('git', ['init', '--quiet'], { cwd: fixture, env: sharedEnv }); + await runProcess('git', ['config', 'user.email', 'rsc-demo@example.invalid'], { cwd: fixture, env: sharedEnv }); + await runProcess('git', ['config', 'user.name', 'RSC Runtime Demo'], { cwd: fixture, env: sharedEnv }); + let result; + if (host === 'claude') { + result = await runProcess('claude', [ + '-p', promptFor(host, correlation), '--plugin-dir', pluginRoot, '--output-format', 'stream-json', '--verbose', '--include-hook-events', + '--no-session-persistence', '--dangerously-skip-permissions', + ], { cwd: fixture, env: sharedEnv }); + } else { + temporaryCodexHome = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-codex-home-')); + await mkdir(temporaryCodexHome, { recursive: true }); + await opaqueCodexAuthCopy(temporaryCodexHome); + const codexEnv = sanitizedHostEnvironment(process.env, { + codexHome: temporaryCodexHome, + hookProbeFile: probeFile, + stateFile, + }); + const marketplace = 'rsc-agent-runtime-marketplace'; + const marketplaceAdd = await runProcess('codex', ['plugin', 'marketplace', 'add', pluginRoot, '--json'], { cwd: fixture, env: codexEnv }); + const pluginAdd = marketplaceAdd.exitCode === 0 + ? await runProcess('codex', ['plugin', 'add', `rsc-agent-runtime@${marketplace}`, '--json'], { cwd: fixture, env: codexEnv }) + : { exitCode: 1, stderr: '', stdout: '' }; + result = pluginAdd.exitCode === 0 + ? await runProcess('codex', [ + '-a', 'never', 'exec', '--ephemeral', '--json', '--dangerously-bypass-hook-trust', '-s', 'workspace-write', '-C', fixture, promptFor(host, correlation), + ], { cwd: fixture, env: codexEnv }) + : { exitCode: 1, stderr: '', stdout: '' }; + } + const evidence = await evidenceFrom(host, fixture, stateFile, probeFile, `${result.stdout}\n${result.stderr}`, correlation); + return classifyNativeEvidence(host, { + ...evidence, + sessionAvailable: result.exitCode === 0 && evidence.finalMarkerObserved, + version, + }, { capturedAt }); + } finally { + if (temporaryCodexHome !== undefined) await rm(temporaryCodexHome, { force: true, recursive: true }); + await rm(fixture, { force: true, recursive: true }); + } +}; + +const run = async () => { + const selected = parseHost(process.argv.slice(2)); + const hosts = selected === 'all' ? ['claude', 'codex'] : [selected]; + const capturedAt = new Date().toISOString(); + const summaries = []; + for (const host of hosts) { + try { + summaries.push(await evaluateHost(host, capturedAt)); + } catch { + summaries.push(classifyNativeEvidence(host, {}, { capturedAt })); + } + } + process.stdout.write(`${JSON.stringify({ capturedAt, hosts: summaries, schemaVersion: 2 })}\n`); + if (summaries.some((summary) => summary.claims.some((claim) => claim.id !== 'mcp-app-iframe' && claim.evidence !== 'observed'))) { + process.exitCode = 1; + } +}; + +run().catch(() => { process.exitCode = 1; }); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs new file mode 100644 index 000000000..07ded4250 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs @@ -0,0 +1,75 @@ +/* global process */ + +import { access, cp, mkdir, readFile, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exampleRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const distRoot = join(exampleRoot, 'dist'); +const pluginsRoot = join(distRoot, 'plugins'); +const runtimeRoot = join(distRoot, 'runtime'); +const appRoot = join(distRoot, 'app'); +const packagingRoot = join(exampleRoot, 'packaging'); + +const assertDirectory = async (path, message) => { + try { + await access(path); + } catch { + throw new Error(message); + } +}; + +const normalizedRuntimeAsset = (asset) => { + if (typeof asset !== 'string') { + throw new Error('runtime-assets.json must contain string paths'); + } + const stripped = asset.replace(/^[/\\]+/, ''); + const normalized = normalize(stripped); + if (stripped.length === 0 || isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`Runtime asset escapes its root: ${asset}`); + } + return normalized; +}; + +const verifyRuntimeCopy = async (pluginRoot) => { + const manifestPath = join(pluginRoot, 'runtime', 'runtime-assets.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + if (!Array.isArray(manifest.allFiles)) { + throw new Error('runtime-assets.json must contain allFiles'); + } + const copiedRuntime = resolve(pluginRoot, 'runtime'); + for (const asset of manifest.allFiles) { + const normalized = normalizedRuntimeAsset(asset); + const target = resolve(copiedRuntime, normalized); + if (relative(copiedRuntime, target).startsWith('..')) { + throw new Error(`Runtime asset escapes copied root: ${asset}`); + } + await access(target); + } +}; + +const packageHost = async (host) => { + const source = join(packagingRoot, host); + const target = join(pluginsRoot, host); + await cp(source, target, { recursive: true }); + await cp(runtimeRoot, join(target, 'runtime'), { recursive: true }); + await cp(appRoot, join(target, 'app'), { recursive: true }); + if (host === 'codex') { + await mkdir(join(target, 'skills'), { recursive: true }); + } + await verifyRuntimeCopy(target); +}; + +const run = async () => { + await assertDirectory(runtimeRoot, 'Build dist/runtime before packaging native hosts.'); + await assertDirectory(appRoot, 'Build dist/app before packaging native hosts.'); + await rm(pluginsRoot, { force: true, recursive: true }); + await mkdir(pluginsRoot, { recursive: true }); + await packageHost('claude'); + await packageHost('codex'); +}; + +run().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts new file mode 100644 index 000000000..ae29ada98 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts @@ -0,0 +1,67 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative, resolve } from 'node:path'; + +import { serializeRuntimeDefinition } from './serialize-definition.js'; +import type { SerializedRuntimeDefinition } from '../runtime/contracts.js'; + +const executableAssets = [ + { name: 'hook', path: 'hook/index.js' }, + { name: 'rsc-worker', path: 'rsc/index.js' }, + { name: 'stdio', path: 'mcp/stdio.js' }, + { name: 'http', path: 'mcp/http.js' }, +] as const; + +const normalizeRuntimeAsset = (asset: unknown): string => { + if (typeof asset !== 'string') { + throw new Error('runtime-assets.json must contain string paths'); + } + + const stripped = asset.replace(/^[/\\]+/, ''); + const normalized = normalize(stripped); + if (stripped.length === 0 || isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`Runtime asset escapes its root: ${asset}`); + } + return normalized; +}; + +const readRuntimeAssets = async (distPath: string): Promise => { + const contents = await readFile(join(distPath, 'runtime-assets.json'), 'utf8'); + const parsed = JSON.parse(contents) as { allFiles?: unknown }; + if (!Array.isArray(parsed.allFiles)) { + throw new Error('runtime-assets.json must contain allFiles'); + } + + const root = resolve(distPath); + const assets = parsed.allFiles.map(normalizeRuntimeAsset); + await Promise.all(assets.map(async (asset) => { + const target = resolve(root, asset); + const pathFromRoot = relative(root, target); + if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(pathFromRoot)) { + throw new Error(`Runtime asset escapes its root: ${asset}`); + } + await access(target); + })); + return assets; +}; + +export const emitRuntimeArtifacts = async ( + distPath: string, + definition: SerializedRuntimeDefinition = serializeRuntimeDefinition(), +): Promise => { + const runtimeAssets = await readRuntimeAssets(distPath); + for (const executable of executableAssets) { + if (!runtimeAssets.includes(executable.path)) { + throw new Error(`runtime-assets.json is missing executable: ${executable.path}`); + } + } + + const manifest = { + ...definition, + executables: executableAssets, + runtimeAssets, + schemaVersion: 1, + }; + + await mkdir(distPath, { recursive: true }); + await writeFile(join(distPath, 'agent-runtime.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts new file mode 100644 index 000000000..e240aa378 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import type { ZodType } from 'zod'; + +import { runtimeDefinition } from '../definition.js'; +import type { + RuntimeDefinition, + SerializedRuntimeDefinition, + SerializedRuntimeToolDefinition, +} from '../runtime/contracts.js'; + +const toMcpJsonSchema = (schema: ZodType): Record => { + const { $schema: _schema, ...jsonSchema } = z.toJSONSchema(schema); + return jsonSchema; +}; + +export const serializeRuntimeDefinition = ( + definition: RuntimeDefinition = runtimeDefinition, +): SerializedRuntimeDefinition => ({ + nativeHooks: definition.nativeHooks.map((hook) => ({ ...hook })), + resources: definition.resources.map((resource) => ({ + ...resource, + _meta: { + ...resource._meta, + 'ui.csp': { + ...resource._meta['ui.csp'], + connectDomains: [...resource._meta['ui.csp'].connectDomains], + resourceDomains: [...resource._meta['ui.csp'].resourceDomains], + }, + }, + })), + tools: definition.tools.map( + (tool): SerializedRuntimeToolDefinition => ({ + ...tool, + _meta: { + ...tool._meta, + ui: tool._meta.ui === undefined ? undefined : { ...tool._meta.ui }, + }, + annotations: { ...tool.annotations }, + inputSchema: toMcpJsonSchema(tool.inputSchema), + outputSchema: toMcpJsonSchema(tool.outputSchema), + }), + ), +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts new file mode 100644 index 000000000..5944a0c65 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; + +import type { RuntimeDefinition, ToolAnnotations } from './runtime/contracts.js'; + +export const editTimelineResourceUri = 'ui://rsc-agent-runtime/edit-timeline-v1.html'; + +const readOnlyAnnotations: ToolAnnotations = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +}; + +const editEventSchema = z.object({ + eventId: z.string(), + host: z.enum(['claude', 'codex']), + path: z.string(), + recordedAt: z.string(), + sessionId: z.string(), + toolName: z.string(), +}); + +const snapshotSchema = z.object({ + edits: z.array(editEventSchema), + stateVersion: z.number().int().nonnegative(), +}); + +const limitInputSchema = z.object({ + limit: z.number().int().min(1).max(50).optional(), +}); + +export const runtimeDefinition: RuntimeDefinition = { + nativeHooks: [ + { + event: 'PostToolUse', + handlerId: 'record_post_tool_use', + host: 'claude', + matcher: 'Write|Edit', + }, + { + event: 'after_tool_use', + handlerId: 'record_post_tool_use', + host: 'codex', + matcher: 'apply_patch', + }, + ], + resources: [ + { + _meta: { + 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', + 'ui.csp': { + connectDomains: [], + resourceDomains: [], + }, + 'ui.prefersBorder': true, + }, + mimeType: 'text/html;profile=mcp-app', + name: 'edit-timeline', + uri: editTimelineResourceUri, + }, + ], + tools: [ + { + _meta: {}, + annotations: readOnlyAnnotations, + description: 'Read file edits recorded by agent hooks.', + handlerId: 'recent_edits', + inputSchema: limitInputSchema, + name: 'recent_edits', + outputSchema: snapshotSchema, + }, + { + _meta: { + 'openai/outputTemplate': editTimelineResourceUri, + ui: { resourceUri: editTimelineResourceUri }, + }, + annotations: readOnlyAnnotations, + description: 'Render the interactive file edit timeline.', + handlerId: 'render_edit_timeline', + inputSchema: limitInputSchema, + name: 'render_edit_timeline', + outputSchema: snapshotSchema, + }, + { + _meta: {}, + annotations: readOnlyAnnotations, + description: 'Read the current shared runtime state.', + handlerId: 'runtime_status', + inputSchema: z.object({}), + name: 'runtime_status', + outputSchema: z.object({ + editCount: z.number().int().nonnegative(), + stateVersion: z.number().int().nonnegative(), + }), + }, + ], +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts new file mode 100644 index 000000000..a76cbb6ef --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts @@ -0,0 +1,23 @@ +import { serializeRuntimeDefinition } from '../build/serialize-definition.js'; + +type JsonValue = null | boolean | number | string | JsonValue[] | { readonly [key: string]: JsonValue }; + +const canonicalize = (value: unknown): JsonValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime definition must contain finite JSON numbers.'); + return value; + } + if (Array.isArray(value)) return value.map(canonicalize); + if (typeof value !== 'object') throw new TypeError('Runtime definition must be JSON serializable.'); + + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input).sort()) { + const item = input[key]; + if (item !== undefined) output[key] = canonicalize(item); + } + return output; +}; + +process.stdout.write(`${JSON.stringify(canonicalize(serializeRuntimeDefinition()))}\n`); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts new file mode 100644 index 000000000..2e125148f --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts @@ -0,0 +1,1027 @@ +import { createHash } from 'node:crypto'; +import { open, lstat, mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; +import type { + RscRuntimeAppDefinition, + RscRuntimeGenerationMetadata, + RscRuntimeSurfaceAsset, + SerializedRuntimeDefinition, +} from '../runtime/contracts.js'; +import type { DevRuntimePreparedProject } from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; +import type { DevRuntimeMcpServerDescriptor } from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; +import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; +import type { + RuntimeGenerationActivationGuard, + RuntimeGenerationAsset, + RuntimeGenerationCandidate, + RuntimeGenerationManifestInput, + RuntimeGenerationMetadataCodec, + RuntimeGenerationPreparedActivation, + RuntimeGenerationStore, + RuntimeGenerationValidationInput, +} from '../../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; + +export type { RscRuntimeGenerationMetadata, RscRuntimeSurfaceAsset } from '../runtime/contracts.js'; + +const definitionFile = 'rsc/runtime-definition.json'; +const runtimeAssetsFile = 'rsc/runtime-assets.json'; +const requiredEntries = Object.freeze([ + 'hook/index', + 'mcp/http', + 'mcp/stdio', + 'rsc/index', +] as const); +const executableAsyncEntries = Object.freeze(['mcp/http', 'mcp/stdio'] as const); +const maximumDefinitionStdout = 1024 * 1024; +const maximumDefinitionStderr = 64 * 1024; +const definitionTimeoutMs = 5_000; +const definitionTerminationGraceMs = 100; +const sha256Expression = /^[a-f0-9]{64}$/u; +const generatedRscAssetPaths = Object.freeze([ + 'agent-runtime.manifest.json', + 'runtime-assets.json', + 'runtime-definition.json', +] as const); + +interface RuntimeAssetsManifest { + readonly allFiles: readonly string[]; + readonly entries: Readonly>; +} + +interface RuntimeAssetsEntry { + readonly async?: Readonly<{ readonly js?: readonly string[] }>; + readonly initial?: Readonly<{ readonly js?: readonly string[] }>; +} + +export interface RscCompilerAssetCheckpointTracker { + checkpoint(compilerRoot: string): Promise; + close(): void; +} + +export interface RscCompilerAssetCheckpoint { + readonly priorAssets: ReadonlyMap; + accept(assets: ReadonlyMap): void; + discard(): void; +} + +interface CompilerAssetCheckpointRoot { + assets: ReadonlyMap; + tail: Promise; +} + +class CompilerAssetCheckpointTracker implements RscCompilerAssetCheckpointTracker { + readonly #activeDiscards = new Set<() => void>(); + readonly #roots = new Map(); + #closed = false; + + async checkpoint(compilerRoot: string): Promise { + if (this.#closed) throw new Error('RSC compiler asset checkpoint tracker is closed.'); + const root = resolve(compilerRoot); + let state = this.#roots.get(root); + if (state === undefined) { + state = { assets: new Map(), tail: Promise.resolve() }; + this.#roots.set(root, state); + } + const previous = state.tail; + let release: (() => void) | undefined; + state.tail = new Promise((resolveTail) => { release = resolveTail; }); + await previous; + if (this.#closed) { + release?.(); + throw new Error('RSC compiler asset checkpoint tracker is closed.'); + } + const priorAssets = new Map(state.assets); + let settled = false; + const settle = (assets: ReadonlyMap | undefined): void => { + if (settled) return; + settled = true; + this.#activeDiscards.delete(discard); + if (assets !== undefined && !this.#closed) state.assets = new Map(assets); + release?.(); + }; + const discard = (): void => settle(undefined); + const accept = (assets: ReadonlyMap): void => settle(assets); + this.#activeDiscards.add(discard); + return Object.freeze({ accept, discard, priorAssets }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#roots.clear(); + for (const discard of [...this.#activeDiscards]) discard(); + } +} + +export const createRscCompilerAssetCheckpointTracker = (): RscCompilerAssetCheckpointTracker => + new CompilerAssetCheckpointTracker(); + +export interface RscRuntimeCapturedGenerationSnapshot { + readonly acceptCompilerAssetCheckpoint?: () => void; + readonly assets: readonly RuntimeGenerationAsset[]; + readonly attemptId: string; + readonly candidate: RuntimeGenerationCandidate; + readonly definition: SerializedRuntimeDefinition; + readonly discardCompilerAssetCheckpoint?: () => void; + readonly preparedRuntime: DevRuntimePreparedProject; + readonly rscCohortRevision: number; + readonly sourceRevision: string; +} + +export interface CaptureRuntimeGenerationSnapshotOptions { + readonly attemptId: string; + readonly candidate: RuntimeGenerationCandidate; + readonly compilerAssetCheckpointTracker?: RscCompilerAssetCheckpointTracker; + readonly compilerRoot: string; + readonly preparedRuntime: DevRuntimePreparedProject; + readonly rscCohortRevision: number; + readonly sourceRevision: string; +} + +export interface MaterializeRuntimeGenerationOptions { + readonly guard?: RuntimeGenerationActivationGuard; + readonly snapshot: RscRuntimeCapturedGenerationSnapshot; + readonly stateStoreId?: string; + readonly store: RuntimeGenerationStore; +} + +const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); + +const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); + + const input = value as Record; + return `{${Object.keys(input).sort().flatMap((key) => { + const item = input[key]; + return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; + }).join(',')}}`; +}; + +const digestValue = (value: unknown): string => + createHash('sha256').update(canonicalJson(value)).digest('hex'); + +const freezeJson = (value: unknown, seen = new WeakSet()): JsonValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); + return value; + } + if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); + if (seen.has(value)) throw new TypeError('Runtime metadata cannot contain cyclic values.'); + seen.add(value); + try { + if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item, seen))); + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input)) { + const item = input[key]; + if (item !== undefined) output[key] = freezeJson(item, seen); + } + return Object.freeze(output); + } finally { + seen.delete(value); + } +}; + +const isJsonObject = (value: JsonValue): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isSafeSegment = (value: string): boolean => + value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); + +const assertInside = (root: string, target: string): void => { + const path = relative(resolve(root), resolve(target)); + if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { + throw new Error('Runtime generation path escaped its root.'); + } +}; + +const assertRelativeAssetPath = (value: unknown): string => { + if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || isAbsolute(value)) { + throw new TypeError('Runtime asset path must be a contained slash-separated path.'); + } + const segments = value.split('/'); + if (segments.some((segment) => !isSafeSegment(segment))) { + throw new TypeError('Runtime asset path must not escape its root.'); + } + return segments.join('/'); +}; + +const fsync = async (path: string): Promise => { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +const copyFileExclusive = async (source: string, destination: string): Promise => { + const bytes = await readFile(source); + const handle = await open(destination, 'wx'); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } +}; + +const copyTree = async (sourceRoot: string, destinationRoot: string): Promise => { + const sourceStatus = await lstat(sourceRoot); + if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { + throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); + } + await mkdir(destinationRoot, { recursive: false }); + + const copyDirectory = async (source: string, destination: string): Promise => { + assertInside(sourceRoot, source); + assertInside(destinationRoot, destination); + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); + const sourcePath = join(source, entry.name); + const destinationPath = join(destination, entry.name); + assertInside(sourceRoot, sourcePath); + assertInside(destinationRoot, destinationPath); + const status = await lstat(sourcePath); + if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); + if (status.isDirectory()) { + await mkdir(destinationPath, { recursive: false }); + await copyDirectory(sourcePath, destinationPath); + } else if (status.isFile()) { + await copyFileExclusive(sourcePath, destinationPath); + } else { + throw new Error('Compiler output can contain only regular files and directories.'); + } + } + await fsync(destination); + }; + + await copyDirectory(sourceRoot, destinationRoot); +}; + +const copyCurrentRscAssets = async ( + sourceRoot: string, + destinationRoot: string, + runtimeAssets: RuntimeAssetsManifest, + priorAssets: ReadonlyMap | undefined, +): Promise> => { + const sourceStatus = await lstat(sourceRoot); + if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { + throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); + } + const currentAssets = new Set([ + ...runtimeAssets.allFiles, + ...generatedRscAssetPaths, + ]); + const sourceFiles = new Map(); + const staleAssets = new Map(); + const inspectDirectory = async (source: string, prefix: string): Promise => { + assertInside(sourceRoot, source); + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); + const sourcePath = join(source, entry.name); + assertInside(sourceRoot, sourcePath); + const status = await lstat(sourcePath); + if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); + const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; + if (status.isDirectory()) { + await inspectDirectory(sourcePath, path); + } else if (status.isFile()) { + if (currentAssets.has(path)) { + sourceFiles.set(path, sourcePath); + continue; + } + const priorDigest = priorAssets?.get(path); + if (priorDigest === undefined || digestBytes(await readFile(sourcePath)) !== priorDigest) { + throw new Error(`Compiler output contains an undeclared file ${JSON.stringify(path)}.`); + } + staleAssets.set(path, priorDigest); + } else { + throw new Error('Compiler output can contain only regular files and directories.'); + } + } + }; + + await inspectDirectory(sourceRoot, ''); + await mkdir(destinationRoot, { recursive: false }); + const destinationDirectories = new Set([destinationRoot]); + const rememberDirectories = (directory: string): void => { + let current = directory; + while (true) { + assertInside(destinationRoot, current); + destinationDirectories.add(current); + if (current === destinationRoot) return; + current = dirname(current); + } + }; + for (const path of [...currentAssets].sort((left, right) => left.localeCompare(right))) { + const source = sourceFiles.get(path); + if (source === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); + const destination = join(destinationRoot, ...path.split('/')); + const directory = dirname(destination); + assertInside(destinationRoot, destination); + await mkdir(directory, { recursive: true }); + rememberDirectories(directory); + await copyFileExclusive(source, destination); + } + for (const directory of [...destinationDirectories].sort((left, right) => right.length - left.length)) { + await fsync(directory); + } + return staleAssets; +}; + +const walkRegularFiles = async (root: string): Promise => { + const status = await lstat(root); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('Runtime generation root must be a regular directory.'); + } + const files: RuntimeGenerationAsset[] = []; + const walk = async (current: string, prefix: string): Promise => { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!isSafeSegment(entry.name)) throw new Error('Runtime generation contains an unsafe path segment.'); + const path = join(current, entry.name); + assertInside(root, path); + const entryStatus = await lstat(path); + if (entryStatus.isSymbolicLink()) throw new Error('Runtime generation cannot contain symbolic links.'); + const relativePath = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; + if (entryStatus.isDirectory()) { + await walk(path, relativePath); + } else if (entryStatus.isFile()) { + const bytes = await readFile(path); + files.push(Object.freeze({ bytes: bytes.byteLength, path: relativePath, sha256: digestBytes(bytes) })); + } else { + throw new Error('Runtime generation can contain only regular files and directories.'); + } + } + }; + await walk(root, ''); + return Object.freeze(files); +}; + +const equalAssets = (left: readonly RuntimeGenerationAsset[], right: readonly RuntimeGenerationAsset[]): boolean => + left.length === right.length && left.every((asset, index) => { + const candidate = right[index]; + return candidate !== undefined && asset.path === candidate.path && asset.bytes === candidate.bytes && asset.sha256 === candidate.sha256; + }); + +const redact = (value: string): string => value + .slice(0, 16 * 1024) + .replace(/((?:authorization|password|secret|token)\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/giu, '$1[REDACTED]'); + +const runDefinitionExecutable = async (entry: string): Promise => + new Promise((resolveDefinition, rejectDefinition) => { + const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let termination: Error | undefined; + let terminationGrace: ReturnType | undefined; + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (terminationGrace !== undefined) clearTimeout(terminationGrace); + callback(); + }; + const terminate = (error: Error): void => { + if (termination !== undefined) return; + termination = error; + child.kill('SIGTERM'); + terminationGrace = setTimeout(() => { + child.kill('SIGKILL'); + }, definitionTerminationGraceMs); + }; + const timeout = setTimeout(() => terminate(new Error('Runtime definition executable exceeded 5 seconds.')), definitionTimeoutMs); + + child.stdout.on('data', (chunk: Buffer) => { + if (termination !== undefined) return; + stdoutBytes += chunk.byteLength; + if (stdoutBytes > maximumDefinitionStdout) { + terminate(new Error('Runtime definition executable exceeded 1 MiB stdout.')); + } else { + stdout.push(chunk); + } + }); + child.stderr.on('data', (chunk: Buffer) => { + if (termination !== undefined) return; + const retained = Math.min(chunk.byteLength, maximumDefinitionStderr - stderrBytes); + if (retained > 0) stderr.push(chunk.subarray(0, retained)); + stderrBytes += chunk.byteLength; + if (stderrBytes > maximumDefinitionStderr) { + terminate(new Error('Runtime definition executable exceeded 64 KiB stderr.')); + } + }); + child.once('error', (error) => terminate(error)); + child.once('close', (code) => { + if (settled) return; + if (termination !== undefined) { + settle(() => rejectDefinition(termination as Error)); + return; + } + const errorOutput = redact(Buffer.concat(stderr).toString('utf8')); + if (code !== 0) { + settle(() => rejectDefinition(new Error(`Runtime definition executable failed with exit code ${String(code)}${errorOutput.length === 0 ? '' : `: ${errorOutput}`}`))); + return; + } + try { + const raw = Buffer.concat(stdout).toString('utf8').trim(); + if (Buffer.byteLength(raw, 'utf8') > maximumDefinitionStdout) throw new Error('Runtime definition executable exceeded 1 MiB stdout.'); + const parsed: unknown = JSON.parse(raw); + const definition = parseDefinition(parsed); + if (canonicalJson(definition) !== raw) throw new Error('Runtime definition executable did not emit canonical JSON.'); + settle(() => resolveDefinition(definition)); + } catch (error) { + settle(() => rejectDefinition(error instanceof Error ? error : new Error('Runtime definition executable emitted invalid JSON.'))); + } + }); + }); + +const closedObject = (value: unknown, fields: readonly string[], name: string): Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object.`); + const object = value as Record; + if (Object.keys(object).some((key) => !fields.includes(key)) || fields.some((field) => !(field in object))) { + throw new TypeError(`${name} has an invalid schema.`); + } + return object; +}; + +const parseDefinition = (value: unknown): SerializedRuntimeDefinition => { + const definition = closedObject(value, ['nativeHooks', 'resources', 'tools'], 'Runtime definition'); + if (!Array.isArray(definition.nativeHooks) || !Array.isArray(definition.resources) || !Array.isArray(definition.tools)) { + throw new TypeError('Runtime definition arrays are malformed.'); + } + const nativeHooks = definition.nativeHooks.map((value) => { + const hook = closedObject(value, ['event', 'handlerId', 'host', 'matcher'], 'Runtime native hook'); + if ((hook.event !== 'PostToolUse' && hook.event !== 'after_tool_use') || + (hook.host !== 'claude' && hook.host !== 'codex') || + typeof hook.handlerId !== 'string' || typeof hook.matcher !== 'string') { + throw new TypeError('Runtime native hook is malformed.'); + } + return Object.freeze({ event: hook.event, handlerId: hook.handlerId, host: hook.host, matcher: hook.matcher }); + }); + const resources = definition.resources.map((value) => { + const resource = closedObject(value, ['_meta', 'mimeType', 'name', 'uri'], 'Runtime resource'); + if (typeof resource.mimeType !== 'string' || typeof resource.name !== 'string' || typeof resource.uri !== 'string') { + throw new TypeError('Runtime resource is malformed.'); + } + const meta = freezeJson(resource._meta); + if (!isJsonObject(meta)) throw new TypeError('Runtime resource metadata is malformed.'); + return Object.freeze({ _meta: meta, mimeType: resource.mimeType, name: resource.name, uri: resource.uri }); + }); + const tools = definition.tools.map((value) => { + const tool = closedObject(value, ['_meta', 'annotations', 'description', 'handlerId', 'inputSchema', 'name', 'outputSchema'], 'Runtime tool'); + const annotations = closedObject(tool.annotations, ['destructiveHint', 'idempotentHint', 'openWorldHint', 'readOnlyHint'], 'Runtime tool annotations'); + if (typeof tool.description !== 'string' || typeof tool.handlerId !== 'string' || typeof tool.name !== 'string' || + Object.values(annotations).some((annotation) => typeof annotation !== 'boolean')) { + throw new TypeError('Runtime tool is malformed.'); + } + const meta = freezeJson(tool._meta); + const inputSchema = freezeJson(tool.inputSchema); + const outputSchema = freezeJson(tool.outputSchema); + if (!isJsonObject(meta) || !isJsonObject(inputSchema) || !isJsonObject(outputSchema)) { + throw new TypeError('Runtime tool JSON fields are malformed.'); + } + return Object.freeze({ + _meta: meta, + annotations: Object.freeze({ + destructiveHint: annotations.destructiveHint as boolean, + idempotentHint: annotations.idempotentHint as boolean, + openWorldHint: annotations.openWorldHint as boolean, + readOnlyHint: annotations.readOnlyHint as boolean, + }), + description: tool.description, + handlerId: tool.handlerId, + inputSchema, + name: tool.name, + outputSchema, + }); + }); + return Object.freeze({ nativeHooks: Object.freeze(nativeHooks), resources: Object.freeze(resources), tools: Object.freeze(tools) }) as unknown as SerializedRuntimeDefinition; +}; + +const parseRuntimeAssets = async (root: string): Promise => { + const parsed: unknown = JSON.parse(await readFile(join(root, 'runtime-assets.json'), 'utf8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new TypeError('runtime-assets.json is malformed.'); + const manifest = parsed as Record; + if (!Array.isArray(manifest.allFiles) || typeof manifest.entries !== 'object' || manifest.entries === null || Array.isArray(manifest.entries)) { + throw new TypeError('runtime-assets.json must contain allFiles and entries.'); + } + const allFiles = manifest.allFiles.map((value) => assertRelativeAssetPath(typeof value === 'string' ? value.replace(/^[/\\]+/, '') : value)); + if (new Set(allFiles).size !== allFiles.length) throw new TypeError('runtime-assets.json contains duplicate paths.'); + const entries: Record = {}; + for (const [name, value] of Object.entries(manifest.entries)) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('runtime-assets.json entry is malformed.'); + const entry = value as Record; + const parseGroup = (group: unknown): Readonly<{ readonly js?: readonly string[] }> | undefined => { + if (group === undefined) return undefined; + if (typeof group !== 'object' || group === null || Array.isArray(group)) throw new TypeError('runtime-assets.json entry group is malformed.'); + const js = (group as Record).js; + if (js === undefined) return Object.freeze({}); + if (!Array.isArray(js)) throw new TypeError('runtime-assets.json entry group is malformed.'); + return Object.freeze({ js: Object.freeze(js.map((asset) => assertRelativeAssetPath(typeof asset === 'string' ? asset.replace(/^[/\\]+/, '') : asset))) }); + }; + entries[name] = Object.freeze({ async: parseGroup(entry.async), initial: parseGroup(entry.initial) }); + } + return Object.freeze({ allFiles: Object.freeze(allFiles), entries: Object.freeze(entries) }); +}; + +const clientReferencePaths = (assets: readonly RuntimeGenerationAsset[]): readonly string[] => + assets.filter((asset) => asset.path.startsWith('widget/')).map((asset) => asset.path); + +const validateRuntimeAssetCoverage = ( + runtimeAssets: RuntimeAssetsManifest, + assets: readonly RuntimeGenerationAsset[], +): void => { + const assetPaths = new Set(assets.map((asset) => asset.path)); + for (const asset of runtimeAssets.allFiles) { + if (!assetPaths.has(`rsc/${asset}`)) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(asset)}.`); + } + for (const entry of requiredEntries) { + const declared = runtimeAssets.entries[entry]; + if (declared === undefined) throw new Error(`runtime-assets.json is missing required entry ${JSON.stringify(entry)}.`); + const files = [...(declared.initial?.js ?? []), ...(declared.async?.js ?? [])]; + if (files.length === 0 || files.some((asset) => !runtimeAssets.allFiles.includes(asset))) { + throw new Error(`runtime-assets.json entry ${JSON.stringify(entry)} has incomplete asset coverage.`); + } + } + for (const entry of executableAsyncEntries) { + const asyncAssets = runtimeAssets.entries[entry]?.async?.js; + if (asyncAssets === undefined || asyncAssets.length === 0 || asyncAssets.some((asset) => !runtimeAssets.allFiles.includes(asset))) { + throw new Error(`runtime-assets.json executable ${JSON.stringify(entry)} is missing async asset coverage.`); + } + } + if (!runtimeAssets.allFiles.some((path) => path.startsWith('chunks/'))) { + throw new Error('runtime-assets.json must declare an async chunks/ asset.'); + } + const expectedRscAssets = new Set([ + ...runtimeAssets.allFiles.map((path) => `rsc/${path}`), + 'rsc/runtime-assets.json', + 'rsc/runtime-definition.json', + 'rsc/agent-runtime.manifest.json', + ]); + const capturedRscAssets = assets.filter((asset) => asset.path.startsWith('rsc/')).map((asset) => asset.path); + if (capturedRscAssets.length !== expectedRscAssets.size || capturedRscAssets.some((path) => !expectedRscAssets.has(path))) { + throw new Error('Captured RSC runtime asset coverage is incomplete.'); + } +}; + +const validateClientReferenceRelationship = async ( + root: string, + assets: readonly RuntimeGenerationAsset[], +): Promise => { + const clientReferences = clientReferencePaths(assets); + const expectedClientAsset = 'widget/static/js/rsc/index.js'; + if (!clientReferences.includes('widget/rsc/index.html') || !clientReferences.includes(expectedClientAsset)) { + throw new Error('Captured generation is missing paired client reference assets.'); + } + const document = await readFile(join(root, 'widget', 'rsc', 'index.html'), 'utf8'); + const references = Array.from(document.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/giu), (match) => match[1]?.split(/[?#]/u, 1)[0]); + if (!references.includes('/static/js/rsc/index.js') && !references.includes('static/js/rsc/index.js')) { + throw new Error('Captured generation has an invalid client reference relationship.'); + } +}; + +const contentTypeFor = (path: string): RscRuntimeSurfaceAsset['contentType'] | undefined => { + if (path.endsWith('.js')) return 'application/javascript'; + if (path.endsWith('.json')) return 'application/json'; + if (path.endsWith('.css')) return 'text/css'; + if (path.endsWith('.html')) return 'text/html'; + return undefined; +}; + +const surfaceAssets = ( + preparedRuntime: DevRuntimePreparedProject, + assets: readonly RuntimeGenerationAsset[], +): Readonly> => { + const widgetAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => { + if (!asset.path.startsWith('widget/')) return []; + const contentType = contentTypeFor(asset.path); + if (contentType === undefined) return []; + const requestPath = asset.path.slice('widget'.length); + return [Object.freeze({ + bytes: asset.bytes, + contentType, + generationPath: asset.path, + requestPath, + sha256: asset.sha256, + })]; + }); + const appHtmlAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => { + if (!asset.path.startsWith('app/') || !asset.path.endsWith('.html')) return []; + return [Object.freeze({ + bytes: asset.bytes, + contentType: 'text/html', + generationPath: asset.path, + requestPath: asset.path.slice('app'.length), + sha256: asset.sha256, + })]; + }); + const surfaces: Record = {}; + for (const app of preparedRuntime.apps) { + const surfaceId = `mcp.${app.name}`; + if (surfaces[surfaceId] !== undefined) throw new Error('Runtime generation has duplicate App surface definitions.'); + const resourcePath = appResourcePath(app.resourceUri); + const html = appHtmlAssets.filter((asset) => asset.requestPath === resourcePath); + if (html.length !== 1) throw new Error(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no unique captured HTML asset.`); + surfaces[surfaceId] = Object.freeze([ + ...widgetAssets.map((asset) => Object.freeze({ ...asset })), + Object.freeze({ ...html[0]! }), + ]); + } + return Object.freeze(surfaces); +}; + +const appResourcePath = (uri: string): string => { + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + throw new TypeError('Runtime generation App resource URI is invalid.'); + } + if (parsed.protocol !== 'ui:' || parsed.host.length === 0 || parsed.search.length > 0 || parsed.hash.length > 0) { + throw new TypeError('Runtime generation App resource URI is invalid.'); + } + const origin = `ui://${parsed.host}`; + if (!uri.startsWith(origin)) throw new TypeError('Runtime generation App resource URI is invalid.'); + const path = uri.slice(origin.length); + const segments = path.startsWith('/') ? path.slice(1).split('/') : []; + if (segments.length === 0 || segments.some((segment) => !isSafeSegment(segment) || decodeURIComponent(segment) !== segment)) { + throw new TypeError('Runtime generation App resource URI is invalid.'); + } + return `/${segments.join('/')}`; +}; + +const validateAppSurfaceAssets = ( + apps: readonly RscRuntimeAppDefinition[], + surfaces: Readonly>, +): void => { + const expected = new Set(); + for (const app of apps) { + const surfaceId = `mcp.${app.name}`; + if (expected.has(surfaceId)) throw new TypeError('Runtime generation has duplicate App surface definitions.'); + expected.add(surfaceId); + const resourcePath = appResourcePath(app.resourceUri); + const appHtml = surfaces[surfaceId]?.filter((asset) => + asset.contentType === 'text/html' && asset.generationPath.startsWith('app/'), + ) ?? []; + if (appHtml.length !== 1 || appHtml[0]!.generationPath !== `app${resourcePath}` || appHtml[0]!.requestPath !== resourcePath) { + throw new TypeError(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no canonical captured HTML asset.`); + } + } + if (Object.keys(surfaces).length !== expected.size || Object.keys(surfaces).some((surfaceId) => !expected.has(surfaceId))) { + throw new TypeError('Runtime generation App surface assets are not owned by App definitions.'); + } +}; + +const transportProjection = (preparedRuntime: DevRuntimePreparedProject): JsonValue => freezeJson({ + provider: preparedRuntime.provider, + servers: preparedRuntime.servers.map((server) => ({ + args: server.args === undefined ? undefined : [...server.args], + command: server.command, + cwd: server.cwd, + env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), + headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), + id: server.id, + name: server.name, + source: server.source, + targets: [...server.targets], + transport: server.transport, + url: server.url, + })), +}); + +type RuntimeDefinitionPreparedProject = Readonly<{ + readonly apps: readonly RscRuntimeAppDefinition[]; +}>; + +const appDefinitions = (preparedRuntime: RuntimeDefinitionPreparedProject): readonly RscRuntimeAppDefinition[] => + freezeJson(preparedRuntime.apps.map((app) => ({ + ...(app._meta === undefined ? {} : { _meta: app._meta }), + id: app.id, + name: app.name, + resourceUri: app.resourceUri, + serverId: app.serverId, + serverName: app.serverName, + targets: [...app.targets], + })).sort((left, right) => { + const leftJson = canonicalJson(left); + const rightJson = canonicalJson(right); + return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0; + })) as unknown as readonly RscRuntimeAppDefinition[]; + +const runtimeDefinitionProjection = ( + definition: SerializedRuntimeDefinition, + preparedRuntime: RuntimeDefinitionPreparedProject, +): JsonValue => freezeJson({ + apps: appDefinitions(preparedRuntime), + definition, +}); + +export const runtimeDefinitionDigest = ( + definition: SerializedRuntimeDefinition, + preparedRuntime: RuntimeDefinitionPreparedProject, +): string => digestValue(runtimeDefinitionProjection(definition, preparedRuntime)); + +const descriptors = ( + preparedRuntime: DevRuntimePreparedProject, + definition: SerializedRuntimeDefinition, + definitionDigest: string, + serverDigest: string, + transportDigest: string, +): readonly DevRuntimeMcpServerDescriptor[] => Object.freeze(preparedRuntime.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ + definitionDigest, + name: server.name, + resources: Object.freeze(definition.resources.map((resource) => freezeJson(resource) as JsonObject)), + serverDigest, + target, + tools: Object.freeze(definition.tools.map((tool) => freezeJson(tool) as JsonObject)), + transportDigest, +})))); + +const metadataFromSnapshot = async ( + snapshot: RscRuntimeCapturedGenerationSnapshot, + assets: readonly RuntimeGenerationAsset[], + stateStoreId: string, +): Promise => { + const root = snapshot.candidate.root; + const runtimeAssets = await parseRuntimeAssets(join(root, 'rsc')); + validateRuntimeAssetCoverage(runtimeAssets, assets); + await validateClientReferenceRelationship(root, assets); + const definitionBytes = await readFile(join(root, ...definitionFile.split('/'))); + const parsedDefinition = parseDefinition(JSON.parse(definitionBytes.toString('utf8'))); + if (canonicalJson(parsedDefinition) !== definitionBytes.toString('utf8')) { + throw new Error('Captured runtime definition is not canonical.'); + } + const capturedAppDefinitions = appDefinitions(snapshot.preparedRuntime); + const definitionDigest = runtimeDefinitionDigest(snapshot.definition, snapshot.preparedRuntime); + const environmentHashes = Object.freeze({ + rsc: digestValue(assets.filter((asset) => asset.path.startsWith('rsc/'))), + widget: digestValue(assets.filter((asset) => asset.path.startsWith('widget/'))), + }); + const serverDigest = digestValue(environmentHashes); + const transportDigest = digestValue(transportProjection(snapshot.preparedRuntime)); + const entries = Object.freeze(Object.fromEntries(requiredEntries.map((entry) => { + const assetsForEntry = runtimeAssets.entries[entry]; + const path = assetsForEntry?.initial?.js?.[0]; + if (path === undefined) throw new Error(`runtime-assets.json entry ${JSON.stringify(entry)} has no initial JavaScript asset.`); + return [entry, `rsc/${path}`]; + }))); + return Object.freeze({ + appDefinitions: capturedAppDefinitions, + definitionDigest, + entries, + environmentHashes, + preparedRevision: snapshot.preparedRuntime.sourceRevision, + serverDigest, + servers: descriptors(snapshot.preparedRuntime, parsedDefinition, definitionDigest, serverDigest, transportDigest), + stateStoreId, + surfaceAssets: surfaceAssets(snapshot.preparedRuntime, assets), + transportDigest, + }); +}; + +const clonePreparedRuntime = (preparedRuntime: DevRuntimePreparedProject): DevRuntimePreparedProject => freezeJson(preparedRuntime) as unknown as DevRuntimePreparedProject; + +export const captureRuntimeGenerationSnapshot = async ( + input: CaptureRuntimeGenerationSnapshotOptions, +): Promise => { + const compilerRoot = resolve(input.compilerRoot); + const checkpoint = await input.compilerAssetCheckpointTracker?.checkpoint(compilerRoot); + try { + const rscRoot = join(compilerRoot, 'rsc'); + const definition = await runDefinitionExecutable(join(rscRoot, 'dev', 'definition.js')); + const definitionBytes = Buffer.from(canonicalJson(definition)); + const definitionPath = join(rscRoot, 'runtime-definition.json'); + await unlink(definitionPath).catch((error: unknown) => { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return undefined; + throw error; + }); + await writeFile(definitionPath, definitionBytes, { encoding: 'utf8', flag: 'wx' }); + await fsync(join(rscRoot, 'runtime-definition.json')); + await emitRuntimeArtifacts(rscRoot, definition); + await fsync(rscRoot); + const runtimeAssets = await parseRuntimeAssets(rscRoot); + const staleAssets = await copyCurrentRscAssets( + rscRoot, + join(input.candidate.root, 'rsc'), + runtimeAssets, + checkpoint?.priorAssets, + ); + await copyTree(join(compilerRoot, 'app'), join(input.candidate.root, 'app')); + await copyTree(join(compilerRoot, 'widget'), join(input.candidate.root, 'widget')); + await fsync(input.candidate.root); + const assets = await walkRegularFiles(input.candidate.root); + const capturedAssets = new Map(assets.map((asset) => [asset.path, asset])); + const checkpointAssets = new Map(staleAssets); + for (const path of runtimeAssets.allFiles) { + const asset = capturedAssets.get(`rsc/${path}`); + if (asset === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); + checkpointAssets.set(path, asset.sha256); + } + return Object.freeze({ + ...(checkpoint === undefined ? {} : { + acceptCompilerAssetCheckpoint: () => checkpoint.accept(checkpointAssets), + discardCompilerAssetCheckpoint: () => checkpoint.discard(), + }), + assets, + attemptId: input.attemptId, + candidate: input.candidate, + definition, + preparedRuntime: clonePreparedRuntime(input.preparedRuntime), + rscCohortRevision: input.rscCohortRevision, + sourceRevision: input.sourceRevision, + }); + } catch (error) { + checkpoint?.discard(); + throw error; + } +}; + +const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => { + if (!isJsonObject(value)) throw new TypeError('Runtime generation metadata is malformed.'); + const required = ['appDefinitions', 'definitionDigest', 'entries', 'environmentHashes', 'preparedRevision', 'serverDigest', 'servers', 'stateStoreId', 'surfaceAssets', 'transportDigest']; + if (Object.keys(value).some((key) => !required.includes(key)) || required.some((key) => !(key in value))) { + throw new TypeError('Runtime generation metadata has an invalid schema.'); + } + const { appDefinitions, definitionDigest, entries, environmentHashes, preparedRevision, serverDigest, servers, stateStoreId, surfaceAssets, transportDigest } = value; + if (typeof definitionDigest !== 'string' || typeof serverDigest !== 'string' || typeof transportDigest !== 'string' || + typeof preparedRevision !== 'string' || typeof stateStoreId !== 'string' || + !sha256Expression.test(definitionDigest) || !sha256Expression.test(serverDigest) || !sha256Expression.test(transportDigest) || + !Array.isArray(appDefinitions) || !isJsonObject(entries) || !isJsonObject(environmentHashes) || !Array.isArray(servers) || !isJsonObject(surfaceAssets)) { + throw new TypeError('Runtime generation metadata is malformed.'); + } + if (preparedRevision.length === 0 || stateStoreId.length === 0 || + Object.keys(entries).length !== requiredEntries.length || requiredEntries.some((entry) => typeof entries[entry] !== 'string') || + Object.keys(environmentHashes).length !== 2 || typeof environmentHashes.rsc !== 'string' || typeof environmentHashes.widget !== 'string' || + !sha256Expression.test(environmentHashes.rsc) || !sha256Expression.test(environmentHashes.widget)) { + throw new TypeError('Runtime generation environment digests are malformed.'); + } + + const decodedAppDefinitions = appDefinitions.map((value): RscRuntimeAppDefinition => { + if (!isJsonObject(value)) throw new TypeError('Runtime generation App definition is malformed.'); + const fields = ['_meta', 'id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; + const requiredFields = ['id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; + if (Object.keys(value).some((key) => !fields.includes(key)) || requiredFields.some((field) => !(field in value)) || + typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.resourceUri !== 'string' || + typeof value.serverId !== 'string' || typeof value.serverName !== 'string' || + !Array.isArray(value.targets) || !value.targets.every((target) => typeof target === 'string') || + ('template' in value && typeof value.template !== 'string')) { + throw new TypeError('Runtime generation App definition is malformed.'); + } + const meta = '_meta' in value ? freezeJson(value._meta) : undefined; + if (meta !== undefined && !isJsonObject(meta)) throw new TypeError('Runtime generation App definition metadata is malformed.'); + return Object.freeze({ + ...(meta === undefined ? {} : { _meta: meta }), + id: value.id, + name: value.name, + resourceUri: value.resourceUri, + serverId: value.serverId, + serverName: value.serverName, + targets: Object.freeze([...value.targets]), + }); + }); + + const decodedServers = servers.map((value): DevRuntimeMcpServerDescriptor => { + if (!isJsonObject(value)) throw new TypeError('Runtime generation server descriptor is malformed.'); + const fields = ['definitionDigest', 'name', 'resources', 'serverDigest', 'target', 'tools', 'transportDigest']; + if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) || + typeof value.definitionDigest !== 'string' || typeof value.name !== 'string' || typeof value.serverDigest !== 'string' || + typeof value.target !== 'string' || typeof value.transportDigest !== 'string' || + !Array.isArray(value.resources) || !value.resources.every(isJsonObject) || !Array.isArray(value.tools) || !value.tools.every(isJsonObject)) { + throw new TypeError('Runtime generation server descriptor is malformed.'); + } + return Object.freeze({ + definitionDigest: value.definitionDigest, + name: value.name, + resources: Object.freeze(value.resources.map((resource) => freezeJson(resource) as JsonObject)), + serverDigest: value.serverDigest, + target: value.target, + tools: Object.freeze(value.tools.map((tool) => freezeJson(tool) as JsonObject)), + transportDigest: value.transportDigest, + }); + }); + + const decodedSurfaceAssets: Record = {}; + for (const [surfaceId, value] of Object.entries(surfaceAssets)) { + if (surfaceId.length === 0 || !Array.isArray(value)) throw new TypeError('Runtime generation surface assets are malformed.'); + decodedSurfaceAssets[surfaceId] = Object.freeze(value.map((value): RscRuntimeSurfaceAsset => { + if (!isJsonObject(value)) throw new TypeError('Runtime generation surface asset is malformed.'); + const fields = ['bytes', 'contentType', 'generationPath', 'requestPath', 'sha256']; + if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) || + typeof value.bytes !== 'number' || !Number.isSafeInteger(value.bytes) || value.bytes < 0 || typeof value.generationPath !== 'string' || + typeof value.requestPath !== 'string' || typeof value.sha256 !== 'string' || !sha256Expression.test(value.sha256) || + (value.contentType !== 'application/javascript' && value.contentType !== 'application/json' && value.contentType !== 'text/css' && value.contentType !== 'text/html')) { + throw new TypeError('Runtime generation surface asset is malformed.'); + } + return Object.freeze({ + bytes: value.bytes, + contentType: value.contentType, + generationPath: assertRelativeAssetPath(value.generationPath), + requestPath: value.requestPath, + sha256: value.sha256, + }); + })); + } + return Object.freeze({ + appDefinitions: Object.freeze(decodedAppDefinitions), + definitionDigest, + entries: Object.freeze(Object.fromEntries(requiredEntries.map((entry) => [entry, entries[entry] as string]))), + environmentHashes: Object.freeze({ rsc: environmentHashes.rsc, widget: environmentHashes.widget }), + preparedRevision, + serverDigest, + servers: Object.freeze(decodedServers), + stateStoreId, + surfaceAssets: Object.freeze(decodedSurfaceAssets), + transportDigest, + }); +}; + +export const rscRuntimeGenerationMetadataCodec: RuntimeGenerationMetadataCodec = Object.freeze({ + decode: decodeMetadata, + encode: (value: RscRuntimeGenerationMetadata) => freezeJson(value), +}); + +export const validateRscRuntimeGenerationMetadata = async ( + input: RuntimeGenerationValidationInput, +): Promise => { + const metadata = decodeMetadata(freezeJson(input.metadata)); + const assets = new Map(input.assets.map((asset) => [asset.path, asset])); + for (const environment of ['rsc', 'widget'] as const) { + if (!input.assets.some((asset) => asset.path.startsWith(`${environment}/`))) { + throw new TypeError(`Runtime generation is missing the ${environment} environment.`); + } + } + for (const entry of requiredEntries) { + const path = metadata.entries[entry]; + if (typeof path !== 'string' || !assets.has(path)) throw new TypeError(`Runtime generation is missing required entry ${JSON.stringify(entry)}.`); + } + if (!assets.has(definitionFile) || !assets.has(runtimeAssetsFile)) { + throw new TypeError('Runtime generation is missing captured definition assets.'); + } + const runtimeAssets = await parseRuntimeAssets(join(input.root, 'rsc')); + validateRuntimeAssetCoverage(runtimeAssets, input.assets); + const definitionBytes = await readFile(join(input.root, ...definitionFile.split('/'))); + const definition = parseDefinition(JSON.parse(definitionBytes.toString('utf8'))); + if (canonicalJson(definition) !== definitionBytes.toString('utf8') || + runtimeDefinitionDigest(definition, Object.freeze({ apps: metadata.appDefinitions })) !== metadata.definitionDigest) { + throw new TypeError('Runtime generation definition digest is inconsistent.'); + } + await validateClientReferenceRelationship(input.root, input.assets); + const expectedEnvironmentHashes = Object.freeze({ + rsc: digestValue(input.assets.filter((asset) => asset.path.startsWith('rsc/'))), + widget: digestValue(input.assets.filter((asset) => asset.path.startsWith('widget/'))), + }); + if (metadata.environmentHashes.rsc !== expectedEnvironmentHashes.rsc || metadata.environmentHashes.widget !== expectedEnvironmentHashes.widget || + metadata.serverDigest !== digestValue(expectedEnvironmentHashes)) { + throw new TypeError('Runtime generation implementation digest is inconsistent.'); + } + const declaredSurfaceAssets = metadata.surfaceAssets as Readonly>; + for (const [surface, descriptors] of Object.entries(declaredSurfaceAssets)) { + const requestPaths = new Set(); + for (const asset of descriptors) { + if (requestPaths.has(asset.requestPath) || assets.get(asset.generationPath)?.sha256 !== asset.sha256 || assets.get(asset.generationPath)?.bytes !== asset.bytes || contentTypeFor(asset.generationPath) !== asset.contentType) { + throw new TypeError(`Runtime generation surface ${JSON.stringify(surface)} is invalid.`); + } + requestPaths.add(asset.requestPath); + } + } + validateAppSurfaceAssets(metadata.appDefinitions, declaredSurfaceAssets); + for (const descriptor of metadata.servers) { + if (descriptor.definitionDigest !== metadata.definitionDigest || descriptor.serverDigest !== metadata.serverDigest || descriptor.transportDigest !== metadata.transportDigest) { + throw new TypeError('Runtime generation server descriptor digest is inconsistent.'); + } + } + return metadata; +}; + +export const materializeRuntimeGeneration = async ( + input: MaterializeRuntimeGenerationOptions, +): Promise> => { + try { + const assets = await walkRegularFiles(input.snapshot.candidate.root); + if (!equalAssets(input.snapshot.assets, assets)) { + throw new Error('Runtime generation candidate no longer matches its captured cohort.'); + } + const metadata = await metadataFromSnapshot(input.snapshot, assets, input.stateStoreId ?? 'playground'); + const manifest: RuntimeGenerationManifestInput = Object.freeze({ assets, metadata }); + return await input.store.prepare(input.snapshot.candidate, manifest, input.guard === undefined ? {} : { guard: input.guard }); + } catch (error) { + await input.store.fail(input.snapshot.candidate).catch(() => undefined); + throw error; + } +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts new file mode 100644 index 000000000..199d7b13d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts @@ -0,0 +1,29 @@ +const diagnosticPreviewBytes = 16 * 1024; + +const sensitiveKey = /(?:api[-_]?key|authorization|bearer|credential|cookie|password|secret|token)/iu; +const sensitiveLabel = /((?:api[-_]?key|authorization|credential|cookie|password|secret|token)\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/giu; +const providerCredentialSources = Object.freeze([ + String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, + String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, +]); +const providerCredentialValues = Object.freeze(providerCredentialSources.map((source) => new RegExp(source, 'iu'))); +const providerCredentialDiagnostics = Object.freeze(providerCredentialSources.map((source) => new RegExp(source, 'giu'))); +const credentialAssignment = /(?:api[-_]?key|authorization|credential|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/iu; +const bearerCredential = /\bbearer\s+[^\s,;]+/iu; +const bearerCredentialDiagnostic = new RegExp(bearerCredential.source, 'giu'); + +export const isInspectionSensitiveKey = (key: string): boolean => sensitiveKey.test(key); + +export const hasInspectionCredential = (value: string): boolean => + credentialAssignment.test(value) + || bearerCredential.test(value) + || providerCredentialValues.some((pattern) => pattern.test(value)); + +export const redactInspectionDiagnostics = (value: string): string => { + let redacted = value + .slice(0, diagnosticPreviewBytes) + .replace(sensitiveLabel, '$1[redacted]') + .replace(bearerCredentialDiagnostic, 'Bearer [redacted]'); + for (const pattern of providerCredentialDiagnostics) redacted = redacted.replace(pattern, '[redacted]'); + return redacted; +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts new file mode 100644 index 000000000..b7741471d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts @@ -0,0 +1,236 @@ +import { requestFlightRenderWithFlight } from '../flight/request-render.js'; +import { writeSync } from 'node:fs'; +import { lowerHookResult, lowerMcpResult } from '@agent-bundle/rsc-runtime'; +import type { + DevRuntimeInspectionRequest, + DevRuntimeInspectionResponse, + EditEvent, + RenderRequest, + RuntimeSnapshot, +} from '../runtime/contracts.js'; +import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js'; + +import { hasInspectionCredential, isInspectionSensitiveKey } from './inspection-security.js'; +import { serializeInspection } from './serialize-inspection.js'; + +const maximumInvocationRequestBytes = 1024 * 1024; +const maximumInvocationFlightBytes = 4 * 1024 * 1024; +const maximumInvocationResponseBytes = 4 * 1024 * 1024; + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; + +const readRequiredString = (value: Record, key: string): string => { + const item = value[key]; + if (typeof item !== 'string' || item.trim() === '') throw new Error(`Invocation request requires ${key}`); + return item; +}; + +const assertExactKeys = (value: Record, keys: readonly string[]): void => { + const unexpected = Object.keys(value).filter((key) => !keys.includes(key)); + if (unexpected.length > 0) throw new Error('Invocation request contains unsupported fields'); + const missing = keys.filter((key) => !(key in value)); + if (missing.length > 0) throw new Error('Invocation request is missing required fields'); +}; + +const assertNoSnapshotCredentials = (value: Record): void => { + for (const [key, item] of Object.entries(value)) { + if (isInspectionSensitiveKey(key) || (typeof item === 'string' && hasInspectionCredential(item))) { + throw new Error('Runtime snapshot contains sensitive data'); + } + } +}; + +const parseEdit = (value: unknown): EditEvent => { + const event = asRecord(value); + if (event === undefined) throw new Error('Runtime snapshot contains an invalid edit'); + assertNoSnapshotCredentials(event); + assertExactKeys(event, ['eventId', 'host', 'path', 'recordedAt', 'sessionId', 'toolName']); + const host = readRequiredString(event, 'host'); + if (host !== 'claude' && host !== 'codex') throw new Error('Runtime snapshot contains an invalid edit host'); + return { + eventId: readRequiredString(event, 'eventId'), + host, + path: readRequiredString(event, 'path'), + recordedAt: readRequiredString(event, 'recordedAt'), + sessionId: readRequiredString(event, 'sessionId'), + toolName: readRequiredString(event, 'toolName'), + }; +}; + +const parseSnapshot = (value: unknown): RuntimeSnapshot => { + const snapshot = asRecord(value); + const stateVersion = snapshot?.stateVersion; + if ( + snapshot === undefined || + typeof stateVersion !== 'number' || + !Number.isSafeInteger(stateVersion) || + stateVersion < 0 + ) { + throw new Error('Invocation request requires a valid runtime snapshot'); + } + assertNoSnapshotCredentials(snapshot); + const seed = snapshot.seed; + assertExactKeys(snapshot, seed === undefined ? ['edits', 'stateVersion'] : ['edits', 'seed', 'stateVersion']); + if (!Array.isArray(snapshot.edits)) throw new Error('Invocation request requires a valid runtime snapshot'); + return seed === undefined + ? { edits: snapshot.edits.map(parseEdit), stateVersion } + : { edits: snapshot.edits.map(parseEdit), seed: seed as RuntimeSnapshot['seed'], stateVersion }; +}; + +const parseRequest = (value: unknown): DevRuntimeInspectionRequest => { + const request = asRecord(value); + if (request === undefined) throw new Error('Invocation request must be a JSON object'); + + const type = readRequiredString(request, 'type'); + if (type === 'hook/after-file-edit') { + assertExactKeys(request, ['host', 'input', 'stateFile', 'stateStoreId', 'type']); + const host = readRequiredString(request, 'host'); + if (host !== 'claude' && host !== 'codex') throw new Error('Hook invocation host must be claude or codex'); + const input = asRecord(request.input); + if (input === undefined) throw new Error('Hook invocation requires an object input'); + return { + host, + input, + stateFile: readRequiredString(request, 'stateFile'), + stateStoreId: readRequiredString(request, 'stateStoreId'), + type, + }; + } + + if (type === 'mcp/render-timeline') { + assertExactKeys(request, ['snapshot', 'stateFile', 'stateStoreId', 'type']); + return { + snapshot: parseSnapshot(request.snapshot), + stateFile: readRequiredString(request, 'stateFile'), + stateStoreId: readRequiredString(request, 'stateStoreId'), + type, + }; + } + + if (type === 'mcp/runtime-status') { + assertExactKeys(request, ['stateFile', 'stateStoreId', 'type']); + return { + stateFile: readRequiredString(request, 'stateFile'), + stateStoreId: readRequiredString(request, 'stateStoreId'), + type, + }; + } + + throw new Error(`Unsupported invocation request type: ${type}`); +}; + +const readRequest = async (): Promise => { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > maximumInvocationRequestBytes) { + throw new Error(`Invocation request exceeded ${maximumInvocationRequestBytes} bytes`); + } + chunks.push(buffer); + } + if (bytes === 0) throw new Error('Invocation request must not be empty'); + + let decoded: string; + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + throw new Error('Invocation request must be valid UTF-8 JSON'); + } + return parseRequest(JSON.parse(decoded)); +}; + +const renderRequestFor = (request: DevRuntimeInspectionRequest): RenderRequest => { + if (request.type === 'hook/after-file-edit') { + return { + event: request.host === 'claude' ? normalizeClaudeHook(request.input) : normalizeCodexHook(request.input), + stateFile: request.stateFile, + type: request.type, + }; + } + if (request.type === 'mcp/render-timeline') { + return { snapshot: request.snapshot, stateFile: request.stateFile, type: request.type }; + } + return { stateFile: request.stateFile, type: request.type }; +}; + +interface InvocationOutput { + readonly flight: Buffer; + readonly response: DevRuntimeInspectionResponse; +} + +const invoke = async (signal?: AbortSignal): Promise => { + const request = await readRequest(); + const rendered = await requestFlightRenderWithFlight(renderRequestFor(request), { + maximumFlightBytes: maximumInvocationFlightBytes, + signal, + }); + + if (request.type === 'hook/after-file-edit') { + const native = lowerHookResult(rendered.node); + return Object.freeze({ + flight: Buffer.from(rendered.flight), + response: Object.freeze({ + flightBytes: rendered.flight.byteLength, + inspection: serializeInspection({ + agentVisible: native.hookSpecificOutput.additionalContext, + flight: rendered.flight, + native, + node: rendered.node, + stateStoreId: request.stateStoreId, + stateVersion: rendered.stateVersion, + }), + }), + }); + } + + const protocol = lowerMcpResult(rendered.node); + return Object.freeze({ + flight: Buffer.from(rendered.flight), + response: Object.freeze({ + flightBytes: rendered.flight.byteLength, + inspection: serializeInspection({ + flight: rendered.flight, + modelVisible: protocol.content, + node: rendered.node, + protocol, + stateStoreId: request.stateStoreId, + stateVersion: rendered.stateVersion, + }), + }), + }); +}; + +const controller = new AbortController(); +const abort = (): void => controller.abort(); +process.once('SIGINT', abort); +process.once('SIGTERM', abort); + +const writeFlight = (flight: Buffer): void => { + let offset = 0; + while (offset < flight.byteLength) { + offset += writeSync(3, flight, offset, flight.byteLength - offset); + } +}; + +const writeResponse = ({ flight, response }: InvocationOutput): void => { + writeFlight(flight); + const line = `${JSON.stringify(response)}\n`; + if (Buffer.byteLength(line, 'utf8') > maximumInvocationResponseBytes) { + throw new Error('Inspection response exceeded output limit'); + } + process.stdout.write(line); +}; + +const reportFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : 'Invocation failed'; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}; + +void invoke(controller.signal).then(writeResponse).catch(reportFailure).finally(() => { + process.removeListener('SIGINT', abort); + process.removeListener('SIGTERM', abort); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts new file mode 100644 index 000000000..d45d7fc69 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts @@ -0,0 +1,13 @@ +import type { DevRuntimeProvider, DevRuntimeStartContext } from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; + +import { RsbuildRuntimeSession } from './rsbuild-runtime-session.js'; + +export const createDevRuntimeProvider = (): DevRuntimeProvider => Object.freeze({ + descriptor: Object.freeze({ + environmentVariables: Object.freeze([]), + id: 'rsc-agent-runtime', + label: 'RSC agent runtime', + schemaVersion: 1, + }), + start: async (context: DevRuntimeStartContext) => RsbuildRuntimeSession.start(context), +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts new file mode 100644 index 000000000..e7cc886c2 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -0,0 +1,2830 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, realpath, rm, writeFile, type FileHandle } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { createRsbuild, type StartDevServerResult } from '@rsbuild/core'; + +import { + createRscRuntimeRsbuildConfig, + type RscRuntimeCompileFailureKind, + type RscRuntimeCompileSnapshot, +} from '../../rsbuild.config.js'; +import { + captureRuntimeGenerationSnapshot, + createRscCompilerAssetCheckpointTracker, + materializeRuntimeGeneration, + rscRuntimeGenerationMetadataCodec, + runtimeDefinitionDigest, + validateRscRuntimeGenerationMetadata, + type RscCompilerAssetCheckpointTracker, + type RscRuntimeCapturedGenerationSnapshot, +} from './generation-materializer.js'; +import type { + RscRuntimeGenerationMetadata, + RuntimeSnapshot, + SerializedRuntimeDefinition, +} from '../runtime/contracts.js'; +import { createFileRuntimeKernel } from '../runtime/state-file.js'; +import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js'; +import { + hasInspectionCredential, + isInspectionSensitiveKey, + redactInspectionDiagnostics, +} from './inspection-security.js'; +import { + RuntimeGenerationStore, + type RuntimeGeneration, + type RuntimeGenerationActivationGuard, + type RuntimeGenerationCandidate, + type RuntimeGenerationPreparedActivation, +} from '../../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; +import { + RuntimeMcpRegistry, + type RuntimeMcpConnection, + type RuntimeMcpConnector, + type RuntimeMcpExecutionContext, + type RuntimeMcpPreparedActivationReconcile, +} from '../../../../packages/agent-bundle/src/dev/runtime-mcp-registry.ts'; +import { + DevRuntimeGenerationConflictError, + DevRuntimeUnavailableError, + type DevRuntimeClientSurfaceEndpoint, + type DevRuntimeEventInput, + type DevRuntimeMcpSession, + type DevRuntimeMcpSessionCloseObservation, + type DevRuntimePreparedProject, + type DevRuntimeSession, + type DevRuntimeStartContext, +} from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; +import { + type DevRuntimeAsset, + type DevRuntimeAssetRequest, + type DevRuntimeDescriptor, + type DevRuntimeDiagnostic, + type DevRuntimeFixture, + type DevRuntimeInspectionEnvelope, + type DevRuntimeInvocationRequest, + type DevRuntimeMcpConnectionState, + type DevRuntimeMcpRegistryReconcileInput, + type DevRuntimeMcpSessionBinding, + type DevRuntimeReplayRequest, + type DevRuntimeRun, + type DevRuntimeStateIdentity, + type DevRuntimeStateResetRequest, + type DevRuntimeStatus, + type DevRuntimeSurface, + type RuntimeVector, +} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; +import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; + +const descriptor: DevRuntimeDescriptor = Object.freeze({ + environmentVariables: Object.freeze([]), + id: 'rsc-agent-runtime', + label: 'RSC agent runtime', + schemaVersion: 1, +}); +const clientSurfaceId = 'mcp.edit-timeline'; +const clientSurfaceEntry = '/edit-timeline-v1.html'; +const maximumAssetBytes = 8 * 1024 * 1024; +const stateStoreId = 'playground'; +const maximumInvocationWorkers = 4; +const maximumInvocationStdoutBytes = 4 * 1024 * 1024; +const maximumInvocationFlightBytes = 4 * 1024 * 1024; +const maximumInvocationStderrBytes = 256 * 1024; +const maximumRunHistory = 50; +const invocationTimeoutMs = 10_000; +const invocationTerminationGraceMs = 100; +const flightPreviewBytes = 32 * 1024; +const windowsJobOwnerPhaseDeadlineMs = 2_000; +const noFixtures: readonly DevRuntimeFixture[] = Object.freeze([]); +const claudePostToolUseFixture: DevRuntimeFixture = Object.freeze({ + id: 'claude-post-tool-use-write', + label: 'Claude PostToolUse Write', + seed: Object.freeze({ + cwd: '/tmp', + hook_event_name: 'PostToolUse', + session_id: 'fixture-claude-post-tool-use', + tool_input: Object.freeze({ file_path: 'fixture-claude-post-tool-use.txt' }), + tool_name: 'Write', + tool_use_id: 'fixture-claude-post-tool-use-write', + }), +}); +const claudeFixtures: readonly DevRuntimeFixture[] = Object.freeze([claudePostToolUseFixture]); +const fixturesForHook = (host: 'claude' | 'codex'): readonly DevRuntimeFixture[] => host === 'claude' ? claudeFixtures : noFixtures; + +const withinDeadline = (promise: Promise, timeoutMs: number, message: string): Promise => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + void promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); + +// The wrapper is a normal Node child, so its inherited fd 3 remains a libuv +// Flight pipe. It imports the generation entry only after the Job owner +// assigns it to a kill-on-close Job Object and the provider writes GO to fd 4. +const windowsInvocationWrapperSource = String.raw` +const { createReadStream } = require('node:fs'); +const { pathToFileURL } = require('node:url'); +const entry = process.argv[1]; +const control = createReadStream(null, { autoClose: false, fd: 4, encoding: 'utf8' }); +let token = ''; +const fail = (message) => { process.stderr.write(message + '\n'); process.exitCode = 1; }; +control.on('data', (chunk) => { + token += chunk; + if (token === 'GO\n') { + control.destroy(); + void import(pathToFileURL(entry).href).catch((error) => fail(error instanceof Error ? error.stack ?? error.message : String(error))); + } else if (token.length > 3 || !'GO\n'.startsWith(token)) { + fail('RSC invocation Windows wrapper received an invalid control token.'); + control.destroy(); + } +}); +control.once('end', () => { if (token !== 'GO\n') fail('RSC invocation Windows wrapper never received a control token.'); }); +control.once('error', () => fail('RSC invocation Windows wrapper control stream failed.')); +`; + +// The owner is intentionally not a child of the Job Object. It owns the only +// job handle, confirms assignment before READY, and tears down/polls the +// whole tree before returning after the wrapper exits. +const windowsJobOwnerSource = String.raw` +$typeDefinition = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Threading; +public static class AgentBundleWindowsJobOwner { + const uint A=1,J=9,K=0x2000,I=0xffffffff,Access=0x00100101; + [StructLayout(LayoutKind.Sequential)] struct BL { public long a,b; public uint flags; public UIntPtr c,d; public uint e; public UIntPtr f; public uint g,h; } + [StructLayout(LayoutKind.Sequential)] struct IO { public ulong a,b,c,d,e,f; } + [StructLayout(LayoutKind.Sequential)] struct EL { public BL b; public IO i; public UIntPtr p,j,pp,pj; } + [StructLayout(LayoutKind.Sequential)] struct BA { public long a,b,c,d; public uint e,f,g,h; } + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr a,string b); + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint a,bool b,int c); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetInformationJobObject(IntPtr a,uint b,IntPtr c,uint d); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr a,uint b,IntPtr c,uint d,IntPtr e); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr a,IntPtr b); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool TerminateJobObject(IntPtr a,uint b); + [DllImport("kernel32.dll",SetLastError=true)] static extern uint WaitForSingleObject(IntPtr a,uint b); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr a); + static void Ok(bool value) { if(!value) throw new Win32Exception(Marshal.GetLastWin32Error()); } + static void Stop(IntPtr job) { IntPtr accounting=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(BA))); try { Ok(TerminateJobObject(job,0)); for(int attempt=0;attempt<1000;attempt++) { Ok(QueryInformationJobObject(job,A,accounting,(uint)Marshal.SizeOf(typeof(BA)),IntPtr.Zero)); if(((BA)Marshal.PtrToStructure(accounting,typeof(BA))).g==0) return; Thread.Sleep(10); } throw new TimeoutException("Windows Job Object did not terminate every descendant."); } finally { Marshal.FreeHGlobal(accounting); } } + static void Drained() { Console.Out.WriteLine("DRAINED"); Console.Out.Flush(); } + public static int Own(int pid,string mode) { IntPtr job=IntPtr.Zero,process=IntPtr.Zero,info=IntPtr.Zero; bool assigned=false,drained=false; try { + if(mode=="hang-ready") { Thread.Sleep(60000); return 1; } + job=CreateJobObject(IntPtr.Zero,null); if(job==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + EL limits=new EL(); limits.b.flags=K; info=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(EL))); Marshal.StructureToPtr(limits,info,false); Ok(SetInformationJobObject(job,J,info,(uint)Marshal.SizeOf(typeof(EL)))); + process=OpenProcess(Access,false,pid); if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); Ok(AssignProcessToJobObject(job,process)); assigned=true; + Console.Out.WriteLine("READY"); Console.Out.Flush(); if(mode=="close-control") { Console.In.Close(); while(true) Thread.Sleep(1000); } if(mode=="ignore-stop") { while(true) Thread.Sleep(1000); } ManualResetEvent stop=new ManualResetEvent(false); Thread control=new Thread(() => { try { Console.In.ReadLine(); } finally { stop.Set(); } }); control.IsBackground=true; control.Start(); while(true) { uint result=WaitForSingleObject(process,20); if(result==0) break; if(result==I) throw new Win32Exception(Marshal.GetLastWin32Error()); if(stop.WaitOne(0)) break; } Stop(job); Drained(); drained=true; if(mode=="nonzero-after-drain") throw new InvalidOperationException("Windows Job owner test failure after drain."); return 0; + } catch(Exception error) { if(job!=IntPtr.Zero && assigned && !drained) { try { Stop(job); Drained(); drained=true; } catch(Exception drainError) { throw new AggregateException(error,drainError); } } else if(job!=IntPtr.Zero && !assigned) TerminateJobObject(job,1); throw; } finally { if(info!=IntPtr.Zero) Marshal.FreeHGlobal(info); if(process!=IntPtr.Zero) CloseHandle(process); if(job!=IntPtr.Zero) CloseHandle(job); } } +} +'@ +Add-Type -TypeDefinition $typeDefinition -ErrorAction Stop +exit [AgentBundleWindowsJobOwner]::Own([int]$args[0], [string]$args[1]) +`; + + +interface InvocationWorker { + readonly done: Promise; + terminate(reason: Error): void; +} + +interface RuntimeAppBroker { + closedObservation: DevRuntimeMcpSessionCloseObservation | undefined; + opening: Promise | undefined; + session: DevRuntimeMcpSession | undefined; +} + +interface RuntimeAppLink { + readonly descriptor: DevRuntimeMcpRegistryReconcileInput['servers'][number]; + readonly key: string; + readonly resourceUri: string; + readonly surfaceId: string; +} + +interface WindowsJobOwner { + readonly closed: Promise; + readonly done: Promise; + readonly drained: Promise; + readonly ready: Promise; + isAssigned(): boolean; + isClosed(): boolean; + forceTerminate(): void; + terminate(): void; +} + +interface OwnedRunsRoot { + readonly dev: number; + readonly ino: number; + readonly marker: string; + readonly root: string; + readonly token: string; +} + +interface RunArtifact { + readonly file: FileHandle; + readonly runId: string; + dev?: number; + digest?: string; + ino?: number; + size?: number; +} + +type LiveSessionCleanupResource = + | 'generation-store' + | 'owned-runs-root' + | 'rsbuild-dev-server' + | 'run-artifact' + | 'runtime-mcp-registry'; + +interface LabeledCleanupFailure { + readonly error: unknown; + readonly label: string; +} + +interface ValidatedInvocation { + readonly fixtureId?: string; + readonly input: JsonValue; + readonly request: DevRuntimeInvocationRequest; + readonly surface: DevRuntimeSurface; +} + +interface AttemptBarrier { + readonly id: string; + readonly sequence: number; + candidate: RuntimeGenerationCandidate | undefined; + readonly settled: Promise; + settle(): void; +} + +export class ResourceLedger { + readonly #closers: Array Promise; readonly label: string }>> = []; + readonly #failures: Array> = []; + readonly #running = new Set>(); + #closed = false; + #closePromise: Promise | undefined; + + add(close: () => Promise, label = 'resource'): Promise | undefined { + const resourceLabel = /^[a-z0-9-]{1,64}$/u.test(label) ? label : 'resource'; + if (!this.#closed) { + this.#closers.push(Object.freeze({ close, label: resourceLabel })); + return undefined; + } + return this.#run(close, resourceLabel); + } + + failures(): readonly Readonly<{ readonly error: unknown; readonly label: string }>[] { + return Object.freeze([...this.#failures]); + } + + #run(close: () => Promise, label = 'resource'): Promise { + const task = Promise.resolve().then(close); + this.#running.add(task); + void task.then( + () => undefined, + (error: unknown) => { this.#failures.push(Object.freeze({ error, label })); }, + ).finally(() => { this.#running.delete(task); }); + return task; + } + + async #drain(): Promise { + while (this.#closers.length > 0) { + const closer = this.#closers.shift()!; + this.#run(closer.close, closer.label); + } + while (this.#running.size > 0) { + await Promise.allSettled([...this.#running]); + while (this.#closers.length > 0) { + const closer = this.#closers.shift()!; + this.#run(closer.close, closer.label); + } + } + if (this.#failures.length > 0) { + throw new AggregateError(this.#failures.map((failure) => failure.error), 'RSC runtime startup cleanup failed.'); + } + } + + close(): Promise { + if (this.#closePromise !== undefined) return this.#closePromise; + this.#closed = true; + this.#closePromise = this.#drain(); + return this.#closePromise; + } +} + +const cleanupAggregate = ( + message: string, + failures: readonly LabeledCleanupFailure[], + cause?: unknown, +): AggregateError => { + const labels = [...new Set(failures.map((failure) => failure.label))].sort(); + return new AggregateError( + failures.map((failure) => failure.error), + `${message}; cleanup failures: ${labels.join(', ')}.`, + cause === undefined ? undefined : { cause }, + ); +}; + +const isInside = (root: string, path: string): boolean => { + const relativePath = relative(resolve(root), resolve(path)); + return relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); +}; + +const safeSegment = (value: string): boolean => + value.length > 0 && value !== '.' && value !== '..' && + !value.includes('/') && !value.includes('\\') && !value.includes('\0') && !value.includes('%'); + +const cloneJson = (value: unknown, ancestors = new WeakSet()): JsonValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime invocation input must contain only finite JSON numbers.'); + return value; + } + if (typeof value !== 'object' || ancestors.has(value)) { + throw new TypeError('Runtime invocation input must be an acyclic JSON value.'); + } + ancestors.add(value); + try { + if (Array.isArray(value)) return Object.freeze(value.map((item) => cloneJson(item, ancestors))); + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + throw new TypeError('Runtime invocation input must contain only plain JSON objects.'); + } + const result: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') throw new TypeError('Runtime invocation input cannot contain symbol keys.'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('Runtime invocation input cannot contain accessors or non-enumerable fields.'); + } + result[key] = cloneJson(descriptor.value, ancestors); + } + return Object.freeze(result); + } finally { + ancestors.delete(value); + } +}; + +const isJsonObject = (value: JsonValue): value is JsonObject => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const cloneJsonObject = (value: unknown): JsonObject => { + const cloned = cloneJson(value); + if (!isJsonObject(cloned)) { + throw new TypeError('Runtime surface input schema must be a JSON object.'); + } + return cloned; +}; + +const invocationDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.freeze({ + code: 'AB8203', + message: error instanceof Error ? redactInspectionDiagnostics(error.message) : 'RSC runtime invocation failed.', + phase: 'rsc-render', + severity: 'error', +}); + +const deepFreeze = (value: T, seen = new WeakSet()): T => { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) throw new TypeError('Runtime prepared configuration cannot contain cycles.'); + seen.add(value); + for (const key of Reflect.ownKeys(value)) { + const property = Object.getOwnPropertyDescriptor(value, key); + if (property !== undefined && 'value' in property) deepFreeze(property.value, seen); + } + seen.delete(value); + return Object.freeze(value); +}; + +const plainRecord = (value: unknown, message: string): Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(message); + } + return value as Record; +}; + +const assertExactKeys = (value: Record, keys: readonly string[], message: string): void => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) throw new Error(message); +}; + +const assertCredentialSafeJson = (value: unknown): void => { + if (value === null || typeof value === 'boolean' || typeof value === 'number') return; + if (typeof value === 'string') { + if (hasInspectionCredential(value)) throw new Error('RSC invocation worker inspection contains credentials.'); + return; + } + if (Array.isArray(value)) { + value.forEach(assertCredentialSafeJson); + return; + } + const record = plainRecord(value, 'RSC invocation worker inspection contains a non-JSON value.'); + for (const [key, item] of Object.entries(record)) { + if (isInspectionSensitiveKey(key)) throw new Error('RSC invocation worker inspection contains sensitive fields.'); + assertCredentialSafeJson(item); + } +}; + +const optionalExactKeys = (value: Record, required: readonly string[], optional: readonly string[], message: string): void => { + const keys = Object.keys(value); + if (keys.some((key) => !required.includes(key) && !optional.includes(key)) || required.some((key) => !(key in value))) { + throw new Error(message); + } +}; + +const validateTrace = (value: unknown): void => { + if (!Array.isArray(value)) throw new Error('RSC invocation worker trace is invalid.'); + for (const item of value) { + const span = plainRecord(item, 'RSC invocation worker trace is invalid.'); + optionalExactKeys(span, ['id', 'phase', 'startedAt', 'status'], ['details', 'durationMs', 'parentId'], 'RSC invocation worker trace is invalid.'); + if (typeof span.id !== 'string' || span.id.length === 0 || typeof span.phase !== 'string' || span.phase.length === 0 || + typeof span.startedAt !== 'string' || !['running', 'succeeded', 'failed'].includes(span.status as string) || + ('parentId' in span && (typeof span.parentId !== 'string' || span.parentId.length === 0)) || + ('durationMs' in span && (typeof span.durationMs !== 'number' || !Number.isFinite(span.durationMs) || span.durationMs < 0))) { + throw new Error('RSC invocation worker trace is invalid.'); + } + if ('details' in span) { + assertCredentialSafeJson(plainRecord(span.details, 'RSC invocation worker trace is invalid.')); + } + } +}; + +const validateTree = (value: unknown): void => { + if (!Array.isArray(value)) throw new Error('RSC invocation worker tree is invalid.'); + for (const item of value) { + const node = plainRecord(item, 'RSC invocation worker tree is invalid.'); + optionalExactKeys(node, ['children', 'id', 'kind', 'label'], ['props'], 'RSC invocation worker tree is invalid.'); + if (typeof node.id !== 'string' || node.id.length === 0 || typeof node.label !== 'string' || + !['component', 'element', 'text', 'value'].includes(node.kind as string)) { + throw new Error('RSC invocation worker tree is invalid.'); + } + if ('props' in node) { + assertCredentialSafeJson(plainRecord(node.props, 'RSC invocation worker tree is invalid.')); + } + validateTree(node.children); + } +}; + +const validateAppBinding = (value: unknown): void => { + const app = plainRecord(value, 'RSC invocation worker App binding is invalid.'); + assertExactKeys(app, ['mcpBinding', 'resourceUri', 'surfaceId'], 'RSC invocation worker App binding is invalid.'); + if (typeof app.resourceUri !== 'string' || app.resourceUri.length === 0 || typeof app.surfaceId !== 'string' || app.surfaceId.length === 0) { + throw new Error('RSC invocation worker App binding is invalid.'); + } + const binding = plainRecord(app.mcpBinding, 'RSC invocation worker App binding is invalid.'); + assertExactKeys(binding, ['definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest'], 'RSC invocation worker App binding is invalid.'); + if (typeof binding.definitionDigest !== 'string' || typeof binding.serverDigest !== 'string' || typeof binding.serverName !== 'string' || + typeof binding.sessionId !== 'string' || typeof binding.target !== 'string' || typeof binding.transportDigest !== 'string' || + !Number.isSafeInteger(binding.registryRevision) || !Number.isSafeInteger(binding.sessionRevision)) { + throw new Error('RSC invocation worker App binding is invalid.'); + } +}; + +const clonePrepared = (prepared: DevRuntimePreparedProject): DevRuntimePreparedProject => + deepFreeze(structuredClone(prepared)); + +const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); + const record = value as Record; + return `{${Object.keys(record).sort().flatMap((key) => { + const item = record[key]; + return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; + }).join(',')}}`; +}; + +const digestValue = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex'); + +const transportDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ + provider: prepared.provider, + servers: prepared.servers.map((server) => ({ + args: server.args === undefined ? undefined : [...server.args], + command: server.command, + cwd: server.cwd, + env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, digestValue(value)])), + headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, digestValue(value)])), + id: server.id, + name: server.name, + source: server.source, + targets: [...server.targets], + transport: server.transport, + url: server.url, + })), +}); + +const preparedRuntimeAuthorityDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ + apps: prepared.apps, + provider: prepared.provider, + servers: prepared.servers, +}); + +const asJsonObject = (value: unknown): JsonObject => value as JsonObject; + +const descriptorsFor = ( + prepared: DevRuntimePreparedProject, + metadata: RscRuntimeGenerationMetadata, + definitionDigest: string, + nextTransportDigest: string, +) => { + const template = metadata.servers[0]; + if (template === undefined) throw new Error('The active runtime generation has no MCP server descriptor.'); + return Object.freeze(prepared.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ + definitionDigest, + name: server.name, + resources: Object.freeze(template.resources.map(asJsonObject)), + serverDigest: metadata.serverDigest, + target, + tools: Object.freeze(template.tools.map(asJsonObject)), + transportDigest: nextTransportDigest, + })))); +}; + +const lifecycleDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.freeze({ + code: 'AB8200', + message: error instanceof Error ? error.message : 'RSC runtime provider failed.', + phase: 'provider-lifecycle', + severity: 'error', +}); + +const sourceBuildDiagnostic = (): DevRuntimeDiagnostic => Object.freeze({ + code: 'AB8206', + message: 'RSC runtime source build failed.', + phase: 'source/build', + severity: 'error', +}); + +const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('RSC runtime provider startup was aborted.'); +const hmrToken = /^[A-Za-z0-9_-]{16,128}$/u; + +export interface RsbuildRuntimeSessionStartTesting { + readonly createRsbuild?: typeof createRsbuild; + /** Test-only startup resource seams; never used by the public provider. */ + readonly afterOwnedRunsRootCreated?: () => Promise | void; + readonly beforeOwnedRunsRootCleanup?: () => Promise | void; + readonly onStartupCleanupClosed?: () => void; + readonly beforeGenerationCapture?: () => Promise | void; + readonly afterActivationPrepare?: (input: Readonly<{ + readonly phase: 'store' | 'registry'; + readonly session: RsbuildRuntimeSession; + }>) => Promise | void; + readonly beforeAssetRead?: (input: Readonly<{ + readonly request: DevRuntimeAssetRequest; + readonly runtimeGenerationId: string; + }>) => Promise | void; + readonly beforeMcpRelist?: () => Promise | void; + readonly afterInvocationWorkerResponse?: (input: Readonly<{ + readonly runId: string; + readonly surfaceId: string; + }>) => Promise | void; + /** Test-only live-session cleanup seams; never used by the public provider. */ + readonly beforeRunArtifactRelease?: (input: Readonly<{ readonly runId: string }>) => Promise | void; + readonly afterRunArtifactEvictionReserved?: (input: Readonly<{ readonly runId: string }>) => Promise | void; + readonly beforeRunDirectoryRemoval?: (input: Readonly<{ readonly runId: string }>) => Promise | void; + readonly beforeRunFlightRead?: (input: Readonly<{ readonly runId: string }>) => Promise | void; + readonly afterLiveSessionCleanupResource?: (input: Readonly<{ + readonly resource: LiveSessionCleanupResource; + }>) => Promise | void; + /** Windows-only Job owner fault injection; never used by the public provider. */ + readonly windowsJobOwnerMode?: 'close-control' | 'hang-ready' | 'ignore-stop' | 'nonzero-after-drain' | 'normal'; +} + +/** + * One provider-owned compiler, generation store, and runtime MCP registry. + * The private compiler URL is exposed only through `clientSurface`. + */ +export class RsbuildRuntimeSession implements DevRuntimeSession { + readonly #checkpointTracker: RscCompilerAssetCheckpointTracker; + readonly #candidatesByAttempt = new Map(); + readonly #captureTasks = new Set>(); + readonly #context: DevRuntimeStartContext; + readonly #generationStore: RuntimeGenerationStore; + readonly #mcpRegistry: RuntimeMcpRegistry; + readonly #preparedRevisions = new Set(); + readonly #invocations = new Set>(); + readonly #invocationAbort = new AbortController(); + readonly #runReadTasks = new Map>>(); + readonly #runArtifacts = new Map(); + readonly #evictingTerminalRuns = new Set(); + readonly #pendingRunDirectoryRemovals = new Set(); + readonly #runRoot: string; + readonly #ownedRunsRoot: OwnedRunsRoot; + readonly #stateFile: string; + readonly #stateKernel: ReturnType; + readonly #activeRuns = new Map(); + readonly #appBrokers = new Map(); + readonly #terminalRuns = new Map(); + readonly #surfaceAssetApps = new Map(); + readonly #surfaces = new Map(); + readonly #testing: RsbuildRuntimeSessionStartTesting; + readonly #attempts = new Map(); + readonly #workers = new Map(); + readonly #failedAttempts = new Set(); + #active: RuntimeGeneration | undefined; + #appWebSocketToken: string | undefined; + #clientSurface: DevRuntimeClientSurfaceEndpoint | undefined; + #closePromise: Promise | undefined; + #closed = false; + #evictionTail: Promise = Promise.resolve(); + #generationSequence = 0; + #failureTail: Promise = Promise.resolve(); + #hmrReady = false; + #latestAttemptSequence = 0; + #latestSupersedingAttemptSequence = 0; + #latestPreparedRuntime: DevRuntimePreparedProject; + #latestRscCohortRevision = 0; + #invocationReservations = 0; + #providerTail: Promise = Promise.resolve(); + #server: StartDevServerResult['server'] | undefined; + #status: DevRuntimeStatus; + + private constructor(input: Readonly<{ + readonly checkpointTracker: RscCompilerAssetCheckpointTracker; + readonly context: DevRuntimeStartContext; + readonly generationStore: RuntimeGenerationStore; + readonly mcpRegistry: RuntimeMcpRegistry; + readonly ownedRunsRoot: OwnedRunsRoot; + readonly preparedRuntime: DevRuntimePreparedProject; + readonly testing: RsbuildRuntimeSessionStartTesting; + }>) { + this.#context = input.context; + this.#checkpointTracker = input.checkpointTracker; + this.#generationStore = input.generationStore; + this.#mcpRegistry = input.mcpRegistry; + this.#latestPreparedRuntime = input.preparedRuntime; + this.#testing = input.testing; + this.#ownedRunsRoot = input.ownedRunsRoot; + this.#runRoot = input.ownedRunsRoot.root; + this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.jsonl`); + this.#stateKernel = createFileRuntimeKernel({ stateFile: this.#stateFile }); + this.#preparedRevisions.add(input.preparedRuntime.sourceRevision); + this.#status = Object.freeze({ + descriptor, + diagnostics: Object.freeze([]), + hmrReady: false, + state: 'starting', + }); + } + + static async start( + context: DevRuntimeStartContext, + testing: RsbuildRuntimeSessionStartTesting = {}, + ): Promise { + context.signal.throwIfAborted(); + const preparedRuntime = clonePrepared(context.preparedRuntime); + RsbuildRuntimeSession.#validateStartContext(context, preparedRuntime); + const ledger = new ResourceLedger(); + let startupCleanup: Promise | undefined; + const closeStartupLedger = (): Promise => { + if (startupCleanup !== undefined) return startupCleanup; + startupCleanup = ledger.close(); + const notifyClosed = (): void => { + try { + testing.onStartupCleanupClosed?.(); + } catch { + // Test observation cannot affect startup cleanup ownership. + } + }; + void startupCleanup.then(notifyClosed, notifyClosed); + return startupCleanup; + }; + let aborting = false; + const abort = (): void => { + aborting = true; + void closeStartupLedger().catch(() => undefined); + }; + context.signal.addEventListener('abort', abort, { once: true }); + + try { + context.signal.throwIfAborted(); + const storageRoot = resolve(context.storageRoot); + const generationStore = new RuntimeGenerationStore({ + metadataCodec: rscRuntimeGenerationMetadataCodec, + retainInactive: 5, + storageRoot: join(storageRoot, 'generation-store'), + validateMetadata: validateRscRuntimeGenerationMetadata, + }); + ledger.add(() => generationStore.close(), 'generation-store'); + const checkpointTracker = createRscCompilerAssetCheckpointTracker(); + ledger.add(async () => { checkpointTracker.close(); }, 'compiler-asset-checkpoints'); + await Promise.all([ + mkdir(join(storageRoot, 'compiler'), { recursive: true }), + mkdir(join(storageRoot, 'state'), { recursive: true }), + ]); + const ownedRunsRoot = await RsbuildRuntimeSession.#createOwnedRunsRoot(storageRoot, context.providerSessionId); + const closeOwnedRunsRoot = async (): Promise => { + await testing.beforeOwnedRunsRootCleanup?.(); + await RsbuildRuntimeSession.#removeOwnedRunsRoot(ownedRunsRoot); + }; + const afterOwnedRunsRootCreated = testing.afterOwnedRunsRootCreated; + if (afterOwnedRunsRootCreated === undefined) { + await ledger.add(closeOwnedRunsRoot, 'owned-runs-root'); + } else { + try { + await afterOwnedRunsRootCreated(); + } finally { + await ledger.add(closeOwnedRunsRoot, 'owned-runs-root'); + } + } + context.signal.throwIfAborted(); + + const connectionState: DevRuntimeMcpConnectionState = Object.freeze({ + capabilities: Object.freeze({ + resources: Object.freeze({}), + tools: Object.freeze({}), + }), + protocolEra: 'modern', + protocolVersion: '2025-06-18', + server: Object.freeze({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }), + }); + const sessionReference: { current: RsbuildRuntimeSession | undefined } = { current: undefined }; + const connector: RuntimeMcpConnector = Object.freeze({ + connect: async ({ signal }: Parameters[0]) => { + signal.throwIfAborted(); + const connection: RuntimeMcpConnection = Object.freeze({ + close: async () => undefined, + relist: async () => { + signal.throwIfAborted(); + await testing.beforeMcpRelist?.(); + signal.throwIfAborted(); + return connectionState; + }, + state: connectionState, + }); + return connection; + }, + }); + const mcpRegistry = new RuntimeMcpRegistry({ + artifactEpochId: () => undefined, + connector, + emit: (event) => { + const session = sessionReference.current; + if (session !== undefined) session.#emit(event); + }, + executor: async (execution) => { + const session = sessionReference.current; + if (session === undefined) throw new Error('RSC runtime session is unavailable.'); + return session.#executeMcp(execution); + }, + generationStore: generationStore as RuntimeGenerationStore, + providerSessionId: context.providerSessionId, + stateStoreId, + }); + ledger.add(() => mcpRegistry.close(), 'runtime-mcp-registry'); + const session = new RsbuildRuntimeSession({ + checkpointTracker, + context, + generationStore, + mcpRegistry, + ownedRunsRoot, + preparedRuntime, + testing, + }); + sessionReference.current = session; + context.signal.throwIfAborted(); + + const rsbuild = await (testing.createRsbuild ?? createRsbuild)({ + callerName: 'agent-bundle-rsc-runtime', + config: createRscRuntimeRsbuildConfig({ + compilerRoot: join(storageRoot, 'compiler'), + mode: 'development', + onAppWebSocketToken: (token) => session.#captureAppWebSocketToken(token), + onCompile: session.#compileObserver(), + }), + cwd: context.projectRoot, + }); + context.signal.throwIfAborted(); + const started = await rsbuild.startDevServer({ getPortSilently: true }); + await ledger.add(() => started.server.close(), 'rsbuild-dev-server'); + context.signal.throwIfAborted(); + session.#attachServer(started, rsbuild.context.devServer); + await session.#providerTail; + context.signal.throwIfAborted(); + context.signal.removeEventListener('abort', abort); + return session; + } catch (error) { + context.signal.removeEventListener('abort', abort); + await closeStartupLedger().catch(() => undefined); + const primary = aborting || context.signal.aborted ? abortReason(context.signal) : error; + const failures = ledger.failures(); + if (failures.length === 0) throw primary; + const labels = [...new Set(failures.map((failure) => failure.label))].sort(); + throw new AggregateError( + [primary, ...failures.map((failure) => failure.error)], + `RSC runtime startup failed; cleanup failures: ${labels.join(', ')}.`, + { cause: error }, + ); + } + } + + get mcpRegistry(): RuntimeMcpRegistry { + return this.#mcpRegistry; + } + + get providerSessionId(): string { + return this.#context.providerSessionId; + } + + clientSurface(surfaceId: string): DevRuntimeClientSurfaceEndpoint | undefined { + return !this.#closed && surfaceId === clientSurfaceId ? this.#clientSurface : undefined; + } + + close(): Promise { + this.#closePromise ??= this.#close(); + return this.#closePromise; + } + + invoke(request: DevRuntimeInvocationRequest): Promise { + if (this.#closed) return Promise.reject(new DevRuntimeUnavailableError('RSC runtime session is closed.')); + const task = this.#invoke(request); + this.#invocations.add(task); + void task.finally(() => { this.#invocations.delete(task); }).catch(() => undefined); + return task; + } + + async readAsset(request: DevRuntimeAssetRequest): Promise { + if (this.#closed || !this.#surfaces.has(request.surfaceId) || request.runtimeGenerationId.length === 0) return undefined; + const segments = request.path.map((segment) => { + if (!safeSegment(segment)) return undefined; + try { + return decodeURIComponent(segment) === segment ? segment : undefined; + } catch { + return undefined; + } + }); + if (segments.some((segment) => segment === undefined)) return undefined; + const requestPath = `/${segments.join('/')}`; + let lease; + try { + lease = await this.#generationStore.lease(request.runtimeGenerationId); + await this.#testing.beforeAssetRead?.(Object.freeze({ + request, + runtimeGenerationId: lease.generation.id, + })); + const app = this.#surfaceAssetApps.get(request.surfaceId); + if (app === undefined) return undefined; + const boundSurfaceId = this.#surfaceAssetBinding(lease.generation, app); + if (boundSurfaceId === undefined) return undefined; + const descriptor = lease.generation.manifest.metadata.surfaceAssets[boundSurfaceId] + ?.find((asset) => asset.requestPath === requestPath); + if (descriptor === undefined || descriptor.bytes > maximumAssetBytes) return undefined; + const assetSegments = descriptor.generationPath.split('/'); + if (assetSegments.some((segment) => !safeSegment(segment))) return undefined; + const path = join(lease.generation.root, ...assetSegments); + if (!isInside(lease.generation.root, path)) return undefined; + const details = await lstat(path); + if (!details.isFile() || details.isSymbolicLink() || details.size !== descriptor.bytes) return undefined; + const body = await readFile(path); + if (body.byteLength !== descriptor.bytes || createHash('sha256').update(body).digest('hex') !== descriptor.sha256) return undefined; + return Object.freeze({ body, contentType: descriptor.contentType }); + } catch { + return undefined; + } finally { + await lease?.release(); + } + } + + async readRunFlight(runId: string): Promise { + if (this.#closed || !safeSegment(runId)) return undefined; + if (this.#evictingTerminalRuns.has(runId)) return undefined; + const run = this.#terminalRuns.get(runId); + if (run?.status !== 'succeeded' || run.vector.providerSessionId !== this.providerSessionId) return undefined; + const artifact = this.#runArtifacts.get(runId); + if (artifact?.digest === undefined || artifact.size === undefined || artifact.dev === undefined || artifact.ino === undefined) return undefined; + const task = (async (): Promise => { + try { + await this.#testing.beforeRunFlightRead?.(Object.freeze({ runId })); + await this.#assertCurrentOwnedRunsRoot(); + const details = await artifact.file.stat(); + if (!details.isFile() || details.size !== artifact.size || details.dev !== artifact.dev || details.ino !== artifact.ino) return undefined; + const body = Buffer.alloc(artifact.size); + let offset = 0; + while (offset < body.byteLength) { + const read = await artifact.file.read(body, offset, body.byteLength - offset, offset); + if (read.bytesRead === 0) return undefined; + offset += read.bytesRead; + } + if (createHash('sha256').update(body).digest('hex') !== artifact.digest) return undefined; + await this.#assertCurrentOwnedRunsRoot(); + return Object.freeze({ body, contentType: 'application/octet-stream' }); + } catch { + return undefined; + } + })(); + const reads = this.#runReadTasks.get(runId) ?? new Set>(); + this.#runReadTasks.set(runId, reads); + reads.add(task); + try { + return await task; + } finally { + reads.delete(task); + if (reads.size === 0) this.#runReadTasks.delete(runId); + } + } + + reconcilePreparedRuntime(prepared: DevRuntimePreparedProject): Promise { + const next = clonePrepared(prepared); + this.#validatePreparedRuntime(next); + if (this.#closed) return Promise.reject(new Error('RSC runtime session is closed.')); + if (this.#preparedRevisions.has(next.sourceRevision)) { + return Promise.reject(new Error('Runtime prepared configuration source revision is stale or unchanged.')); + } + this.#preparedRevisions.add(next.sourceRevision); + this.#latestPreparedRuntime = next; + return this.#append(async () => this.#reconcilePreparedRuntime(next)); + } + + async replay(request: DevRuntimeReplayRequest): Promise { + if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); + if (request === null || typeof request !== 'object' || !safeSegment(request.runId)) { + throw new TypeError('Runtime replay requires a retained run id.'); + } + if (request.mode !== 'exact' && request.mode !== 'latest') throw new TypeError('Runtime replay mode is invalid.'); + const historical = this.#terminalRuns.get(request.runId); + if (historical === undefined) throw new Error(`Runtime run ${JSON.stringify(request.runId)} does not exist.`); + const historicalGenerationId = historical.vector.runtimeGenerationId; + const activeGenerationId = this.#active?.id; + if (request.mode === 'exact' && request.expectedGenerationId !== undefined && request.expectedGenerationId !== historicalGenerationId) { + throw new DevRuntimeGenerationConflictError(request.expectedGenerationId, historicalGenerationId); + } + if (request.mode === 'latest' && request.expectedGenerationId !== undefined && request.expectedGenerationId !== activeGenerationId) { + throw new DevRuntimeGenerationConflictError(request.expectedGenerationId, activeGenerationId); + } + const expectedGenerationId = request.mode === 'exact' ? historicalGenerationId : activeGenerationId; + if (expectedGenerationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); + if (request.mode === 'exact') { + let retained: Awaited['lease']>> | undefined; + try { + try { + retained = await this.#generationStore.lease(historicalGenerationId); + } catch { + throw new DevRuntimeGenerationConflictError(historicalGenerationId, this.#active?.id); + } + let surface: DevRuntimeSurface; + try { + surface = await this.#historicalSurface(retained.generation, historical.surfaceId); + } catch { + throw new DevRuntimeGenerationConflictError(historicalGenerationId, this.#active?.id); + } + const replay = this.#invoke({ + expectedGenerationId, + ...(historical.fixtureId === undefined ? {} : { fixtureId: historical.fixtureId }), + input: historical.input, + surfaceId: historical.surfaceId, + target: historical.target, + }, retained, surface); + retained = undefined; + return await replay; + } finally { + await retained?.release(); + } + } + return this.invoke({ + expectedGenerationId, + ...(historical.fixtureId === undefined ? {} : { fixtureId: historical.fixtureId }), + input: historical.input, + surfaceId: historical.surfaceId, + target: historical.target, + }); + } + + async resetState(request: DevRuntimeStateResetRequest): Promise { + if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); + if (request.stateStoreId !== stateStoreId) throw new Error(`Unknown runtime state store ${JSON.stringify(request.stateStoreId)}.`); + const generationId = request.expectedGenerationId ?? this.#active?.id; + if (generationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); + let lease; + try { + lease = await this.#generationStore.lease(generationId); + } catch { + throw new DevRuntimeGenerationConflictError(generationId, this.#active?.id); + } + try { + if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); + const seed = request.seed === undefined ? undefined : cloneJson(request.seed); + if (seed !== undefined) assertCredentialSafeJson(seed); + const snapshot = await this.#stateKernel.resetState({ + idempotencyKey: `runtime:reset:${randomUUID()}`, + ...(seed === undefined ? {} : { seed }), + }); + return Object.freeze({ stateStoreId, stateVersion: snapshot.stateVersion }); + } finally { + await lease.release(); + } + } + + run(runId: string): DevRuntimeRun | undefined { + return this.#closed ? undefined : this.#activeRuns.get(runId) ?? this.#terminalRuns.get(runId); + } + + runs(limit: number): readonly DevRuntimeRun[] { + if (this.#closed) return Object.freeze([]); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximumRunHistory) { + throw new RangeError(`Runtime run history limit must be an integer from 1 through ${maximumRunHistory}.`); + } + return Object.freeze([...this.#terminalRuns.values()].reverse().slice(0, limit)); + } + + status(): DevRuntimeStatus { + return this.#status; + } + + surfaces(): readonly DevRuntimeSurface[] { + return Object.freeze([...this.#surfaces.values()]); + } + + async #invoke( + request: DevRuntimeInvocationRequest, + suppliedLease?: Awaited['lease']>>, + historicalSurface?: DevRuntimeSurface, + ): Promise { + let lease = suppliedLease; + let releaseReservation: (() => void) | undefined; + try { + const invocation = this.#validateInvocation(request, historicalSurface); + const generationId = invocation.request.expectedGenerationId ?? lease?.generation.id ?? this.#active?.id; + if (generationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); + if (lease !== undefined && lease.generation.id !== generationId) { + throw new DevRuntimeGenerationConflictError(generationId, lease.generation.id); + } + releaseReservation = this.#reserveInvocation(); + if (lease === undefined) { + try { + lease = await this.#generationStore.lease(generationId); + } catch { + throw new DevRuntimeGenerationConflictError(generationId, this.#active?.id); + } + } + const generationLease = lease; + if (generationLease === undefined) throw new Error('RSC runtime generation lease is unavailable.'); + + let runDirectory: string | undefined; + let running: DevRuntimeRun | undefined; + let artifact: RunArtifact | undefined; + try { + this.#assertInvocationOpen(); + const stateBefore = await this.#stateKernel.readSnapshot(); + this.#assertInvocationOpen(); + const runId = randomUUID(); + const startedAt = new Date().toISOString(); + running = Object.freeze({ + ...(invocation.fixtureId === undefined ? {} : { fixtureId: invocation.fixtureId }), + id: runId, + input: invocation.input, + startedAt, + status: 'running' as const, + surfaceId: invocation.surface.id, + target: invocation.request.target, + vector: this.#vector(generationLease.generation, stateBefore.stateVersion), + }); + this.#activeRuns.set(runId, running); + runDirectory = join(this.#runRoot, runId); + if (!isInside(this.#runRoot, runDirectory) || !safeSegment(runId)) { + throw new Error('RSC runtime run directory escaped its provider storage root.'); + } + await this.#assertCurrentOwnedRunsRoot(); + await mkdir(runDirectory, { recursive: false }); + artifact = await this.#openRunArtifact(runId); + this.#assertInvocationOpen(); + this.#emit(Object.freeze({ runId, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.started' })); + const workerInput = await this.#workerRequest(invocation); + this.#assertInvocationOpen(); + const response = await this.#runInvocationWorker({ + generation: generationLease.generation, + input: workerInput, + runId, + surfaceId: invocation.surface.id, + }); + this.#assertInvocationOpen(); + await this.#testing.afterInvocationWorkerResponse?.(Object.freeze({ runId, surfaceId: invocation.surface.id })); + this.#assertInvocationOpen(); + const flight = response.flight; + const inspectedStateVersion = response.inspection.state.identity.stateVersion; + const stateAfter = await this.#stateKernel.readSnapshot({ stateVersion: inspectedStateVersion }); + if (stateAfter.stateVersion !== inspectedStateVersion) throw new Error('RSC invocation inspection state version is not durable.'); + this.#assertInvocationOpen(); + const app = await this.#runtimeAppResult(generationLease.generation, invocation); + this.#assertInvocationOpen(); + const result = this.#inspectionResult(response.inspection, flight, stateAfter, runId, app); + if (artifact === undefined) throw new Error('RSC runtime Flight artifact is unavailable.'); + await this.#writeRunFlight(artifact, flight); + const completed = Object.freeze({ + ...(invocation.fixtureId === undefined ? {} : { fixtureId: invocation.fixtureId }), + completedAt: new Date().toISOString(), + id: runId, + input: invocation.input, + result, + startedAt, + status: 'succeeded' as const, + surfaceId: invocation.surface.id, + target: invocation.request.target, + vector: this.#vector(generationLease.generation, inspectedStateVersion), + }); + this.#activeRuns.delete(runId); + await this.#recordTerminal(completed); + this.#publishActiveStateVersion(generationLease.generation, inspectedStateVersion); + this.#emit(Object.freeze({ runId, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.completed' })); + return completed; + } catch (error) { + const cleanupFailures: LabeledCleanupFailure[] = []; + if (artifact !== undefined) { + try { + await this.#releaseRunArtifact(artifact.runId); + } catch (cleanupError) { + cleanupFailures.push(Object.freeze({ error: cleanupError, label: 'run-artifact' })); + } + } + if (cleanupFailures.length === 0 && runDirectory !== undefined) { + try { + await this.#removeRunDirectory(running?.id); + } catch (cleanupError) { + cleanupFailures.push(Object.freeze({ error: cleanupError, label: 'run-artifact' })); + } + } + if (running === undefined) throw error; + this.#activeRuns.delete(running.id); + const stateAfter = await this.#readTerminalStateVersion(running.vector.stateVersion); + const invocationError = cleanupFailures.length === 0 + ? error + : cleanupAggregate('RSC runtime invocation cleanup failed', cleanupFailures, error); + const failed = Object.freeze({ + ...(running.fixtureId === undefined ? {} : { fixtureId: running.fixtureId }), + completedAt: new Date().toISOString(), + diagnostics: Object.freeze([invocationDiagnostic(invocationError)]), + id: running.id, + input: running.input, + startedAt: running.startedAt, + status: 'failed' as const, + surfaceId: running.surfaceId, + target: running.target, + vector: this.#vector(generationLease.generation, stateAfter), + }); + await this.#recordTerminal(failed); + this.#publishActiveStateVersion(generationLease.generation, stateAfter); + this.#emit(Object.freeze({ runId: running.id, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.failed' })); + return failed; + } + } finally { + await lease?.release(); + releaseReservation?.(); + } + } + + #assertInvocationOpen(): void { + if (this.#closed || this.#invocationAbort.signal.aborted) { + throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); + } + } + + #reserveInvocation(): () => void { + this.#assertInvocationOpen(); + if (this.#invocationReservations >= maximumInvocationWorkers) { + throw new Error(`RSC runtime invocation limit of ${maximumInvocationWorkers} concurrent workers has been reached.`); + } + this.#invocationReservations += 1; + let released = false; + return () => { + if (released) return; + released = true; + this.#invocationReservations -= 1; + }; + } + + #validateInvocation(request: DevRuntimeInvocationRequest, historicalSurface?: DevRuntimeSurface): ValidatedInvocation { + if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); + if (request === null || typeof request !== 'object') throw new TypeError('Runtime invocation request must be an object.'); + if (typeof request.surfaceId !== 'string' || request.surfaceId.length === 0) { + throw new TypeError('Runtime invocation requires a nonempty surfaceId.'); + } + if (typeof request.target !== 'string' || request.target.length === 0) { + throw new TypeError('Runtime invocation requires a nonempty target.'); + } + if (request.expectedGenerationId !== undefined && (typeof request.expectedGenerationId !== 'string' || request.expectedGenerationId.length === 0)) { + throw new TypeError('Runtime invocation expectedGenerationId must be nonempty when provided.'); + } + const surface = historicalSurface ?? this.#surfaces.get(request.surfaceId); + if (surface === undefined) throw new Error(`Runtime surface ${JSON.stringify(request.surfaceId)} does not exist.`); + if (!surface.targets.includes(request.target)) { + throw new Error(`Runtime surface ${JSON.stringify(request.surfaceId)} does not support target ${JSON.stringify(request.target)}.`); + } + if (!['hook.claude', 'hook.codex', 'mcp.render_edit_timeline', 'mcp.recent_edits', 'mcp.runtime_status'].includes(surface.id)) { + throw new Error(`Runtime surface ${JSON.stringify(surface.id)} is not invocable.`); + } + if (request.fixtureId !== undefined) { + if (typeof request.fixtureId !== 'string' || request.fixtureId.length === 0) { + throw new TypeError('Runtime invocation fixtureId must be nonempty when provided.'); + } + if (!surface.fixtures.some((fixture) => fixture.id === request.fixtureId)) { + throw new Error(`Runtime surface ${JSON.stringify(surface.id)} has no fixture ${JSON.stringify(request.fixtureId)}.`); + } + } + const input = cloneJson(request.input); + if (surface.id === 'hook.claude' || surface.id === 'hook.codex') { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('Native hook runtime invocation input must be an object.'); + } + const hookInput = input as Record; + if (surface.id === 'hook.claude') normalizeClaudeHook(hookInput); + else normalizeCodexHook(hookInput); + } else if ( + surface.id === 'mcp.render_edit_timeline' || + surface.id === 'mcp.recent_edits' || + surface.id === 'mcp.runtime_status' + ) { + if (input === null || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 0) { + throw new TypeError(`Runtime surface ${JSON.stringify(surface.id)} requires an empty object input.`); + } + } + return Object.freeze({ + ...(request.fixtureId === undefined ? {} : { fixtureId: request.fixtureId }), + input, + request: Object.freeze({ ...request }), + surface, + }); + } + + async #workerRequest(invocation: ValidatedInvocation): Promise { + if (invocation.surface.id === 'hook.claude' || invocation.surface.id === 'hook.codex') { + if (invocation.input === null || typeof invocation.input !== 'object' || Array.isArray(invocation.input)) { + throw new TypeError('Native hook runtime invocation input must be an object.'); + } + return Object.freeze({ + host: invocation.surface.id === 'hook.claude' ? 'claude' : 'codex', + input: invocation.input, + stateFile: this.#stateFile, + stateStoreId, + type: 'hook/after-file-edit', + }); + } + if (invocation.surface.id === 'mcp.render_edit_timeline' || invocation.surface.id === 'mcp.recent_edits') { + return Object.freeze({ + snapshot: cloneJson(await this.#stateKernel.readSnapshot()), + stateFile: this.#stateFile, + stateStoreId, + type: 'mcp/render-timeline', + }); + } + return Object.freeze({ stateFile: this.#stateFile, stateStoreId, type: 'mcp/runtime-status' }); + } + + async #historicalSurface( + generation: RuntimeGeneration, + surfaceId: string, + ): Promise { + const definitionPath = join(generation.root, 'rsc', 'runtime-definition.json'); + const asset = generation.manifest.assets.find((candidate) => candidate.path === 'rsc/runtime-definition.json'); + if (asset === undefined || !isInside(generation.root, definitionPath)) throw new Error('Historical runtime generation has no definition asset.'); + const details = await lstat(definitionPath); + if (!details.isFile() || details.isSymbolicLink() || details.size !== asset.bytes) throw new Error('Historical runtime definition is unsafe.'); + const bytes = await readFile(definitionPath); + if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256) throw new Error('Historical runtime definition changed.'); + const definition = JSON.parse(bytes.toString('utf8')) as Partial; + const targets = Object.freeze([...new Set(generation.manifest.metadata.servers.map((server) => server.target))]); + if (surfaceId.startsWith('hook.')) { + const host = surfaceId.slice('hook.'.length); + if ((host !== 'claude' && host !== 'codex') || !definition.nativeHooks?.some((hook) => hook.host === host)) { + throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); + } + return Object.freeze({ fixtures: fixturesForHook(host), id: surfaceId, kind: 'hook', label: `After tool hook (${host})`, readOnly: false, targets: Object.freeze([host]) }); + } + const name = surfaceId.startsWith('mcp.') ? surfaceId.slice('mcp.'.length) : ''; + if (definition.tools?.some((tool) => tool.name === name)) { + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-tool', label: name, readOnly: true, targets }); + } + if (definition.resources?.some((resource) => resource.name === name)) { + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-resource', label: name, readOnly: true, targets }); + } + const app = generation.manifest.metadata.appDefinitions.find((candidate) => candidate.name === name); + if (app !== undefined) { + return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-app', label: name, readOnly: true, targets: app.targets }); + } + throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); + } + + async #readTerminalStateVersion(fallback: number): Promise { + try { + return (await this.#stateKernel.readSnapshot()).stateVersion; + } catch { + return fallback; + } + } + + async #recordTerminal(run: DevRuntimeRun): Promise { + this.#terminalRuns.set(run.id, run); + const eviction = this.#evictionTail.then(() => this.#evictTerminalRuns()); + this.#evictionTail = eviction.catch(() => undefined); + await eviction; + } + + async #evictTerminalRuns(): Promise { + while (this.#terminalRuns.size > maximumRunHistory) { + const oldestId = this.#terminalRuns.keys().next().value as string | undefined; + if (oldestId === undefined) return; + this.#evictingTerminalRuns.add(oldestId); + try { + await this.#testing.afterRunArtifactEvictionReserved?.(Object.freeze({ runId: oldestId })); + const reads = this.#runReadTasks.get(oldestId); + if (reads !== undefined) await Promise.allSettled([...reads]); + await this.#releaseRunArtifact(oldestId); + this.#terminalRuns.delete(oldestId); + this.#pendingRunDirectoryRemovals.add(oldestId); + await this.#removeRunDirectory(oldestId); + this.#pendingRunDirectoryRemovals.delete(oldestId); + } catch (error) { + throw cleanupAggregate('RSC runtime run artifact cleanup failed', [Object.freeze({ error, label: 'run-artifact' })]); + } finally { + this.#evictingTerminalRuns.delete(oldestId); + } + } + } + + async #removeRunDirectory(runId: string | undefined): Promise { + if (runId === undefined || !safeSegment(runId)) return; + await this.#testing.beforeRunDirectoryRemoval?.(Object.freeze({ runId })); + await this.#assertCurrentOwnedRunsRoot(); + const directory = join(this.#runRoot, runId); + if (!isInside(this.#runRoot, directory)) throw new Error('RSC runtime run directory escaped its provider storage root.'); + const details = await lstat(directory).catch((error: unknown) => { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + if (code === 'ENOENT') return undefined; + throw error; + }); + if (details === undefined) return; + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new Error('RSC runtime run directory is not a contained non-symbolic directory.'); + } + await rm(directory, { force: true, recursive: true }); + } + + async #openRunArtifact(runId: string): Promise { + await this.#assertCurrentOwnedRunsRoot(); + const directory = join(this.#runRoot, runId); + if (!safeSegment(runId) || !isInside(this.#runRoot, directory)) throw new Error('RSC runtime run directory escaped its provider storage root.'); + const details = await lstat(directory); + if (!details.isDirectory() || details.isSymbolicLink()) throw new Error('RSC runtime run directory is unsafe.'); + const directoryHandle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + try { + const openedDirectory = await directoryHandle.stat(); + if (!openedDirectory.isDirectory() || openedDirectory.dev !== details.dev || openedDirectory.ino !== details.ino) { + throw new Error('RSC runtime run directory changed while opening its Flight artifact.'); + } + const flightPath = process.platform === 'linux' + ? `/proc/self/fd/${String(directoryHandle.fd)}/flight.bin` + : join(directory, 'flight.bin'); + const file = await open(flightPath, constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR, 0o600); + const artifact: RunArtifact = { file, runId }; + this.#runArtifacts.set(runId, artifact); + return artifact; + } finally { + await directoryHandle.close(); + } + } + + async #writeRunFlight(artifact: RunArtifact, flight: Buffer): Promise { + if (flight.byteLength > maximumInvocationFlightBytes) throw new Error(`RSC invocation Flight exceeded ${maximumInvocationFlightBytes} bytes.`); + let offset = 0; + while (offset < flight.byteLength) { + const written = await artifact.file.write(flight, offset, flight.byteLength - offset, offset); + if (written.bytesWritten === 0) throw new Error('RSC runtime Flight artifact could not be written.'); + offset += written.bytesWritten; + } + await artifact.file.sync(); + const details = await artifact.file.stat(); + if (!details.isFile() || details.size !== flight.byteLength || details.size > maximumInvocationFlightBytes) { + throw new Error('RSC runtime Flight artifact has an invalid identity.'); + } + artifact.dev = details.dev; + artifact.digest = createHash('sha256').update(flight).digest('hex'); + artifact.ino = details.ino; + artifact.size = details.size; + } + + async #releaseRunArtifact(runId: string): Promise { + const artifact = this.#runArtifacts.get(runId); + if (artifact === undefined) return; + await this.#testing.beforeRunArtifactRelease?.(Object.freeze({ runId })); + await artifact.file.close(); + this.#runArtifacts.delete(runId); + } + + #inspectionResult( + inspection: DevRuntimeInspectionEnvelope, + flight: Buffer, + snapshot: RuntimeSnapshot, + runId: string, + app: DevRuntimeInspectionEnvelope['app'], + ): DevRuntimeInspectionEnvelope { + const { app: _workerApp, ...workerInspection } = inspection; + return Object.freeze({ + ...workerInspection, + ...(app === undefined ? {} : { app }), + flight: Object.freeze({ + bytes: flight.byteLength, + downloadPath: `/api/runtime/runs/${encodeURIComponent(runId)}/flight`, + preview: flight.subarray(0, flightPreviewBytes).toString('base64'), + truncated: flight.byteLength > flightPreviewBytes, + }), + state: Object.freeze({ + ...inspection.state, + identity: Object.freeze({ stateStoreId, stateVersion: snapshot.stateVersion }), + snapshot: cloneJson(snapshot), + }), + }); + } + + #runtimeAppLink( + generation: RuntimeGeneration, + invocation: ValidatedInvocation, + ): RuntimeAppLink | undefined { + if (invocation.surface.id !== 'mcp.render_edit_timeline') return undefined; + const registry = this.#mcpRegistry.snapshot(); + const metadata = generation.manifest.metadata; + if ( + this.#active?.id !== generation.id || registry?.runtimeGenerationId !== generation.id + ) { + throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); + } + const toolName = invocation.surface.id.slice('mcp.'.length); + const matches = registry.servers.flatMap((descriptor) => { + if ( + descriptor.target !== invocation.request.target || descriptor.definitionDigest !== registry.definitionDigest || + descriptor.transportDigest !== registry.transportDigest || descriptor.serverDigest !== metadata.serverDigest + ) return []; + const tool = descriptor.tools.find((candidate) => candidate.name === toolName); + const toolMeta = tool?._meta; + const outputTemplate = toolMeta === null || typeof toolMeta !== 'object' || Array.isArray(toolMeta) + ? undefined + : Object.getOwnPropertyDescriptor(toolMeta, 'openai/outputTemplate')?.value; + const resourceUri = typeof outputTemplate === 'string' ? outputTemplate : undefined; + if (resourceUri === undefined) return []; + return metadata.appDefinitions + .filter((app) => app.serverName === descriptor.name && app.resourceUri === resourceUri && app.targets.includes(invocation.request.target) && metadata.surfaceAssets[`mcp.${app.name}`] !== undefined) + .map((app) => Object.freeze({ app, descriptor, resourceUri })); + }); + if (matches.length !== 1) throw new Error('Runtime App invocation has no unambiguous current-generation App definition.'); + const match = matches[0]!; + return Object.freeze({ + descriptor: match.descriptor, + key: `${match.descriptor.name}\u0000${invocation.request.target}`, + resourceUri: match.resourceUri, + surfaceId: clientSurfaceId, + }); + } + + #assertRuntimeAppAuthority( + generation: RuntimeGeneration, + link: RuntimeAppLink, + ): NonNullable> { + this.#assertInvocationOpen(); + const registry = this.#mcpRegistry.snapshot(); + if ( + registry === undefined || this.#active?.id !== generation.id || registry.runtimeGenerationId !== generation.id || + link.descriptor.definitionDigest !== registry.definitionDigest || link.descriptor.transportDigest !== registry.transportDigest || + !registry.servers.some((descriptor) => descriptor.name === link.descriptor.name && descriptor.target === link.descriptor.target && + descriptor.definitionDigest === link.descriptor.definitionDigest && descriptor.serverDigest === link.descriptor.serverDigest && + descriptor.transportDigest === link.descriptor.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) + ) { + throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); + } + return registry; + } + + #matchesRuntimeAppBinding( + binding: DevRuntimeMcpSessionBinding, + link: RuntimeAppLink, + registry: NonNullable>, + ): boolean { + return binding.definitionDigest === registry.definitionDigest && binding.registryRevision === registry.registryRevision && + binding.serverDigest === link.descriptor.serverDigest && binding.serverName === link.descriptor.name && + binding.target === link.descriptor.target && binding.transportDigest === registry.transportDigest; + } + + async #runtimeAppSession( + generation: RuntimeGeneration, + link: RuntimeAppLink, + ): Promise { + const registry = this.#assertRuntimeAppAuthority(generation, link); + const existing = this.#appBrokers.get(link.key); + const broker = existing ?? { closedObservation: undefined, opening: undefined, session: undefined }; + if (existing === undefined) this.#appBrokers.set(link.key, broker); + const current = broker.session; + if (current !== undefined) { + const snapshot = current.snapshot(); + if (snapshot.state === 'ready' && this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) return current; + broker.closedObservation?.unsubscribe(); + broker.closedObservation = undefined; + broker.session = undefined; + if (this.#appBrokers.get(link.key) === broker) this.#appBrokers.delete(link.key); + return this.#runtimeAppSession(generation, link); + } + if (broker.opening !== undefined) return broker.opening; + const opening = (async (): Promise => { + let session: DevRuntimeMcpSession | undefined; + try { + session = await this.#mcpRegistry.open(Object.freeze({ + expectedRegistryRevision: registry.registryRevision, + serverName: link.descriptor.name, + target: link.descriptor.target, + })); + const currentRegistry = this.#assertRuntimeAppAuthority(generation, link); + const snapshot = session.snapshot(); + if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, currentRegistry)) { + throw new Error('Runtime App broker session did not negotiate the current generation authority.'); + } + broker.session = session; + broker.closedObservation = session.watchClosed(() => { + if (this.#appBrokers.get(link.key) !== broker) return; + broker.closedObservation?.unsubscribe(); + broker.closedObservation = undefined; + broker.session = undefined; + this.#appBrokers.delete(link.key); + }); + return session; + } catch (error) { + if (session !== undefined) await session.close().catch(() => undefined); + if (this.#appBrokers.get(link.key) === broker && broker.session === undefined) this.#appBrokers.delete(link.key); + throw error; + } + })(); + broker.opening = opening; + void opening.finally(() => { + if (broker.opening === opening) broker.opening = undefined; + }).catch(() => undefined); + return opening; + } + + async #runtimeAppResult( + generation: RuntimeGeneration, + invocation: ValidatedInvocation, + ): Promise { + const link = this.#runtimeAppLink(generation, invocation); + if (link === undefined) return undefined; + const session = await this.#runtimeAppSession(generation, link); + const registry = this.#assertRuntimeAppAuthority(generation, link); + const snapshot = session.snapshot(); + if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) { + throw new Error('Runtime App broker session became stale before invocation completion.'); + } + const binding = snapshot.binding; + return Object.freeze({ + mcpBinding: Object.freeze({ + definitionDigest: binding.definitionDigest, + registryRevision: binding.registryRevision, + serverDigest: binding.serverDigest, + serverName: binding.serverName, + sessionId: binding.sessionId, + sessionRevision: binding.sessionRevision, + target: binding.target, + transportDigest: binding.transportDigest, + }), + resourceUri: link.resourceUri, + surfaceId: link.surfaceId, + }); + } + + #validateWorkerResponse(value: unknown, flightBytes: number, surfaceId: string): DevRuntimeInspectionEnvelope { + const response = plainRecord(value, 'RSC invocation worker emitted an invalid response.'); + assertExactKeys(response, ['flightBytes', 'inspection'], 'RSC invocation worker response has unsupported fields.'); + if ( + typeof response.flightBytes !== 'number' || !Number.isSafeInteger(response.flightBytes) || + response.flightBytes < 0 || response.flightBytes > maximumInvocationFlightBytes || response.flightBytes !== flightBytes + ) { + throw new Error('RSC invocation worker Flight framing is invalid.'); + } + const inspection = plainRecord(response.inspection, 'RSC invocation worker inspection is invalid.'); + const hook = surfaceId === 'hook.claude' || surfaceId === 'hook.codex'; + if ('app' in inspection) validateAppBinding(inspection.app); + optionalExactKeys( + inspection, + hook ? ['agentVisible', 'flight', 'native', 'state', 'trace', 'tree'] : ['flight', 'modelVisible', 'protocol', 'state', 'trace', 'tree'], + hook ? [] : [], + 'RSC invocation worker inspection has unsupported fields.', + ); + const flight = plainRecord(inspection.flight, 'RSC invocation worker inspection is missing Flight metadata.'); + assertExactKeys(flight, ['bytes', 'preview', 'truncated'], 'RSC invocation worker Flight metadata is invalid.'); + if (flight.bytes !== flightBytes || typeof flight.preview !== 'string' || typeof flight.truncated !== 'boolean') { + throw new Error('RSC invocation worker Flight metadata does not match its raw Flight stream.'); + } + const state = plainRecord(inspection.state, 'RSC invocation worker inspection is missing state metadata.'); + optionalExactKeys(state, ['identity'], ['snapshot'], 'RSC invocation worker state metadata is invalid.'); + const identity = plainRecord(state.identity, 'RSC invocation worker state identity is invalid.'); + assertExactKeys(identity, ['stateStoreId', 'stateVersion'], 'RSC invocation worker state identity is invalid.'); + if (identity.stateStoreId !== stateStoreId || !Number.isSafeInteger(identity.stateVersion) || (identity.stateVersion as number) < 0) { + throw new Error('RSC invocation worker state identity is invalid.'); + } + validateTrace(inspection.trace); + validateTree(inspection.tree); + assertCredentialSafeJson(inspection); + return deepFreeze(inspection as unknown as DevRuntimeInspectionEnvelope); + } + + #runInvocationWorker(input: Readonly<{ + readonly generation: RuntimeGeneration; + readonly input: JsonObject; + readonly runId: string; + readonly surfaceId: string; + }>): Promise> { + this.#assertInvocationOpen(); + const entry = join(input.generation.root, 'rsc', 'dev', 'invoke.js'); + if (!isInside(input.generation.root, entry)) return Promise.reject(new Error('RSC invocation entry escaped its generation root.')); + const windowsSupervised = process.platform === 'win32'; + const child = spawn(process.execPath, windowsSupervised ? ['-e', windowsInvocationWrapperSource, entry] : [entry], { + cwd: resolve(this.#context.projectRoot), + detached: process.platform !== 'win32', + env: { + ...this.#context.environment, + AGENT_RUNTIME_STATE_FILE: this.#stateFile, + NODE_ENV: 'development', + }, + stdio: windowsSupervised ? ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdout = child.stdout; + const stderr = child.stderr; + const flightOutput = child.stdio[3] as NodeJS.ReadableStream | undefined; + const invocationControl = windowsSupervised ? child.stdio[4] as NodeJS.WritableStream | undefined : undefined; + const processGroupId = child.pid; + if ( + stdout === null || stderr === null || flightOutput === undefined || flightOutput === null || processGroupId === undefined || + (windowsSupervised && (invocationControl === undefined || invocationControl === null)) + ) { + child.kill('SIGKILL'); + return Promise.reject(new Error('RSC invocation worker streams are unavailable.')); + } + + const jobOwner = windowsSupervised ? (() => { + const owner = spawn('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + windowsJobOwnerSource, + String(processGroupId), + this.#testing.windowsJobOwnerMode ?? 'normal', + ], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const ownerControl = owner.stdin; + const ownerStdout = owner.stdout; + const ownerStderr = owner.stderr; + if (ownerControl === null || ownerStdout === null || ownerStderr === null) { + owner.kill('SIGKILL'); + return Object.freeze({ + closed: Promise.resolve(), + done: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), + drained: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), + ready: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), + isAssigned: () => false, + isClosed: () => true, + forceTerminate: () => undefined, + terminate: () => undefined, + } satisfies WindowsJobOwner); + } + const ownerStderrChunks: Buffer[] = []; + let ownerStderrBytes = 0; + let assigned = false; + let readySettled = false; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + let drainedSettled = false; + let resolveDrained!: () => void; + let rejectDrained!: (error: Error) => void; + const drained = new Promise((resolve, reject) => { + resolveDrained = resolve; + rejectDrained = reject; + }); + let doneSettled = false; + let resolveDone!: () => void; + let rejectDone!: (error: Error) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const ownerFailure = (message: string): Error => { + const diagnostics = redactInspectionDiagnostics(Buffer.concat(ownerStderrChunks).toString('utf8')); + return new Error(message + (diagnostics.length === 0 ? '' : ': ' + diagnostics)); + }; + const failReady = (failure: Error): void => { + if (!readySettled) { + readySettled = true; + rejectReady(failure); + } + }; + const failDrained = (failure: Error): void => { + if (!drainedSettled) { + drainedSettled = true; + rejectDrained(failure); + } + }; + const failDone = (failure: Error): void => { + if (!doneSettled) { + doneSettled = true; + rejectDone(failure); + } + }; + const protocolFailure = (message: string): void => { + const failure = ownerFailure(message); + failReady(failure); + failDrained(failure); + failDone(failure); + }; + let protocolBytes = 0; + let protocolOffset = 0; + const protocolChunks: Buffer[] = []; + const consumeOwnerProtocol = (): void => { + const protocol = Buffer.concat(protocolChunks).toString('utf8'); + let remainder = protocol.slice(protocolOffset); + if (!readySettled) { + const readyLine = ['READY\n', 'READY\r\n'].find((line) => remainder.startsWith(line)); + if (readyLine !== undefined) { + readySettled = true; + assigned = true; + resolveReady(); + protocolOffset += readyLine.length; + remainder = protocol.slice(protocolOffset); + } else if (!['READY\n', 'READY\r\n'].some((line) => line.startsWith(remainder))) { + protocolFailure('RSC invocation Windows Job Object owner emitted an invalid readiness response.'); + return; + } else { + return; + } + } + if (!drainedSettled) { + const drainedLine = ['DRAINED\n', 'DRAINED\r\n'].find((line) => remainder.startsWith(line)); + if (drainedLine !== undefined) { + drainedSettled = true; + resolveDrained(); + protocolOffset += drainedLine.length; + remainder = protocol.slice(protocolOffset); + } else if (!['DRAINED\n', 'DRAINED\r\n'].some((line) => line.startsWith(remainder))) { + protocolFailure('RSC invocation Windows Job Object owner did not confirm descendant drain.'); + return; + } else { + return; + } + } + if (remainder.length > 0) protocolFailure('RSC invocation Windows Job Object owner emitted extra protocol output.'); + }; + ownerStdout.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + protocolBytes += bytes.byteLength; + if (protocolBytes > 32 || doneSettled) { + protocolFailure('RSC invocation Windows Job Object owner emitted oversized or late protocol output.'); + return; + } + protocolChunks.push(bytes); + consumeOwnerProtocol(); + }); + ownerControl.once('error', () => { + const failure = ownerFailure('RSC invocation Windows Job Object owner control stream failed.'); + failReady(failure); + failDrained(failure); + failDone(failure); + }); + ownerStdout.once('error', () => { + protocolFailure('RSC invocation Windows Job Object owner protocol stream failed.'); + }); + ownerStderr.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const retained = Math.min(bytes.byteLength, Math.max(0, maximumInvocationStderrBytes - ownerStderrBytes)); + if (retained > 0) ownerStderrChunks.push(bytes.subarray(0, retained)); + ownerStderrBytes += bytes.byteLength; + }); + ownerStderr.once('error', () => { + protocolFailure('RSC invocation Windows Job Object owner diagnostics stream failed.'); + }); + owner.once('error', (error) => { + const failure = ownerFailure('RSC invocation Windows Job Object owner could not be started: ' + error.message); + failReady(failure); + failDrained(failure); + failDone(failure); + }); + owner.once('close', (code) => { + resolveClosed(); + const failure = ownerFailure('RSC invocation Windows Job Object owner exited with code ' + String(code) + '.'); + if (!readySettled) failReady(failure); + if (!drainedSettled) failDrained(failure); + if (code === 0 && readySettled && drainedSettled) { + if (!doneSettled) { + doneSettled = true; + resolveDone(); + } + } else { + failDone(failure); + } + }); + return Object.freeze({ + closed, + done, + drained, + ready, + isAssigned: () => assigned, + isClosed: () => owner.exitCode !== null || owner.signalCode !== null, + forceTerminate: () => { + try { owner.kill('SIGKILL'); } catch { /* Owner already exited. */ } + }, + terminate: () => { + if (!ownerControl.destroyed) ownerControl.end('STOP\n'); + }, + } satisfies WindowsJobOwner); + })() : undefined; + let termination: Error | undefined; + let timeout: ReturnType | undefined; + let settled = false; + let stdoutBytes = 0; + let stderrBytes = 0; + let flightBytes = 0; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const flightChunks: Buffer[] = []; + const childClosed = new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once('close', () => resolve()); + }); + const cleanupFailure = (error: unknown): void => { + const failure = error instanceof Error ? error : new Error('RSC invocation worker teardown failed.'); + termination = termination === undefined + ? failure + : new AggregateError([termination, failure], 'RSC invocation worker teardown failed.'); + }; + const signalGroup = async (signal: NodeJS.Signals): Promise => { + if (windowsSupervised) return; + try { + process.kill(-processGroupId, signal); + } catch { + try { child.kill(signal); } catch { /* Child already exited. */ } + } + }; + let treeCleanup: Promise | undefined; + const teardownTree = (): Promise => { + treeCleanup ??= (async () => { + if (jobOwner !== undefined) { + let forcedOwnerTermination = false; + const forceOwnerTermination = (): void => { + if (forcedOwnerTermination) return; + forcedOwnerTermination = true; + jobOwner.forceTerminate(); + }; + if (!jobOwner.isAssigned()) { + // READY was never observed, so wrapper code is still blocked on GO. + // The retained ChildProcess handle is safe only in this pre-assignment + // phase; all assigned trees are owned exclusively through the Job. + forceOwnerTermination(); + try { child.kill('SIGKILL'); } catch { /* Wrapper already exited. */ } + } else { + jobOwner.terminate(); + try { + await withinDeadline( + jobOwner.drained, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object owner did not confirm descendant drain.', + ); + } catch (error) { + cleanupFailure(error); + forceOwnerTermination(); + } + } + try { + await withinDeadline( + childClosed, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object did not terminate its wrapper.', + ); + } catch (error) { + cleanupFailure(error); + forceOwnerTermination(); + try { + await withinDeadline( + childClosed, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object did not terminate its wrapper after forced owner shutdown.', + ); + } catch (forcedError) { + cleanupFailure(forcedError); + } + } + try { + await withinDeadline( + jobOwner.closed, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object owner did not exit after cleanup.', + ); + } catch (error) { + cleanupFailure(error); + forceOwnerTermination(); + try { + await withinDeadline( + jobOwner.closed, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object owner did not exit after forced shutdown.', + ); + } catch (forcedError) { + cleanupFailure(forcedError); + } + } + try { + await withinDeadline( + jobOwner.done, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object owner did not complete its verified drain protocol.', + ); + } catch (error) { + cleanupFailure(error); + } + return; + } + await signalGroup('SIGTERM'); + await new Promise((resolve) => setTimeout(resolve, invocationTerminationGraceMs)); + await signalGroup('SIGKILL'); + })(); + return treeCleanup; + }; + const terminate = (reason: Error): void => { + if (termination !== undefined) return; + termination = reason; + child.stdin.destroy(); + void teardownTree(); + }; + void jobOwner?.done.catch((error: unknown) => { + if (termination === undefined) terminate(error instanceof Error ? error : new Error('RSC invocation Windows Job Object owner failed.')); + }); + const abort = (): void => terminate(new DevRuntimeUnavailableError('RSC runtime session is closed.')); + this.#invocationAbort.signal.addEventListener('abort', abort, { once: true }); + + const response = new Promise>((resolveResponse, rejectResponse) => { + const finish = async (callback: () => void): Promise => { + if (settled) return; + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + await treeCleanup; + callback(); + }; + const parseWorkerResponse = (): Readonly<{ readonly flight: Buffer; readonly inspection: DevRuntimeInspectionEnvelope }> => { + const output = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(stdoutChunks)); + if (!output.endsWith('\n') || output.indexOf('\n') !== output.length - 1) { + throw new Error('RSC invocation worker did not emit exactly one JSON response line.'); + } + return Object.freeze({ + flight: Buffer.concat(flightChunks), + inspection: this.#validateWorkerResponse(JSON.parse(output), flightBytes, input.surfaceId), + }); + }; + stdout.on('data', (chunk: Buffer | string) => { + if (termination !== undefined) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += bytes.byteLength; + if (stdoutBytes > maximumInvocationStdoutBytes) { + terminate(new Error(`RSC invocation stdout exceeded ${maximumInvocationStdoutBytes} bytes.`)); + return; + } + stdoutChunks.push(bytes); + }); + stdout.once('error', () => terminate(new Error('RSC invocation stdout stream failed.'))); + flightOutput.on('data', (chunk: Buffer | string) => { + if (termination !== undefined) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + flightBytes += bytes.byteLength; + if (flightBytes > maximumInvocationFlightBytes) { + terminate(new Error(`RSC invocation Flight exceeded ${maximumInvocationFlightBytes} bytes.`)); + return; + } + flightChunks.push(bytes); + }); + flightOutput.once('error', () => terminate(new Error('RSC invocation Flight stream failed.'))); + stderr.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const retained = Math.min(bytes.byteLength, Math.max(0, maximumInvocationStderrBytes - stderrBytes)); + if (retained > 0) stderrChunks.push(bytes.subarray(0, retained)); + stderrBytes += bytes.byteLength; + if (stderrBytes > maximumInvocationStderrBytes) { + terminate(new Error(`RSC invocation stderr exceeded ${maximumInvocationStderrBytes} bytes.`)); + } + }); + stderr.once('error', () => terminate(new Error('RSC invocation stderr stream failed.'))); + if (invocationControl !== undefined && invocationControl !== null) { + invocationControl.once('error', () => terminate(new Error('RSC invocation Windows wrapper control stream failed.'))); + } + child.stdin.once('error', () => terminate(new Error('RSC invocation request stream failed.'))); + child.once('error', (error) => terminate(new Error(`RSC invocation worker could not be started: ${error.message}`))); + child.once('close', (code) => { + void (async () => { + const diagnostics = redactInspectionDiagnostics(Buffer.concat(stderrChunks).toString('utf8')); + if (termination !== undefined) { + const message = termination.message; + void finish(() => rejectResponse(new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`))); + return; + } + if (code !== 0) { + const failure = new Error(`RSC invocation worker exited with code ${String(code)}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`); + terminate(failure); + void finish(() => rejectResponse(failure)); + return; + } + try { + const parsed = parseWorkerResponse(); + void (async () => { + await teardownTree(); + const terminationAfterCleanup = termination as Error | undefined; + if (terminationAfterCleanup !== undefined) { + const message = terminationAfterCleanup.message; + await finish(() => rejectResponse(new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`))); + return; + } + await finish(() => resolveResponse(parsed)); + })(); + } catch (error) { + const failure = error instanceof Error ? error : new Error('RSC invocation worker emitted invalid JSON.'); + terminate(failure); + void finish(() => rejectResponse(failure)); + } + })(); + }); + timeout = setTimeout(() => terminate(new Error(`RSC invocation worker exceeded ${invocationTimeoutMs} ms.`)), invocationTimeoutMs); + void (async () => { + try { + if (jobOwner !== undefined) { + await withinDeadline( + jobOwner.ready, + windowsJobOwnerPhaseDeadlineMs, + 'RSC invocation Windows Job Object owner did not confirm assignment readiness.', + ); + this.#assertInvocationOpen(); + if (jobOwner.isClosed()) throw new Error('RSC invocation Windows Job Object owner closed before the worker was armed.'); + invocationControl!.end('GO\\n'); + } + this.#assertInvocationOpen(); + child.stdin.end(JSON.stringify(input.input)); + } catch (error) { + terminate(error instanceof Error ? error : new Error('RSC invocation request could not be encoded.')); + } + })(); + }); + const worker: InvocationWorker = Object.freeze({ + done: response.then(() => undefined, () => undefined), + terminate, + }); + this.#workers.set(input.runId, worker); + void worker.done.finally(() => { + if (this.#workers.get(input.runId) === worker) this.#workers.delete(input.runId); + this.#invocationAbort.signal.removeEventListener('abort', abort); + }); + return response; + } + + #attachServer( + started: StartDevServerResult, + devServer: Readonly<{ readonly hostname: string; readonly https: boolean; readonly port: number }> | undefined, + ): void { + if (this.#closed) return; + if ( + devServer === undefined || devServer.hostname !== '127.0.0.1' || devServer.https || + !Number.isSafeInteger(devServer.port) || devServer.port < 1 || devServer.port > 65_535 + ) throw new Error('RSC runtime dev server did not expose a valid loopback HTTP origin.'); + const webSocketToken = this.#appWebSocketToken; + if (webSocketToken === undefined) throw new Error('RSC runtime App compiler did not capture an HMR credential.'); + const origin = new URL(`http://${devServer.hostname}:${String(devServer.port)}`).origin; + this.#server = started.server; + this.#clientSurface = Object.freeze({ + entryPath: clientSurfaceEntry, + httpOrigin: origin, + httpPathPrefixes: Object.freeze(['/']), + surfaceId: clientSurfaceId, + webSocketOrigin: origin.replace(/^http:/u, 'ws:'), + webSocketPath: '/rsbuild-hmr', + webSocketToken, + }); + this.#hmrReady = true; + this.#setStatus(this.#active === undefined ? 'compiling' : 'active'); + } + + #captureAppWebSocketToken(token: string): void { + if (!hmrToken.test(token)) throw new Error('RSC runtime App compiler exposed an invalid HMR credential.'); + if (this.#appWebSocketToken !== undefined && this.#appWebSocketToken !== token) { + throw new Error('RSC runtime App compiler changed its HMR credential during startup.'); + } + this.#appWebSocketToken = token; + } + + #compileObserver(): NonNullable[0]['onCompile']> { + return Object.freeze({ + beforeAttempt: () => this.#beforeAttempt(), + capture: async (input) => this.#trackCapture(input), + enqueue: (snapshot) => this.#enqueue(snapshot), + failAttempt: (attemptId, error, kind) => { void this.#failAttempt(attemptId, error, kind); }, + }); + } + + #trackCapture(input: Readonly<{ + readonly attemptId: string; + readonly cohortChanged: boolean; + readonly hasErrors: boolean; + readonly sourceRevision: string; + }>): Promise { + const capture = this.#capture(input); + const tracked = capture.then(() => undefined, () => undefined); + this.#captureTasks.add(tracked); + void tracked.then(() => { this.#captureTasks.delete(tracked); }); + return capture; + } + + #beforeAttempt(): string { + if (this.#closed) throw new Error('RSC runtime session is closed.'); + const sequence = ++this.#latestAttemptSequence; + const id = `attempt-${String(sequence)}`; + let settlePromise!: () => void; + const settled = new Promise((resolve) => { settlePromise = resolve; }); + const barrier: AttemptBarrier = { + candidate: undefined, + id, + sequence, + settle: () => { + if (!this.#attempts.delete(id)) return; + settlePromise(); + }, + settled, + }; + this.#attempts.set(id, barrier); + return id; + } + + async #capture(input: Readonly<{ + readonly attemptId: string; + readonly cohortChanged: boolean; + readonly hasErrors: boolean; + readonly sourceRevision: string; + }>): Promise { + const barrier = this.#attempts.get(input.attemptId); + if (barrier === undefined) throw new Error('RSC runtime compile capture has no live attempt barrier.'); + if (input.hasErrors) { + await this.#failAttempt(input.attemptId, new Error('RSC runtime compilation failed.'), 'source-build'); + return undefined; + } + if (input.sourceRevision.length === 0) { + await this.#failAttempt(input.attemptId, new Error('RSC runtime compilation has no source revision.')); + return undefined; + } + if (!input.cohortChanged) { + barrier.settle(); + return undefined; + } + this.#latestSupersedingAttemptSequence = Math.max(this.#latestSupersedingAttemptSequence, barrier.sequence); + const cohortRevision = ++this.#latestRscCohortRevision; + const preparedRuntime = this.#latestPreparedRuntime; + barrier.settle(); + this.#emit(Object.freeze({ runtimeGenerationId: undefined, type: 'runtime.generation.compiling' })); + try { + const candidate = await this.#generationStore.begin({ + id: `generation-${String(++this.#generationSequence)}`, + sourceRevision: input.sourceRevision, + }); + barrier.candidate = candidate; + this.#candidatesByAttempt.set(input.attemptId, candidate); + await this.#testing.beforeGenerationCapture?.(); + if (this.#closed) throw new Error('RSC runtime session is closed.'); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: input.attemptId, + candidate, + compilerAssetCheckpointTracker: this.#checkpointTracker, + compilerRoot: join(this.#context.storageRoot, 'compiler'), + preparedRuntime, + rscCohortRevision: cohortRevision, + sourceRevision: input.sourceRevision, + }); + if (this.#closed) throw new Error('RSC runtime session is closed.'); + return Object.freeze({ + acceptCompilerAssetCheckpoint: snapshot.acceptCompilerAssetCheckpoint, + attemptId: snapshot.attemptId, + candidateId: snapshot.candidate.id, + discardCompilerAssetCheckpoint: snapshot.discardCompilerAssetCheckpoint, + preparedRevision: snapshot.preparedRuntime.sourceRevision, + rscCohortRevision: snapshot.rscCohortRevision, + sourceRevision: snapshot.sourceRevision, + snapshot, + } as RscRuntimeCompileSnapshot & Readonly<{ readonly snapshot: RscRuntimeCapturedGenerationSnapshot }>); + } catch (error) { + await this.#failAttempt(input.attemptId, error); + throw error; + } + } + + #enqueue(snapshot: RscRuntimeCompileSnapshot): Promise<'activated' | 'failed'> { + const captured = (snapshot as RscRuntimeCompileSnapshot & Readonly<{ readonly snapshot?: RscRuntimeCapturedGenerationSnapshot }>).snapshot; + if (captured === undefined) throw new Error('RSC runtime compile snapshot was not captured by this session.'); + if (this.#closed) { + snapshot.discardCompilerAssetCheckpoint?.(); + return this.#failAttempt(snapshot.attemptId, new Error('RSC runtime session is closed.')).then(() => 'failed'); + } + return this.#append(async () => this.#activate(captured)); + } + + async #failAttempt( + attemptId: string, + error: unknown, + kind: RscRuntimeCompileFailureKind = 'provider-lifecycle', + ): Promise { + if (this.#failedAttempts.has(attemptId)) return; + this.#failedAttempts.add(attemptId); + this.#latestSupersedingAttemptSequence = Math.max(this.#latestSupersedingAttemptSequence, this.#sequenceFor(attemptId)); + const barrier = this.#attempts.get(attemptId); + barrier?.settle(); + const candidate = barrier?.candidate ?? this.#candidatesByAttempt.get(attemptId); + this.#candidatesByAttempt.delete(attemptId); + if (candidate !== undefined) { + const cleanup = this.#failureTail.then(() => this.#generationStore.fail(candidate)); + this.#failureTail = cleanup.catch(() => undefined); + await cleanup.catch(() => undefined); + } + if (!this.#closed) this.#setStatus( + this.#active === undefined ? 'degraded' : 'active', + [kind === 'source-build' ? sourceBuildDiagnostic() : lifecycleDiagnostic(error)], + ); + this.#emit(Object.freeze({ type: 'runtime.generation.failed' })); + } + + #activationGuard(snapshot: RscRuntimeCapturedGenerationSnapshot): RuntimeGenerationActivationGuard { + const preparedAuthorityDigest = preparedRuntimeAuthorityDigest(snapshot.preparedRuntime); + let waitedSequence = -1; + return Object.freeze({ + check: () => !this.#closed && + waitedSequence === this.#latestSupersedingAttemptSequence && + ![...this.#attempts.values()].some((attempt) => attempt.sequence > this.#sequenceFor(snapshot.attemptId)) && + snapshot.rscCohortRevision === this.#latestRscCohortRevision && + preparedAuthorityDigest === preparedRuntimeAuthorityDigest(this.#latestPreparedRuntime), + wait: async () => { + while (!this.#closed) { + const sequence = this.#sequenceFor(snapshot.attemptId); + const pending = [...this.#attempts.values()].filter((attempt) => attempt.sequence > sequence); + if (pending.length === 0) { + waitedSequence = this.#latestSupersedingAttemptSequence; + return; + } + await Promise.all(pending.map((attempt) => attempt.settled)); + } + throw new Error('RSC runtime session is closed.'); + }, + }); + } + + async #activate(snapshot: RscRuntimeCapturedGenerationSnapshot): Promise<'activated' | 'failed'> { + const guard = this.#activationGuard(snapshot); + let preparedGeneration: RuntimeGenerationPreparedActivation | undefined; + let preparedRegistry: RuntimeMcpPreparedActivationReconcile | undefined; + try { + preparedGeneration = await materializeRuntimeGeneration({ + guard, + snapshot, + stateStoreId, + store: this.#generationStore, + }); + await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'store', session: this })); + const metadata = preparedGeneration.generation.manifest.metadata; + preparedRegistry = await this.#mcpRegistry.prepareActivationReconcile({ + definitionDigest: metadata.definitionDigest, + runtimeGenerationId: preparedGeneration.generation.id, + servers: metadata.servers, + transportDigest: metadata.transportDigest, + }); + await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'registry', session: this })); + await guard.wait(preparedGeneration.generation.manifest); + if (!guard.check(preparedGeneration.generation.manifest) || !this.#generationStore.canCommit(preparedGeneration)) { + throw new Error('RSC runtime generation activation was superseded.'); + } + const generation = this.#generationStore.commit(preparedGeneration); + const committed = this.#mcpRegistry.commitActivationReconcile(preparedRegistry); + preparedGeneration = undefined; + preparedRegistry = undefined; + this.#active = generation; + this.#updateSurfaces(snapshot, snapshot.preparedRuntime); + this.#updateSurfaceAssetApps(snapshot.preparedRuntime); + this.#setStatus('active'); + this.#emit(Object.freeze({ + mcpRegistryRevision: this.#mcpRegistry.snapshot()?.registryRevision, + runtimeGenerationId: generation.id, + type: 'runtime.generation.activated', + })); + committed.publish(); + try { + await committed.finalize(); + } catch (error) { + if (!this.#closed) this.#setStatus('degraded', [lifecycleDiagnostic(error)]); + } + return 'activated'; + } catch (error) { + if (preparedGeneration !== undefined || preparedRegistry !== undefined) { + await Promise.allSettled([ + ...(preparedGeneration === undefined ? [] : [this.#generationStore.abort(preparedGeneration)]), + ...(preparedRegistry === undefined ? [] : [this.#mcpRegistry.abortActivationReconcile(preparedRegistry)]), + ]); + } + snapshot.discardCompilerAssetCheckpoint?.(); + await this.#failAttempt(snapshot.attemptId, error); + return 'failed'; + } finally { + this.#candidatesByAttempt.delete(snapshot.attemptId); + } + } + + async #reconcilePreparedRuntime(prepared: DevRuntimePreparedProject): Promise { + const active = this.#active; + if (active === undefined || this.#closed) return; + const metadata = active.manifest.metadata; + const definition = JSON.parse(await readFile(join(active.root, 'rsc', 'runtime-definition.json'), 'utf8')) as SerializedRuntimeDefinition; + const nextDefinitionDigest = runtimeDefinitionDigest(definition, prepared); + const nextTransportDigest = transportDigest(prepared); + const current = this.#mcpRegistry.snapshot(); + if ( + current?.runtimeGenerationId === active.id && + current.definitionDigest === nextDefinitionDigest && + current.transportDigest === nextTransportDigest + ) return; + const input: DevRuntimeMcpRegistryReconcileInput = Object.freeze({ + definitionDigest: nextDefinitionDigest, + runtimeGenerationId: active.id, + servers: descriptorsFor(prepared, metadata, nextDefinitionDigest, nextTransportDigest), + transportDigest: nextTransportDigest, + }); + this.#setStatus('compiling'); + try { + await this.#mcpRegistry.reconcile(input); + this.#updateSurfaces({ definition }, prepared); + this.#updateSurfaceAssetApps(prepared); + this.#setStatus('active'); + } catch (error) { + this.#setStatus('degraded', [lifecycleDiagnostic(error)]); + throw error; + } + } + + async #executeMcp(execution: RuntimeMcpExecutionContext): Promise> { + execution.signal.throwIfAborted(); + const generation = execution.generation as RuntimeGeneration; + this.#assertMcpExecutionAuthority(execution, generation); + if (execution.request.kind === 'read-resource') { + const resource = this.#appResource(execution, generation, execution.request.uri); + const asset = await this.#readGenerationSurfaceHtml(generation, resource.surfaceId); + execution.signal.throwIfAborted(); + this.#assertMcpExecutionAuthority(execution, generation); + return Object.freeze({ + stateVersion: 0, + value: Object.freeze({ + contents: Object.freeze([Object.freeze({ + _meta: resource.metadata, + mimeType: resource.mimeType, + text: asset, + uri: resource.uri, + })]), + }), + }); + } + if (execution.request.kind === 'call-tool') { + this.#appTool(execution, generation, execution.request.name); + return this.#executeTimelineTool(execution, generation, execution.request.arguments); + } + throw new Error(`Runtime MCP operation ${JSON.stringify(execution.request.kind)} is not available.`); + } + + #assertMcpExecutionAuthority( + execution: RuntimeMcpExecutionContext, + generation: RuntimeGeneration, + ): NonNullable> { + this.#assertInvocationOpen(); + const registry = this.#mcpRegistry.snapshot(); + const binding = this.#mcpRegistry.session(execution.sessionId)?.snapshot().binding; + if ( + registry === undefined || binding === undefined || this.#active?.id !== generation.id || + registry.runtimeGenerationId !== generation.id || + binding.sessionId !== execution.sessionId || binding.registryRevision !== registry.registryRevision || + !registry.servers.some((descriptor) => descriptor.name === execution.descriptor.name && descriptor.target === execution.descriptor.target && + descriptor.definitionDigest === execution.descriptor.definitionDigest && descriptor.serverDigest === execution.descriptor.serverDigest && + descriptor.transportDigest === execution.descriptor.transportDigest && descriptor.definitionDigest === registry.definitionDigest && + descriptor.transportDigest === registry.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) || + binding.definitionDigest !== execution.descriptor.definitionDigest || binding.serverDigest !== execution.descriptor.serverDigest || + binding.serverName !== execution.descriptor.name || binding.target !== execution.descriptor.target || + binding.transportDigest !== execution.descriptor.transportDigest + ) { + throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); + } + return registry; + } + + #appResource( + execution: RuntimeMcpExecutionContext, + generation: RuntimeGeneration, + uri: string, + ): Readonly<{ readonly metadata: JsonObject; readonly mimeType: string; readonly surfaceId: string; readonly uri: string }> { + const resource = execution.descriptor.resources.filter((candidate) => + candidate.uri === uri && candidate.mimeType === 'text/html;profile=mcp-app' && isJsonObject(candidate._meta), + ); + const app = generation.manifest.metadata.appDefinitions.filter((candidate) => + candidate.resourceUri === uri && candidate.serverName === execution.descriptor.name && candidate.targets.includes(execution.descriptor.target), + ); + if (resource.length !== 1 || app.length !== 1) throw new Error('Runtime MCP App resource is not owned by the current generation.'); + const surfaceId = `mcp.${app[0]!.name}`; + if (generation.manifest.metadata.surfaceAssets[surfaceId] === undefined) { + throw new Error('Runtime MCP App resource has no current-generation asset.'); + } + return Object.freeze({ metadata: resource[0]!._meta as JsonObject, mimeType: resource[0]!.mimeType as string, surfaceId, uri }); + } + + #appTool( + execution: RuntimeMcpExecutionContext, + generation: RuntimeGeneration, + name: string, + ): void { + const tool = execution.descriptor.tools.filter((candidate) => candidate.name === name); + if (tool.length !== 1 || tool[0]!.handlerId !== 'render_edit_timeline' || !isJsonObject(tool[0]!._meta)) { + throw new Error('Runtime MCP App tool is not owned by the current generation.'); + } + const uri = tool[0]!._meta['openai/outputTemplate']; + if (typeof uri !== 'string') throw new Error('Runtime MCP App tool has no App resource binding.'); + this.#appResource(execution, generation, uri); + } + + #timelineLimit(argumentsValue: JsonValue | undefined): Readonly<{ readonly limit?: number }> { + if (argumentsValue === undefined) return Object.freeze({}); + if (!isJsonObject(argumentsValue) || Object.keys(argumentsValue).some((key) => key !== 'limit')) { + throw new TypeError('Runtime MCP App tool arguments are invalid.'); + } + const limit = argumentsValue.limit; + if (limit === undefined) return Object.freeze({}); + if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 50) { + throw new TypeError('Runtime MCP App tool arguments are invalid.'); + } + return Object.freeze({ limit }); + } + + async #executeTimelineTool( + execution: RuntimeMcpExecutionContext, + generation: RuntimeGeneration, + argumentsValue: JsonObject, + ): Promise> { + const release = this.#reserveInvocation(); + const runId = `runtime-mcp-${randomUUID()}`; + const abort = (): void => this.#workers.get(runId)?.terminate(new Error('Runtime MCP operation was aborted.')); + execution.signal.addEventListener('abort', abort, { once: true }); + try { + const snapshot = await this.#stateKernel.readSnapshot(this.#timelineLimit(argumentsValue)); + execution.signal.throwIfAborted(); + this.#assertMcpExecutionAuthority(execution, generation); + const response = await this.#runInvocationWorker({ + generation, + input: Object.freeze({ + snapshot: cloneJson(snapshot), + stateFile: this.#stateFile, + stateStoreId, + type: 'mcp/render-timeline', + }), + runId, + surfaceId: 'mcp.render_edit_timeline', + }); + execution.signal.throwIfAborted(); + this.#assertMcpExecutionAuthority(execution, generation); + const stateVersion = response.inspection.state.identity.stateVersion; + const durable = await this.#stateKernel.readSnapshot({ stateVersion }); + const protocol = response.inspection.protocol; + if (durable.stateVersion !== stateVersion || protocol === undefined || !isJsonObject(protocol)) { + throw new Error('Runtime MCP App tool result is not a durable protocol response.'); + } + execution.signal.throwIfAborted(); + this.#assertMcpExecutionAuthority(execution, generation); + return Object.freeze({ stateVersion, value: cloneJson(protocol) }); + } finally { + execution.signal.removeEventListener('abort', abort); + release(); + } + } + + async #readGenerationSurfaceHtml( + generation: RuntimeGeneration, + surfaceId: string, + ): Promise { + const matches = generation.manifest.metadata.surfaceAssets[surfaceId]?.filter((asset) => + asset.contentType === 'text/html' && asset.requestPath === clientSurfaceEntry, + ) ?? []; + if (matches.length !== 1) throw new Error('Runtime MCP App resource has no canonical HTML asset.'); + const asset = matches[0]!; + if (asset.bytes > maximumAssetBytes) throw new Error('Runtime MCP App HTML exceeds the asset limit.'); + const segments = asset.generationPath.split('/'); + if (segments.some((segment) => !safeSegment(segment))) throw new Error('Runtime MCP App HTML asset path is unsafe.'); + const path = join(generation.root, ...segments); + if (!isInside(generation.root, path)) throw new Error('Runtime MCP App HTML asset escaped its generation root.'); + const details = await lstat(path); + if (!details.isFile() || details.isSymbolicLink() || details.size !== asset.bytes) { + throw new Error('Runtime MCP App HTML asset changed.'); + } + const body = await readFile(path); + if (body.byteLength !== asset.bytes || createHash('sha256').update(body).digest('hex') !== asset.sha256) { + throw new Error('Runtime MCP App HTML asset changed.'); + } + const text = body.toString('utf8'); + if (Buffer.byteLength(text, 'utf8') !== body.byteLength) throw new Error('Runtime MCP App HTML asset is not UTF-8.'); + return text; + } + + async #close(): Promise { + this.#closed = true; + this.#invocationAbort.abort(new Error('RSC runtime session is closing.')); + this.#hmrReady = false; + for (const attempt of [...this.#attempts.values()]) attempt.settle(); + for (const worker of this.#workers.values()) { + worker.terminate(new Error('RSC runtime session is closing.')); + } + this.#checkpointTracker.close(); + this.#setStatus('closed'); + for (const broker of this.#appBrokers.values()) broker.closedObservation?.unsubscribe(); + this.#appBrokers.clear(); + this.#surfaceAssetApps.clear(); + const mcpRegistryClose = this.#closeLiveSessionResource('runtime-mcp-registry', () => this.#mcpRegistry.close()); + void mcpRegistryClose.catch(() => undefined); + while (this.#captureTasks.size > 0) await Promise.all([...this.#captureTasks]); + while (this.#invocations.size > 0) await Promise.allSettled([...this.#invocations]); + while (this.#runReadTasks.size > 0) { + await Promise.allSettled([...this.#runReadTasks.values()].flatMap((reads) => [...reads])); + } + await this.#evictionTail; + await Promise.all([this.#providerTail.catch(() => undefined), this.#failureTail]); + const runArtifactCleanup = this.#closeRunArtifacts(); + void runArtifactCleanup.catch(() => undefined); + const resources: readonly Readonly<{ + readonly close: () => Promise; + readonly label: LiveSessionCleanupResource; + }>[] = Object.freeze([ + Object.freeze({ label: 'run-artifact' as const, close: () => runArtifactCleanup }), + Object.freeze({ label: 'owned-runs-root' as const, close: async () => { + await runArtifactCleanup.catch(() => undefined); + await this.#closeLiveSessionResource( + 'owned-runs-root', + () => RsbuildRuntimeSession.#removeOwnedRunsRoot(this.#ownedRunsRoot), + ); + } }), + Object.freeze({ label: 'rsbuild-dev-server' as const, close: () => this.#closeLiveSessionResource( + 'rsbuild-dev-server', + () => this.#server?.close() ?? Promise.resolve(), + ) }), + Object.freeze({ label: 'runtime-mcp-registry' as const, close: () => mcpRegistryClose }), + Object.freeze({ label: 'generation-store' as const, close: () => this.#closeLiveSessionResource( + 'generation-store', + () => this.#generationStore.close(), + ) }), + ]); + const results = await Promise.allSettled(resources.map((resource) => resource.close())); + const failures = results.flatMap((result, index) => result.status === 'rejected' + ? [Object.freeze({ error: result.reason, label: resources[index]!.label })] + : []); + if (failures.length > 0) throw cleanupAggregate('RSC runtime session close failed', failures); + } + + async #closeRunArtifacts(): Promise { + const artifactResults = await Promise.allSettled([...this.#runArtifacts.keys()].map((runId) => this.#releaseRunArtifact(runId))); + const directoryResults = await Promise.allSettled([...this.#pendingRunDirectoryRemovals].map(async (runId) => { + await this.#removeRunDirectory(runId); + this.#pendingRunDirectoryRemovals.delete(runId); + })); + const failures = [ + ...artifactResults.flatMap((result) => result.status === 'rejected' + ? [Object.freeze({ error: result.reason, label: 'run-artifact' })] + : []), + ...directoryResults.flatMap((result) => result.status === 'rejected' + ? [Object.freeze({ error: result.reason, label: 'run-artifact' })] + : []), + ]; + if (failures.length > 0) throw cleanupAggregate('RSC runtime run artifact cleanup failed', failures); + await this.#testing.afterLiveSessionCleanupResource?.(Object.freeze({ resource: 'run-artifact' as const })); + } + + async #closeLiveSessionResource( + resource: LiveSessionCleanupResource, + close: () => Promise, + ): Promise { + await close(); + await this.#testing.afterLiveSessionCleanupResource?.(Object.freeze({ resource })); + } + + #append(work: () => Promise): Promise { + const next = this.#providerTail.then(work, work); + this.#providerTail = next.then(() => undefined, () => undefined); + return next; + } + + #emit(event: DevRuntimeEventInput): void { + if (this.#closed) return; + try { + this.#context.emit(event); + } catch { + // Runtime listeners cannot affect lifecycle ordering. + } + } + + #sequenceFor(attemptId: string): number { + const match = /^attempt-(\d+)$/u.exec(attemptId); + return match === null ? Number.MAX_SAFE_INTEGER : Number(match[1]); + } + + #publishActiveStateVersion(generation: RuntimeGeneration, stateVersion: number): void { + if (this.#active?.id !== generation.id) return; + const current = this.#status.activeVector; + if (current?.runtimeGenerationId === generation.id && current.stateVersion > stateVersion) return; + this.#setStatus(this.#status.state, this.#status.diagnostics, stateVersion); + } + + #setStatus( + state: DevRuntimeStatus['state'], + diagnostics: readonly DevRuntimeDiagnostic[] = [], + stateVersion = 0, + ): void { + const active = this.#active; + const vector = active === undefined ? undefined : this.#vector(active, stateVersion); + this.#status = Object.freeze({ + ...(vector === undefined ? {} : { activeVector: vector, lastGoodVector: vector }), + descriptor, + diagnostics: Object.freeze([...diagnostics]), + hmrReady: this.#hmrReady, + state, + }); + } + + #updateSurfaces( + snapshot: Pick, + prepared: Pick, + ): void { + this.#surfaces.clear(); + for (const hook of snapshot.definition.nativeHooks) { + this.#surfaces.set(`hook.${hook.host}`, Object.freeze({ + id: `hook.${hook.host}`, + kind: 'hook', + label: `After tool hook (${hook.host})`, + readOnly: false, + targets: Object.freeze([hook.host]), + fixtures: fixturesForHook(hook.host), + })); + } + for (const tool of snapshot.definition.tools) { + this.#surfaces.set(`mcp.${tool.name}`, Object.freeze({ + inputSchema: cloneJsonObject(tool.inputSchema), + id: `mcp.${tool.name}`, + kind: 'mcp-tool', + label: tool.description, + readOnly: tool.annotations.readOnlyHint, + targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), + fixtures: Object.freeze([]), + })); + } + for (const resource of snapshot.definition.resources) { + this.#surfaces.set(`mcp.${resource.name}`, Object.freeze({ + id: `mcp.${resource.name}`, + kind: 'mcp-resource', + label: resource.name, + readOnly: true, + targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), + fixtures: Object.freeze([]), + })); + } + for (const app of prepared.apps) { + this.#surfaces.set(`mcp.${app.name}`, Object.freeze({ + id: `mcp.${app.name}`, + kind: 'mcp-app', + label: app.name, + readOnly: true, + targets: Object.freeze([...app.targets]), + fixtures: Object.freeze([]), + })); + } + } + + #updateSurfaceAssetApps(prepared: Pick): void { + this.#surfaceAssetApps.clear(); + for (const app of prepared.apps) { + this.#surfaceAssetApps.set(`mcp.${app.name}`, app); + } + } + + #surfaceAssetBinding( + generation: RuntimeGeneration, + app: DevRuntimePreparedProject['apps'][number], + ): string | undefined { + const metadata = generation.manifest.metadata; + const exact = metadata.appDefinitions.find((candidate) => + candidate.id === app.id && candidate.resourceUri === app.resourceUri, + ); + if (exact !== undefined) { + const surfaceId = `mcp.${exact.name}`; + return metadata.surfaceAssets[surfaceId] === undefined ? undefined : surfaceId; + } + const matches = metadata.appDefinitions.filter((candidate) => + candidate.resourceUri === app.resourceUri && metadata.surfaceAssets[`mcp.${candidate.name}`] !== undefined, + ); + return matches.length === 1 ? `mcp.${matches[0]!.name}` : undefined; + } + + #vector(generation: RuntimeGeneration, stateVersion = 0): RuntimeVector { + return Object.freeze({ + providerSessionId: this.providerSessionId, + runtimeGenerationId: generation.id, + sourceRevision: generation.sourceRevision, + stateStoreId, + stateVersion, + }); + } + + static async #createOwnedRunsRoot(storageRoot: string, providerSessionId: string): Promise { + await mkdir(storageRoot, { recursive: true }); + const canonicalStorageRoot = await realpath(storageRoot); + const candidate = join(canonicalStorageRoot, 'runs'); + try { + await mkdir(candidate); + } catch (error) { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + if (code === 'EEXIST') { + throw new Error('RSC runtime invocation root already exists and is not owned by this provider session.', { cause: error }); + } + throw error; + } + try { + const root = await realpath(candidate); + const details = await lstat(root); + if (!isInside(canonicalStorageRoot, root) || !details.isDirectory() || details.isSymbolicLink()) { + throw new Error('RSC runtime invocation root is not a contained non-symbolic directory.'); + } + const marker = join(root, '.agent-bundle-runtime-owner'); + const token = `${providerSessionId}:${randomUUID()}`; + await writeFile(marker, token, { flag: 'wx' }); + const markerDetails = await lstat(marker); + if (!markerDetails.isFile() || markerDetails.isSymbolicLink()) { + throw new Error('RSC runtime invocation root ownership marker is unsafe.'); + } + return Object.freeze({ dev: details.dev, ino: details.ino, marker, root, token }); + } catch (error) { + await rm(candidate, { force: true, recursive: true }).catch(() => undefined); + throw error; + } + } + + static async #assertOwnedRunsRoot(owned: OwnedRunsRoot): Promise { + const details = await lstat(owned.root); + if ( + !details.isDirectory() || details.isSymbolicLink() || + details.dev !== owned.dev || details.ino !== owned.ino || + await realpath(owned.root) !== owned.root + ) { + throw new Error('RSC runtime invocation root ownership changed during this provider session.'); + } + const markerDetails = await lstat(owned.marker); + if (!markerDetails.isFile() || markerDetails.isSymbolicLink() || await readFile(owned.marker, 'utf8') !== owned.token) { + throw new Error('RSC runtime invocation root ownership marker changed during this provider session.'); + } + } + + static async #removeOwnedRunsRoot(owned: OwnedRunsRoot): Promise { + await RsbuildRuntimeSession.#assertOwnedRunsRoot(owned); + await rm(owned.root, { force: true, recursive: true }); + } + + #assertCurrentOwnedRunsRoot(): Promise { + return RsbuildRuntimeSession.#assertOwnedRunsRoot(this.#ownedRunsRoot); + } + + #validatePreparedRuntime(prepared: DevRuntimePreparedProject): void { + RsbuildRuntimeSession.#validateStartContext(this.#context, prepared); + } + + static #validateStartContext(context: DevRuntimeStartContext, prepared: DevRuntimePreparedProject): void { + if (prepared.provider !== './src/dev/provider.ts') throw new Error('RSC runtime provider declaration does not match this provider.'); + if (!isInside(context.projectRoot, resolve(context.projectRoot, prepared.provider))) { + throw new Error('RSC runtime provider declaration escapes the project root.'); + } + for (const source of [ + ...prepared.servers.flatMap((server) => [server.cwd, server.source]), + ...prepared.apps.flatMap((app) => [app.source, app.template]), + ]) { + if (source !== undefined && !isInside(context.projectRoot, resolve(context.projectRoot, source))) { + throw new Error('RSC runtime prepared declaration contains a path outside the project root.'); + } + } + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts new file mode 100644 index 000000000..c1f009796 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts @@ -0,0 +1,249 @@ +import { isValidElement, type ReactNode } from 'react'; + +import type { + DevRuntimeInspectionEnvelope, + DevRuntimeTraceSpan, + DevRuntimeTreeNode, +} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; +import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; + +const inspectionStartedAt = '1970-01-01T00:00:00.000Z'; +const flightPreviewBytes = 32 * 1024; + +const stripped = Symbol('inspection-stripped'); +type JsonCandidate = JsonValue | typeof stripped; + +const inspectionJsonError = (message: string): Error => new Error(`Inspection JSON contains ${message}.`); + +const isArrayIndex = (key: string, length: number): boolean => { + if (key === '0') return length > 0; + if (!/^[1-9]\d*$/u.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index < length; +}; + +/** + * Inspection output intentionally drops function and symbol values because they + * cannot cross the JSON boundary. Every other non-JSON shape is rejected so a + * decoded Flight value can never be silently changed while being inspected. + */ +const freezeJson = (value: unknown, references = new WeakSet()): JsonCandidate => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw inspectionJsonError('a non-finite number'); + return value; + } + if (typeof value === 'function' || typeof value === 'symbol') return stripped; + if (typeof value !== 'object') throw inspectionJsonError('a non-JSON value'); + if (references.has(value)) throw inspectionJsonError('a repeated or cyclic value'); + + references.add(value); + if (Array.isArray(value)) { + const keys = Reflect.ownKeys(value); + if ( + keys.length !== value.length + 1 || + keys.some((key) => key !== 'length' && (typeof key !== 'string' || !isArrayIndex(key, value.length))) + ) { + throw inspectionJsonError('a sparse or decorated array'); + } + + const output: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw inspectionJsonError('a sparse array'); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) throw inspectionJsonError('an array accessor'); + const item = freezeJson(descriptor.value, references); + if (item !== stripped) output.push(item); + } + return Object.freeze(output); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw inspectionJsonError('a non-plain object'); + + const output: Record = Object.create(null) as Record; + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) throw inspectionJsonError('a symbol key'); + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw inspectionJsonError('a non-enumerable or accessor property'); + } + const item = freezeJson(descriptor.value, references); + if (item !== stripped) output[key] = item; + } + return Object.freeze(Object.fromEntries(Object.entries(output).sort(([left], [right]) => left.localeCompare(right)))); +}; + +const freezeOptionalJson = (value: unknown): JsonCandidate | undefined => + value === undefined ? undefined : freezeJson(value); + +const labelForElement = (type: unknown): Readonly<{ kind: 'component' | 'element'; label: string }> => { + if (typeof type === 'string') return { kind: 'element', label: type }; + if (typeof type === 'function') { + const component = type as Readonly<{ displayName?: unknown; name?: unknown }>; + return { + kind: 'component', + label: + typeof component.displayName === 'string' && component.displayName.length > 0 + ? component.displayName + : typeof component.name === 'string' && component.name.length > 0 + ? component.name + : 'Anonymous', + }; + } + if (type === Symbol.for('react.fragment')) return { kind: 'element', label: 'Fragment' }; + return { kind: 'element', label: 'Unknown' }; +}; + +const ownDataProperties = (value: unknown, name: string): readonly (readonly [string, unknown])[] => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Inspection tree ${name} must be a plain object.`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`Inspection tree ${name} must be a plain object.`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) throw new Error(`Inspection tree ${name} contains a symbol key.`); + return Object.freeze((keys as string[]).sort().map((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new Error(`Inspection tree ${name} contains a non-enumerable or accessor property.`); + } + return [key, descriptor.value] as const; + })); +}; + +const serializeProps = (value: unknown, references: WeakSet): JsonObject | undefined => { + const output: Record = Object.create(null) as Record; + for (const [key, itemValue] of ownDataProperties(value, 'props')) { + if (key === 'children') continue; + const item = freezeJson(itemValue, references); + if (item !== stripped) output[key] = item; + } + return Object.keys(output).length === 0 ? undefined : Object.freeze(output); +}; + +const childrenFor = (value: unknown): unknown => { + for (const [key, item] of ownDataProperties(value, 'props')) { + if (key === 'children') return item; + } + return undefined; +}; + +const assertTreeArray = (value: readonly unknown[]): void => { + const keys = Reflect.ownKeys(value); + if ( + keys.length !== value.length + 1 || + keys.some((key) => key !== 'length' && (typeof key !== 'string' || !isArrayIndex(key, value.length))) + ) { + throw new Error('Inspection tree contains a sparse or decorated array.'); + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new Error('Inspection tree contains a sparse array.'); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) throw new Error('Inspection tree contains an array accessor.'); + } +}; + +const serializeTree = (node: ReactNode): readonly DevRuntimeTreeNode[] => { + let nextId = 0; + const jsonReferences = new WeakSet(); + const nodes = (value: unknown, ancestors = new WeakSet()): DevRuntimeTreeNode[] => { + if (Array.isArray(value)) { + if (ancestors.has(value)) throw new Error('Inspection tree contains a cyclic value.'); + ancestors.add(value); + try { + assertTreeArray(value); + const output: DevRuntimeTreeNode[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) throw new Error('Inspection tree contains an array accessor.'); + output.push(...nodes(descriptor.value, ancestors)); + } + return output; + } finally { + ancestors.delete(value); + } + } + if (value === undefined || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') return []; + if (value === null || typeof value === 'boolean') { + return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'value', label: String(value) })]; + } + if (typeof value === 'string' || typeof value === 'number') { + return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'text', label: String(value) })]; + } + if (!isValidElement(value)) { + void freezeJson(value, jsonReferences); + return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'value', label: 'Object' })]; + } + + if (ancestors.has(value)) throw new Error('Inspection tree contains a cyclic value.'); + ancestors.add(value); + try { + const id = `node-${nextId++}`; + const element = labelForElement(value.type); + const props = serializeProps(value.props, jsonReferences); + const children = nodes(childrenFor(value.props), ancestors); + return [Object.freeze({ + children: Object.freeze(children), + id, + kind: element.kind, + label: element.label, + ...(props === undefined ? {} : { props }), + })]; + } finally { + ancestors.delete(value); + } + }; + + return Object.freeze(nodes(node)); +}; + +const trace = (): readonly DevRuntimeTraceSpan[] => + Object.freeze(['normalize', 'worker', 'flight', 'decode', 'lower'].map((phase) => Object.freeze({ + id: phase, + phase, + startedAt: inspectionStartedAt, + status: 'succeeded' as const, + }))); + +export interface SerializeInspectionInput { + readonly agentVisible?: unknown; + readonly flight: Uint8Array; + readonly modelVisible?: unknown; + readonly native?: unknown; + readonly node: ReactNode; + readonly protocol?: unknown; + readonly stateStoreId: string; + readonly stateVersion: number; +} + +export const serializeInspection = (input: SerializeInspectionInput): DevRuntimeInspectionEnvelope => { + const stateStoreId = input.stateStoreId.trim(); + if (stateStoreId.length === 0) throw new Error('stateStoreId must be non-empty'); + if (!Number.isSafeInteger(input.stateVersion) || input.stateVersion < 0) { + throw new Error('stateVersion must be a non-negative safe integer'); + } + + const rawFlight = Buffer.from(input.flight); + const agentVisible = freezeOptionalJson(input.agentVisible); + const modelVisible = freezeOptionalJson(input.modelVisible); + const native = freezeOptionalJson(input.native); + const protocol = freezeOptionalJson(input.protocol); + return Object.freeze({ + ...(agentVisible === undefined || agentVisible === stripped ? {} : { agentVisible }), + flight: Object.freeze({ + bytes: rawFlight.byteLength, + preview: rawFlight.subarray(0, flightPreviewBytes).toString('base64'), + truncated: rawFlight.byteLength > flightPreviewBytes, + }), + ...(modelVisible === undefined || modelVisible === stripped ? {} : { modelVisible }), + ...(native === undefined || native === stripped ? {} : { native }), + ...(protocol === undefined || protocol === stripped ? {} : { protocol }), + state: Object.freeze({ identity: Object.freeze({ stateStoreId, stateVersion: input.stateVersion }) }), + trace: trace(), + tree: serializeTree(input.node), + }); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts new file mode 100644 index 000000000..351a8a700 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts @@ -0,0 +1,190 @@ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; +import type { ReactNode } from 'react'; + +import type { RenderRequest } from '../runtime/contracts.js'; +import { redactInspectionDiagnostics } from '../dev/inspection-security.js'; + +export const maximumFlightRenderBytes = 4 * 1024 * 1024; +export const maximumFlightRenderStderrBytes = 256 * 1024; +export const maximumFlightRenderMetadataBytes = 128; + +const defaultTerminationGraceMs = 100; + +export interface FlightRenderResult { + readonly flight: Uint8Array; + readonly node: ReactNode; + /** Exact durable state identity captured by the render worker; never user-visible. */ + readonly stateVersion: number; +} + +export interface FlightRenderOptions { + readonly maximumFlightBytes?: number; + readonly maximumStderrBytes?: number; + readonly signal?: AbortSignal; + readonly terminationGraceMs?: number; +} + +const positiveSafeInteger = (value: number, name: string): number => { + if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`); + return value; +}; + +const workerFailure = (message: string, diagnostics: string): Error => + new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`); + +const parseSnapshotMetadata = (metadata: Buffer): number => { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(metadata); + } catch { + throw new Error('RSC worker emitted invalid snapshot metadata.'); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('RSC worker emitted invalid snapshot metadata.'); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('RSC worker emitted invalid snapshot metadata.'); + } + const record = parsed as Record; + const stateVersion = record.stateVersion; + if ( + Object.keys(record).length !== 1 || + typeof stateVersion !== 'number' || + !Number.isSafeInteger(stateVersion) || + stateVersion < 0 || + text !== `{"stateVersion":${String(stateVersion)}}` + ) { + throw new Error('RSC worker emitted invalid snapshot metadata.'); + } + return stateVersion; +}; + +export const requestFlightRenderWithFlight = async ( + request: RenderRequest, + options: FlightRenderOptions = {}, +): Promise => { + const maximumFlightBytes = positiveSafeInteger(options.maximumFlightBytes ?? maximumFlightRenderBytes, 'maximumFlightBytes'); + const maximumStderrBytes = positiveSafeInteger(options.maximumStderrBytes ?? maximumFlightRenderStderrBytes, 'maximumStderrBytes'); + const terminationGraceMs = positiveSafeInteger(options.terminationGraceMs ?? defaultTerminationGraceMs, 'terminationGraceMs'); + + return new Promise((resolveRender, rejectRender) => { + const currentDirectory = dirname(fileURLToPath(import.meta.url)); + const workerPath = join(currentDirectory, '../rsc/index.js'); + const child = spawn(process.execPath, [workerPath], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); + const stdout = child.stdout; + const stderr = child.stderr; + const snapshotMetadata = child.stdio[3] as NodeJS.ReadableStream | undefined; + const flight: Buffer[] = []; + const diagnostics: Buffer[] = []; + const metadata: Buffer[] = []; + let flightBytes = 0; + let stderrBytes = 0; + let metadataBytes = 0; + let termination: Error | undefined; + let terminationGrace: ReturnType | undefined; + let closed = false; + + const cleanup = (): void => { + if (terminationGrace !== undefined) clearTimeout(terminationGrace); + options.signal?.removeEventListener('abort', abort); + }; + + const terminate = (error: Error): void => { + if (termination !== undefined || closed) return; + termination = error; + child.stdin.destroy(); + child.kill('SIGTERM'); + terminationGrace = setTimeout(() => { + if (!closed) child.kill('SIGKILL'); + }, terminationGraceMs); + }; + + const abort = (): void => terminate(new Error('RSC worker render was aborted.')); + + if (stdout === null || stderr === null || snapshotMetadata === undefined || snapshotMetadata === null) { + terminate(new Error('RSC worker streams are unavailable.')); + } else { + stdout.on('data', (chunk: Buffer | string) => { + if (termination !== undefined) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + flightBytes += buffer.byteLength; + if (flightBytes > maximumFlightBytes) { + terminate(new Error(`RSC worker Flight exceeded ${maximumFlightBytes} bytes.`)); + return; + } + flight.push(buffer); + }); + stdout.once('error', () => terminate(new Error('RSC worker Flight stream failed.'))); + stderr.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const retained = Math.min(buffer.byteLength, Math.max(0, maximumStderrBytes - stderrBytes)); + if (retained > 0) diagnostics.push(buffer.subarray(0, retained)); + stderrBytes += buffer.byteLength; + if (stderrBytes > maximumStderrBytes) { + terminate(new Error(`RSC worker stderr exceeded ${maximumStderrBytes} bytes.`)); + } + }); + stderr.once('error', () => terminate(new Error('RSC worker stderr stream failed.'))); + snapshotMetadata.on('data', (chunk: Buffer | string) => { + if (termination !== undefined) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + metadataBytes += buffer.byteLength; + if (metadataBytes > maximumFlightRenderMetadataBytes) { + terminate(new Error(`RSC worker snapshot metadata exceeded ${maximumFlightRenderMetadataBytes} bytes.`)); + return; + } + metadata.push(buffer); + }); + snapshotMetadata.once('error', () => terminate(new Error('RSC worker snapshot metadata stream failed.'))); + } + + child.stdin.once('error', () => terminate(new Error('RSC worker request stream failed.'))); + child.once('error', () => terminate(new Error('RSC worker could not be started.'))); + child.once('close', (code) => { + closed = true; + cleanup(); + const output = redactInspectionDiagnostics(Buffer.concat(diagnostics).toString('utf8')); + if (termination !== undefined) { + rejectRender(workerFailure(termination.message, output)); + return; + } + if (code !== 0) { + rejectRender(workerFailure(`RSC worker exited with code ${String(code)}`, output)); + return; + } + void (async () => { + try { + const rawFlight = Buffer.concat(flight); + const node = await createFromReadableStream( + Readable.toWeb(Readable.from([rawFlight])) as ReadableStream, + ); + resolveRender(Object.freeze({ flight: rawFlight, node, stateVersion: parseSnapshotMetadata(Buffer.concat(metadata)) })); + } catch { + rejectRender(new Error('RSC worker emitted invalid Flight data.')); + } + })(); + }); + + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) { + abort(); + return; + } + try { + child.stdin.end(JSON.stringify(request)); + } catch { + terminate(new Error('RSC worker request could not be encoded.')); + } + }); +}; + +export const requestFlightRender = async (request: RenderRequest): Promise => + (await requestFlightRenderWithFlight(request)).node; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts new file mode 100644 index 000000000..f9b7ac3be --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts @@ -0,0 +1,86 @@ +import { appendFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { requestFlightRender } from '../flight/request-render.js'; +import { lowerHookResult } from '@agent-bundle/rsc-runtime'; +import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js'; +import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js'; + +let probeInput: Record | undefined; + +const valueType = (value: unknown): string => { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +}; + +const writeEvalProbe = async (input: Record, exitStatus: number): Promise => { + const probeFile = process.env.AGENT_RUNTIME_HOOK_PROBE_FILE; + if (probeFile === undefined || probeFile.trim() === '') return; + + const toolInput = input.tool_input; + const toolInputRecord = toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput) + ? toolInput as Record + : undefined; + const topLevelKeys = Object.keys(input).sort(); + const toolInputKeys = toolInputRecord === undefined ? [] : Object.keys(toolInputRecord).sort(); + await appendFile(probeFile, `${JSON.stringify({ + commandLaunched: true, + exitStatus, + toolInputKeys, + toolInputValueTypes: Object.fromEntries(toolInputKeys.map((key) => [key, valueType(toolInputRecord?.[key])])), + toolName: typeof input.tool_name === 'string' ? input.tool_name : undefined, + topLevelKeys, + topLevelValueTypes: Object.fromEntries(topLevelKeys.map((key) => [key, valueType(input[key])])), + })}\n`); +}; + +const readInput = async (): Promise> => { + let contents = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) { + contents += chunk; + } + + const parsed: unknown = JSON.parse(contents); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('Native hook input must be a JSON object'); + } + + return parsed as Record; +}; + +const readHost = (): 'claude' | 'codex' => { + const host = process.argv[process.argv.indexOf('--host') + 1]; + if (host !== 'claude' && host !== 'codex') { + throw new Error('Expected --host claude or codex'); + } + + return host; +}; + +const run = async (): Promise => { + const host = readHost(); + const input = await readInput(); + probeInput = input; + const event = host === 'claude' ? normalizeClaudeHook(input) : normalizeCodexHook(input); + const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE; + const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === '' + ? await resolveImplicitRuntimeStateFile(event.cwd) + : resolve(configuredStateFile); + + const result = await requestFlightRender({ + event, + stateFile, + type: 'hook/after-file-edit', + }); + process.stdout.write(`${JSON.stringify(lowerHookResult(result))}\n`); + await writeEvalProbe(input, 0); +}; + +run().catch(async (error: unknown) => { + if (probeInput !== undefined) await writeEvalProbe(probeInput, 1).catch(() => undefined); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts new file mode 100644 index 000000000..1c62ec94c --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts @@ -0,0 +1,93 @@ +import { resolve } from 'node:path'; + +import type { CanonicalPostToolUse } from '../runtime/contracts.js'; + +type NativeHookInput = Record; + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' ? (value as Record) : undefined; + +const readString = (input: NativeHookInput, key: string): string | undefined => + typeof input[key] === 'string' ? input[key] : undefined; + +const readRequiredString = (input: NativeHookInput, key: string): string => { + const value = readString(input, key); + if (value === undefined || value.trim() === '') { + throw new Error(`Native hook input requires ${key}`); + } + + return value; +}; + +const readIdempotencyKey = (host: CanonicalPostToolUse['host'], input: NativeHookInput): string => { + const toolUseId = readString(input, 'tool_use_id')?.trim(); + if (toolUseId !== undefined && toolUseId !== '') { + return `${host}:tool:${toolUseId}`; + } + + const eventId = readString(input, 'event_id')?.trim(); + if (eventId !== undefined && eventId !== '') { + return `${host}:event:${eventId}`; + } + + throw new Error('Mutating native hook input requires a nonempty tool_use_id or event_id'); +}; + +const readBaseEvent = (host: CanonicalPostToolUse['host'], input: NativeHookInput) => { + if (readRequiredString(input, 'hook_event_name') !== 'PostToolUse') { + throw new Error('Only PostToolUse events are supported'); + } + + return { + cwd: readRequiredString(input, 'cwd'), + host, + idempotencyKey: readIdempotencyKey(host, input), + sessionId: readRequiredString(input, 'session_id'), + toolName: readRequiredString(input, 'tool_name'), + }; +}; + +const resolveNativePath = (cwd: string, path: string): string => { + if (path.trim() === '') { + throw new Error('Native hook input requires a file path'); + } + + return resolve(cwd, path); +}; + +export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => { + const event = readBaseEvent('claude', input); + if (event.toolName !== 'Write' && event.toolName !== 'Edit') { + throw new Error('Claude hook supports only Write and Edit'); + } + + const toolInput = asRecord(input.tool_input); + if (toolInput === undefined) { + throw new Error('Native hook input requires tool_input'); + } + + return { + ...event, + path: resolveNativePath(event.cwd, readRequiredString(toolInput, 'file_path')), + }; +}; + +export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => { + const event = readBaseEvent('codex', input); + if (event.toolName !== 'apply_patch') { + throw new Error('Codex hook supports only apply_patch'); + } + + const toolInput = asRecord(input.tool_input); + if (toolInput === undefined) { + throw new Error('Native hook input requires tool_input'); + } + + const command = readRequiredString(toolInput, 'command'); + const path = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/m.exec(command)?.[1]; + if (path === undefined) { + throw new Error('Codex apply_patch command requires a file header'); + } + + return { ...event, path: resolveNativePath(event.cwd, path) }; +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts new file mode 100644 index 000000000..63bec7f4b --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts @@ -0,0 +1,76 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { runtimeDefinition } from '../definition.js'; +import { createMcpHandlers } from './handlers.js'; +import { resourceMetadata } from './host-metadata.js'; +import type { McpRequestExtra, ResolveStateOptions } from './resolve-state.js'; + +export interface CreateRuntimeMcpServerOptions extends ResolveStateOptions { + publicMcpUrl?: string; + widgetHtml?: string; +} + +const defaultWidgetPath = (): string => + join(dirname(process.argv[1] ?? process.cwd()), '../../app/edit-timeline-v1.html'); + +const defaultWidgetHtml = async (): Promise => readFile(defaultWidgetPath(), 'utf8'); + +export const createRuntimeMcpServer = (options: CreateRuntimeMcpServerOptions = {}): McpServer => { + const server = new McpServer({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); + const handlers = createMcpHandlers(options); + + for (const tool of runtimeDefinition.tools) { + const handler = handlers[tool.handlerId]; + if (handler === undefined) { + throw new Error(`No MCP handler registered for ${tool.handlerId}`); + } + + const callback = (input: unknown, extra: McpRequestExtra) => + handler( + input !== null && typeof input === 'object' && typeof (input as { limit?: unknown }).limit === 'number' + ? { limit: (input as { limit: number }).limit } + : {}, + extra, + ); + const config = { + _meta: tool._meta, + annotations: tool.annotations, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + }; + + if (tool._meta.ui !== undefined) { + registerAppTool(server, tool.name, config, callback); + } else { + server.registerTool(tool.name, config, callback); + } + } + + for (const resource of runtimeDefinition.resources) { + const registrationMetadata = resourceMetadata(resource); + const contentMetadata = resourceMetadata(resource, options.publicMcpUrl); + registerAppResource( + server, + resource.name, + resource.uri, + { _meta: registrationMetadata, mimeType: RESOURCE_MIME_TYPE }, + async () => ({ + contents: [ + { + _meta: contentMetadata, + mimeType: RESOURCE_MIME_TYPE, + text: options.widgetHtml ?? (await defaultWidgetHtml()), + uri: resource.uri, + }, + ], + }), + ); + } + + return server; +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts new file mode 100644 index 000000000..b4dadd3b9 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts @@ -0,0 +1,33 @@ +import { createFileRuntimeKernel } from '../runtime/state-file.js'; +import { lowerMcpResult } from '@agent-bundle/rsc-runtime'; +import { requestFlightRender } from '../flight/request-render.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +import { resolveStateFile, type McpRequestExtra, type ResolveStateOptions } from './resolve-state.js'; + +type ToolInput = { limit?: number }; +type McpToolHandler = (input: ToolInput, extra: McpRequestExtra) => Promise; + +const textSnapshot = (snapshot: { edits: unknown[]; stateVersion: number }): CallToolResult => ({ + content: [{ text: JSON.stringify(snapshot), type: 'text' }], + structuredContent: snapshot, +}); + +export const createMcpHandlers = (options: ResolveStateOptions): Record => ({ + recent_edits: async (input, extra) => { + const stateFile = await resolveStateFile(options, extra); + const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit }); + return textSnapshot(snapshot); + }, + render_edit_timeline: async (input, extra) => { + const stateFile = await resolveStateFile(options, extra); + const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit }); + return lowerMcpResult( + await requestFlightRender({ snapshot, stateFile, type: 'mcp/render-timeline' }), + ); + }, + runtime_status: async (_input, extra) => { + const stateFile = await resolveStateFile(options, extra); + return lowerMcpResult(await requestFlightRender({ stateFile, type: 'mcp/runtime-status' })); + }, +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts new file mode 100644 index 000000000..c6e93e22d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; + +import type { RuntimeResourceDefinition } from '../runtime/contracts.js'; + +export type SerializableValue = null | boolean | number | string | SerializableValue[] | { [key: string]: SerializableValue }; +export type SerializableMetadata = Record; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const cloneSerializableValue = (value: unknown, seen: Set = new Set()): SerializableValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (value === null || typeof value !== 'object' || seen.has(value)) { + throw new Error('Metadata must be JSON-serializable'); + } + + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => cloneSerializableValue(item, seen)); + } + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + throw new Error('Metadata must be JSON-serializable'); + } + + const clone: SerializableMetadata = Object.create(null) as SerializableMetadata; + for (const [key, item] of Object.entries(value)) { + clone[key] = cloneSerializableValue(item, seen); + } + return clone; + } finally { + seen.delete(value); + } +}; + +/** + * Copies extension metadata verbatim while enforcing the portable JSON boundary. + * Namespaces are deliberately opaque to this runtime. + */ +export const mergeSerializableMetadata = (...values: Array | undefined>): SerializableMetadata => { + const result: SerializableMetadata = Object.create(null) as SerializableMetadata; + for (const value of values) { + if (value === undefined) { + continue; + } + if (!isRecord(value)) { + throw new Error('Metadata must be a JSON object'); + } + for (const [key, item] of Object.entries(value)) { + result[key] = cloneSerializableValue(item); + } + } + return result; +}; + +export const claudeStableAppDomain = (publicMcpUrl: string): string => { + const parsed = new URL(publicMcpUrl); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('Public MCP URL must use HTTP or HTTPS'); + } + + return `${createHash('sha256').update(publicMcpUrl).digest('hex').slice(0, 32)}.claudemcpcontent.com`; +}; + +/** + * Converts the definition's portable resource fields into the MCP Apps shape. + * The Claude domain is opt-in and belongs only to returned resource content. + */ +export const resourceMetadata = ( + resource: RuntimeResourceDefinition, + publicMcpUrl?: string, +): SerializableMetadata => { + const source = mergeSerializableMetadata(resource._meta); + const csp = source['ui.csp']; + const prefersBorder = source['ui.prefersBorder']; + const existingUi = isRecord(source.ui) ? source.ui : undefined; + delete source['ui.csp']; + delete source['ui.prefersBorder']; + delete source.ui; + + return mergeSerializableMetadata(source, { + ui: mergeSerializableMetadata(existingUi, { + ...(csp === undefined ? {} : { csp }), + ...(prefersBorder === undefined ? {} : { prefersBorder }), + ...(publicMcpUrl === undefined ? {} : { domain: claudeStableAppDomain(publicMcpUrl) }), + }), + }); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts new file mode 100644 index 000000000..96a3d48c3 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts @@ -0,0 +1,84 @@ +const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; + +export interface HttpSecurityConfig { + allowedHosts: string[]; + allowedOrigins: string[]; +} + +const valuesFromEnvironment = (value: string | undefined, name: string): string[] => { + const values = value + ?.split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry !== '') ?? []; + + if (values.includes('*')) { + throw new Error(`${name} must not include a wildcard`); + } + + return values; +}; + +const normalizeHostname = (value: string, name: string): string => { + try { + const hostname = new URL(`http://${value}`).hostname; + if (hostname !== value.toLowerCase()) { + throw new Error('hostnames must not include a port'); + } + + return hostname; + } catch { + throw new Error(`${name} contains an invalid hostname: ${value}`); + } +}; + +const normalizeOrigin = (value: string, name: string): string => { + try { + const origin = new URL(value); + if (!['http:', 'https:'].includes(origin.protocol) || origin.origin !== value) { + throw new Error('origins must be exact HTTP(S) origins'); + } + + return origin.origin; + } catch { + throw new Error(`${name} contains an invalid origin: ${value}`); + } +}; + +const sameHttpOrigin = (hostHeader: string | undefined): string | undefined => { + if (hostHeader === undefined) { + return undefined; + } + + try { + return new URL(`http://${hostHeader}`).origin; + } catch { + return undefined; + } +}; + +export const resolveHttpSecurityConfig = (environment: NodeJS.ProcessEnv = process.env): HttpSecurityConfig => ({ + allowedHosts: [ + ...new Set([ + ...loopbackHosts, + ...valuesFromEnvironment(environment.AGENT_RUNTIME_ALLOWED_HOSTS, 'AGENT_RUNTIME_ALLOWED_HOSTS').map((value) => + normalizeHostname(value, 'AGENT_RUNTIME_ALLOWED_HOSTS'), + ), + ]), + ], + allowedOrigins: [ + ...new Set( + valuesFromEnvironment(environment.AGENT_RUNTIME_ALLOWED_ORIGINS, 'AGENT_RUNTIME_ALLOWED_ORIGINS').map((value) => + normalizeOrigin(value, 'AGENT_RUNTIME_ALLOWED_ORIGINS'), + ), + ), + ], +}); + +export const allowsOrigin = ( + config: HttpSecurityConfig, + hostHeader: string | undefined, + originHeader: string | undefined, +): boolean => + originHeader === undefined || + originHeader === sameHttpOrigin(hostHeader) || + config.allowedOrigins.includes(originHeader); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts new file mode 100644 index 000000000..87359817d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts @@ -0,0 +1,55 @@ +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +import { createRuntimeMcpServer } from './create-server.js'; +import { allowsOrigin, resolveHttpSecurityConfig } from './http-security.js'; + +const port = Number.parseInt(process.env.PORT ?? '3000', 10); +const security = resolveHttpSecurityConfig(); +const app = createMcpExpressApp({ allowedHosts: security.allowedHosts }); + +app.use((request, response, next) => { + if (allowsOrigin(security, request.get('host'), request.get('origin'))) { + next(); + return; + } + + response.status(403).json({ + error: { code: -32000, message: `Invalid Origin header: ${request.get('origin')}` }, + id: null, + jsonrpc: '2.0', + }); +}); + +app.get('/health', (_request, response) => { + response.json({ ok: true, transport: 'streamable-http' }); +}); + +app.post('/mcp', async (request, response) => { + const server = createRuntimeMcpServer({ publicMcpUrl: process.env.AGENT_RUNTIME_PUBLIC_MCP_URL }); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + + try { + await server.connect(transport); + await transport.handleRequest(request, response, request.body); + } catch (error) { + if (!response.headersSent) { + response.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } + } finally { + await server.close(); + } +}); + +const httpServer = app.listen(port, '127.0.0.1', () => { + const address = httpServer.address(); + const actualPort = typeof address === 'object' && address !== null ? address.port : port; + process.stderr.write(`${JSON.stringify({ port: actualPort, transport: 'streamable-http' })}\n`); +}); + +const close = (): void => { + httpServer.close(() => process.exit(0)); +}; + +process.once('SIGINT', close); +process.once('SIGTERM', close); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts new file mode 100644 index 000000000..ed65653e2 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +import { ListRootsResultSchema, type ServerNotification, type ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; + +import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js'; + +export type McpRequestExtra = RequestHandlerExtra; + +export interface ResolveStateOptions { + stateFile?: string; + resolveStateFile?: (extra: McpRequestExtra) => string | undefined | Promise; +} + +const usablePath = (value: string | undefined): string | undefined => + value === undefined || value.trim() === '' ? undefined : resolve(value); + +const stateFileFromRoots = async (extra: McpRequestExtra): Promise => { + try { + const result = await extra.sendRequest({ method: 'roots/list' }, ListRootsResultSchema); + const root = result.roots[0]; + if (root === undefined) { + return undefined; + } + + return resolveImplicitRuntimeStateFile(fileURLToPath(root.uri)); + } catch { + return undefined; + } +}; + +export const resolveStateFile = async (options: ResolveStateOptions, extra: McpRequestExtra): Promise => { + const resolvedByOption = options.resolveStateFile === undefined ? undefined : await options.resolveStateFile(extra); + const explicit = usablePath(resolvedByOption) ?? usablePath(options.stateFile); + if (explicit !== undefined) { + return explicit; + } + + const fromEnvironment = usablePath(process.env.AGENT_RUNTIME_STATE_FILE); + if (fromEnvironment !== undefined) { + return fromEnvironment; + } + + const fromRoots = await stateFileFromRoots(extra); + if (fromRoots !== undefined) { + return fromRoots; + } + + return resolveImplicitRuntimeStateFile(process.cwd()); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts new file mode 100644 index 000000000..6676b5014 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts @@ -0,0 +1,14 @@ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import { createRuntimeMcpServer } from './create-server.js'; + +const run = async (): Promise => { + const server = createRuntimeMcpServer(); + await server.connect(new StdioServerTransport()); +}; + +run().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts new file mode 100644 index 000000000..26d9bbca7 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts @@ -0,0 +1,3 @@ +'use client'; + +export const clientAnchor = true; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx new file mode 100644 index 000000000..78147e3b6 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx @@ -0,0 +1,41 @@ +import { basename } from 'node:path'; + +import { Hook, Mcp } from '@agent-bundle/rsc-runtime'; +import type { RuntimeSnapshot } from '../runtime/contracts.js'; +import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; + +export const AfterFileEdit = () => { + const edit = useEdit(); + const snapshot = useRuntimeSnapshot(); + const editCount = snapshot.stateVersion; + const editNoun = editCount === 1 ? 'edit' : 'edits'; + + return ( + + + {`Recorded ${basename(edit.path)} from ${edit.host}. Shared state now contains ${editCount} ${editNoun}.`} + + + ); +}; + +export const RenderEditTimeline = ({ snapshot }: { snapshot: RuntimeSnapshot }) => ( + + {`Showing ${snapshot.edits.length} recorded edits.`} + +); + +const STATUS_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'; + +export const RuntimeStatus = ({ snapshot }: { snapshot: RuntimeSnapshot }) => { + const editCount = snapshot.edits.length; + const editNoun = editCount === 1 ? 'edit' : 'edits'; + + return ( + + {`Runtime state contains ${editCount} ${editNoun}.`} + + + ); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx new file mode 100644 index 000000000..735d5efff --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +import type { RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; +import { AfterFileEdit, RenderEditTimeline, RuntimeStatus } from './components.js'; + +export const renderRoute = (request: RenderRequest, snapshot: RuntimeSnapshot): ReactNode => { + if (request.type === 'hook/after-file-edit') { + return ; + } + + if (request.type === 'mcp/render-timeline') { + return ; + } + + if (request.type === 'mcp/runtime-status') { + return ; + } + + throw new Error('Unsupported RSC render request'); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx new file mode 100644 index 000000000..34dc6d6e4 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx @@ -0,0 +1,149 @@ +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; +import { resolve } from 'node:path'; +import { writeSync } from 'node:fs'; + +import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; + +import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; +import { withRenderContext } from '../runtime/request-context.js'; +import { createFileRuntimeKernel } from '../runtime/state-file.js'; +import { renderRoute } from './routes.js'; + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' ? (value as Record) : undefined; + +const readString = (value: Record, key: string): string | undefined => + typeof value[key] === 'string' ? value[key] : undefined; + +const readRequiredString = (value: Record, key: string): string => { + const result = readString(value, key); + if (result === undefined || result.trim() === '') { + throw new Error(`RSC worker requires a nonempty ${key}`); + } + return result; +}; + +const parseEvent = (value: unknown): CanonicalPostToolUse => { + const event = asRecord(value); + if (event === undefined) { + throw new Error('RSC worker received an invalid event'); + } + + const host = readString(event, 'host'); + if (host !== 'claude' && host !== 'codex') { + throw new Error('RSC worker received an invalid event'); + } + return { + cwd: readRequiredString(event, 'cwd'), + host, + idempotencyKey: readRequiredString(event, 'idempotencyKey'), + path: readRequiredString(event, 'path'), + sessionId: readRequiredString(event, 'sessionId'), + toolName: readRequiredString(event, 'toolName'), + }; +}; + +const parseSnapshot = (value: unknown): RuntimeSnapshot => { + const snapshot = asRecord(value); + const stateVersion = snapshot?.stateVersion; + if ( + snapshot === undefined || + typeof stateVersion !== 'number' || + !Number.isInteger(stateVersion) || + stateVersion < 0 || + !Array.isArray(snapshot.edits) + ) { + throw new Error('RSC worker received an invalid runtime snapshot'); + } + + return snapshot.seed === undefined + ? { edits: snapshot.edits as RuntimeSnapshot['edits'], stateVersion } + : { edits: snapshot.edits as RuntimeSnapshot['edits'], seed: snapshot.seed as RuntimeSnapshot['seed'], stateVersion }; +}; + +const parseRequest = (value: unknown): RenderRequest => { + const request = asRecord(value); + if (request === undefined) { + throw new Error('RSC worker received an unsupported render request'); + } + + const stateFile = readRequiredString(request, 'stateFile'); + + if (request.type === 'hook/after-file-edit') { + return { + event: parseEvent(request.event), + stateFile: resolve(stateFile), + type: 'hook/after-file-edit', + }; + } + + if (request.type === 'mcp/render-timeline') { + return { + snapshot: parseSnapshot(request.snapshot), + stateFile: resolve(stateFile), + type: 'mcp/render-timeline', + }; + } + + if (request.type === 'mcp/runtime-status') { + return { stateFile: resolve(stateFile), type: 'mcp/runtime-status' }; + } + + throw new Error('RSC worker received an unsupported render request'); +}; + +const readRequest = async (): Promise => { + let contents = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) { + contents += chunk; + } + + return parseRequest(JSON.parse(contents)); +}; + +const render = async (): Promise => { + const request = await readRequest(); + const runtime = createFileRuntimeKernel({ stateFile: request.stateFile }); + const snapshot = + request.type === 'hook/after-file-edit' + ? await runtime.recordEdit({ + host: request.event.host, + idempotencyKey: request.event.idempotencyKey, + path: request.event.path, + sessionId: request.event.sessionId, + toolName: request.event.toolName, + }) + : request.type === 'mcp/render-timeline' + ? request.snapshot + : await runtime.readSnapshot(); + + const renderFlight = async (): Promise => { + const flight = renderToReadableStream(renderRoute(request, snapshot)); + const output = Readable.from(flight); + output.pipe(process.stdout, { end: false }); + await finished(output); + }; + + const writeSnapshotMetadata = (): void => { + const metadata = Buffer.from(`{"stateVersion":${String(snapshot.stateVersion)}}`, 'utf8'); + let offset = 0; + while (offset < metadata.byteLength) { + offset += writeSync(3, metadata, offset, metadata.byteLength - offset); + } + }; + + if (request.type === 'hook/after-file-edit') { + await withRenderContext({ edit: request.event, snapshot }, renderFlight); + } else { + await renderFlight(); + } + writeSnapshotMetadata(); +}; + +render().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts new file mode 100644 index 000000000..bfb5dc9c7 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts @@ -0,0 +1,216 @@ +import type { ZodType } from 'zod'; +import type { + DevRuntimeInspectionEnvelope, + DevRuntimeMcpServerDescriptor, +} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; +import type { JsonObject } from '../../../../packages/agent-bundle/src/dev/types.ts'; + +export interface EditEvent { + eventId: string; + host: 'claude' | 'codex'; + sessionId: string; + toolName: string; + path: string; + recordedAt: string; +} + +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | Readonly<{ [key: string]: JsonValue }>; + +export type RuntimeStateRecord = + | Readonly<{ + event: EditEvent; + idempotencyKey: string; + kind: 'edit'; + stateVersion: number; + }> + | Readonly<{ + idempotencyKey: string; + kind: 'reset'; + seed?: JsonValue; + stateVersion: number; + }>; + +export interface RuntimeSnapshot { + stateVersion: number; + edits: EditEvent[]; + readonly seed?: JsonValue; +} + +export interface RuntimeKernel { + recordEdit( + input: Omit & Readonly<{ idempotencyKey: string }>, + options?: RuntimeMutationOptions, + ): Promise; + resetState( + input: Readonly<{ idempotencyKey: string; seed?: JsonValue }>, + options?: RuntimeMutationOptions, + ): Promise; + readSnapshot(options?: RuntimeSnapshotReadOptions): Promise; +} + +/** Internal durable-state read options; this is not part of the runtime provider protocol. */ +export interface RuntimeSnapshotReadOptions { + readonly limit?: number; + /** Reconstruct the exact validated durable prefix at this version. */ + readonly stateVersion?: number; +} + +export interface RuntimeMutationOptions { + /** Bounded caller wait for an existing owner; lock timing itself is never caller-configurable. */ + lockAcquireTimeoutMs?: number; + signal?: AbortSignal; +} + +export interface CanonicalPostToolUse { + host: 'claude' | 'codex'; + idempotencyKey: string; + sessionId: string; + cwd: string; + toolName: string; + path: string; +} + +export interface HookRenderRequest { + type: 'hook/after-file-edit'; + stateFile: string; + event: CanonicalPostToolUse; +} + +export interface McpRenderTimelineRequest { + type: 'mcp/render-timeline'; + stateFile: string; + snapshot: RuntimeSnapshot; +} + +export interface McpRuntimeStatusRequest { + type: 'mcp/runtime-status'; + stateFile: string; +} + +export type RenderRequest = HookRenderRequest | McpRenderTimelineRequest | McpRuntimeStatusRequest; + +export interface DevRuntimeHookInspectionRequest { + readonly host: 'claude' | 'codex'; + readonly input: Readonly>; + readonly stateFile: string; + readonly stateStoreId: string; + readonly type: 'hook/after-file-edit'; +} + +export interface DevRuntimeMcpTimelineInspectionRequest { + readonly snapshot: RuntimeSnapshot; + readonly stateFile: string; + readonly stateStoreId: string; + readonly type: 'mcp/render-timeline'; +} + +export interface DevRuntimeMcpStatusInspectionRequest { + readonly stateFile: string; + readonly stateStoreId: string; + readonly type: 'mcp/runtime-status'; +} + +export type DevRuntimeInspectionRequest = + | DevRuntimeHookInspectionRequest + | DevRuntimeMcpTimelineInspectionRequest + | DevRuntimeMcpStatusInspectionRequest; + +export interface DevRuntimeInspectionResponse { + /** Raw Flight bytes are sent over the provider-owned fd 3 side channel. */ + readonly flightBytes: number; + readonly inspection: DevRuntimeInspectionEnvelope; +} + +export type McpTimeline = RuntimeSnapshot; + +export interface ToolAnnotations { + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; +} + +export interface RuntimeToolDefinition { + name: string; + description: string; + inputSchema: ZodType; + outputSchema: ZodType; + annotations: ToolAnnotations; + handlerId: string; + _meta: Record; +} + +export interface NativeHookDefinition { + host: 'claude' | 'codex'; + event: 'PostToolUse' | 'after_tool_use'; + matcher: string; + handlerId: string; +} + +export interface RuntimeResourceDefinition { + name: string; + uri: string; + mimeType: string; + _meta: Record & { + 'ui.prefersBorder': true; + 'ui.csp': { + connectDomains: []; + resourceDomains: []; + }; + 'openai/widgetDescription': string; + }; +} + +export interface RuntimeDefinition { + tools: RuntimeToolDefinition[]; + nativeHooks: NativeHookDefinition[]; + resources: RuntimeResourceDefinition[]; +} + +export interface SerializedRuntimeToolDefinition extends Omit { + inputSchema: Record; + outputSchema: Record; +} + +export interface SerializedRuntimeDefinition { + tools: SerializedRuntimeToolDefinition[]; + nativeHooks: NativeHookDefinition[]; + resources: RuntimeResourceDefinition[]; +} + +export interface RscRuntimeSurfaceAsset { + readonly bytes: number; + readonly contentType: 'application/javascript' | 'application/json' | 'text/css' | 'text/html'; + readonly generationPath: string; + readonly requestPath: string; + readonly sha256: string; +} + +export interface RscRuntimeAppDefinition { + readonly _meta?: JsonObject; + readonly id: string; + readonly name: string; + readonly resourceUri: string; + readonly serverId: string; + readonly serverName: string; + readonly targets: readonly string[]; +} + +export interface RscRuntimeGenerationMetadata { + readonly appDefinitions: readonly RscRuntimeAppDefinition[]; + readonly definitionDigest: string; + readonly entries: Readonly>; + readonly environmentHashes: Readonly>; + readonly preparedRevision: string; + readonly serverDigest: string; + readonly servers: readonly DevRuntimeMcpServerDescriptor[]; + readonly stateStoreId: string; + readonly surfaceAssets: Readonly>; + readonly transportDigest: string; +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts new file mode 100644 index 000000000..4315709dc --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts @@ -0,0 +1,17 @@ +import { createRscRequestContext } from '@agent-bundle/rsc-runtime'; + +import type { CanonicalPostToolUse, RuntimeSnapshot } from './contracts.js'; + +export interface RenderContext { + edit: CanonicalPostToolUse; + snapshot: RuntimeSnapshot; +} + +const renderContext = createRscRequestContext('RSC runtime hook'); + +export const withRenderContext = (context: RenderContext, operation: () => T): T => + renderContext.run(context, operation); + +export const useEdit = (): CanonicalPostToolUse => renderContext.use().edit; + +export const useRuntimeSnapshot = (): RuntimeSnapshot => renderContext.use().snapshot; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts new file mode 100644 index 000000000..a2e69df41 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts @@ -0,0 +1,781 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { lock as acquireLockfile } from 'proper-lockfile'; + +import type { + EditEvent, + JsonValue, + RuntimeKernel, + RuntimeMutationOptions, + RuntimeSnapshot, + RuntimeSnapshotReadOptions, + RuntimeStateRecord, +} from './contracts.js'; + +export const MAX_STATE_BYTES = 16 * 1024 * 1024; + +export class RuntimeStateCorruptionError extends Error { + readonly line: number; + readonly offset: number; + + constructor({ line, message, offset }: { line: number; message: string; offset: number }) { + super(`Runtime state corruption at line ${line}, byte ${offset}: ${message}`); + this.name = 'RuntimeStateCorruptionError'; + this.line = line; + this.offset = offset; + } +} + +export class RuntimeStateLockError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'RuntimeStateLockError'; + } +} + +export interface StateKernelPolicy { + readonly acquireLimitMs: number; + readonly mutationMs: number; + readonly ownerSettlementMs: number; + readonly releaseMs: number; + readonly retryDelayMs: number; + readonly staleMs: number; + readonly updateMs: number; + readonly terminateOwner: (error: RuntimeStateLockError) => void; +} + +export type StateLeaseRelease = () => Promise; + +export interface StateStorage { + readonly acquire: (input: Readonly<{ + onCompromised: (error: Error) => void; + stale: number; + stateFile: string; + update: number; + }>) => Promise; + readonly append: (stateFile: string, contents: Buffer, signal: AbortSignal) => Promise; + readonly prepare: (stateFile: string, signal: AbortSignal) => Promise; + readonly read: (stateFile: string, signal: AbortSignal) => Promise; + readonly readOwnerStaleMs: (stateFile: string, signal: AbortSignal) => Promise; + readonly removeOwner: (stateFile: string, signal: AbortSignal) => Promise; + readonly repair: (stateFile: string, completeBytes: number, signal: AbortSignal) => Promise; + readonly writeOwner: (stateFile: string, staleMs: number, signal: AbortSignal) => Promise; +} + +export interface StateKernelInput { + readonly createId?: () => string; + readonly now?: () => Date; + readonly policy: StateKernelPolicy; + readonly stateFile: string; + readonly storage: StateStorage; +} + +interface ParsedState { + readonly completeBytes: number; + readonly records: readonly RuntimeStateRecord[]; + readonly snapshot: RuntimeSnapshot; +} + +interface OperationOwner { + readonly controller: AbortController; + unsafeToRelease: boolean; +} + +type Settled = + | Readonly<{ type: 'error'; error: Error }> + | Readonly<{ type: 'value'; value: T }>; + +const asRecord = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; + +const hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => { + const actualKeys = Object.keys(value).sort(); + const expectedKeys = [...keys].sort(); + return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); +}; + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''; + +const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + const record = asRecord(value); + return record !== undefined && Object.values(record).every(isJsonValue); +}; + +const isEditEvent = (value: unknown): value is EditEvent => { + const event = asRecord(value); + return ( + event !== undefined && + hasOnlyKeys(event, ['eventId', 'host', 'path', 'recordedAt', 'sessionId', 'toolName']) && + isNonEmptyString(event.eventId) && + (event.host === 'claude' || event.host === 'codex') && + isNonEmptyString(event.sessionId) && + isNonEmptyString(event.toolName) && + isNonEmptyString(event.path) && + isNonEmptyString(event.recordedAt) + ); +}; + +const canonicalize = (value: JsonValue): string => { + if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + const object = value as Readonly>; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`).join(',')}}`; +}; + +const canonicalRecordInput = (record: RuntimeStateRecord): string => + record.kind === 'edit' + ? canonicalize({ + event: { + host: record.event.host, + path: record.event.path, + sessionId: record.event.sessionId, + toolName: record.event.toolName, + }, + kind: 'edit', + }) + : canonicalize(record.seed === undefined ? { kind: 'reset' } : { kind: 'reset', seed: record.seed }); + +const parseStateRecord = ({ line, offset, value }: { line: number; offset: number; value: unknown }): RuntimeStateRecord => { + const record = asRecord(value); + const stateVersion = record?.stateVersion; + if ( + record === undefined || + !isNonEmptyString(record.idempotencyKey) || + typeof stateVersion !== 'number' || + !Number.isInteger(stateVersion) || + stateVersion < 1 + ) { + throw new RuntimeStateCorruptionError({ line, message: 'record shape is invalid', offset }); + } + if (record.kind === 'edit') { + if (!hasOnlyKeys(record, ['event', 'idempotencyKey', 'kind', 'stateVersion']) || !isEditEvent(record.event)) { + throw new RuntimeStateCorruptionError({ line, message: 'edit record shape is invalid', offset }); + } + return { event: record.event, idempotencyKey: record.idempotencyKey, kind: 'edit', stateVersion }; + } + if (record.kind === 'reset') { + if ( + !hasOnlyKeys(record, record.seed === undefined + ? ['idempotencyKey', 'kind', 'stateVersion'] + : ['idempotencyKey', 'kind', 'seed', 'stateVersion']) || + (record.seed !== undefined && !isJsonValue(record.seed)) + ) { + throw new RuntimeStateCorruptionError({ line, message: 'reset record shape is invalid', offset }); + } + return record.seed === undefined + ? { idempotencyKey: record.idempotencyKey, kind: 'reset', stateVersion } + : { idempotencyKey: record.idempotencyKey, kind: 'reset', seed: record.seed, stateVersion }; + } + throw new RuntimeStateCorruptionError({ line, message: 'record kind is invalid', offset }); +}; + +const snapshotForRecords = (records: readonly RuntimeStateRecord[], limit?: number): RuntimeSnapshot => { + let edits: EditEvent[] = []; + let seed: JsonValue | undefined; + for (const record of records) { + if (record.kind === 'edit') { + edits = [...edits, record.event]; + } else { + edits = []; + seed = record.seed; + } + } + const visibleEdits = limit === undefined ? edits : edits.slice(-limit); + return seed === undefined + ? { edits: visibleEdits, stateVersion: records.length } + : { edits: visibleEdits, seed, stateVersion: records.length }; +}; + +const parseSnapshot = (contents: Buffer): ParsedState => { + if (contents.byteLength > MAX_STATE_BYTES) { + throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); + } + let completeBytes = contents.byteLength; + if (contents.byteLength > 0 && contents[contents.byteLength - 1] !== 0x0a) { + const lastNewline = contents.lastIndexOf(0x0a); + completeBytes = lastNewline < 0 ? 0 : lastNewline + 1; + } + const records: RuntimeStateRecord[] = []; + const idempotencyKeys = new Set(); + let offset = 0; + let line = 1; + while (offset < completeBytes) { + const newline = contents.indexOf(0x0a, offset); + const end = newline < 0 ? completeBytes : newline; + let raw: unknown; + try { + raw = JSON.parse(contents.subarray(offset, end).toString('utf8')); + } catch { + throw new RuntimeStateCorruptionError({ line, message: 'record is not valid JSON', offset }); + } + const record = parseStateRecord({ line, offset, value: raw }); + const expectedVersion = records.length + 1; + if (record.stateVersion !== expectedVersion) { + throw new RuntimeStateCorruptionError({ + line, + message: `expected monotonic state version ${expectedVersion}, received ${record.stateVersion}`, + offset, + }); + } + if (idempotencyKeys.has(record.idempotencyKey)) { + throw new RuntimeStateCorruptionError({ line, message: `duplicate idempotency key ${record.idempotencyKey}`, offset }); + } + idempotencyKeys.add(record.idempotencyKey); + records.push(record); + offset = end + 1; + line += 1; + } + return { completeBytes, records, snapshot: snapshotForRecords(records) }; +}; + +const abortError = (signal: AbortSignal): Error => + signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); + +const settled = (operation: Promise): Promise> => + operation.then( + (value) => ({ type: 'value', value }), + (error: unknown) => ({ type: 'error', error: error instanceof Error ? error : new Error(String(error)) }), + ); + +const cancellation = (signal: AbortSignal): Promise> => + signal.aborted + ? Promise.resolve({ type: 'cancelled', error: abortError(signal) }) + : new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ type: 'cancelled', error: abortError(signal) }), { once: true }); + }); + +const isAlreadyLocked = (error: unknown): boolean => (error as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED'; + +const delay = async (milliseconds: number, signal: AbortSignal): Promise => { + if (signal.aborted) throw abortError(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(done, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + function done() { + signal.removeEventListener('abort', onAbort); + resolve(); + } + signal.addEventListener('abort', onAbort, { once: true }); + }); +}; + +const validateLimit = (limit: number | undefined): void => { + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 50)) { + throw new RangeError('limit must be an integer from 1 through 50'); + } +}; + +const validateStateVersion = (stateVersion: number | undefined): void => { + if (stateVersion !== undefined && (!Number.isSafeInteger(stateVersion) || stateVersion < 0)) { + throw new RangeError('stateVersion must be a nonnegative safe integer'); + } +}; + +export const createRuntimeStateKernel = ({ + createId = randomUUID, + now = () => new Date(), + policy, + stateFile, + storage, +}: StateKernelInput): RuntimeKernel => { + let poisoned: RuntimeStateLockError | undefined; + const owners = new Set(); + + const poison = (error: RuntimeStateLockError, fatal: boolean): RuntimeStateLockError => { + poisoned ??= error; + for (const owner of owners) owner.controller.abort(poisoned); + if (fatal) { + try { + policy.terminateOwner(poisoned); + } catch { + // The permanent poisoned state remains authoritative if teardown itself throws. + } + } + return poisoned; + }; + + const assertHealthy = (signal?: AbortSignal): void => { + if (signal?.aborted === true) throw abortError(signal); + if (poisoned !== undefined) throw poisoned; + }; + + const createOwner = (signal: AbortSignal | undefined): OperationOwner => { + assertHealthy(signal); + const owner: OperationOwner = { controller: new AbortController(), unsafeToRelease: false }; + if (signal !== undefined) { + if (signal.aborted) owner.controller.abort(abortError(signal)); + else signal.addEventListener('abort', () => owner.controller.abort(abortError(signal)), { once: true }); + } + owners.add(owner); + return owner; + }; + + const armDeadline = (owner: OperationOwner, deadline: number, error: Error): ReturnType => + setTimeout(() => owner.controller.abort(error), Math.max(0, deadline - Date.now())); + + const releaseRaw = async (owner: OperationOwner, rawRelease: StateLeaseRelease, label: string): Promise => { + const operation = settled(rawRelease()); + const timeoutError = new RuntimeStateLockError(`${label} exceeded ${policy.releaseMs} ms`); + let releaseTimer: ReturnType | undefined; + const timeout = new Promise>((resolve) => { + releaseTimer = setTimeout(() => resolve({ type: 'timeout', error: timeoutError }), policy.releaseMs); + }); + const outcome = await Promise.race([operation, timeout]); + clearTimeout(releaseTimer); + if (outcome.type === 'timeout') { + owner.unsafeToRelease = true; + throw poison( + new RuntimeStateLockError(`${outcome.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), + true, + ); + } + if (outcome.type === 'error') { + owner.unsafeToRelease = true; + throw poison( + new RuntimeStateLockError(`${label} failed; this kernel is permanently poisoned`, { cause: outcome.error }), + true, + ); + } + }; + + const awaitUnowned = async ( + owner: OperationOwner, + deadline: number, + operation: Promise, + timeoutError: RuntimeStateLockError, + ): Promise => { + const timer = armDeadline(owner, deadline, timeoutError); + const outcome = await Promise.race([settled(operation), cancellation(owner.controller.signal)]); + clearTimeout(timer); + if (outcome.type === 'cancelled') throw outcome.error; + if (outcome.type === 'error') throw outcome.error; + if (Date.now() >= deadline) { + owner.controller.abort(timeoutError); + throw timeoutError; + } + return outcome.value; + }; + + const awaitAcquisition = async ( + owner: OperationOwner, + deadline: number, + operation: Promise, + timeoutError: RuntimeStateLockError, + ): Promise => { + const phase = settled(operation); + const timer = armDeadline(owner, deadline, timeoutError); + const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); + clearTimeout(timer); + if (outcome.type === 'cancelled') { + void phase.then(async (late) => { + if (late.type === 'value') await releaseRaw(owner, late.value, 'Late runtime state lease release'); + }).catch(() => undefined); + throw outcome.error; + } + if (outcome.type === 'error') throw outcome.error; + if (Date.now() >= deadline || owner.controller.signal.aborted || poisoned !== undefined) { + const reason = poisoned ?? (owner.controller.signal.aborted ? abortError(owner.controller.signal) : timeoutError); + await releaseRaw(owner, outcome.value, 'Late runtime state lease release'); + throw reason; + } + return outcome.value; + }; + + const awaitOwned = async ( + owner: OperationOwner, + deadline: number, + operation: Promise, + timeoutError: RuntimeStateLockError, + ): Promise => { + const phase = settled(operation); + const timer = armDeadline(owner, deadline, timeoutError); + const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); + clearTimeout(timer); + if (outcome.type === 'error') throw outcome.error; + if (outcome.type === 'value') { + if (poisoned !== undefined) { + owner.unsafeToRelease = true; + throw poisoned; + } + if (owner.controller.signal.aborted) { + throw abortError(owner.controller.signal); + } + if (Date.now() >= deadline) { + owner.controller.abort(timeoutError); + throw timeoutError; + } + return outcome.value; + } + + if (poisoned !== undefined) { + owner.unsafeToRelease = true; + throw poisoned; + } + const settlementTimeout = new RuntimeStateLockError( + `Runtime state phase did not settle within ${policy.ownerSettlementMs} ms after cancellation`, + ); + let settlementTimer: ReturnType | undefined; + const settlement = await Promise.race([ + phase, + new Promise>((resolve) => { + settlementTimer = setTimeout( + () => resolve({ type: 'settlement-timeout', error: settlementTimeout }), + policy.ownerSettlementMs, + ); + }), + ]); + clearTimeout(settlementTimer); + if (settlement.type === 'settlement-timeout') { + owner.unsafeToRelease = true; + throw poison( + new RuntimeStateLockError(`${settlement.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), + true, + ); + } + throw outcome.error; + }; + + const releaseLease = async (owner: OperationOwner, canonicalStateFile: string, rawRelease: StateLeaseRelease): Promise => { + let metadataFailure: Error | undefined; + try { + const removal = settled(storage.removeOwner(canonicalStateFile, owner.controller.signal)); + let removalTimer: ReturnType | undefined; + const timeout = new Promise>((resolve) => { + removalTimer = setTimeout(() => resolve({ type: 'timeout' }), policy.releaseMs); + }); + const outcome = await Promise.race([removal, timeout]); + clearTimeout(removalTimer); + if (outcome.type === 'timeout') { + owner.unsafeToRelease = true; + throw poison( + new RuntimeStateLockError(`Runtime state lease release exceeded ${policy.releaseMs} ms; this kernel is permanently poisoned`), + true, + ); + } + if (outcome.type === 'error') metadataFailure = outcome.error; + } catch (error) { + if (owner.unsafeToRelease) throw error; + metadataFailure = error instanceof Error ? error : new Error(String(error)); + } + + try { + await releaseRaw(owner, rawRelease, 'Runtime state lease release'); + } catch (releaseError) { + if (metadataFailure === undefined) throw releaseError; + throw new AggregateError( + [metadataFailure, releaseError], + 'Runtime state lease release failed', + { cause: releaseError }, + ); + } + if (metadataFailure !== undefined) throw metadataFailure; + }; + + const acquireLease = async (signal: AbortSignal | undefined, timeoutMs: number) => { + const owner = createOwner(signal); + const deadline = Date.now() + timeoutMs; + const timeoutError = new RuntimeStateLockError(`Timed out acquiring runtime state lease after ${timeoutMs} ms`); + let rawRelease: StateLeaseRelease | undefined; + try { + const canonicalStateFile = await awaitUnowned(owner, deadline, storage.prepare(stateFile, owner.controller.signal), timeoutError); + while (true) { + assertHealthy(owner.controller.signal); + const ownerStale = await awaitUnowned( + owner, + deadline, + storage.readOwnerStaleMs(canonicalStateFile, owner.controller.signal), + timeoutError, + ); + try { + rawRelease = await awaitAcquisition( + owner, + deadline, + storage.acquire({ + onCompromised: (error) => { + owner.unsafeToRelease = true; + const compromise = new RuntimeStateLockError( + 'Runtime state lease was compromised; this kernel is permanently poisoned', + { cause: error }, + ); + owner.controller.abort(compromise); + poison(compromise, true); + }, + stale: Math.max(policy.staleMs, ownerStale), + stateFile: canonicalStateFile, + update: policy.updateMs, + }), + timeoutError, + ); + await awaitOwned( + owner, + deadline, + storage.writeOwner(canonicalStateFile, policy.staleMs, owner.controller.signal), + timeoutError, + ); + return { canonicalStateFile, owner, rawRelease }; + } catch (error) { + if (rawRelease !== undefined && !owner.unsafeToRelease) { + await releaseRaw(owner, rawRelease, 'Runtime state lease release'); + rawRelease = undefined; + } + if (!isAlreadyLocked(error)) throw error; + rawRelease = undefined; + await awaitUnowned( + owner, + deadline, + delay(Math.min(policy.retryDelayMs, Math.max(0, deadline - Date.now())), owner.controller.signal), + timeoutError, + ); + } + } + } catch (error) { + owners.delete(owner); + throw error; + } + }; + + const readSnapshot = async ({ limit, stateVersion }: RuntimeSnapshotReadOptions = {}): Promise => { + validateLimit(limit); + validateStateVersion(stateVersion); + assertHealthy(); + const controller = new AbortController(); + const parsed = parseSnapshot(await storage.read(stateFile, controller.signal)); + if (stateVersion !== undefined) { + if (stateVersion > parsed.records.length) throw new RangeError(`state version ${stateVersion} is unavailable`); + return snapshotForRecords(parsed.records.slice(0, stateVersion), limit); + } + return snapshotForRecords(parsed.records, limit); + }; + + const mutate = async (record: RuntimeStateRecord, options: RuntimeMutationOptions | undefined): Promise => { + if (!isNonEmptyString(record.idempotencyKey)) { + throw new TypeError('Runtime state mutations require a nonempty idempotency key'); + } + if (record.kind === 'edit' && !isEditEvent(record.event)) { + throw new TypeError('Runtime state edits require every event field to be nonempty and valid'); + } + if (record.kind === 'reset' && record.seed !== undefined && !isJsonValue(record.seed)) { + throw new TypeError('Runtime state reset seed must be JSON-safe'); + } + const timeoutMs = options?.lockAcquireTimeoutMs ?? policy.acquireLimitMs; + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > policy.acquireLimitMs) { + throw new RangeError(`lockAcquireTimeoutMs must be an integer from 1 through ${policy.acquireLimitMs}`); + } + assertHealthy(options?.signal); + const lease = await acquireLease(options?.signal, timeoutMs); + const deadline = Date.now() + policy.mutationMs; + const timeoutError = new RuntimeStateLockError( + `Runtime state mutation exceeded ${policy.mutationMs} ms critical-section limit`, + ); + let result: RuntimeSnapshot | undefined; + let failure: unknown; + try { + const bytes = await awaitOwned( + lease.owner, + deadline, + storage.read(lease.canonicalStateFile, lease.owner.controller.signal), + timeoutError, + ); + const parsed = parseSnapshot(bytes); + const sameKey = parsed.records.find((current) => current.idempotencyKey === record.idempotencyKey); + if (sameKey !== undefined) { + if (canonicalRecordInput(sameKey) !== canonicalRecordInput(record)) { + throw new RuntimeStateLockError(`Runtime state idempotency key ${record.idempotencyKey} was reused with conflicting input`); + } + result = parsed.snapshot; + } else { + if (parsed.completeBytes !== bytes.byteLength) { + await awaitOwned( + lease.owner, + deadline, + storage.repair(lease.canonicalStateFile, parsed.completeBytes, lease.owner.controller.signal), + timeoutError, + ); + } + const nextRecord: RuntimeStateRecord = record.kind === 'edit' + ? { ...record, event: record.event, stateVersion: parsed.snapshot.stateVersion + 1 } + : record.seed === undefined + ? { ...record, stateVersion: parsed.snapshot.stateVersion + 1 } + : { ...record, seed: record.seed, stateVersion: parsed.snapshot.stateVersion + 1 }; + const serialized = Buffer.from(`${JSON.stringify(nextRecord)}\n`, 'utf8'); + if (parsed.completeBytes + serialized.byteLength > MAX_STATE_BYTES) { + throw new RuntimeStateLockError(`Runtime state file cannot exceed ${MAX_STATE_BYTES} bytes`); + } + await awaitOwned( + lease.owner, + deadline, + storage.append(lease.canonicalStateFile, serialized, lease.owner.controller.signal), + timeoutError, + ); + result = snapshotForRecords([...parsed.records, nextRecord]); + } + } catch (error) { + failure = error; + } + + if (!lease.owner.unsafeToRelease) { + try { + await releaseLease(lease.owner, lease.canonicalStateFile, lease.rawRelease); + } catch (error) { + failure = failure === undefined + ? error + : new AggregateError( + [failure, error], + 'Runtime state mutation and lease release failed', + { cause: error }, + ); + } + } + owners.delete(lease.owner); + if (failure !== undefined) throw failure; + return result!; + }; + + return { + recordEdit(input, options) { + return mutate({ + event: { + eventId: createId(), + host: input.host, + path: input.path, + recordedAt: now().toISOString(), + sessionId: input.sessionId, + toolName: input.toolName, + }, + idempotencyKey: input.idempotencyKey, + kind: 'edit', + stateVersion: 0, + }, options); + }, + resetState(input, options) { + return mutate( + input.seed === undefined + ? { idempotencyKey: input.idempotencyKey, kind: 'reset', stateVersion: 0 } + : { idempotencyKey: input.idempotencyKey, kind: 'reset', seed: input.seed, stateVersion: 0 }, + options, + ); + }, + readSnapshot, + }; +}; + +const metadataFile = (stateFile: string): string => `${stateFile}.agent-runtime-lock.json`; + +export const createNodeStateStorage = ({ + platform = process.platform, + syncParent, +}: Readonly<{ + platform?: NodeJS.Platform; + syncParent?: (directory: string) => Promise; +}> = {}): StateStorage => ({ + acquire: (input) => acquireLockfile(input.stateFile, { + onCompromised: input.onCompromised, + realpath: false, + retries: 0, + stale: input.stale, + update: input.update, + }), + async append(stateFile, contents, signal) { + if (signal.aborted) throw abortError(signal); + const handle = await open(stateFile, 'a'); + try { + await handle.writeFile(contents); + await handle.sync(); + } finally { + await handle.close(); + } + }, + async prepare(stateFile) { + await mkdir(dirname(stateFile), { recursive: true }); + let created = false; + try { + await stat(stateFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + try { + const handle = await open(stateFile, 'wx'); + await handle.sync(); + await handle.close(); + created = true; + } catch (createError) { + if ((createError as NodeJS.ErrnoException).code !== 'EEXIST') throw createError; + } + } + if (created) { + try { + if (syncParent !== undefined) await syncParent(dirname(stateFile)); + else { + const parent = await open(dirname(stateFile), 'r'); + try { + await parent.sync(); + } finally { + await parent.close(); + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!(platform === 'win32' && (code === 'EPERM' || code === 'EINVAL'))) throw error; + } + } + const canonical = await realpath(stateFile); + const details = await lstat(canonical); + if (!details.isFile() || details.isSymbolicLink()) { + throw new RuntimeStateLockError(`Runtime state path is not a regular file: ${stateFile}`); + } + return canonical; + }, + async read(stateFile) { + try { + const handle = await open(stateFile, 'r'); + try { + const contents = Buffer.allocUnsafe(MAX_STATE_BYTES + 1); + let offset = 0; + while (offset < contents.byteLength) { + const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_STATE_BYTES) { + throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); + } + return contents.subarray(0, offset); + } finally { + await handle.close(); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Buffer.alloc(0); + throw error; + } + }, + async readOwnerStaleMs(stateFile) { + try { + const metadata: unknown = JSON.parse(await readFile(metadataFile(stateFile), 'utf8')); + const stale = asRecord(metadata)?.stale; + return typeof stale === 'number' && Number.isInteger(stale) && stale > 0 ? stale : 0; + } catch { + return 0; + } + }, + removeOwner: (stateFile) => rm(metadataFile(stateFile), { force: true }), + async repair(stateFile, completeBytes) { + const handle = await open(stateFile, 'r+'); + try { + await handle.truncate(completeBytes); + await handle.sync(); + } finally { + await handle.close(); + } + }, + writeOwner: (stateFile, staleMs, signal) => + writeFile(metadataFile(stateFile), JSON.stringify({ stale: staleMs }), { encoding: 'utf8', signal }), +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts new file mode 100644 index 000000000..19c5d2c39 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts @@ -0,0 +1,101 @@ +import type { RuntimeKernel } from './contracts.js'; +import { open } from 'node:fs/promises'; +import { + createNodeStateStorage, + createRuntimeStateKernel, + type StateKernelPolicy, + type StateLeaseRelease, + type StateStorage, +} from './state-file-core.js'; +import type { FileRuntimeKernelOptions } from './state-file.js'; + +export interface RuntimeStateTestAdapter { + readonly acquireLock?: StateStorage['acquire']; + readonly beforeAppend?: () => Promise; + readonly beforeAppendSync?: () => Promise; + readonly beforeAppendWrite?: () => Promise; + readonly beforeRead?: () => Promise; + readonly beforeRelease?: () => Promise; + readonly beforeRepair?: () => Promise; + readonly criticalSectionMs?: number; + readonly fatalOwnerTeardown?: (error: Error) => void; + readonly ownerSettlementMs?: number; + readonly platform?: NodeJS.Platform; + readonly prepareStateFile?: (input: Readonly<{ stateFile: string }>) => Promise; + readonly readState?: StateStorage['read']; + readonly releaseMs?: number; + readonly syncParent?: (directory: string) => Promise; +} + +export interface TestFileRuntimeKernelOptions extends FileRuntimeKernelOptions { + readonly adapter?: RuntimeStateTestAdapter; +} + +const wrapRelease = ( + release: StateLeaseRelease, + adapter: RuntimeStateTestAdapter, +): StateLeaseRelease => async () => { + await adapter.beforeRelease?.(); + await release(); +}; + +export const createTestFileRuntimeKernel = ({ adapter = {}, ...options }: TestFileRuntimeKernelOptions): RuntimeKernel => { + const native = createNodeStateStorage({ platform: adapter.platform, syncParent: adapter.syncParent }); + const storage: StateStorage = { + ...native, + acquire: async (input) => wrapRelease( + await (adapter.acquireLock === undefined ? native.acquire(input) : adapter.acquireLock(input)), + adapter, + ), + async append(stateFile, contents, signal) { + await adapter.beforeAppend?.(); + if (signal.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); + } + if (adapter.beforeAppendWrite !== undefined || adapter.beforeAppendSync !== undefined) { + const handle = await open(stateFile, 'a'); + try { + await adapter.beforeAppendWrite?.(); + if (signal.aborted) throw signal.reason; + await handle.writeFile(contents); + await adapter.beforeAppendSync?.(); + if (signal.aborted) throw signal.reason; + await handle.sync(); + return; + } finally { + await handle.close(); + } + } + return native.append(stateFile, contents, signal); + }, + prepare: adapter.prepareStateFile === undefined + ? native.prepare + : (stateFile) => adapter.prepareStateFile!({ stateFile }), + read: adapter.readState ?? (async (stateFile, signal) => { + await adapter.beforeRead?.(); + return native.read(stateFile, signal); + }), + async repair(stateFile, completeBytes, signal) { + await adapter.beforeRepair?.(); + if (signal.aborted) throw signal.reason; + return native.repair(stateFile, completeBytes, signal); + }, + }; + const policy: StateKernelPolicy = { + acquireLimitMs: 30_000, + mutationMs: adapter.criticalSectionMs ?? 10_000, + ownerSettlementMs: adapter.ownerSettlementMs ?? 100, + releaseMs: adapter.releaseMs ?? 100, + retryDelayMs: 25, + staleMs: 2_000, + terminateOwner: (error) => adapter.fatalOwnerTeardown?.(error), + updateMs: 1_000, + }; + return createRuntimeStateKernel({ + createId: options.createId, + now: options.now, + policy, + stateFile: options.stateFile, + storage, + }); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts new file mode 100644 index 000000000..73175a34b --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import { realpath } from 'node:fs/promises'; + +import type { RuntimeKernel } from './contracts.js'; +import { + createNodeStateStorage, + createRuntimeStateKernel, + RuntimeStateCorruptionError, + RuntimeStateLockError, + type StateKernelPolicy, +} from './state-file-core.js'; + +const PRODUCTION_POLICY: StateKernelPolicy = Object.freeze({ + acquireLimitMs: 30_000, + mutationMs: 10_000, + ownerSettlementMs: 10_000, + releaseMs: 10_000, + retryDelayMs: 25, + staleMs: 30_000, + terminateOwner(error: RuntimeStateLockError) { + process.stderr.write(`${error.message}\n`); + process.kill(process.pid, 'SIGTERM'); + }, + updateMs: 5_000, +}); + +export { RuntimeStateCorruptionError, RuntimeStateLockError }; + +export interface FileRuntimeKernelOptions { + stateFile: string; + now?: () => Date; + createId?: () => string; +} + +export const createFileRuntimeKernel = (options: FileRuntimeKernelOptions): RuntimeKernel => + createRuntimeStateKernel({ + createId: options.createId, + now: options.now, + policy: PRODUCTION_POLICY, + stateFile: options.stateFile, + storage: createNodeStateStorage(), + }); + +const stateHome = (): string => { + const configured = process.env.XDG_STATE_HOME; + return configured !== undefined && configured.trim() !== '' && isAbsolute(configured) + ? configured + : join(homedir(), '.local', 'state'); +}; + +/** Resolves implicit host state outside the repository from one canonical workspace identity. */ +export const resolveImplicitRuntimeStateFile = async (workspaceRoot: string): Promise => { + const canonicalWorkspace = await realpath(resolve(workspaceRoot)); + const workspaceId = createHash('sha256').update(canonicalWorkspace).digest('hex'); + return join(stateHome(), 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts new file mode 100644 index 000000000..7c6b7371c --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts @@ -0,0 +1,19 @@ +declare module '@modelcontextprotocol/ext-apps/react' { + import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; + import type { App, McpUiAppCapabilities, McpUiHostContext } from '@modelcontextprotocol/ext-apps'; + + export type UseAppOptions = { + appInfo: Implementation; + capabilities: McpUiAppCapabilities; + onAppCreated?: (app: App) => void; + }; + + export type AppState = { + app: App | null; + error: Error | null; + isConnected: boolean; + }; + + export function useApp(options: UseAppOptions): AppState; + export function useHostStyles(app: App | null, initialContext?: McpUiHostContext | null): void; +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts new file mode 100644 index 000000000..9e7be798b --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts @@ -0,0 +1,24 @@ +type RscTemporaryReferenceSet = unknown; + +type RscOptions = { + onError?: (error: unknown) => string | undefined; + temporaryReferences?: RscTemporaryReferenceSet; +}; + +type RscClientOptions = { + temporaryReferences?: RscTemporaryReferenceSet; +}; + +declare module 'react-server-dom-rspack/client.node' { + export function createFromReadableStream( + stream: ReadableStream, + options?: RscClientOptions, + ): Promise; +} + +declare module 'react-server-dom-rspack/server.node' { + export function renderToReadableStream( + model: unknown, + options?: RscOptions, + ): ReadableStream; +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx new file mode 100644 index 000000000..473fff255 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react'; + +import type { EditEvent } from '../runtime/contracts.js'; +import { createWidgetStateAdapter, safeAreaCustomProperties, type HostContext } from './host-adapters.js'; + +type TimelineState = { stateVersion: number; edits: EditEvent[] }; +export type RefreshState = 'idle' | 'refreshing' | 'error'; + +const standaloneTimeline: TimelineState = { + edits: [ + { + eventId: 'concept-1', + host: 'claude', + path: 'src/runtime/state.ts', + recordedAt: '2026-08-14T10:24:31.000Z', + sessionId: 'concept', + toolName: 'Write', + }, + { + eventId: 'concept-2', + host: 'codex', + path: 'src/widget/App.tsx', + recordedAt: '2026-08-14T10:21:07.000Z', + sessionId: 'concept', + toolName: 'Edit', + }, + { + eventId: 'concept-3', + host: 'claude', + path: 'README.md', + recordedAt: '2026-08-14T10:17:42.000Z', + sessionId: 'concept', + toolName: 'Read', + }, + ], + stateVersion: 3, +}; + +const asTimelineState = (value: unknown): TimelineState | undefined => { + if (value === null || typeof value !== 'object') { + return undefined; + } + + const state = value as Record; + if (!Number.isInteger(state.stateVersion) || !Array.isArray(state.edits)) { + return undefined; + } + + const edits = state.edits.filter( + (edit): edit is EditEvent => + edit !== null && + typeof edit === 'object' && + typeof (edit as Record).eventId === 'string' && + ((edit as Record).host === 'claude' || (edit as Record).host === 'codex') && + typeof (edit as Record).path === 'string' && + typeof (edit as Record).recordedAt === 'string' && + typeof (edit as Record).sessionId === 'string' && + typeof (edit as Record).toolName === 'string', + ); + + return edits.length === state.edits.length ? { edits, stateVersion: state.stateVersion as number } : undefined; +}; + +const displayTime = (recordedAt: string): string => + new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + hour12: true, + minute: '2-digit', + second: '2-digit', + }).format(new Date(recordedAt)); + +export const RefreshStatus = ({ refresh }: { refresh: RefreshState }) => { + const message = + refresh === 'refreshing' ? 'Refreshing timeline.' : refresh === 'error' ? 'Unable to refresh timeline.' : ''; + + return ( +

+ {message} +

+ ); +}; + +export const App = () => { + const standalone = window.parent === window; + const [timeline, setTimeline] = useState(standalone ? standaloneTimeline : { edits: [], stateVersion: 0 }); + const [refresh, setRefresh] = useState('idle'); + const [hostContext, setHostContext] = useState(); + const [selectedEventId, setSelectedEventId] = useState(); + const widgetState = useMemo(() => createWidgetStateAdapter(window as Window & { openai?: unknown }), []); + const { app } = useApp({ + appInfo: { name: 'rsc-agent-runtime-timeline', version: '1.0.0' }, + capabilities: {}, + onAppCreated: (createdApp) => { + createdApp.onteardown = () => ({}); + createdApp.ontoolresult = (result) => { + const state = asTimelineState(result.structuredContent); + if (state !== undefined) { + setTimeline(state); + setRefresh('idle'); + } + }; + createdApp.onhostcontextchanged = (context) => { + setHostContext((previous) => ({ ...previous, ...context })); + }; + }, + }); + const initialHostContext = app?.getHostContext(); + useHostStyles(app, initialHostContext); + + const activeHostContext = hostContext ?? initialHostContext; + useEffect(() => { + const validIds = timeline.edits.map((edit) => edit.eventId); + setSelectedEventId((selected) => { + if (selected !== undefined && validIds.includes(selected)) { + return selected; + } + return widgetState.restore(validIds); + }); + }, [timeline.edits, widgetState]); + + const selectEvent = (eventId: string): void => { + setSelectedEventId(eventId); + widgetState.persist(eventId); + }; + + const refreshTimeline = async (): Promise => { + setRefresh('refreshing'); + if (standalone) { + setTimeline((state) => ({ ...state, stateVersion: state.stateVersion + 1 })); + setRefresh('idle'); + return; + } + + if (app === null) { + setRefresh('error'); + return; + } + + try { + const result = await app.callServerTool({ name: 'render_edit_timeline', arguments: { limit: 10 } }); + const state = asTimelineState(result.structuredContent); + if (state === undefined) { + throw new Error('The runtime returned an invalid timeline.'); + } + + setTimeline(state); + setRefresh('idle'); + } catch { + setRefresh('error'); + } + }; + + return ( +
+
+
+

Runtime edit timeline

+

Hook events, shared across processes.

+
+ +
+ + + {timeline.edits.length === 0 ? ( +

No file edits recorded yet.

+ ) : ( +
    + {timeline.edits.map((edit) => ( +
  1. selectEvent(edit.eventId)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectEvent(edit.eventId); + } + }} + role="button" + tabIndex={0} + > +
  2. + ))} +
+ )} + +
State version {timeline.stateVersion}
+
+ ); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts new file mode 100644 index 000000000..c2a5e55fd --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts @@ -0,0 +1,74 @@ +export interface HostContext { + [key: string]: unknown; + safeAreaInsets?: { + bottom: number; + left: number; + right: number; + top: number; + }; +} + +export interface WidgetStateAdapter { + kind: 'openai' | 'portable'; + persist(selectedEventId: string): void; + restore(validEventIds: readonly string[]): string | undefined; +} + +type OpenAiCapability = { + setWidgetState: (state: { selectedEventId: string }) => unknown; + widgetState: Record; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const portableAdapter: WidgetStateAdapter = { + kind: 'portable', + persist: () => undefined, + restore: () => undefined, +}; + +const openAiCapability = (host: { openai?: unknown } | undefined): OpenAiCapability | undefined => { + if (!isRecord(host?.openai)) { + return undefined; + } + const { setWidgetState, widgetState } = host.openai; + if (typeof setWidgetState !== 'function' || !isRecord(widgetState)) { + return undefined; + } + return { setWidgetState: setWidgetState as OpenAiCapability['setWidgetState'], widgetState }; +}; + +/** Feature-detects documented state methods; no host name or user-agent is inspected. */ +export const createWidgetStateAdapter = (host: { openai?: unknown } | undefined): WidgetStateAdapter => { + const capability = openAiCapability(host); + if (capability === undefined) { + return portableAdapter; + } + + return { + kind: 'openai', + persist(selectedEventId) { + try { + capability.setWidgetState({ selectedEventId }); + } catch { + // Host state is an optional presentation enhancement. + } + }, + restore(validEventIds) { + const selectedEventId = capability.widgetState.selectedEventId; + return typeof selectedEventId === 'string' && validEventIds.includes(selectedEventId) + ? selectedEventId + : undefined; + }, + }; +}; + +const inset = (value: unknown): string => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? `${value}px` : '0px'); + +export const safeAreaCustomProperties = (context: HostContext | undefined): Record => ({ + '--timeline-safe-area-bottom': inset(context?.safeAreaInsets?.bottom), + '--timeline-safe-area-left': inset(context?.safeAreaInsets?.left), + '--timeline-safe-area-right': inset(context?.safeAreaInsets?.right), + '--timeline-safe-area-top': inset(context?.safeAreaInsets?.top), +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx new file mode 100644 index 000000000..dd6b8cd74 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx @@ -0,0 +1,11 @@ +import { createRoot } from 'react-dom/client'; + +import { App } from './App.js'; +import './styles.css'; + +const root = document.getElementById('root'); +if (root === null) { + throw new Error('Widget root was not found'); +} + +createRoot(root).render(); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css new file mode 100644 index 000000000..5a2f51a11 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css @@ -0,0 +1,238 @@ +:root { + color: var(--color-text-primary, #10162a); + background: var(--color-background-primary, #ffffff); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 0; + background: var(--color-background-primary, #ffffff); +} + +button, +input, +textarea, +select { + font: inherit; +} + +.timeline { + width: min(calc(100% - 40px), 760px); + min-height: 460px; + margin: 20px auto; + padding: calc(36px + var(--timeline-safe-area-top, 0px)) calc(32px + var(--timeline-safe-area-right, 0px)) + calc(24px + var(--timeline-safe-area-bottom, 0px)) calc(32px + var(--timeline-safe-area-left, 0px)); + background: var(--color-background-primary, #ffffff); + border: 1px solid var(--color-border-primary, #d9dde7); + border-radius: 12px; +} + +.timeline__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +h1, +p { + margin: 0; +} + +h1 { + font-size: clamp(28px, 4vw, 32px); + line-height: 1.18; + letter-spacing: -0.06em; +} + +.timeline__header p, +.timeline__details, +footer, +.timeline__empty { + color: var(--color-text-secondary, #667085); +} + +.timeline__header p { + margin-top: 12px; + font-size: 16px; +} + +.timeline__status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +button { + min-width: 104px; + min-height: 44px; + padding: 10px 14px; + color: var(--color-ring-primary, #5b3df5); + background: var(--color-background-primary, #ffffff); + border: 2px solid var(--color-ring-primary, #5b3df5); + border-radius: 5px; + cursor: pointer; + font-size: 16px; +} + +button:hover:not(:disabled) { + color: var(--color-text-inverse, #ffffff); + background: var(--color-ring-primary, #5b3df5); +} + +button:focus-visible { + outline: 3px solid var(--color-ring-primary, #5b3df5); + outline-offset: 3px; +} + +button:disabled { + cursor: wait; + opacity: 0.65; +} + +.timeline__events { + position: relative; + display: grid; + gap: 0; + margin: 30px 0 12px; + padding: 0 0 0 46px; + list-style: none; +} + +.timeline__events::before { + position: absolute; + top: 14px; + bottom: 16px; + left: 13px; + width: 1px; + background: #d9dde7; + content: ''; +} + +.timeline__event { + position: relative; + padding: 0 0 16px; + cursor: pointer; + border-radius: 5px; +} + +.timeline__event + .timeline__event { + padding-top: 16px; + border-top: 1px solid var(--color-border-primary, #d9dde7); +} + +.timeline__event:focus-visible { + outline: 3px solid var(--color-ring-primary, #5b3df5); + outline-offset: 5px; +} + +.timeline__event--selected .timeline__path { + color: var(--color-ring-primary, #5b3df5); +} + +.timeline__node { + position: absolute; + top: 0; + left: -46px; + width: 28px; + height: 28px; + background: var(--color-background-primary, #ffffff); + border: 3px solid var(--color-ring-primary, #5b3df5); + border-radius: 50%; +} + +.timeline__path { + overflow-wrap: anywhere; + font-size: 20px; + font-weight: 700; + line-height: 1.25; +} + +.timeline__details { + display: flex; + align-items: center; + gap: 18px; + margin-top: 12px; + font-size: 16px; +} + +.timeline__details span:first-child { + color: var(--color-text-primary, #10162a); +} + +.timeline__details time { + margin-left: auto; + white-space: nowrap; +} + +.timeline__empty { + margin: 64px 0 36px; +} + +footer { + padding-top: 18px; + border-top: 1px solid var(--color-border-primary, #d9dde7); + font-size: 15px; +} + +@media (max-width: 480px) { + .timeline { + width: 100%; + min-height: 100vh; + margin: 0; + padding: calc(32px + var(--timeline-safe-area-top, 0px)) calc(24px + var(--timeline-safe-area-right, 0px)) + calc(32px + var(--timeline-safe-area-bottom, 0px)) calc(24px + var(--timeline-safe-area-left, 0px)); + border: 0; + border-radius: 0; + } + + .timeline__header { + flex-direction: column; + } + + button { + width: 100%; + } + + .timeline__events { + margin-top: 48px; + padding-left: 42px; + } + + .timeline__node { + left: -42px; + width: 28px; + height: 28px; + border-width: 3px; + } + + .timeline__details { + flex-wrap: wrap; + gap: 10px 16px; + } + + .timeline__details time { + width: 100%; + margin-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts new file mode 100644 index 000000000..6eafe5441 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -0,0 +1,2155 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { appendFile, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createRsbuild } from '@rsbuild/core'; +import { expect, test } from '@rstest/core'; + +import { copyExample } from './support/copy-example.ts'; +import { createElement, type ReactNode } from 'react'; + +import { ProjectService } from '../../../packages/agent-bundle/src/dev/index.ts'; +import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; +import { createDevRuntimeProvider } from '../src/dev/provider.js'; +import { RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; +import { serializeInspection } from '../src/dev/serialize-inspection.js'; + +const readChildOutput = (stream: NodeJS.ReadableStream): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + stream.once('error', reject); + stream.once('end', () => resolve(Buffer.concat(chunks))); + }); + +const windowsTest = process.platform === 'win32' ? test : test.skip; + +const exampleRoot = process.cwd(); + +const copyInvocationExample = async () => copyExample(exampleRoot, { prefix: 'rsc-agent-runtime-invocation-copy-' }); + +const startInvocation = (entry: string, request: Record) => { + const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); + const flight = child.stdio[3] as NodeJS.ReadableStream | null | undefined; + if (flight === null || flight === undefined) throw new Error('Invocation worker Flight stream is unavailable.'); + child.stdin.end(JSON.stringify(request)); + + const completed = Promise.all([ + readChildOutput(flight), + readChildOutput(child.stdout), + readChildOutput(child.stderr), + new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }), + ]).then(([flight, stdout, stderr, exitCode]) => ({ exitCode, flight, stderr: stderr.toString('utf8'), stdout })); + + return { child, completed }; +}; + +test('streams a raw Flight payload separately from its bounded inspection response', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + try { + const entry = await buildInvocationEntry(compilerRoot); + const flightBytes = 3 * 1024 * 1024; + await writeFile(entry, ` +const { writeSync } = require('node:fs'); +writeSync(3, Buffer.alloc(${flightBytes}, 120)); +process.stdout.end(JSON.stringify({ + flightBytes: ${flightBytes}, + inspection: { + flight: { bytes: ${flightBytes}, preview: '', truncated: true }, + state: { identity: { stateStoreId: 'fixture-state', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const result = await invoke(entry, { + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + expect(result.flight.byteLength).toBe(flightBytes); + expect(result.flight.byteLength).toBeLessThanOrEqual(4 * 1024 * 1024); + expect(result.stdout.byteLength).toBeLessThanOrEqual(4 * 1024 * 1024); + expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ + flightBytes: result.flight.byteLength, + inspection: expect.any(Object), + }); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}, 30_000); + +const invoke = async (entry: string, request: Record) => startInvocation(entry, request).completed; + +const buildInvocationEntry = async (compilerRoot: string, cwd = process.cwd()): Promise => { + const rsbuild = await createRsbuild({ + config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), + cwd, + }); + await rsbuild.build(); + return join(compilerRoot, 'rsc', 'dev', 'invoke.js'); +}; + +const waitFor = async (condition: () => boolean, message: string, timeoutMs = 4_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return Object.freeze({ promise, reject, resolve }); +}; + +const readWhenPresent = async (path: string): Promise => { + let value: string | undefined; + await waitFor(() => { + try { + value = readFileSync(path, 'utf8'); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + }, `Timed out waiting for ${path}`); + return value as string; +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; + throw error; + } +}; + +const startWindowsJobOwnerSession = async ( + storageRoot: string, + mode: 'close-control' | 'hang-ready' | 'ignore-stop' | 'nonzero-after-drain' | 'normal', +) => { + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: `session-windows-owner-${mode}`, + signal: new AbortController().signal, + storageRoot, + }, { windowsJobOwnerMode: mode }); + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + return Object.freeze({ + generationId: session.status().activeVector!.runtimeGenerationId, + session, + storageRoot, + }); +}; + +const event = (eventId: string) => ({ + eventId, + host: 'claude' as const, + path: `src/${eventId}.ts`, + recordedAt: '2026-08-15T00:00:00.000Z', + sessionId: 'session', + toolName: 'Write', +}); + +const oversizedMcpWorker = (payloadBytes: number): string => { + return `const { writeSync } = require('node:fs'); +const payload = 'x'.repeat(${payloadBytes}); +const model = ['$', 'mcp-result', null, { + _meta: '$undefined', + isError: '$undefined', + structuredContent: { payload, stateVersion: 0 }, + children: [['$', 'mcp-text', null, { children: 'ok' }]], +}]; +writeSync(3, Buffer.from('{"stateVersion":0}')); +process.stdout.end(\`0:\${JSON.stringify(model)}\\n\`); +`; +}; + +const inspectionShape = (result: { inspection: Record }) => { + const { flight: _flight, ...inspection } = result.inspection; + return inspection; +}; + +const assertJsonOnly = (value: unknown): void => { + if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return; + expect(typeof value).not.toBe('function'); + expect(typeof value).not.toBe('symbol'); + if (Array.isArray(value)) { + value.forEach(assertJsonOnly); + return; + } + expect(value).toBeTypeOf('object'); + Object.values(value as Record).forEach(assertJsonOnly); +}; + +test('lowers the hook state version from durable state when copied RSC output grammar changes', async () => { + const copied = await copyInvocationExample(); + const compilerRoot = join(copied.workspaceRoot, 'compiler'); + const componentSource = join(copied.projectRoot, 'src', 'rsc', 'components.tsx'); + try { + const source = await readFile(componentSource, 'utf8'); + const edited = source.replace( + 'Shared state now contains ${editCount} ${editNoun}.', + "There is now ${editCount === 1 ? 'one recorded edit' : `${editCount} recorded ${editNoun}`}", + ); + expect(edited).not.toBe(source); + await writeFile(componentSource, edited); + const entry = await buildInvocationEntry(compilerRoot, copied.projectRoot); + const result = await invoke(entry, { + host: 'claude', + input: { + cwd: join(copied.workspaceRoot, 'workspace'), + hook_event_name: 'PostToolUse', + session_id: 'wording-independent-state-version', + tool_input: { file_path: 'changed-wording.txt' }, + tool_name: 'Write', + tool_use_id: 'changed-wording-tool', + }, + stateFile: join(copied.workspaceRoot, 'events.jsonl'), + stateStoreId: 'wording-independent-state-version', + type: 'hook/after-file-edit', + }); + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ + inspection: { + agentVisible: 'Recorded changed-wording.txt from claude. There is now one recorded edit', + state: { identity: { stateStoreId: 'wording-independent-state-version', stateVersion: 1 } }, + }, + }); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('builds a generation-contained inspection entry for Claude, Codex, and MCP fixtures', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + const workspace = join(compilerRoot, 'workspace'); + const request = { + host: 'claude', + input: { + cwd: workspace, + hook_event_name: 'PostToolUse', + session_id: 'claude-session', + tool_input: { file_path: 'demo.txt' }, + tool_name: 'Write', + tool_use_id: 'claude-fixture-1', + }, + stateStoreId: 'fixture-state', + type: 'hook/after-file-edit', + }; + + try { + const entry = await buildInvocationEntry(compilerRoot); + const first = await invoke(entry, { ...request, stateFile: join(compilerRoot, 'first.jsonl') }); + const second = await invoke(entry, { ...request, stateFile: join(compilerRoot, 'second.jsonl') }); + + expect(first).toMatchObject({ exitCode: 0, stderr: '' }); + expect(second).toMatchObject({ exitCode: 0, stderr: '' }); + expect(first.stdout.byteLength).toBeLessThan(1024 * 1024); + expect(first.stdout.toString('utf8')).toMatch(/^\{[^\n]+\}\n$/u); + + const firstResult = JSON.parse(first.stdout.toString('utf8')) as { + flightBytes: number; + inspection: Record; + }; + const secondResult = JSON.parse(second.stdout.toString('utf8')) as typeof firstResult; + expect(inspectionShape(secondResult)).toEqual(inspectionShape(firstResult)); + expect(firstResult.flightBytes).toBe(first.flight.byteLength); + expect(secondResult.flightBytes).toBe(second.flight.byteLength); + expect(first.flight.byteLength).toBeGreaterThan(0); + assertJsonOnly(firstResult); + expect(firstResult.inspection).toMatchObject({ + agentVisible: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', + native: { + hookSpecificOutput: { + additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', + hookEventName: 'PostToolUse', + }, + }, + state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, + trace: [ + { id: 'normalize', phase: 'normalize', status: 'succeeded' }, + { id: 'worker', phase: 'worker', status: 'succeeded' }, + { id: 'flight', phase: 'flight', status: 'succeeded' }, + { id: 'decode', phase: 'decode', status: 'succeeded' }, + { id: 'lower', phase: 'lower', status: 'succeeded' }, + ], + tree: [ + { + children: [ + { + children: [ + { children: [], id: 'node-2', kind: 'text', label: 'Recorded demo.txt from claude. Shared state now contains 1 edit.' }, + ], + id: 'node-1', + kind: 'element', + label: 'agent-hook-additional-context', + }, + ], + id: 'node-0', + kind: 'element', + label: 'agent-hook-result', + }, + ], + }); + + const codex = await invoke(entry, { + host: 'codex', + input: { + cwd: workspace, + hook_event_name: 'PostToolUse', + session_id: 'codex-session', + tool_input: { command: '*** Begin Patch\n*** Add File: codex.txt\n+content\n*** End Patch' }, + tool_name: 'apply_patch', + tool_use_id: 'codex-fixture-1', + }, + stateFile: join(compilerRoot, 'codex.jsonl'), + stateStoreId: 'fixture-state', + type: 'hook/after-file-edit', + }); + expect(codex).toMatchObject({ exitCode: 0, stderr: '' }); + expect(JSON.parse(codex.stdout.toString('utf8'))).toMatchObject({ + inspection: { + agentVisible: 'Recorded codex.txt from codex. Shared state now contains 1 edit.', + native: { + hookSpecificOutput: { + additionalContext: 'Recorded codex.txt from codex. Shared state now contains 1 edit.', + hookEventName: 'PostToolUse', + }, + }, + }, + }); + + const status = await invoke(entry, { + stateFile: join(compilerRoot, 'first.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + expect(status).toMatchObject({ exitCode: 0, stderr: '' }); + expect(JSON.parse(status.stdout.toString('utf8'))).toMatchObject({ + inspection: { + modelVisible: [ + { text: 'Runtime state contains 1 edit.', type: 'text' }, + { + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + mimeType: 'image/png', + type: 'image', + }, + ], + protocol: { + content: [ + { text: 'Runtime state contains 1 edit.', type: 'text' }, + { + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + mimeType: 'image/png', + type: 'image', + }, + ], + structuredContent: { editCount: 1, stateVersion: 1 }, + }, + state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, + }, + }); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('strictly freezes decoded inspection values while stripping only functions and symbols', () => { + const valid = serializeInspection({ + flight: Buffer.from('flight'), + node: createElement('inspection-root', { callback: () => undefined, keep: 'value', marker: Symbol('marker') }, 'text'), + stateStoreId: 'state', + stateVersion: 1, + }); + expect(valid.tree).toEqual([ + { + children: [{ children: [], id: 'node-1', kind: 'text', label: 'text' }], + id: 'node-0', + kind: 'element', + label: 'inspection-root', + props: { keep: 'value' }, + }, + ]); + + const accessor = {}; + Object.defineProperty(accessor, 'value', { enumerable: true, get: () => 'unexpected' }); + const sparse = new Array(2); + sparse[1] = 'present'; + const cycle: Record = {}; + cycle.self = cycle; + const repeatedCycle = { cycle }; + const shared = Object.freeze({ value: 'shared' }); + const cyclicChildren: ReactNode[] = []; + cyclicChildren.push(cyclicChildren); + const sparseChildren = new Array(2); + sparseChildren[1] = 'present'; + const accessorChildren = new Array(1); + Object.defineProperty(accessorChildren, '0', { enumerable: true, get: () => 'unexpected' }); + + for (const value of [accessor, sparse, new Date('2026-08-15T00:00:00.000Z'), repeatedCycle]) { + expect(() => serializeInspection({ + flight: Buffer.from('flight'), + node: createElement('inspection-root', { value }), + stateStoreId: 'state', + stateVersion: 1, + })).toThrow('Inspection JSON'); + } + expect(() => serializeInspection({ + flight: Buffer.from('flight'), + node: createElement('inspection-root', null, cyclicChildren), + stateStoreId: 'state', + stateVersion: 1, + })).toThrow('Inspection tree'); + for (const children of [sparseChildren, accessorChildren]) { + expect(() => serializeInspection({ + flight: Buffer.from('flight'), + node: createElement('inspection-root', null, children), + stateStoreId: 'state', + stateVersion: 1, + })).toThrow('Inspection tree'); + } + expect(() => serializeInspection({ + flight: Buffer.from('flight'), + node: createElement('inspection-root', null, new Date('2026-08-15T00:00:00.000Z') as unknown as ReactNode), + stateStoreId: 'state', + stateVersion: 1, + })).toThrow('Inspection JSON'); + expect(() => serializeInspection({ + flight: Buffer.from('flight'), + native: { first: shared, second: shared }, + node: createElement('inspection-root'), + stateStoreId: 'state', + stateVersion: 1, + })).toThrow('Inspection JSON'); +}); + +test('rejects unsafe timeline snapshots before emitting an inspection', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + try { + const entry = await buildInvocationEntry(compilerRoot); + const sensitiveValue = 'Bearer fixture-credential-value'; + const providerCredential = 'sk-live-abcdefghijklmnopqrstuvwxyz'; + for (const snapshot of [ + { edits: [event('one')], stateVersion: 1, unexpected: true }, + { edits: [{ ...event('two'), accessToken: 'fixture-credential-value' }], stateVersion: 1 }, + { edits: [{ ...event('three'), path: sensitiveValue }], stateVersion: 1 }, + { edits: [{ ...event('four'), path: providerCredential }], stateVersion: 1 }, + ]) { + const result = await invoke(entry, { + snapshot, + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/render-timeline', + }); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).not.toContain('fixture-credential-value'); + expect(result.stderr).not.toContain(providerCredential); + } + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('retains a supplied timeline snapshot across a deferred concurrent state-file edit', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + try { + const entry = await buildInvocationEntry(compilerRoot); + const stateFile = join(compilerRoot, 'events.jsonl'); + const rscRoot = join(compilerRoot, 'rsc', 'rsc'); + const workerPath = join(rscRoot, 'index.js'); + const delayedWorkerPath = join(rscRoot, 'index.deferred.js'); + const marker = join(compilerRoot, 'rsc-timeline-child.ready'); + await writeFile(stateFile, `${JSON.stringify(event('first'))}\n`); + await rename(workerPath, delayedWorkerPath); + await writeFile( + workerPath, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready'); setTimeout(() => require('./index.deferred.js'), 100);\n`, + ); + const invocation = startInvocation(entry, { + snapshot: { edits: [event('first')], stateVersion: 1 }, + stateFile, + stateStoreId: 'fixture-state', + type: 'mcp/render-timeline', + }); + await readWhenPresent(marker); + await appendFile(stateFile, `${JSON.stringify(event('second'))}\n`); + const result = await invocation.completed; + + expect(result).toMatchObject({ exitCode: 0, stderr: '' }); + expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ + inspection: { + protocol: { structuredContent: { edits: [event('first')], stateVersion: 1 } }, + state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, + }, + }); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('redacts bounded RSC worker stderr diagnostics', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + try { + const entry = await buildInvocationEntry(compilerRoot); + await writeFile( + join(compilerRoot, 'rsc', 'rsc', 'index.js'), + "process.stderr.write('credential=fixture-credential cookie=fixture-cookie authorization=fixture-authorization Bearer fixture-bearer-secret sk-live-abcdefghijklmnopqrstuvwxyz ghp_012345678901234567890123456789 xoxb-0123456789-0123456789-abcdefghijklmnop AKIA0123456789ABCDEF\\n'.repeat(20_000), () => process.exit(1));\n", + ); + const result = await invoke(entry, { + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(Buffer.byteLength(result.stderr, 'utf8')).toBeLessThanOrEqual(256 * 1024 + 1_024); + for (const secret of [ + 'fixture-credential', + 'fixture-cookie', + 'fixture-authorization', + 'fixture-bearer-secret', + 'sk-live-abcdefghijklmnopqrstuvwxyz', + 'ghp_012345678901234567890123456789', + 'xoxb-0123456789-0123456789-abcdefghijklmnop', + 'AKIA0123456789ABCDEF', + ]) expect(result.stderr).not.toContain(secret); + expect(result.stderr).toContain('[redacted]'); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('caps inspection stdout independently after Flight leaves its response envelope', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + try { + const entry = await buildInvocationEntry(compilerRoot); + await writeFile(join(compilerRoot, 'rsc', 'rsc', 'index.js'), oversizedMcpWorker(2_100_000)); + const result = await invoke(entry, { + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).toContain('Inspection response exceeded output limit'); + expect(result.stderr).not.toContain('x'.repeat(128)); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('bounds Flight output and waits for a SIGKILL cleanup when the RSC child ignores SIGTERM', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + let childPid: number | undefined; + let invocation: ReturnType | undefined; + try { + const entry = await buildInvocationEntry(compilerRoot); + const marker = join(compilerRoot, 'rsc-flight-child.pid'); + await writeFile( + join(compilerRoot, 'rsc', 'rsc', 'index.js'), + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); process.stdout.write('x'.repeat(5 * 1024 * 1024)); setInterval(() => undefined, 1_000);\n`, + ); + invocation = startInvocation(entry, { + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + childPid = Number(await readWhenPresent(marker)); + const result = await invocation.completed; + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + expect(result.stderr).toContain('Flight exceeded'); + await waitFor(() => !isProcessAlive(childPid as number), 'RSC child remained alive after Flight overflow'); + } finally { + invocation?.child.kill('SIGKILL'); + if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); + await rm(compilerRoot, { force: true, recursive: true }); + } +}, 6_000); + +test('forwards dev invocation termination through a SIGKILL cleanup of its RSC child', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); + let childPid: number | undefined; + let invocation: ReturnType | undefined; + try { + const entry = await buildInvocationEntry(compilerRoot); + const marker = join(compilerRoot, 'rsc-child.pid'); + await writeFile( + join(compilerRoot, 'rsc', 'rsc', 'index.js'), + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);\n`, + ); + invocation = startInvocation(entry, { + stateFile: join(compilerRoot, 'events.jsonl'), + stateStoreId: 'fixture-state', + type: 'mcp/runtime-status', + }); + childPid = Number(await readWhenPresent(marker)); + expect(Number.isSafeInteger(childPid)).toBe(true); + invocation.child.kill('SIGTERM'); + const result = await invocation.completed; + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toEqual(Buffer.alloc(0)); + await waitFor(() => !isProcessAlive(childPid as number), 'RSC child remained alive after invocation termination'); + } finally { + invocation?.child.kill('SIGKILL'); + if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); + await rm(compilerRoot, { force: true, recursive: true }); + } +}, 6_000); + +test('keeps each concurrent hook run bound to its rendered durable snapshot', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-exact-snapshot-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const firstWorkerResponse = deferred(); + const releaseFirst = deferred(); + let pauseFirst = true; + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-exact-concurrent-hook-snapshot', + signal: new AbortController().signal, + storageRoot, + }, { + afterInvocationWorkerResponse: async ({ surfaceId }) => { + if (surfaceId !== 'hook.claude' || !pauseFirst) return; + pauseFirst = false; + firstWorkerResponse.resolve(); + await releaseFirst.promise; + }, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const request = (path: string, toolUseId: string) => ({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-exact-concurrent-hook-snapshot', + tool_input: { file_path: path }, + tool_name: 'Write', + tool_use_id: toolUseId, + }, + surfaceId: 'hook.claude' as const, + target: 'claude' as const, + }); + + const first = session.invoke(request('first-coherent.ts', 'coherent-hook-a')); + await firstWorkerResponse.promise; + const second = await session.invoke(request('second-coherent.ts', 'coherent-hook-b')); + releaseFirst.resolve(); + const firstRun = await first; + + expect(firstRun).toMatchObject({ + result: { + agentVisible: 'Recorded first-coherent.ts from claude. Shared state now contains 1 edit.', + native: { hookSpecificOutput: { additionalContext: 'Recorded first-coherent.ts from claude. Shared state now contains 1 edit.' } }, + state: { + identity: { stateStoreId: 'playground', stateVersion: 1 }, + snapshot: { edits: [expect.objectContaining({ path: join(projectRoot, 'first-coherent.ts') })], stateVersion: 1 }, + }, + }, + status: 'succeeded', + vector: { runtimeGenerationId: generationId, stateVersion: 1 }, + }); + expect(second).toMatchObject({ + result: { + state: { + identity: { stateStoreId: 'playground', stateVersion: 2 }, + snapshot: { + edits: [ + expect.objectContaining({ path: join(projectRoot, 'first-coherent.ts') }), + expect.objectContaining({ path: join(projectRoot, 'second-coherent.ts') }), + ], + stateVersion: 2, + }, + }, + }, + status: 'succeeded', + vector: { runtimeGenerationId: generationId, stateVersion: 2 }, + }); + expect(session.run(firstRun.id)).toEqual(firstRun); + } finally { + releaseFirst.resolve(); + await session.close(); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('runs an exact generation-contained hook invocation and retains its immutable Flight asset', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-')); + const controller = new AbortController(); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-invocation-test', + signal: controller.signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + await expect(session.invoke({ + expectedGenerationId: 'generation-that-does-not-exist', + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-invocation-test', + tool_input: { file_path: 'timeline.ts' }, + tool_name: 'Write', + tool_use_id: 'missing-generation', + }, + surfaceId: 'hook.claude', + target: 'claude', + })).rejects.toThrow('generation-that-does-not-exist'); + expect(session.runs(50)).toEqual([]); + + await expect(session.invoke({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-invocation-test', + tool_input: { file_path: 'timeline.ts' }, + tool_name: 'Write', + }, + surfaceId: 'hook.claude', + target: 'claude', + })).rejects.toThrow('tool_use_id or event_id'); + expect(session.runs(50)).toEqual([]); + + const run = await session.invoke({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-invocation-test', + tool_input: { file_path: 'timeline.ts' }, + tool_name: 'Write', + tool_use_id: 'native-event-1', + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + + expect(run).toMatchObject({ + result: { + flight: { downloadPath: `/api/runtime/runs/${encodeURIComponent(run.id)}/flight` }, + state: { identity: { stateStoreId: 'playground', stateVersion: 1 } }, + }, + status: 'succeeded', + vector: { runtimeGenerationId: generationId, stateVersion: 1 }, + }); + const flight = await session.readRunFlight(run.id); + expect(flight?.body.byteLength).toBeGreaterThan(0); + expect(session.run(run.id)).toEqual(run); + expect(session.runs(1)).toEqual([run]); + + const replacedRunDirectory = join(storageRoot, 'replaced-run-directory'); + await mkdir(replacedRunDirectory); + await writeFile(join(replacedRunDirectory, 'flight.bin'), 'untrusted Flight'); + const trustedFlight = flight!.body; + await rm(join(storageRoot, 'runs', run.id), { force: true, recursive: true }); + await symlink(replacedRunDirectory, join(storageRoot, 'runs', run.id), 'dir'); + const afterSwap = await session.readRunFlight(run.id); + expect(afterSwap?.body).toEqual(trustedFlight); + expect(afterSwap?.body).not.toEqual(Buffer.from('untrusted Flight')); + } finally { + await session.close(); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('preserves the Claude fixture seed in post-state while exact replay stays valid', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-fixture-seed-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-fixture-seed-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const hook = session.surfaces().find((surface) => surface.id === 'hook.claude'); + const fixture = hook?.fixtures.find((candidate) => candidate.id === 'claude-post-tool-use-write'); + expect(fixture).toMatchObject({ + id: 'claude-post-tool-use-write', + seed: { + cwd: '/tmp', + hook_event_name: 'PostToolUse', + session_id: 'fixture-claude-post-tool-use', + tool_input: { file_path: 'fixture-claude-post-tool-use.txt' }, + tool_name: 'Write', + tool_use_id: 'fixture-claude-post-tool-use-write', + }, + }); + if (fixture?.seed === undefined) throw new Error('Claude fixture seed was unavailable.'); + + await expect(session.resetState({ + expectedGenerationId: generationId, + seed: fixture.seed, + stateStoreId: 'playground', + })).resolves.toEqual({ stateStoreId: 'playground', stateVersion: 1 }); + const run = await session.invoke({ + expectedGenerationId: generationId, + fixtureId: fixture.id, + input: fixture.seed, + surfaceId: 'hook.claude', + target: 'claude', + }); + expect(run).toMatchObject({ + fixtureId: fixture.id, + result: { + state: { + identity: { stateStoreId: 'playground', stateVersion: 2 }, + snapshot: { + edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], + seed: fixture.seed, + stateVersion: 2, + }, + }, + }, + status: 'succeeded', + vector: { runtimeGenerationId: generationId, stateVersion: 2 }, + }); + if (run.status !== 'succeeded') throw new Error('Fixture invocation did not succeed.'); + const postState = run.result.state.snapshot; + if (postState === null || typeof postState !== 'object' || Array.isArray(postState)) throw new Error('Fixture post-state snapshot was unavailable.'); + const postStateSeed = Object.getOwnPropertyDescriptor(postState, 'seed')?.value; + expect(Object.isFrozen(postState)).toBe(true); + expect(postStateSeed).not.toBe(fixture.seed); + expect(Object.isFrozen(postStateSeed)).toBe(true); + expect(run.result).not.toHaveProperty('app'); + + const replay = await session.replay({ mode: 'exact', runId: run.id }); + expect(replay).toMatchObject({ + fixtureId: fixture.id, + result: { state: { snapshot: { seed: fixture.seed, stateVersion: 2 } } }, + status: 'succeeded', + vector: { runtimeGenerationId: generationId, stateVersion: 2 }, + }); + + const timelineTarget = session.surfaces().find((surface) => surface.id === 'mcp.render_edit_timeline')!.targets[0]!; + const timeline = await session.invoke({ + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.render_edit_timeline', + target: timelineTarget, + }); + expect(timeline).toMatchObject({ + result: { + app: { + mcpBinding: { + definitionDigest: expect.any(String), + registryRevision: expect.any(Number), + serverDigest: expect.any(String), + serverName: 'timeline', + sessionId: expect.any(String), + sessionRevision: expect.any(Number), + target: timelineTarget, + transportDigest: expect.any(String), + }, + resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + surfaceId: 'mcp.edit-timeline', + }, + protocol: { + structuredContent: { + edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], + stateVersion: 2, + }, + }, + }, + status: 'succeeded', + }); + if (timeline.status !== 'succeeded' || timeline.result.protocol === null || typeof timeline.result.protocol !== 'object' || Array.isArray(timeline.result.protocol) || timeline.result.app === undefined) { + throw new Error('Timeline protocol was unavailable.'); + } + expect(timeline.surfaceId).toBe('mcp.render_edit_timeline'); + expect(timeline.result.app.surfaceId).toBe('mcp.edit-timeline'); + expect(Object.keys(timeline.result.app.mcpBinding).sort()).toEqual([ + 'definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest', + ]); + expect(Object.isFrozen(timeline.result.app)).toBe(true); + expect(Object.isFrozen(timeline.result.app.mcpBinding)).toBe(true); + expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)?.snapshot()).toMatchObject({ + binding: timeline.result.app.mcpBinding, + state: 'ready', + }); + const broker = session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId); + if (broker === undefined) throw new Error('Timeline App broker was unavailable.'); + const listedTools = await broker.execute({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'list-tools', + }); + const listedResources = await broker.execute({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'list-resources', + }); + expect(listedTools.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); + expect(listedResources.value).toEqual(expect.arrayContaining([expect.objectContaining({ + mimeType: 'text/html;profile=mcp-app', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + })])); + await expect(broker.execute({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'read-resource', + uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + })).resolves.toEqual(expect.objectContaining({ + value: { + contents: [{ + _meta: { + 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', + 'ui.csp': { connectDomains: [], resourceDomains: [] }, + 'ui.prefersBorder': true, + }, + mimeType: 'text/html;profile=mcp-app', + text: expect.stringMatching(/^/iu), + uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + }], + }, + })); + await expect(broker.execute({ + arguments: { limit: 1 }, + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'call-tool', + name: 'render_edit_timeline', + })).resolves.toMatchObject({ + sessionId: timeline.result.app.mcpBinding.sessionId, + sessionRevision: timeline.result.app.mcpBinding.sessionRevision, + value: { + content: [{ text: 'Showing 1 recorded edits.', type: 'text' }], + structuredContent: { edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], stateVersion: 2 }, + }, + vector: { runtimeGenerationId: generationId, stateVersion: 2 }, + }); + await expect(broker.execute({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'read-resource', + uri: 'ui://rsc-agent-runtime/foreign.html', + })).rejects.toThrow('not declared'); + await expect(broker.execute({ + arguments: {}, + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'call-tool', + name: 'foreign_tool', + })).rejects.toThrow('not declared'); + await expect(broker.execute({ + arguments: { limit: 0 }, + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + kind: 'call-tool', + name: 'render_edit_timeline', + })).rejects.toThrow('arguments'); + await expect(broker.execute({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision + 1, + kind: 'read-resource', + uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + })).rejects.toThrow('revision'); + expect(session.clientSurface(timeline.result.app.surfaceId)).toMatchObject({ surfaceId: 'mcp.edit-timeline' }); + expect((timeline.result.protocol as Record).structuredContent).not.toHaveProperty('seed'); + + const timelineRequest = Object.freeze({ + expectedGenerationId: generationId, + input: Object.freeze({}), + surfaceId: 'mcp.render_edit_timeline', + target: timelineTarget, + }); + const [repeatedTimeline, concurrentTimeline] = await Promise.all([ + session.invoke(timelineRequest), + session.invoke(timelineRequest), + ]); + for (const candidate of [repeatedTimeline, concurrentTimeline]) { + expect(candidate).toMatchObject({ status: 'succeeded' }); + if (candidate.status !== 'succeeded' || candidate.result.app === undefined) throw new Error('Repeated timeline App result was unavailable.'); + expect(candidate.result.app.mcpBinding).toEqual(timeline.result.app.mcpBinding); + } + + await session.mcpRegistry.closeSession({ + expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, + sessionId: timeline.result.app.mcpBinding.sessionId, + }); + expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)).toBeUndefined(); + const reopenedTimeline = await session.invoke(timelineRequest); + expect(reopenedTimeline).toMatchObject({ status: 'succeeded' }); + if (reopenedTimeline.status !== 'succeeded' || reopenedTimeline.result.app === undefined) throw new Error('Reopened timeline App result was unavailable.'); + expect(reopenedTimeline.result.app.mcpBinding).toMatchObject({ + definitionDigest: timeline.result.app.mcpBinding.definitionDigest, + registryRevision: timeline.result.app.mcpBinding.registryRevision, + serverDigest: timeline.result.app.mcpBinding.serverDigest, + serverName: timeline.result.app.mcpBinding.serverName, + target: timeline.result.app.mcpBinding.target, + transportDigest: timeline.result.app.mcpBinding.transportDigest, + }); + expect(reopenedTimeline.result.app.mcpBinding.sessionId).not.toBe(timeline.result.app.mcpBinding.sessionId); + + await expect(session.resetState({ + expectedGenerationId: generationId, + seed: { authorization: 'Bearer sk-live-abcdefghijklmnopqrstuvwxyz' }, + stateStoreId: 'playground', + })).rejects.toThrow('sensitive fields'); + const stateBeforeStatus = session.status().activeVector; + expect(stateBeforeStatus).toEqual(run.vector); + const status = await session.invoke({ + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target: session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!, + }); + expect(status).toMatchObject({ + result: { state: { snapshot: { seed: fixture.seed, stateVersion: 2 } } }, + status: 'succeeded', + vector: { stateVersion: 2 }, + }); + if (status.status !== 'succeeded') throw new Error('Runtime status did not succeed.'); + expect(status.result).not.toHaveProperty('app'); + expect(status.vector).toMatchObject(status.result.state.identity); + expect(status.vector).toEqual(stateBeforeStatus); + expect(session.status().activeVector).toEqual(stateBeforeStatus); + expect(session.run(status.id)).toEqual(status); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('does not spawn an invocation worker when runtime.run.started closes the session', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-started-close-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + let close: Promise | undefined; + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: (event) => { + if (event.type === 'runtime.run.started') close ??= session.close(); + }, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-started-close-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'worker-spawned-after-close'); + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned');`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + + await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) + .resolves.toMatchObject({ status: 'failed' }); + expect(close).toBeDefined(); + await close; + expect(() => readFileSync(marker)).toThrow(); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('refuses to adopt an existing or symbolic provider run root', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-run-root-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const external = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-external-runs-')); + await symlink(external, join(storageRoot, 'runs'), 'dir'); + + try { + await expect(createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-run-root-test', + signal: new AbortController().signal, + storageRoot, + })).rejects.toThrow('invocation root already exists'); + expect(await readdir(external)).toEqual([]); + } finally { + await rm(storageRoot, { force: true, recursive: true }); + await rm(external, { force: true, recursive: true }); + } +}, 30_000); + +test('refreshes a failed run vector after its generation-contained hook mutates durable state', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-failed-vector-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + let activeVectorAtFailure: unknown; + let readActiveVector = (): unknown => undefined; + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: (event) => { + if (event.type === 'runtime.run.failed') activeVectorAtFailure = readActiveVector(); + }, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-failed-vector-test', + signal: new AbortController().signal, + storageRoot, + }); + readActiveVector = () => session.status().activeVector; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + const original = `${entry}.original`; + await rename(entry, original); + await writeFile(entry, ` +process.stdout.write = () => { process.exitCode = 1; return true; }; +require(${JSON.stringify(original)}); +`); + + const run = await session.invoke({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-failed-vector-test', + tool_input: { file_path: 'failed-vector.ts' }, + tool_name: 'Write', + tool_use_id: 'failed-vector-hook', + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + + expect(run).toMatchObject({ status: 'failed', vector: { runtimeGenerationId: generationId, stateVersion: 1 } }); + expect(activeVectorAtFailure).toEqual(run.vector); + expect(session.status().activeVector).toEqual(run.vector); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('pins an overlapping g1 invocation while exact replay stays on g1 and latest replay advances to g2', async () => { + const copied = await copyInvocationExample(); + const storageRoot = join(copied.workspaceRoot, 'runtime-storage'); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-generation-pinning-test', + signal: new AbortController().signal, + storageRoot, + }); + let blocked: Promise | undefined; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const g1 = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const completedG1 = await session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); + expect(completedG1).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); + + const g1Worker = join(storageRoot, 'generation-store', 'generations', g1, 'rsc', 'rsc', 'index.js'); + const originalG1Worker = await readFile(g1Worker); + const marker = join(storageRoot, 'g1-blocked-worker.txt'); + await writeFile(g1Worker, ` +require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready'); +process.on('SIGTERM', () => undefined); +setInterval(() => undefined, 1_000); +`); + blocked = session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); + await readWhenPresent(marker); + + const workerSource = join(copied.projectRoot, 'src', 'rsc', 'worker.tsx'); + const source = await readFile(workerSource, 'utf8'); + await writeFile(workerSource, source.replace('RSC worker received an invalid event', 'RSC worker received an invalid event generation-two')); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + const g2 = session.status().activeVector!.runtimeGenerationId; + await writeFile(g1Worker, originalG1Worker); + + const exact = await session.replay({ expectedGenerationId: g1, mode: 'exact', runId: completedG1.id }); + const latest = await session.replay({ expectedGenerationId: g2, mode: 'latest', runId: completedG1.id }); + expect(exact).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); + expect(latest).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g2 } }); + } finally { + await session.close().catch(() => undefined); + await blocked?.catch(() => undefined); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('replays an exact historical surface after generation two removes it', async () => { + const copied = await copyInvocationExample(); + const storageRoot = join(copied.workspaceRoot, 'runtime-storage'); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-historical-surface-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const g1 = session.status().activeVector!.runtimeGenerationId; + const run = await session.invoke({ + expectedGenerationId: g1, + input: { + cwd: copied.projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-historical-surface-test', + tool_input: { file_path: 'g1.ts' }, + tool_name: 'Write', + tool_use_id: 'historical-surface', + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + expect(run).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); + + const definition = join(copied.projectRoot, 'src', 'definition.ts'); + const source = await readFile(definition, 'utf8'); + await writeFile(definition, source.replace(" host: 'claude',", " host: 'codex',")); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + const g2 = session.status().activeVector!.runtimeGenerationId; + + await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: run.id })) + .resolves.toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); + await expect(session.replay({ expectedGenerationId: g2, mode: 'latest', runId: run.id })) + .rejects.toThrow('does not exist'); + } finally { + await session.close().catch(() => undefined); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('releases an exact historical lease when four active workers reject its admission', async () => { + const copied = await copyInvocationExample(); + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-exact-lease-capacity'); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-exact-lease-capacity', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const g1 = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const historical = await session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); + expect(historical).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); + + const definition = join(copied.projectRoot, 'src', 'definition.ts'); + await appendFile(definition, '\n// exact-lease-capacity-g2\n'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + const g2 = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'blocked-exact-lease-workers.txt'); + const worker = join(storageRoot, 'generation-store', 'generations', g2, 'rsc', 'rsc', 'index.js'); + await writeFile(worker, ` +import { appendFileSync } from 'node:fs'; +appendFileSync(${JSON.stringify(marker)}, 'ready\\n'); +setTimeout(() => process.exit(0), 1_000); +`); + const workers = Array.from({ length: 4 }, () => session.invoke({ + expectedGenerationId: g2, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + })); + await waitFor(() => { + try { + return readFileSync(marker, 'utf8').trim().split('\n').length === 4; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + }, 'Timed out waiting for four capacity workers'); + + await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: historical.id })) + .rejects.toThrow('limit of 4 concurrent workers'); + await Promise.all(workers); + + let active = g2; + for (let generation = 3; generation <= 8; generation += 1) { + await appendFile(definition, `// exact-lease-capacity-g${String(generation)}\\n`); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== active, `Timed out waiting for generation ${String(generation)}`, 15_000); + active = session.status().activeVector!.runtimeGenerationId; + } + await waitFor(() => !existsSync(join(storageRoot, 'generation-store', 'generations', g1)), 'Exact replay leaked generation one after capacity rejection', 15_000); + } finally { + await session.close().catch(() => undefined); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 90_000); + +test('closes the provider-owned invocation process group without orphaning its RSC grandchild', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-close-')); + const controller = new AbortController(); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-close-test', + signal: controller.signal, + storageRoot, + }); + let grandchildPid: number | undefined; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'rsc-invocation-grandchild.pid'); + const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); + await writeFile(worker, ` +const { spawn } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); +const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)']); +writeFileSync(${JSON.stringify(marker)}, String(child.pid)); +process.on('SIGTERM', () => undefined); +setInterval(() => undefined, 1000); +`); + + const invocation = session.invoke({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-close-test', + tool_input: { file_path: 'timeline.ts' }, + tool_name: 'Write', + tool_use_id: 'native-event-close', + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + grandchildPid = Number(await readWhenPresent(marker)); + expect(Number.isSafeInteger(grandchildPid)).toBe(true); + await session.close(); + await expect(invocation).resolves.toMatchObject({ status: 'failed' }); + await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC invocation grandchild remained alive after provider close'); + expect(session.run('any-run')).toBeUndefined(); + expect(session.runs(1)).toEqual([]); + await expect(session.readRunFlight('any-run')).resolves.toBeUndefined(); + expect(() => readFileSync(join(storageRoot, 'runs'))).toThrow(); + } finally { + if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('hard-kills the invocation process group when its leader exits before an RSC grandchild', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-leader-exit-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-leader-exit-test', + signal: new AbortController().signal, + storageRoot, + }); + let grandchildPid: number | undefined; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'rsc-invocation-leader-exit-grandchild.pid'); + const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); + await writeFile(worker, ` +const { spawn } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); +const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { stdio: 'ignore' }); +writeFileSync(${JSON.stringify(marker)}, String(child.pid)); +process.exit(0); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + grandchildPid = Number(await readWhenPresent(marker)); + + expect(run).toMatchObject({ status: 'failed' }); + await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC grandchild remained alive after invocation leader exit'); + } finally { + if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('settles a successful invocation only after its TERM-resistant RSC grandchild exits', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-success-tree-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-success-tree-test', + signal: new AbortController().signal, + storageRoot, + }); + let grandchildPid: number | undefined; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'rsc-invocation-success-grandchild.pid'); + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +const { spawn } = require('node:child_process'); +const { writeFileSync, writeSync } = require('node:fs'); +const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { stdio: 'ignore' }); +child.unref(); +writeFileSync(${JSON.stringify(marker)}, String(child.pid)); +writeSync(3, Buffer.from('x')); +process.stdout.end(JSON.stringify({ + flightBytes: 1, + inspection: { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: [], + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + grandchildPid = Number(await readWhenPresent(marker)); + + expect(run).toMatchObject({ status: 'succeeded' }); + await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC invocation grandchild remained alive after successful invocation'); + } finally { + if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('keeps a detached successful worker grandchild in its Windows Job Object until it dies', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-job-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-windows-job-test', + signal: new AbortController().signal, + storageRoot, + }); + let grandchildPid: number | undefined; + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'rsc-invocation-windows-job-grandchild.pid'); + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +const { spawn } = require('node:child_process'); +const { writeFileSync, writeSync } = require('node:fs'); +const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { detached: true, stdio: 'ignore' }); +child.unref(); +writeFileSync(${JSON.stringify(marker)}, String(child.pid)); +writeSync(3, Buffer.from('x')); +process.stdout.end(JSON.stringify({ + flightBytes: 1, + inspection: { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: [], + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + grandchildPid = Number(await readWhenPresent(marker)); + + expect(run).toMatchObject({ status: 'succeeded' }); + const flight = await session.readRunFlight(run.id); + expect(flight?.body).toEqual(Buffer.from('x')); + await waitFor(() => !isProcessAlive(grandchildPid as number), 'Windows Job Object left a detached RSC grandchild alive after invocation'); + await session.close(); + expect(isProcessAlive(grandchildPid as number)).toBe(false); + } finally { + if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('bounds a hung Windows Job owner before it can arm the invocation wrapper', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-hang-')); + const marker = join(storageRoot, 'wrapper-ran'); + const startedAt = Date.now(); + const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'hang-ready'); + + try { + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + + await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) + .resolves.toMatchObject({ status: 'failed' }); + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(existsSync(marker)).toBe(false); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('bounds a broken Windows Job owner control pipe and drains its assigned wrapper', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-pipe-')); + const marker = join(storageRoot, 'wrapper-ran'); + const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'close-control'); + + try { + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran'); +setInterval(() => undefined, 1000); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const invocation = session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + await readWhenPresent(marker); + const startedAt = Date.now(); + + await session.close(); + await expect(invocation).resolves.toMatchObject({ status: 'failed' }); + expect(Date.now() - startedAt).toBeLessThan(5_000); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('forces an ignored Windows Job owner STOP without leaving its wrapper alive', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-stop-')); + const marker = join(storageRoot, 'wrapper.pid'); + const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'ignore-stop'); + let wrapperPid: number | undefined; + + try { + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); +setInterval(() => undefined, 1000); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const invocation = session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + wrapperPid = Number(await readWhenPresent(marker)); + const startedAt = Date.now(); + + await session.close(); + await expect(invocation).resolves.toMatchObject({ status: 'failed' }); + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(isProcessAlive(wrapperPid)).toBe(false); + } finally { + if (wrapperPid !== undefined && isProcessAlive(wrapperPid)) process.kill(wrapperPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('fails a nonzero Windows Job owner only after its resistant descendant is drained', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-nonzero-')); + const marker = join(storageRoot, 'rsc-invocation-windows-owner-nonzero-grandchild.pid'); + const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'nonzero-after-drain'); + let grandchildPid: number | undefined; + + try { + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +const { spawn } = require('node:child_process'); +const { writeFileSync, writeSync } = require('node:fs'); +const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { detached: true, stdio: 'ignore' }); +child.unref(); +writeFileSync(${JSON.stringify(marker)}, String(child.pid)); +writeSync(3, Buffer.from('x')); +process.stdout.end(JSON.stringify({ + flightBytes: 1, + inspection: { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: [], + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); + grandchildPid = Number(await readWhenPresent(marker)); + + expect(run).toMatchObject({ status: 'failed' }); + await waitFor(() => !isProcessAlive(grandchildPid as number), 'Windows Job owner reported failure before draining its descendant'); + } finally { + if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +windowsTest('never invokes taskkill after a Windows Job has owned and drained the wrapper', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-no-taskkill-')); + const commandRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-fake-taskkill-')); + const taskkillMarker = join(commandRoot, 'taskkill-invoked'); + const pathBefore = process.env.PATH; + const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'normal'); + + try { + await writeFile(join(commandRoot, 'taskkill.cmd'), `@echo invoked>"${taskkillMarker}"\r\n@exit /b 0\r\n`); + process.env.PATH = `${commandRoot};${pathBefore ?? ''}`; + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + await writeFile(entry, ` +const { writeSync } = require('node:fs'); +writeSync(3, Buffer.from('x')); +process.stdout.end(JSON.stringify({ + flightBytes: 1, + inspection: { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: [], + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) + .resolves.toMatchObject({ status: 'succeeded' }); + await session.close(); + expect(existsSync(taskkillMarker)).toBe(false); + } finally { + process.env.PATH = pathBefore; + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + await rm(commandRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('keeps the newest fifty immutable run artifacts and evicts the oldest completed Flight', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-history-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-history-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const first = await session.invoke({ + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + }); + if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); + const firstFlight = await session.readRunFlight(first.id); + expect(firstFlight?.body.byteLength).toBeGreaterThan(0); + await session.resetState({ expectedGenerationId: generationId, stateStoreId: 'playground' }); + expect(session.run(first.id)).toEqual(first); + + for (let index = 0; index < 50; index += 1) { + const run = await session.invoke({ + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + }); + expect(run.status).toBe('succeeded'); + } + + expect(session.run(first.id)).toBeUndefined(); + await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); + await expect(session.readRunFlight('../flight.bin')).resolves.toBeUndefined(); + expect(session.runs(50)).toHaveLength(50); + expect(session.runs(50)[0]!.id).not.toBe(first.id); + expect((await readdir(join(storageRoot, 'runs'))).filter((entry) => entry !== '.agent-bundle-runtime-owner')).toHaveLength(50); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 45_000); + +test('retains a failed invocation Flight artifact until its explicit session-close release succeeds', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-artifact-release-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + let failRelease = true; + let releaseAttempts = 0; + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-artifact-release-test', + signal: new AbortController().signal, + storageRoot, + }, { + afterInvocationWorkerResponse: () => { throw new Error('forced invocation failure'); }, + beforeRunArtifactRelease: () => { + releaseAttempts += 1; + if (failRelease) throw new Error('do-not-expose-run-artifact-release-secret'); + }, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const run = await session.invoke({ + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + }); + + expect(run).toMatchObject({ + diagnostics: [expect.objectContaining({ + message: 'RSC runtime invocation cleanup failed; cleanup failures: run-artifact.', + })], + status: 'failed', + }); + expect(run.status === 'failed' && run.diagnostics[0]!.message).not.toContain('do-not-expose-run-artifact-release-secret'); + expect(releaseAttempts).toBe(1); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([run.id])); + + failRelease = false; + const closing = session.close(); + expect(session.close()).toBe(closing); + await expect(closing).resolves.toBeUndefined(); + expect(releaseAttempts).toBe(2); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 45_000); + +test('keeps the oldest artifact and terminal history owned when eviction release fails', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-release-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + let firstRunId: string | undefined; + let failedReleaseAttempts = 0; + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-eviction-release-test', + signal: new AbortController().signal, + storageRoot, + }, { + beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId !== firstRunId) return; + failedReleaseAttempts += 1; + throw new Error('do-not-expose-eviction-release-secret'); + }, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const request = { + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + } as const; + const first = await session.invoke(request); + if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); + firstRunId = first.id; + + for (let index = 0; index < 49; index += 1) { + await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); + } + await expect(session.invoke(request)).rejects.toThrow('RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.'); + + expect(failedReleaseAttempts).toBeGreaterThan(0); + expect(session.run(first.id)).toEqual(first); + await expect(session.readRunFlight(first.id)).resolves.toMatchObject({ body: expect.any(Buffer) }); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); + + const closing = session.close(); + expect(session.close()).toBe(closing); + await expect(closing).rejects.toMatchObject({ + message: 'RSC runtime session close failed; cleanup failures: run-artifact.', + }); + await expect(closing).rejects.not.toThrow('do-not-expose-eviction-release-secret'); + expect(failedReleaseAttempts).toBeGreaterThan(1); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('reserves an evicting terminal run before draining its admitted Flight readers', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-reader-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const readerEntered = deferred(); + const releaseReader = deferred(); + const evictionReserved = deferred(); + let firstRunId: string | undefined; + let holdFirstReader = false; + let firstReaderAdmissions = 0; + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-eviction-reader-test', + signal: new AbortController().signal, + storageRoot, + }, { + afterRunArtifactEvictionReserved: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === firstRunId) evictionReserved.resolve(); + }, + beforeRunFlightRead: async ({ runId }: Readonly<{ readonly runId: string }>) => { + if (!holdFirstReader || runId !== firstRunId) return; + firstReaderAdmissions += 1; + if (firstReaderAdmissions !== 1) return; + readerEntered.resolve(); + await releaseReader.promise; + }, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const request = { + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + } as const; + const first = await session.invoke(request); + if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); + firstRunId = first.id; + + holdFirstReader = true; + const admittedReader = session.readRunFlight(first.id); + await readerEntered.promise; + for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); + + const evicting = session.invoke(request); + await evictionReserved.promise; + await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); + expect(firstReaderAdmissions).toBe(1); + + releaseReader.resolve(); + await expect(admittedReader).resolves.toMatchObject({ body: expect.any(Buffer) }); + await expect(evicting).resolves.toMatchObject({ status: 'succeeded' }); + await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); + } finally { + releaseReader.resolve(); + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 90_000); + +test('finalizes successful history before a failed evicted run-directory removal', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-directory-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + let firstRunId: string | undefined; + let failFirstDirectoryRemoval = true; + let firstArtifactReleaseAttempts = 0; + let firstDirectoryRemovalAttempts = 0; + const session = await RsbuildRuntimeSession.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-eviction-directory-test', + signal: new AbortController().signal, + storageRoot, + }, { + beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === firstRunId) firstArtifactReleaseAttempts += 1; + }, + beforeRunDirectoryRemoval: ({ runId }: Readonly<{ readonly runId: string }>) => { + if (runId === firstRunId) firstDirectoryRemovalAttempts += 1; + if (failFirstDirectoryRemoval && runId === firstRunId) { + failFirstDirectoryRemoval = false; + throw new Error('do-not-expose-evicted-run-directory-removal-secret'); + } + }, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const request = { + expectedGenerationId: generationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + } as const; + const first = await session.invoke(request); + if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); + firstRunId = first.id; + + for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); + const evictionFailure = await session.invoke(request); + + expect(evictionFailure).toMatchObject({ + diagnostics: [expect.objectContaining({ message: 'RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.' })], + status: 'failed', + }); + expect(evictionFailure.status === 'failed' && evictionFailure.diagnostics[0]!.message) + .not.toContain('do-not-expose-evicted-run-directory-removal-secret'); + expect(session.run(first.id)).toBeUndefined(); + await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); + expect(firstArtifactReleaseAttempts).toBe(1); + + await expect(session.close()).resolves.toBeUndefined(); + expect(firstArtifactReleaseAttempts).toBe(1); + expect(firstDirectoryRemovalAttempts).toBe(2); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('rejects a fifth blocked generation worker and settles every leased worker on close', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-bound-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-bound-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const marker = join(storageRoot, 'blocked-workers.txt'); + const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); + await writeFile(worker, ` +const { appendFileSync } = require('node:fs'); +appendFileSync(${JSON.stringify(marker)}, 'ready\\n'); +process.on('SIGTERM', () => undefined); +setInterval(() => undefined, 1000); +`); + const request = (id: string) => ({ + expectedGenerationId: generationId, + input: { + cwd: projectRoot, + hook_event_name: 'PostToolUse', + session_id: 'session-bound-test', + tool_input: { file_path: 'timeline.ts' }, + tool_name: 'Write', + tool_use_id: id, + }, + surfaceId: 'hook.claude', + target: 'claude', + }); + const workers = ['one', 'two', 'three', 'four'].map((id) => session.invoke(request(id))); + const fifth = session.invoke(request('five')); + await waitFor(() => { + try { + return readFileSync(marker, 'utf8').trim().split('\n').length >= 4; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + }, 'Timed out waiting for four blocked invocation workers'); + await expect(fifth).rejects.toThrow('limit of 4 concurrent workers'); + expect(readFileSync(marker, 'utf8').trim().split('\n')).toHaveLength(4); + await session.close(); + await expect(Promise.all(workers)).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ status: 'failed' }), + ])); + expect(session.runs(1)).toEqual([]); + expect(() => readFileSync(join(storageRoot, 'runs'))).toThrow(); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('contains invocation stdout, stderr, and timeout failures without retaining partial run artifacts', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-output-')); + const projectRoot = process.cwd(); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); + const session = await createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'session-output-test', + signal: new AbortController().signal, + storageRoot, + }); + + try { + await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); + const generationId = session.status().activeVector!.runtimeGenerationId; + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); + const request = { expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target } as const; + + await writeFile(entry, `process.stdout.write('x'.repeat(${(4 * 1024 * 1024) + 1}));`); + const stdout = await session.invoke(request); + expect(stdout).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('stdout exceeded') })], status: 'failed' }); + + await writeFile(entry, "process.stderr.write('credential=fixture-credential '.repeat(30000));"); + const stderr = await session.invoke(request); + expect(stderr).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('stderr exceeded') })], status: 'failed' }); + if (stderr.status === 'failed') expect(stderr.diagnostics[0]!.message).not.toContain('fixture-credential'); + + await writeFile(entry, "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000);"); + const startedAt = Date.now(); + const timeout = await session.invoke(request); + expect(timeout).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('exceeded 10000 ms') })], status: 'failed' }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(9_000); + + await writeFile(entry, ` +require('node:fs').writeSync(3, Buffer.from('x')); +process.stdout.end(JSON.stringify({ + flightBytes: 1, + inspection: { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: 'token=worker-response-secret', + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }, +}) + '\\n'); +`); + const credential = await session.invoke(request); + expect(credential).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('credentials') })], status: 'failed' }); + if (credential.status === 'failed') expect(credential.diagnostics[0]!.message).not.toContain('worker-response-secret'); + + const malformed = async (inspection: Record) => { + await writeFile(entry, ` +require('node:fs').writeSync(3, Buffer.from('x')); +process.stdout.end(${JSON.stringify(`${JSON.stringify({ flightBytes: 1, inspection })}\n`)}); +`); + const run = await session.invoke(request); + expect(run).toMatchObject({ status: 'failed' }); + await expect(session.readRunFlight(run.id)).resolves.toBeUndefined(); + }; + const validInspection = { + flight: { bytes: 1, preview: 'eA==', truncated: false }, + modelVisible: [], + protocol: [], + state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, + trace: [], + tree: [], + }; + await malformed({ ...validInspection, tree: [{ children: {}, id: 'node', kind: 'element', label: 'bad' }] }); + await malformed({ ...validInspection, tree: [{ children: [], id: 'node', kind: 'element', label: 'bad', props: [] }] }); + await malformed({ ...validInspection, tree: [{ children: [], id: 'node', kind: 'element', label: 'bad', props: null }] }); + await malformed({ ...validInspection, trace: [{ id: '', phase: 'render', startedAt: 'not-a-date', status: 'unknown' }] }); + await malformed({ ...validInspection, trace: [{ details: null, id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); + await malformed({ ...validInspection, trace: [{ details: [], id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); + await malformed({ ...validInspection, app: { mcpBinding: {}, resourceUri: 'ui://unsafe', surfaceId: 'mcp.timeline' } }); + expect(await readdir(join(storageRoot, 'runs'))).toEqual(['.agent-bundle-runtime-owner']); + } finally { + await session.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 45_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts new file mode 100644 index 000000000..ac971022b --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -0,0 +1,1683 @@ +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { expect, test } from '@rstest/core'; +import type { createRsbuild, StartDevServerResult } from '@rsbuild/core'; + +import { + ArtifactService, + ProjectService, +} from '../../../packages/agent-bundle/src/dev/index.ts'; +import { EpochStore } from '../../../packages/agent-bundle/src/dev/epoch-store.ts'; +import { resolveDevRuntimeProvider } from '../../../packages/agent-bundle/src/dev/runtime-provider-loader.ts'; +import { + createRscRuntimeRsbuildConfig, + type RscRuntimeActivationOutcome, + type RscRuntimeCompileSnapshot, +} from '../rsbuild.config.js'; +import { createDevRuntimeProvider } from '../src/dev/provider.js'; +import { ResourceLedger, RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; +import { copyExample, type CopiedExample } from './support/copy-example.ts'; + +const exampleRoot = process.cwd(); + +const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 15_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for the RSC runtime provider.'); + await new Promise((resolve) => { setTimeout(resolve, 25); }); + } +}; + +const deferred = () => { + let reject!: (reason?: unknown) => void; + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return Object.freeze({ promise, reject, resolve }); +}; + +const compileObserver = (onCompile: NonNullable[0]['onCompile']>) => { + const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-observer'), mode: 'development', onCompile }); + const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ + readonly name: string; + setup(api: unknown): void; + }> => typeof candidate === 'object' && candidate !== null && + (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-compile-observer'); + if (plugin === undefined) throw new Error('RSC compiler observer plugin is unavailable.'); + let before: (() => void) | undefined; + let after: ((input: unknown) => Promise) | undefined; + plugin.setup({ + onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, + onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, + }); + return Object.freeze({ + async compile(input: Readonly<{ + readonly children?: readonly unknown[]; + readonly hasErrors?: boolean; + }> = {}): Promise { + before?.(); + await after?.({ + stats: { + hasErrors: () => input.hasErrors ?? false, + toJson: () => ({ children: input.children ?? [{ hash: 'rsc-hash', name: 'rsc' }, { hash: 'widget-hash', name: 'widget' }] }), + }, + }); + }, + }); +}; + +const snapshotFor = (attemptId: string, sourceRevision: string): RscRuntimeCompileSnapshot => Object.freeze({ + attemptId, + candidateId: attemptId, + preparedRevision: 'prepared', + rscCohortRevision: 1, + sourceRevision, +}); + +const startContext = (input: Readonly<{ + readonly projectRoot: string; + readonly preparedRuntime: NonNullable>['devRuntime']>; + readonly providerSessionId: string; + readonly signal: AbortSignal; + readonly storageRoot: string; +}>) => Object.freeze({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: input.projectRoot, + preparedRuntime: input.preparedRuntime, + providerSessionId: input.providerSessionId, + signal: input.signal, + storageRoot: input.storageRoot, +}); + +const copyProviderExample = async (): Promise => + copyExample(exampleRoot, { linkPackages: true, prefix: 'rsc-agent-runtime-provider-' }); + +/** + * Replaces source atomically through a same-directory rename. An in-place + * write is truncate-then-append, which a loaded watcher observes as two + * change events and compiles twice; the duplicate attempt supersedes the + * generation that ordinal-pinned assertions expect to commit. + */ +const replaceSource = async (path: string, replace: (source: string) => string): Promise => { + const source = await readFile(path, 'utf8'); + const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + await writeFile(temporary, replace(source)); + await rename(temporary, path); +}; + +const changeDefinition = async (projectRoot: string, replacement: string): Promise => { + await replaceSource( + join(projectRoot, 'src', 'definition.ts'), + (source) => source.replace('Read the current shared runtime state.', replacement), + ); +}; + +const changeWorkerImplementation = async (projectRoot: string, marker: string): Promise => { + await replaceSource( + join(projectRoot, 'src', 'rsc', 'worker.tsx'), + (source) => source.replace( + /RSC worker received an invalid event(?: [^']*)?/u, + `RSC worker received an invalid event ${marker}`, + ), + ); +}; + +const introduceWorkerSyntaxError = async (projectRoot: string): Promise => { + await replaceSource( + join(projectRoot, 'src', 'rsc', 'worker.tsx'), + (source) => `${source}\nconst = ;\n`, + ); +}; + +test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => { + const captured: string[] = []; + const config = createRscRuntimeRsbuildConfig({ + compilerRoot: join(tmpdir(), 'rsc-provider-hmr-token'), + mode: 'development', + onAppWebSocketToken: (token: string) => { captured.push(token); }, + } as Parameters[0]); + const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ + readonly name: string; + setup(api: unknown): void; + }> => typeof candidate === 'object' && candidate !== null && + (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); + if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); + let afterCreate: ((input: unknown) => void) | undefined; + plugin.setup({ + onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, + onAfterEnvironmentCompile: () => undefined, + onBeforeStartDevServer: () => undefined, + onCloseDevServer: () => undefined, + }); + afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); + expect(captured).toEqual(['rsbuild-token-1234']); +}); + +test('sends one App-only full reload for each later successful App compilation', async () => { + const captured: string[] = []; + const config = createRscRuntimeRsbuildConfig({ + compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'), + mode: 'development', + onAppWebSocketToken: (token: string) => { captured.push(token); }, + } as Parameters[0]); + const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ + readonly name: string; + setup(api: unknown): void; + }> => typeof candidate === 'object' && candidate !== null && + (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); + if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); + + let afterCompiler: ((input: unknown) => void) | undefined; + let afterEnvironmentCompile: ((input: unknown) => void) | undefined; + let beforeStartDevServer: ((input: unknown) => unknown) | undefined; + let closeDevServer: (() => unknown) | undefined; + plugin.setup({ + onAfterCreateCompiler: (callback: unknown) => { afterCompiler = callback as (input: unknown) => void; }, + onAfterEnvironmentCompile: (callback: unknown) => { afterEnvironmentCompile = callback as (input: unknown) => void; }, + onBeforeStartDevServer: (callback: unknown) => { beforeStartDevServer = callback as (input: unknown) => unknown; }, + onCloseDevServer: (callback: unknown) => { closeDevServer = callback as () => unknown; }, + }); + + const appSends: string[] = []; + const otherSends: string[] = []; + const firstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: true, stats: { hasErrors: () => false, hash: 'app-change-a' } }); + const duplicateFirstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } }); + const appBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } }); + const appAUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } }); + const repeatedAppBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } }); + const failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } }); + const nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } }); + + afterCompiler?.({ environments: { app: { webSocketToken: 'rsbuild-app-token-1234' }, widget: { webSocketToken: 'widget-token-must-not-leak' } } }); + afterEnvironmentCompile?.(appBUpdate); + expect(appSends).toEqual([]); + beforeStartDevServer?.({ + server: { + environments: { + app: { hot: { send: (type: string) => { appSends.push(type); } } }, + widget: { hot: { send: (type: string) => { otherSends.push(type); } } }, + }, + }, + }); + afterEnvironmentCompile?.(firstAppUpdate); + afterEnvironmentCompile?.(nonAppUpdate); + afterEnvironmentCompile?.(failedAppUpdate); + afterEnvironmentCompile?.(duplicateFirstAppUpdate); + expect(captured).toEqual(['rsbuild-app-token-1234']); + expect(appSends).toEqual([]); + + afterEnvironmentCompile?.(appBUpdate); + expect(appSends).toEqual(['full-reload']); + afterEnvironmentCompile?.(appAUpdate); + expect(appSends).toEqual(['full-reload', 'full-reload']); + afterEnvironmentCompile?.(repeatedAppBUpdate); + expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']); + expect(otherSends).toEqual([]); + + await closeDevServer?.(); + afterEnvironmentCompile?.(appAUpdate); + expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']); + + const replacementSends: string[] = []; + beforeStartDevServer?.({ server: { environments: { app: { hot: { send: (type: string) => { replacementSends.push(type); } } } } } }); + afterEnvironmentCompile?.(appBUpdate); + expect(replacementSends).toEqual(['full-reload']); +}); + +test('keeps compiler-App HMR out of the opaque browser child', () => { + const config = createRscRuntimeRsbuildConfig({ + compilerRoot: join(tmpdir(), 'rsc-provider-outer-hmr'), + mode: 'development', + }); + const app = config.environments?.app as Readonly<{ readonly dev?: unknown }> | undefined; + expect(app?.dev).toMatchObject({ hmr: false, liveReload: false }); +}); + +test('declares an optional runtime while keeping Claude and Codex artifacts buildable', async () => { + const copied = await copyProviderExample(); + try { + const root = copied.projectRoot; + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root }).prepare('dev'); + + expect(prepared.source.state).toBe('ready'); + expect(prepared.devRuntime).toMatchObject({ + apps: [expect.objectContaining({ name: 'timeline', resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' })], + provider: './src/dev/provider.ts', + servers: [expect.objectContaining({ name: 'timeline', transport: 'stdio' })], + }); + expect(prepared.model?.hooks).toEqual(expect.arrayContaining([ + expect.objectContaining({ targets: expect.arrayContaining(['claude', 'codex']) }), + ])); + + const artifact = await new ArtifactService({ epochStore: new EpochStore({ projectRoot: root }) }).build(prepared); + if (artifact.outcome !== 'succeeded') throw new Error(JSON.stringify(artifact.diagnostics)); + expect(artifact).toMatchObject({ outcome: 'succeeded' }); + const provider = createDevRuntimeProvider(); + const runtimeStorageRoot = join(root, '.agent-bundle', 'runtime-test'); + expect(provider.descriptor).toEqual({ + environmentVariables: [], + id: 'rsc-agent-runtime', + label: 'RSC agent runtime', + schemaVersion: 1, + }); + const session = await provider.start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: root, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-test', + signal: new AbortController().signal, + storageRoot: runtimeStorageRoot, + }); + try { + await waitFor(() => session.status().state === 'active'); + expect(session.status()).toMatchObject({ hmrReady: true, state: 'active' }); + expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ + entryPath: '/edit-timeline-v1.html', + httpOrigin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:[1-9]\d*$/u), + httpPathPrefixes: ['/'], + surfaceId: 'mcp.edit-timeline', + webSocketOrigin: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:[1-9]\d*$/u), + webSocketPath: '/rsbuild-hmr', + }); + expect(session.status()).not.toHaveProperty('clientSurface'); + expect(session.surfaces()).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'hook' }), + expect.objectContaining({ id: 'mcp.render_edit_timeline', kind: 'mcp-tool' }), + expect.objectContaining({ id: 'mcp.edit-timeline', kind: 'mcp-resource' }), + expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app' }), + ])); + const registry = session.mcpRegistry.snapshot(); + expect(registry).toMatchObject({ runtimeGenerationId: expect.any(String) }); + expect([...new Set([ + registry!.definitionDigest, + registry!.servers[0]!.serverDigest, + registry!.transportDigest, + ])]).toHaveLength(3); + + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: registry!.runtimeGenerationId, + surfaceId: 'mcp.timeline', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['..'], + runtimeGenerationId: registry!.runtimeGenerationId, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: registry!.runtimeGenerationId, + surfaceId: 'mcp.unknown', + })).resolves.toBeUndefined(); + for (const path of [ + ['rsc', 'missing.html'], + ['..'], + ['.'], + ['rsc\\index.html'], + ['rsc', 'index\0.html'], + ['%2e%2e'], + ]) { + await expect(session.readAsset({ + path, + runtimeGenerationId: registry!.runtimeGenerationId, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + } + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: '', + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: 'generation-pruned', + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + const assetPath = join( + runtimeStorageRoot, + 'generation-store', + 'generations', + registry!.runtimeGenerationId, + 'widget', + 'rsc', + 'index.html', + ); + const originalAsset = await readFile(assetPath); + const readTimelineAsset = () => session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: registry!.runtimeGenerationId, + surfaceId: 'mcp.timeline', + }); + const digestTampered = Buffer.from(originalAsset); + digestTampered[0] = digestTampered[0] === 0 ? 1 : 0; + await writeFile(assetPath, digestTampered); + await expect(readTimelineAsset()).resolves.toBeUndefined(); + await writeFile(assetPath, originalAsset); + await writeFile(assetPath, Buffer.alloc((8 * 1024 * 1024) + 1)); + await expect(readTimelineAsset()).resolves.toBeUndefined(); + await writeFile(assetPath, originalAsset); + await rm(assetPath); + await symlink(join(root, 'src', 'definition.ts'), assetPath); + await expect(readTimelineAsset()).resolves.toBeUndefined(); + await rm(assetPath); + await mkdir(assetPath); + await expect(readTimelineAsset()).resolves.toBeUndefined(); + await rm(assetPath, { recursive: true }); + await writeFile(assetPath, originalAsset); + + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + const initialCapabilities = mcp.snapshot().connection.capabilities; + if (initialCapabilities === undefined) throw new Error('Expected runtime MCP capabilities.'); + expect(initialCapabilities).toEqual({ resources: {}, tools: {} }); + expect(Object.isFrozen(initialCapabilities)).toBe(true); + expect(Object.isFrozen(initialCapabilities.resources)).toBe(true); + expect(Object.isFrozen(initialCapabilities.tools)).toBe(true); + const list = await mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' }); + expect(list.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); + const originalBinding = mcp.snapshot().binding; + await session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: prepared.devRuntime!.apps.map((app) => ({ + ...app, + _meta: { ...app._meta, 'openai/widgetDescription': 'Updated timeline description.' }, + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-app-metadata`, + }); + const reconciledRegistry = session.mcpRegistry.snapshot(); + expect(reconciledRegistry!.definitionDigest).not.toBe(registry!.definitionDigest); + expect(reconciledRegistry).toMatchObject({ + registryRevision: originalBinding.registryRevision + 1, + runtimeGenerationId: registry!.runtimeGenerationId, + }); + expect(mcp.snapshot().binding.sessionRevision).toBe(originalBinding.sessionRevision + 1); + await expect(mcp.execute({ expectedSessionRevision: originalBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); + await expect(mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' })).resolves.toMatchObject({ + vector: { runtimeGenerationId: registry!.runtimeGenerationId }, + }); + expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); + await session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + sourceRevision: `${prepared.devRuntime!.sourceRevision}-p1-revert`, + }); + const revertedRegistry = session.mcpRegistry.snapshot(); + expect(revertedRegistry).toMatchObject({ + definitionDigest: registry!.definitionDigest, + registryRevision: originalBinding.registryRevision + 2, + runtimeGenerationId: registry!.runtimeGenerationId, + }); + const revertedRevision = mcp.snapshot().binding.sessionRevision; + await session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + sourceRevision: `${prepared.devRuntime!.sourceRevision}-p3-repeat`, + }); + expect(session.mcpRegistry.snapshot()).toMatchObject({ + definitionDigest: registry!.definitionDigest, + registryRevision: revertedRegistry!.registryRevision, + }); + expect(mcp.snapshot().binding.sessionRevision).toBe(revertedRevision); + await mcp.close(); + const closing = session.close(); + await expect(session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + sourceRevision: `${prepared.devRuntime!.sourceRevision}-close-race`, + })).rejects.toThrow('RSC runtime session is closed.'); + await closing; + expect(session.status()).toMatchObject({ hmrReady: false, state: 'closed' }); + expect(session.clientSurface('mcp.edit-timeline')).toBeUndefined(); + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('resets state through the dynamically loaded copied provider', async () => { + const copied = await copyProviderExample(); + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-dynamic-reset'); + const controller = new AbortController(); + let session: Awaited>['start']>> | undefined; + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const provider = await resolveDevRuntimeProvider(copied.projectRoot, prepared.devRuntime!); + session = await provider.start(startContext({ + preparedRuntime: prepared.devRuntime!, + projectRoot: copied.projectRoot, + providerSessionId: 'provider-dynamic-reset', + signal: controller.signal, + storageRoot, + })); + await waitFor(() => session!.status().state === 'active'); + const activeVector = session.status().activeVector; + if (activeVector === undefined) throw new Error('The copied provider did not activate a runtime generation.'); + + await expect(session.resetState({ + expectedGenerationId: activeVector.runtimeGenerationId, + stateStoreId: activeVector.stateStoreId, + })).resolves.toEqual({ stateStoreId: activeVector.stateStoreId, stateVersion: 1 }); + } finally { + controller.abort(); + await session?.close(); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('rejects an already-aborted provider start before creating a runtime session', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('provider startup cancelled'); + controller.abort(reason); + + await expect(createDevRuntimeProvider().start({ + artifactStatus: () => Object.freeze({ state: 'missing' as const }), + emit: () => undefined, + environment: Object.freeze({}), + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-aborted', + signal: controller.signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-aborted'), + })).rejects.toBe(reason); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('retries an identical compiler cohort after an asynchronous provider activation failure', async () => { + const outcomes = [deferred(), deferred()]; + const captures: boolean[] = []; + let enqueueCount = 0; + const observer = compileObserver({ + beforeAttempt: () => `attempt-${String(captures.length + 1)}`, + capture: async (input) => { + captures.push(input.cohortChanged); + return input.cohortChanged ? snapshotFor(input.attemptId, input.sourceRevision) : undefined; + }, + enqueue: () => outcomes[enqueueCount++]!.promise, + failAttempt: () => undefined, + }); + + await observer.compile(); + outcomes[0]!.resolve('failed'); + await Promise.resolve(); + await observer.compile(); + outcomes[1]!.resolve('activated'); + await Promise.resolve(); + await observer.compile(); + + expect(captures).toEqual([true, true, false]); + expect(enqueueCount).toBe(2); +}); + +test('classifies a same-hash compiler cohort as unchanged while its activation is pending', async () => { + const activation = deferred(); + const captures: boolean[] = []; + let attempts = 0; + let enqueueCount = 0; + const observer = compileObserver({ + beforeAttempt: () => `attempt-${String(++attempts)}`, + capture: async (input) => { + captures.push(input.cohortChanged); + return input.cohortChanged ? snapshotFor(input.attemptId, input.sourceRevision) : undefined; + }, + enqueue: () => { + enqueueCount += 1; + return activation.promise; + }, + failAttempt: () => undefined, + }); + + await observer.compile(); + await observer.compile(); + + expect(captures).toEqual([true, false]); + expect(enqueueCount).toBe(1); + activation.resolve('activated'); +}); + +test('classifies direct compiler errors as source build failures without capture or enqueue', async () => { + const captured: string[] = []; + const enqueued: string[] = []; + const failures: unknown[][] = []; + const observer = compileObserver({ + beforeAttempt: () => 'attempt-source-build', + capture: async (input) => { + captured.push(input.attemptId); + return snapshotFor(input.attemptId, input.sourceRevision); + }, + enqueue: (snapshot) => { + enqueued.push(snapshot.attemptId); + return 'activated'; + }, + failAttempt: (...input: unknown[]) => { failures.push(input); }, + }); + + await observer.compile({ hasErrors: true }); + + expect(captured).toEqual([]); + expect(enqueued).toEqual([]); + expect(failures).toHaveLength(1); + expect(failures[0]?.[0]).toBe('attempt-source-build'); + expect(failures[0]?.[2]).toBe('source-build'); +}); + +test('recaptures an unchanged successful cohort after a source build failure', async () => { + const captured: boolean[] = []; + const observer = compileObserver({ + beforeAttempt: () => `attempt-${String(captured.length + 1)}`, + capture: async (input) => { + captured.push(input.cohortChanged); + return snapshotFor(input.attemptId, input.sourceRevision); + }, + enqueue: () => 'activated', + failAttempt: () => undefined, + }); + + await observer.compile(); + await observer.compile({ hasErrors: true }); + await observer.compile(); + + expect(captured).toEqual([true, true]); +}); + +test('keeps malformed compiler stats in the provider lifecycle failure lane', async () => { + const failures: unknown[][] = []; + const observer = compileObserver({ + beforeAttempt: () => 'attempt-malformed-stats', + capture: async (input) => snapshotFor(input.attemptId, input.sourceRevision), + enqueue: () => 'activated', + failAttempt: (...input: unknown[]) => { failures.push(input); }, + }); + + await observer.compile({ children: [] }); + + expect(failures).toHaveLength(1); + expect(failures[0]?.[0]).toBe('attempt-malformed-stats'); + expect(failures[0]?.[2]).toBe('provider-lifecycle'); +}); + +test('aggregates owned resource closer failures', async () => { + const ledger = new ResourceLedger(); + const first = new Error('first closer failed'); + const second = new Error('second closer failed'); + ledger.add(async () => { throw first; }); + ledger.add(async () => { throw second; }); + + await expect(ledger.close()).rejects.toMatchObject({ + errors: expect.arrayContaining([first, second]), + message: 'RSC runtime startup cleanup failed.', + }); +}); + +test('records one failed event when capture and observer finalization both fail an attempt', async () => { + const copied = await copyProviderExample(); + try { + await writeFile(join(copied.projectRoot, 'src', 'definition.ts'), 'export const runtimeDefinition: any = {};\n'); + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const events: Array<{ readonly type: string }> = []; + const session = await RsbuildRuntimeSession.start({ + ...startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-double-failure', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-double-failure'), + }), + emit: (event) => { events.push(event); }, + }); + try { + await waitFor(() => session.status().state === 'degraded'); + expect(session.status().diagnostics).toEqual([{ + code: 'AB8200', + message: expect.any(String), + phase: 'provider-lifecycle', + severity: 'error', + }]); + expect(events.filter((event) => event.type === 'runtime.generation.failed')).toHaveLength(1); + await expect(readdir(join(copied.projectRoot, '.agent-bundle', 'runtime-double-failure', 'generation-store', 'staging'))).resolves.toEqual([]); + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('keeps the active generation while publishing a source build diagnostic before its failed event', async () => { + const copied = await copyProviderExample(); + let session: RsbuildRuntimeSession | undefined; + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const events: Array<{ readonly type: string }> = []; + const failedStatuses: Array> = []; + session = await RsbuildRuntimeSession.start({ + ...startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-source-build-retention', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-source-build-retention'), + }), + emit: (event) => { + events.push(event); + if (event.type === 'runtime.generation.failed' && session !== undefined) failedStatuses.push(session.status()); + }, + }); + await waitFor(() => session?.status().state === 'active'); + const beforeStatus = session.status(); + const beforeSurfaces = session.surfaces(); + const beforeRuns = session.runs(50); + + await introduceWorkerSyntaxError(copied.projectRoot); + await waitFor(() => events.filter((event) => event.type === 'runtime.generation.failed').length === 1); + + expect(failedStatuses).toHaveLength(1); + expect(failedStatuses[0]).toMatchObject({ + activeVector: beforeStatus.activeVector, + diagnostics: [{ + code: 'AB8206', + message: 'RSC runtime source build failed.', + phase: 'source/build', + severity: 'error', + }], + lastGoodVector: beforeStatus.lastGoodVector, + state: 'active', + }); + expect(session.status()).toEqual(failedStatuses[0]); + expect(session.surfaces()).toEqual(beforeSurfaces); + expect(session.runs(50)).toEqual(beforeRuns); + } finally { + await session?.close(); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('drains a deferred generation pipeline before close without publishing late lifecycle events', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const reached = deferred(); + const release = deferred(); + const events: Array<{ readonly type: string }> = []; + let deferActivation = false; + let held = false; + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-close-deferred-generation'); + const session = await RsbuildRuntimeSession.start({ + ...startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-close-deferred-generation', + signal: new AbortController().signal, + storageRoot, + }), + emit: (event) => { events.push(event); }, + }, { + beforeGenerationCapture: async () => { + if (!deferActivation || held) return; + held = true; + reached.resolve(); + await release.promise; + }, + }); + try { + await waitFor(() => session.status().state === 'active'); + deferActivation = true; + await changeWorkerImplementation(copied.projectRoot, 'close-deferred-generation'); + const captureReached = await Promise.race([ + reached.promise.then(() => true), + new Promise((resolve) => { setTimeout(() => { resolve(false); }, 5_000); }), + ]); + expect(captureReached).toBe(true); + const eventCountBeforeClose = events.length; + const closing = session.close(); + let closed = false; + void closing.then(() => { closed = true; }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + expect(closed).toBe(false); + release.resolve(); + await closing; + expect(events).toHaveLength(eventCountBeforeClose); + await expect(lstat(join(storageRoot, 'generation-store', 'staging'))).rejects.toThrow(); + } finally { + release.resolve(); + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('binds renamed and added App surfaces to the active generation assets without restoring removed surfaces', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-reconciled-app-assets', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-reconciled-app-assets'), + })); + try { + await waitFor(() => session.status().state === 'active'); + const runtimeGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const original = prepared.devRuntime!.apps[0]!; + await session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: [ + { ...original, name: 'timeline-renamed' }, + { ...original, id: `${original.id}-added`, name: 'timeline-added' }, + ], + sourceRevision: `${prepared.devRuntime!.sourceRevision}-reconciled-app-assets`, + }); + + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId, + surfaceId: 'mcp.timeline-renamed', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId, + surfaceId: 'mcp.timeline-added', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + + await changeWorkerImplementation(copied.projectRoot, 'reconciled-app-assets-generation-two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== runtimeGenerationId); + const nextRuntimeGenerationId = session.status().activeVector!.runtimeGenerationId; + for (const generationId of [runtimeGenerationId, nextRuntimeGenerationId]) { + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline-renamed', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline-added', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('rebinds current App surfaces across retained generations after a later configuration reconcile', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-reconciled-retained-app-assets', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-reconciled-retained-app-assets'), + })); + try { + await waitFor(() => session.status().state === 'active'); + const firstGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; + await changeWorkerImplementation(copied.projectRoot, 'reconciled-retained-app-assets-generation-two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGenerationId); + const secondGenerationId = session.status().activeVector!.runtimeGenerationId; + const original = prepared.devRuntime!.apps[0]!; + await session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: [ + { ...original, name: 'timeline-renamed' }, + { ...original, id: `${original.id}-added`, name: 'timeline-added' }, + ], + sourceRevision: `${prepared.devRuntime!.sourceRevision}-reconciled-retained-app-assets`, + }); + + for (const generationId of [firstGenerationId, secondGenerationId]) { + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline-renamed', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline-added', + })).resolves.toMatchObject({ contentType: 'text/html' }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: generationId, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('keeps the same MCP session and revision across an implementation-only generation', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-implementation-only', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-implementation-only'), + })); + try { + await waitFor(() => session.status().state === 'active'); + const beforeGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + try { + const before = mcp.snapshot(); + await changeWorkerImplementation(copied.projectRoot, 'implementation-only'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== beforeGeneration); + const after = mcp.snapshot(); + expect(after.binding).toMatchObject({ + sessionId: before.binding.sessionId, + sessionRevision: before.binding.sessionRevision, + }); + await expect(mcp.execute({ + expectedSessionRevision: after.binding.sessionRevision, + kind: 'list-tools', + })).resolves.toMatchObject({ + sessionId: before.binding.sessionId, + sessionRevision: before.binding.sessionRevision, + vector: { runtimeGenerationId: session.status().activeVector!.runtimeGenerationId }, + }); + } finally { + await mcp.close(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('restarts and relists an open MCP session after a warm-cache definition change', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-definition-change', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-definition-change'), + })); + try { + await waitFor(() => session.status().state === 'active'); + const beforeRegistry = session.mcpRegistry.snapshot()!; + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + try { + const before = mcp.snapshot().binding; + await changeDefinition(copied.projectRoot, 'Read the freshly rebuilt shared runtime state.'); + await waitFor(() => session.mcpRegistry.snapshot()!.definitionDigest !== beforeRegistry.definitionDigest); + const afterRegistry = session.mcpRegistry.snapshot()!; + const after = mcp.snapshot(); + expect(afterRegistry.runtimeGenerationId).not.toBe(beforeRegistry.runtimeGenerationId); + expect(after.binding.sessionRevision).toBe(before.sessionRevision + 1); + await expect(mcp.execute({ + expectedSessionRevision: after.binding.sessionRevision, + kind: 'list-tools', + })).resolves.toMatchObject({ vector: { runtimeGenerationId: afterRegistry.runtimeGenerationId } }); + } finally { + await mcp.close(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('uses the live registry authority after a transport-only runtime MCP reconciliation', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-live-transport-authority', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-live-transport-authority'), + })); + try { + await waitFor(() => session.status().state === 'active'); + const initialRegistry = session.mcpRegistry.snapshot()!; + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + try { + const initialBinding = mcp.snapshot().binding; + const definitionPrepared = Object.freeze({ + ...prepared.devRuntime!, + apps: prepared.devRuntime!.apps.map((app) => Object.freeze({ + ...app, + _meta: Object.freeze({ ...app._meta, 'openai/widgetDescription': 'Live definition authority.' }), + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-definition-v2`, + }); + await session.reconcilePreparedRuntime(definitionPrepared); + const definitionRegistry = session.mcpRegistry.snapshot()!; + const definitionBinding = mcp.snapshot().binding; + expect(definitionRegistry).toMatchObject({ + registryRevision: initialRegistry.registryRevision + 1, + runtimeGenerationId: initialRegistry.runtimeGenerationId, + transportDigest: initialRegistry.transportDigest, + }); + expect(definitionRegistry.definitionDigest).not.toBe(initialRegistry.definitionDigest); + expect(definitionBinding).toMatchObject({ + definitionDigest: definitionRegistry.definitionDigest, + registryRevision: definitionRegistry.registryRevision, + sessionId: initialBinding.sessionId, + sessionRevision: initialBinding.sessionRevision + 1, + }); + await expect(mcp.execute({ expectedSessionRevision: initialBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); + const definitionRun = await session.invoke({ + expectedGenerationId: definitionRegistry.runtimeGenerationId, + input: {}, + surfaceId: 'mcp.render_edit_timeline', + target: 'portable', + }); + expect(definitionRun).toMatchObject({ + status: 'succeeded', vector: { runtimeGenerationId: definitionRegistry.runtimeGenerationId }, + }); + if (definitionRun.status !== 'succeeded' || definitionRun.result.app === undefined) throw new Error('Definition reconciliation run omitted its Runtime App binding.'); + const definitionAppBinding = definitionRun.result.app.mcpBinding; + expect(definitionAppBinding).toMatchObject({ + definitionDigest: definitionRegistry.definitionDigest, + registryRevision: definitionRegistry.registryRevision, + sessionId: expect.any(String), + sessionRevision: expect.any(Number), + transportDigest: definitionRegistry.transportDigest, + }); + + await session.reconcilePreparedRuntime({ + ...definitionPrepared, + servers: definitionPrepared.servers.map((server) => Object.freeze({ + ...server, + env: Object.freeze({ ...(server.env ?? {}), TIMELINE_TRANSPORT_SENTINEL: 'transport-v2' }), + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-transport-v2`, + }); + const registry = session.mcpRegistry.snapshot()!; + const currentBinding = mcp.snapshot().binding; + expect(registry).toMatchObject({ + definitionDigest: definitionRegistry.definitionDigest, + registryRevision: definitionRegistry.registryRevision + 1, + runtimeGenerationId: definitionRegistry.runtimeGenerationId, + }); + expect(registry.transportDigest).not.toBe(definitionRegistry.transportDigest); + expect(currentBinding).toMatchObject({ + registryRevision: registry.registryRevision, + sessionId: definitionBinding.sessionId, + sessionRevision: definitionBinding.sessionRevision + 1, + transportDigest: registry.transportDigest, + }); + await expect(mcp.execute({ expectedSessionRevision: definitionBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); + + const appRun = await session.invoke({ + expectedGenerationId: registry.runtimeGenerationId, + input: {}, + surfaceId: 'mcp.render_edit_timeline', + target: 'portable', + }); + expect(appRun).toMatchObject({ + status: 'succeeded', vector: { runtimeGenerationId: registry.runtimeGenerationId }, + }); + if (appRun.status !== 'succeeded' || appRun.result.app === undefined) throw new Error('Transport reconciliation run omitted its Runtime App binding.'); + expect(appRun.result.app.mcpBinding).toMatchObject({ + definitionDigest: registry.definitionDigest, + registryRevision: registry.registryRevision, + sessionId: definitionAppBinding.sessionId, + sessionRevision: definitionAppBinding.sessionRevision + 1, + transportDigest: registry.transportDigest, + }); + await expect(mcp.execute({ + expectedSessionRevision: currentBinding.sessionRevision, + kind: 'read-resource', + uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + })).resolves.toMatchObject({ + sessionId: currentBinding.sessionId, + sessionRevision: currentBinding.sessionRevision, + vector: { runtimeGenerationId: registry.runtimeGenerationId }, + }); + await expect(mcp.execute({ + arguments: { limit: 1 }, + expectedSessionRevision: currentBinding.sessionRevision, + kind: 'call-tool', + name: 'render_edit_timeline', + })).resolves.toMatchObject({ + sessionId: currentBinding.sessionId, + sessionRevision: currentBinding.sessionRevision, + vector: { runtimeGenerationId: registry.runtimeGenerationId }, + }); + } finally { + await mcp.close(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('rejects MCP admission until a deferred public prepared-config restart has relisted', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const relistReached = deferred(); + const allowRelist = deferred(); + let deferRelist = false; + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-deferred-restart', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-deferred-restart'), + }), { + beforeMcpRelist: async () => { + if (!deferRelist) return; + relistReached.resolve(); + await allowRelist.promise; + }, + }); + try { + await waitFor(() => session.status().state === 'active'); + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + try { + expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); + const before = mcp.snapshot().binding; + deferRelist = true; + const reconciling = session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: prepared.devRuntime!.apps.map((app) => ({ + ...app, + _meta: { ...app._meta, 'openai/widgetDescription': 'Restart after deferred relist.' }, + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-deferred-public-restart`, + }); + await relistReached.promise; + const restarting = mcp.snapshot(); + expect(restarting).toMatchObject({ state: 'restarting' }); + await expect(mcp.execute({ + expectedSessionRevision: restarting.binding.sessionRevision, + kind: 'list-tools', + })).rejects.toThrow('Runtime MCP session is restarting.'); + allowRelist.resolve(); + await reconciling; + expect(mcp.snapshot()).toMatchObject({ + binding: { sessionRevision: before.sessionRevision + 1 }, + state: 'ready', + }); + const restartedCapabilities = mcp.snapshot().connection.capabilities; + if (restartedCapabilities === undefined) throw new Error('Expected restarted runtime MCP capabilities.'); + expect(restartedCapabilities).toEqual({ resources: {}, tools: {} }); + expect(Object.isFrozen(restartedCapabilities)).toBe(true); + expect(Object.isFrozen(restartedCapabilities.resources)).toBe(true); + expect(Object.isFrozen(restartedCapabilities.tools)).toBe(true); + } finally { + await mcp.close(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 30_000); + +test('aborts stale activation transactions at both private preparation boundaries', async () => { + for (const phase of ['store', 'registry'] as const) { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const reached = deferred(); + const allow = deferred(); + const events: Array<{ readonly runtimeGenerationId?: string; readonly type: string }> = []; + let armBarrier = false; + let held = false; + const session = await RsbuildRuntimeSession.start({ + ...startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: `provider-${phase}-prepare`, + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', `runtime-${phase}-prepare`), + }), + emit: (event) => { events.push(event); }, + }, { + afterActivationPrepare: async (input) => { + if (!armBarrier || held || input.phase !== phase) return; + held = true; + reached.resolve(); + await allow.promise; + }, + }); + try { + await waitFor(() => session.status().state === 'active'); + const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); + try { + const firstBinding = mcp.snapshot().binding; + armBarrier = true; + await changeDefinition(copied.projectRoot, `Read state after ${phase} preparation.`); + await reached.promise; + expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); + const reconciled = session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: prepared.devRuntime!.apps.map((app) => ({ + ...app, + source: './src/widget/App.tsx', + })), + sourceRevision: `${prepared.devRuntime!.sourceRevision}-${phase}-superseding-prepared`, + }); + allow.resolve(); + await reconciled; + await new Promise((resolve) => { setTimeout(resolve, 50); }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: 'generation-2', + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); + expect(mcp.snapshot().binding).toMatchObject({ + sessionId: firstBinding.sessionId, + sessionRevision: firstBinding.sessionRevision, + }); + expect(events.filter((event) => event.type === 'runtime.generation.activated' && event.runtimeGenerationId === 'generation-2')).toHaveLength(0); + armBarrier = false; + await changeWorkerImplementation(copied.projectRoot, `${phase}-current-generation`); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGeneration); + expect(session.status().activeVector?.runtimeGenerationId).not.toBe('generation-2'); + expect(mcp.snapshot().binding.sessionRevision).toBe(firstBinding.sessionRevision + 1); + } finally { + await mcp.close(); + } + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } + } +}, 60_000); + +test('commits a compiled generation across an equivalent prepared-runtime revision', { timeout: 0 }, async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const reached = deferred(); + const allow = deferred(); + let armBarrier = false; + let held = false; + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-equivalent-prepared-revision', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-equivalent-prepared-revision'), + }), { + afterActivationPrepare: async (input) => { + if (!armBarrier || held || input.phase !== 'store') return; + held = true; + reached.resolve(); + await allow.promise; + }, + }); + try { + await waitFor(() => session.status().state === 'active'); + const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + armBarrier = true; + await changeDefinition(copied.projectRoot, 'Read state after equivalent prepared revision.'); + await reached.promise; + const reconciled = session.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + sourceRevision: `${prepared.devRuntime!.sourceRevision}-equivalent-prepared`, + }); + allow.resolve(); + await reconciled; + + expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: 'generation-2' }); + expect(session.status()).toMatchObject({ + activeVector: { runtimeGenerationId: 'generation-2' }, + diagnostics: [], + state: 'active', + }); + expect(session.mcpRegistry.snapshot()!.runtimeGenerationId).not.toBe(firstGeneration); + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('retains a leased inactive generation through pruning and prunes it after the read releases', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const enteredRead = deferred(); + const releaseRead = deferred(); + let deferAssetRead = true; + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-asset-lease'); + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-asset-lease', + signal: new AbortController().signal, + storageRoot, + }), { + beforeAssetRead: async () => { + if (!deferAssetRead) return; + enteredRead.resolve(); + await releaseRead.promise; + }, + }); + try { + await waitFor(() => session.status().state === 'active'); + const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; + const heldRead = session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: firstGeneration, + surfaceId: 'mcp.timeline', + }); + await enteredRead.promise; + let activeGeneration = firstGeneration; + for (let marker = 2; marker <= 7; marker += 1) { + await changeWorkerImplementation(copied.projectRoot, `lease-prune-${String(marker)}`); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== activeGeneration); + activeGeneration = session.status().activeVector!.runtimeGenerationId; + } + expect((await lstat(join(storageRoot, 'generation-store', 'generations', firstGeneration))).isDirectory()).toBe(true); + releaseRead.resolve(); + await expect(heldRead).resolves.toMatchObject({ contentType: 'text/html' }); + deferAssetRead = false; + await new Promise((resolve) => { setTimeout(resolve, 100); }); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: firstGeneration, + surfaceId: 'mcp.timeline', + })).resolves.toBeUndefined(); + } finally { + await session.close(); + } + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 60_000); + +test('aborts a deferred Rsbuild creation before starting its dev server', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('deferred compiler creation aborted'); + const created = deferred>>(); + let createCalls = 0; + let devServerStarts = 0; + const starting = RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-late-compiler', + signal: controller.signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-late-compiler'), + }), { + createRsbuild: (async () => { + createCalls += 1; + return created.promise; + }) as typeof createRsbuild, + }); + await waitFor(() => createCalls === 1); + controller.abort(reason); + created.resolve(Object.freeze({ + startDevServer: async () => { + devServerStarts += 1; + throw new Error('The aborted provider must not start a dev server.'); + }, + }) as unknown as Awaited>); + + await expect(starting).rejects.toBe(reason); + expect(devServerStarts).toBe(0); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('uses the bound Rsbuild dev-server context instead of a stale port-zero start result', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + let closeCalls = 0; + const create = async (input: Readonly<{ readonly config: unknown }>) => { + const plugin = ((input.config as Readonly<{ readonly plugins?: readonly unknown[] }>).plugins ?? []).find((candidate): candidate is Readonly<{ + readonly name: string; + setup(api: unknown): void; + }> => typeof candidate === 'object' && candidate !== null && + (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); + if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); + let afterCreate: ((input: unknown) => void) | undefined; + plugin.setup({ + onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, + onAfterEnvironmentCompile: () => undefined, + onBeforeStartDevServer: () => undefined, + onCloseDevServer: () => undefined, + }); + afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); + return Object.freeze({ + context: Object.freeze({ + devServer: Object.freeze({ hostname: '127.0.0.1', https: false, port: 41_103 }), + }), + startDevServer: async () => Object.freeze({ + port: 0, + server: Object.freeze({ close: async () => { closeCalls += 1; } }), + urls: Object.freeze(['http://127.0.0.1:0']), + }) as unknown as StartDevServerResult, + }) as unknown as Awaited>; + }; + const session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-bound-dev-server-context', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-bound-dev-server-context'), + }), { createRsbuild: create as typeof createRsbuild }); + try { + expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ + httpOrigin: 'http://127.0.0.1:41103', + webSocketOrigin: 'ws://127.0.0.1:41103', + }); + } finally { + await session.close(); + } + expect(closeCalls).toBe(1); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('waits for a late Rsbuild server closer after aborting startup', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('late server startup aborted'); + const started = deferred(); + const closeGate = deferred(); + let createCalls = 0; + let closeCalls = 0; + const create = async () => { + createCalls += 1; + return Object.freeze({ startDevServer: async () => started.promise }) as unknown as Awaited>; + }; + const starting = RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-late-server', + signal: controller.signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-late-server'), + }), { createRsbuild: create as typeof createRsbuild }); + + await waitFor(() => createCalls === 1); + controller.abort(reason); + await new Promise((resolve) => { setTimeout(resolve, 50); }); + started.resolve(Object.freeze({ + port: 41_001, + server: Object.freeze({ close: async () => { + closeCalls += 1; + await closeGate.promise; + } }), + urls: Object.freeze(['http://127.0.0.1:41001']), + }) as unknown as StartDevServerResult); + + const outcome = starting.then( + () => 'resolved', + (error: unknown) => error, + ); + let settled = false; + void outcome.then(() => { settled = true; }); + await waitFor(() => closeCalls === 1); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + const settledBeforeCloseFinished = settled; + closeGate.resolve(); + await expect(outcome).resolves.toBe(reason); + expect(settledBeforeCloseFinished).toBe(false); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('closes a server returned immediately after startup abort', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('returned server startup aborted'); + let closeCalls = 0; + const create = async () => Object.freeze({ + startDevServer: async () => { + controller.abort(reason); + return Object.freeze({ + port: 41_002, + server: Object.freeze({ close: async () => { closeCalls += 1; } }), + urls: Object.freeze(['http://127.0.0.1:41002']), + }) as unknown as StartDevServerResult; + }, + }) as unknown as Awaited>; + + await expect(RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-returned-server', + signal: controller.signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-returned-server'), + }), { createRsbuild: create as typeof createRsbuild })).rejects.toBe(reason); + expect(closeCalls).toBe(1); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('preserves an aborted startup cause with every acquired cleanup failure', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('startup aborted after acquiring the dev server'); + const cleanupSecret = 'do-not-expose-startup-cleanup-secret'; + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-startup-cleanup-failure'); + let serverCloseCalls = 0; + const create = async () => Object.freeze({ + startDevServer: async () => { + await writeFile(join(storageRoot, 'runs', '.agent-bundle-runtime-owner'), 'tampered-owner-marker'); + controller.abort(reason); + return Object.freeze({ + port: 41_003, + server: Object.freeze({ close: async () => { + serverCloseCalls += 1; + throw new Error(cleanupSecret); + } }), + urls: Object.freeze(['http://127.0.0.1:41003']), + }) as unknown as StartDevServerResult; + }, + }) as unknown as Awaited>; + + const outcome = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-startup-cleanup-failure', + signal: controller.signal, + storageRoot, + }), { createRsbuild: create as typeof createRsbuild }).then( + () => undefined, + (error: unknown) => error, + ); + + expect(serverCloseCalls).toBe(1); + expect(outcome).toBeInstanceOf(AggregateError); + const failure = outcome as AggregateError; + expect(failure.message).toBe('RSC runtime startup failed; cleanup failures: owned-runs-root, rsbuild-dev-server.'); + expect(failure.message).not.toContain(cleanupSecret); + expect(failure.errors[0]).toBe(reason); + expect(failure.errors).toEqual(expect.arrayContaining([ + reason, + expect.objectContaining({ message: cleanupSecret }), + expect.objectContaining({ message: 'RSC runtime invocation root ownership marker changed during this provider session.' }), + ])); + expect((await lstat(join(storageRoot, 'runs'))).isDirectory()).toBe(true); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('joins a late owned-runs cleanup after abort has already drained startup cleanup', async () => { + const copied = await copyProviderExample(); + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const controller = new AbortController(); + const reason = new Error('startup aborted while acquiring owned runs root'); + const cleanupSecret = 'do-not-expose-late-owned-runs-secret'; + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-late-owned-runs-root'); + const ownedRunsRootCreated = deferred(); + const releaseOwnedRunsRoot = deferred(); + const startupCleanupClosed = deferred(); + const ownedRunsCleanupEntered = deferred(); + const releaseOwnedRunsCleanup = deferred(); + let settled = false; + const starting = RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-late-owned-runs-root', + signal: controller.signal, + storageRoot, + }), { + afterOwnedRunsRootCreated: async () => { + ownedRunsRootCreated.resolve(); + await releaseOwnedRunsRoot.promise; + }, + beforeOwnedRunsRootCleanup: async () => { + ownedRunsCleanupEntered.resolve(); + await releaseOwnedRunsCleanup.promise; + await writeFile(join(storageRoot, 'runs', '.agent-bundle-runtime-owner'), cleanupSecret); + }, + onStartupCleanupClosed: () => { startupCleanupClosed.resolve(); }, + }); + const outcome = starting.then( + () => undefined, + (error: unknown) => error, + ); + void outcome.then(() => { settled = true; }); + + await ownedRunsRootCreated.promise; + controller.abort(reason); + await startupCleanupClosed.promise; + releaseOwnedRunsRoot.resolve(); + await ownedRunsCleanupEntered.promise; + await new Promise((resolve) => { setTimeout(resolve, 0); }); + expect(settled).toBe(false); + releaseOwnedRunsCleanup.resolve(); + + const failure = await outcome; + expect(failure).toBeInstanceOf(AggregateError); + const aggregate = failure as AggregateError; + expect(aggregate.message).toBe('RSC runtime startup failed; cleanup failures: owned-runs-root.'); + expect(aggregate.message).not.toContain(cleanupSecret); + expect(aggregate.errors[0]).toBe(reason); + expect(aggregate.errors).toEqual(expect.arrayContaining([ + reason, + expect.objectContaining({ message: 'RSC runtime invocation root ownership marker changed during this provider session.' }), + ])); + expect((await lstat(join(storageRoot, 'runs'))).isDirectory()).toBe(true); + } finally { + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}); + +test('drains every live-session cleanup group once when independent closers reject', async () => { + const copied = await copyProviderExample(); + const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-live-close-failures'); + const attempted: string[] = []; + const secrets = new Map([ + ['owned-runs-root', 'do-not-expose-live-root-secret'], + ['rsbuild-dev-server', 'do-not-expose-live-server-secret'], + ['run-artifact', 'do-not-expose-live-artifact-secret'], + ]); + let session: RsbuildRuntimeSession | undefined; + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + session = await RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-live-close-failures', + signal: new AbortController().signal, + storageRoot, + }), { + afterLiveSessionCleanupResource: ({ resource }: Readonly<{ readonly resource: string }>) => { + attempted.push(resource); + const secret = secrets.get(resource); + if (secret !== undefined) throw new Error(secret); + }, + }); + await waitFor(() => session!.status().state === 'active'); + const activeVector = session.status().activeVector; + if (activeVector === undefined) throw new Error('Expected an active runtime generation.'); + const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; + await expect(session.invoke({ + expectedGenerationId: activeVector.runtimeGenerationId, + input: {}, + surfaceId: 'mcp.runtime_status', + target, + })).resolves.toMatchObject({ status: 'succeeded' }); + + const closing = session.close(); + expect(session.close()).toBe(closing); + const failure = await closing.then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AggregateError); + const aggregate = failure as AggregateError; + expect(aggregate.message).toBe('RSC runtime session close failed; cleanup failures: owned-runs-root, rsbuild-dev-server, run-artifact.'); + for (const secret of secrets.values()) expect(aggregate.message).not.toContain(secret); + expect(aggregate.errors).toEqual(expect.arrayContaining([...secrets.values()].map((secret) => expect.objectContaining({ message: secret })))); + expect(attempted).toEqual(expect.arrayContaining([ + 'generation-store', + 'owned-runs-root', + 'rsbuild-dev-server', + 'run-artifact', + 'runtime-mcp-registry', + ])); + expect(new Set(attempted).size).toBe(attempted.length); + expect(session.close()).toBe(closing); + } finally { + await session?.close().catch(() => undefined); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 45_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts new file mode 100644 index 000000000..9d5b9c2b1 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts @@ -0,0 +1,67 @@ +import { execFile as executeFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +import { expect, test } from '@rstest/core'; + +const readme = async (): Promise => readFile(join(process.cwd(), 'README.md'), 'utf8'); +const execFile = promisify(executeFile); + +test('keeps the Hook JSX author example executable', async () => { + const source = await readme(); + const afterFileEdit = source.match(/export function AfterFileEdit\(\) \{[\s\S]*?\n}\n```/); + + expect(afterFileEdit?.[0]).toContain('\n '); + expect(afterFileEdit?.[0]).toContain('\n '); +}); + +test('requires attached native evidence before documenting Claude or Codex observations', async () => { + const source = await readme(); + + expect(source).toContain('`apply_patch` hook'); + expect(source).toContain('`dist/runtime/agent-runtime.manifest.json`'); + expect(source).toContain('value-free hook launch probe'); + expect(source).toContain('native PostToolUse/shared state remains unproven under `exec --ephemeral`'); + expect(source).toMatch(/Real Claude Code and Codex CLI runs are\s+intentionally skip-gated out of ordinary CI and default test runs/u); + expect(source).toMatch(/No attached tracked\s+schema-v2 native-evidence artifact exists in this repository snapshot/u); + expect(source).toMatch(/profiles are local compatibility simulations, and deterministic evaluator tests are not native certification/u); + expect(source).toContain('pnpm --filter @agent-bundle/rsc-agent-runtime-demo eval:hosts -- --host claude'); + expect(source).toContain('pnpm --filter @agent-bundle/rsc-agent-runtime-demo eval:hosts -- --host codex'); + expect(source).toContain('schema-v2 JSON evidence document'); + expect(source).toContain('MCP App iframe evidence is unavailable from either terminal CLI'); + expect(source).not.toContain('Claude fully proves hook→MCP/RSC shared behavior'); + expect(source).not.toContain('A non-authenticated session is reported as an environment limitation'); + expect(source).not.toContain('unavailable/not run'); + expect(source).not.toMatch(/in progress/iu); +}); + +test('documents the ordinary-CI micro-eval spot-check', async () => { + const source = await readme(); + + expect(source).toContain('### CI micro-eval spot-check'); + expect(source).toContain('pnpm eval:spot'); + expect(source).toMatch(/contacts\s+no real host and needs no credentials/u); +}); + +test('declares a shell-independent production build', async () => { + const manifest = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as { + readonly scripts?: Readonly>; + }; + + expect(manifest.scripts?.build).toBe('rsbuild build --mode production && pnpm package:hosts'); +}); + +test('derives the native evaluator root from decoded module URLs', async () => { + const helperUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-host-paths.mjs')).href; + const moduleUrl = pathToFileURL(join(tmpdir(), 'rsc runtime encoded path', 'scripts', 'eval-hosts.mjs')).href; + const source = [ + `import { exampleRootFromModule } from ${JSON.stringify(helperUrl)};`, + `process.stdout.write(exampleRootFromModule(${JSON.stringify(moduleUrl)}));`, + ].join('\n'); + const { stdout } = await execFile(process.execPath, ['--input-type=module', '--eval', source]); + + expect(stdout).toBe(join(tmpdir(), 'rsc runtime encoded path')); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts new file mode 100644 index 000000000..7ee54b3b0 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts @@ -0,0 +1,592 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { expect, test } from '@rstest/core'; + +type TranscriptEvidence = { + eventCounts: { hook: number; json: number; mcp: number; rscRender: number }; + finalMarkerObserved: boolean; + mcpReadObserved: boolean; + mcpReadMarkerObserved: boolean; + rscRenderToolObserved: boolean; + sharedHookStateObserved: boolean; +}; + +type HookProbeSummary = { + commandLaunched: boolean; + exitStatuses: number[]; + launches: number; +}; + +type NativeEvidenceEnvelope = { + capturedAt: string; + claims: Array<{ basis: string; evidence: 'inferred' | 'observed' | 'unavailable'; id: string }>; + host: 'claude' | 'codex'; + hostVersion: string; +}; + +const marker = (host: 'claude' | 'codex'): string => `HOST_EVAL_FINAL host=${host} path=host-created.txt`; + +const parseEvidence = async ( + host: 'claude' | 'codex', + transcript: string, + correlation?: Readonly<{ finalMarker?: string; marker?: string; stateRecords?: readonly unknown[] }>, +): Promise => { + const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; + const source = [ + `import { evidenceFromTranscript } from ${JSON.stringify(moduleUrl)};`, + `process.stdout.write(JSON.stringify(evidenceFromTranscript(${JSON.stringify(host)}, ${JSON.stringify(transcript)}, ${JSON.stringify(correlation)})));`, + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const [exitCode] = (await once(child, 'close')) as [number | null]; + + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + return JSON.parse(stdout) as TranscriptEvidence; +}; + +const parseHookProbe = async (records: unknown[]): Promise => { + const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; + const source = [ + `import { hookEvidenceFromProbe, summarizeHookProbe } from ${JSON.stringify(moduleUrl)};`, + `const summary = summarizeHookProbe(${JSON.stringify(records)});`, + 'process.stdout.write(JSON.stringify({ ...summary, hookObserved: hookEvidenceFromProbe(summary) }));', + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const [exitCode] = (await once(child, 'close')) as [number | null]; + + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + return JSON.parse(stdout) as HookProbeSummary & { hookObserved: boolean }; +}; + +const classifyEvidence = async ( + host: 'claude' | 'codex', + result: Record, + capturedAt: string, +): Promise => { + const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; + const source = [ + `import { classifyNativeEvidence } from ${JSON.stringify(moduleUrl)};`, + `process.stdout.write(JSON.stringify(classifyNativeEvidence(${JSON.stringify(host)}, ${JSON.stringify(result)}, { capturedAt: ${JSON.stringify(capturedAt)} })));`, + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const [exitCode] = (await once(child, 'close')) as [number | null]; + + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + return JSON.parse(stdout) as NativeEvidenceEnvelope; +}; + +const sanitizeEnvironment = async ( + environment: Record, + owned: { codexHome: string; hookProbeFile: string; stateFile: string }, +): Promise> => { + const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-host-environment.mjs')).href; + const source = [ + `import { sanitizedHostEnvironment } from ${JSON.stringify(moduleUrl)};`, + `process.stdout.write(JSON.stringify(sanitizedHostEnvironment(${JSON.stringify(environment)}, ${JSON.stringify(owned)})));`, + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const [exitCode] = (await once(child, 'close')) as [number | null]; + + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + return JSON.parse(stdout) as Record; +}; + +const unavailableHostEnvelope = async (): Promise<{ capturedAt: string; hosts: NativeEvidenceEnvelope[]; schemaVersion: number }> => { + const child = spawn(process.execPath, ['scripts/eval-hosts.mjs', '--host', 'claude'], { + cwd: process.cwd(), + env: { HOME: '/tmp', LANG: 'C', PATH: '', TERM: 'dumb' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const [exitCode] = (await once(child, 'close')) as [number | null]; + + expect(exitCode).toBe(1); + expect(stderr).toBe(''); + return JSON.parse(stdout) as { capturedAt: string; hosts: NativeEvidenceEnvelope[]; schemaVersion: number }; +}; + +test('does not treat Claude prompt, prose, or tool listings as host evidence', async () => { + const transcript = [ + JSON.stringify({ prompt: `Call recent_edits, render_edit_timeline, and say ${marker('claude')}.` }), + JSON.stringify({ tools: ['recent_edits', 'render_edit_timeline'], type: 'system' }), + JSON.stringify({ message: { content: [{ text: `I will say ${marker('claude')}.`, type: 'text' }], role: 'assistant' }, type: 'assistant' }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ + eventCounts: { hook: 0, mcp: 0, rscRender: 0 }, + finalMarkerObserved: false, + mcpReadObserved: false, + rscRenderToolObserved: false, + }); +}); + +test('does not count an invented Claude hook callback event', async () => { + const transcript = JSON.stringify({ hook_event_name: 'PostToolUse', subtype: 'hook_callback', type: 'system' }); + + await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ + eventCounts: { hook: 0 }, + }); +}); + +test('accepts only correlated Claude tool-use and successful result events', async () => { + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'tool-recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { content: [{ id: 'tool-render', input: {}, name: 'mcp__rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ message: { content: [{ content: 'snapshot', is_error: false, tool_use_id: 'tool-recent', type: 'tool_result' }], role: 'user' }, type: 'user' }), + JSON.stringify({ message: { content: [{ content: 'rendered', is_error: false, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, type: 'user' }), + JSON.stringify({ is_error: false, result: `${marker('claude')}\n`, subtype: 'success', type: 'result' }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ + eventCounts: { hook: 0, mcp: 1, rscRender: 1 }, + finalMarkerObserved: true, + mcpReadObserved: true, + rscRenderToolObserved: true, + }); +}); + +test('accepts Claude 2.1.250 plugin-qualified MCP tool names', async () => { + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'tool-recent', name: 'mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { content: [{ id: 'tool-render', name: 'mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { content: [{ content: 'snapshot', is_error: false, tool_use_id: 'tool-recent', type: 'tool_result' }], role: 'user' }, + type: 'user', + }), + JSON.stringify({ + message: { content: [{ content: 'rendered', is_error: false, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ + eventCounts: { mcp: 1, rscRender: 1 }, + mcpReadObserved: true, + rscRenderToolObserved: true, + }); +}); + +test('rejects Claude tool uses without matching successful tool results', async () => { + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'tool-recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { content: [{ id: 'tool-render', input: {}, name: 'mcp__rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ message: { content: [{ is_error: true, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, type: 'user' }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ + eventCounts: { mcp: 0, rscRender: 0 }, + mcpReadObserved: false, + rscRenderToolObserved: false, + }); +}); + +test('rejects lookalike, failed, malformed, and oversized Claude recent_edits results', async () => { + const oversized = 'x'.repeat(16_385); + const transcript = [ + JSON.stringify({ + message: { + content: [ + { id: 'other', input: {}, name: 'mcp__other__recent_edits', type: 'tool_use' }, + { id: 'suffix', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits_suffix', type: 'tool_use' }, + { id: 'failed', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, + { id: 'malformed', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, + { id: 'oversized', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, + { id: 'too-many-blocks', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, + { id: 'joined-too-large', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, + ], + role: 'assistant', + }, + type: 'assistant', + }), + JSON.stringify({ + message: { + content: [ + { content: 'unrelated', is_error: false, tool_use_id: 'other', type: 'tool_result' }, + { content: 'unrelated', is_error: false, tool_use_id: 'suffix', type: 'tool_result' }, + { content: 'owned marker', is_error: true, tool_use_id: 'failed', type: 'tool_result' }, + { content: { text: 'owned marker' }, is_error: false, tool_use_id: 'malformed', type: 'tool_result' }, + { content: oversized, is_error: false, tool_use_id: 'oversized', type: 'tool_result' }, + { content: Array.from({ length: 21 }, () => ({ text: 'owned marker', type: 'text' })), is_error: false, tool_use_id: 'too-many-blocks', type: 'tool_result' }, + { content: Array.from({ length: 20 }, () => ({ text: 'x'.repeat(819), type: 'text' })), is_error: false, tool_use_id: 'joined-too-large', type: 'tool_result' }, + ], + role: 'user', + }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript, { marker: 'owned marker' })).resolves.toMatchObject({ + eventCounts: { mcp: 0 }, + mcpReadMarkerObserved: false, + mcpReadObserved: false, + sharedHookStateObserved: false, + }); +}); + +test('correlates one exact Claude result marker to one matching owned hook-state record', async () => { + const correlation = { + marker: 'rsc-eval-marker-1234567890abcdef', + stateRecords: [{ + event: { host: 'claude', path: '/owned/host-created-rsc-eval-marker-1234567890abcdef.txt' }, + kind: 'edit', + }], + }; + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { + content: [{ content: [{ text: `state returned\n${correlation.marker}`, type: 'text' }], is_error: false, tool_use_id: 'recent', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript, correlation)).resolves.toMatchObject({ + eventCounts: { mcp: 1 }, + mcpReadMarkerObserved: true, + mcpReadObserved: true, + sharedHookStateObserved: true, + }); +}); + +test('keeps an exact successful Claude read observed without upgrading unmarked hook state', async () => { + const correlation = { + marker: 'rsc-eval-marker-unmarked', + stateRecords: [{ + event: { host: 'claude', path: '/owned/host-created-rsc-eval-marker-unmarked.txt' }, + kind: 'edit', + }], + }; + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { content: [{ content: 'snapshot without the owned marker', is_error: false, tool_use_id: 'recent', type: 'tool_result' }], role: 'user' }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript, correlation)).resolves.toMatchObject({ + eventCounts: { mcp: 1 }, + mcpReadMarkerObserved: false, + mcpReadObserved: true, + sharedHookStateObserved: false, + }); +}); + +test('does not borrow a duplicate result marker or unrelated state record for shared-hook evidence', async () => { + const marker = 'rsc-eval-marker-borrowed'; + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { + content: [ + { content: 'ordinary response', is_error: false, tool_use_id: 'recent', type: 'tool_result' }, + { content: marker, is_error: false, tool_use_id: 'recent', type: 'tool_result' }, + ], + role: 'user', + }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript, { + marker, + stateRecords: [{ event: { host: 'claude', path: '/owned/unrelated.txt' }, kind: 'edit' }], + })).resolves.toMatchObject({ + eventCounts: { mcp: 0 }, + mcpReadMarkerObserved: false, + mcpReadObserved: false, + sharedHookStateObserved: false, + }); +}); + +test('does not borrow a marker from a different Claude tool-result ID', async () => { + const marker = 'rsc-eval-marker-mixed'; + const transcript = [ + JSON.stringify({ + message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, + type: 'assistant', + }), + JSON.stringify({ + message: { + content: [ + { content: 'ordinary snapshot', is_error: false, tool_use_id: 'recent', type: 'tool_result' }, + { content: marker, is_error: false, tool_use_id: 'foreign', type: 'tool_result' }, + ], + role: 'user', + }, + type: 'user', + }), + ].join('\n'); + + await expect(parseEvidence('claude', transcript, { + marker, + stateRecords: [{ event: { host: 'claude', path: `/owned/${marker}.txt` }, kind: 'edit' }], + })).resolves.toMatchObject({ + eventCounts: { mcp: 1 }, + mcpReadMarkerObserved: false, + mcpReadObserved: true, + sharedHookStateObserved: false, + }); +}); + +test('derives Claude hook evidence only from its value-free launch probe', async () => { + const probe = [ + { + commandLaunched: true, + exitStatus: 0, + toolInputKeys: ['file_path'], + toolInputValueTypes: { file_path: 'string' }, + toolName: 'Write', + topLevelKeys: ['cwd', 'hook_event_name', 'session_id', 'tool_input', 'tool_name'], + topLevelValueTypes: { cwd: 'string', hook_event_name: 'string', session_id: 'string', tool_input: 'object', tool_name: 'string' }, + }, + ]; + + await expect(parseHookProbe(probe)).resolves.toMatchObject({ + commandLaunched: true, + exitStatuses: [0], + hookObserved: true, + launches: 1, + }); +}); + +test('does not treat Codex prompt, tool listings, or non-final agent prose as host evidence', async () => { + const transcript = [ + JSON.stringify({ item: { text: `Call recent_edits, render_edit_timeline, then print ${marker('codex')}.`, type: 'reasoning' }, type: 'item.completed' }), + JSON.stringify({ item: { text: marker('codex'), type: 'agent_message' }, type: 'item.completed' }), + JSON.stringify({ item: { result: 'recent_edits render_edit_timeline', server: 'other', status: 'completed', tool: 'tool_listing', type: 'mcp_tool_call' }, type: 'item.completed' }), + JSON.stringify({ type: 'turn.completed' }), + ].join('\n'); + + await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ + eventCounts: { hook: 0, mcp: 0, rscRender: 0 }, + finalMarkerObserved: false, + mcpReadObserved: false, + rscRenderToolObserved: false, + }); +}); + +test('accepts only completed Codex MCP calls and its terminal agent result', async () => { + const transcript = [ + JSON.stringify({ item: { arguments: {}, server: 'rsc-agent-runtime', status: 'completed', tool: 'recent_edits', type: 'mcp_tool_call' }, type: 'item.completed' }), + JSON.stringify({ item: { arguments: {}, server: 'rsc-agent-runtime', status: 'completed', tool: 'render_edit_timeline', type: 'mcp_tool_call' }, type: 'item.completed' }), + JSON.stringify({ item: { text: marker('codex'), type: 'agent_message' }, type: 'item.completed' }), + JSON.stringify({ type: 'turn.completed' }), + ].join('\n'); + + await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ + eventCounts: { hook: 0, mcp: 1, rscRender: 1 }, + finalMarkerObserved: true, + mcpReadObserved: true, + rscRenderToolObserved: true, + }); +}); + +test('does not count a failed Codex runtime MCP call', async () => { + const transcript = JSON.stringify({ + item: { + is_error: true, + result: { is_error: true }, + server: 'rsc-agent-runtime', + status: 'completed', + tool: 'recent_edits', + type: 'mcp_tool_call', + }, + type: 'item.completed', + }); + + await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ + eventCounts: { mcp: 0 }, + mcpReadObserved: false, + }); +}); + +test('classifies complete Claude native evidence as literal claim-level observations', async () => { + const capturedAt = '2026-08-14T20:00:00.000Z'; + const completeClaude = { + editObservedByHook: true, + finalMarkerObserved: true, + mcpReadObserved: true, + rscRenderToolObserved: true, + sessionAvailable: true, + sharedHookStateObserved: true, + version: '2.1.232', + }; + + await expect(classifyEvidence('claude', completeClaude, capturedAt)).resolves.toEqual({ + capturedAt, + claims: [ + { basis: 'native terminal marker and loaded plugin session', evidence: 'observed', id: 'package-activation' }, + { basis: 'value-free hook launch probe exited 0', evidence: 'observed', id: 'hook-dispatch' }, + { basis: 'completed recent_edits call with native success result', evidence: 'observed', id: 'mcp-read' }, + { basis: 'completed render_edit_timeline call with native success result', evidence: 'observed', id: 'rsc-render' }, + { basis: 'hook-recorded state was returned by recent_edits', evidence: 'observed', id: 'shared-hook-mcp-state' }, + { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, + ], + host: 'claude', + hostVersion: '2.1.232', + }); +}); + +test('keeps Codex hook claims unavailable under exec ephemeral despite completed MCP calls', async () => { + const capturedAt = '2026-08-14T20:00:00.000Z'; + const incompleteCodex = { + editObservedByHook: true, + finalMarkerObserved: true, + mcpReadObserved: true, + rscRenderToolObserved: true, + sessionAvailable: true, + version: '0.147.0', + }; + + await expect(classifyEvidence('codex', incompleteCodex, capturedAt)).resolves.toEqual({ + capturedAt, + claims: [ + { basis: 'native terminal marker and loaded plugin session', evidence: 'observed', id: 'package-activation' }, + { basis: 'Codex exec --ephemeral does not prove native hook dispatch', evidence: 'unavailable', id: 'hook-dispatch' }, + { basis: 'completed recent_edits call with native success result', evidence: 'observed', id: 'mcp-read' }, + { basis: 'completed render_edit_timeline call with native success result', evidence: 'observed', id: 'rsc-render' }, + { basis: 'Codex exec --ephemeral has no native hook-recorded state correlation', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, + { basis: 'Codex CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, + ], + host: 'codex', + hostVersion: '0.147.0', + }); +}); + +test('keeps unavailable-host claims bounded and removes ambient credentials from child environments', async () => { + const capturedAt = '2026-08-14T20:00:00.000Z'; + const missing = await classifyEvidence('claude', {}, capturedAt); + expect(missing).toEqual({ + capturedAt, + claims: [ + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'package-activation' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'hook-dispatch' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'mcp-read' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'rsc-render' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, + { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, + ], + host: 'claude', + hostVersion: 'unavailable', + }); + expect(JSON.stringify(missing)).not.toMatch(/secret|auth|prompt|transcript|\/private/iu); + + const environment = { + ANTHROPIC_API_KEY: 'anthropic-secret', + ANTHROPIC_AUTH_TOKEN: 'anthropic-auth', + ANTHROPIC_BASE_URL: 'https://private.example', + CLAUDE_CODE_USE_BEDROCK: '1', + CLAUDE_CODE_USE_FOUNDRY: '1', + CLAUDE_CODE_USE_VERTEX: '1', + EXAMPLE_API_KEY: 'example-secret', + LANG: 'en_US.UTF-8', + NODE_OPTIONS: '--require /private/module.cjs', + NODE_PATH: '/private/modules', + OPENAI_API_KEY: 'openai-secret', + PATH: '/safe/bin', + TERM: 'xterm-256color', + openai_api_key: 'case-insensitive-secret', + }; + const before = { ...environment }; + await expect(sanitizeEnvironment(environment, { + codexHome: '/tmp/owned-codex-home', + hookProbeFile: '/tmp/owned-hook-probe.jsonl', + stateFile: '/tmp/owned-state.jsonl', + })).resolves.toEqual({ + AGENT_RUNTIME_HOOK_PROBE_FILE: '/tmp/owned-hook-probe.jsonl', + AGENT_RUNTIME_STATE_FILE: '/tmp/owned-state.jsonl', + CODEX_HOME: '/tmp/owned-codex-home', + LANG: 'en_US.UTF-8', + PATH: '/safe/bin', + TERM: 'xterm-256color', + }); + expect(environment).toEqual(before); +}); + +test('emits one schema-v2 envelope and fails truthfully when the selected native host is unavailable', async () => { + const envelope = await unavailableHostEnvelope(); + + expect(Object.keys(envelope).sort()).toEqual(['capturedAt', 'hosts', 'schemaVersion']); + expect(envelope.schemaVersion).toBe(2); + expect(envelope.capturedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/u); + expect(envelope.hosts).toEqual([ + { + capturedAt: envelope.capturedAt, + claims: [ + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'package-activation' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'hook-dispatch' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'mcp-read' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'rsc-render' }, + { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, + { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, + ], + host: 'claude', + hostVersion: 'unavailable', + }, + ]); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs new file mode 100644 index 000000000..64c5b3107 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs @@ -0,0 +1,31 @@ +import { open, rm, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { setInterval } from 'node:timers'; + +import lockfile from 'proper-lockfile'; + +const stateFile = process.argv[2]; +if (stateFile === undefined) { + throw new Error('state file argument is required'); +} + +const handle = await open(stateFile, 'a'); +await handle.close(); +const stale = Number(process.argv[3] ?? '2000'); +const update = Number(process.argv[4] ?? '1000'); +const release = await lockfile.lock(stateFile, { + realpath: true, + retries: 0, + stale, + update, +}); +const metadataFile = `${stateFile}.agent-runtime-lock.json`; +await writeFile(metadataFile, JSON.stringify({ stale })); +process.stdout.write('{"ready":true}\n'); + +process.once('SIGTERM', async () => { + await release(); + await rm(metadataFile, { force: true }); + process.exit(0); +}); +setInterval(() => undefined, 1_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts new file mode 100644 index 000000000..139bf8da7 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts @@ -0,0 +1,27 @@ +import { createTestFileRuntimeKernel } from '../../src/runtime/state-file-test-support.js'; + +const stateFile = process.argv[2]; +if (stateFile === undefined) throw new Error('state file argument is required'); + +const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + beforeAppend: () => new Promise((resolve) => setTimeout(resolve, 50)), + criticalSectionMs: 10, + ownerSettlementMs: 2_000, + }, +}); + +try { + await kernel.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:settlement-exit', + path: 'settlement-exit.ts', + sessionId: 'session-1', + toolName: 'apply_patch', + }); + throw new Error('timed-out state mutation unexpectedly succeeded'); +} catch (error) { + if (!(error instanceof Error) || !error.message.includes('exceeded 10 ms')) throw error; + process.stdout.write('phase-settled\n'); +} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts new file mode 100644 index 000000000..38237dd71 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts @@ -0,0 +1,983 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { createRsbuild } from '@rsbuild/core'; +import { expect, test } from '@rstest/core'; + +import { + createRscRuntimeRsbuildConfig, + type RscRuntimeCompileSnapshot, +} from '../rsbuild.config.js'; +import { + captureRuntimeGenerationSnapshot, + createRscCompilerAssetCheckpointTracker, + materializeRuntimeGeneration, + rscRuntimeGenerationMetadataCodec, + runtimeDefinitionDigest, + validateRscRuntimeGenerationMetadata, + type RscCompilerAssetCheckpointTracker, + type RscRuntimeCapturedGenerationSnapshot, + type RscRuntimeGenerationMetadata, +} from '../src/dev/generation-materializer.js'; +import { digest, stableJson } from '../../../packages/agent-bundle/src/core/digest.ts'; +import { RuntimeGenerationStore } from '../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; +import type { DevRuntimePreparedProject } from '../../../packages/agent-bundle/src/dev/runtime-provider.ts'; + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + +const definitionJson = '{"nativeHooks":[],"resources":[],"tools":[]}'; + +const runtimeFiles = { + 'chunks/101.js': 'async-chunk', + 'dev/definition.js': `process.stdout.write(${JSON.stringify(`${definitionJson}\n`)});\n`, + 'dev/invoke.js': 'invoke-worker', + 'hook/index.js': 'hook-entry', + 'mcp/http.js': 'http-entry', + 'mcp/stdio.js': 'stdio-entry', + 'rsc/index.js': 'rsc-entry', +} as const; + +const widgetFiles = { + 'rsc/index.html': '', + 'static/js/rsc/index.js': 'client-reference', +} as const; + +const appFiles = { + 'edit-timeline-v1.html': '
Timeline
', + 'edit-timeline-v2.html': '
Timeline v2
', + 'activity-v1.html': '
Activity
', +} as const; + +const writeTree = async (root: string, files: Readonly>): Promise => { + await Promise.all(Object.entries(files).map(async ([path, contents]) => { + const destination = join(root, ...path.split('/')); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents, 'utf8'); + })); +}; + +const writeCompilerCohort = async ( + compilerRoot: string, + options: Readonly<{ + readonly appFiles?: Readonly>; + readonly rscFiles?: Readonly>; + readonly widgetFiles?: Readonly>; + }> = {}, +): Promise => { + const rscRoot = join(compilerRoot, 'rsc'); + await writeTree(rscRoot, { ...runtimeFiles, ...options.rscFiles }); + await mkdir(join(compilerRoot, 'app'), { recursive: true }); + await writeTree(join(compilerRoot, 'app'), options.appFiles ?? appFiles); + await writeTree(join(compilerRoot, 'widget'), { ...widgetFiles, ...options.widgetFiles }); + await writeFile(join(rscRoot, 'runtime-assets.json'), JSON.stringify({ + allFiles: Object.keys(runtimeFiles).map((path) => `/${path}`), + entries: { + 'dev/definition': { initial: { js: ['/dev/definition.js'] } }, + 'dev/invoke': { initial: { js: ['/dev/invoke.js'] } }, + 'hook/index': { initial: { js: ['/hook/index.js'] } }, + 'mcp/http': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/http.js'] } }, + 'mcp/stdio': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/stdio.js'] } }, + 'rsc/index': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/rsc/index.js'] } }, + }, + }), 'utf8'); +}; + +const preparedRuntime = Object.freeze({ + apps: Object.freeze([]), + provider: './src/dev/provider.ts', + servers: Object.freeze([]), + sourceRevision: 'prepared-r1', +}); + +const preparedRuntimeWithApp = ( + app: Partial = {}, + runtime: Partial> = {}, +): DevRuntimePreparedProject => Object.freeze({ + apps: Object.freeze([Object.freeze({ + _meta: Object.freeze({ presentation: Object.freeze({ accent: 'indigo', version: 1 }) }), + id: 'timeline-app', + name: 'Timeline', + resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + serverId: 'timeline-server', + serverName: 'Timeline MCP', + source: '/workspace/plugin/agent-bundle.config.ts', + targets: Object.freeze(['claude', 'codex']), + template: '/workspace/plugin/src/app/edit-timeline.html', + ...app, + })]), + provider: './src/dev/provider.ts', + servers: Object.freeze([Object.freeze({ + command: 'node', + cwd: '/workspace/plugin', + id: 'timeline-server', + name: 'Timeline MCP', + source: '/workspace/plugin/agent-bundle.config.ts', + targets: Object.freeze(['claude', 'codex']), + transport: 'stdio' as const, + })]), + sourceRevision: 'prepared-r1', + ...runtime, +}); + +const createStore = (storageRoot: string): RuntimeGenerationStore => + new RuntimeGenerationStore({ + metadataCodec: rscRuntimeGenerationMetadataCodec, + now: () => new Date('2026-08-15T00:00:00.000Z'), + storageRoot, + validateMetadata: validateRscRuntimeGenerationMetadata, + }); + +const rewriteGenerationManifest = async ( + root: string, + mutateMetadata: (metadata: Readonly>) => Readonly>, +): Promise => { + const manifestPath = join(root, 'generation.manifest.json'); + const parsed: unknown = JSON.parse(await readFile(manifestPath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || + !('metadata' in parsed) || typeof parsed.metadata !== 'object' || parsed.metadata === null || Array.isArray(parsed.metadata)) { + throw new TypeError('Test generation manifest was malformed.'); + } + const { manifestDigest: _manifestDigest, ...withoutDigest } = parsed as Readonly>; + const updated = Object.freeze({ ...withoutDigest, metadata: mutateMetadata(parsed.metadata as Readonly>) }); + await writeFile(manifestPath, stableJson({ ...updated, manifestDigest: digest(updated) }), 'utf8'); +}; + +const acceptCompilerAssetCheckpoint = (snapshot: RscRuntimeCapturedGenerationSnapshot): void => { + expect(snapshot.acceptCompilerAssetCheckpoint).toBeTypeOf('function'); + snapshot.acceptCompilerAssetCheckpoint?.(); +}; + +const captureWithCompilerAssetCheckpoint = async ( + input: Parameters[0], + tracker: RscCompilerAssetCheckpointTracker, +) => captureRuntimeGenerationSnapshot({ + ...input, + compilerAssetCheckpointTracker: tracker, +}); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') return false; + throw error; + } +}; + +const activateCompilerObserver = (onCompile: NonNullable[0]['onCompile']>) => { + const config = createRscRuntimeRsbuildConfig({ + compilerRoot: join(tmpdir(), 'rsc-agent-runtime-observer'), + mode: 'development', + onCompile, + }); + const plugin = (config.plugins as readonly unknown[]).find((value): value is Readonly<{ + readonly name: string; + setup(api: unknown): void; + }> => typeof value === 'object' && value !== null && 'name' in value && (value as { name?: unknown }).name === 'agent-bundle:rsc-runtime-compile-observer'); + if (plugin === undefined) throw new Error('Compile observer plugin was not configured.'); + + let before: (() => void) | undefined; + let after: ((input: unknown) => Promise) | undefined; + plugin.setup({ + onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, + onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, + }); + return Object.freeze({ + async compile(children: readonly Readonly<{ readonly hash?: string; readonly name?: string }>[]): Promise { + before?.(); + await after?.({ + stats: { + hasErrors: () => false, + toJson: () => ({ children }), + }, + }); + }, + }); +}; + +const compilerObserver = (input: Readonly<{ + readonly capture: Array>>; + readonly enqueued: string[]; + readonly failed: unknown[]; +}>) => activateCompilerObserver({ + beforeAttempt: () => 'attempt-1', + capture: async (value) => { + input.capture.push(value); + return { + attemptId: value.attemptId, + candidateId: 'candidate-1', + preparedRevision: 'prepared-1', + rscCohortRevision: 1, + sourceRevision: value.sourceRevision, + }; + }, + enqueue: (snapshot) => input.enqueued.push(snapshot.attemptId), + failAttempt: (_attemptId, error) => input.failed.push(error), +}); + +test('resolves the coherent development compiler configuration through Rsbuild', async () => { + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-compiler-')); + try { + const rsbuild = await createRsbuild({ + config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), + cwd: process.cwd(), + }); + const inspection = await rsbuild.inspectConfig({ mode: 'development' }); + const environments = inspection.origin.environmentConfigs; + const bundlers = inspection.origin.bundlerConfigs; + const rscBundler = bundlers.find((config) => config.name === 'rsc'); + const widgetBundler = bundlers.find((config) => config.name === 'widget'); + const appBundler = bundlers.find((config) => config.name === 'app'); + + expect(Object.keys(environments).sort()).toEqual(['app', 'rsc', 'widget']); + expect(environments.rsc?.output.target).toBe('node'); + expect(environments.widget?.output.target).toBe('web'); + expect(environments.rsc?.output.distPath.root).toBe(join(compilerRoot, 'rsc')); + expect(environments.widget?.output.distPath.root).toBe(join(compilerRoot, 'widget')); + expect(environments.app?.output.distPath.root).toBe(join(compilerRoot, 'app')); + expect(inspection.origin.rsbuildConfig.dev.writeToDisk).toBe(true); + expect(inspection.origin.rsbuildConfig.server.host).toBe('127.0.0.1'); + expect(inspection.origin.rsbuildConfig.server.port).toBe(3000); + expect(rscBundler?.output?.chunkFilename).toBe('chunks/[name].js'); + expect(rscBundler?.output?.path).toBe(join(compilerRoot, 'rsc')); + expect(widgetBundler?.output?.path).toBe(join(compilerRoot, 'widget')); + expect(appBundler?.output?.path).toBe(join(compilerRoot, 'app')); + expect(rscBundler?.module?.rules?.some((rule) => + typeof rule === 'object' && rule !== null && 'test' in rule && String(rule.test).includes('request-render'))).toBe(true); + expect(appBundler?.target).toEqual(expect.arrayContaining(['web'])); + expect(appBundler?.plugins?.some((plugin) => plugin?.constructor?.name.includes('ReactRefresh'))).toBe(false); + + const production = await createRsbuild({ + config: createRscRuntimeRsbuildConfig({ mode: 'production' }), + cwd: process.cwd(), + }); + const productionInspection = await production.inspectConfig({ mode: 'production' }); + expect(productionInspection.origin.rsbuildConfig.dev.writeToDisk).not.toBe(true); + expect(productionInspection.origin.environmentConfigs.rsc?.output.distPath.root).toBe('dist/runtime'); + expect(productionInspection.origin.environmentConfigs.widget?.output.distPath.root).toBe('dist/widget'); + expect(productionInspection.origin.environmentConfigs.rsc?.source.entry).not.toHaveProperty('dev/definition'); + expect(productionInspection.origin.environmentConfigs.rsc?.source.entry).not.toHaveProperty('dev/invoke'); + } finally { + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('captures immutable paired compiler outputs and records every digested asset', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const candidate = await store.begin({ id: 'g1', sourceRevision: 'source-r1' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-1', + candidate, + compilerRoot, + preparedRuntime, + rscCohortRevision: 1, + sourceRevision: 'source-r1', + }); + + await writeFile(join(compilerRoot, 'rsc', 'rsc', 'index.js'), 'overwritten-after-capture', 'utf8'); + expect(await readFile(join(candidate.root, 'rsc', 'rsc', 'index.js'), 'utf8')).toBe('rsc-entry'); + + const prepared = await materializeRuntimeGeneration({ snapshot, store }); + const assets = prepared.generation.manifest.assets; + expect(assets).toEqual(expect.arrayContaining([ + { bytes: 10, path: 'rsc/hook/index.js', sha256: '124bca2527b3be927263a58d4fe32fd7dbaeff7988aa596840a72930d754c19e' }, + { bytes: 9, path: 'rsc/rsc/index.js', sha256: '9d51e6aa438ceebcf519fc709042d53177818b9e41161e477e36686acf169a84' }, + { bytes: 16, path: 'widget/static/js/rsc/index.js', sha256: '293818db721cb0d68e14d84f58fe9bc7ad285be34c4dbee827f967891b94015f' }, + ])); + expect(assets.map((asset) => asset.path)).toEqual(expect.arrayContaining([ + 'rsc/runtime-assets.json', + 'rsc/runtime-definition.json', + 'rsc/agent-runtime.manifest.json', + 'rsc/chunks/101.js', + 'widget/rsc/index.html', + 'widget/static/js/rsc/index.js', + ])); + expect(prepared.generation.manifest.metadata.definitionDigest) + .toBe(sha256('{"apps":[],"definition":{"nativeHooks":[],"resources":[],"tools":[]}}')); + expect(prepared.generation.manifest.metadata.environmentHashes).toEqual(expect.objectContaining({ + rsc: expect.stringMatching(/^[a-f0-9]{64}$/u), + widget: expect.stringMatching(/^[a-f0-9]{64}$/u), + })); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('includes prepared App definitions in the captured runtime definition digest', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-definition-digest-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const metadataFor = async ( + id: string, + prepared: DevRuntimePreparedProject, + sourceRevision = 'captured-r1', + ) => { + const candidate = await store.begin({ id, sourceRevision }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: `attempt-${id}`, + candidate, + compilerRoot, + preparedRuntime: prepared, + rscCohortRevision: 1, + sourceRevision, + }); + const generation = await materializeRuntimeGeneration({ snapshot, store }); + return Object.freeze({ generation: generation.generation, metadata: generation.generation.manifest.metadata, snapshot }); + }; + + const baseline = await metadataFor('baseline', preparedRuntimeWithApp()); + const appDefinitionVariants: readonly Readonly<{ readonly id: string; readonly prepared: DevRuntimePreparedProject }>[] = [ + { id: 'meta', prepared: preparedRuntimeWithApp({ _meta: Object.freeze({ presentation: Object.freeze({ accent: 'teal', version: 2 }) }) }) }, + { id: 'id', prepared: preparedRuntimeWithApp({ id: 'timeline-app-v2' }) }, + { id: 'name', prepared: preparedRuntimeWithApp({ name: 'Timeline v2' }) }, + { id: 'server-id', prepared: preparedRuntimeWithApp({ serverId: 'timeline-server-v2' }) }, + { id: 'server-name', prepared: preparedRuntimeWithApp({ serverName: 'Timeline MCP v2' }) }, + { id: 'resource-uri', prepared: preparedRuntimeWithApp({ resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v2.html' }) }, + { id: 'targets', prepared: preparedRuntimeWithApp({ targets: Object.freeze(['codex']) }) }, + ]; + + for (const variant of appDefinitionVariants) { + const captured = await metadataFor(variant.id, variant.prepared); + expect(captured.metadata.definitionDigest).not.toBe(baseline.metadata.definitionDigest); + expect(captured.metadata.servers.map((server) => server.definitionDigest)).toEqual([ + captured.metadata.definitionDigest, + captured.metadata.definitionDigest, + ]); + } + + const sourceAndTransportNoise = await metadataFor('noise', preparedRuntimeWithApp({ + source: '/other-machine/plugin/agent-bundle.config.ts', + template: '/other-machine/plugin/src/app/edit-timeline.html', + }, { + provider: '/other-machine/plugin/src/dev/provider.ts', + servers: Object.freeze([Object.freeze({ + args: Object.freeze(['--serve', '--token=top-secret']), + command: '/other-machine/bin/timeline-server', + cwd: '/other-machine/plugin', + env: Object.freeze({ API_TOKEN: 'top-secret' }), + headers: Object.freeze({ Authorization: 'Bearer top-secret' }), + id: 'timeline-server', + name: 'Timeline MCP', + source: '/other-machine/plugin/agent-bundle.config.ts', + targets: Object.freeze(['claude', 'codex']), + transport: 'streamable-http' as const, + url: 'https://other-machine.invalid/mcp', + })]), + sourceRevision: 'prepared-r2', + }), 'captured-r2'); + expect(sourceAndTransportNoise.metadata.definitionDigest).toBe(baseline.metadata.definitionDigest); + + expect(runtimeDefinitionDigest(baseline.snapshot.definition, baseline.snapshot.preparedRuntime)) + .toBe(baseline.metadata.definitionDigest); + + const [timelineApp] = baseline.snapshot.preparedRuntime.apps; + if (timelineApp === undefined) throw new Error('Baseline prepared App was not captured.'); + const activityApp = Object.freeze({ + ...timelineApp, + id: 'activity-app', + name: 'Activity', + resourceUri: 'ui://rsc-agent-runtime/activity-v1.html', + }); + const orderedForward = await metadataFor('ordered-forward', Object.freeze({ + ...baseline.snapshot.preparedRuntime, + apps: Object.freeze([timelineApp, activityApp]), + })); + const orderedReverse = await metadataFor('ordered-reverse', Object.freeze({ + ...baseline.snapshot.preparedRuntime, + apps: Object.freeze([activityApp, timelineApp]), + })); + expect(orderedReverse.metadata.definitionDigest).toBe(orderedForward.metadata.definitionDigest); + expect(orderedForward.metadata.appDefinitions.map((app) => app.id)).toEqual(['activity-app', 'timeline-app']); + expect(orderedForward.metadata.appDefinitions.every((app) => !('template' in app))).toBe(true); + const [firstAppDefinition] = orderedForward.metadata.appDefinitions; + if (firstAppDefinition === undefined || firstAppDefinition._meta === undefined) throw new Error('Ordered App definition was malformed.'); + expect(Object.isFrozen(orderedForward.metadata.appDefinitions)).toBe(true); + expect(Object.isFrozen(firstAppDefinition)).toBe(true); + expect(Object.isFrozen(firstAppDefinition.targets)).toBe(true); + expect(Object.isFrozen(firstAppDefinition._meta)).toBe(true); + expect(Object.isFrozen(firstAppDefinition._meta.presentation)).toBe(true); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('captures the canonical generated HTML asset for each prepared App surface', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + const html = '
Timeline
'; + try { + await writeCompilerCohort(compilerRoot, { appFiles: { 'edit-timeline-v1.html': html } }); + const candidate = await store.begin({ id: 'app-html', sourceRevision: 'source-app-html' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-html', + candidate, + compilerRoot, + preparedRuntime: preparedRuntimeWithApp(), + rscCohortRevision: 1, + sourceRevision: 'source-app-html', + }); + const prepared = await materializeRuntimeGeneration({ snapshot, store }); + + expect(prepared.generation.manifest.metadata.surfaceAssets['mcp.Timeline']).toEqual(expect.arrayContaining([{ + bytes: Buffer.byteLength(html), + contentType: 'text/html', + generationPath: 'app/edit-timeline-v1.html', + requestPath: '/edit-timeline-v1.html', + sha256: sha256(html), + }])); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a traversal-normalized App URI even when a matching generated HTML file exists', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-traversal-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot, { appFiles: { 'escaped.html': '
Escaped
' } }); + const candidate = await store.begin({ id: 'app-html-traversal', sourceRevision: 'source-app-html-traversal' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-html-traversal', + candidate, + compilerRoot, + preparedRuntime: preparedRuntimeWithApp({ resourceUri: 'ui://rsc-agent-runtime/../escaped.html' }), + rscCohortRevision: 1, + sourceRevision: 'source-app-html-traversal', + }); + await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('resource URI is invalid'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects missing, duplicate, and symbolic-link App HTML capture inputs', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-invalid-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot, { appFiles: {} }); + const missingCandidate = await store.begin({ id: 'app-html-missing', sourceRevision: 'source-app-html-missing' }); + const missingSnapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-html-missing', candidate: missingCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 1, sourceRevision: 'source-app-html-missing', + }); + await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('no unique captured HTML asset'); + + await writeCompilerCohort(compilerRoot); + const [timelineApp] = preparedRuntimeWithApp().apps; + if (timelineApp === undefined) throw new Error('Timeline App fixture was unavailable.'); + const duplicateCandidate = await store.begin({ id: 'app-html-duplicate', sourceRevision: 'source-app-html-duplicate' }); + const duplicateSnapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-html-duplicate', + candidate: duplicateCandidate, + compilerRoot, + preparedRuntime: Object.freeze({ + ...preparedRuntimeWithApp(), + apps: Object.freeze([timelineApp, Object.freeze({ ...timelineApp, id: 'timeline-app-duplicate' })]), + }), + rscCohortRevision: 2, + sourceRevision: 'source-app-html-duplicate', + }); + await expect(materializeRuntimeGeneration({ snapshot: duplicateSnapshot, store })).rejects.toThrow('duplicate App surface'); + + await symlink(join(compilerRoot, 'app', 'edit-timeline-v1.html'), join(compilerRoot, 'app', 'linked.html')); + const linkedCandidate = await store.begin({ id: 'app-html-link', sourceRevision: 'source-app-html-link' }); + await expect(captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-html-link', candidate: linkedCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-html-link', + })).rejects.toThrow('symbolic links'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a rewritten prepared App definition manifest on post-rename reload', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-persisted-app-definition-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const candidate = await store.begin({ id: 'persisted-app', sourceRevision: 'source-persisted-app' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-persisted-app', + candidate, + compilerRoot, + preparedRuntime: preparedRuntimeWithApp(), + rscCohortRevision: 1, + sourceRevision: 'source-persisted-app', + }); + let waits = 0; + await expect(materializeRuntimeGeneration({ + guard: { + check: () => true, + wait: async () => { + waits += 1; + if (waits !== 1) return; + await rewriteGenerationManifest(snapshot.candidate.root, (metadata) => ({ + ...metadata, + appDefinitions: [{ + ...(metadata.appDefinitions as readonly Readonly>[])[0], + name: 'Tampered timeline', + }], + })); + }, + }, + snapshot, + store, + })).rejects.toMatchObject({ code: 'RUNTIME_GENERATION_INVALID' }); + expect(waits).toBe(1); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a persisted App surface manifest without its declared canonical HTML asset', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-persisted-app-surface-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const candidate = await store.begin({ id: 'persisted-app-surface', sourceRevision: 'source-persisted-app-surface' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-persisted-app-surface', + candidate, + compilerRoot, + preparedRuntime: preparedRuntimeWithApp(), + rscCohortRevision: 1, + sourceRevision: 'source-persisted-app-surface', + }); + let waits = 0; + await expect(materializeRuntimeGeneration({ + guard: { + check: () => true, + wait: async () => { + waits += 1; + if (waits !== 1) return; + await rewriteGenerationManifest(snapshot.candidate.root, (metadata) => ({ + ...metadata, + surfaceAssets: Object.fromEntries(Object.entries(metadata.surfaceAssets as Readonly>[]>>) + .map(([surfaceId, assets]) => [surfaceId, assets.filter((asset) => asset.contentType !== 'text/html')])), + })); + }, + }, + snapshot, + store, + })).rejects.toMatchObject({ code: 'RUNTIME_GENERATION_INVALID' }); + expect(waits).toBe(1); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a removed or replaced paired compiler asset after capture', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const missingCandidate = await store.begin({ id: 'missing', sourceRevision: 'source-missing' }); + const missingSnapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-missing', candidate: missingCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing', + }); + await unlink(join(missingCandidate.root, 'widget', 'rsc', 'index.html')); + await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('captured cohort'); + + const replacedCandidate = await store.begin({ id: 'replaced', sourceRevision: 'source-replaced' }); + const replacedSnapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-replaced', candidate: replacedCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-replaced', + }); + await writeFile(join(replacedCandidate.root, 'widget', 'static', 'js', 'rsc', 'index.js'), 'replaced-client-reference', 'utf8'); + await expect(materializeRuntimeGeneration({ snapshot: replacedSnapshot, store })).rejects.toThrow('captured cohort'); + + const appCandidate = await store.begin({ id: 'app-replaced', sourceRevision: 'source-app-replaced' }); + const appSnapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-app-replaced', candidate: appCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-replaced', + }); + await writeFile(join(appCandidate.root, 'app', 'edit-timeline-v1.html'), 'replaced-App-HTML', 'utf8'); + await expect(materializeRuntimeGeneration({ snapshot: appSnapshot, store })).rejects.toThrow('captured cohort'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('bounds and redacts a definition executable stderr flood', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot, { + rscFiles: { + 'dev/definition.js': "process.stderr.write('token=supersecret ' + 'x'.repeat(1024 * 1024)); process.exitCode = 1;\n", + }, + }); + const candidate = await store.begin({ id: 'stderr', sourceRevision: 'source-stderr' }); + const error = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-stderr', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-stderr', + }).then( + () => new Error('Definition stderr flood unexpectedly captured.'), + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('stderr'); + expect((error as Error).message).not.toContain('supersecret'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('waits for grace-to-SIGKILL termination of a SIGTERM-ignoring definition child', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const marker = join(storageRoot, 'definition-child.pid'); + const store = createStore(storageRoot); + let childPid: number | undefined; + try { + await writeCompilerCohort(compilerRoot, { + rscFiles: { + 'dev/definition.js': `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);\n`, + }, + }); + const candidate = await store.begin({ id: 'ignores-term', sourceRevision: 'source-ignores-term' }); + await expect(captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-ignores-term', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-ignores-term', + })).rejects.toThrow('exceeded 5 seconds'); + childPid = Number(await readFile(marker, 'utf8')); + expect(Number.isSafeInteger(childPid)).toBe(true); + expect(isProcessAlive(childPid)).toBe(false); + } finally { + if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}, 10_000); + +test('fails compile attempts unless stats contain one nonempty RSC and widget hash', async () => { + for (const children of [ + [{ name: 'rsc', hash: 'rsc-hash' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'rsc', hash: 'second-rsc-hash' }, { name: 'widget', hash: 'widget-hash' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget' }], + ]) { + const capture: Array>> = []; + const enqueued: string[] = []; + const failed: unknown[] = []; + await compilerObserver({ capture, enqueued, failed }).compile(children); + expect(capture).toEqual([]); + expect(enqueued).toEqual([]); + expect(failed).toHaveLength(1); + } +}); + +test('accepts a compiler checkpoint only after enqueue and discards it after an enqueue failure', async () => { + const lifecycle: string[] = []; + const captures: Array> = []; + const failed: unknown[] = []; + const snapshots = [ + Object.freeze({ + acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-a'), + attemptId: 'a', candidateId: 'a', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-a'), preparedRevision: 'prepared-a', rscCohortRevision: 1, sourceRevision: 'a', + }), + Object.freeze({ + acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b'), + attemptId: 'b', candidateId: 'b', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b'), preparedRevision: 'prepared-b', rscCohortRevision: 2, sourceRevision: 'b', + }), + Object.freeze({ + acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b-retry'), + attemptId: 'b-retry', candidateId: 'b-retry', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b-retry'), preparedRevision: 'prepared-b', rscCohortRevision: 3, sourceRevision: 'b', + }), + ] as const satisfies readonly RscRuntimeCompileSnapshot[]; + let index = 0; + let enqueueCount = 0; + const observer = activateCompilerObserver({ + beforeAttempt: () => `attempt-${String(index)}`, + capture: async (input) => { + captures.push({ cohortChanged: input.cohortChanged }); + const snapshot = snapshots[index]; + index += 1; + return snapshot; + }, + enqueue: (snapshot) => { + lifecycle.push(`enqueue-${snapshot.attemptId}`); + enqueueCount += 1; + if (enqueueCount === 2) throw new Error('enqueue failed'); + }, + failAttempt: (_attemptId, error) => failed.push(error), + }); + + await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); + await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + + expect(lifecycle).toEqual([ + 'enqueue-a', 'accept-a', + 'enqueue-b', 'discard-b', + 'enqueue-b-retry', 'accept-b-retry', + ]); + expect(captures).toEqual([ + { cohortChanged: true }, + { cohortChanged: true }, + { cohortChanged: true }, + ]); + expect(failed).toHaveLength(1); +}); + +test('requires every executable entry to declare its async cohort assets', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot); + const manifestPath = join(compilerRoot, 'rsc', 'runtime-assets.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { entries: Record }; + delete manifest.entries['mcp/http']?.async; + await writeFile(manifestPath, JSON.stringify(manifest), 'utf8'); + const candidate = await store.begin({ id: 'missing-async', sourceRevision: 'source-missing-async' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-missing-async', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing-async', + }); + await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('async'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a genuinely undeclared RSC file outside the known compiler cohort', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot, { rscFiles: { 'undeclared.js': 'not-in-runtime-assets' } }); + const candidate = await store.begin({ id: 'undeclared', sourceRevision: 'source-undeclared' }); + await expect(captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-undeclared', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-undeclared', + })).rejects.toThrow('undeclared'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('reconciles a stale known async chunk from a prior incremental compiler cohort', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + const tracker = createRscCompilerAssetCheckpointTracker(); + try { + await writeCompilerCohort(compilerRoot); + const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); + const firstSnapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', + }, tracker); + const firstPrepared = await materializeRuntimeGeneration({ snapshot: firstSnapshot, store }); + await store.abort(firstPrepared); + acceptCompilerAssetCheckpoint(firstSnapshot); + + const rscRoot = join(compilerRoot, 'rsc'); + await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); + const manifestPath = join(rscRoot, 'runtime-assets.json'); + const manifest = await readFile(manifestPath, 'utf8'); + await writeFile(manifestPath, manifest.replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); + expect(await readFile(join(rscRoot, 'chunks', '101.js'), 'utf8')).toBe('async-chunk'); + + const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); + const snapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', + }, tracker); + const prepared = await materializeRuntimeGeneration({ snapshot, store }); + + expect(prepared.generation.manifest.assets.map((asset) => asset.path)).toEqual(expect.arrayContaining([ + 'rsc/chunks/202.js', + ])); + expect(prepared.generation.manifest.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); + expect(await readFile(join(prepared.generation.root, 'rsc', 'chunks', '202.js'), 'utf8')).toBe('replacement-async-chunk'); + await expect(readFile(join(prepared.generation.root, 'rsc', 'chunks', '101.js'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + tracker.close(); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('retries a stale known compiler chunk after enqueue discards the prior capture checkpoint', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + const tracker = createRscCompilerAssetCheckpointTracker(); + const snapshots: RscRuntimeCapturedGenerationSnapshot[] = []; + const failed: unknown[] = []; + let candidateNumber = 0; + let enqueueNumber = 0; + try { + await writeCompilerCohort(compilerRoot); + const observer = activateCompilerObserver({ + beforeAttempt: () => `attempt-${String(candidateNumber)}`, + capture: async (input) => { + candidateNumber += 1; + const candidate = await store.begin({ id: `candidate-${String(candidateNumber)}`, sourceRevision: input.sourceRevision }); + const snapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: input.attemptId, + candidate, + compilerRoot, + preparedRuntime, + rscCohortRevision: candidateNumber, + sourceRevision: input.sourceRevision, + }, tracker); + snapshots.push(snapshot); + return Object.freeze({ + ...snapshot, + candidateId: candidate.id, + preparedRevision: snapshot.preparedRuntime.sourceRevision, + }); + }, + enqueue: () => { + enqueueNumber += 1; + if (enqueueNumber === 2) throw new Error('enqueue rejects B'); + }, + failAttempt: (_attemptId, error) => failed.push(error), + }); + + await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); + const rscRoot = join(compilerRoot, 'rsc'); + await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); + const manifestPath = join(rscRoot, 'runtime-assets.json'); + await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); + + await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + + expect(failed).toHaveLength(1); + expect(snapshots).toHaveLength(3); + for (const snapshot of snapshots.slice(1)) { + expect(snapshot.assets.map((asset) => asset.path)).toContain('rsc/chunks/202.js'); + expect(snapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); + } + } finally { + tracker.close(); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('isolates roots between tracker sessions and revokes checkpoint provenance on close', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const otherCompilerRoot = join(storageRoot, 'other-compiler'); + const store = createStore(storageRoot); + const firstTracker = createRscCompilerAssetCheckpointTracker(); + let secondTracker: RscCompilerAssetCheckpointTracker | undefined; + try { + await writeCompilerCohort(compilerRoot); + const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); + const firstSnapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', + }, firstTracker); + acceptCompilerAssetCheckpoint(firstSnapshot); + + const rscRoot = join(compilerRoot, 'rsc'); + await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); + const manifestPath = join(rscRoot, 'runtime-assets.json'); + await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); + firstTracker.close(); + + secondTracker = createRscCompilerAssetCheckpointTracker(); + const reusedRootCandidate = await store.begin({ id: 'reused-root', sourceRevision: 'source-reused-root' }); + await expect(captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-reused-root', candidate: reusedRootCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-reused-root', + }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); + + await writeCompilerCohort(otherCompilerRoot); + const otherRootManifestPath = join(otherCompilerRoot, 'rsc', 'runtime-assets.json'); + await writeFile(join(otherCompilerRoot, 'rsc', 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); + await writeFile(otherRootManifestPath, (await readFile(otherRootManifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); + const otherRootCandidate = await store.begin({ id: 'other-root', sourceRevision: 'source-other-root' }); + await expect(captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-other-root', candidate: otherRootCandidate, compilerRoot: otherCompilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-other-root', + }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); + } finally { + secondTracker?.close(); + firstTracker.close(); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('serializes concurrent same-root captures and commits checkpoints in capture order', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + const tracker = createRscCompilerAssetCheckpointTracker(); + try { + await writeCompilerCohort(compilerRoot); + const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); + const firstSnapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', + }, tracker); + acceptCompilerAssetCheckpoint(firstSnapshot); + + const rscRoot = join(compilerRoot, 'rsc'); + await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); + const manifestPath = join(rscRoot, 'runtime-assets.json'); + await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); + const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); + const thirdCandidate = await store.begin({ id: 'third', sourceRevision: 'source-third' }); + const secondSnapshot = await captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', + }, tracker); + let thirdSettled = false; + const thirdSnapshotPromise = captureWithCompilerAssetCheckpoint({ + attemptId: 'attempt-third', candidate: thirdCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-third', + }, tracker).then((snapshot) => { + thirdSettled = true; + return snapshot; + }); + await new Promise((resolveMicrotask) => queueMicrotask(resolveMicrotask)); + expect(thirdSettled).toBe(false); + + acceptCompilerAssetCheckpoint(secondSnapshot); + const thirdSnapshot = await thirdSnapshotPromise; + expect(thirdSnapshot.assets.map((asset) => asset.path)).toContain('rsc/chunks/202.js'); + expect(thirdSnapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); + acceptCompilerAssetCheckpoint(thirdSnapshot); + } finally { + tracker.close(); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects a client entry document that points at a different client-reference asset', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createStore(storageRoot); + try { + await writeCompilerCohort(compilerRoot, { + widgetFiles: { 'rsc/index.html': '' }, + }); + const candidate = await store.begin({ id: 'mismatched-client', sourceRevision: 'source-mismatched-client' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-mismatched-client', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-mismatched-client', + }); + await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('client reference relationship'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts new file mode 100644 index 000000000..57e18af88 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -0,0 +1,307 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { once } from 'node:events'; +import { tmpdir } from 'node:os'; +import { dirname, join, normalize } from 'node:path'; +import type { Readable } from 'node:stream'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { expect, test } from '@rstest/core'; + +const exampleRoot = process.cwd(); +const pluginsRoot = join(exampleRoot, 'dist/plugins'); + +const runPackageHosts = async (): Promise => { + const child = spawn(process.execPath, ['scripts/package-hosts.mjs'], { cwd: exampleRoot, stdio: 'pipe' }); + const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; + expect(signal).toBeNull(); + expect(exitCode).toBe(0); +}; + +const runProductionBuild = async (): Promise => { + await rm(join(exampleRoot, 'dist/app'), { force: true, recursive: true }); + const child = spawn('npm', ['run', 'build'], { cwd: exampleRoot, stdio: 'pipe' }); + const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; + expect(signal).toBeNull(); + expect(exitCode).toBe(0); +}; + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')) as T; + +const runtimeAssets = async (): Promise => { + const manifest = await readJson<{ allFiles: string[] }>(join(exampleRoot, 'dist/runtime/runtime-assets.json')); + return manifest.allFiles.map((asset) => asset.replace(/^\//, '')); +}; + +const runDeclaredHook = async ( + command: string, + environment: Readonly>, + input: Readonly>, +): Promise> => { + const child = spawn('/bin/sh', ['-c', command], { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify(input)); + const collect = (stream: Readable): Promise => new Promise((resolve, reject) => { + let text = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { text += chunk; }); + stream.once('error', reject); + stream.once('end', () => resolve(text)); + }); + const [stdout, stderr, outcome] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>, + ]); + return Object.freeze({ exitCode: outcome[0], signal: outcome[1], stderr, stdout }); +}; + +type ArtifactDigestEntry = Readonly<{ + readonly bytes: number; + readonly path: string; + readonly sha256: string; +}>; + +const artifactDigest = async (root: string): Promise => { + const entries = (await readdir(root, { recursive: true })) + .filter((entry): entry is string => typeof entry === 'string') + .sort(); + const digest: ArtifactDigestEntry[] = []; + for (const path of entries) { + const absolutePath = join(root, path); + if (!(await stat(absolutePath)).isFile()) continue; + const content = await readFile(absolutePath); + digest.push({ + bytes: content.byteLength, + path, + sha256: createHash('sha256').update(content).digest('hex'), + }); + } + return digest; +}; + +test('materializes self-contained Claude and Codex native plugin artifacts', async () => { + await runPackageHosts(); + const claudeRoot = join(pluginsRoot, 'claude'); + const codexRoot = join(pluginsRoot, 'codex'); + const claudeManifest = await readJson<{ name: string; version: string }>(join(claudeRoot, '.claude-plugin/plugin.json')); + const codexManifest = await readJson<{ + interface: unknown; + mcpServers: string; + hooks: string; + name: string; + skills: string; + version: string; + }>(join(codexRoot, '.codex-plugin/plugin.json')); + const claudeMcp = await readJson<{ mcpServers: Record }>(join(claudeRoot, '.mcp.json')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(codexRoot, '.mcp.json')); + const claudeHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( + join(claudeRoot, 'hooks/hooks.json'), + ); + const codexHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( + join(codexRoot, 'hooks/hooks.json'), + ); + + expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime', version: '0.1.0' }); + expect(codexManifest).toMatchObject({ + hooks: './hooks/hooks.json', + interface: expect.any(Object), + mcpServers: './.mcp.json', + name: 'rsc-agent-runtime', + skills: './skills/', + version: '0.1.0', + }); + expect(claudeMcp.mcpServers['rsc-agent-runtime'].args).toContain('${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js'); + expect(codexMcp.mcpServers['rsc-agent-runtime']).toMatchObject({ args: ['./runtime/mcp/stdio.js'], cwd: './' }); + expect(JSON.stringify(codexMcp)).not.toMatch(/PLUGIN_ROOT|PLUGIN_DATA|workspace/i); + expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'Write|Edit' }); + expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${CLAUDE_PLUGIN_ROOT}'); + expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host claude'); + expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'apply_patch' }); + expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${PLUGIN_ROOT}'); + expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host codex'); + expect(JSON.stringify({ claudeMcp, claudeHooks, codexMcp, codexHooks })).not.toMatch(/api[ _-]?key/i); + + const runtimeRoot = join(exampleRoot, 'dist/runtime'); + const runtimeDigest = await artifactDigest(runtimeRoot); + expect(await artifactDigest(join(claudeRoot, 'runtime'))).toEqual(runtimeDigest); + expect(await artifactDigest(join(codexRoot, 'runtime'))).toEqual(runtimeDigest); + + const assets = await runtimeAssets(); + expect(assets.some((asset) => /^chunks\/.+\.js$/u.test(asset))).toBe(true); + for (const root of [claudeRoot, codexRoot]) { + for (const asset of assets) { + await access(join(root, 'runtime', asset)); + } + const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); + expect(asyncChunk).toBeDefined(); + expect((await stat(join(root, 'runtime', asyncChunk!))).isFile()).toBe(true); + } + for (const relative of ['dist/app/edit-timeline-v1.html', 'dist/app/standalone.html']) { + const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); + expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); + } + for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json', 'skills']) { + await access(join(codexRoot, relative)); + } +}); + +test('keeps fresh production App legal payload names stable and package-identical', async () => { + await runProductionBuild(); + const appDigest = await artifactDigest(join(exampleRoot, 'dist/app')); + expect(appDigest.map((entry) => entry.path)).toEqual([ + 'edit-timeline-v1.html', + 'lib-react.js.LICENSE.txt', + 'standalone.html', + ]); + const legalNotice = appDigest.find((entry) => entry.path === 'lib-react.js.LICENSE.txt'); + expect(legalNotice).toMatchObject({ path: 'lib-react.js.LICENSE.txt' }); + const legalNoticeContent = await readFile(join(exampleRoot, 'dist/app/lib-react.js.LICENSE.txt'), 'utf8'); + expect(legalNoticeContent).toContain('LICENSE file'); + + for (const entry of appDigest) { + expect(entry.path).not.toMatch(/(?:^|\/)[^/]*\.[a-f\d]{8,}\.(?:js|css)(?:\.LICENSE\.txt)?$/iu); + } + for (const appRoot of [join(exampleRoot, 'dist/app'), ...['claude', 'codex'].map((host) => join(pluginsRoot, host, 'app'))]) { + const payload = await artifactDigest(appRoot); + expect(payload).toEqual(appDigest); + let legalReferences = 0; + for (const artifact of payload.filter((entry) => /\.(?:css|html|js)$/iu.test(entry.path))) { + const source = await readFile(join(appRoot, artifact.path), 'utf8'); + for (const match of source.matchAll(/\/\*!\s*LICENSE:\s*([^*\r\n]+?)\s*\*\//gu)) { + legalReferences += 1; + const target = normalize(join(dirname(artifact.path), match[1]!.trim())); + expect(target).not.toMatch(/^(?:\.\.\/|\/)/u); + expect(payload.some((entry) => entry.path === target)).toBe(true); + expect(await readFile(join(appRoot, target), 'utf8')).toBe(legalNoticeContent); + } + if (artifact.path.endsWith('.html')) { + expect(source).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); + } + } + expect(legalReferences).toBeGreaterThan(0); + } +}); + +test('runs the packaged MCP server after its artifact is isolated from the example dist directory', async () => { + await runPackageHosts(); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-isolated-')); + const pluginRoot = join(temporaryRoot, 'claude'); + const stateFile = join(temporaryRoot, 'events.jsonl'); + await cp(join(pluginsRoot, 'claude'), pluginRoot, { recursive: true }); + await writeFile(stateFile, '', 'utf8'); + + const client = new Client({ name: 'host-artifact-test', version: '1.0.0' }); + const transport = new StdioClientTransport({ + args: [join(pluginRoot, 'runtime/mcp/stdio.js')], + command: process.execPath, + env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, + stderr: 'pipe', + }); + + try { + await client.connect(transport); + await expect(client.callTool({ arguments: {}, name: 'render_edit_timeline' })).resolves.toMatchObject({ + content: [{ type: 'text' }], + structuredContent: { edits: [], stateVersion: 0 }, + }); + } finally { + await client.close(); + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('runs each packaged native hook from one shell argv path when its plugin root contains spaces and metacharacters', async () => { + await runPackageHosts(); + const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-hook-root-')); + try { + const nodeBin = join(temporaryRoot, 'bin'); + const argvFile = join(temporaryRoot, 'hook-argv.bin'); + await mkdir(nodeBin); + await writeFile(join(nodeBin, 'node'), '#!/bin/sh\nprintf \'%s\\0\' "$@" > "$AGENT_RUNTIME_HOOK_ARGV_FILE"\nexec "$AGENT_RUNTIME_NODE" "$@"\n', 'utf8'); + await chmod(join(nodeBin, 'node'), 0o755); + + for (const host of ['claude', 'codex'] as const) { + const pluginRoot = join(temporaryRoot, `${host} plugin root ; ordinary`); + const workspace = join(temporaryRoot, `${host}-workspace`); + const stateFile = join(temporaryRoot, `${host}-events.jsonl`); + const manifestPath = join(pluginRoot, 'hooks/hooks.json'); + const rootVariable = host === 'claude' ? 'CLAUDE_PLUGIN_ROOT' : 'PLUGIN_ROOT'; + const filename = `${host}-note.txt`; + await cp(join(pluginsRoot, host), pluginRoot, { recursive: true }); + await mkdir(workspace); + const manifest = await readJson<{ hooks: { PostToolUse: Array<{ hooks: Array<{ command: string }> }> } }>(manifestPath); + const command = manifest.hooks.PostToolUse[0]?.hooks[0]?.command; + expect(command).toBeTypeOf('string'); + const input = host === 'claude' + ? { + cwd: workspace, + hook_event_name: 'PostToolUse', + session_id: `${host}-session`, + tool_input: { file_path: join(workspace, filename) }, + tool_name: 'Write', + tool_use_id: `${host}-tool`, + } + : { + cwd: workspace, + event_id: `${host}-event`, + hook_event_name: 'PostToolUse', + session_id: `${host}-session`, + tool_input: { command: `*** Begin Patch\n*** Add File: ${filename}\n+recorded\n*** End Patch` }, + tool_name: 'apply_patch', + }; + const result = await runDeclaredHook(command!, { + [rootVariable]: pluginRoot, + AGENT_RUNTIME_HOOK_ARGV_FILE: argvFile, + AGENT_RUNTIME_NODE: process.execPath, + AGENT_RUNTIME_STATE_FILE: stateFile, + PATH: `${nodeBin}:${process.env.PATH ?? ''}`, + }, input); + + expect(result.signal).toBeNull(); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + hookSpecificOutput: { + additionalContext: `Recorded ${filename} from ${host}. Shared state now contains 1 edit.`, + hookEventName: 'PostToolUse', + }, + }); + expect((await readFile(argvFile)).toString('utf8').split('\0').filter(Boolean)).toEqual([ + join(pluginRoot, 'runtime/hook/index.js'), '--host', host, + ]); + expect((await readFile(stateFile, 'utf8')).trim()).toContain(`"host":"${host}"`); + expect(command).toBe(`node "\${${rootVariable}}/runtime/hook/index.js" --host ${host}`); + expect(command).not.toMatch(/(?:api[ _-]?key|echo|printenv|AGENT_RUNTIME_)/iu); + } + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('keeps the published Agent Bundle package free of the supplemental RSC runtime', async () => { + const packageRoot = join(exampleRoot, '../../packages/agent-bundle'); + const packageJson = await readJson<{ dependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record }>( + join(packageRoot, 'package.json'), + ); + const allDependencies = { + ...packageJson.dependencies, + ...packageJson.optionalDependencies, + ...packageJson.peerDependencies, + }; + + expect(allDependencies).not.toHaveProperty('react'); + expect(allDependencies).not.toHaveProperty('react-server-dom-rspack'); + expect(allDependencies).not.toHaveProperty('rsbuild-plugin-rsc'); + + const sourceRoot = join(packageRoot, 'src'); + const sourceFiles = await readdir(sourceRoot, { recursive: true }); + for (const relative of sourceFiles) { + if (typeof relative !== 'string' || !relative.endsWith('.ts')) continue; + const source = await readFile(join(sourceRoot, relative), 'utf8'); + expect(source).not.toMatch(/examples\/rsc-agent-runtime|react-server-dom-rspack|rsbuild-plugin-rsc/); + } +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx new file mode 100644 index 000000000..8a11e954f --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx @@ -0,0 +1,86 @@ +import { expect, test } from '@rstest/core'; +import React from 'react'; + +import { runtimeDefinition } from '../src/definition.js'; +import { + claudeStableAppDomain, + mergeSerializableMetadata, + resourceMetadata, +} from '../src/mcp/host-metadata.js'; +import { + createWidgetStateAdapter, + safeAreaCustomProperties, +} from '../src/widget/host-adapters.js'; + +test('keeps the MCP Apps widget portable when no vendor capability exists', () => { + const adapter = createWidgetStateAdapter(undefined); + const metadata = resourceMetadata(runtimeDefinition.resources[0]); + + expect(adapter.kind).toBe('portable'); + expect(adapter.restore(['concept-1', 'concept-2'])).toBeUndefined(); + adapter.persist('concept-2'); + expect(adapter.restore(['concept-1', 'concept-2'])).toBeUndefined(); + expect(metadata.ui).not.toHaveProperty('domain'); + expect(JSON.stringify(metadata)).not.toContain('claudemcpcontent.com'); +}); + +test('restores and synchronously persists only valid documented widget state', () => { + const writes: unknown[] = []; + const adapter = createWidgetStateAdapter({ + openai: { + setWidgetState: (value: unknown) => { + writes.push(value); + }, + widgetState: { selectedEventId: 'concept-2' }, + }, + }); + + expect(adapter.kind).toBe('openai'); + expect(adapter.restore(['concept-1', 'concept-2'])).toBe('concept-2'); + adapter.persist('concept-1'); + expect(writes).toEqual([{ selectedEventId: 'concept-1' }]); + + const malformed = createWidgetStateAdapter({ + openai: { setWidgetState: () => undefined, widgetState: { selectedEventId: 12 } }, + }); + expect(malformed.restore(['concept-1', 'concept-2'])).toBeUndefined(); +}); + +test('derives the optional Claude resource domain only from a supplied public URL', () => { + expect(claudeStableAppDomain('https://example.com/mcp')).toBe('c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com'); + expect(resourceMetadata(runtimeDefinition.resources[0], 'https://example.com/mcp')).toMatchObject({ + ui: { domain: 'c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com' }, + }); +}); + +test('preserves arbitrary serializable extension metadata without changing complete portable data', () => { + const extension = { 'example.acme/trace': { requestId: 'trace-7', retry: false } }; + const merged = mergeSerializableMetadata({ 'openai/outputTemplate': 'ui://timeline' }, extension); + const resource = resourceMetadata({ + ...runtimeDefinition.resources[0], + _meta: { ...runtimeDefinition.resources[0]._meta, ...extension }, + }); + + expect(merged).toEqual({ + 'example.acme/trace': { requestId: 'trace-7', retry: false }, + 'openai/outputTemplate': 'ui://timeline', + }); + expect(resource).toMatchObject(extension); + expect(resource).not.toHaveProperty('ui.domain'); +}); + +test('exposes standard safe-area values without choosing a host product', () => { + expect( + safeAreaCustomProperties({ + platform: 'mobile', + safeAreaInsets: { bottom: 34, left: 11, right: 13, top: 47 }, + styles: { variables: { '--color-background-primary': '#10162a', '--font-mono': 'Fira Code' } }, + theme: 'dark', + }), + ).toEqual({ + '--timeline-safe-area-bottom': '34px', + '--timeline-safe-area-left': '11px', + '--timeline-safe-area-right': '13px', + '--timeline-safe-area-top': '47px', + }); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts new file mode 100644 index 000000000..0ed665110 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@rstest/core'; + +import { allowsOrigin, resolveHttpSecurityConfig } from '../src/mcp/http-security.js'; + +test('uses loopback defaults and only admits absent or same-origin browser requests', () => { + const config = resolveHttpSecurityConfig({}); + + expect(config.allowedHosts).toEqual(['127.0.0.1', 'localhost', '[::1]']); + expect(config.allowedOrigins).toEqual([]); + expect(allowsOrigin(config, '127.0.0.1:4312', undefined)).toBe(true); + expect(allowsOrigin(config, '127.0.0.1:4312', 'http://127.0.0.1:4312')).toBe(true); + expect(allowsOrigin(config, '127.0.0.1:4312', 'https://attacker.example')).toBe(false); +}); + +test('requires explicit public host and origin allowlists for a tunnel', () => { + const config = resolveHttpSecurityConfig({ + AGENT_RUNTIME_ALLOWED_HOSTS: 'tunnel.example', + AGENT_RUNTIME_ALLOWED_ORIGINS: 'https://tunnel.example', + }); + + expect(config.allowedHosts).toEqual(['127.0.0.1', 'localhost', '[::1]', 'tunnel.example']); + expect(config.allowedOrigins).toEqual(['https://tunnel.example']); + expect(allowsOrigin(config, 'tunnel.example', 'https://tunnel.example')).toBe(true); + expect(allowsOrigin(config, 'tunnel.example', 'https://attacker.example')).toBe(false); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx new file mode 100644 index 000000000..bbdce24ad --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx @@ -0,0 +1,175 @@ +import { expect, test } from '@rstest/core'; +import React from 'react'; + +import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime'; + +test('lowers every supported MCP result block in authored order', () => { + const result = lowerMcpResult( + + two edits + + + + + {'{"stateVersion":2}'} + + , + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'two edits' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'audio', data: 'UklGRg==', mimeType: 'audio/wav' }, + { + type: 'resource_link', + uri: 'file:///demo.txt', + name: 'demo.txt', + mimeType: 'text/plain', + }, + { + type: 'resource', + resource: { + uri: 'runtime://snapshot', + mimeType: 'application/json', + text: '{"stateVersion":2}', + }, + }, + ], + structuredContent: { stateVersion: 2 }, + isError: false, + }); +}); + +test('rejects malformed or nested MCP protocol result trees', () => { + expect(() => + lowerMcpResult( + + {} + , + ), + ).toThrow('mcp-image requires non-empty data and mimeType'); + + expect(() => + lowerMcpResult( + + {} + , + ), + ).toThrow('mcp-audio requires non-empty data and mimeType'); + + expect(() => + lowerMcpResult( + + {} + , + ), + ).toThrow('mcp-embedded-resource accepts exactly one text or blob value'); + + expect(() => + lowerMcpResult( + + + {'{}'} + + , + ), + ).toThrow('mcp-embedded-resource accepts exactly one text or blob value'); + + expect(() => + lowerMcpResult( + + + nested + + , + ), + ).toThrow('mcp-result may not be nested'); + + expect(() => + lowerMcpResult( + + invalid + , + ), + ).toThrow('mcp-result structuredContent must be JSON-serializable'); +}); + +test('rejects non-JSON structured content instead of normalizing it', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const sparse = new Array(2); + sparse[1] = 'present'; + + for (const value of [ + undefined, + () => undefined, + Symbol('value'), + Number.NaN, + Number.POSITIVE_INFINITY, + new Date('2026-08-14T00:00:00.000Z'), + new Map(), + sparse, + [undefined], + cyclic, + ]) { + expect(() => + lowerMcpResult( + + invalid + , + ), + ).toThrow('mcp-result structuredContent must be JSON-serializable'); + } +}); + +test('clones recursively valid JSON records for structured content', () => { + const input = Object.assign(Object.create(null), { + nested: { array: [null, false, 2.5, 'value'] }, + stateVersion: 2, + }); + + const result = lowerMcpResult( + + valid + , + ); + + expect(result.structuredContent).toEqual({ + nested: { array: [null, false, 2.5, 'value'] }, + stateVersion: 2, + }); + expect(result.structuredContent).not.toBe(input); +}); + +test('preserves serializable extension metadata alongside complete portable results', () => { + const result = lowerMcpResult( + + two edits + , + ); + + expect(result).toEqual({ + _meta: { 'example.acme/trace': { attempt: 2 } }, + content: [{ text: 'two edits', type: 'text' }], + structuredContent: { stateVersion: 2 }, + }); +}); + +test('preserves an own __proto__ key in valid structured content', () => { + const input = Object.create(null) as Record; + Object.defineProperty(input, '__proto__', { + enumerable: true, + value: { value: 'preserved' }, + }); + + const result = lowerMcpResult( + + valid + , + ); + + expect(Object.getOwnPropertyDescriptor(result.structuredContent as object, '__proto__')?.value).toEqual({ + value: 'preserved', + }); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts new file mode 100644 index 000000000..7d417297d --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts @@ -0,0 +1,426 @@ +import { spawn } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { once } from 'node:events'; +import { pathToFileURL } from 'node:url'; + +import { createRsbuild } from '@rsbuild/core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { expect, test } from '@rstest/core'; + +import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; +import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; + +const createStateFile = async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-mcp-')); + const stateFile = join(directory, 'events.jsonl'); + const kernel = createFileRuntimeKernel({ + stateFile, + createId: () => 'seed-edit', + now: () => new Date('2026-08-14T10:24:31.000Z'), + }); + + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:mcp-transport:seed-1', + path: 'src/runtime/state.ts', + sessionId: 'seed-session', + toolName: 'Write', + }); + return stateFile; +}; + +const createClient = (): Client => + new Client({ name: 'rsc-agent-runtime-test', version: '1.0.0' }); + +const requestStatus = ({ + headers, + path, + port, +}: { + headers: Record; + path: string; + port: number; +}): Promise => + new Promise((resolve, reject) => { + const request = httpRequest({ headers, hostname: '127.0.0.1', method: 'GET', path, port }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode ?? 0)); + }); + request.once('error', reject); + request.end(); + }); + +const expectStaticSurface = async (client: Client) => { + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + 'recent_edits', + 'render_edit_timeline', + 'runtime_status', + ]); + expect(tools.tools).toMatchObject([ + { name: 'recent_edits', _meta: {} }, + { + name: 'render_edit_timeline', + _meta: { + 'openai/outputTemplate': 'ui://rsc-agent-runtime/edit-timeline-v1.html', + ui: { resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, + }, + }, + { name: 'runtime_status', _meta: {} }, + ]); + + const resources = await client.listResources(); + expect(resources.resources).toMatchObject([ + { + mimeType: 'text/html;profile=mcp-app', + _meta: { + 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', + ui: { + csp: { connectDomains: [], resourceDomains: [] }, + prefersBorder: true, + }, + }, + uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', + }, + ]); +}; + +test('built stdio MCP serves static tools, file-backed data, Flight results, and inline widget', async () => { + const stateFile = await createStateFile(); + const client = createClient(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(process.cwd(), 'dist/runtime/mcp/stdio.js')], + env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, + stderr: 'pipe', + }); + + try { + await client.connect(transport); + await expectStaticSurface(client); + + await expect(client.callTool({ name: 'recent_edits', arguments: { limit: 10 } })).resolves.toMatchObject({ + content: [{ type: 'text' }], + structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, + }); + await expect(client.callTool({ name: 'render_edit_timeline', arguments: {} })).resolves.toMatchObject({ + content: [{ type: 'text' }], + structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, + }); + const runtimeStatus = await client.callTool({ name: 'runtime_status', arguments: {} }); + expect(runtimeStatus.structuredContent).toMatchObject({ editCount: 1, stateVersion: 1 }); + expect(runtimeStatus.content).toContainEqual({ + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + mimeType: 'image/png', + type: 'image', + }); + await expect( + client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }), + ).resolves.toMatchObject({ + contents: [ + { + mimeType: 'text/html;profile=mcp-app', + _meta: { + 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', + ui: { + csp: { connectDomains: [], resourceDomains: [] }, + prefersBorder: true, + }, + }, + text: expect.stringContaining(' { + const runtimeRoot = join(process.cwd(), 'dist/runtime'); + const workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-shared-workspace-')); + const stateHome = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-shared-state-')); + const environment = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + delete environment.AGENT_RUNTIME_STATE_FILE; + environment.XDG_STATE_HOME = stateHome; + + const hook = spawn(process.execPath, [join(runtimeRoot, 'hook/index.js'), '--host', 'codex'], { + cwd: workspace, + env: environment, + stdio: ['pipe', 'pipe', 'pipe'], + }); + hook.stdin.end(JSON.stringify({ + cwd: workspace, + event_id: 'shared-fallback-event', + hook_event_name: 'PostToolUse', + session_id: 'shared-session', + tool_input: { command: '*** Begin Patch\n*** Add File: shared.txt\n+shared\n*** End Patch' }, + tool_name: 'apply_patch', + })); + const hookStderr: Buffer[] = []; + hook.stderr.on('data', (chunk: Buffer) => hookStderr.push(chunk)); + hook.stdout.resume(); + const [hookExit] = (await once(hook, 'close')) as [number | null, NodeJS.Signals | null]; + expect(hookExit, Buffer.concat(hookStderr).toString('utf8')).toBe(0); + + const client = createClient(); + const transport = new StdioClientTransport({ + args: [join(runtimeRoot, 'mcp/stdio.js')], + command: process.execPath, + cwd: workspace, + env: environment, + stderr: 'pipe', + }); + try { + await client.connect(transport); + await expect(client.callTool({ name: 'recent_edits', arguments: { limit: 10 } })).resolves.toMatchObject({ + structuredContent: { + edits: [{ eventId: expect.any(String), path: join(workspace, 'shared.txt') }], + stateVersion: 1, + }, + }); + await expect(access(join(workspace, '.agent-runtime-demo'))).rejects.toThrow(); + } finally { + await client.close(); + await Promise.all([ + rm(workspace, { force: true, recursive: true }), + rm(stateHome, { force: true, recursive: true }), + ]); + } +}); + +test('built Streamable HTTP MCP reports its one JSON startup line and closes cleanly', async () => { + const stateFile = await createStateFile(); + const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { + env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile, PORT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + const client = createClient(); + try { + await once(child.stderr, 'data'); + const startup = JSON.parse(stderr.trim()) as { port: number }; + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`)); + await client.connect(transport); + await expectStaticSurface(client); + + const localHost = `127.0.0.1:${startup.port}`; + await expect( + requestStatus({ + headers: { Host: localHost, Origin: `http://${localHost}` }, + path: '/health', + port: startup.port, + }), + ).resolves.toBe(200); + for (const path of ['/health', '/mcp']) { + await expect( + requestStatus({ headers: { Host: 'attacker.example' }, path, port: startup.port }), + ).resolves.toBe(403); + await expect( + requestStatus({ headers: { Host: localHost, Origin: 'https://attacker.example' }, path, port: startup.port }), + ).resolves.toBe(403); + } + } finally { + await client.close(); + child.kill('SIGTERM'); + const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; + expect(exitCode).toBe(0); + expect(signal).toBeNull(); + await rm(join(stateFile, '..'), { force: true, recursive: true }); + } +}); + +test('built Streamable HTTP MCP accepts only explicitly allowed public tunnel origins', async () => { + const stateFile = await createStateFile(); + const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { + env: { + ...process.env, + AGENT_RUNTIME_ALLOWED_HOSTS: 'tunnel.example', + AGENT_RUNTIME_ALLOWED_ORIGINS: 'https://tunnel.example', + AGENT_RUNTIME_STATE_FILE: stateFile, + PORT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + try { + await once(child.stderr, 'data'); + const startup = JSON.parse(stderr.trim()) as { port: number }; + await expect( + requestStatus({ + headers: { Host: 'tunnel.example', Origin: 'https://tunnel.example' }, + path: '/health', + port: startup.port, + }), + ).resolves.toBe(200); + } finally { + child.kill('SIGTERM'); + await once(child, 'close'); + await rm(join(stateFile, '..'), { force: true, recursive: true }); + } +}); + +test('adds an explicit public MCP URL domain only to returned resource content', async () => { + const stateFile = await createStateFile(); + const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { + env: { + ...process.env, + AGENT_RUNTIME_PUBLIC_MCP_URL: 'https://example.com/mcp', + AGENT_RUNTIME_STATE_FILE: stateFile, + PORT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + const client = createClient(); + + try { + await once(child.stderr, 'data'); + const startup = JSON.parse(stderr.trim()) as { port: number }; + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`))); + const resources = await client.listResources(); + expect(resources.resources[0]._meta?.ui).not.toHaveProperty('domain'); + await expect(client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' })).resolves.toMatchObject({ + contents: [{ _meta: { ui: { domain: 'c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com' } } }], + }); + } finally { + await client.close(); + child.kill('SIGTERM'); + await once(child, 'close'); + await rm(join(stateFile, '..'), { force: true, recursive: true }); + } +}); + +test('built widget HTML is self-contained without external app bundle assets', async () => { + for (const name of ['edit-timeline-v1', 'standalone']) { + const artifact = join(process.cwd(), 'dist/app', `${name}.html`); + await access(artifact); + const html = await readFile(artifact, 'utf8'); + expect(html).toContain(' { + const entries = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js']; + const runtimeRoot = join(process.cwd(), 'dist/runtime'); + const manifest = JSON.parse(await readFile(join(runtimeRoot, 'runtime-assets.json'), 'utf8')) as { + allFiles: string[]; + }; + const manifestFiles = manifest.allFiles.map((file) => file.replace(/^\//, '')); + const dynamicChunkDependencies = ( + await Promise.all( + entries.map(async (entry) => { + const source = await readFile(join(runtimeRoot, entry), 'utf8'); + return [...source.matchAll(/__webpack_require__\.e\(\/\* import\(\) \*\/\s*(\d+)\)/g)].map((match) => ({ + chunkId: match[1], + entry, + })); + }), + ) + ).flat(); + + expect(manifestFiles).toEqual(expect.arrayContaining(entries)); + expect(manifestFiles.some((file) => file.startsWith('chunks/'))).toBe(true); + for (const file of manifestFiles) { + await access(join(runtimeRoot, file)); + } + for (const { chunkId } of dynamicChunkDependencies) { + expect(manifestFiles).toContain(`chunks/${chunkId}.js`); + } +}); + +test('production and development runtime graphs exclude state test controls', async () => { + const forbidden = [ + 'state-file-test-support', + 'createFileRuntimeKernelForTesting', + 'RuntimeStateTestAdapter', + 'beforeAppend', + 'criticalSectionMs', + ]; + const readRuntimeSources = async (root: string): Promise => { + const sources: string[] = []; + const visit = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.name.endsWith('.js') || entry.name.endsWith('.map')) sources.push(await readFile(path, 'utf8')); + } + }; + await visit(root); + return sources.join('\n'); + }; + const assertExcluded = (source: string): void => { + for (const name of forbidden) expect(source).not.toContain(name); + }; + + assertExcluded(await readRuntimeSources(join(process.cwd(), 'dist/runtime'))); + for (const host of ['claude', 'codex']) { + const packagedRuntime = join(process.cwd(), 'dist/plugins', host, 'runtime'); + assertExcluded(await readRuntimeSources(packagedRuntime)); + await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); + } + + const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-graph-')); + const rsbuild = await createRsbuild({ + config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), + cwd: process.cwd(), + }); + let closeBuild = async (): Promise => undefined; + try { + const result = await rsbuild.build(); + closeBuild = result.close; + expect(result.stats).toBeDefined(); + assertExcluded(JSON.stringify(result.stats?.toJson({ all: false, children: true, modules: true, source: true }))); + assertExcluded(await readRuntimeSources(join(compilerRoot, 'rsc'))); + } finally { + await closeBuild(); + await rm(compilerRoot, { force: true, recursive: true }); + } +}); + +test('a second multi-environment build removes stale app chunks', async () => { + const staleAsset = join(process.cwd(), 'dist/app/static/js/async/stale.js'); + await mkdir(dirname(staleAsset), { recursive: true }); + await writeFile(staleAsset, 'stale artifact', 'utf8'); + + try { + const child = spawn('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'ignore' }); + const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; + expect(exitCode).toBe(0); + expect(signal).toBeNull(); + await expect(access(staleAsset)).rejects.toThrow(); + for (const name of ['edit-timeline-v1', 'standalone']) { + const html = await readFile(join(process.cwd(), 'dist/app', `${name}.html`), 'utf8'); + expect(html).toContain(' { + const workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-micro-eval-')); + const stateFile = join(workspace, 'events.jsonl'); + const client = new Client({ name: 'rsc-agent-runtime-micro-eval', version: '1.0.0' }); + const transport = new StdioClientTransport({ + args: [join(process.cwd(), 'dist/runtime/mcp/stdio.js')], + command: process.execPath, + env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, + stderr: 'pipe', + }); + + try { + const hook = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/hook/index.js'), '--host', 'claude'], { + env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + hook.stdin.end(JSON.stringify({ + cwd: workspace, + hook_event_name: 'PostToolUse', + session_id: 'micro-eval-session', + tool_input: { content: 'micro-eval\n', file_path: join(workspace, 'spot-check.txt') }, + tool_name: 'Write', + tool_response: { success: true }, + tool_use_id: 'micro-eval-tool-1', + })); + const hookStdout: Buffer[] = []; + const hookStderr: Buffer[] = []; + hook.stdout.on('data', (chunk: Buffer) => hookStdout.push(chunk)); + hook.stderr.on('data', (chunk: Buffer) => hookStderr.push(chunk)); + const [hookExit] = (await once(hook, 'close')) as [number | null, NodeJS.Signals | null]; + + expect(hookExit, Buffer.concat(hookStderr).toString('utf8')).toBe(0); + expect(JSON.parse(Buffer.concat(hookStdout).toString('utf8'))).toEqual({ + hookSpecificOutput: { + additionalContext: 'Recorded spot-check.txt from claude. Shared state now contains 1 edit.', + hookEventName: 'PostToolUse', + }, + }); + + const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line) as { + readonly event: { readonly host: string; readonly path: string }; + readonly idempotencyKey: string; + }); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + event: { host: 'claude', path: join(workspace, 'spot-check.txt') }, + idempotencyKey: 'claude:tool:micro-eval-tool-1', + }); + + await client.connect(transport); + const tools = await client.listTools(); + expect(tools.tools.find((tool) => tool.name === 'render_edit_timeline')?._meta).toMatchObject({ + ui: { resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, + }); + await expect(client.callTool({ arguments: {}, name: 'render_edit_timeline' })).resolves.toMatchObject({ + content: [{ text: 'Showing 1 recorded edits.', type: 'text' }], + structuredContent: { + edits: [{ host: 'claude', path: join(workspace, 'spot-check.txt') }], + stateVersion: 1, + }, + }); + const resource = await client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }); + expect(resource.contents[0]).toMatchObject({ + mimeType: 'text/html;profile=mcp-app', + text: expect.stringContaining(' => { + const directory = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-hook-')); + temporaryDirectories.push(directory); + return directory; +}; + +const runHook = async ( + host: 'claude' | 'codex', + input: Record, + stateFile: string | undefined, + additionalEnvironment: Record = {}, +) => { + const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/hook/index.js'), '--host', host], { + env: { + ...process.env, + ...(stateFile === undefined ? {} : { AGENT_RUNTIME_STATE_FILE: stateFile }), + ...additionalEnvironment, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.stdin.end(JSON.stringify(input)); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Promise((resolve, reject) => { + let output = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + output += chunk; + }); + child.stdout.on('error', reject); + child.stdout.on('end', () => resolve(output)); + }), + new Promise((resolve, reject) => { + let output = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + output += chunk; + }); + child.stderr.on('error', reject); + child.stderr.on('end', () => resolve(output)); + }), + new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }), + ]); + + return { exitCode, stderr, stdout }; +}; + +const runRscWorker = async (request: Record) => { + const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/rsc/index.js')], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(JSON.stringify(request)); + const [stdout, exitCode] = await Promise.all([ + new Promise((resolve, reject) => { + let output = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + output += chunk; + }); + child.stdout.on('error', reject); + child.stdout.on('end', () => resolve(output)); + }), + new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }), + ]); + return { exitCode, stdout }; +}; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); +}); + +describe('built RSC hook entry', () => { + it('uses native tool ids before host event ids for durable mutation idempotency', () => { + expect( + normalizeClaudeHook({ + cwd: '/workspace', + event_id: 'event-1', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: { file_path: 'demo.txt' }, + tool_name: 'Write', + tool_use_id: 'tool-1', + }), + ).toMatchObject({ idempotencyKey: 'claude:tool:tool-1' }); + expect( + normalizeCodexHook({ + cwd: '/workspace', + event_id: 'event-2', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: { command: '*** Begin Patch\n*** Add File: demo.txt\n+demo\n*** End Patch' }, + tool_name: 'apply_patch', + }), + ).toMatchObject({ idempotencyKey: 'codex:event:event-2' }); + expect(() => + normalizeClaudeHook({ + cwd: '/workspace', + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: { file_path: 'demo.txt' }, + tool_name: 'Write', + }), + ).toThrow('tool_use_id or event_id'); + }); + + it('rejects every empty RSC mutation field before creating state', async () => { + const workspace = await createTemporaryDirectory(); + for (const emptyField of ['cwd', 'idempotencyKey', 'path', 'sessionId', 'toolName']) { + const stateFile = join(workspace, `${emptyField}.jsonl`); + const event = { + cwd: workspace, + host: 'claude', + idempotencyKey: 'claude:tool:worker-fields', + path: join(workspace, 'demo.txt'), + sessionId: 'session-1', + toolName: 'Write', + [emptyField]: '', + }; + const result = await runRscWorker({ event, stateFile, type: 'hook/after-file-edit' }); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(''); + await expect(readFile(stateFile, 'utf8')).rejects.toThrow(); + } + }); + + it('renders native Claude and Codex outputs through Flight while retaining file-backed state', async () => { + const workspace = await createTemporaryDirectory(); + const stateFile = join(workspace, 'state.jsonl'); + + const first = await runHook( + 'claude', + { + session_id: 'claude-session', + cwd: workspace, + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { file_path: `${workspace}/demo.txt`, content: 'hello\n' }, + tool_response: { success: true }, + tool_use_id: 'tool-1', + }, + stateFile, + ); + + expect(first.exitCode).toBe(0); + expect(JSON.parse(first.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', + }, + }); + + const replay = await runHook( + 'claude', + { + session_id: 'claude-session', + cwd: workspace, + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { file_path: `${workspace}/demo.txt`, content: 'hello\n' }, + tool_response: { success: true }, + tool_use_id: 'tool-1', + }, + stateFile, + ); + expect(replay.exitCode).toBe(0); + expect(JSON.parse(replay.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', + }, + }); + + const second = await runHook( + 'codex', + { + session_id: 'codex-session', + cwd: workspace, + hook_event_name: 'PostToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Add File: second.txt\n+second\n*** End Patch' }, + tool_response: { success: true }, + tool_use_id: 'tool-2', + }, + stateFile, + ); + + expect(second.exitCode).toBe(0); + expect(JSON.parse(second.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'Recorded second.txt from codex. Shared state now contains 2 edits.', + }, + }); + + const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + expect(records.map((record) => record.event.host)).toEqual(['claude', 'codex']); + expect(records.map((record) => record.idempotencyKey)).toEqual(['claude:tool:tool-1', 'codex:tool:tool-2']); + }); + + it('rejects unsupported native hook input without writing stdout', async () => { + const workspace = await createTemporaryDirectory(); + const result = await runHook( + 'claude', + { + session_id: 'claude-session', + cwd: workspace, + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: `${workspace}/demo.txt` }, + }, + join(workspace, 'state.jsonl'), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(''); + }); + + it('falls back to tool-owned external state when a native host omits the configured environment', async () => { + const workspace = await createTemporaryDirectory(); + const stateHome = await createTemporaryDirectory(); + const result = await runHook( + 'codex', + { + session_id: 'codex-session', + cwd: workspace, + hook_event_name: 'PostToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Add File: fallback.txt\n+fallback\n*** End Patch' }, + event_id: 'fallback-event-1', + }, + undefined, + { XDG_STATE_HOME: stateHome }, + ); + + expect(result.exitCode).toBe(0); + const workspaceId = createHash('sha256').update(await realpath(workspace)).digest('hex'); + const stateFile = join(stateHome, 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); + expect((await readFile(stateFile, 'utf8')).trim()).toContain('fallback.txt'); + await expect(access(join(workspace, '.agent-runtime-demo'))).rejects.toThrow(); + }); + + it('ignores workspace fallback symlink swaps and never modifies their external target', async () => { + const workspace = await createTemporaryDirectory(); + const external = await createTemporaryDirectory(); + const stateHome = await createTemporaryDirectory(); + const externalState = join(external, 'events.jsonl'); + await writeFile(externalState, '', 'utf8'); + const workspaceFallback = join(workspace, '.agent-runtime-demo'); + let keepSwapping = true; + const swapper = (async () => { + while (keepSwapping) { + await rm(workspaceFallback, { force: true, recursive: true }); + await mkdir(workspaceFallback); + await rm(workspaceFallback, { force: true, recursive: true }); + await symlink(external, workspaceFallback, 'dir'); + } + })(); + + let result: Awaited>; + try { + result = await runHook( + 'codex', + { + session_id: 'codex-session', + cwd: workspace, + event_id: 'symlink-fallback-event', + hook_event_name: 'PostToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Add File: protected.txt\n+protected\n*** End Patch' }, + }, + undefined, + { XDG_STATE_HOME: stateHome }, + ); + } finally { + keepSwapping = false; + await swapper; + } + + expect(result.exitCode).toBe(0); + await expect(readFile(externalState, 'utf8')).resolves.toBe(''); + }); + + it('emits only a value-free optional eval hook probe', async () => { + const workspace = await createTemporaryDirectory(); + const probeFile = join(workspace, 'hook-probe.jsonl'); + const result = await runHook( + 'codex', + { + session_id: 'codex-session', + cwd: workspace, + hook_event_name: 'PostToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Add File: secret.txt\n+do-not-persist-this-value\n*** End Patch' }, + event_id: 'probe-event-1', + }, + join(workspace, 'state.jsonl'), + { AGENT_RUNTIME_HOOK_PROBE_FILE: probeFile }, + ); + + expect(result.exitCode).toBe(0); + const probe = JSON.parse(await readFile(probeFile, 'utf8')); + expect(probe).toEqual({ + commandLaunched: true, + exitStatus: 0, + toolInputKeys: ['command'], + toolInputValueTypes: { command: 'string' }, + toolName: 'apply_patch', + topLevelKeys: ['cwd', 'event_id', 'hook_event_name', 'session_id', 'tool_input', 'tool_name'], + topLevelValueTypes: { cwd: 'string', event_id: 'string', hook_event_name: 'string', session_id: 'string', tool_input: 'object', tool_name: 'string' }, + }); + expect(await readFile(probeFile, 'utf8')).not.toContain('do-not-persist-this-value'); + }); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts new file mode 100644 index 000000000..891d27d33 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, test } from '@rstest/core'; + +import { emitRuntimeArtifacts } from '../src/build/emit-artifacts.js'; + +test('declares every executable and contained runtime asset in the runtime manifest', async () => { + const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); + const runtimeAssets = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js', 'chunks/101.js']; + + try { + for (const asset of runtimeAssets) { + const target = join(runtimeRoot, asset); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'artifact', 'utf8'); + } + await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: runtimeAssets.map((asset) => `/${asset}`) }), 'utf8'); + + await emitRuntimeArtifacts(runtimeRoot); + + const manifest = JSON.parse(await readFile(join(runtimeRoot, 'agent-runtime.manifest.json'), 'utf8')) as { + executables: Array<{ name: string; path: string }>; + runtimeAssets: string[]; + }; + expect(manifest.executables).toEqual([ + { name: 'hook', path: 'hook/index.js' }, + { name: 'rsc-worker', path: 'rsc/index.js' }, + { name: 'stdio', path: 'mcp/stdio.js' }, + { name: 'http', path: 'mcp/http.js' }, + ]); + expect(manifest.runtimeAssets).toEqual(runtimeAssets); + } finally { + await rm(runtimeRoot, { force: true, recursive: true }); + } +}); + +test('uses an explicitly captured definition instead of the host module serializer', async () => { + const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); + const runtimeAssets = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js']; + const definition = { + nativeHooks: [], + resources: [], + tools: [], + }; + + try { + for (const asset of runtimeAssets) { + const target = join(runtimeRoot, asset); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'artifact', 'utf8'); + } + await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: runtimeAssets }), 'utf8'); + + await emitRuntimeArtifacts(runtimeRoot, definition); + + const manifest = JSON.parse(await readFile(join(runtimeRoot, 'agent-runtime.manifest.json'), 'utf8')) as { tools: unknown[] }; + expect(manifest.tools).toEqual([]); + } finally { + await rm(runtimeRoot, { force: true, recursive: true }); + } +}); + +test('rejects a runtime asset that escapes the manifest root', async () => { + const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); + try { + await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: ['../outside.js'] }), 'utf8'); + await expect(emitRuntimeArtifacts(runtimeRoot)).rejects.toThrow('Runtime asset escapes its root'); + } finally { + await rm(runtimeRoot, { force: true, recursive: true }); + } +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts new file mode 100644 index 000000000..64fa568d1 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts @@ -0,0 +1,1083 @@ +import { access, appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, test } from '@rstest/core'; +import { createRsbuild } from '@rsbuild/core'; + +import { serializeRuntimeDefinition } from '../src/build/serialize-definition.js'; +import { runtimeDefinition } from '../src/definition.js'; +import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; +import { createTestFileRuntimeKernel } from '../src/runtime/state-file-test-support.js'; + +const readOnlyAnnotations = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +}; + +const resourceUri = 'ui://rsc-agent-runtime/edit-timeline-v1.html'; + +const wait = async (milliseconds: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + +const errorMessages = (value: unknown, seen = new Set()): readonly string[] => { + if (!(value instanceof Error) || seen.has(value)) return []; + seen.add(value); + return [ + value.message, + ...(value instanceof AggregateError ? value.errors.flatMap((error) => errorMessages(error, seen)) : []), + ...errorMessages(value.cause, seen), + ]; +}; + +const eagerPromise = (value: T): Promise => ({ + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + _onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return Promise.resolve(onfulfilled === undefined || onfulfilled === null ? value as unknown as TResult1 : onfulfilled(value)); + }, +}) as Promise; + +const startLockOwner = async (stateFile: string, timing: { stale: number; update: number } = { stale: 2_000, update: 1_000 }) => { + const child = spawn(process.execPath, [ + join(process.cwd(), 'tests/fixtures/state-lock-owner.mjs'), + stateFile, + String(timing.stale), + String(timing.update), + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.stdout.once('data', (chunk: Buffer) => { + if (chunk.toString('utf8').trim() === '{"ready":true}') { + resolve(); + return; + } + reject(new Error(`Unexpected lock-owner output: ${chunk.toString('utf8')}`)); + }); + }); + return child; +}; + +const validEditRecord = (stateVersion: number, idempotencyKey: string) => ({ + event: { + eventId: `event-${stateVersion}`, + host: 'claude', + path: `src/${stateVersion}.ts`, + recordedAt: '2026-08-14T12:00:00.000Z', + sessionId: 'session-1', + toolName: 'Write', + }, + idempotencyKey, + kind: 'edit', + stateVersion, +}); + +const containsFunction = (value: unknown): boolean => { + if (typeof value === 'function') { + return true; + } + + if (Array.isArray(value)) { + return value.some(containsFunction); + } + + if (value !== null && typeof value === 'object') { + return Object.values(value).some(containsFunction); + } + + return false; +}; + +test('reads an edit recorded by another kernel instance', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const first = createFileRuntimeKernel({ + stateFile, + now: () => new Date('2026-08-14T12:00:00.000Z'), + createId: () => 'edit-1', + }); + const second = createFileRuntimeKernel({ stateFile }); + + await first.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:other-kernel', + path: 'src/runtime/state-file.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + + expect(await second.readSnapshot()).toMatchObject({ + edits: [{ eventId: 'edit-1', host: 'claude', path: 'src/runtime/state-file.ts' }], + stateVersion: 1, + }); +}); + +test('limits snapshots to the newest valid edit events', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let nextId = 0; + const kernel = createFileRuntimeKernel({ + stateFile, + createId: () => `edit-${++nextId}`, + now: () => new Date('2026-08-14T12:00:00.000Z'), + }); + + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:limit-1', + path: 'first.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + await kernel.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:limit-2', + path: 'second.ts', + sessionId: 'session-1', + toolName: 'apply_patch', + }); + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:limit-3', + path: 'third.ts', + sessionId: 'session-1', + toolName: 'Edit', + }); + + await expect(kernel.readSnapshot({ limit: 0 })).rejects.toThrow(RangeError); + await expect(kernel.readSnapshot({ limit: 51 })).rejects.toThrow(RangeError); + await expect(kernel.readSnapshot({ limit: 1.5 })).rejects.toThrow(RangeError); + await expect(kernel.readSnapshot({ limit: 2 })).resolves.toMatchObject({ + edits: [{ eventId: 'edit-2' }, { eventId: 'edit-3' }], + stateVersion: 3, + }); +}); + +test('ignores one trailing partial JSONL record', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); + + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:partial', + path: 'complete.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + await appendFile(stateFile, '{"eventId":"partial"', 'utf8'); + + await expect(kernel.readSnapshot()).resolves.toMatchObject({ + edits: [{ eventId: 'complete-edit', path: 'complete.ts' }], + stateVersion: 1, + }); +}); + +test('deduplicates identical state edits and rejects conflicting idempotency-key reuse', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const first = createFileRuntimeKernel({ + stateFile, + createId: () => 'first-event', + now: () => new Date('2026-08-14T12:00:00.000Z'), + }); + const second = createFileRuntimeKernel({ + stateFile, + createId: () => 'second-event', + now: () => new Date('2026-08-14T12:00:00.000Z'), + }); + const edit = { + host: 'claude' as const, + idempotencyKey: 'claude:tool:tool-1', + path: 'src/first.ts', + sessionId: 'session-1', + toolName: 'Write', + }; + + const [firstSnapshot, secondSnapshot] = await Promise.all([first.recordEdit(edit), second.recordEdit(edit)]); + expect(firstSnapshot.stateVersion).toBe(1); + expect(secondSnapshot.stateVersion).toBe(1); + expect((await readFile(stateFile, 'utf8')).trim().split('\n')).toHaveLength(1); + expect(JSON.parse((await readFile(stateFile, 'utf8')).trim())).toMatchObject({ + idempotencyKey: 'claude:tool:tool-1', + kind: 'edit', + stateVersion: 1, + }); + + await expect(second.recordEdit({ ...edit, path: 'src/conflict.ts' })).rejects.toThrow( + 'idempotency key claude:tool:tool-1', + ); +}); + +test('appends reset records without resetting the monotonic durable version', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createFileRuntimeKernel({ + stateFile, + createId: () => 'event-1', + now: () => new Date('2026-08-14T12:00:00.000Z'), + }); + + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:before-reset', + path: 'src/before-reset.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + const reset = await kernel.resetState({ idempotencyKey: 'test:state:reset-1', seed: { reason: 'test' } }); + + expect(reset).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); + const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + expect(records).toMatchObject([ + { kind: 'edit', stateVersion: 1 }, + { idempotencyKey: 'test:state:reset-1', kind: 'reset', seed: { reason: 'test' }, stateVersion: 2 }, + ]); + expect(await createFileRuntimeKernel({ stateFile }).readSnapshot()).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); +}); + +test('preserves reset seeds across immediate, idempotent, reopened, limited, and follow-up snapshots', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const seed = Object.freeze({ + cwd: '/tmp', + hook_event_name: 'PostToolUse', + session_id: 'fixture-seed-session', + tool_input: Object.freeze({ file_path: 'fixture-seed.txt' }), + tool_name: 'Write', + tool_use_id: 'fixture-seed-tool', + }); + const first = createFileRuntimeKernel({ + stateFile, + createId: () => 'seed-follow-up-edit', + now: () => new Date('2026-08-15T00:00:00.000Z'), + }); + + await first.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:seed-before-reset', + path: 'before-reset.ts', + sessionId: 'fixture-seed-session', + toolName: 'Write', + }); + const reset = await first.resetState({ idempotencyKey: 'test:state:seed-reset', seed }); + expect(reset).toEqual({ edits: [], seed, stateVersion: 2 }); + await expect(first.resetState({ idempotencyKey: 'test:state:seed-reset', seed })).resolves.toEqual(reset); + + const reopened = createFileRuntimeKernel({ + stateFile, + createId: () => 'seed-follow-up-edit', + now: () => new Date('2026-08-15T00:00:01.000Z'), + }); + await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual(reset); + await expect(reopened.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:seed-follow-up', + path: 'after-reset.ts', + sessionId: 'fixture-seed-session', + toolName: 'Write', + })).resolves.toEqual({ + edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], + seed, + stateVersion: 3, + }); + await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual({ + edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], + seed, + stateVersion: 3, + }); + await expect(reopened.resetState({ + idempotencyKey: 'test:state:seed-reset', + seed: { ...seed, session_id: 'conflicting-seed-session' }, + })).rejects.toThrow('idempotency key test:state:seed-reset'); + await expect(reopened.resetState({ idempotencyKey: 'test:state:seed-clear' })).resolves.toEqual({ edits: [], stateVersion: 4 }); + await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ edits: [], stateVersion: 4 }); +}); + +test('reconstructs an exact durable snapshot version through edits, resets, and idempotent replays', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createFileRuntimeKernel({ + stateFile, + createId: () => 'exact-version-edit', + now: () => new Date('2026-08-15T01:00:00.000Z'), + }); + const readExact = (stateVersion: number) => kernel.readSnapshot({ stateVersion }); + + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:exact-before-reset', + path: 'before-reset.ts', + sessionId: 'exact-version-session', + toolName: 'Write', + }); + const seed = Object.freeze({ reason: 'exact-version-reset' }); + await kernel.resetState({ idempotencyKey: 'test:state:exact-reset', seed }); + const afterReset = await kernel.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:exact-after-reset', + path: 'after-reset.ts', + sessionId: 'exact-version-session', + toolName: 'apply_patch', + }); + await expect(kernel.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:exact-after-reset', + path: 'after-reset.ts', + sessionId: 'exact-version-session', + toolName: 'apply_patch', + })).resolves.toEqual(afterReset); + + await expect(readExact(0)).resolves.toEqual({ edits: [], stateVersion: 0 }); + await expect(readExact(1)).resolves.toMatchObject({ + edits: [expect.objectContaining({ path: 'before-reset.ts' })], + stateVersion: 1, + }); + await expect(readExact(2)).resolves.toEqual({ edits: [], seed, stateVersion: 2 }); + await expect(readExact(3)).resolves.toMatchObject({ + edits: [expect.objectContaining({ path: 'after-reset.ts' })], + seed, + stateVersion: 3, + }); + await expect(readExact(4)).rejects.toThrow('state version 4 is unavailable'); + await expect(readExact(-1)).rejects.toThrow(RangeError); + await expect(readExact(1.5)).rejects.toThrow(RangeError); +}); + +test('rejects terminated JSONL corruption while preserving only an incomplete final tail for recovery', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); + await kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:complete', + path: 'complete.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + + await appendFile(stateFile, '{"broken":true}\n', 'utf8'); + await expect(kernel.readSnapshot()).rejects.toThrow('Runtime state corruption'); + + const recoverableStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'recoverable.jsonl'); + const recoverable = createFileRuntimeKernel({ stateFile: recoverableStateFile, createId: () => 'recovered-edit' }); + await recoverable.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:before-tail', + path: 'first.ts', + sessionId: 'session-1', + toolName: 'apply_patch', + }); + await appendFile(recoverableStateFile, '{"truncated"', 'utf8'); + await expect( + recoverable.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:after-tail', + path: 'second.ts', + sessionId: 'session-1', + toolName: 'apply_patch', + }), + ).resolves.toMatchObject({ stateVersion: 2 }); + await expect(recoverable.readSnapshot()).resolves.toMatchObject({ + edits: [{ path: 'first.ts' }, { path: 'second.ts' }], + stateVersion: 2, + }); +}); + +test('rejects malformed middle records and non-monotonic durable versions', async () => { + const middleStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'middle.jsonl'); + await writeFile(middleStateFile, `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n{"invalid":true}\n`, 'utf8'); + await expect(createFileRuntimeKernel({ stateFile: middleStateFile }).readSnapshot()).rejects.toThrow('Runtime state corruption'); + + const versionStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'version.jsonl'); + await writeFile( + versionStateFile, + `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n${JSON.stringify(validEditRecord(1, 'test:state:second'))}\n`, + 'utf8', + ); + await expect(createFileRuntimeKernel({ stateFile: versionStateFile }).readSnapshot()).rejects.toThrow('monotonic state version'); +}); + +test('excludes a live heartbeat owner and recovers its stale lock only after SIGKILL', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + await writeFile(stateFile, '', 'utf8'); + const owner = await startLockOwner(stateFile); + try { + const lockDirectory = `${stateFile}.lock`; + const firstMtime = (await stat(lockDirectory)).mtimeMs; + await wait(1_100); + expect((await stat(lockDirectory)).mtimeMs).toBeGreaterThan(firstMtime); + + const aborted = new AbortController(); + setTimeout(() => aborted.abort(new Error('test abort')), 50); + await expect( + createTestFileRuntimeKernel({ stateFile }).recordEdit( + { + host: 'claude', + idempotencyKey: 'test:state:live-owner', + path: 'live-owner.ts', + sessionId: 'session-1', + toolName: 'Write', + }, + { lockAcquireTimeoutMs: 500, signal: aborted.signal }, + ), + ).rejects.toThrow('test abort'); + + owner.kill('SIGKILL'); + await new Promise((resolve) => owner.once('close', () => resolve())); + await wait(2_100); + await expect( + createTestFileRuntimeKernel({ stateFile }).recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:stale-recovery', + path: 'recovered.ts', + sessionId: 'session-1', + toolName: 'apply_patch', + }), + ).resolves.toMatchObject({ stateVersion: 1 }); + } finally { + owner.kill('SIGKILL'); + } +}); + +test('a non-production short-timing contender cannot steal a production lease', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + await writeFile(stateFile, '', 'utf8'); + const owner = await startLockOwner(stateFile, { stale: 30_000, update: 5_000 }); + try { + const cancelled = new AbortController(); + setTimeout(() => cancelled.abort(new Error('short contender aborted')), 2_100); + await expect( + createTestFileRuntimeKernel({ stateFile }).recordEdit( + { + host: 'claude', + idempotencyKey: 'test:state:short-contender', + path: 'must-not-write.ts', + sessionId: 'session-1', + toolName: 'Write', + }, + { lockAcquireTimeoutMs: 30_000, signal: cancelled.signal }, + ), + ).rejects.toThrow('short contender aborted'); + await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); + } finally { + owner.kill('SIGTERM'); + await new Promise((resolve) => owner.once('close', () => resolve())); + } +}); + +test('releases a lease acquired after an expired absolute acquisition deadline', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let releases = 0; + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + prepareStateFile: async ({ stateFile: preparedStateFile }) => preparedStateFile, + acquireLock: async () => + new Promise((resolve) => { + setTimeout(() => resolve(async () => { + releases += 1; + }), 30); + }), + }, + }); + + await expect( + kernel.recordEdit( + { + host: 'claude', + idempotencyKey: 'test:state:late-lock', + path: 'late-lock.ts', + sessionId: 'session-1', + toolName: 'Write', + }, + { lockAcquireTimeoutMs: 20 }, + ), + ).rejects.toThrow('Timed out acquiring runtime state lease'); + await wait(60); + expect(releases).toBe(1); +}); + +test('cancels a never-settling active phase at the hard critical-section deadline', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + beforeAppend: () => new Promise(() => undefined), + criticalSectionMs: 10, + }, + }); + + await expect( + kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:never-settles', + path: 'never-settles.ts', + sessionId: 'session-1', + toolName: 'Write', + }), + ).rejects.toThrow('did not settle within 100 ms after cancellation'); + await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); +}); + +test('exits promptly after a timed-out phase settles before its owner-settlement deadline', async () => { + const buildRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-build-')); + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-')), 'state.jsonl'); + const rsbuild = await createRsbuild({ + config: { + output: { + distPath: { root: buildRoot }, + filename: { js: '[name].js' }, + target: 'node', + }, + source: { entry: { fixture: './tests/fixtures/state-settlement-exit.ts' } }, + }, + cwd: process.cwd(), + }); + const build = await rsbuild.build(); + const startedAt = Date.now(); + const child = spawn(process.execPath, [join(buildRoot, 'fixture.js'), stateFile], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + const outcome = await Promise.race([ + new Promise>((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => resolve({ exitCode, type: 'closed' })); + }), + wait(500).then(() => ({ type: 'timeout' as const })), + ]); + if (outcome.type === 'timeout') child.kill('SIGKILL'); + await build.close(); + await rm(buildRoot, { force: true, recursive: true }); + + expect(outcome.type).toBe('closed'); + if (outcome.type === 'closed') expect(outcome.exitCode).toBe(0); + expect(stdout).toBe('phase-settled\n'); + expect(Date.now() - startedAt).toBeLessThan(500); +}); + +test('retains the lease until a timed-out mutation phase actually settles', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let entered!: () => void; + let settle!: () => void; + const phaseEntered = new Promise((resolve) => { + entered = resolve; + }); + const phaseSettlement = new Promise((resolve) => { + settle = resolve; + }); + const first = createTestFileRuntimeKernel({ + stateFile, + adapter: { + beforeAppend: async () => { + entered(); + await phaseSettlement; + }, + criticalSectionMs: 20, + ownerSettlementMs: 200, + }, + }); + const second = createTestFileRuntimeKernel({ stateFile }); + + const firstMutation = first.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:late-phase-owner', + path: 'late-phase-owner.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + void firstMutation.catch(() => undefined); + await phaseEntered; + await wait(30); + + let contenderSettled = false; + const contender = second.recordEdit( + { + host: 'codex', + idempotencyKey: 'test:state:late-phase-contender', + path: 'late-phase-contender.ts', + sessionId: 'session-2', + toolName: 'apply_patch', + }, + { lockAcquireTimeoutMs: 500 }, + ).finally(() => { + contenderSettled = true; + }); + await wait(40); + expect(contenderSettled).toBe(false); + + settle(); + await expect(firstMutation).rejects.toThrow('exceeded 20 ms critical-section limit'); + await expect(contender).resolves.toMatchObject({ stateVersion: 1 }); + const settledContents = await readFile(stateFile, 'utf8'); + await wait(30); + expect(await readFile(stateFile, 'utf8')).toBe(settledContents); + expect(settledContents).not.toContain('late-phase-owner.ts'); + expect(settledContents).toContain('late-phase-contender.ts'); +}); + +for (const phase of ['truncate', 'append', 'fsync'] as const) { + test(`does not unlock while a timed-out ${phase} phase is unsettled`, async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + if (phase === 'truncate') { + await writeFile(stateFile, '{"incomplete":true', 'utf8'); + } + let entered!: () => void; + let settle!: () => void; + const phaseEntered = new Promise((resolve) => { + entered = resolve; + }); + const phaseSettlement = new Promise((resolve) => { + settle = resolve; + }); + const barrier = async () => { + entered(); + await phaseSettlement; + }; + const first = createTestFileRuntimeKernel({ + stateFile, + adapter: { + ...(phase === 'truncate' ? { beforeRepair: barrier } : {}), + ...(phase === 'append' ? { beforeAppendWrite: barrier } : {}), + ...(phase === 'fsync' ? { beforeAppendSync: barrier } : {}), + criticalSectionMs: 20, + ownerSettlementMs: 200, + }, + }); + const second = createTestFileRuntimeKernel({ stateFile }); + const firstMutation = first.recordEdit({ + host: 'claude', + idempotencyKey: `test:state:${phase}-owner`, + path: `${phase}-owner.ts`, + sessionId: 'session-1', + toolName: 'Write', + }); + void firstMutation.catch(() => undefined); + await phaseEntered; + await wait(30); + + let contenderSettled = false; + const contender = second.recordEdit( + { + host: 'codex', + idempotencyKey: `test:state:${phase}-contender`, + path: `${phase}-contender.ts`, + sessionId: 'session-2', + toolName: 'apply_patch', + }, + { lockAcquireTimeoutMs: 500 }, + ).finally(() => { + contenderSettled = true; + }); + await wait(40); + expect(contenderSettled).toBe(false); + + settle(); + await expect(firstMutation).rejects.toThrow('exceeded 20 ms critical-section limit'); + await expect(contender).resolves.toMatchObject({ stateVersion: phase === 'fsync' ? 2 : 1 }); + const contentsAtUnlock = await readFile(stateFile, 'utf8'); + await wait(30); + expect(await readFile(stateFile, 'utf8')).toBe(contentsAtUnlock); + }); +} + +test('keeps contenders excluded until a delayed release settles', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let releaseEntered!: () => void; + let settleRelease!: () => void; + const entered = new Promise((resolve) => { + releaseEntered = resolve; + }); + const settlement = new Promise((resolve) => { + settleRelease = resolve; + }); + const first = createTestFileRuntimeKernel({ + stateFile, + adapter: { + beforeRelease: async () => { + releaseEntered(); + await settlement; + }, + releaseMs: 200, + }, + }); + const second = createTestFileRuntimeKernel({ stateFile }); + const firstMutation = first.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:delayed-release-owner', + path: 'release-owner.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + await entered; + let contenderSettled = false; + const contender = second.recordEdit( + { + host: 'codex', + idempotencyKey: 'test:state:delayed-release-contender', + path: 'release-contender.ts', + sessionId: 'session-2', + toolName: 'apply_patch', + }, + { lockAcquireTimeoutMs: 500 }, + ).finally(() => { + contenderSettled = true; + }); + await wait(40); + expect(contenderSettled).toBe(false); + settleRelease(); + await expect(firstMutation).resolves.toMatchObject({ stateVersion: 1 }); + await expect(contender).resolves.toMatchObject({ stateVersion: 2 }); +}); + +test('bounds a stuck release and invokes fatal owner teardown without unlocking', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let fatalError: Error | undefined; + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + acquireLock: async () => async () => new Promise(() => undefined), + criticalSectionMs: 20, + fatalOwnerTeardown: (error) => { + fatalError = error; + }, + prepareStateFile: async ({ stateFile: preparedStateFile }) => { + await writeFile(preparedStateFile, '', 'utf8'); + return preparedStateFile; + }, + releaseMs: 20, + }, + }); + + const outcome = await Promise.race([ + kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:stuck-release', + path: 'stuck-release.ts', + sessionId: 'session-1', + toolName: 'Write', + }).then(() => 'resolved', (error: unknown) => error), + wait(200).then(() => 'test-timeout'), + ]); + + expect(outcome).toBeInstanceOf(Error); + expect(errorMessages(outcome).some((message) => message.includes('lease release exceeded 20 ms'))).toBe(true); + expect(fatalError?.message).toContain('lease release exceeded 20 ms'); + await expect( + kernel.recordEdit({ + host: 'codex', + idempotencyKey: 'test:state:after-stuck-release', + path: 'after-stuck-release.ts', + sessionId: 'session-2', + toolName: 'apply_patch', + }), + ).rejects.toThrow('permanently poisoned'); +}); + +test('lease compromise cancels its owning mutation while a contender is acquiring', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let allowRead!: () => void; + let compromiseOwner!: (error: Error) => void; + let firstRead = true; + let acquireCount = 0; + const readBarrier = new Promise((resolve) => { + allowRead = resolve; + }); + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + acquireLock: async ({ onCompromised }) => { + acquireCount += 1; + if (acquireCount === 1) { + compromiseOwner = onCompromised; + return async () => undefined; + } + return new Promise(() => undefined); + }, + beforeRead: async () => { + if (firstRead) { + firstRead = false; + await readBarrier; + } + }, + criticalSectionMs: 500, + prepareStateFile: async ({ stateFile: preparedStateFile }) => { + await writeFile(preparedStateFile, '', 'utf8'); + return preparedStateFile; + }, + }, + }); + const first = kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:compromise-owner-a', + path: 'owner-a.ts', + sessionId: 'session-a', + toolName: 'Write', + }); + void first.catch(() => undefined); + await wait(10); + const second = kernel.recordEdit( + { + host: 'codex', + idempotencyKey: 'test:state:compromise-contender-b', + path: 'contender-b.ts', + sessionId: 'session-b', + toolName: 'apply_patch', + }, + { lockAcquireTimeoutMs: 500 }, + ); + void second.catch(() => undefined); + await wait(10); + compromiseOwner(new Error('simulated owner compromise')); + + const firstOutcome = await Promise.race([ + first.then(() => 'resolved', (error: unknown) => error), + wait(100).then(() => 'test-timeout'), + ]); + expect(firstOutcome).toBeInstanceOf(Error); + expect((firstOutcome as Error).message).toContain('permanently poisoned'); + await expect(second).rejects.toThrow('permanently poisoned'); + await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); + allowRead(); +}); + +test('rechecks a simultaneous owner abort after a phase value wins and releases exactly once', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const controller = new AbortController(); + let appendAttempts = 0; + let releases = 0; + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + acquireLock: async () => async () => { + releases += 1; + }, + beforeAppend: async () => { + appendAttempts += 1; + }, + prepareStateFile: async ({ stateFile: preparedStateFile }) => { + await writeFile(preparedStateFile, '', 'utf8'); + return preparedStateFile; + }, + readState: () => { + controller.abort(new Error('simultaneous owner abort')); + return eagerPromise(Buffer.alloc(0)); + }, + }, + }); + + await expect( + kernel.recordEdit( + { + host: 'claude', + idempotencyKey: 'test:state:simultaneous-abort', + path: 'simultaneous-abort.ts', + sessionId: 'session-1', + toolName: 'Write', + }, + { signal: controller.signal }, + ), + ).rejects.toThrow('simultaneous owner abort'); + expect(appendAttempts).toBe(0); + expect(releases).toBe(1); +}); + +test('rechecks simultaneous lease poison after a phase value wins and never enters append', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let appendAttempts = 0; + let compromise!: (error: Error) => void; + let fatalTeardowns = 0; + let releases = 0; + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + acquireLock: async ({ onCompromised }) => { + compromise = onCompromised; + return async () => { + releases += 1; + }; + }, + beforeAppend: async () => { + appendAttempts += 1; + }, + fatalOwnerTeardown: () => { + fatalTeardowns += 1; + }, + prepareStateFile: async ({ stateFile: preparedStateFile }) => { + await writeFile(preparedStateFile, '', 'utf8'); + return preparedStateFile; + }, + readState: () => { + compromise(new Error('simultaneous owner compromise')); + return eagerPromise(Buffer.alloc(0)); + }, + }, + }); + + await expect( + kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:simultaneous-poison', + path: 'simultaneous-poison.ts', + sessionId: 'session-1', + toolName: 'Write', + }), + ).rejects.toThrow('permanently poisoned'); + expect(appendAttempts).toBe(0); + expect(fatalTeardowns).toBe(1); + expect(releases).toBe(0); +}); + +test('accepts Windows parent-fsync limitations when creating a new state file', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { + platform: 'win32', + syncParent: async () => { + throw Object.assign(new Error('Windows directory sync unsupported'), { code: 'EPERM' }); + }, + }, + }); + await expect( + kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:windows-parent-sync', + path: 'windows.ts', + sessionId: 'session-1', + toolName: 'Write', + }), + ).resolves.toMatchObject({ stateVersion: 1 }); +}); + +test('rejects oversized snapshots before parsing or allocating their full file size', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'oversized.jsonl'); + await writeFile(stateFile, Buffer.alloc(16 * 1024 * 1024 + 1)); + await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).rejects.toThrow('exceeds 16777216 byte limit'); +}); + +test('rejects invalid writes before creating their state file', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + await expect( + createFileRuntimeKernel({ stateFile }).recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:invalid-write', + path: '', + sessionId: 'session-1', + toolName: 'Write', + }), + ).rejects.toThrow('every event field'); + await expect(access(stateFile)).rejects.toThrow(); +}); + +test('poisons a kernel after lease compromise before it can append or mutate again', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + let entered!: () => void; + let continueAppend!: () => void; + const enteredBeforeAppend = new Promise((resolve) => { + entered = resolve; + }); + const allowAppend = new Promise((resolve) => { + continueAppend = resolve; + }); + const kernel = createTestFileRuntimeKernel({ + stateFile, + adapter: { beforeAppend: async () => { + entered(); + await allowAppend; + } }, + }); + const pending = kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:compromised', + path: 'compromised.ts', + sessionId: 'session-1', + toolName: 'Write', + }); + void pending.catch(() => undefined); + await Promise.race([ + enteredBeforeAppend, + wait(100).then(() => Promise.reject(new Error('test-only append barrier was not reached'))), + ]); + await rm(`${stateFile}.lock`, { force: true, recursive: true }); + await wait(1_100); + continueAppend(); + await expect(pending).rejects.toThrow('lease was compromised'); + await expect( + kernel.recordEdit({ + host: 'claude', + idempotencyKey: 'test:state:after-compromise', + path: 'after-compromise.ts', + sessionId: 'session-1', + toolName: 'Write', + }), + ).rejects.toThrow('permanently poisoned'); + await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); +}); + +test('treats a valid empty JSONL file as an empty snapshot', async () => { + const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); + await writeFile(stateFile, '', 'utf8'); + + await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ + edits: [], + stateVersion: 0, + }); +}); + +test('exposes the static MCP tools, native hooks, and app resource contract', () => { + expect(runtimeDefinition.tools.map((tool) => tool.name)).toEqual([ + 'recent_edits', + 'render_edit_timeline', + 'runtime_status', + ]); + expect(runtimeDefinition.nativeHooks.map((hook) => hook.matcher)).toEqual([ + 'Write|Edit', + 'apply_patch', + ]); + expect(runtimeDefinition.resources).toMatchObject([ + { + _meta: { + 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', + 'ui.csp': { connectDomains: [], resourceDomains: [] }, + 'ui.prefersBorder': true, + }, + uri: resourceUri, + }, + ]); + expect(runtimeDefinition.tools.map((tool) => tool.annotations)).toEqual([ + readOnlyAnnotations, + readOnlyAnnotations, + readOnlyAnnotations, + ]); + + for (const tool of runtimeDefinition.tools) { + const metadata = tool._meta as { ui?: { resourceUri?: string }; 'openai/outputTemplate'?: string }; + + if (tool.name === 'render_edit_timeline') { + expect(metadata.ui?.resourceUri).toBe(resourceUri); + expect(metadata['openai/outputTemplate']).toBe(resourceUri); + } else { + expect(metadata.ui?.resourceUri).toBeUndefined(); + expect(metadata['openai/outputTemplate']).toBeUndefined(); + } + } +}); + +test('serializes the registry into JSON Schema descriptors without functions', () => { + const serialized = serializeRuntimeDefinition(); + + expect(containsFunction(serialized)).toBe(false); + expect(serialized.tools).toHaveLength(3); + for (const tool of serialized.tools) { + expect(tool.inputSchema).toEqual(expect.any(Object)); + expect(tool.outputSchema).toEqual(expect.any(Object)); + expect(tool.inputSchema.$schema).toBeUndefined(); + expect(tool.outputSchema.$schema).toBeUndefined(); + } +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts new file mode 100644 index 000000000..8b87a40ad --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts @@ -0,0 +1,34 @@ +import { cp, mkdtemp, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface CopiedExample { + readonly projectRoot: string; + readonly workspaceRoot: string; +} + +/** + * Copies the example into a temporary workspace shaped like the repository. + * The example's direct dependencies (zod, @agent-bundle/rsc-runtime) live in + * its own node_modules, not the workspace root's hoisted set, so the copy + * links both. + */ +export const copyExample = async ( + exampleRoot: string, + options: { readonly linkPackages?: boolean; readonly prefix: string }, +): Promise => { + const workspaceRoot = await mkdtemp(join(tmpdir(), options.prefix)); + const projectRoot = join(workspaceRoot, 'examples', 'rsc-agent-runtime'); + await cp(exampleRoot, projectRoot, { + filter: (source) => !['.agent-bundle', 'dist', 'node_modules'].includes(source.split('/').at(-1) ?? ''), + recursive: true, + }); + await symlink(join(exampleRoot, '../../node_modules'), join(workspaceRoot, 'node_modules'), 'dir'); + await symlink(join(exampleRoot, 'node_modules'), join(projectRoot, 'node_modules'), 'dir'); + if (options.linkPackages === true) { + await symlink(join(exampleRoot, '../../packages'), join(workspaceRoot, 'packages'), 'dir'); + } + await symlink(join(exampleRoot, '../../tsconfig.json'), join(workspaceRoot, 'tsconfig.json')); + await symlink(join(exampleRoot, '../../tsconfig.base.json'), join(workspaceRoot, 'tsconfig.base.json')); + return Object.freeze({ projectRoot, workspaceRoot }); +}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts new file mode 100644 index 000000000..81224d9f9 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts @@ -0,0 +1,15 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, test } from '@rstest/core'; + +test('typechecks all TypeScript source and test files, including development materializers', async () => { + const config = JSON.parse(await readFile(join(process.cwd(), 'tsconfig.json'), 'utf8')) as { include: string[] }; + + expect(config.include).toEqual(expect.arrayContaining([ + 'src/**/*.ts', + 'src/**/*.tsx', + 'tests/**/*.ts', + 'tests/**/*.tsx', + ])); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx new file mode 100644 index 000000000..c5c19e6f1 --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx @@ -0,0 +1,16 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, test } from '@rstest/core'; +import React from 'react'; + +import { RefreshStatus } from '../src/widget/App.js'; + +test('announces timeline refresh and errors through one implicit live region', () => { + const refreshing = renderToStaticMarkup(); + const error = renderToStaticMarkup(); + + expect(refreshing).toContain('class="timeline__status"'); + expect(refreshing).toContain('role="status"'); + expect(refreshing).not.toContain('aria-live='); + expect(refreshing).toContain('Refreshing timeline.'); + expect(error).toContain('Unable to refresh timeline.'); +}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json new file mode 100644 index 000000000..67cbb7fcf --- /dev/null +++ b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": [ + "agent-bundle.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "tests/**/*.tsx" + ] +} diff --git a/.runtime-playground-vH2Kdl/node_modules b/.runtime-playground-vH2Kdl/node_modules new file mode 120000 index 000000000..e9526f206 --- /dev/null +++ b/.runtime-playground-vH2Kdl/node_modules @@ -0,0 +1 @@ +/fast/projects/agent-bundle/node_modules \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/packages b/.runtime-playground-vH2Kdl/packages new file mode 120000 index 000000000..53a23d560 --- /dev/null +++ b/.runtime-playground-vH2Kdl/packages @@ -0,0 +1 @@ +/fast/projects/agent-bundle/packages \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/tsconfig.base.json b/.runtime-playground-vH2Kdl/tsconfig.base.json new file mode 100644 index 000000000..a5e985e32 --- /dev/null +++ b/.runtime-playground-vH2Kdl/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "isolatedModules": true, + "jsx": "react-jsx", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "verbatimModuleSyntax": true + } +} diff --git a/.runtime-playground-vH2Kdl/tsconfig.json b/.runtime-playground-vH2Kdl/tsconfig.json new file mode 100644 index 000000000..194c678ab --- /dev/null +++ b/.runtime-playground-vH2Kdl/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": [ + "node" + ] + }, + "include": [ + "*.ts", + "fixtures/**/*.ts", + "packages/agent-bundle/src/**/*.ts", + "packages/agent-bundle/tests/**/*.ts" + ] +} diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 50ab37ab7..7edc26412 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -28,6 +28,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@inspector/core": "workspace:*", "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@types/react": "19.2.18", diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index d5026e6eb..08da060e5 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -4,7 +4,6 @@ import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; const sourceRoot = resolve(import.meta.dirname, 'src'); -const vendorRoot = resolve(sourceRoot, 'inspector', 'vendor'); /** * The contributor dev process proxies to a separately started foreground @@ -35,14 +34,6 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ }, plugins: [pluginReact()], root: import.meta.dirname, - resolve: { - alias: { - '@inspector/core/json/xMcpHeader.js': resolve(vendorRoot, 'core', 'json', 'xMcpHeader.ts'), - '@inspector/core/mcp/fetchTracking.js': resolve(vendorRoot, 'core', 'mcp', 'fetchTracking.ts'), - '@inspector/core/mcp/types.js': resolve(vendorRoot, 'core', 'mcp', 'types.ts'), - '@inspector/core': resolve(vendorRoot, 'core'), - }, - }, source: { entry: { index: resolve(sourceRoot, 'main.tsx'), diff --git a/packages/workbench/src/inspector/vendor/core/package.json b/packages/workbench/src/inspector/vendor/core/package.json new file mode 100644 index 000000000..c64e679ae --- /dev/null +++ b/packages/workbench/src/inspector/vendor/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@inspector/core", + "version": "0.0.0", + "private": true, + "description": "Vendored MCP Inspector core, linked so `@inspector/core/*` specifiers resolve through the package manager instead of per-config aliases.", + "type": "module", + "exports": { + "./*.js": "./*.ts", + "./*": "./*" + } +} diff --git a/packages/workbench/tests/runtime-contract-compile.test.ts b/packages/workbench/tests/runtime-contract-compile.test.ts index 33b2bfb00..d5d83e920 100644 --- a/packages/workbench/tests/runtime-contract-compile.test.ts +++ b/packages/workbench/tests/runtime-contract-compile.test.ts @@ -221,10 +221,14 @@ const runtimePlaygroundController = createRuntimePlaygroundController({ }); const runtimePlaygroundProps: RuntimePlaygroundProps = { controller: runtimePlaygroundController }; -it('compiles RuntimeClient against the exact provider wire contract', () => { +it('compiles RuntimeClient against the exact provider wire contract', async () => { const foreground = new ForegroundRouteClient({ fetch: async () => Response.json(statusResponse) }); const client: RuntimeClient = new RuntimeClient(foreground); const bootstrap: Promise = client.bootstrap(); + // The stub answers every route with the status wrapper, so the fan-out + // rejects by design; handling it here keeps the rejection from racing the + // worker's post-file unhandled-error check. + await expect(bootstrap).rejects.toThrow('Runtime route returned an invalid surfaces wrapper.'); const error: RuntimeClientError = new RuntimeClientError({ code: 'AB8204', message: 'Generation changed.', phase: 'provider-lifecycle' }); const runtimeModel: RuntimeModel = createRuntimeModel({ bootstrap: runtimeBootstrap, profiles }); const requested = reduceRuntimeModel(runtimeModel, { type: 'run.request' }); diff --git a/packages/workbench/tests/support/workbench-browser-modules.ts b/packages/workbench/tests/support/workbench-browser-modules.ts index ec2d027f2..ec29da819 100644 --- a/packages/workbench/tests/support/workbench-browser-modules.ts +++ b/packages/workbench/tests/support/workbench-browser-modules.ts @@ -10,10 +10,6 @@ export const workbenchNodeModules = join(workbenchRoot, 'node_modules'); export const dependencyRoot = (name: string): string => dirname(requireFromWorkbench.resolve(`${name}/package.json`)); export const workbenchBrowserAliases = { - '@inspector/core/json/xMcpHeader.js': join(vendorRoot, 'core', 'json', 'xMcpHeader.ts'), - '@inspector/core/mcp/fetchTracking.js': join(vendorRoot, 'core', 'mcp', 'fetchTracking.ts'), - '@inspector/core/mcp/types.js': join(vendorRoot, 'core', 'mcp', 'types.ts'), - '@inspector/core': join(vendorRoot, 'core'), // @mantine/core's exports map blocks package.json resolution, so its path // comes from the workbench package's own direct dependency directory. '@mantine/core': join(workbenchRoot, 'node_modules', '@mantine', 'core'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d91430f75..59fc1730c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: specifier: 4.4.3 version: 4.4.3 devDependencies: + '@inspector/core': + specifier: workspace:* + version: link:src/inspector/vendor/core '@rsbuild/core': specifier: 2.2.1 version: 2.2.1 @@ -341,6 +344,8 @@ importers: specifier: 19.2.5 version: 19.2.5(@types/react@19.2.18) + packages/workbench/src/inspector/vendor/core: {} + packages: '@andrewbranch/untar.js@1.0.4': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fa88c1a9e..bbf319141 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - packages/workbench/src/inspector/vendor/core - examples/* allowBuilds: '@google/genai': false diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts index 1a32dc765..6cb104f61 100644 --- a/rstest.runtime-playground.browser.config.ts +++ b/rstest.runtime-playground.browser.config.ts @@ -22,10 +22,6 @@ export default defineConfig({ pool: { maxWorkers: 1 }, resolve: { alias: { - '@inspector/core/json/xMcpHeader.js': resolve('packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts'), - '@inspector/core/mcp/fetchTracking.js': resolve('packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts'), - '@inspector/core/mcp/types.js': resolve('packages/workbench/src/inspector/vendor/core/mcp/types.ts'), - '@inspector/core': resolve('packages/workbench/src/inspector/vendor/core'), react: browserReactRoot, 'react-dom': browserReactDomRoot, }, diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts index 5df506785..426d018b8 100644 --- a/rstest.runtime-playground.config.ts +++ b/rstest.runtime-playground.config.ts @@ -43,10 +43,6 @@ export default defineConfig({ plugins: [pluginReact()], resolve: { alias: { - '@inspector/core/json/xMcpHeader.js': resolve('packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts'), - '@inspector/core/mcp/fetchTracking.js': resolve('packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts'), - '@inspector/core/mcp/types.js': resolve('packages/workbench/src/inspector/vendor/core/mcp/types.ts'), - '@inspector/core': resolve('packages/workbench/src/inspector/vendor/core'), react: browserReactRoot, 'react-dom': browserReactDomRoot, }, From f312ae8c03685724b196ce9da9579fea3d42e9bc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 22:28:17 +0000 Subject: [PATCH 07/11] chore: drop a stray runtime-playground fixture workspace An aborted test run left its copied fixture workspace in the repo root and the previous commit swept it in; the fixture prefix is now ignored so test debris cannot enter history again. --- .gitignore | 3 + .../examples/rsc-agent-runtime/README.md | 250 -- .../rsc-agent-runtime/agent-bundle.config.ts | 41 - .../examples/rsc-agent-runtime/node_modules | 1 - .../examples/rsc-agent-runtime/package.json | 37 - .../claude/.claude-plugin/plugin.json | 7 - .../packaging/claude/.mcp.json | 9 - .../packaging/claude/hooks/hooks.json | 16 - .../codex/.agents/plugins/marketplace.json | 12 - .../packaging/codex/.codex-plugin/plugin.json | 18 - .../packaging/codex/.mcp.json | 10 - .../packaging/codex/hooks/hooks.json | 16 - .../rsc-agent-runtime/rsbuild.config.ts | 291 -- .../rsc-agent-runtime/rstest.config.ts | 7 - .../scripts/capture-widget.mjs | 231 -- .../scripts/eval-evidence.mjs | 241 -- .../scripts/eval-host-environment.mjs | 49 - .../scripts/eval-host-paths.mjs | 4 - .../rsc-agent-runtime/scripts/eval-hosts.mjs | 169 - .../scripts/package-hosts.mjs | 75 - .../src/build/emit-artifacts.ts | 67 - .../src/build/serialize-definition.ts | 43 - .../rsc-agent-runtime/src/definition.ts | 97 - .../src/dev/definition-entry.ts | 23 - .../src/dev/generation-materializer.ts | 1027 ------ .../src/dev/inspection-security.ts | 29 - .../src/dev/invocation-worker.ts | 236 -- .../rsc-agent-runtime/src/dev/provider.ts | 13 - .../src/dev/rsbuild-runtime-session.ts | 2830 ----------------- .../src/dev/serialize-inspection.ts | 249 -- .../src/flight/request-render.ts | 190 -- .../rsc-agent-runtime/src/hook/cli.ts | 86 - .../rsc-agent-runtime/src/hook/normalize.ts | 93 - .../src/mcp/create-server.ts | 76 - .../rsc-agent-runtime/src/mcp/handlers.ts | 33 - .../src/mcp/host-metadata.ts | 93 - .../src/mcp/http-security.ts | 84 - .../rsc-agent-runtime/src/mcp/http.ts | 55 - .../src/mcp/resolve-state.ts | 51 - .../rsc-agent-runtime/src/mcp/stdio.ts | 14 - .../src/rsc/client-anchor.ts | 3 - .../rsc-agent-runtime/src/rsc/components.tsx | 41 - .../rsc-agent-runtime/src/rsc/routes.tsx | 20 - .../rsc-agent-runtime/src/rsc/worker.tsx | 149 - .../src/runtime/contracts.ts | 216 -- .../src/runtime/request-context.ts | 17 - .../src/runtime/state-file-core.ts | 781 ----- .../src/runtime/state-file-test-support.ts | 101 - .../src/runtime/state-file.ts | 58 - .../src/types/mcp-ext-apps-react.d.ts | 19 - .../src/types/react-server-dom-rspack.d.ts | 24 - .../rsc-agent-runtime/src/types/styles.d.ts | 1 - .../rsc-agent-runtime/src/widget/App.tsx | 202 -- .../src/widget/host-adapters.ts | 74 - .../rsc-agent-runtime/src/widget/index.tsx | 11 - .../rsc-agent-runtime/src/widget/styles.css | 238 -- .../tests/dev-invocation.integration.test.ts | 2155 ------------- .../tests/dev-provider.integration.test.ts | 1683 ---------- .../tests/docs-contract.test.ts | 67 - .../tests/eval-evidence.test.ts | 592 ---- .../tests/fixtures/state-lock-owner.mjs | 31 - .../tests/fixtures/state-settlement-exit.ts | 27 - .../tests/generation-materializer.test.ts | 983 ------ .../tests/host-artifacts.test.ts | 307 -- .../tests/host-extensions.test.tsx | 86 - .../tests/http-security.test.ts | 25 - .../tests/mcp-lowering.test.tsx | 175 - .../tests/mcp-transports.integration.test.ts | 426 --- .../tests/micro-eval.spot.test.ts | 87 - .../tests/rsc-hook.integration.test.ts | 331 -- .../tests/runtime-artifact-manifest.test.ts | 73 - .../tests/state-and-definition.test.ts | 1083 ------- .../tests/support/copy-example.ts | 34 - .../tests/tsconfig-coverage.test.ts | 15 - .../tests/widget-accessibility.test.tsx | 16 - .../examples/rsc-agent-runtime/tsconfig.json | 13 - .runtime-playground-vH2Kdl/node_modules | 1 - .runtime-playground-vH2Kdl/packages | 1 - .runtime-playground-vH2Kdl/tsconfig.base.json | 15 - .runtime-playground-vH2Kdl/tsconfig.json | 14 - 80 files changed, 3 insertions(+), 17068 deletions(-) delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts delete mode 120000 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/rsc-hook.integration.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx delete mode 100644 .runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json delete mode 120000 .runtime-playground-vH2Kdl/node_modules delete mode 120000 .runtime-playground-vH2Kdl/packages delete mode 100644 .runtime-playground-vH2Kdl/tsconfig.base.json delete mode 100644 .runtime-playground-vH2Kdl/tsconfig.json diff --git a/.gitignore b/.gitignore index 98b36237f..e6b9ec671 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist/ coverage/ *.log examples/audiobook-curator/artifact/ + +# Aborted runtime-playground fixture workspaces +.runtime-playground-*/ diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md deleted file mode 100644 index 8d18e9024..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/README.md +++ /dev/null @@ -1,250 +0,0 @@ -# RSC Agent Runtime demo - -This private, opt-in example shows one React Server Components (RSC) runtime shared by native file-edit hooks, MCP tools, and an MCP App timeline. It is an architecture experiment, not an `agent-bundle` public API. - -## Four planes - -| Plane | Responsibility | Lifetime | -| --- | --- | --- | -| Definition | Static hook matchers, tool schemas, resource URIs, and metadata | Build/startup | -| Kernel | Append-only JSONL events and snapshots | Cross-process | -| RSC render | Hook and MCP result component trees, lowered from Flight | One request | -| MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance | - -Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker, lowers the Flight result, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls. - -```tsx -// A Hook JSX route reads request-scoped context. -import { Hook } from '@agent-bundle/rsc-runtime'; -import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; - -export function AfterFileEdit() { - const edit = useEdit(); - const snapshot = useRuntimeSnapshot(); - return ( - - - Recorded {edit.path}; {snapshot.edits.length} edits exist. - - - ); -} -``` - -```tsx -// An MCP JSX route describes protocol blocks, not browser HTML. -import { Mcp } from '@agent-bundle/rsc-runtime'; - -export function RenderTimeline({ snapshot }: { snapshot: { edits: unknown[]; stateVersion: number } }) { - return ( - - {`Showing ${snapshot.edits.length} edits.`} - - ); -} -``` - -## Run locally - -From the repository root: - -```bash -pnpm --filter @agent-bundle/rsc-agent-runtime-demo build -pnpm --filter @agent-bundle/rsc-agent-runtime-demo test -pnpm --filter @agent-bundle/rsc-agent-runtime-demo typecheck -pnpm --filter @agent-bundle/rsc-agent-runtime-demo capture:widget -- --output /tmp/rsc-agent-runtime-widget.png -pnpm docs:runtime-topology -``` - -For contributor Workbench/HMR evidence, use the repository fixture rather than -the published package: - -```bash -node packages/workbench/scripts/capture-runtime-playground.mjs \ - --desktop "$PWD/docs/assets/rsc-runtime-workbench/desktop.png" \ - --mobile "$PWD/docs/assets/rsc-runtime-workbench/mobile.png" \ - --hmr-before "$PWD/docs/assets/rsc-runtime-workbench/hmr-before.png" \ - --hmr-after "$PWD/docs/assets/rsc-runtime-workbench/hmr-after.png" \ - --compile-error "$PWD/docs/assets/rsc-runtime-workbench/compile-error.png" \ - --recovered "$PWD/docs/assets/rsc-runtime-workbench/recovered.png" \ - --evidence /tmp/rsc-runtime-delivery/evidence.json -``` - -The published Agent Bundle library is built with Rslib. This example's separate -production RSC/runtime artifacts are built by its explicit Rsbuild production -command (`pnpm --filter @agent-bundle/rsc-agent-runtime-demo build`); its provider -uses a separate long-lived Rsbuild development/HMR session only when an -`agent-bundle dev` project opts into `dev.runtime.provider`. Installing -`agent-bundle` alone does not install or activate this example provider. See -[the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) -for the full ownership boundary. - -The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and two self-contained native plugin artifacts under `dist/plugins`. It runs `package:hosts` automatically; it can also be run directly: - -```bash -pnpm --filter @agent-bundle/rsc-agent-runtime-demo package:hosts -``` - -To exercise one hook manually, give it an explicit state file and native Claude-shaped JSON: - -```bash -AGENT_RUNTIME_STATE_FILE=/tmp/rsc-events.jsonl \ - node examples/rsc-agent-runtime/dist/runtime/hook/index.js --host claude <.apps` compiler. It compiles self-contained HTML and exposes it through the virtual `agent-bundle/mcp-apps` resource lane without React/RSC runtime requirements. Opt into this paired RSC runtime only when hooks or MCP tool results genuinely need RSC Flight and shared runtime behavior. - -## Limits and opt-in boundary - -The demo kernel is append-only JSONL: it is appropriate for a small local example, not concurrent/distributed production storage. The RSC-facing packages are exact pins because their framework-facing surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`, `react-server-dom-rspack` `0.1.0`, Rsbuild `2.2.1`, and `rsbuild-plugin-rsc` `0.1.1`. - -Existing Agent Bundle skills, static MCPs, evaluations, and normal hooks neither require nor activate this runtime. Nothing under `packages/agent-bundle` imports the example or React/RSC runtime packages. - -`PlaygroundService` is the landed, provider-neutral durable whole-plugin -authoring timeline foundation. Runtime Playground history is deliberately -provider-session-scoped and ephemeral in this example; wiring a provider -adapter, authenticated API, timeline UI, durable Runtime export, or evaluation -promotion onto that history is an explicit non-goal of this demo. - -## Sources - -- [Rsbuild React Server Components plugin](https://www.npmjs.com/package/rsbuild-plugin-rsc) -- [MCP Apps patterns and host context](https://apps.extensions.modelcontextprotocol.io/api/documents/Patterns.html) -- [OpenAI plugin UI / ChatGPT MCP Apps guidance](https://developers.openai.com/plugins/build/chatgpt-ui) -- [Claude MCP Apps cross-compatibility](https://claude.com/docs/connectors/building/mcp-apps/cross-compatibility) and [design guidance](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines) -- [Claude Code hooks](https://code.claude.com/docs/en/hooks) -- [Codex CLI documentation](https://developers.openai.com/codex/cli) -- [Codex 0.147.0 `apply_patch` PostToolUse payload](https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/core/src/tools/handlers/apply_patch.rs#L2237-L2264) and [analogous hook issue #26729](https://github.com/openai/codex/issues/26729) diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts deleted file mode 100644 index 5f8a67e75..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/agent-bundle.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { defineConfig } from 'agent-bundle/config'; - -export default defineConfig({ - claude: {}, - codex: {}, - dev: { runtime: { provider: './src/dev/provider.ts' } }, - hooks: { - afterTool: { - handler: './src/hook/cli.ts', - targets: ['claude', 'codex'], - tools: ['file.write'], - }, - }, - mcp: { - servers: { - timeline: { - apps: { - timeline: { - _meta: { - 'openai/widgetDescription': 'Interactive timeline of recorded file edits.', - }, - entry: './src/widget/index.tsx', - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - targets: ['portable', 'claude', 'codex'], - }, - }, - entry: './src/mcp/stdio.ts', - targets: ['portable', 'claude', 'codex'], - transport: 'stdio', - }, - }, - }, - portable: {}, - plugin: { - description: 'React Server Components agent runtime demonstration.', - name: 'rsc-agent-runtime-demo', - version: '1.0.0', - }, - skills: [], - targets: ['portable', 'claude', 'codex'], -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules deleted file mode 120000 index c20969271..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/node_modules +++ /dev/null @@ -1 +0,0 @@ -/fast/projects/agent-bundle/examples/rsc-agent-runtime/node_modules \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json deleted file mode 100644 index 228aebc70..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@agent-bundle/rsc-agent-runtime-demo", - "private": true, - "type": "module", - "scripts": { - "build": "rsbuild build --mode production && pnpm package:hosts", - "package:hosts": "node scripts/package-hosts.mjs", - "test": "rstest --config rstest.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit", - "check": "pnpm build && pnpm test && pnpm typecheck", - "eval:hosts": "node scripts/eval-hosts.mjs", - "capture:widget": "node scripts/capture-widget.mjs" - }, - "dependencies": { - "@agent-bundle/rsc-runtime": "workspace:*", - "@modelcontextprotocol/ext-apps": "1.7.5", - "@modelcontextprotocol/sdk": "1.30.0", - "express": "5.2.1", - "proper-lockfile": "^4.1.2", - "react": "19.2.8", - "react-dom": "19.2.8", - "react-server-dom-rspack": "0.1.0", - "zod": "4.4.3" - }, - "devDependencies": { - "@rsbuild/core": "2.2.1", - "@rsbuild/plugin-react": "2.1.0", - "@rstest/core": "0.11.10", - "@types/express": "5.0.6", - "@types/proper-lockfile": "^4.1.4", - "@types/react": "19.2.18", - "@types/react-dom": "19.2.5", - "agent-bundle": "workspace:*", - "playwright-core": "1.62.1", - "rsbuild-plugin-rsc": "0.1.1" - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json deleted file mode 100644 index 7d1cc63ba..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.claude-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "rsc-agent-runtime", - "version": "0.1.0", - "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "author": { "name": "Agent Bundle" }, - "hooks": "./hooks/hooks.json" -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json deleted file mode 100644 index 086579eb1..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/.mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcpServers": { - "rsc-agent-runtime": { - "type": "stdio", - "command": "node", - "args": ["${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js"] - } - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json deleted file mode 100644 index ceb2b1e19..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/claude/hooks/hooks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/runtime/hook/index.js\" --host claude", - "timeout": 30 - } - ] - } - ] - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json deleted file mode 100644 index 196c9eac0..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.agents/plugins/marketplace.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "rsc-agent-runtime-marketplace", - "interface": { "displayName": "RSC Agent Runtime" }, - "plugins": [ - { - "name": "rsc-agent-runtime", - "category": "Productivity", - "source": { "source": "local", "path": "./" }, - "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" } - } - ] -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json deleted file mode 100644 index 60b1fd87c..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.codex-plugin/plugin.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "rsc-agent-runtime", - "version": "0.1.0", - "description": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "author": { "name": "Agent Bundle" }, - "interface": { - "displayName": "RSC Agent Runtime", - "shortDescription": "Shared-state RSC hooks and MCP runtime demo.", - "longDescription": "RSC hooks, shared state, MCP tools, and an MCP App in one runtime demo.", - "developerName": "Agent Bundle", - "category": "Productivity", - "capabilities": ["mcp", "hooks"], - "defaultPrompt": ["Show the recent RSC runtime edit timeline."] - }, - "mcpServers": "./.mcp.json", - "hooks": "./hooks/hooks.json", - "skills": "./skills/" -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json deleted file mode 100644 index 1a9819efe..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "rsc-agent-runtime": { - "type": "stdio", - "command": "node", - "args": ["./runtime/mcp/stdio.js"], - "cwd": "./" - } - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json deleted file mode 100644 index fda568697..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/packaging/codex/hooks/hooks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "apply_patch", - "hooks": [ - { - "type": "command", - "command": "node \"${PLUGIN_ROOT}/runtime/hook/index.js\" --host codex", - "timeout": 30 - } - ] - } - ] - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts deleted file mode 100644 index d85acca9d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rsbuild.config.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { createHash } from 'node:crypto'; -import { rm } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - -import { defineConfig, type RsbuildConfig, type RsbuildDevServer, type RsbuildPlugin } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; -import { Layers, pluginRSC } from 'rsbuild-plugin-rsc'; - -import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js'; - -export interface RscRuntimeCompileSnapshot { - readonly acceptCompilerAssetCheckpoint?: () => void; - readonly attemptId: string; - readonly candidateId: string; - readonly discardCompilerAssetCheckpoint?: () => void; - readonly preparedRevision: string; - readonly rscCohortRevision: number; - readonly sourceRevision: string; -} - -export type RscRuntimeActivationOutcome = 'activated' | 'failed'; -export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build'; - -export interface RscRuntimeRsbuildConfigOptions { - readonly compilerRoot?: string; - readonly mode: 'development' | 'production'; - /** Receives the App environment's server-only Rsbuild HMR credential. */ - readonly onAppWebSocketToken?: (token: string) => void; - readonly onCompile?: Readonly<{ - beforeAttempt(): string; - capture(input: { - readonly attemptId: string; - readonly cohortChanged: boolean; - readonly hasErrors: boolean; - readonly sourceRevision: string; - }): Promise; - /** Queues provider activation but never blocks the Rsbuild compile hook. */ - enqueue(snapshot: RscRuntimeCompileSnapshot): unknown; - failAttempt(attemptId: string, error: unknown, kind: RscRuntimeCompileFailureKind): void; - }>; -} - -const runtimeAppHmrTokenPlugin = ( - capture: NonNullable, -): RsbuildPlugin => { - let devServer: RsbuildDevServer | undefined; - let lastAppCompilation: object | string | undefined; - return { - name: 'agent-bundle:rsc-runtime-app-hmr-token', - setup(api) { - api.onAfterCreateCompiler(({ environments }) => { - const token = environments.app?.webSocketToken; - if (typeof token !== 'string') throw new Error('RSC runtime App compiler did not expose an HMR credential.'); - capture(token); - }); - api.onBeforeStartDevServer(({ server }) => { - devServer = server; - lastAppCompilation = undefined; - }); - api.onCloseDevServer(() => { - devServer = undefined; - lastAppCompilation = undefined; - }); - api.onAfterEnvironmentCompile(({ environment, isFirstCompile, stats }) => { - if (devServer === undefined || environment.name !== 'app' || stats === undefined || stats.hasErrors()) return; - const compilation = typeof stats.hash === 'string' && stats.hash.length > 0 ? stats.hash : stats; - if (lastAppCompilation === compilation) return; - lastAppCompilation = compilation; - if (isFirstCompile) return; - devServer?.environments.app.hot.send('full-reload'); - }); - }, - }; -}; - -const emitRuntimeManifest = (): RsbuildPlugin => ({ - apply: 'build', - name: 'emit-rsc-agent-runtime-manifest', - setup(api) { - api.onBeforeBuild(async ({ environments }) => { - await rm(dirname(environments.rsc.distPath), { force: true, recursive: true }); - }); - api.onAfterBuild(async ({ environments }) => { - await emitRuntimeArtifacts(environments.rsc.distPath); - }); - }, -}); - -const runtimeCompileObserverPlugin = ( - observer: NonNullable, -): RsbuildPlugin => { - const pendingAttemptIds: string[] = []; - let capturedCohort: Readonly<{ readonly activationSequence: number; readonly sourceRevision: string }> | undefined; - let nextActivationSequence = 0; - return { - name: 'agent-bundle:rsc-runtime-compile-observer', - setup(api) { - api.onBeforeDevCompile(() => { - pendingAttemptIds.push(observer.beforeAttempt()); - }); - api.onAfterDevCompile(async ({ stats }) => { - const attemptId = pendingAttemptIds.shift(); - if (attemptId === undefined) { - throw new Error('RSC runtime compile completed without a matching attempt.'); - } - let snapshot: RscRuntimeCompileSnapshot | undefined; - try { - if (stats.hasErrors()) { - capturedCohort = undefined; - observer.failAttempt(attemptId, new Error('RSC runtime compile reported errors.'), 'source-build'); - return; - } - const json = stats.toJson({ all: false, children: true, hash: true }); - const cohortHashes = new Map<'rsc' | 'widget', string>(); - for (const child of json.children ?? []) { - if (child.name !== 'rsc' && child.name !== 'widget') continue; - if (typeof child.hash !== 'string' || child.hash.length === 0) { - throw new Error(`RSC runtime ${child.name} compilation has no hash.`); - } - if (cohortHashes.has(child.name)) { - throw new Error(`RSC runtime compile contains duplicate ${child.name} stats.`); - } - cohortHashes.set(child.name, child.hash); - } - if (cohortHashes.size !== 2 || !cohortHashes.has('rsc') || !cohortHashes.has('widget')) { - throw new Error('RSC runtime compile requires exactly one RSC and widget stats child.'); - } - const hashes = (['rsc', 'widget'] as const).map((name) => [name, cohortHashes.get(name) as string]); - const sourceRevision = createHash('sha256').update(JSON.stringify(hashes)).digest('hex'); - snapshot = await observer.capture({ - attemptId, - cohortChanged: sourceRevision !== capturedCohort?.sourceRevision, - hasErrors: false, - sourceRevision, - }); - if (snapshot !== undefined) { - const activationSequence = ++nextActivationSequence; - capturedCohort = Object.freeze({ activationSequence, sourceRevision }); - let queued: unknown; - try { - queued = observer.enqueue(snapshot); - } catch (error) { - if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; - throw error; - } - const completion = queued instanceof Promise - ? queued as Promise - : Promise.resolve(undefined); - snapshot.acceptCompilerAssetCheckpoint?.(); - void completion.then((outcome) => { - if (outcome === 'activated' || outcome === undefined) return; - if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; - }, () => { - if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; - }); - } - } catch (error) { - try { - snapshot?.discardCompilerAssetCheckpoint?.(); - } catch { - // The original capture/enqueue error remains the attempted failure cause. - } - observer.failAttempt(attemptId, error, 'provider-lifecycle'); - } - }); - }, - }; -}; - -export const createRscRuntimeRsbuildConfig = ( - options: RscRuntimeRsbuildConfigOptions, -): RsbuildConfig => { - const development = options.mode === 'development'; - if (development && options.compilerRoot === undefined) { - throw new TypeError('Development RSC runtime config requires compilerRoot.'); - } - const root = (name: 'rsc' | 'widget' | 'app', productionRoot: string): string => - development ? join(options.compilerRoot as string, name) : productionRoot; - - return { - ...(development ? { - dev: { writeToDisk: true }, - server: { host: '127.0.0.1', printUrls: false }, - } : {}), - plugins: [ - pluginReact(), - pluginRSC({ environments: { server: 'rsc', client: 'widget' } }), - emitRuntimeManifest(), - ...(options.onAppWebSocketToken === undefined ? [] : [runtimeAppHmrTokenPlugin(options.onAppWebSocketToken)]), - ...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]), - ], - environments: { - rsc: { - source: { - entry: { - ...(development ? { 'dev/definition': './src/dev/definition-entry.ts' } : {}), - ...(development ? { 'dev/invoke': './src/dev/invocation-worker.ts' } : {}), - 'hook/index': './src/hook/cli.ts', - 'rsc/index': { import: './src/rsc/worker.tsx', layer: Layers.rsc }, - 'mcp/stdio': './src/mcp/stdio.ts', - 'mcp/http': './src/mcp/http.ts', - }, - }, - tools: { - rspack: { - module: { - rules: [{ - parser: { importMeta: { url: false } }, - test: /[\\/]src[\\/]flight[\\/]request-render\.ts$/, - }], - }, - }, - }, - output: { - cleanDistPath: false, - distPath: { js: './', jsAsync: 'chunks', root: root('rsc', 'dist/runtime') }, - filename: { js: '[name].js' }, - manifest: 'runtime-assets.json', - target: 'node', - }, - // Rsbuild 2.2 enabled sync chunk splitting for node targets by - // default. Worker-spawning modules here resolve sibling entries from - // their own preserved `import.meta.url`, so hoisting them into a - // shared chunk at the dist root breaks those relative paths. - splitChunks: false, - }, - widget: { - source: { - entry: { - ...(development ? { 'dev/definition': './src/rsc/client-anchor.ts' } : {}), - ...(development ? { 'dev/invoke': './src/rsc/client-anchor.ts' } : {}), - 'hook/index': './src/rsc/client-anchor.ts', - 'rsc/index': './src/rsc/client-anchor.ts', - 'mcp/stdio': './src/rsc/client-anchor.ts', - 'mcp/http': './src/rsc/client-anchor.ts', - }, - }, - output: { - cleanDistPath: false, - distPath: { root: root('widget', 'dist/widget') }, - filename: { js: '[name].js' }, - target: 'web', - }, - }, - app: { - ...(development ? { - dev: { - // The trusted runtime-surface outer document owns the one HMR - // socket. The compiler App itself runs in an opaque srcdoc child - // and must never receive a browser HMR credential or connection. - hmr: false, - liveReload: false, - }, - } : {}), - html: { inject: 'body' }, - output: { - cleanDistPath: false, - distPath: { - ...(development ? {} : { js: './' }), - root: root('app', 'dist/app'), - }, - ...(development ? {} : { - filename: { - assets: '[name][ext]', - css: '[name].css', - js: '[name].js', - }, - filenameHash: false, - legalComments: 'linked', - }), - inlineScripts: true, - inlineStyles: true, - target: 'web', - }, - source: { - entry: { - 'edit-timeline-v1': './src/widget/index.tsx', - standalone: './src/widget/index.tsx', - }, - }, - tools: { - rspack: { - module: { parser: { javascript: { dynamicImportMode: 'eager' } } }, - }, - }, - }, - }, - }; -}; - -export default defineConfig(createRscRuntimeRsbuildConfig({ mode: 'production' })); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts deleted file mode 100644 index df37962ff..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/rstest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from '@rstest/core'; - -export default defineConfig({ - include: ['tests/**/*.test.{ts,tsx}'], - pool: { maxWorkers: 1 }, - testEnvironment: 'node', -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs deleted file mode 100644 index 07bcfc1f8..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/capture-widget.mjs +++ /dev/null @@ -1,231 +0,0 @@ -/* global URL, document, HTMLElement, getComputedStyle, process */ - -import { createServer } from 'node:http'; -import { access, mkdir, readFile } from 'node:fs/promises'; -import { dirname, extname, join, resolve } from 'node:path'; -import { execFile } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -import { chromium } from 'playwright-core'; - -const exec = promisify(execFile); -const exampleRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const appRoot = join(exampleRoot, 'dist', 'app'); - -const timeline = (stateVersion) => ({ - edits: [ - { eventId: 'concept-1', host: 'claude', path: 'src/runtime/state.ts', recordedAt: '2026-08-14T10:24:31.000Z', sessionId: 'concept', toolName: 'Write' }, - { eventId: 'concept-2', host: 'codex', path: 'src/widget/App.tsx', recordedAt: '2026-08-14T10:21:07.000Z', sessionId: 'concept', toolName: 'Edit' }, - { eventId: 'concept-3', host: 'claude', path: 'README.md', recordedAt: '2026-08-14T10:17:42.000Z', sessionId: 'concept', toolName: 'Read' }, - ], - stateVersion, -}); - -const withHeadScript = (html, script) => html.replace('', ``); - -const openAiBootstrap = ` - window.openai = { - get widgetState() { - try { return JSON.parse(sessionStorage.getItem('rsc-agent-runtime-widget-state') || '{}'); } catch { return {}; } - }, - setWidgetState(state) { - sessionStorage.setItem('rsc-agent-runtime-widget-state', JSON.stringify(state)); - } - }; -`; - -const hostHarness = () => ` - - -`; - -const parseArguments = (argv) => { - const outputIndex = argv.indexOf('--output'); - const output = outputIndex === -1 ? undefined : argv[outputIndex + 1]; - if (output === undefined || output.trim() === '' || argv.length !== 2) { - throw new Error('Usage: node scripts/capture-widget.mjs --output '); - } - return resolve(output); -}; - -const findChrome = async () => { - const candidates = [process.env.CHROME_PATH, 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].filter(Boolean); - for (const candidate of candidates) { - if (candidate.includes('/')) { - try { - await access(candidate); - return candidate; - } catch { - continue; - } - } - try { - const { stdout } = await exec('which', [candidate]); - return stdout.trim(); - } catch { - // Try the next installed browser name. - } - } - throw new Error('Could not locate an installed Chrome executable. Set CHROME_PATH to use capture:widget.'); -}; - -const sibling = (output, suffix) => { - const extension = extname(output) || '.png'; - return join(dirname(output), `${output.slice(output.lastIndexOf('/') + 1, -extension.length)}${suffix}${extension}`); -}; - -const listen = (documents) => new Promise((resolvePromise, reject) => { - const server = createServer((request, response) => { - const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; - const document = documents.get(path); - if (document === undefined) { - response.writeHead(404).end('Not found'); - return; - } - response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); - response.end(document); - }); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (address === null || typeof address === 'string') { - reject(new Error('Could not allocate a loopback capture port.')); - return; - } - resolvePromise({ port: address.port, server }); - }); -}); - -const closeServer = (server) => new Promise((resolvePromise, reject) => server.close((error) => (error ? reject(error) : resolvePromise()))); - -const waitForState = async (pageOrFrame, version) => { - await pageOrFrame.waitForFunction( - (expected) => document.querySelector('footer')?.textContent === `State version ${expected}`, - version, - ); -}; - -const run = async () => { - const output = parseArguments(process.argv.slice(2)); - await mkdir(dirname(output), { recursive: true }); - const [standalone, editTimeline] = await Promise.all([ - readFile(join(appRoot, 'standalone.html'), 'utf8'), - readFile(join(appRoot, 'edit-timeline-v1.html'), 'utf8'), - ]); - const chrome = await findChrome(); - const documents = new Map([ - ['/standalone.html', standalone], - ['/openai.html', withHeadScript(standalone, openAiBootstrap)], - ['/context-widget.html', editTimeline], - ]); - const listener = await listen(documents); - documents.set('/claude-context.html', hostHarness()); - const baseUrl = `http://127.0.0.1:${listener.port}`; - let browser; - try { - browser = await chromium.launch({ executablePath: chrome, headless: true }); - const desktop = await browser.newPage({ viewport: { width: 760, height: 500 } }); - await desktop.goto(`${baseUrl}/standalone.html`); - await waitForState(desktop, 3); - await desktop.screenshot({ path: output }); - await desktop.getByRole('button', { name: 'Refresh' }).click(); - await waitForState(desktop, 4); - - const mobilePath = sibling(output, '-mobile'); - const mobile = await browser.newPage({ viewport: { width: 360, height: 640 } }); - await mobile.goto(`${baseUrl}/standalone.html`); - await waitForState(mobile, 3); - await mobile.screenshot({ path: mobilePath }); - - const openAiPath = sibling(output, '-openai'); - const openAi = await browser.newPage({ viewport: { width: 760, height: 500 } }); - await openAi.goto(`${baseUrl}/openai.html`); - await waitForState(openAi, 3); - await openAi.locator('.timeline__event').nth(1).click(); - await openAi.waitForFunction(() => document.querySelectorAll('.timeline__event')[1]?.getAttribute('aria-pressed') === 'true'); - await openAi.reload(); - await waitForState(openAi, 3); - await openAi.waitForFunction(() => document.querySelectorAll('.timeline__event')[1]?.getAttribute('aria-pressed') === 'true'); - await openAi.screenshot({ path: openAiPath }); - - const contextPath = sibling(output, '-claude-context'); - const context = await browser.newPage({ viewport: { width: 360, height: 640 } }); - await context.goto(`${baseUrl}/claude-context.html`); - const frame = context.frames().find((candidate) => candidate.url().endsWith('/context-widget.html')); - if (frame === undefined) { - throw new Error('Claude-compatible host fixture did not load its MCP Apps frame.'); - } - await waitForState(frame, 3); - await frame.getByRole('button', { name: 'Refresh' }).click(); - await waitForState(frame, 4); - const contextProof = await frame.evaluate(() => { - const timeline = document.querySelector('.timeline'); - const refresh = document.querySelector('button'); - if (!(timeline instanceof HTMLElement) || !(refresh instanceof HTMLElement)) throw new Error('Expected timeline controls.'); - const computed = getComputedStyle(timeline); - const box = refresh.getBoundingClientRect(); - return { - horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, - nestedVerticalOverflow: computed.overflowY === 'auto' || computed.overflowY === 'scroll', - refreshHeight: box.height, - refreshWidth: box.width, - safeAreaTop: computed.getPropertyValue('--timeline-safe-area-top').trim(), - theme: document.documentElement.getAttribute('data-theme'), - }; - }); - if ( - contextProof.horizontalOverflow || - contextProof.nestedVerticalOverflow || - contextProof.refreshWidth < 44 || - contextProof.refreshHeight < 44 || - contextProof.safeAreaTop !== '12px' || - contextProof.theme !== 'dark' - ) { - throw new Error('Claude-compatible host fixture did not apply safe areas, styles, or usable Refresh sizing.'); - } - await context.screenshot({ path: contextPath }); - - process.stdout.write(`${JSON.stringify({ - claudeContext: contextPath, - desktop: output, - mobile: mobilePath, - openai: openAiPath, - refreshChangedVersion: true, - restoredOpenAiSelection: true, - })}\n`); - } finally { - await browser?.close(); - await closeServer(listener.server); - } -}; - -run().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs deleted file mode 100644 index d260411f5..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-evidence.mjs +++ /dev/null @@ -1,241 +0,0 @@ -const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); - -const jsonEvents = (output) => output.split('\n').flatMap((line) => { - try { - const value = JSON.parse(line); - return isRecord(value) ? [value] : []; - } catch { - return []; - } -}); - -const markerOnOwnLine = (value, marker) => - typeof value === 'string' && value.split(/\r?\n/).some((line) => line.trim() === marker); - -const MAX_RESULT_CONTENT_CHARACTERS = 16_384; -const MAX_RESULT_CONTENT_BLOCKS = 20; - -const boundedText = (value) => typeof value === 'string' && value.length <= MAX_RESULT_CONTENT_CHARACTERS - ? value - : undefined; - -const boundedClaudeResultContent = (value) => { - const text = boundedText(value); - if (text !== undefined) return text; - if (!Array.isArray(value) || value.length === 0 || value.length > MAX_RESULT_CONTENT_BLOCKS) return undefined; - const blocks = []; - let length = 0; - for (const block of value) { - if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') return undefined; - length += block.text.length + (blocks.length === 0 ? 0 : 1); - if (length > MAX_RESULT_CONTENT_CHARACTERS) return undefined; - blocks.push(block.text); - } - return blocks.join('\n'); -}; - -const claudeToolUses = (event) => { - if (event.type !== 'assistant' || !isRecord(event.message) || event.message.role !== 'assistant' || !Array.isArray(event.message.content)) { - return []; - } - return event.message.content.flatMap((content) => - isRecord(content) && - content.type === 'tool_use' && - typeof content.id === 'string' && - typeof content.name === 'string' - ? [content] - : [], - ); -}; - -const claudeToolResults = (event) => { - if (event.type !== 'user' || !isRecord(event.message) || event.message.role !== 'user' || !Array.isArray(event.message.content)) { - return []; - } - return event.message.content.flatMap((content) => - isRecord(content) && - content.type === 'tool_result' && - typeof content.tool_use_id === 'string' - ? [{ - content: content.is_error === true ? undefined : boundedClaudeResultContent(content.content), - id: content.tool_use_id, - succeeded: content.is_error !== true, - }] - : [], - ); -}; - -// Claude Code 2.1.250 names plugin MCP tools mcp__plugin____; -// the 2.1.232-era short form stays accepted for hosts at the supported floor. -const claudeRuntimeToolNames = (toolName) => [ - `mcp__rsc-agent-runtime__${toolName}`, - `mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__${toolName}`, -]; -const isClaudeRuntimeTool = (candidate, toolName) => claudeRuntimeToolNames(toolName).includes(candidate); - -const completedClaudeToolUses = (events) => { - const uses = events.flatMap(claudeToolUses); - const useCounts = new Map(); - for (const toolUse of uses) useCounts.set(toolUse.id, (useCounts.get(toolUse.id) ?? 0) + 1); - - const results = new Map(); - const duplicateResults = new Set(); - for (const result of events.flatMap(claudeToolResults)) { - if (results.has(result.id)) duplicateResults.add(result.id); - else results.set(result.id, result); - } - - return uses.flatMap((toolUse) => { - const result = results.get(toolUse.id); - if (useCounts.get(toolUse.id) !== 1 || duplicateResults.has(toolUse.id) || result?.succeeded !== true || result.content === undefined) { - return []; - } - return [{ content: result.content, toolUse }]; - }); -}; - -const stateHasMarker = (host, records, marker) => - typeof marker === 'string' && marker.length > 0 && Array.isArray(records) && records.some((record) => - isRecord(record) && - record.kind === 'edit' && - isRecord(record.event) && - record.event.host === host && - typeof record.event.path === 'string' && - record.event.path.includes(marker)); - -const claudeEvidence = (events, marker, finalMarker) => { - const completed = completedClaudeToolUses(events); - const recentEdits = completed.filter(({ toolUse }) => isClaudeRuntimeTool(toolUse.name, 'recent_edits')); - const renderTimeline = completed.filter(({ toolUse }) => isClaudeRuntimeTool(toolUse.name, 'render_edit_timeline')); - const finalMarkerObserved = events.some( - (event) => event.type === 'result' && event.is_error === false && markerOnOwnLine(event.result, finalMarker), - ); - - return { - eventCounts: { hook: 0, json: events.length, mcp: recentEdits.length, rscRender: renderTimeline.length }, - finalMarkerObserved, - mcpReadMarkerObserved: typeof marker === 'string' && recentEdits.some(({ content }) => content.includes(marker)), - mcpReadObserved: recentEdits.length > 0, - rscRenderToolObserved: renderTimeline.length > 0, - }; -}; - -const distinct = (values) => [...new Set(values)].sort(); - -const terminalHostCanRenderIframe = false; -const evidence = (condition, observedBasis, unavailableBasis) => ({ - basis: condition ? observedBasis : unavailableBasis, - evidence: condition ? 'observed' : 'unavailable', -}); - -const observed = (result, key) => isRecord(result) && result[key] === true; - -/** Converts bounded native-run observations into explicit, non-browser host claims. */ -export const classifyNativeEvidence = (host, result, { capturedAt }) => { - const hostAvailable = isRecord(result) && typeof result.version === 'string'; - const unavailableBasis = hostAvailable ? 'selected native run did not produce the required evidence' : 'installed host/version/session unavailable'; - const packageActivated = hostAvailable && observed(result, 'sessionAvailable') && observed(result, 'finalMarkerObserved'); - const hookDispatched = host === 'claude' && hostAvailable && observed(result, 'editObservedByHook'); - const mcpRead = hostAvailable && observed(result, 'mcpReadObserved'); - const rscRender = hostAvailable && observed(result, 'rscRenderToolObserved'); - const sharedHookState = host === 'claude' && hookDispatched && observed(result, 'sharedHookStateObserved'); - const iframeBasis = host === 'claude' - ? 'Claude Code CLI is not an MCP Apps iframe host' - : 'Codex CLI is not an MCP Apps iframe host'; - - return { - capturedAt, - claims: [ - { id: 'package-activation', ...evidence(packageActivated, 'native terminal marker and loaded plugin session', unavailableBasis) }, - { - id: 'hook-dispatch', - ...evidence( - hookDispatched, - 'value-free hook launch probe exited 0', - host === 'codex' && hostAvailable ? 'Codex exec --ephemeral does not prove native hook dispatch' : unavailableBasis, - ), - }, - { id: 'mcp-read', ...evidence(mcpRead, 'completed recent_edits call with native success result', unavailableBasis) }, - { id: 'rsc-render', ...evidence(rscRender, 'completed render_edit_timeline call with native success result', unavailableBasis) }, - { - id: 'shared-hook-mcp-state', - ...evidence( - sharedHookState, - 'hook-recorded state was returned by recent_edits', - host === 'codex' && hostAvailable ? 'Codex exec --ephemeral has no native hook-recorded state correlation' : unavailableBasis, - ), - }, - { id: 'mcp-app-iframe', ...evidence(terminalHostCanRenderIframe, 'terminal host iframe rendering is not supported', iframeBasis) }, - ], - host, - hostVersion: hostAvailable ? result.version : 'unavailable', - }; -}; - -/** Reduces hook probe records to key/type/exit-status evidence without returning input values. */ -export const summarizeHookProbe = (records) => { - const probeRecords = Array.isArray(records) ? records.filter(isRecord) : []; - return { - commandLaunched: probeRecords.some((record) => record.commandLaunched === true), - exitStatuses: distinct(probeRecords.map((record) => record.exitStatus).filter((value) => Number.isInteger(value))), - launches: probeRecords.filter((record) => record.commandLaunched === true).length, - toolInputKeySets: distinct(probeRecords.map((record) => JSON.stringify(record.toolInputKeys ?? []))), - toolNames: distinct(probeRecords.map((record) => record.toolName).filter((value) => typeof value === 'string')), - topLevelKeySets: distinct(probeRecords.map((record) => JSON.stringify(record.topLevelKeys ?? []))), - valueTypeSets: distinct(probeRecords.map((record) => JSON.stringify({ - toolInput: record.toolInputValueTypes ?? {}, - topLevel: record.topLevelValueTypes ?? {}, - }))), - }; -}; - -export const hookEvidenceFromProbe = (summary) => - isRecord(summary) && summary.commandLaunched === true && Array.isArray(summary.exitStatuses) && summary.exitStatuses.includes(0); - -const isCodexMcpCall = (event, toolName) => - event.type === 'item.completed' && - isRecord(event.item) && - event.item.type === 'mcp_tool_call' && - event.item.status === 'completed' && - event.item.is_error !== true && - !(isRecord(event.item.result) && event.item.result.is_error === true) && - event.item.server === 'rsc-agent-runtime' && - event.item.tool === toolName; - -const codexEvidence = (events, finalMarker) => { - const recentEdits = events.filter((event) => isCodexMcpCall(event, 'recent_edits')).length; - const renderTimeline = events.filter((event) => isCodexMcpCall(event, 'render_edit_timeline')).length; - const finalMarkerObserved = events.some( - (event, index) => - event.type === 'item.completed' && - isRecord(event.item) && - event.item.type === 'agent_message' && - markerOnOwnLine(event.item.text, finalMarker) && - events[index + 1]?.type === 'turn.completed', - ); - - return { - eventCounts: { hook: 0, json: events.length, mcp: recentEdits, rscRender: renderTimeline }, - finalMarkerObserved, - mcpReadMarkerObserved: false, - mcpReadObserved: recentEdits > 0, - rscRenderToolObserved: renderTimeline > 0, - }; -}; - -/** Parses only known JSONL event discriminants and returns no host-supplied values. */ -export const evidenceFromTranscript = (host, transcript, correlation = {}) => { - const events = jsonEvents(transcript); - const safeCorrelation = isRecord(correlation) ? correlation : {}; - const finalMarker = typeof safeCorrelation.finalMarker === 'string' - ? safeCorrelation.finalMarker - : `HOST_EVAL_FINAL host=${host} path=host-created.txt`; - const evidence = host === 'claude' - ? claudeEvidence(events, safeCorrelation.marker, finalMarker) - : codexEvidence(events, finalMarker); - return { - ...evidence, - sharedHookStateObserved: host === 'claude' && evidence.mcpReadMarkerObserved && stateHasMarker(host, safeCorrelation.stateRecords, safeCorrelation.marker), - stateMarkerObserved: stateHasMarker(host, safeCorrelation.stateRecords, safeCorrelation.marker), - }; -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs deleted file mode 100644 index 5f5ff0043..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-environment.mjs +++ /dev/null @@ -1,49 +0,0 @@ -const ordinarySessionKeys = [ - 'PATH', - 'HOME', - 'USERPROFILE', - 'XDG_CONFIG_HOME', - 'CLAUDE_CONFIG_DIR', - 'LANG', - 'LC_ALL', - 'LC_CTYPE', - 'TERM', - 'COLORTERM', - 'NO_COLOR', - 'TMPDIR', - 'TMP', - 'TEMP', - 'SYSTEMROOT', - 'WINDIR', - 'PATHEXT', - 'COMSPEC', - 'SHELL', -]; - -const sensitiveEnvironmentKey = (key) => - /_API_KEY$/iu.test(key) || - /(?:^|_)(?:AUTH|AUTH_TOKEN|ACCESS_TOKEN|TOKEN|SECRET|PASSWORD|CREDENTIAL|BASE_URL|API_BASE|USE_BEDROCK|USE_FOUNDRY|USE_VERTEX)$/iu.test(key); - -const ownString = (environment, key) => { - const descriptor = Object.getOwnPropertyDescriptor(environment, key); - return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string' ? descriptor.value : undefined; -}; - -/** Returns the sole child environment allowed for native-host evaluation. */ -export const sanitizedHostEnvironment = (environment, owned = {}) => { - const child = {}; - for (const key of ordinarySessionKeys) { - if (sensitiveEnvironmentKey(key)) continue; - const value = ownString(environment, key); - if (value !== undefined) child[key] = value; - } - const ownedKeys = [ - ['AGENT_RUNTIME_HOOK_PROBE_FILE', owned.hookProbeFile], - ['AGENT_RUNTIME_STATE_FILE', owned.stateFile], - ['CODEX_HOME', owned.codexHome], - ]; - for (const [key, value] of ownedKeys) { - if (typeof value === 'string') child[key] = value; - } - return child; -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs deleted file mode 100644 index 5041dd69d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-host-paths.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const exampleRootFromModule = (moduleUrl) => resolve(dirname(fileURLToPath(moduleUrl)), '..'); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs deleted file mode 100644 index 3aeebe067..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/eval-hosts.mjs +++ /dev/null @@ -1,169 +0,0 @@ -/* global URL, process */ - -import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; -import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; -import { once } from 'node:events'; -import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { classifyNativeEvidence, evidenceFromTranscript, hookEvidenceFromProbe, summarizeHookProbe } from './eval-evidence.mjs'; -import { sanitizedHostEnvironment } from './eval-host-environment.mjs'; -import { exampleRootFromModule } from './eval-host-paths.mjs'; - -const exampleRoot = exampleRootFromModule(import.meta.url); -const expectedVersions = { claude: '2.1.250', codex: '0.147.0' }; - -const parseHost = (argv) => { - const hostIndex = argv.indexOf('--host'); - const host = hostIndex === -1 ? 'all' : argv[hostIndex + 1]; - if (!['claude', 'codex', 'all'].includes(host) || argv.length !== (hostIndex === -1 ? 0 : 2)) { - throw new Error('Usage: node scripts/eval-hosts.mjs [--host claude|codex|all]'); - } - return host; -}; - -const runProcess = async (command, args, options = {}) => { - const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - const [exitCode, signal] = await once(child, 'close'); - return { exitCode, signal, stderr, stdout }; -}; - -const cliVersion = async (host, environment) => { - const result = await runProcess(host, ['--version'], { env: environment }); - const version = result.stdout.trim() || result.stderr.trim(); - if (result.exitCode !== 0 || !version.includes(expectedVersions[host])) { - throw new Error(`${host} ${expectedVersions[host]} is not installed`); - } - return expectedVersions[host]; -}; - -const opaqueCodexAuthCopy = async (temporaryCodexHome) => { - const sourceHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'); - const source = join(sourceHome, 'auth.json'); - try { - const sourceStat = await stat(source); - await copyFile(source, join(temporaryCodexHome, 'auth.json')); - await chmod(join(temporaryCodexHome, 'auth.json'), sourceStat.mode & 0o777); - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') return false; - throw error; - } - return true; -}; - -const hookProbeSummary = async (probeFile) => { - const records = await readFile(probeFile, 'utf8') - .then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line))) - .catch(() => []); - return summarizeHookProbe(records); -}; - -const evidenceFrom = async (host, fixture, stateFile, probeFile, transcript, correlation) => { - const stateRecords = await readFile(stateFile, 'utf8') - .then((contents) => contents.split('\n').filter(Boolean).map((line) => JSON.parse(line))) - .catch(() => []); - const transcriptEvidence = evidenceFromTranscript(host, transcript, { ...correlation, stateRecords }); - const editObserved = await stat(join(fixture, correlation.editPath)).then(() => true).catch(() => false); - const hookProbe = await hookProbeSummary(probeFile); - return { - editObservedByHook: editObserved && transcriptEvidence.stateMarkerObserved && hookEvidenceFromProbe(hookProbe), - eventCounts: { ...transcriptEvidence.eventCounts, hook: hookProbe.launches, state: stateRecords.length }, - finalMarkerObserved: transcriptEvidence.finalMarkerObserved, - hookProbe, - mcpReadObserved: transcriptEvidence.mcpReadObserved, - rscRenderToolObserved: transcriptEvidence.rscRenderToolObserved, - sharedHookStateObserved: transcriptEvidence.sharedHookStateObserved, - }; -}; - -const promptFor = (host, { editPath, finalMarker }) => { - const nativeEdit = host === 'codex' - ? 'Use the apply_patch tool for that file edit; do not use a shell command.' - : 'Use the Write tool for that file edit.'; - return `In this workspace, create exactly one file named ${editPath} containing the word ${host}. ${nativeEdit} Then call the rsc-agent-runtime MCP tool recent_edits, pass its snapshot to render_edit_timeline, and finish with this exact marker on its own line: ${finalMarker}. Do not create any other files.`; -}; - -const evaluateHost = async (host, capturedAt) => { - const nativeEnvironment = sanitizedHostEnvironment(process.env); - const version = await cliVersion(host, nativeEnvironment); - const pluginRoot = join(exampleRoot, 'dist', 'plugins', host); - await stat(pluginRoot); - const fixture = await mkdtemp(join(tmpdir(), `rsc-agent-runtime-${host}-fixture-`)); - const marker = `rsc-eval-${randomBytes(16).toString('hex')}`; - const correlation = { - editPath: `host-created-${marker}.txt`, - finalMarker: `HOST_EVAL_FINAL host=${host} marker=${marker}`, - marker, - }; - const stateFile = join(fixture, '.agent-runtime-demo', 'events.jsonl'); - const probeFile = join(fixture, 'hook-probe.jsonl'); - const sharedEnv = sanitizedHostEnvironment(process.env, { hookProbeFile: probeFile, stateFile }); - let temporaryCodexHome; - try { - await runProcess('git', ['init', '--quiet'], { cwd: fixture, env: sharedEnv }); - await runProcess('git', ['config', 'user.email', 'rsc-demo@example.invalid'], { cwd: fixture, env: sharedEnv }); - await runProcess('git', ['config', 'user.name', 'RSC Runtime Demo'], { cwd: fixture, env: sharedEnv }); - let result; - if (host === 'claude') { - result = await runProcess('claude', [ - '-p', promptFor(host, correlation), '--plugin-dir', pluginRoot, '--output-format', 'stream-json', '--verbose', '--include-hook-events', - '--no-session-persistence', '--dangerously-skip-permissions', - ], { cwd: fixture, env: sharedEnv }); - } else { - temporaryCodexHome = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-codex-home-')); - await mkdir(temporaryCodexHome, { recursive: true }); - await opaqueCodexAuthCopy(temporaryCodexHome); - const codexEnv = sanitizedHostEnvironment(process.env, { - codexHome: temporaryCodexHome, - hookProbeFile: probeFile, - stateFile, - }); - const marketplace = 'rsc-agent-runtime-marketplace'; - const marketplaceAdd = await runProcess('codex', ['plugin', 'marketplace', 'add', pluginRoot, '--json'], { cwd: fixture, env: codexEnv }); - const pluginAdd = marketplaceAdd.exitCode === 0 - ? await runProcess('codex', ['plugin', 'add', `rsc-agent-runtime@${marketplace}`, '--json'], { cwd: fixture, env: codexEnv }) - : { exitCode: 1, stderr: '', stdout: '' }; - result = pluginAdd.exitCode === 0 - ? await runProcess('codex', [ - '-a', 'never', 'exec', '--ephemeral', '--json', '--dangerously-bypass-hook-trust', '-s', 'workspace-write', '-C', fixture, promptFor(host, correlation), - ], { cwd: fixture, env: codexEnv }) - : { exitCode: 1, stderr: '', stdout: '' }; - } - const evidence = await evidenceFrom(host, fixture, stateFile, probeFile, `${result.stdout}\n${result.stderr}`, correlation); - return classifyNativeEvidence(host, { - ...evidence, - sessionAvailable: result.exitCode === 0 && evidence.finalMarkerObserved, - version, - }, { capturedAt }); - } finally { - if (temporaryCodexHome !== undefined) await rm(temporaryCodexHome, { force: true, recursive: true }); - await rm(fixture, { force: true, recursive: true }); - } -}; - -const run = async () => { - const selected = parseHost(process.argv.slice(2)); - const hosts = selected === 'all' ? ['claude', 'codex'] : [selected]; - const capturedAt = new Date().toISOString(); - const summaries = []; - for (const host of hosts) { - try { - summaries.push(await evaluateHost(host, capturedAt)); - } catch { - summaries.push(classifyNativeEvidence(host, {}, { capturedAt })); - } - } - process.stdout.write(`${JSON.stringify({ capturedAt, hosts: summaries, schemaVersion: 2 })}\n`); - if (summaries.some((summary) => summary.claims.some((claim) => claim.id !== 'mcp-app-iframe' && claim.evidence !== 'observed'))) { - process.exitCode = 1; - } -}; - -run().catch(() => { process.exitCode = 1; }); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs deleted file mode 100644 index 07ded4250..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/scripts/package-hosts.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/* global process */ - -import { access, cp, mkdir, readFile, rm } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const exampleRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const distRoot = join(exampleRoot, 'dist'); -const pluginsRoot = join(distRoot, 'plugins'); -const runtimeRoot = join(distRoot, 'runtime'); -const appRoot = join(distRoot, 'app'); -const packagingRoot = join(exampleRoot, 'packaging'); - -const assertDirectory = async (path, message) => { - try { - await access(path); - } catch { - throw new Error(message); - } -}; - -const normalizedRuntimeAsset = (asset) => { - if (typeof asset !== 'string') { - throw new Error('runtime-assets.json must contain string paths'); - } - const stripped = asset.replace(/^[/\\]+/, ''); - const normalized = normalize(stripped); - if (stripped.length === 0 || isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { - throw new Error(`Runtime asset escapes its root: ${asset}`); - } - return normalized; -}; - -const verifyRuntimeCopy = async (pluginRoot) => { - const manifestPath = join(pluginRoot, 'runtime', 'runtime-assets.json'); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - if (!Array.isArray(manifest.allFiles)) { - throw new Error('runtime-assets.json must contain allFiles'); - } - const copiedRuntime = resolve(pluginRoot, 'runtime'); - for (const asset of manifest.allFiles) { - const normalized = normalizedRuntimeAsset(asset); - const target = resolve(copiedRuntime, normalized); - if (relative(copiedRuntime, target).startsWith('..')) { - throw new Error(`Runtime asset escapes copied root: ${asset}`); - } - await access(target); - } -}; - -const packageHost = async (host) => { - const source = join(packagingRoot, host); - const target = join(pluginsRoot, host); - await cp(source, target, { recursive: true }); - await cp(runtimeRoot, join(target, 'runtime'), { recursive: true }); - await cp(appRoot, join(target, 'app'), { recursive: true }); - if (host === 'codex') { - await mkdir(join(target, 'skills'), { recursive: true }); - } - await verifyRuntimeCopy(target); -}; - -const run = async () => { - await assertDirectory(runtimeRoot, 'Build dist/runtime before packaging native hosts.'); - await assertDirectory(appRoot, 'Build dist/app before packaging native hosts.'); - await rm(pluginsRoot, { force: true, recursive: true }); - await mkdir(pluginsRoot, { recursive: true }); - await packageHost('claude'); - await packageHost('codex'); -}; - -run().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts deleted file mode 100644 index ae29ada98..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/emit-artifacts.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; -import { isAbsolute, join, normalize, relative, resolve } from 'node:path'; - -import { serializeRuntimeDefinition } from './serialize-definition.js'; -import type { SerializedRuntimeDefinition } from '../runtime/contracts.js'; - -const executableAssets = [ - { name: 'hook', path: 'hook/index.js' }, - { name: 'rsc-worker', path: 'rsc/index.js' }, - { name: 'stdio', path: 'mcp/stdio.js' }, - { name: 'http', path: 'mcp/http.js' }, -] as const; - -const normalizeRuntimeAsset = (asset: unknown): string => { - if (typeof asset !== 'string') { - throw new Error('runtime-assets.json must contain string paths'); - } - - const stripped = asset.replace(/^[/\\]+/, ''); - const normalized = normalize(stripped); - if (stripped.length === 0 || isAbsolute(normalized) || normalized === '..' || normalized.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { - throw new Error(`Runtime asset escapes its root: ${asset}`); - } - return normalized; -}; - -const readRuntimeAssets = async (distPath: string): Promise => { - const contents = await readFile(join(distPath, 'runtime-assets.json'), 'utf8'); - const parsed = JSON.parse(contents) as { allFiles?: unknown }; - if (!Array.isArray(parsed.allFiles)) { - throw new Error('runtime-assets.json must contain allFiles'); - } - - const root = resolve(distPath); - const assets = parsed.allFiles.map(normalizeRuntimeAsset); - await Promise.all(assets.map(async (asset) => { - const target = resolve(root, asset); - const pathFromRoot = relative(root, target); - if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(pathFromRoot)) { - throw new Error(`Runtime asset escapes its root: ${asset}`); - } - await access(target); - })); - return assets; -}; - -export const emitRuntimeArtifacts = async ( - distPath: string, - definition: SerializedRuntimeDefinition = serializeRuntimeDefinition(), -): Promise => { - const runtimeAssets = await readRuntimeAssets(distPath); - for (const executable of executableAssets) { - if (!runtimeAssets.includes(executable.path)) { - throw new Error(`runtime-assets.json is missing executable: ${executable.path}`); - } - } - - const manifest = { - ...definition, - executables: executableAssets, - runtimeAssets, - schemaVersion: 1, - }; - - await mkdir(distPath, { recursive: true }); - await writeFile(join(distPath, 'agent-runtime.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts deleted file mode 100644 index e240aa378..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/build/serialize-definition.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from 'zod'; -import type { ZodType } from 'zod'; - -import { runtimeDefinition } from '../definition.js'; -import type { - RuntimeDefinition, - SerializedRuntimeDefinition, - SerializedRuntimeToolDefinition, -} from '../runtime/contracts.js'; - -const toMcpJsonSchema = (schema: ZodType): Record => { - const { $schema: _schema, ...jsonSchema } = z.toJSONSchema(schema); - return jsonSchema; -}; - -export const serializeRuntimeDefinition = ( - definition: RuntimeDefinition = runtimeDefinition, -): SerializedRuntimeDefinition => ({ - nativeHooks: definition.nativeHooks.map((hook) => ({ ...hook })), - resources: definition.resources.map((resource) => ({ - ...resource, - _meta: { - ...resource._meta, - 'ui.csp': { - ...resource._meta['ui.csp'], - connectDomains: [...resource._meta['ui.csp'].connectDomains], - resourceDomains: [...resource._meta['ui.csp'].resourceDomains], - }, - }, - })), - tools: definition.tools.map( - (tool): SerializedRuntimeToolDefinition => ({ - ...tool, - _meta: { - ...tool._meta, - ui: tool._meta.ui === undefined ? undefined : { ...tool._meta.ui }, - }, - annotations: { ...tool.annotations }, - inputSchema: toMcpJsonSchema(tool.inputSchema), - outputSchema: toMcpJsonSchema(tool.outputSchema), - }), - ), -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts deleted file mode 100644 index 5944a0c65..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/definition.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from 'zod'; - -import type { RuntimeDefinition, ToolAnnotations } from './runtime/contracts.js'; - -export const editTimelineResourceUri = 'ui://rsc-agent-runtime/edit-timeline-v1.html'; - -const readOnlyAnnotations: ToolAnnotations = { - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - readOnlyHint: true, -}; - -const editEventSchema = z.object({ - eventId: z.string(), - host: z.enum(['claude', 'codex']), - path: z.string(), - recordedAt: z.string(), - sessionId: z.string(), - toolName: z.string(), -}); - -const snapshotSchema = z.object({ - edits: z.array(editEventSchema), - stateVersion: z.number().int().nonnegative(), -}); - -const limitInputSchema = z.object({ - limit: z.number().int().min(1).max(50).optional(), -}); - -export const runtimeDefinition: RuntimeDefinition = { - nativeHooks: [ - { - event: 'PostToolUse', - handlerId: 'record_post_tool_use', - host: 'claude', - matcher: 'Write|Edit', - }, - { - event: 'after_tool_use', - handlerId: 'record_post_tool_use', - host: 'codex', - matcher: 'apply_patch', - }, - ], - resources: [ - { - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - 'ui.csp': { - connectDomains: [], - resourceDomains: [], - }, - 'ui.prefersBorder': true, - }, - mimeType: 'text/html;profile=mcp-app', - name: 'edit-timeline', - uri: editTimelineResourceUri, - }, - ], - tools: [ - { - _meta: {}, - annotations: readOnlyAnnotations, - description: 'Read file edits recorded by agent hooks.', - handlerId: 'recent_edits', - inputSchema: limitInputSchema, - name: 'recent_edits', - outputSchema: snapshotSchema, - }, - { - _meta: { - 'openai/outputTemplate': editTimelineResourceUri, - ui: { resourceUri: editTimelineResourceUri }, - }, - annotations: readOnlyAnnotations, - description: 'Render the interactive file edit timeline.', - handlerId: 'render_edit_timeline', - inputSchema: limitInputSchema, - name: 'render_edit_timeline', - outputSchema: snapshotSchema, - }, - { - _meta: {}, - annotations: readOnlyAnnotations, - description: 'Read the current shared runtime state.', - handlerId: 'runtime_status', - inputSchema: z.object({}), - name: 'runtime_status', - outputSchema: z.object({ - editCount: z.number().int().nonnegative(), - stateVersion: z.number().int().nonnegative(), - }), - }, - ], -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts deleted file mode 100644 index a76cbb6ef..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/definition-entry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { serializeRuntimeDefinition } from '../build/serialize-definition.js'; - -type JsonValue = null | boolean | number | string | JsonValue[] | { readonly [key: string]: JsonValue }; - -const canonicalize = (value: unknown): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime definition must contain finite JSON numbers.'); - return value; - } - if (Array.isArray(value)) return value.map(canonicalize); - if (typeof value !== 'object') throw new TypeError('Runtime definition must be JSON serializable.'); - - const input = value as Record; - const output: Record = {}; - for (const key of Object.keys(input).sort()) { - const item = input[key]; - if (item !== undefined) output[key] = canonicalize(item); - } - return output; -}; - -process.stdout.write(`${JSON.stringify(canonicalize(serializeRuntimeDefinition()))}\n`); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts deleted file mode 100644 index 2e125148f..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/generation-materializer.ts +++ /dev/null @@ -1,1027 +0,0 @@ -import { createHash } from 'node:crypto'; -import { open, lstat, mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; - -import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; -import type { - RscRuntimeAppDefinition, - RscRuntimeGenerationMetadata, - RscRuntimeSurfaceAsset, - SerializedRuntimeDefinition, -} from '../runtime/contracts.js'; -import type { DevRuntimePreparedProject } from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; -import type { DevRuntimeMcpServerDescriptor } from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; -import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; -import type { - RuntimeGenerationActivationGuard, - RuntimeGenerationAsset, - RuntimeGenerationCandidate, - RuntimeGenerationManifestInput, - RuntimeGenerationMetadataCodec, - RuntimeGenerationPreparedActivation, - RuntimeGenerationStore, - RuntimeGenerationValidationInput, -} from '../../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; - -export type { RscRuntimeGenerationMetadata, RscRuntimeSurfaceAsset } from '../runtime/contracts.js'; - -const definitionFile = 'rsc/runtime-definition.json'; -const runtimeAssetsFile = 'rsc/runtime-assets.json'; -const requiredEntries = Object.freeze([ - 'hook/index', - 'mcp/http', - 'mcp/stdio', - 'rsc/index', -] as const); -const executableAsyncEntries = Object.freeze(['mcp/http', 'mcp/stdio'] as const); -const maximumDefinitionStdout = 1024 * 1024; -const maximumDefinitionStderr = 64 * 1024; -const definitionTimeoutMs = 5_000; -const definitionTerminationGraceMs = 100; -const sha256Expression = /^[a-f0-9]{64}$/u; -const generatedRscAssetPaths = Object.freeze([ - 'agent-runtime.manifest.json', - 'runtime-assets.json', - 'runtime-definition.json', -] as const); - -interface RuntimeAssetsManifest { - readonly allFiles: readonly string[]; - readonly entries: Readonly>; -} - -interface RuntimeAssetsEntry { - readonly async?: Readonly<{ readonly js?: readonly string[] }>; - readonly initial?: Readonly<{ readonly js?: readonly string[] }>; -} - -export interface RscCompilerAssetCheckpointTracker { - checkpoint(compilerRoot: string): Promise; - close(): void; -} - -export interface RscCompilerAssetCheckpoint { - readonly priorAssets: ReadonlyMap; - accept(assets: ReadonlyMap): void; - discard(): void; -} - -interface CompilerAssetCheckpointRoot { - assets: ReadonlyMap; - tail: Promise; -} - -class CompilerAssetCheckpointTracker implements RscCompilerAssetCheckpointTracker { - readonly #activeDiscards = new Set<() => void>(); - readonly #roots = new Map(); - #closed = false; - - async checkpoint(compilerRoot: string): Promise { - if (this.#closed) throw new Error('RSC compiler asset checkpoint tracker is closed.'); - const root = resolve(compilerRoot); - let state = this.#roots.get(root); - if (state === undefined) { - state = { assets: new Map(), tail: Promise.resolve() }; - this.#roots.set(root, state); - } - const previous = state.tail; - let release: (() => void) | undefined; - state.tail = new Promise((resolveTail) => { release = resolveTail; }); - await previous; - if (this.#closed) { - release?.(); - throw new Error('RSC compiler asset checkpoint tracker is closed.'); - } - const priorAssets = new Map(state.assets); - let settled = false; - const settle = (assets: ReadonlyMap | undefined): void => { - if (settled) return; - settled = true; - this.#activeDiscards.delete(discard); - if (assets !== undefined && !this.#closed) state.assets = new Map(assets); - release?.(); - }; - const discard = (): void => settle(undefined); - const accept = (assets: ReadonlyMap): void => settle(assets); - this.#activeDiscards.add(discard); - return Object.freeze({ accept, discard, priorAssets }); - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.#roots.clear(); - for (const discard of [...this.#activeDiscards]) discard(); - } -} - -export const createRscCompilerAssetCheckpointTracker = (): RscCompilerAssetCheckpointTracker => - new CompilerAssetCheckpointTracker(); - -export interface RscRuntimeCapturedGenerationSnapshot { - readonly acceptCompilerAssetCheckpoint?: () => void; - readonly assets: readonly RuntimeGenerationAsset[]; - readonly attemptId: string; - readonly candidate: RuntimeGenerationCandidate; - readonly definition: SerializedRuntimeDefinition; - readonly discardCompilerAssetCheckpoint?: () => void; - readonly preparedRuntime: DevRuntimePreparedProject; - readonly rscCohortRevision: number; - readonly sourceRevision: string; -} - -export interface CaptureRuntimeGenerationSnapshotOptions { - readonly attemptId: string; - readonly candidate: RuntimeGenerationCandidate; - readonly compilerAssetCheckpointTracker?: RscCompilerAssetCheckpointTracker; - readonly compilerRoot: string; - readonly preparedRuntime: DevRuntimePreparedProject; - readonly rscCohortRevision: number; - readonly sourceRevision: string; -} - -export interface MaterializeRuntimeGenerationOptions { - readonly guard?: RuntimeGenerationActivationGuard; - readonly snapshot: RscRuntimeCapturedGenerationSnapshot; - readonly stateStoreId?: string; - readonly store: RuntimeGenerationStore; -} - -const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); - -const canonicalJson = (value: unknown): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - - const input = value as Record; - return `{${Object.keys(input).sort().flatMap((key) => { - const item = input[key]; - return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; - }).join(',')}}`; -}; - -const digestValue = (value: unknown): string => - createHash('sha256').update(canonicalJson(value)).digest('hex'); - -const freezeJson = (value: unknown, seen = new WeakSet()): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return value; - } - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - if (seen.has(value)) throw new TypeError('Runtime metadata cannot contain cyclic values.'); - seen.add(value); - try { - if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item, seen))); - const input = value as Record; - const output: Record = {}; - for (const key of Object.keys(input)) { - const item = input[key]; - if (item !== undefined) output[key] = freezeJson(item, seen); - } - return Object.freeze(output); - } finally { - seen.delete(value); - } -}; - -const isJsonObject = (value: JsonValue): value is JsonObject => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const isSafeSegment = (value: string): boolean => - value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); - -const assertInside = (root: string, target: string): void => { - const path = relative(resolve(root), resolve(target)); - if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { - throw new Error('Runtime generation path escaped its root.'); - } -}; - -const assertRelativeAssetPath = (value: unknown): string => { - if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || isAbsolute(value)) { - throw new TypeError('Runtime asset path must be a contained slash-separated path.'); - } - const segments = value.split('/'); - if (segments.some((segment) => !isSafeSegment(segment))) { - throw new TypeError('Runtime asset path must not escape its root.'); - } - return segments.join('/'); -}; - -const fsync = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; - -const copyFileExclusive = async (source: string, destination: string): Promise => { - const bytes = await readFile(source); - const handle = await open(destination, 'wx'); - try { - await handle.writeFile(bytes); - await handle.sync(); - } finally { - await handle.close(); - } -}; - -const copyTree = async (sourceRoot: string, destinationRoot: string): Promise => { - const sourceStatus = await lstat(sourceRoot); - if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); - } - await mkdir(destinationRoot, { recursive: false }); - - const copyDirectory = async (source: string, destination: string): Promise => { - assertInside(sourceRoot, source); - assertInside(destinationRoot, destination); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); - const sourcePath = join(source, entry.name); - const destinationPath = join(destination, entry.name); - assertInside(sourceRoot, sourcePath); - assertInside(destinationRoot, destinationPath); - const status = await lstat(sourcePath); - if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); - if (status.isDirectory()) { - await mkdir(destinationPath, { recursive: false }); - await copyDirectory(sourcePath, destinationPath); - } else if (status.isFile()) { - await copyFileExclusive(sourcePath, destinationPath); - } else { - throw new Error('Compiler output can contain only regular files and directories.'); - } - } - await fsync(destination); - }; - - await copyDirectory(sourceRoot, destinationRoot); -}; - -const copyCurrentRscAssets = async ( - sourceRoot: string, - destinationRoot: string, - runtimeAssets: RuntimeAssetsManifest, - priorAssets: ReadonlyMap | undefined, -): Promise> => { - const sourceStatus = await lstat(sourceRoot); - if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); - } - const currentAssets = new Set([ - ...runtimeAssets.allFiles, - ...generatedRscAssetPaths, - ]); - const sourceFiles = new Map(); - const staleAssets = new Map(); - const inspectDirectory = async (source: string, prefix: string): Promise => { - assertInside(sourceRoot, source); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); - const sourcePath = join(source, entry.name); - assertInside(sourceRoot, sourcePath); - const status = await lstat(sourcePath); - if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); - const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; - if (status.isDirectory()) { - await inspectDirectory(sourcePath, path); - } else if (status.isFile()) { - if (currentAssets.has(path)) { - sourceFiles.set(path, sourcePath); - continue; - } - const priorDigest = priorAssets?.get(path); - if (priorDigest === undefined || digestBytes(await readFile(sourcePath)) !== priorDigest) { - throw new Error(`Compiler output contains an undeclared file ${JSON.stringify(path)}.`); - } - staleAssets.set(path, priorDigest); - } else { - throw new Error('Compiler output can contain only regular files and directories.'); - } - } - }; - - await inspectDirectory(sourceRoot, ''); - await mkdir(destinationRoot, { recursive: false }); - const destinationDirectories = new Set([destinationRoot]); - const rememberDirectories = (directory: string): void => { - let current = directory; - while (true) { - assertInside(destinationRoot, current); - destinationDirectories.add(current); - if (current === destinationRoot) return; - current = dirname(current); - } - }; - for (const path of [...currentAssets].sort((left, right) => left.localeCompare(right))) { - const source = sourceFiles.get(path); - if (source === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); - const destination = join(destinationRoot, ...path.split('/')); - const directory = dirname(destination); - assertInside(destinationRoot, destination); - await mkdir(directory, { recursive: true }); - rememberDirectories(directory); - await copyFileExclusive(source, destination); - } - for (const directory of [...destinationDirectories].sort((left, right) => right.length - left.length)) { - await fsync(directory); - } - return staleAssets; -}; - -const walkRegularFiles = async (root: string): Promise => { - const status = await lstat(root); - if (!status.isDirectory() || status.isSymbolicLink()) { - throw new Error('Runtime generation root must be a regular directory.'); - } - const files: RuntimeGenerationAsset[] = []; - const walk = async (current: string, prefix: string): Promise => { - const entries = await readdir(current, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Runtime generation contains an unsafe path segment.'); - const path = join(current, entry.name); - assertInside(root, path); - const entryStatus = await lstat(path); - if (entryStatus.isSymbolicLink()) throw new Error('Runtime generation cannot contain symbolic links.'); - const relativePath = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; - if (entryStatus.isDirectory()) { - await walk(path, relativePath); - } else if (entryStatus.isFile()) { - const bytes = await readFile(path); - files.push(Object.freeze({ bytes: bytes.byteLength, path: relativePath, sha256: digestBytes(bytes) })); - } else { - throw new Error('Runtime generation can contain only regular files and directories.'); - } - } - }; - await walk(root, ''); - return Object.freeze(files); -}; - -const equalAssets = (left: readonly RuntimeGenerationAsset[], right: readonly RuntimeGenerationAsset[]): boolean => - left.length === right.length && left.every((asset, index) => { - const candidate = right[index]; - return candidate !== undefined && asset.path === candidate.path && asset.bytes === candidate.bytes && asset.sha256 === candidate.sha256; - }); - -const redact = (value: string): string => value - .slice(0, 16 * 1024) - .replace(/((?:authorization|password|secret|token)\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/giu, '$1[REDACTED]'); - -const runDefinitionExecutable = async (entry: string): Promise => - new Promise((resolveDefinition, rejectDefinition) => { - const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - let stdoutBytes = 0; - let stderrBytes = 0; - let settled = false; - let termination: Error | undefined; - let terminationGrace: ReturnType | undefined; - const settle = (callback: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (terminationGrace !== undefined) clearTimeout(terminationGrace); - callback(); - }; - const terminate = (error: Error): void => { - if (termination !== undefined) return; - termination = error; - child.kill('SIGTERM'); - terminationGrace = setTimeout(() => { - child.kill('SIGKILL'); - }, definitionTerminationGraceMs); - }; - const timeout = setTimeout(() => terminate(new Error('Runtime definition executable exceeded 5 seconds.')), definitionTimeoutMs); - - child.stdout.on('data', (chunk: Buffer) => { - if (termination !== undefined) return; - stdoutBytes += chunk.byteLength; - if (stdoutBytes > maximumDefinitionStdout) { - terminate(new Error('Runtime definition executable exceeded 1 MiB stdout.')); - } else { - stdout.push(chunk); - } - }); - child.stderr.on('data', (chunk: Buffer) => { - if (termination !== undefined) return; - const retained = Math.min(chunk.byteLength, maximumDefinitionStderr - stderrBytes); - if (retained > 0) stderr.push(chunk.subarray(0, retained)); - stderrBytes += chunk.byteLength; - if (stderrBytes > maximumDefinitionStderr) { - terminate(new Error('Runtime definition executable exceeded 64 KiB stderr.')); - } - }); - child.once('error', (error) => terminate(error)); - child.once('close', (code) => { - if (settled) return; - if (termination !== undefined) { - settle(() => rejectDefinition(termination as Error)); - return; - } - const errorOutput = redact(Buffer.concat(stderr).toString('utf8')); - if (code !== 0) { - settle(() => rejectDefinition(new Error(`Runtime definition executable failed with exit code ${String(code)}${errorOutput.length === 0 ? '' : `: ${errorOutput}`}`))); - return; - } - try { - const raw = Buffer.concat(stdout).toString('utf8').trim(); - if (Buffer.byteLength(raw, 'utf8') > maximumDefinitionStdout) throw new Error('Runtime definition executable exceeded 1 MiB stdout.'); - const parsed: unknown = JSON.parse(raw); - const definition = parseDefinition(parsed); - if (canonicalJson(definition) !== raw) throw new Error('Runtime definition executable did not emit canonical JSON.'); - settle(() => resolveDefinition(definition)); - } catch (error) { - settle(() => rejectDefinition(error instanceof Error ? error : new Error('Runtime definition executable emitted invalid JSON.'))); - } - }); - }); - -const closedObject = (value: unknown, fields: readonly string[], name: string): Record => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object.`); - const object = value as Record; - if (Object.keys(object).some((key) => !fields.includes(key)) || fields.some((field) => !(field in object))) { - throw new TypeError(`${name} has an invalid schema.`); - } - return object; -}; - -const parseDefinition = (value: unknown): SerializedRuntimeDefinition => { - const definition = closedObject(value, ['nativeHooks', 'resources', 'tools'], 'Runtime definition'); - if (!Array.isArray(definition.nativeHooks) || !Array.isArray(definition.resources) || !Array.isArray(definition.tools)) { - throw new TypeError('Runtime definition arrays are malformed.'); - } - const nativeHooks = definition.nativeHooks.map((value) => { - const hook = closedObject(value, ['event', 'handlerId', 'host', 'matcher'], 'Runtime native hook'); - if ((hook.event !== 'PostToolUse' && hook.event !== 'after_tool_use') || - (hook.host !== 'claude' && hook.host !== 'codex') || - typeof hook.handlerId !== 'string' || typeof hook.matcher !== 'string') { - throw new TypeError('Runtime native hook is malformed.'); - } - return Object.freeze({ event: hook.event, handlerId: hook.handlerId, host: hook.host, matcher: hook.matcher }); - }); - const resources = definition.resources.map((value) => { - const resource = closedObject(value, ['_meta', 'mimeType', 'name', 'uri'], 'Runtime resource'); - if (typeof resource.mimeType !== 'string' || typeof resource.name !== 'string' || typeof resource.uri !== 'string') { - throw new TypeError('Runtime resource is malformed.'); - } - const meta = freezeJson(resource._meta); - if (!isJsonObject(meta)) throw new TypeError('Runtime resource metadata is malformed.'); - return Object.freeze({ _meta: meta, mimeType: resource.mimeType, name: resource.name, uri: resource.uri }); - }); - const tools = definition.tools.map((value) => { - const tool = closedObject(value, ['_meta', 'annotations', 'description', 'handlerId', 'inputSchema', 'name', 'outputSchema'], 'Runtime tool'); - const annotations = closedObject(tool.annotations, ['destructiveHint', 'idempotentHint', 'openWorldHint', 'readOnlyHint'], 'Runtime tool annotations'); - if (typeof tool.description !== 'string' || typeof tool.handlerId !== 'string' || typeof tool.name !== 'string' || - Object.values(annotations).some((annotation) => typeof annotation !== 'boolean')) { - throw new TypeError('Runtime tool is malformed.'); - } - const meta = freezeJson(tool._meta); - const inputSchema = freezeJson(tool.inputSchema); - const outputSchema = freezeJson(tool.outputSchema); - if (!isJsonObject(meta) || !isJsonObject(inputSchema) || !isJsonObject(outputSchema)) { - throw new TypeError('Runtime tool JSON fields are malformed.'); - } - return Object.freeze({ - _meta: meta, - annotations: Object.freeze({ - destructiveHint: annotations.destructiveHint as boolean, - idempotentHint: annotations.idempotentHint as boolean, - openWorldHint: annotations.openWorldHint as boolean, - readOnlyHint: annotations.readOnlyHint as boolean, - }), - description: tool.description, - handlerId: tool.handlerId, - inputSchema, - name: tool.name, - outputSchema, - }); - }); - return Object.freeze({ nativeHooks: Object.freeze(nativeHooks), resources: Object.freeze(resources), tools: Object.freeze(tools) }) as unknown as SerializedRuntimeDefinition; -}; - -const parseRuntimeAssets = async (root: string): Promise => { - const parsed: unknown = JSON.parse(await readFile(join(root, 'runtime-assets.json'), 'utf8')); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new TypeError('runtime-assets.json is malformed.'); - const manifest = parsed as Record; - if (!Array.isArray(manifest.allFiles) || typeof manifest.entries !== 'object' || manifest.entries === null || Array.isArray(manifest.entries)) { - throw new TypeError('runtime-assets.json must contain allFiles and entries.'); - } - const allFiles = manifest.allFiles.map((value) => assertRelativeAssetPath(typeof value === 'string' ? value.replace(/^[/\\]+/, '') : value)); - if (new Set(allFiles).size !== allFiles.length) throw new TypeError('runtime-assets.json contains duplicate paths.'); - const entries: Record = {}; - for (const [name, value] of Object.entries(manifest.entries)) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('runtime-assets.json entry is malformed.'); - const entry = value as Record; - const parseGroup = (group: unknown): Readonly<{ readonly js?: readonly string[] }> | undefined => { - if (group === undefined) return undefined; - if (typeof group !== 'object' || group === null || Array.isArray(group)) throw new TypeError('runtime-assets.json entry group is malformed.'); - const js = (group as Record).js; - if (js === undefined) return Object.freeze({}); - if (!Array.isArray(js)) throw new TypeError('runtime-assets.json entry group is malformed.'); - return Object.freeze({ js: Object.freeze(js.map((asset) => assertRelativeAssetPath(typeof asset === 'string' ? asset.replace(/^[/\\]+/, '') : asset))) }); - }; - entries[name] = Object.freeze({ async: parseGroup(entry.async), initial: parseGroup(entry.initial) }); - } - return Object.freeze({ allFiles: Object.freeze(allFiles), entries: Object.freeze(entries) }); -}; - -const clientReferencePaths = (assets: readonly RuntimeGenerationAsset[]): readonly string[] => - assets.filter((asset) => asset.path.startsWith('widget/')).map((asset) => asset.path); - -const validateRuntimeAssetCoverage = ( - runtimeAssets: RuntimeAssetsManifest, - assets: readonly RuntimeGenerationAsset[], -): void => { - const assetPaths = new Set(assets.map((asset) => asset.path)); - for (const asset of runtimeAssets.allFiles) { - if (!assetPaths.has(`rsc/${asset}`)) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(asset)}.`); - } - for (const entry of requiredEntries) { - const declared = runtimeAssets.entries[entry]; - if (declared === undefined) throw new Error(`runtime-assets.json is missing required entry ${JSON.stringify(entry)}.`); - const files = [...(declared.initial?.js ?? []), ...(declared.async?.js ?? [])]; - if (files.length === 0 || files.some((asset) => !runtimeAssets.allFiles.includes(asset))) { - throw new Error(`runtime-assets.json entry ${JSON.stringify(entry)} has incomplete asset coverage.`); - } - } - for (const entry of executableAsyncEntries) { - const asyncAssets = runtimeAssets.entries[entry]?.async?.js; - if (asyncAssets === undefined || asyncAssets.length === 0 || asyncAssets.some((asset) => !runtimeAssets.allFiles.includes(asset))) { - throw new Error(`runtime-assets.json executable ${JSON.stringify(entry)} is missing async asset coverage.`); - } - } - if (!runtimeAssets.allFiles.some((path) => path.startsWith('chunks/'))) { - throw new Error('runtime-assets.json must declare an async chunks/ asset.'); - } - const expectedRscAssets = new Set([ - ...runtimeAssets.allFiles.map((path) => `rsc/${path}`), - 'rsc/runtime-assets.json', - 'rsc/runtime-definition.json', - 'rsc/agent-runtime.manifest.json', - ]); - const capturedRscAssets = assets.filter((asset) => asset.path.startsWith('rsc/')).map((asset) => asset.path); - if (capturedRscAssets.length !== expectedRscAssets.size || capturedRscAssets.some((path) => !expectedRscAssets.has(path))) { - throw new Error('Captured RSC runtime asset coverage is incomplete.'); - } -}; - -const validateClientReferenceRelationship = async ( - root: string, - assets: readonly RuntimeGenerationAsset[], -): Promise => { - const clientReferences = clientReferencePaths(assets); - const expectedClientAsset = 'widget/static/js/rsc/index.js'; - if (!clientReferences.includes('widget/rsc/index.html') || !clientReferences.includes(expectedClientAsset)) { - throw new Error('Captured generation is missing paired client reference assets.'); - } - const document = await readFile(join(root, 'widget', 'rsc', 'index.html'), 'utf8'); - const references = Array.from(document.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/giu), (match) => match[1]?.split(/[?#]/u, 1)[0]); - if (!references.includes('/static/js/rsc/index.js') && !references.includes('static/js/rsc/index.js')) { - throw new Error('Captured generation has an invalid client reference relationship.'); - } -}; - -const contentTypeFor = (path: string): RscRuntimeSurfaceAsset['contentType'] | undefined => { - if (path.endsWith('.js')) return 'application/javascript'; - if (path.endsWith('.json')) return 'application/json'; - if (path.endsWith('.css')) return 'text/css'; - if (path.endsWith('.html')) return 'text/html'; - return undefined; -}; - -const surfaceAssets = ( - preparedRuntime: DevRuntimePreparedProject, - assets: readonly RuntimeGenerationAsset[], -): Readonly> => { - const widgetAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => { - if (!asset.path.startsWith('widget/')) return []; - const contentType = contentTypeFor(asset.path); - if (contentType === undefined) return []; - const requestPath = asset.path.slice('widget'.length); - return [Object.freeze({ - bytes: asset.bytes, - contentType, - generationPath: asset.path, - requestPath, - sha256: asset.sha256, - })]; - }); - const appHtmlAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => { - if (!asset.path.startsWith('app/') || !asset.path.endsWith('.html')) return []; - return [Object.freeze({ - bytes: asset.bytes, - contentType: 'text/html', - generationPath: asset.path, - requestPath: asset.path.slice('app'.length), - sha256: asset.sha256, - })]; - }); - const surfaces: Record = {}; - for (const app of preparedRuntime.apps) { - const surfaceId = `mcp.${app.name}`; - if (surfaces[surfaceId] !== undefined) throw new Error('Runtime generation has duplicate App surface definitions.'); - const resourcePath = appResourcePath(app.resourceUri); - const html = appHtmlAssets.filter((asset) => asset.requestPath === resourcePath); - if (html.length !== 1) throw new Error(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no unique captured HTML asset.`); - surfaces[surfaceId] = Object.freeze([ - ...widgetAssets.map((asset) => Object.freeze({ ...asset })), - Object.freeze({ ...html[0]! }), - ]); - } - return Object.freeze(surfaces); -}; - -const appResourcePath = (uri: string): string => { - let parsed: URL; - try { - parsed = new URL(uri); - } catch { - throw new TypeError('Runtime generation App resource URI is invalid.'); - } - if (parsed.protocol !== 'ui:' || parsed.host.length === 0 || parsed.search.length > 0 || parsed.hash.length > 0) { - throw new TypeError('Runtime generation App resource URI is invalid.'); - } - const origin = `ui://${parsed.host}`; - if (!uri.startsWith(origin)) throw new TypeError('Runtime generation App resource URI is invalid.'); - const path = uri.slice(origin.length); - const segments = path.startsWith('/') ? path.slice(1).split('/') : []; - if (segments.length === 0 || segments.some((segment) => !isSafeSegment(segment) || decodeURIComponent(segment) !== segment)) { - throw new TypeError('Runtime generation App resource URI is invalid.'); - } - return `/${segments.join('/')}`; -}; - -const validateAppSurfaceAssets = ( - apps: readonly RscRuntimeAppDefinition[], - surfaces: Readonly>, -): void => { - const expected = new Set(); - for (const app of apps) { - const surfaceId = `mcp.${app.name}`; - if (expected.has(surfaceId)) throw new TypeError('Runtime generation has duplicate App surface definitions.'); - expected.add(surfaceId); - const resourcePath = appResourcePath(app.resourceUri); - const appHtml = surfaces[surfaceId]?.filter((asset) => - asset.contentType === 'text/html' && asset.generationPath.startsWith('app/'), - ) ?? []; - if (appHtml.length !== 1 || appHtml[0]!.generationPath !== `app${resourcePath}` || appHtml[0]!.requestPath !== resourcePath) { - throw new TypeError(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no canonical captured HTML asset.`); - } - } - if (Object.keys(surfaces).length !== expected.size || Object.keys(surfaces).some((surfaceId) => !expected.has(surfaceId))) { - throw new TypeError('Runtime generation App surface assets are not owned by App definitions.'); - } -}; - -const transportProjection = (preparedRuntime: DevRuntimePreparedProject): JsonValue => freezeJson({ - provider: preparedRuntime.provider, - servers: preparedRuntime.servers.map((server) => ({ - args: server.args === undefined ? undefined : [...server.args], - command: server.command, - cwd: server.cwd, - env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), - headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => [key, digestValue(value)])), - id: server.id, - name: server.name, - source: server.source, - targets: [...server.targets], - transport: server.transport, - url: server.url, - })), -}); - -type RuntimeDefinitionPreparedProject = Readonly<{ - readonly apps: readonly RscRuntimeAppDefinition[]; -}>; - -const appDefinitions = (preparedRuntime: RuntimeDefinitionPreparedProject): readonly RscRuntimeAppDefinition[] => - freezeJson(preparedRuntime.apps.map((app) => ({ - ...(app._meta === undefined ? {} : { _meta: app._meta }), - id: app.id, - name: app.name, - resourceUri: app.resourceUri, - serverId: app.serverId, - serverName: app.serverName, - targets: [...app.targets], - })).sort((left, right) => { - const leftJson = canonicalJson(left); - const rightJson = canonicalJson(right); - return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0; - })) as unknown as readonly RscRuntimeAppDefinition[]; - -const runtimeDefinitionProjection = ( - definition: SerializedRuntimeDefinition, - preparedRuntime: RuntimeDefinitionPreparedProject, -): JsonValue => freezeJson({ - apps: appDefinitions(preparedRuntime), - definition, -}); - -export const runtimeDefinitionDigest = ( - definition: SerializedRuntimeDefinition, - preparedRuntime: RuntimeDefinitionPreparedProject, -): string => digestValue(runtimeDefinitionProjection(definition, preparedRuntime)); - -const descriptors = ( - preparedRuntime: DevRuntimePreparedProject, - definition: SerializedRuntimeDefinition, - definitionDigest: string, - serverDigest: string, - transportDigest: string, -): readonly DevRuntimeMcpServerDescriptor[] => Object.freeze(preparedRuntime.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ - definitionDigest, - name: server.name, - resources: Object.freeze(definition.resources.map((resource) => freezeJson(resource) as JsonObject)), - serverDigest, - target, - tools: Object.freeze(definition.tools.map((tool) => freezeJson(tool) as JsonObject)), - transportDigest, -})))); - -const metadataFromSnapshot = async ( - snapshot: RscRuntimeCapturedGenerationSnapshot, - assets: readonly RuntimeGenerationAsset[], - stateStoreId: string, -): Promise => { - const root = snapshot.candidate.root; - const runtimeAssets = await parseRuntimeAssets(join(root, 'rsc')); - validateRuntimeAssetCoverage(runtimeAssets, assets); - await validateClientReferenceRelationship(root, assets); - const definitionBytes = await readFile(join(root, ...definitionFile.split('/'))); - const parsedDefinition = parseDefinition(JSON.parse(definitionBytes.toString('utf8'))); - if (canonicalJson(parsedDefinition) !== definitionBytes.toString('utf8')) { - throw new Error('Captured runtime definition is not canonical.'); - } - const capturedAppDefinitions = appDefinitions(snapshot.preparedRuntime); - const definitionDigest = runtimeDefinitionDigest(snapshot.definition, snapshot.preparedRuntime); - const environmentHashes = Object.freeze({ - rsc: digestValue(assets.filter((asset) => asset.path.startsWith('rsc/'))), - widget: digestValue(assets.filter((asset) => asset.path.startsWith('widget/'))), - }); - const serverDigest = digestValue(environmentHashes); - const transportDigest = digestValue(transportProjection(snapshot.preparedRuntime)); - const entries = Object.freeze(Object.fromEntries(requiredEntries.map((entry) => { - const assetsForEntry = runtimeAssets.entries[entry]; - const path = assetsForEntry?.initial?.js?.[0]; - if (path === undefined) throw new Error(`runtime-assets.json entry ${JSON.stringify(entry)} has no initial JavaScript asset.`); - return [entry, `rsc/${path}`]; - }))); - return Object.freeze({ - appDefinitions: capturedAppDefinitions, - definitionDigest, - entries, - environmentHashes, - preparedRevision: snapshot.preparedRuntime.sourceRevision, - serverDigest, - servers: descriptors(snapshot.preparedRuntime, parsedDefinition, definitionDigest, serverDigest, transportDigest), - stateStoreId, - surfaceAssets: surfaceAssets(snapshot.preparedRuntime, assets), - transportDigest, - }); -}; - -const clonePreparedRuntime = (preparedRuntime: DevRuntimePreparedProject): DevRuntimePreparedProject => freezeJson(preparedRuntime) as unknown as DevRuntimePreparedProject; - -export const captureRuntimeGenerationSnapshot = async ( - input: CaptureRuntimeGenerationSnapshotOptions, -): Promise => { - const compilerRoot = resolve(input.compilerRoot); - const checkpoint = await input.compilerAssetCheckpointTracker?.checkpoint(compilerRoot); - try { - const rscRoot = join(compilerRoot, 'rsc'); - const definition = await runDefinitionExecutable(join(rscRoot, 'dev', 'definition.js')); - const definitionBytes = Buffer.from(canonicalJson(definition)); - const definitionPath = join(rscRoot, 'runtime-definition.json'); - await unlink(definitionPath).catch((error: unknown) => { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return undefined; - throw error; - }); - await writeFile(definitionPath, definitionBytes, { encoding: 'utf8', flag: 'wx' }); - await fsync(join(rscRoot, 'runtime-definition.json')); - await emitRuntimeArtifacts(rscRoot, definition); - await fsync(rscRoot); - const runtimeAssets = await parseRuntimeAssets(rscRoot); - const staleAssets = await copyCurrentRscAssets( - rscRoot, - join(input.candidate.root, 'rsc'), - runtimeAssets, - checkpoint?.priorAssets, - ); - await copyTree(join(compilerRoot, 'app'), join(input.candidate.root, 'app')); - await copyTree(join(compilerRoot, 'widget'), join(input.candidate.root, 'widget')); - await fsync(input.candidate.root); - const assets = await walkRegularFiles(input.candidate.root); - const capturedAssets = new Map(assets.map((asset) => [asset.path, asset])); - const checkpointAssets = new Map(staleAssets); - for (const path of runtimeAssets.allFiles) { - const asset = capturedAssets.get(`rsc/${path}`); - if (asset === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); - checkpointAssets.set(path, asset.sha256); - } - return Object.freeze({ - ...(checkpoint === undefined ? {} : { - acceptCompilerAssetCheckpoint: () => checkpoint.accept(checkpointAssets), - discardCompilerAssetCheckpoint: () => checkpoint.discard(), - }), - assets, - attemptId: input.attemptId, - candidate: input.candidate, - definition, - preparedRuntime: clonePreparedRuntime(input.preparedRuntime), - rscCohortRevision: input.rscCohortRevision, - sourceRevision: input.sourceRevision, - }); - } catch (error) { - checkpoint?.discard(); - throw error; - } -}; - -const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => { - if (!isJsonObject(value)) throw new TypeError('Runtime generation metadata is malformed.'); - const required = ['appDefinitions', 'definitionDigest', 'entries', 'environmentHashes', 'preparedRevision', 'serverDigest', 'servers', 'stateStoreId', 'surfaceAssets', 'transportDigest']; - if (Object.keys(value).some((key) => !required.includes(key)) || required.some((key) => !(key in value))) { - throw new TypeError('Runtime generation metadata has an invalid schema.'); - } - const { appDefinitions, definitionDigest, entries, environmentHashes, preparedRevision, serverDigest, servers, stateStoreId, surfaceAssets, transportDigest } = value; - if (typeof definitionDigest !== 'string' || typeof serverDigest !== 'string' || typeof transportDigest !== 'string' || - typeof preparedRevision !== 'string' || typeof stateStoreId !== 'string' || - !sha256Expression.test(definitionDigest) || !sha256Expression.test(serverDigest) || !sha256Expression.test(transportDigest) || - !Array.isArray(appDefinitions) || !isJsonObject(entries) || !isJsonObject(environmentHashes) || !Array.isArray(servers) || !isJsonObject(surfaceAssets)) { - throw new TypeError('Runtime generation metadata is malformed.'); - } - if (preparedRevision.length === 0 || stateStoreId.length === 0 || - Object.keys(entries).length !== requiredEntries.length || requiredEntries.some((entry) => typeof entries[entry] !== 'string') || - Object.keys(environmentHashes).length !== 2 || typeof environmentHashes.rsc !== 'string' || typeof environmentHashes.widget !== 'string' || - !sha256Expression.test(environmentHashes.rsc) || !sha256Expression.test(environmentHashes.widget)) { - throw new TypeError('Runtime generation environment digests are malformed.'); - } - - const decodedAppDefinitions = appDefinitions.map((value): RscRuntimeAppDefinition => { - if (!isJsonObject(value)) throw new TypeError('Runtime generation App definition is malformed.'); - const fields = ['_meta', 'id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; - const requiredFields = ['id', 'name', 'resourceUri', 'serverId', 'serverName', 'targets']; - if (Object.keys(value).some((key) => !fields.includes(key)) || requiredFields.some((field) => !(field in value)) || - typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.resourceUri !== 'string' || - typeof value.serverId !== 'string' || typeof value.serverName !== 'string' || - !Array.isArray(value.targets) || !value.targets.every((target) => typeof target === 'string') || - ('template' in value && typeof value.template !== 'string')) { - throw new TypeError('Runtime generation App definition is malformed.'); - } - const meta = '_meta' in value ? freezeJson(value._meta) : undefined; - if (meta !== undefined && !isJsonObject(meta)) throw new TypeError('Runtime generation App definition metadata is malformed.'); - return Object.freeze({ - ...(meta === undefined ? {} : { _meta: meta }), - id: value.id, - name: value.name, - resourceUri: value.resourceUri, - serverId: value.serverId, - serverName: value.serverName, - targets: Object.freeze([...value.targets]), - }); - }); - - const decodedServers = servers.map((value): DevRuntimeMcpServerDescriptor => { - if (!isJsonObject(value)) throw new TypeError('Runtime generation server descriptor is malformed.'); - const fields = ['definitionDigest', 'name', 'resources', 'serverDigest', 'target', 'tools', 'transportDigest']; - if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) || - typeof value.definitionDigest !== 'string' || typeof value.name !== 'string' || typeof value.serverDigest !== 'string' || - typeof value.target !== 'string' || typeof value.transportDigest !== 'string' || - !Array.isArray(value.resources) || !value.resources.every(isJsonObject) || !Array.isArray(value.tools) || !value.tools.every(isJsonObject)) { - throw new TypeError('Runtime generation server descriptor is malformed.'); - } - return Object.freeze({ - definitionDigest: value.definitionDigest, - name: value.name, - resources: Object.freeze(value.resources.map((resource) => freezeJson(resource) as JsonObject)), - serverDigest: value.serverDigest, - target: value.target, - tools: Object.freeze(value.tools.map((tool) => freezeJson(tool) as JsonObject)), - transportDigest: value.transportDigest, - }); - }); - - const decodedSurfaceAssets: Record = {}; - for (const [surfaceId, value] of Object.entries(surfaceAssets)) { - if (surfaceId.length === 0 || !Array.isArray(value)) throw new TypeError('Runtime generation surface assets are malformed.'); - decodedSurfaceAssets[surfaceId] = Object.freeze(value.map((value): RscRuntimeSurfaceAsset => { - if (!isJsonObject(value)) throw new TypeError('Runtime generation surface asset is malformed.'); - const fields = ['bytes', 'contentType', 'generationPath', 'requestPath', 'sha256']; - if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) || - typeof value.bytes !== 'number' || !Number.isSafeInteger(value.bytes) || value.bytes < 0 || typeof value.generationPath !== 'string' || - typeof value.requestPath !== 'string' || typeof value.sha256 !== 'string' || !sha256Expression.test(value.sha256) || - (value.contentType !== 'application/javascript' && value.contentType !== 'application/json' && value.contentType !== 'text/css' && value.contentType !== 'text/html')) { - throw new TypeError('Runtime generation surface asset is malformed.'); - } - return Object.freeze({ - bytes: value.bytes, - contentType: value.contentType, - generationPath: assertRelativeAssetPath(value.generationPath), - requestPath: value.requestPath, - sha256: value.sha256, - }); - })); - } - return Object.freeze({ - appDefinitions: Object.freeze(decodedAppDefinitions), - definitionDigest, - entries: Object.freeze(Object.fromEntries(requiredEntries.map((entry) => [entry, entries[entry] as string]))), - environmentHashes: Object.freeze({ rsc: environmentHashes.rsc, widget: environmentHashes.widget }), - preparedRevision, - serverDigest, - servers: Object.freeze(decodedServers), - stateStoreId, - surfaceAssets: Object.freeze(decodedSurfaceAssets), - transportDigest, - }); -}; - -export const rscRuntimeGenerationMetadataCodec: RuntimeGenerationMetadataCodec = Object.freeze({ - decode: decodeMetadata, - encode: (value: RscRuntimeGenerationMetadata) => freezeJson(value), -}); - -export const validateRscRuntimeGenerationMetadata = async ( - input: RuntimeGenerationValidationInput, -): Promise => { - const metadata = decodeMetadata(freezeJson(input.metadata)); - const assets = new Map(input.assets.map((asset) => [asset.path, asset])); - for (const environment of ['rsc', 'widget'] as const) { - if (!input.assets.some((asset) => asset.path.startsWith(`${environment}/`))) { - throw new TypeError(`Runtime generation is missing the ${environment} environment.`); - } - } - for (const entry of requiredEntries) { - const path = metadata.entries[entry]; - if (typeof path !== 'string' || !assets.has(path)) throw new TypeError(`Runtime generation is missing required entry ${JSON.stringify(entry)}.`); - } - if (!assets.has(definitionFile) || !assets.has(runtimeAssetsFile)) { - throw new TypeError('Runtime generation is missing captured definition assets.'); - } - const runtimeAssets = await parseRuntimeAssets(join(input.root, 'rsc')); - validateRuntimeAssetCoverage(runtimeAssets, input.assets); - const definitionBytes = await readFile(join(input.root, ...definitionFile.split('/'))); - const definition = parseDefinition(JSON.parse(definitionBytes.toString('utf8'))); - if (canonicalJson(definition) !== definitionBytes.toString('utf8') || - runtimeDefinitionDigest(definition, Object.freeze({ apps: metadata.appDefinitions })) !== metadata.definitionDigest) { - throw new TypeError('Runtime generation definition digest is inconsistent.'); - } - await validateClientReferenceRelationship(input.root, input.assets); - const expectedEnvironmentHashes = Object.freeze({ - rsc: digestValue(input.assets.filter((asset) => asset.path.startsWith('rsc/'))), - widget: digestValue(input.assets.filter((asset) => asset.path.startsWith('widget/'))), - }); - if (metadata.environmentHashes.rsc !== expectedEnvironmentHashes.rsc || metadata.environmentHashes.widget !== expectedEnvironmentHashes.widget || - metadata.serverDigest !== digestValue(expectedEnvironmentHashes)) { - throw new TypeError('Runtime generation implementation digest is inconsistent.'); - } - const declaredSurfaceAssets = metadata.surfaceAssets as Readonly>; - for (const [surface, descriptors] of Object.entries(declaredSurfaceAssets)) { - const requestPaths = new Set(); - for (const asset of descriptors) { - if (requestPaths.has(asset.requestPath) || assets.get(asset.generationPath)?.sha256 !== asset.sha256 || assets.get(asset.generationPath)?.bytes !== asset.bytes || contentTypeFor(asset.generationPath) !== asset.contentType) { - throw new TypeError(`Runtime generation surface ${JSON.stringify(surface)} is invalid.`); - } - requestPaths.add(asset.requestPath); - } - } - validateAppSurfaceAssets(metadata.appDefinitions, declaredSurfaceAssets); - for (const descriptor of metadata.servers) { - if (descriptor.definitionDigest !== metadata.definitionDigest || descriptor.serverDigest !== metadata.serverDigest || descriptor.transportDigest !== metadata.transportDigest) { - throw new TypeError('Runtime generation server descriptor digest is inconsistent.'); - } - } - return metadata; -}; - -export const materializeRuntimeGeneration = async ( - input: MaterializeRuntimeGenerationOptions, -): Promise> => { - try { - const assets = await walkRegularFiles(input.snapshot.candidate.root); - if (!equalAssets(input.snapshot.assets, assets)) { - throw new Error('Runtime generation candidate no longer matches its captured cohort.'); - } - const metadata = await metadataFromSnapshot(input.snapshot, assets, input.stateStoreId ?? 'playground'); - const manifest: RuntimeGenerationManifestInput = Object.freeze({ assets, metadata }); - return await input.store.prepare(input.snapshot.candidate, manifest, input.guard === undefined ? {} : { guard: input.guard }); - } catch (error) { - await input.store.fail(input.snapshot.candidate).catch(() => undefined); - throw error; - } -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts deleted file mode 100644 index 199d7b13d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/inspection-security.ts +++ /dev/null @@ -1,29 +0,0 @@ -const diagnosticPreviewBytes = 16 * 1024; - -const sensitiveKey = /(?:api[-_]?key|authorization|bearer|credential|cookie|password|secret|token)/iu; -const sensitiveLabel = /((?:api[-_]?key|authorization|credential|cookie|password|secret|token)\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/giu; -const providerCredentialSources = Object.freeze([ - String.raw`\bsk-(?:proj-|ant-|live-)?[a-z0-9_-]{16,}\b`, - String.raw`\b(?:gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{16,}|akia[a-z0-9]{16})\b`, -]); -const providerCredentialValues = Object.freeze(providerCredentialSources.map((source) => new RegExp(source, 'iu'))); -const providerCredentialDiagnostics = Object.freeze(providerCredentialSources.map((source) => new RegExp(source, 'giu'))); -const credentialAssignment = /(?:api[-_]?key|authorization|credential|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/iu; -const bearerCredential = /\bbearer\s+[^\s,;]+/iu; -const bearerCredentialDiagnostic = new RegExp(bearerCredential.source, 'giu'); - -export const isInspectionSensitiveKey = (key: string): boolean => sensitiveKey.test(key); - -export const hasInspectionCredential = (value: string): boolean => - credentialAssignment.test(value) - || bearerCredential.test(value) - || providerCredentialValues.some((pattern) => pattern.test(value)); - -export const redactInspectionDiagnostics = (value: string): string => { - let redacted = value - .slice(0, diagnosticPreviewBytes) - .replace(sensitiveLabel, '$1[redacted]') - .replace(bearerCredentialDiagnostic, 'Bearer [redacted]'); - for (const pattern of providerCredentialDiagnostics) redacted = redacted.replace(pattern, '[redacted]'); - return redacted; -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts deleted file mode 100644 index b7741471d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/invocation-worker.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { requestFlightRenderWithFlight } from '../flight/request-render.js'; -import { writeSync } from 'node:fs'; -import { lowerHookResult, lowerMcpResult } from '@agent-bundle/rsc-runtime'; -import type { - DevRuntimeInspectionRequest, - DevRuntimeInspectionResponse, - EditEvent, - RenderRequest, - RuntimeSnapshot, -} from '../runtime/contracts.js'; -import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js'; - -import { hasInspectionCredential, isInspectionSensitiveKey } from './inspection-security.js'; -import { serializeInspection } from './serialize-inspection.js'; - -const maximumInvocationRequestBytes = 1024 * 1024; -const maximumInvocationFlightBytes = 4 * 1024 * 1024; -const maximumInvocationResponseBytes = 4 * 1024 * 1024; - -const asRecord = (value: unknown): Record | undefined => - value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; - -const readRequiredString = (value: Record, key: string): string => { - const item = value[key]; - if (typeof item !== 'string' || item.trim() === '') throw new Error(`Invocation request requires ${key}`); - return item; -}; - -const assertExactKeys = (value: Record, keys: readonly string[]): void => { - const unexpected = Object.keys(value).filter((key) => !keys.includes(key)); - if (unexpected.length > 0) throw new Error('Invocation request contains unsupported fields'); - const missing = keys.filter((key) => !(key in value)); - if (missing.length > 0) throw new Error('Invocation request is missing required fields'); -}; - -const assertNoSnapshotCredentials = (value: Record): void => { - for (const [key, item] of Object.entries(value)) { - if (isInspectionSensitiveKey(key) || (typeof item === 'string' && hasInspectionCredential(item))) { - throw new Error('Runtime snapshot contains sensitive data'); - } - } -}; - -const parseEdit = (value: unknown): EditEvent => { - const event = asRecord(value); - if (event === undefined) throw new Error('Runtime snapshot contains an invalid edit'); - assertNoSnapshotCredentials(event); - assertExactKeys(event, ['eventId', 'host', 'path', 'recordedAt', 'sessionId', 'toolName']); - const host = readRequiredString(event, 'host'); - if (host !== 'claude' && host !== 'codex') throw new Error('Runtime snapshot contains an invalid edit host'); - return { - eventId: readRequiredString(event, 'eventId'), - host, - path: readRequiredString(event, 'path'), - recordedAt: readRequiredString(event, 'recordedAt'), - sessionId: readRequiredString(event, 'sessionId'), - toolName: readRequiredString(event, 'toolName'), - }; -}; - -const parseSnapshot = (value: unknown): RuntimeSnapshot => { - const snapshot = asRecord(value); - const stateVersion = snapshot?.stateVersion; - if ( - snapshot === undefined || - typeof stateVersion !== 'number' || - !Number.isSafeInteger(stateVersion) || - stateVersion < 0 - ) { - throw new Error('Invocation request requires a valid runtime snapshot'); - } - assertNoSnapshotCredentials(snapshot); - const seed = snapshot.seed; - assertExactKeys(snapshot, seed === undefined ? ['edits', 'stateVersion'] : ['edits', 'seed', 'stateVersion']); - if (!Array.isArray(snapshot.edits)) throw new Error('Invocation request requires a valid runtime snapshot'); - return seed === undefined - ? { edits: snapshot.edits.map(parseEdit), stateVersion } - : { edits: snapshot.edits.map(parseEdit), seed: seed as RuntimeSnapshot['seed'], stateVersion }; -}; - -const parseRequest = (value: unknown): DevRuntimeInspectionRequest => { - const request = asRecord(value); - if (request === undefined) throw new Error('Invocation request must be a JSON object'); - - const type = readRequiredString(request, 'type'); - if (type === 'hook/after-file-edit') { - assertExactKeys(request, ['host', 'input', 'stateFile', 'stateStoreId', 'type']); - const host = readRequiredString(request, 'host'); - if (host !== 'claude' && host !== 'codex') throw new Error('Hook invocation host must be claude or codex'); - const input = asRecord(request.input); - if (input === undefined) throw new Error('Hook invocation requires an object input'); - return { - host, - input, - stateFile: readRequiredString(request, 'stateFile'), - stateStoreId: readRequiredString(request, 'stateStoreId'), - type, - }; - } - - if (type === 'mcp/render-timeline') { - assertExactKeys(request, ['snapshot', 'stateFile', 'stateStoreId', 'type']); - return { - snapshot: parseSnapshot(request.snapshot), - stateFile: readRequiredString(request, 'stateFile'), - stateStoreId: readRequiredString(request, 'stateStoreId'), - type, - }; - } - - if (type === 'mcp/runtime-status') { - assertExactKeys(request, ['stateFile', 'stateStoreId', 'type']); - return { - stateFile: readRequiredString(request, 'stateFile'), - stateStoreId: readRequiredString(request, 'stateStoreId'), - type, - }; - } - - throw new Error(`Unsupported invocation request type: ${type}`); -}; - -const readRequest = async (): Promise => { - const chunks: Buffer[] = []; - let bytes = 0; - for await (const chunk of process.stdin) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - bytes += buffer.byteLength; - if (bytes > maximumInvocationRequestBytes) { - throw new Error(`Invocation request exceeded ${maximumInvocationRequestBytes} bytes`); - } - chunks.push(buffer); - } - if (bytes === 0) throw new Error('Invocation request must not be empty'); - - let decoded: string; - try { - decoded = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); - } catch { - throw new Error('Invocation request must be valid UTF-8 JSON'); - } - return parseRequest(JSON.parse(decoded)); -}; - -const renderRequestFor = (request: DevRuntimeInspectionRequest): RenderRequest => { - if (request.type === 'hook/after-file-edit') { - return { - event: request.host === 'claude' ? normalizeClaudeHook(request.input) : normalizeCodexHook(request.input), - stateFile: request.stateFile, - type: request.type, - }; - } - if (request.type === 'mcp/render-timeline') { - return { snapshot: request.snapshot, stateFile: request.stateFile, type: request.type }; - } - return { stateFile: request.stateFile, type: request.type }; -}; - -interface InvocationOutput { - readonly flight: Buffer; - readonly response: DevRuntimeInspectionResponse; -} - -const invoke = async (signal?: AbortSignal): Promise => { - const request = await readRequest(); - const rendered = await requestFlightRenderWithFlight(renderRequestFor(request), { - maximumFlightBytes: maximumInvocationFlightBytes, - signal, - }); - - if (request.type === 'hook/after-file-edit') { - const native = lowerHookResult(rendered.node); - return Object.freeze({ - flight: Buffer.from(rendered.flight), - response: Object.freeze({ - flightBytes: rendered.flight.byteLength, - inspection: serializeInspection({ - agentVisible: native.hookSpecificOutput.additionalContext, - flight: rendered.flight, - native, - node: rendered.node, - stateStoreId: request.stateStoreId, - stateVersion: rendered.stateVersion, - }), - }), - }); - } - - const protocol = lowerMcpResult(rendered.node); - return Object.freeze({ - flight: Buffer.from(rendered.flight), - response: Object.freeze({ - flightBytes: rendered.flight.byteLength, - inspection: serializeInspection({ - flight: rendered.flight, - modelVisible: protocol.content, - node: rendered.node, - protocol, - stateStoreId: request.stateStoreId, - stateVersion: rendered.stateVersion, - }), - }), - }); -}; - -const controller = new AbortController(); -const abort = (): void => controller.abort(); -process.once('SIGINT', abort); -process.once('SIGTERM', abort); - -const writeFlight = (flight: Buffer): void => { - let offset = 0; - while (offset < flight.byteLength) { - offset += writeSync(3, flight, offset, flight.byteLength - offset); - } -}; - -const writeResponse = ({ flight, response }: InvocationOutput): void => { - writeFlight(flight); - const line = `${JSON.stringify(response)}\n`; - if (Buffer.byteLength(line, 'utf8') > maximumInvocationResponseBytes) { - throw new Error('Inspection response exceeded output limit'); - } - process.stdout.write(line); -}; - -const reportFailure = (error: unknown): void => { - const message = error instanceof Error ? error.message : 'Invocation failed'; - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}; - -void invoke(controller.signal).then(writeResponse).catch(reportFailure).finally(() => { - process.removeListener('SIGINT', abort); - process.removeListener('SIGTERM', abort); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts deleted file mode 100644 index d45d7fc69..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/provider.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { DevRuntimeProvider, DevRuntimeStartContext } from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; - -import { RsbuildRuntimeSession } from './rsbuild-runtime-session.js'; - -export const createDevRuntimeProvider = (): DevRuntimeProvider => Object.freeze({ - descriptor: Object.freeze({ - environmentVariables: Object.freeze([]), - id: 'rsc-agent-runtime', - label: 'RSC agent runtime', - schemaVersion: 1, - }), - start: async (context: DevRuntimeStartContext) => RsbuildRuntimeSession.start(context), -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts deleted file mode 100644 index e7cc886c2..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ /dev/null @@ -1,2830 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { constants } from 'node:fs'; -import { lstat, mkdir, open, readFile, realpath, rm, writeFile, type FileHandle } from 'node:fs/promises'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; - -import { createRsbuild, type StartDevServerResult } from '@rsbuild/core'; - -import { - createRscRuntimeRsbuildConfig, - type RscRuntimeCompileFailureKind, - type RscRuntimeCompileSnapshot, -} from '../../rsbuild.config.js'; -import { - captureRuntimeGenerationSnapshot, - createRscCompilerAssetCheckpointTracker, - materializeRuntimeGeneration, - rscRuntimeGenerationMetadataCodec, - runtimeDefinitionDigest, - validateRscRuntimeGenerationMetadata, - type RscCompilerAssetCheckpointTracker, - type RscRuntimeCapturedGenerationSnapshot, -} from './generation-materializer.js'; -import type { - RscRuntimeGenerationMetadata, - RuntimeSnapshot, - SerializedRuntimeDefinition, -} from '../runtime/contracts.js'; -import { createFileRuntimeKernel } from '../runtime/state-file.js'; -import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js'; -import { - hasInspectionCredential, - isInspectionSensitiveKey, - redactInspectionDiagnostics, -} from './inspection-security.js'; -import { - RuntimeGenerationStore, - type RuntimeGeneration, - type RuntimeGenerationActivationGuard, - type RuntimeGenerationCandidate, - type RuntimeGenerationPreparedActivation, -} from '../../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; -import { - RuntimeMcpRegistry, - type RuntimeMcpConnection, - type RuntimeMcpConnector, - type RuntimeMcpExecutionContext, - type RuntimeMcpPreparedActivationReconcile, -} from '../../../../packages/agent-bundle/src/dev/runtime-mcp-registry.ts'; -import { - DevRuntimeGenerationConflictError, - DevRuntimeUnavailableError, - type DevRuntimeClientSurfaceEndpoint, - type DevRuntimeEventInput, - type DevRuntimeMcpSession, - type DevRuntimeMcpSessionCloseObservation, - type DevRuntimePreparedProject, - type DevRuntimeSession, - type DevRuntimeStartContext, -} from '../../../../packages/agent-bundle/src/dev/runtime-provider.ts'; -import { - type DevRuntimeAsset, - type DevRuntimeAssetRequest, - type DevRuntimeDescriptor, - type DevRuntimeDiagnostic, - type DevRuntimeFixture, - type DevRuntimeInspectionEnvelope, - type DevRuntimeInvocationRequest, - type DevRuntimeMcpConnectionState, - type DevRuntimeMcpRegistryReconcileInput, - type DevRuntimeMcpSessionBinding, - type DevRuntimeReplayRequest, - type DevRuntimeRun, - type DevRuntimeStateIdentity, - type DevRuntimeStateResetRequest, - type DevRuntimeStatus, - type DevRuntimeSurface, - type RuntimeVector, -} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; -import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; - -const descriptor: DevRuntimeDescriptor = Object.freeze({ - environmentVariables: Object.freeze([]), - id: 'rsc-agent-runtime', - label: 'RSC agent runtime', - schemaVersion: 1, -}); -const clientSurfaceId = 'mcp.edit-timeline'; -const clientSurfaceEntry = '/edit-timeline-v1.html'; -const maximumAssetBytes = 8 * 1024 * 1024; -const stateStoreId = 'playground'; -const maximumInvocationWorkers = 4; -const maximumInvocationStdoutBytes = 4 * 1024 * 1024; -const maximumInvocationFlightBytes = 4 * 1024 * 1024; -const maximumInvocationStderrBytes = 256 * 1024; -const maximumRunHistory = 50; -const invocationTimeoutMs = 10_000; -const invocationTerminationGraceMs = 100; -const flightPreviewBytes = 32 * 1024; -const windowsJobOwnerPhaseDeadlineMs = 2_000; -const noFixtures: readonly DevRuntimeFixture[] = Object.freeze([]); -const claudePostToolUseFixture: DevRuntimeFixture = Object.freeze({ - id: 'claude-post-tool-use-write', - label: 'Claude PostToolUse Write', - seed: Object.freeze({ - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'fixture-claude-post-tool-use', - tool_input: Object.freeze({ file_path: 'fixture-claude-post-tool-use.txt' }), - tool_name: 'Write', - tool_use_id: 'fixture-claude-post-tool-use-write', - }), -}); -const claudeFixtures: readonly DevRuntimeFixture[] = Object.freeze([claudePostToolUseFixture]); -const fixturesForHook = (host: 'claude' | 'codex'): readonly DevRuntimeFixture[] => host === 'claude' ? claudeFixtures : noFixtures; - -const withinDeadline = (promise: Promise, timeoutMs: number, message: string): Promise => - new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); - void promise.then( - (value) => { - clearTimeout(timeout); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timeout); - reject(error); - }, - ); - }); - -// The wrapper is a normal Node child, so its inherited fd 3 remains a libuv -// Flight pipe. It imports the generation entry only after the Job owner -// assigns it to a kill-on-close Job Object and the provider writes GO to fd 4. -const windowsInvocationWrapperSource = String.raw` -const { createReadStream } = require('node:fs'); -const { pathToFileURL } = require('node:url'); -const entry = process.argv[1]; -const control = createReadStream(null, { autoClose: false, fd: 4, encoding: 'utf8' }); -let token = ''; -const fail = (message) => { process.stderr.write(message + '\n'); process.exitCode = 1; }; -control.on('data', (chunk) => { - token += chunk; - if (token === 'GO\n') { - control.destroy(); - void import(pathToFileURL(entry).href).catch((error) => fail(error instanceof Error ? error.stack ?? error.message : String(error))); - } else if (token.length > 3 || !'GO\n'.startsWith(token)) { - fail('RSC invocation Windows wrapper received an invalid control token.'); - control.destroy(); - } -}); -control.once('end', () => { if (token !== 'GO\n') fail('RSC invocation Windows wrapper never received a control token.'); }); -control.once('error', () => fail('RSC invocation Windows wrapper control stream failed.')); -`; - -// The owner is intentionally not a child of the Job Object. It owns the only -// job handle, confirms assignment before READY, and tears down/polls the -// whole tree before returning after the wrapper exits. -const windowsJobOwnerSource = String.raw` -$typeDefinition = @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -using System.Threading; -public static class AgentBundleWindowsJobOwner { - const uint A=1,J=9,K=0x2000,I=0xffffffff,Access=0x00100101; - [StructLayout(LayoutKind.Sequential)] struct BL { public long a,b; public uint flags; public UIntPtr c,d; public uint e; public UIntPtr f; public uint g,h; } - [StructLayout(LayoutKind.Sequential)] struct IO { public ulong a,b,c,d,e,f; } - [StructLayout(LayoutKind.Sequential)] struct EL { public BL b; public IO i; public UIntPtr p,j,pp,pj; } - [StructLayout(LayoutKind.Sequential)] struct BA { public long a,b,c,d; public uint e,f,g,h; } - [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr a,string b); - [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint a,bool b,int c); - [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetInformationJobObject(IntPtr a,uint b,IntPtr c,uint d); - [DllImport("kernel32.dll",SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr a,uint b,IntPtr c,uint d,IntPtr e); - [DllImport("kernel32.dll",SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr a,IntPtr b); - [DllImport("kernel32.dll",SetLastError=true)] static extern bool TerminateJobObject(IntPtr a,uint b); - [DllImport("kernel32.dll",SetLastError=true)] static extern uint WaitForSingleObject(IntPtr a,uint b); - [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr a); - static void Ok(bool value) { if(!value) throw new Win32Exception(Marshal.GetLastWin32Error()); } - static void Stop(IntPtr job) { IntPtr accounting=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(BA))); try { Ok(TerminateJobObject(job,0)); for(int attempt=0;attempt<1000;attempt++) { Ok(QueryInformationJobObject(job,A,accounting,(uint)Marshal.SizeOf(typeof(BA)),IntPtr.Zero)); if(((BA)Marshal.PtrToStructure(accounting,typeof(BA))).g==0) return; Thread.Sleep(10); } throw new TimeoutException("Windows Job Object did not terminate every descendant."); } finally { Marshal.FreeHGlobal(accounting); } } - static void Drained() { Console.Out.WriteLine("DRAINED"); Console.Out.Flush(); } - public static int Own(int pid,string mode) { IntPtr job=IntPtr.Zero,process=IntPtr.Zero,info=IntPtr.Zero; bool assigned=false,drained=false; try { - if(mode=="hang-ready") { Thread.Sleep(60000); return 1; } - job=CreateJobObject(IntPtr.Zero,null); if(job==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); - EL limits=new EL(); limits.b.flags=K; info=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(EL))); Marshal.StructureToPtr(limits,info,false); Ok(SetInformationJobObject(job,J,info,(uint)Marshal.SizeOf(typeof(EL)))); - process=OpenProcess(Access,false,pid); if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); Ok(AssignProcessToJobObject(job,process)); assigned=true; - Console.Out.WriteLine("READY"); Console.Out.Flush(); if(mode=="close-control") { Console.In.Close(); while(true) Thread.Sleep(1000); } if(mode=="ignore-stop") { while(true) Thread.Sleep(1000); } ManualResetEvent stop=new ManualResetEvent(false); Thread control=new Thread(() => { try { Console.In.ReadLine(); } finally { stop.Set(); } }); control.IsBackground=true; control.Start(); while(true) { uint result=WaitForSingleObject(process,20); if(result==0) break; if(result==I) throw new Win32Exception(Marshal.GetLastWin32Error()); if(stop.WaitOne(0)) break; } Stop(job); Drained(); drained=true; if(mode=="nonzero-after-drain") throw new InvalidOperationException("Windows Job owner test failure after drain."); return 0; - } catch(Exception error) { if(job!=IntPtr.Zero && assigned && !drained) { try { Stop(job); Drained(); drained=true; } catch(Exception drainError) { throw new AggregateException(error,drainError); } } else if(job!=IntPtr.Zero && !assigned) TerminateJobObject(job,1); throw; } finally { if(info!=IntPtr.Zero) Marshal.FreeHGlobal(info); if(process!=IntPtr.Zero) CloseHandle(process); if(job!=IntPtr.Zero) CloseHandle(job); } } -} -'@ -Add-Type -TypeDefinition $typeDefinition -ErrorAction Stop -exit [AgentBundleWindowsJobOwner]::Own([int]$args[0], [string]$args[1]) -`; - - -interface InvocationWorker { - readonly done: Promise; - terminate(reason: Error): void; -} - -interface RuntimeAppBroker { - closedObservation: DevRuntimeMcpSessionCloseObservation | undefined; - opening: Promise | undefined; - session: DevRuntimeMcpSession | undefined; -} - -interface RuntimeAppLink { - readonly descriptor: DevRuntimeMcpRegistryReconcileInput['servers'][number]; - readonly key: string; - readonly resourceUri: string; - readonly surfaceId: string; -} - -interface WindowsJobOwner { - readonly closed: Promise; - readonly done: Promise; - readonly drained: Promise; - readonly ready: Promise; - isAssigned(): boolean; - isClosed(): boolean; - forceTerminate(): void; - terminate(): void; -} - -interface OwnedRunsRoot { - readonly dev: number; - readonly ino: number; - readonly marker: string; - readonly root: string; - readonly token: string; -} - -interface RunArtifact { - readonly file: FileHandle; - readonly runId: string; - dev?: number; - digest?: string; - ino?: number; - size?: number; -} - -type LiveSessionCleanupResource = - | 'generation-store' - | 'owned-runs-root' - | 'rsbuild-dev-server' - | 'run-artifact' - | 'runtime-mcp-registry'; - -interface LabeledCleanupFailure { - readonly error: unknown; - readonly label: string; -} - -interface ValidatedInvocation { - readonly fixtureId?: string; - readonly input: JsonValue; - readonly request: DevRuntimeInvocationRequest; - readonly surface: DevRuntimeSurface; -} - -interface AttemptBarrier { - readonly id: string; - readonly sequence: number; - candidate: RuntimeGenerationCandidate | undefined; - readonly settled: Promise; - settle(): void; -} - -export class ResourceLedger { - readonly #closers: Array Promise; readonly label: string }>> = []; - readonly #failures: Array> = []; - readonly #running = new Set>(); - #closed = false; - #closePromise: Promise | undefined; - - add(close: () => Promise, label = 'resource'): Promise | undefined { - const resourceLabel = /^[a-z0-9-]{1,64}$/u.test(label) ? label : 'resource'; - if (!this.#closed) { - this.#closers.push(Object.freeze({ close, label: resourceLabel })); - return undefined; - } - return this.#run(close, resourceLabel); - } - - failures(): readonly Readonly<{ readonly error: unknown; readonly label: string }>[] { - return Object.freeze([...this.#failures]); - } - - #run(close: () => Promise, label = 'resource'): Promise { - const task = Promise.resolve().then(close); - this.#running.add(task); - void task.then( - () => undefined, - (error: unknown) => { this.#failures.push(Object.freeze({ error, label })); }, - ).finally(() => { this.#running.delete(task); }); - return task; - } - - async #drain(): Promise { - while (this.#closers.length > 0) { - const closer = this.#closers.shift()!; - this.#run(closer.close, closer.label); - } - while (this.#running.size > 0) { - await Promise.allSettled([...this.#running]); - while (this.#closers.length > 0) { - const closer = this.#closers.shift()!; - this.#run(closer.close, closer.label); - } - } - if (this.#failures.length > 0) { - throw new AggregateError(this.#failures.map((failure) => failure.error), 'RSC runtime startup cleanup failed.'); - } - } - - close(): Promise { - if (this.#closePromise !== undefined) return this.#closePromise; - this.#closed = true; - this.#closePromise = this.#drain(); - return this.#closePromise; - } -} - -const cleanupAggregate = ( - message: string, - failures: readonly LabeledCleanupFailure[], - cause?: unknown, -): AggregateError => { - const labels = [...new Set(failures.map((failure) => failure.label))].sort(); - return new AggregateError( - failures.map((failure) => failure.error), - `${message}; cleanup failures: ${labels.join(', ')}.`, - cause === undefined ? undefined : { cause }, - ); -}; - -const isInside = (root: string, path: string): boolean => { - const relativePath = relative(resolve(root), resolve(path)); - return relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); -}; - -const safeSegment = (value: string): boolean => - value.length > 0 && value !== '.' && value !== '..' && - !value.includes('/') && !value.includes('\\') && !value.includes('\0') && !value.includes('%'); - -const cloneJson = (value: unknown, ancestors = new WeakSet()): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime invocation input must contain only finite JSON numbers.'); - return value; - } - if (typeof value !== 'object' || ancestors.has(value)) { - throw new TypeError('Runtime invocation input must be an acyclic JSON value.'); - } - ancestors.add(value); - try { - if (Array.isArray(value)) return Object.freeze(value.map((item) => cloneJson(item, ancestors))); - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { - throw new TypeError('Runtime invocation input must contain only plain JSON objects.'); - } - const result: Record = {}; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new TypeError('Runtime invocation input cannot contain symbol keys.'); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { - throw new TypeError('Runtime invocation input cannot contain accessors or non-enumerable fields.'); - } - result[key] = cloneJson(descriptor.value, ancestors); - } - return Object.freeze(result); - } finally { - ancestors.delete(value); - } -}; - -const isJsonObject = (value: JsonValue): value is JsonObject => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const cloneJsonObject = (value: unknown): JsonObject => { - const cloned = cloneJson(value); - if (!isJsonObject(cloned)) { - throw new TypeError('Runtime surface input schema must be a JSON object.'); - } - return cloned; -}; - -const invocationDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.freeze({ - code: 'AB8203', - message: error instanceof Error ? redactInspectionDiagnostics(error.message) : 'RSC runtime invocation failed.', - phase: 'rsc-render', - severity: 'error', -}); - -const deepFreeze = (value: T, seen = new WeakSet()): T => { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) throw new TypeError('Runtime prepared configuration cannot contain cycles.'); - seen.add(value); - for (const key of Reflect.ownKeys(value)) { - const property = Object.getOwnPropertyDescriptor(value, key); - if (property !== undefined && 'value' in property) deepFreeze(property.value, seen); - } - seen.delete(value); - return Object.freeze(value); -}; - -const plainRecord = (value: unknown, message: string): Record => { - if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { - throw new Error(message); - } - return value as Record; -}; - -const assertExactKeys = (value: Record, keys: readonly string[], message: string): void => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) throw new Error(message); -}; - -const assertCredentialSafeJson = (value: unknown): void => { - if (value === null || typeof value === 'boolean' || typeof value === 'number') return; - if (typeof value === 'string') { - if (hasInspectionCredential(value)) throw new Error('RSC invocation worker inspection contains credentials.'); - return; - } - if (Array.isArray(value)) { - value.forEach(assertCredentialSafeJson); - return; - } - const record = plainRecord(value, 'RSC invocation worker inspection contains a non-JSON value.'); - for (const [key, item] of Object.entries(record)) { - if (isInspectionSensitiveKey(key)) throw new Error('RSC invocation worker inspection contains sensitive fields.'); - assertCredentialSafeJson(item); - } -}; - -const optionalExactKeys = (value: Record, required: readonly string[], optional: readonly string[], message: string): void => { - const keys = Object.keys(value); - if (keys.some((key) => !required.includes(key) && !optional.includes(key)) || required.some((key) => !(key in value))) { - throw new Error(message); - } -}; - -const validateTrace = (value: unknown): void => { - if (!Array.isArray(value)) throw new Error('RSC invocation worker trace is invalid.'); - for (const item of value) { - const span = plainRecord(item, 'RSC invocation worker trace is invalid.'); - optionalExactKeys(span, ['id', 'phase', 'startedAt', 'status'], ['details', 'durationMs', 'parentId'], 'RSC invocation worker trace is invalid.'); - if (typeof span.id !== 'string' || span.id.length === 0 || typeof span.phase !== 'string' || span.phase.length === 0 || - typeof span.startedAt !== 'string' || !['running', 'succeeded', 'failed'].includes(span.status as string) || - ('parentId' in span && (typeof span.parentId !== 'string' || span.parentId.length === 0)) || - ('durationMs' in span && (typeof span.durationMs !== 'number' || !Number.isFinite(span.durationMs) || span.durationMs < 0))) { - throw new Error('RSC invocation worker trace is invalid.'); - } - if ('details' in span) { - assertCredentialSafeJson(plainRecord(span.details, 'RSC invocation worker trace is invalid.')); - } - } -}; - -const validateTree = (value: unknown): void => { - if (!Array.isArray(value)) throw new Error('RSC invocation worker tree is invalid.'); - for (const item of value) { - const node = plainRecord(item, 'RSC invocation worker tree is invalid.'); - optionalExactKeys(node, ['children', 'id', 'kind', 'label'], ['props'], 'RSC invocation worker tree is invalid.'); - if (typeof node.id !== 'string' || node.id.length === 0 || typeof node.label !== 'string' || - !['component', 'element', 'text', 'value'].includes(node.kind as string)) { - throw new Error('RSC invocation worker tree is invalid.'); - } - if ('props' in node) { - assertCredentialSafeJson(plainRecord(node.props, 'RSC invocation worker tree is invalid.')); - } - validateTree(node.children); - } -}; - -const validateAppBinding = (value: unknown): void => { - const app = plainRecord(value, 'RSC invocation worker App binding is invalid.'); - assertExactKeys(app, ['mcpBinding', 'resourceUri', 'surfaceId'], 'RSC invocation worker App binding is invalid.'); - if (typeof app.resourceUri !== 'string' || app.resourceUri.length === 0 || typeof app.surfaceId !== 'string' || app.surfaceId.length === 0) { - throw new Error('RSC invocation worker App binding is invalid.'); - } - const binding = plainRecord(app.mcpBinding, 'RSC invocation worker App binding is invalid.'); - assertExactKeys(binding, ['definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest'], 'RSC invocation worker App binding is invalid.'); - if (typeof binding.definitionDigest !== 'string' || typeof binding.serverDigest !== 'string' || typeof binding.serverName !== 'string' || - typeof binding.sessionId !== 'string' || typeof binding.target !== 'string' || typeof binding.transportDigest !== 'string' || - !Number.isSafeInteger(binding.registryRevision) || !Number.isSafeInteger(binding.sessionRevision)) { - throw new Error('RSC invocation worker App binding is invalid.'); - } -}; - -const clonePrepared = (prepared: DevRuntimePreparedProject): DevRuntimePreparedProject => - deepFreeze(structuredClone(prepared)); - -const canonicalJson = (value: unknown): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - const record = value as Record; - return `{${Object.keys(record).sort().flatMap((key) => { - const item = record[key]; - return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; - }).join(',')}}`; -}; - -const digestValue = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex'); - -const transportDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ - provider: prepared.provider, - servers: prepared.servers.map((server) => ({ - args: server.args === undefined ? undefined : [...server.args], - command: server.command, - cwd: server.cwd, - env: server.env === undefined ? undefined : Object.fromEntries(Object.entries(server.env) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, digestValue(value)])), - headers: server.headers === undefined ? undefined : Object.fromEntries(Object.entries(server.headers) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, digestValue(value)])), - id: server.id, - name: server.name, - source: server.source, - targets: [...server.targets], - transport: server.transport, - url: server.url, - })), -}); - -const preparedRuntimeAuthorityDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ - apps: prepared.apps, - provider: prepared.provider, - servers: prepared.servers, -}); - -const asJsonObject = (value: unknown): JsonObject => value as JsonObject; - -const descriptorsFor = ( - prepared: DevRuntimePreparedProject, - metadata: RscRuntimeGenerationMetadata, - definitionDigest: string, - nextTransportDigest: string, -) => { - const template = metadata.servers[0]; - if (template === undefined) throw new Error('The active runtime generation has no MCP server descriptor.'); - return Object.freeze(prepared.servers.flatMap((server) => server.targets.map((target) => Object.freeze({ - definitionDigest, - name: server.name, - resources: Object.freeze(template.resources.map(asJsonObject)), - serverDigest: metadata.serverDigest, - target, - tools: Object.freeze(template.tools.map(asJsonObject)), - transportDigest: nextTransportDigest, - })))); -}; - -const lifecycleDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.freeze({ - code: 'AB8200', - message: error instanceof Error ? error.message : 'RSC runtime provider failed.', - phase: 'provider-lifecycle', - severity: 'error', -}); - -const sourceBuildDiagnostic = (): DevRuntimeDiagnostic => Object.freeze({ - code: 'AB8206', - message: 'RSC runtime source build failed.', - phase: 'source/build', - severity: 'error', -}); - -const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('RSC runtime provider startup was aborted.'); -const hmrToken = /^[A-Za-z0-9_-]{16,128}$/u; - -export interface RsbuildRuntimeSessionStartTesting { - readonly createRsbuild?: typeof createRsbuild; - /** Test-only startup resource seams; never used by the public provider. */ - readonly afterOwnedRunsRootCreated?: () => Promise | void; - readonly beforeOwnedRunsRootCleanup?: () => Promise | void; - readonly onStartupCleanupClosed?: () => void; - readonly beforeGenerationCapture?: () => Promise | void; - readonly afterActivationPrepare?: (input: Readonly<{ - readonly phase: 'store' | 'registry'; - readonly session: RsbuildRuntimeSession; - }>) => Promise | void; - readonly beforeAssetRead?: (input: Readonly<{ - readonly request: DevRuntimeAssetRequest; - readonly runtimeGenerationId: string; - }>) => Promise | void; - readonly beforeMcpRelist?: () => Promise | void; - readonly afterInvocationWorkerResponse?: (input: Readonly<{ - readonly runId: string; - readonly surfaceId: string; - }>) => Promise | void; - /** Test-only live-session cleanup seams; never used by the public provider. */ - readonly beforeRunArtifactRelease?: (input: Readonly<{ readonly runId: string }>) => Promise | void; - readonly afterRunArtifactEvictionReserved?: (input: Readonly<{ readonly runId: string }>) => Promise | void; - readonly beforeRunDirectoryRemoval?: (input: Readonly<{ readonly runId: string }>) => Promise | void; - readonly beforeRunFlightRead?: (input: Readonly<{ readonly runId: string }>) => Promise | void; - readonly afterLiveSessionCleanupResource?: (input: Readonly<{ - readonly resource: LiveSessionCleanupResource; - }>) => Promise | void; - /** Windows-only Job owner fault injection; never used by the public provider. */ - readonly windowsJobOwnerMode?: 'close-control' | 'hang-ready' | 'ignore-stop' | 'nonzero-after-drain' | 'normal'; -} - -/** - * One provider-owned compiler, generation store, and runtime MCP registry. - * The private compiler URL is exposed only through `clientSurface`. - */ -export class RsbuildRuntimeSession implements DevRuntimeSession { - readonly #checkpointTracker: RscCompilerAssetCheckpointTracker; - readonly #candidatesByAttempt = new Map(); - readonly #captureTasks = new Set>(); - readonly #context: DevRuntimeStartContext; - readonly #generationStore: RuntimeGenerationStore; - readonly #mcpRegistry: RuntimeMcpRegistry; - readonly #preparedRevisions = new Set(); - readonly #invocations = new Set>(); - readonly #invocationAbort = new AbortController(); - readonly #runReadTasks = new Map>>(); - readonly #runArtifacts = new Map(); - readonly #evictingTerminalRuns = new Set(); - readonly #pendingRunDirectoryRemovals = new Set(); - readonly #runRoot: string; - readonly #ownedRunsRoot: OwnedRunsRoot; - readonly #stateFile: string; - readonly #stateKernel: ReturnType; - readonly #activeRuns = new Map(); - readonly #appBrokers = new Map(); - readonly #terminalRuns = new Map(); - readonly #surfaceAssetApps = new Map(); - readonly #surfaces = new Map(); - readonly #testing: RsbuildRuntimeSessionStartTesting; - readonly #attempts = new Map(); - readonly #workers = new Map(); - readonly #failedAttempts = new Set(); - #active: RuntimeGeneration | undefined; - #appWebSocketToken: string | undefined; - #clientSurface: DevRuntimeClientSurfaceEndpoint | undefined; - #closePromise: Promise | undefined; - #closed = false; - #evictionTail: Promise = Promise.resolve(); - #generationSequence = 0; - #failureTail: Promise = Promise.resolve(); - #hmrReady = false; - #latestAttemptSequence = 0; - #latestSupersedingAttemptSequence = 0; - #latestPreparedRuntime: DevRuntimePreparedProject; - #latestRscCohortRevision = 0; - #invocationReservations = 0; - #providerTail: Promise = Promise.resolve(); - #server: StartDevServerResult['server'] | undefined; - #status: DevRuntimeStatus; - - private constructor(input: Readonly<{ - readonly checkpointTracker: RscCompilerAssetCheckpointTracker; - readonly context: DevRuntimeStartContext; - readonly generationStore: RuntimeGenerationStore; - readonly mcpRegistry: RuntimeMcpRegistry; - readonly ownedRunsRoot: OwnedRunsRoot; - readonly preparedRuntime: DevRuntimePreparedProject; - readonly testing: RsbuildRuntimeSessionStartTesting; - }>) { - this.#context = input.context; - this.#checkpointTracker = input.checkpointTracker; - this.#generationStore = input.generationStore; - this.#mcpRegistry = input.mcpRegistry; - this.#latestPreparedRuntime = input.preparedRuntime; - this.#testing = input.testing; - this.#ownedRunsRoot = input.ownedRunsRoot; - this.#runRoot = input.ownedRunsRoot.root; - this.#stateFile = join(resolve(input.context.storageRoot), 'state', `${stateStoreId}.jsonl`); - this.#stateKernel = createFileRuntimeKernel({ stateFile: this.#stateFile }); - this.#preparedRevisions.add(input.preparedRuntime.sourceRevision); - this.#status = Object.freeze({ - descriptor, - diagnostics: Object.freeze([]), - hmrReady: false, - state: 'starting', - }); - } - - static async start( - context: DevRuntimeStartContext, - testing: RsbuildRuntimeSessionStartTesting = {}, - ): Promise { - context.signal.throwIfAborted(); - const preparedRuntime = clonePrepared(context.preparedRuntime); - RsbuildRuntimeSession.#validateStartContext(context, preparedRuntime); - const ledger = new ResourceLedger(); - let startupCleanup: Promise | undefined; - const closeStartupLedger = (): Promise => { - if (startupCleanup !== undefined) return startupCleanup; - startupCleanup = ledger.close(); - const notifyClosed = (): void => { - try { - testing.onStartupCleanupClosed?.(); - } catch { - // Test observation cannot affect startup cleanup ownership. - } - }; - void startupCleanup.then(notifyClosed, notifyClosed); - return startupCleanup; - }; - let aborting = false; - const abort = (): void => { - aborting = true; - void closeStartupLedger().catch(() => undefined); - }; - context.signal.addEventListener('abort', abort, { once: true }); - - try { - context.signal.throwIfAborted(); - const storageRoot = resolve(context.storageRoot); - const generationStore = new RuntimeGenerationStore({ - metadataCodec: rscRuntimeGenerationMetadataCodec, - retainInactive: 5, - storageRoot: join(storageRoot, 'generation-store'), - validateMetadata: validateRscRuntimeGenerationMetadata, - }); - ledger.add(() => generationStore.close(), 'generation-store'); - const checkpointTracker = createRscCompilerAssetCheckpointTracker(); - ledger.add(async () => { checkpointTracker.close(); }, 'compiler-asset-checkpoints'); - await Promise.all([ - mkdir(join(storageRoot, 'compiler'), { recursive: true }), - mkdir(join(storageRoot, 'state'), { recursive: true }), - ]); - const ownedRunsRoot = await RsbuildRuntimeSession.#createOwnedRunsRoot(storageRoot, context.providerSessionId); - const closeOwnedRunsRoot = async (): Promise => { - await testing.beforeOwnedRunsRootCleanup?.(); - await RsbuildRuntimeSession.#removeOwnedRunsRoot(ownedRunsRoot); - }; - const afterOwnedRunsRootCreated = testing.afterOwnedRunsRootCreated; - if (afterOwnedRunsRootCreated === undefined) { - await ledger.add(closeOwnedRunsRoot, 'owned-runs-root'); - } else { - try { - await afterOwnedRunsRootCreated(); - } finally { - await ledger.add(closeOwnedRunsRoot, 'owned-runs-root'); - } - } - context.signal.throwIfAborted(); - - const connectionState: DevRuntimeMcpConnectionState = Object.freeze({ - capabilities: Object.freeze({ - resources: Object.freeze({}), - tools: Object.freeze({}), - }), - protocolEra: 'modern', - protocolVersion: '2025-06-18', - server: Object.freeze({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }), - }); - const sessionReference: { current: RsbuildRuntimeSession | undefined } = { current: undefined }; - const connector: RuntimeMcpConnector = Object.freeze({ - connect: async ({ signal }: Parameters[0]) => { - signal.throwIfAborted(); - const connection: RuntimeMcpConnection = Object.freeze({ - close: async () => undefined, - relist: async () => { - signal.throwIfAborted(); - await testing.beforeMcpRelist?.(); - signal.throwIfAborted(); - return connectionState; - }, - state: connectionState, - }); - return connection; - }, - }); - const mcpRegistry = new RuntimeMcpRegistry({ - artifactEpochId: () => undefined, - connector, - emit: (event) => { - const session = sessionReference.current; - if (session !== undefined) session.#emit(event); - }, - executor: async (execution) => { - const session = sessionReference.current; - if (session === undefined) throw new Error('RSC runtime session is unavailable.'); - return session.#executeMcp(execution); - }, - generationStore: generationStore as RuntimeGenerationStore, - providerSessionId: context.providerSessionId, - stateStoreId, - }); - ledger.add(() => mcpRegistry.close(), 'runtime-mcp-registry'); - const session = new RsbuildRuntimeSession({ - checkpointTracker, - context, - generationStore, - mcpRegistry, - ownedRunsRoot, - preparedRuntime, - testing, - }); - sessionReference.current = session; - context.signal.throwIfAborted(); - - const rsbuild = await (testing.createRsbuild ?? createRsbuild)({ - callerName: 'agent-bundle-rsc-runtime', - config: createRscRuntimeRsbuildConfig({ - compilerRoot: join(storageRoot, 'compiler'), - mode: 'development', - onAppWebSocketToken: (token) => session.#captureAppWebSocketToken(token), - onCompile: session.#compileObserver(), - }), - cwd: context.projectRoot, - }); - context.signal.throwIfAborted(); - const started = await rsbuild.startDevServer({ getPortSilently: true }); - await ledger.add(() => started.server.close(), 'rsbuild-dev-server'); - context.signal.throwIfAborted(); - session.#attachServer(started, rsbuild.context.devServer); - await session.#providerTail; - context.signal.throwIfAborted(); - context.signal.removeEventListener('abort', abort); - return session; - } catch (error) { - context.signal.removeEventListener('abort', abort); - await closeStartupLedger().catch(() => undefined); - const primary = aborting || context.signal.aborted ? abortReason(context.signal) : error; - const failures = ledger.failures(); - if (failures.length === 0) throw primary; - const labels = [...new Set(failures.map((failure) => failure.label))].sort(); - throw new AggregateError( - [primary, ...failures.map((failure) => failure.error)], - `RSC runtime startup failed; cleanup failures: ${labels.join(', ')}.`, - { cause: error }, - ); - } - } - - get mcpRegistry(): RuntimeMcpRegistry { - return this.#mcpRegistry; - } - - get providerSessionId(): string { - return this.#context.providerSessionId; - } - - clientSurface(surfaceId: string): DevRuntimeClientSurfaceEndpoint | undefined { - return !this.#closed && surfaceId === clientSurfaceId ? this.#clientSurface : undefined; - } - - close(): Promise { - this.#closePromise ??= this.#close(); - return this.#closePromise; - } - - invoke(request: DevRuntimeInvocationRequest): Promise { - if (this.#closed) return Promise.reject(new DevRuntimeUnavailableError('RSC runtime session is closed.')); - const task = this.#invoke(request); - this.#invocations.add(task); - void task.finally(() => { this.#invocations.delete(task); }).catch(() => undefined); - return task; - } - - async readAsset(request: DevRuntimeAssetRequest): Promise { - if (this.#closed || !this.#surfaces.has(request.surfaceId) || request.runtimeGenerationId.length === 0) return undefined; - const segments = request.path.map((segment) => { - if (!safeSegment(segment)) return undefined; - try { - return decodeURIComponent(segment) === segment ? segment : undefined; - } catch { - return undefined; - } - }); - if (segments.some((segment) => segment === undefined)) return undefined; - const requestPath = `/${segments.join('/')}`; - let lease; - try { - lease = await this.#generationStore.lease(request.runtimeGenerationId); - await this.#testing.beforeAssetRead?.(Object.freeze({ - request, - runtimeGenerationId: lease.generation.id, - })); - const app = this.#surfaceAssetApps.get(request.surfaceId); - if (app === undefined) return undefined; - const boundSurfaceId = this.#surfaceAssetBinding(lease.generation, app); - if (boundSurfaceId === undefined) return undefined; - const descriptor = lease.generation.manifest.metadata.surfaceAssets[boundSurfaceId] - ?.find((asset) => asset.requestPath === requestPath); - if (descriptor === undefined || descriptor.bytes > maximumAssetBytes) return undefined; - const assetSegments = descriptor.generationPath.split('/'); - if (assetSegments.some((segment) => !safeSegment(segment))) return undefined; - const path = join(lease.generation.root, ...assetSegments); - if (!isInside(lease.generation.root, path)) return undefined; - const details = await lstat(path); - if (!details.isFile() || details.isSymbolicLink() || details.size !== descriptor.bytes) return undefined; - const body = await readFile(path); - if (body.byteLength !== descriptor.bytes || createHash('sha256').update(body).digest('hex') !== descriptor.sha256) return undefined; - return Object.freeze({ body, contentType: descriptor.contentType }); - } catch { - return undefined; - } finally { - await lease?.release(); - } - } - - async readRunFlight(runId: string): Promise { - if (this.#closed || !safeSegment(runId)) return undefined; - if (this.#evictingTerminalRuns.has(runId)) return undefined; - const run = this.#terminalRuns.get(runId); - if (run?.status !== 'succeeded' || run.vector.providerSessionId !== this.providerSessionId) return undefined; - const artifact = this.#runArtifacts.get(runId); - if (artifact?.digest === undefined || artifact.size === undefined || artifact.dev === undefined || artifact.ino === undefined) return undefined; - const task = (async (): Promise => { - try { - await this.#testing.beforeRunFlightRead?.(Object.freeze({ runId })); - await this.#assertCurrentOwnedRunsRoot(); - const details = await artifact.file.stat(); - if (!details.isFile() || details.size !== artifact.size || details.dev !== artifact.dev || details.ino !== artifact.ino) return undefined; - const body = Buffer.alloc(artifact.size); - let offset = 0; - while (offset < body.byteLength) { - const read = await artifact.file.read(body, offset, body.byteLength - offset, offset); - if (read.bytesRead === 0) return undefined; - offset += read.bytesRead; - } - if (createHash('sha256').update(body).digest('hex') !== artifact.digest) return undefined; - await this.#assertCurrentOwnedRunsRoot(); - return Object.freeze({ body, contentType: 'application/octet-stream' }); - } catch { - return undefined; - } - })(); - const reads = this.#runReadTasks.get(runId) ?? new Set>(); - this.#runReadTasks.set(runId, reads); - reads.add(task); - try { - return await task; - } finally { - reads.delete(task); - if (reads.size === 0) this.#runReadTasks.delete(runId); - } - } - - reconcilePreparedRuntime(prepared: DevRuntimePreparedProject): Promise { - const next = clonePrepared(prepared); - this.#validatePreparedRuntime(next); - if (this.#closed) return Promise.reject(new Error('RSC runtime session is closed.')); - if (this.#preparedRevisions.has(next.sourceRevision)) { - return Promise.reject(new Error('Runtime prepared configuration source revision is stale or unchanged.')); - } - this.#preparedRevisions.add(next.sourceRevision); - this.#latestPreparedRuntime = next; - return this.#append(async () => this.#reconcilePreparedRuntime(next)); - } - - async replay(request: DevRuntimeReplayRequest): Promise { - if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); - if (request === null || typeof request !== 'object' || !safeSegment(request.runId)) { - throw new TypeError('Runtime replay requires a retained run id.'); - } - if (request.mode !== 'exact' && request.mode !== 'latest') throw new TypeError('Runtime replay mode is invalid.'); - const historical = this.#terminalRuns.get(request.runId); - if (historical === undefined) throw new Error(`Runtime run ${JSON.stringify(request.runId)} does not exist.`); - const historicalGenerationId = historical.vector.runtimeGenerationId; - const activeGenerationId = this.#active?.id; - if (request.mode === 'exact' && request.expectedGenerationId !== undefined && request.expectedGenerationId !== historicalGenerationId) { - throw new DevRuntimeGenerationConflictError(request.expectedGenerationId, historicalGenerationId); - } - if (request.mode === 'latest' && request.expectedGenerationId !== undefined && request.expectedGenerationId !== activeGenerationId) { - throw new DevRuntimeGenerationConflictError(request.expectedGenerationId, activeGenerationId); - } - const expectedGenerationId = request.mode === 'exact' ? historicalGenerationId : activeGenerationId; - if (expectedGenerationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); - if (request.mode === 'exact') { - let retained: Awaited['lease']>> | undefined; - try { - try { - retained = await this.#generationStore.lease(historicalGenerationId); - } catch { - throw new DevRuntimeGenerationConflictError(historicalGenerationId, this.#active?.id); - } - let surface: DevRuntimeSurface; - try { - surface = await this.#historicalSurface(retained.generation, historical.surfaceId); - } catch { - throw new DevRuntimeGenerationConflictError(historicalGenerationId, this.#active?.id); - } - const replay = this.#invoke({ - expectedGenerationId, - ...(historical.fixtureId === undefined ? {} : { fixtureId: historical.fixtureId }), - input: historical.input, - surfaceId: historical.surfaceId, - target: historical.target, - }, retained, surface); - retained = undefined; - return await replay; - } finally { - await retained?.release(); - } - } - return this.invoke({ - expectedGenerationId, - ...(historical.fixtureId === undefined ? {} : { fixtureId: historical.fixtureId }), - input: historical.input, - surfaceId: historical.surfaceId, - target: historical.target, - }); - } - - async resetState(request: DevRuntimeStateResetRequest): Promise { - if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); - if (request.stateStoreId !== stateStoreId) throw new Error(`Unknown runtime state store ${JSON.stringify(request.stateStoreId)}.`); - const generationId = request.expectedGenerationId ?? this.#active?.id; - if (generationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); - let lease; - try { - lease = await this.#generationStore.lease(generationId); - } catch { - throw new DevRuntimeGenerationConflictError(generationId, this.#active?.id); - } - try { - if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); - const seed = request.seed === undefined ? undefined : cloneJson(request.seed); - if (seed !== undefined) assertCredentialSafeJson(seed); - const snapshot = await this.#stateKernel.resetState({ - idempotencyKey: `runtime:reset:${randomUUID()}`, - ...(seed === undefined ? {} : { seed }), - }); - return Object.freeze({ stateStoreId, stateVersion: snapshot.stateVersion }); - } finally { - await lease.release(); - } - } - - run(runId: string): DevRuntimeRun | undefined { - return this.#closed ? undefined : this.#activeRuns.get(runId) ?? this.#terminalRuns.get(runId); - } - - runs(limit: number): readonly DevRuntimeRun[] { - if (this.#closed) return Object.freeze([]); - if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximumRunHistory) { - throw new RangeError(`Runtime run history limit must be an integer from 1 through ${maximumRunHistory}.`); - } - return Object.freeze([...this.#terminalRuns.values()].reverse().slice(0, limit)); - } - - status(): DevRuntimeStatus { - return this.#status; - } - - surfaces(): readonly DevRuntimeSurface[] { - return Object.freeze([...this.#surfaces.values()]); - } - - async #invoke( - request: DevRuntimeInvocationRequest, - suppliedLease?: Awaited['lease']>>, - historicalSurface?: DevRuntimeSurface, - ): Promise { - let lease = suppliedLease; - let releaseReservation: (() => void) | undefined; - try { - const invocation = this.#validateInvocation(request, historicalSurface); - const generationId = invocation.request.expectedGenerationId ?? lease?.generation.id ?? this.#active?.id; - if (generationId === undefined) throw new DevRuntimeUnavailableError('RSC runtime has no active generation.'); - if (lease !== undefined && lease.generation.id !== generationId) { - throw new DevRuntimeGenerationConflictError(generationId, lease.generation.id); - } - releaseReservation = this.#reserveInvocation(); - if (lease === undefined) { - try { - lease = await this.#generationStore.lease(generationId); - } catch { - throw new DevRuntimeGenerationConflictError(generationId, this.#active?.id); - } - } - const generationLease = lease; - if (generationLease === undefined) throw new Error('RSC runtime generation lease is unavailable.'); - - let runDirectory: string | undefined; - let running: DevRuntimeRun | undefined; - let artifact: RunArtifact | undefined; - try { - this.#assertInvocationOpen(); - const stateBefore = await this.#stateKernel.readSnapshot(); - this.#assertInvocationOpen(); - const runId = randomUUID(); - const startedAt = new Date().toISOString(); - running = Object.freeze({ - ...(invocation.fixtureId === undefined ? {} : { fixtureId: invocation.fixtureId }), - id: runId, - input: invocation.input, - startedAt, - status: 'running' as const, - surfaceId: invocation.surface.id, - target: invocation.request.target, - vector: this.#vector(generationLease.generation, stateBefore.stateVersion), - }); - this.#activeRuns.set(runId, running); - runDirectory = join(this.#runRoot, runId); - if (!isInside(this.#runRoot, runDirectory) || !safeSegment(runId)) { - throw new Error('RSC runtime run directory escaped its provider storage root.'); - } - await this.#assertCurrentOwnedRunsRoot(); - await mkdir(runDirectory, { recursive: false }); - artifact = await this.#openRunArtifact(runId); - this.#assertInvocationOpen(); - this.#emit(Object.freeze({ runId, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.started' })); - const workerInput = await this.#workerRequest(invocation); - this.#assertInvocationOpen(); - const response = await this.#runInvocationWorker({ - generation: generationLease.generation, - input: workerInput, - runId, - surfaceId: invocation.surface.id, - }); - this.#assertInvocationOpen(); - await this.#testing.afterInvocationWorkerResponse?.(Object.freeze({ runId, surfaceId: invocation.surface.id })); - this.#assertInvocationOpen(); - const flight = response.flight; - const inspectedStateVersion = response.inspection.state.identity.stateVersion; - const stateAfter = await this.#stateKernel.readSnapshot({ stateVersion: inspectedStateVersion }); - if (stateAfter.stateVersion !== inspectedStateVersion) throw new Error('RSC invocation inspection state version is not durable.'); - this.#assertInvocationOpen(); - const app = await this.#runtimeAppResult(generationLease.generation, invocation); - this.#assertInvocationOpen(); - const result = this.#inspectionResult(response.inspection, flight, stateAfter, runId, app); - if (artifact === undefined) throw new Error('RSC runtime Flight artifact is unavailable.'); - await this.#writeRunFlight(artifact, flight); - const completed = Object.freeze({ - ...(invocation.fixtureId === undefined ? {} : { fixtureId: invocation.fixtureId }), - completedAt: new Date().toISOString(), - id: runId, - input: invocation.input, - result, - startedAt, - status: 'succeeded' as const, - surfaceId: invocation.surface.id, - target: invocation.request.target, - vector: this.#vector(generationLease.generation, inspectedStateVersion), - }); - this.#activeRuns.delete(runId); - await this.#recordTerminal(completed); - this.#publishActiveStateVersion(generationLease.generation, inspectedStateVersion); - this.#emit(Object.freeze({ runId, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.completed' })); - return completed; - } catch (error) { - const cleanupFailures: LabeledCleanupFailure[] = []; - if (artifact !== undefined) { - try { - await this.#releaseRunArtifact(artifact.runId); - } catch (cleanupError) { - cleanupFailures.push(Object.freeze({ error: cleanupError, label: 'run-artifact' })); - } - } - if (cleanupFailures.length === 0 && runDirectory !== undefined) { - try { - await this.#removeRunDirectory(running?.id); - } catch (cleanupError) { - cleanupFailures.push(Object.freeze({ error: cleanupError, label: 'run-artifact' })); - } - } - if (running === undefined) throw error; - this.#activeRuns.delete(running.id); - const stateAfter = await this.#readTerminalStateVersion(running.vector.stateVersion); - const invocationError = cleanupFailures.length === 0 - ? error - : cleanupAggregate('RSC runtime invocation cleanup failed', cleanupFailures, error); - const failed = Object.freeze({ - ...(running.fixtureId === undefined ? {} : { fixtureId: running.fixtureId }), - completedAt: new Date().toISOString(), - diagnostics: Object.freeze([invocationDiagnostic(invocationError)]), - id: running.id, - input: running.input, - startedAt: running.startedAt, - status: 'failed' as const, - surfaceId: running.surfaceId, - target: running.target, - vector: this.#vector(generationLease.generation, stateAfter), - }); - await this.#recordTerminal(failed); - this.#publishActiveStateVersion(generationLease.generation, stateAfter); - this.#emit(Object.freeze({ runId: running.id, runtimeGenerationId: generationLease.generation.id, type: 'runtime.run.failed' })); - return failed; - } - } finally { - await lease?.release(); - releaseReservation?.(); - } - } - - #assertInvocationOpen(): void { - if (this.#closed || this.#invocationAbort.signal.aborted) { - throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); - } - } - - #reserveInvocation(): () => void { - this.#assertInvocationOpen(); - if (this.#invocationReservations >= maximumInvocationWorkers) { - throw new Error(`RSC runtime invocation limit of ${maximumInvocationWorkers} concurrent workers has been reached.`); - } - this.#invocationReservations += 1; - let released = false; - return () => { - if (released) return; - released = true; - this.#invocationReservations -= 1; - }; - } - - #validateInvocation(request: DevRuntimeInvocationRequest, historicalSurface?: DevRuntimeSurface): ValidatedInvocation { - if (this.#closed) throw new DevRuntimeUnavailableError('RSC runtime session is closed.'); - if (request === null || typeof request !== 'object') throw new TypeError('Runtime invocation request must be an object.'); - if (typeof request.surfaceId !== 'string' || request.surfaceId.length === 0) { - throw new TypeError('Runtime invocation requires a nonempty surfaceId.'); - } - if (typeof request.target !== 'string' || request.target.length === 0) { - throw new TypeError('Runtime invocation requires a nonempty target.'); - } - if (request.expectedGenerationId !== undefined && (typeof request.expectedGenerationId !== 'string' || request.expectedGenerationId.length === 0)) { - throw new TypeError('Runtime invocation expectedGenerationId must be nonempty when provided.'); - } - const surface = historicalSurface ?? this.#surfaces.get(request.surfaceId); - if (surface === undefined) throw new Error(`Runtime surface ${JSON.stringify(request.surfaceId)} does not exist.`); - if (!surface.targets.includes(request.target)) { - throw new Error(`Runtime surface ${JSON.stringify(request.surfaceId)} does not support target ${JSON.stringify(request.target)}.`); - } - if (!['hook.claude', 'hook.codex', 'mcp.render_edit_timeline', 'mcp.recent_edits', 'mcp.runtime_status'].includes(surface.id)) { - throw new Error(`Runtime surface ${JSON.stringify(surface.id)} is not invocable.`); - } - if (request.fixtureId !== undefined) { - if (typeof request.fixtureId !== 'string' || request.fixtureId.length === 0) { - throw new TypeError('Runtime invocation fixtureId must be nonempty when provided.'); - } - if (!surface.fixtures.some((fixture) => fixture.id === request.fixtureId)) { - throw new Error(`Runtime surface ${JSON.stringify(surface.id)} has no fixture ${JSON.stringify(request.fixtureId)}.`); - } - } - const input = cloneJson(request.input); - if (surface.id === 'hook.claude' || surface.id === 'hook.codex') { - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - throw new TypeError('Native hook runtime invocation input must be an object.'); - } - const hookInput = input as Record; - if (surface.id === 'hook.claude') normalizeClaudeHook(hookInput); - else normalizeCodexHook(hookInput); - } else if ( - surface.id === 'mcp.render_edit_timeline' || - surface.id === 'mcp.recent_edits' || - surface.id === 'mcp.runtime_status' - ) { - if (input === null || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 0) { - throw new TypeError(`Runtime surface ${JSON.stringify(surface.id)} requires an empty object input.`); - } - } - return Object.freeze({ - ...(request.fixtureId === undefined ? {} : { fixtureId: request.fixtureId }), - input, - request: Object.freeze({ ...request }), - surface, - }); - } - - async #workerRequest(invocation: ValidatedInvocation): Promise { - if (invocation.surface.id === 'hook.claude' || invocation.surface.id === 'hook.codex') { - if (invocation.input === null || typeof invocation.input !== 'object' || Array.isArray(invocation.input)) { - throw new TypeError('Native hook runtime invocation input must be an object.'); - } - return Object.freeze({ - host: invocation.surface.id === 'hook.claude' ? 'claude' : 'codex', - input: invocation.input, - stateFile: this.#stateFile, - stateStoreId, - type: 'hook/after-file-edit', - }); - } - if (invocation.surface.id === 'mcp.render_edit_timeline' || invocation.surface.id === 'mcp.recent_edits') { - return Object.freeze({ - snapshot: cloneJson(await this.#stateKernel.readSnapshot()), - stateFile: this.#stateFile, - stateStoreId, - type: 'mcp/render-timeline', - }); - } - return Object.freeze({ stateFile: this.#stateFile, stateStoreId, type: 'mcp/runtime-status' }); - } - - async #historicalSurface( - generation: RuntimeGeneration, - surfaceId: string, - ): Promise { - const definitionPath = join(generation.root, 'rsc', 'runtime-definition.json'); - const asset = generation.manifest.assets.find((candidate) => candidate.path === 'rsc/runtime-definition.json'); - if (asset === undefined || !isInside(generation.root, definitionPath)) throw new Error('Historical runtime generation has no definition asset.'); - const details = await lstat(definitionPath); - if (!details.isFile() || details.isSymbolicLink() || details.size !== asset.bytes) throw new Error('Historical runtime definition is unsafe.'); - const bytes = await readFile(definitionPath); - if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256) throw new Error('Historical runtime definition changed.'); - const definition = JSON.parse(bytes.toString('utf8')) as Partial; - const targets = Object.freeze([...new Set(generation.manifest.metadata.servers.map((server) => server.target))]); - if (surfaceId.startsWith('hook.')) { - const host = surfaceId.slice('hook.'.length); - if ((host !== 'claude' && host !== 'codex') || !definition.nativeHooks?.some((hook) => hook.host === host)) { - throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); - } - return Object.freeze({ fixtures: fixturesForHook(host), id: surfaceId, kind: 'hook', label: `After tool hook (${host})`, readOnly: false, targets: Object.freeze([host]) }); - } - const name = surfaceId.startsWith('mcp.') ? surfaceId.slice('mcp.'.length) : ''; - if (definition.tools?.some((tool) => tool.name === name)) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-tool', label: name, readOnly: true, targets }); - } - if (definition.resources?.some((resource) => resource.name === name)) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-resource', label: name, readOnly: true, targets }); - } - const app = generation.manifest.metadata.appDefinitions.find((candidate) => candidate.name === name); - if (app !== undefined) { - return Object.freeze({ fixtures: Object.freeze([]), id: surfaceId, kind: 'mcp-app', label: name, readOnly: true, targets: app.targets }); - } - throw new Error(`Historical runtime surface ${JSON.stringify(surfaceId)} does not exist.`); - } - - async #readTerminalStateVersion(fallback: number): Promise { - try { - return (await this.#stateKernel.readSnapshot()).stateVersion; - } catch { - return fallback; - } - } - - async #recordTerminal(run: DevRuntimeRun): Promise { - this.#terminalRuns.set(run.id, run); - const eviction = this.#evictionTail.then(() => this.#evictTerminalRuns()); - this.#evictionTail = eviction.catch(() => undefined); - await eviction; - } - - async #evictTerminalRuns(): Promise { - while (this.#terminalRuns.size > maximumRunHistory) { - const oldestId = this.#terminalRuns.keys().next().value as string | undefined; - if (oldestId === undefined) return; - this.#evictingTerminalRuns.add(oldestId); - try { - await this.#testing.afterRunArtifactEvictionReserved?.(Object.freeze({ runId: oldestId })); - const reads = this.#runReadTasks.get(oldestId); - if (reads !== undefined) await Promise.allSettled([...reads]); - await this.#releaseRunArtifact(oldestId); - this.#terminalRuns.delete(oldestId); - this.#pendingRunDirectoryRemovals.add(oldestId); - await this.#removeRunDirectory(oldestId); - this.#pendingRunDirectoryRemovals.delete(oldestId); - } catch (error) { - throw cleanupAggregate('RSC runtime run artifact cleanup failed', [Object.freeze({ error, label: 'run-artifact' })]); - } finally { - this.#evictingTerminalRuns.delete(oldestId); - } - } - } - - async #removeRunDirectory(runId: string | undefined): Promise { - if (runId === undefined || !safeSegment(runId)) return; - await this.#testing.beforeRunDirectoryRemoval?.(Object.freeze({ runId })); - await this.#assertCurrentOwnedRunsRoot(); - const directory = join(this.#runRoot, runId); - if (!isInside(this.#runRoot, directory)) throw new Error('RSC runtime run directory escaped its provider storage root.'); - const details = await lstat(directory).catch((error: unknown) => { - const code = error instanceof Error && 'code' in error ? error.code : undefined; - if (code === 'ENOENT') return undefined; - throw error; - }); - if (details === undefined) return; - if (!details.isDirectory() || details.isSymbolicLink()) { - throw new Error('RSC runtime run directory is not a contained non-symbolic directory.'); - } - await rm(directory, { force: true, recursive: true }); - } - - async #openRunArtifact(runId: string): Promise { - await this.#assertCurrentOwnedRunsRoot(); - const directory = join(this.#runRoot, runId); - if (!safeSegment(runId) || !isInside(this.#runRoot, directory)) throw new Error('RSC runtime run directory escaped its provider storage root.'); - const details = await lstat(directory); - if (!details.isDirectory() || details.isSymbolicLink()) throw new Error('RSC runtime run directory is unsafe.'); - const directoryHandle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); - try { - const openedDirectory = await directoryHandle.stat(); - if (!openedDirectory.isDirectory() || openedDirectory.dev !== details.dev || openedDirectory.ino !== details.ino) { - throw new Error('RSC runtime run directory changed while opening its Flight artifact.'); - } - const flightPath = process.platform === 'linux' - ? `/proc/self/fd/${String(directoryHandle.fd)}/flight.bin` - : join(directory, 'flight.bin'); - const file = await open(flightPath, constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR, 0o600); - const artifact: RunArtifact = { file, runId }; - this.#runArtifacts.set(runId, artifact); - return artifact; - } finally { - await directoryHandle.close(); - } - } - - async #writeRunFlight(artifact: RunArtifact, flight: Buffer): Promise { - if (flight.byteLength > maximumInvocationFlightBytes) throw new Error(`RSC invocation Flight exceeded ${maximumInvocationFlightBytes} bytes.`); - let offset = 0; - while (offset < flight.byteLength) { - const written = await artifact.file.write(flight, offset, flight.byteLength - offset, offset); - if (written.bytesWritten === 0) throw new Error('RSC runtime Flight artifact could not be written.'); - offset += written.bytesWritten; - } - await artifact.file.sync(); - const details = await artifact.file.stat(); - if (!details.isFile() || details.size !== flight.byteLength || details.size > maximumInvocationFlightBytes) { - throw new Error('RSC runtime Flight artifact has an invalid identity.'); - } - artifact.dev = details.dev; - artifact.digest = createHash('sha256').update(flight).digest('hex'); - artifact.ino = details.ino; - artifact.size = details.size; - } - - async #releaseRunArtifact(runId: string): Promise { - const artifact = this.#runArtifacts.get(runId); - if (artifact === undefined) return; - await this.#testing.beforeRunArtifactRelease?.(Object.freeze({ runId })); - await artifact.file.close(); - this.#runArtifacts.delete(runId); - } - - #inspectionResult( - inspection: DevRuntimeInspectionEnvelope, - flight: Buffer, - snapshot: RuntimeSnapshot, - runId: string, - app: DevRuntimeInspectionEnvelope['app'], - ): DevRuntimeInspectionEnvelope { - const { app: _workerApp, ...workerInspection } = inspection; - return Object.freeze({ - ...workerInspection, - ...(app === undefined ? {} : { app }), - flight: Object.freeze({ - bytes: flight.byteLength, - downloadPath: `/api/runtime/runs/${encodeURIComponent(runId)}/flight`, - preview: flight.subarray(0, flightPreviewBytes).toString('base64'), - truncated: flight.byteLength > flightPreviewBytes, - }), - state: Object.freeze({ - ...inspection.state, - identity: Object.freeze({ stateStoreId, stateVersion: snapshot.stateVersion }), - snapshot: cloneJson(snapshot), - }), - }); - } - - #runtimeAppLink( - generation: RuntimeGeneration, - invocation: ValidatedInvocation, - ): RuntimeAppLink | undefined { - if (invocation.surface.id !== 'mcp.render_edit_timeline') return undefined; - const registry = this.#mcpRegistry.snapshot(); - const metadata = generation.manifest.metadata; - if ( - this.#active?.id !== generation.id || registry?.runtimeGenerationId !== generation.id - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - const toolName = invocation.surface.id.slice('mcp.'.length); - const matches = registry.servers.flatMap((descriptor) => { - if ( - descriptor.target !== invocation.request.target || descriptor.definitionDigest !== registry.definitionDigest || - descriptor.transportDigest !== registry.transportDigest || descriptor.serverDigest !== metadata.serverDigest - ) return []; - const tool = descriptor.tools.find((candidate) => candidate.name === toolName); - const toolMeta = tool?._meta; - const outputTemplate = toolMeta === null || typeof toolMeta !== 'object' || Array.isArray(toolMeta) - ? undefined - : Object.getOwnPropertyDescriptor(toolMeta, 'openai/outputTemplate')?.value; - const resourceUri = typeof outputTemplate === 'string' ? outputTemplate : undefined; - if (resourceUri === undefined) return []; - return metadata.appDefinitions - .filter((app) => app.serverName === descriptor.name && app.resourceUri === resourceUri && app.targets.includes(invocation.request.target) && metadata.surfaceAssets[`mcp.${app.name}`] !== undefined) - .map((app) => Object.freeze({ app, descriptor, resourceUri })); - }); - if (matches.length !== 1) throw new Error('Runtime App invocation has no unambiguous current-generation App definition.'); - const match = matches[0]!; - return Object.freeze({ - descriptor: match.descriptor, - key: `${match.descriptor.name}\u0000${invocation.request.target}`, - resourceUri: match.resourceUri, - surfaceId: clientSurfaceId, - }); - } - - #assertRuntimeAppAuthority( - generation: RuntimeGeneration, - link: RuntimeAppLink, - ): NonNullable> { - this.#assertInvocationOpen(); - const registry = this.#mcpRegistry.snapshot(); - if ( - registry === undefined || this.#active?.id !== generation.id || registry.runtimeGenerationId !== generation.id || - link.descriptor.definitionDigest !== registry.definitionDigest || link.descriptor.transportDigest !== registry.transportDigest || - !registry.servers.some((descriptor) => descriptor.name === link.descriptor.name && descriptor.target === link.descriptor.target && - descriptor.definitionDigest === link.descriptor.definitionDigest && descriptor.serverDigest === link.descriptor.serverDigest && - descriptor.transportDigest === link.descriptor.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - return registry; - } - - #matchesRuntimeAppBinding( - binding: DevRuntimeMcpSessionBinding, - link: RuntimeAppLink, - registry: NonNullable>, - ): boolean { - return binding.definitionDigest === registry.definitionDigest && binding.registryRevision === registry.registryRevision && - binding.serverDigest === link.descriptor.serverDigest && binding.serverName === link.descriptor.name && - binding.target === link.descriptor.target && binding.transportDigest === registry.transportDigest; - } - - async #runtimeAppSession( - generation: RuntimeGeneration, - link: RuntimeAppLink, - ): Promise { - const registry = this.#assertRuntimeAppAuthority(generation, link); - const existing = this.#appBrokers.get(link.key); - const broker = existing ?? { closedObservation: undefined, opening: undefined, session: undefined }; - if (existing === undefined) this.#appBrokers.set(link.key, broker); - const current = broker.session; - if (current !== undefined) { - const snapshot = current.snapshot(); - if (snapshot.state === 'ready' && this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) return current; - broker.closedObservation?.unsubscribe(); - broker.closedObservation = undefined; - broker.session = undefined; - if (this.#appBrokers.get(link.key) === broker) this.#appBrokers.delete(link.key); - return this.#runtimeAppSession(generation, link); - } - if (broker.opening !== undefined) return broker.opening; - const opening = (async (): Promise => { - let session: DevRuntimeMcpSession | undefined; - try { - session = await this.#mcpRegistry.open(Object.freeze({ - expectedRegistryRevision: registry.registryRevision, - serverName: link.descriptor.name, - target: link.descriptor.target, - })); - const currentRegistry = this.#assertRuntimeAppAuthority(generation, link); - const snapshot = session.snapshot(); - if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, currentRegistry)) { - throw new Error('Runtime App broker session did not negotiate the current generation authority.'); - } - broker.session = session; - broker.closedObservation = session.watchClosed(() => { - if (this.#appBrokers.get(link.key) !== broker) return; - broker.closedObservation?.unsubscribe(); - broker.closedObservation = undefined; - broker.session = undefined; - this.#appBrokers.delete(link.key); - }); - return session; - } catch (error) { - if (session !== undefined) await session.close().catch(() => undefined); - if (this.#appBrokers.get(link.key) === broker && broker.session === undefined) this.#appBrokers.delete(link.key); - throw error; - } - })(); - broker.opening = opening; - void opening.finally(() => { - if (broker.opening === opening) broker.opening = undefined; - }).catch(() => undefined); - return opening; - } - - async #runtimeAppResult( - generation: RuntimeGeneration, - invocation: ValidatedInvocation, - ): Promise { - const link = this.#runtimeAppLink(generation, invocation); - if (link === undefined) return undefined; - const session = await this.#runtimeAppSession(generation, link); - const registry = this.#assertRuntimeAppAuthority(generation, link); - const snapshot = session.snapshot(); - if (snapshot.state !== 'ready' || !this.#matchesRuntimeAppBinding(snapshot.binding, link, registry)) { - throw new Error('Runtime App broker session became stale before invocation completion.'); - } - const binding = snapshot.binding; - return Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: binding.definitionDigest, - registryRevision: binding.registryRevision, - serverDigest: binding.serverDigest, - serverName: binding.serverName, - sessionId: binding.sessionId, - sessionRevision: binding.sessionRevision, - target: binding.target, - transportDigest: binding.transportDigest, - }), - resourceUri: link.resourceUri, - surfaceId: link.surfaceId, - }); - } - - #validateWorkerResponse(value: unknown, flightBytes: number, surfaceId: string): DevRuntimeInspectionEnvelope { - const response = plainRecord(value, 'RSC invocation worker emitted an invalid response.'); - assertExactKeys(response, ['flightBytes', 'inspection'], 'RSC invocation worker response has unsupported fields.'); - if ( - typeof response.flightBytes !== 'number' || !Number.isSafeInteger(response.flightBytes) || - response.flightBytes < 0 || response.flightBytes > maximumInvocationFlightBytes || response.flightBytes !== flightBytes - ) { - throw new Error('RSC invocation worker Flight framing is invalid.'); - } - const inspection = plainRecord(response.inspection, 'RSC invocation worker inspection is invalid.'); - const hook = surfaceId === 'hook.claude' || surfaceId === 'hook.codex'; - if ('app' in inspection) validateAppBinding(inspection.app); - optionalExactKeys( - inspection, - hook ? ['agentVisible', 'flight', 'native', 'state', 'trace', 'tree'] : ['flight', 'modelVisible', 'protocol', 'state', 'trace', 'tree'], - hook ? [] : [], - 'RSC invocation worker inspection has unsupported fields.', - ); - const flight = plainRecord(inspection.flight, 'RSC invocation worker inspection is missing Flight metadata.'); - assertExactKeys(flight, ['bytes', 'preview', 'truncated'], 'RSC invocation worker Flight metadata is invalid.'); - if (flight.bytes !== flightBytes || typeof flight.preview !== 'string' || typeof flight.truncated !== 'boolean') { - throw new Error('RSC invocation worker Flight metadata does not match its raw Flight stream.'); - } - const state = plainRecord(inspection.state, 'RSC invocation worker inspection is missing state metadata.'); - optionalExactKeys(state, ['identity'], ['snapshot'], 'RSC invocation worker state metadata is invalid.'); - const identity = plainRecord(state.identity, 'RSC invocation worker state identity is invalid.'); - assertExactKeys(identity, ['stateStoreId', 'stateVersion'], 'RSC invocation worker state identity is invalid.'); - if (identity.stateStoreId !== stateStoreId || !Number.isSafeInteger(identity.stateVersion) || (identity.stateVersion as number) < 0) { - throw new Error('RSC invocation worker state identity is invalid.'); - } - validateTrace(inspection.trace); - validateTree(inspection.tree); - assertCredentialSafeJson(inspection); - return deepFreeze(inspection as unknown as DevRuntimeInspectionEnvelope); - } - - #runInvocationWorker(input: Readonly<{ - readonly generation: RuntimeGeneration; - readonly input: JsonObject; - readonly runId: string; - readonly surfaceId: string; - }>): Promise> { - this.#assertInvocationOpen(); - const entry = join(input.generation.root, 'rsc', 'dev', 'invoke.js'); - if (!isInside(input.generation.root, entry)) return Promise.reject(new Error('RSC invocation entry escaped its generation root.')); - const windowsSupervised = process.platform === 'win32'; - const child = spawn(process.execPath, windowsSupervised ? ['-e', windowsInvocationWrapperSource, entry] : [entry], { - cwd: resolve(this.#context.projectRoot), - detached: process.platform !== 'win32', - env: { - ...this.#context.environment, - AGENT_RUNTIME_STATE_FILE: this.#stateFile, - NODE_ENV: 'development', - }, - stdio: windowsSupervised ? ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - const stdout = child.stdout; - const stderr = child.stderr; - const flightOutput = child.stdio[3] as NodeJS.ReadableStream | undefined; - const invocationControl = windowsSupervised ? child.stdio[4] as NodeJS.WritableStream | undefined : undefined; - const processGroupId = child.pid; - if ( - stdout === null || stderr === null || flightOutput === undefined || flightOutput === null || processGroupId === undefined || - (windowsSupervised && (invocationControl === undefined || invocationControl === null)) - ) { - child.kill('SIGKILL'); - return Promise.reject(new Error('RSC invocation worker streams are unavailable.')); - } - - const jobOwner = windowsSupervised ? (() => { - const owner = spawn('powershell.exe', [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - windowsJobOwnerSource, - String(processGroupId), - this.#testing.windowsJobOwnerMode ?? 'normal', - ], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); - const ownerControl = owner.stdin; - const ownerStdout = owner.stdout; - const ownerStderr = owner.stderr; - if (ownerControl === null || ownerStdout === null || ownerStderr === null) { - owner.kill('SIGKILL'); - return Object.freeze({ - closed: Promise.resolve(), - done: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), - drained: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), - ready: Promise.reject(new Error('RSC invocation Windows Job Object owner streams are unavailable.')), - isAssigned: () => false, - isClosed: () => true, - forceTerminate: () => undefined, - terminate: () => undefined, - } satisfies WindowsJobOwner); - } - const ownerStderrChunks: Buffer[] = []; - let ownerStderrBytes = 0; - let assigned = false; - let readySettled = false; - let resolveReady!: () => void; - let rejectReady!: (error: Error) => void; - const ready = new Promise((resolve, reject) => { - resolveReady = resolve; - rejectReady = reject; - }); - let drainedSettled = false; - let resolveDrained!: () => void; - let rejectDrained!: (error: Error) => void; - const drained = new Promise((resolve, reject) => { - resolveDrained = resolve; - rejectDrained = reject; - }); - let doneSettled = false; - let resolveDone!: () => void; - let rejectDone!: (error: Error) => void; - const done = new Promise((resolve, reject) => { - resolveDone = resolve; - rejectDone = reject; - }); - let resolveClosed!: () => void; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - const ownerFailure = (message: string): Error => { - const diagnostics = redactInspectionDiagnostics(Buffer.concat(ownerStderrChunks).toString('utf8')); - return new Error(message + (diagnostics.length === 0 ? '' : ': ' + diagnostics)); - }; - const failReady = (failure: Error): void => { - if (!readySettled) { - readySettled = true; - rejectReady(failure); - } - }; - const failDrained = (failure: Error): void => { - if (!drainedSettled) { - drainedSettled = true; - rejectDrained(failure); - } - }; - const failDone = (failure: Error): void => { - if (!doneSettled) { - doneSettled = true; - rejectDone(failure); - } - }; - const protocolFailure = (message: string): void => { - const failure = ownerFailure(message); - failReady(failure); - failDrained(failure); - failDone(failure); - }; - let protocolBytes = 0; - let protocolOffset = 0; - const protocolChunks: Buffer[] = []; - const consumeOwnerProtocol = (): void => { - const protocol = Buffer.concat(protocolChunks).toString('utf8'); - let remainder = protocol.slice(protocolOffset); - if (!readySettled) { - const readyLine = ['READY\n', 'READY\r\n'].find((line) => remainder.startsWith(line)); - if (readyLine !== undefined) { - readySettled = true; - assigned = true; - resolveReady(); - protocolOffset += readyLine.length; - remainder = protocol.slice(protocolOffset); - } else if (!['READY\n', 'READY\r\n'].some((line) => line.startsWith(remainder))) { - protocolFailure('RSC invocation Windows Job Object owner emitted an invalid readiness response.'); - return; - } else { - return; - } - } - if (!drainedSettled) { - const drainedLine = ['DRAINED\n', 'DRAINED\r\n'].find((line) => remainder.startsWith(line)); - if (drainedLine !== undefined) { - drainedSettled = true; - resolveDrained(); - protocolOffset += drainedLine.length; - remainder = protocol.slice(protocolOffset); - } else if (!['DRAINED\n', 'DRAINED\r\n'].some((line) => line.startsWith(remainder))) { - protocolFailure('RSC invocation Windows Job Object owner did not confirm descendant drain.'); - return; - } else { - return; - } - } - if (remainder.length > 0) protocolFailure('RSC invocation Windows Job Object owner emitted extra protocol output.'); - }; - ownerStdout.on('data', (chunk: Buffer | string) => { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - protocolBytes += bytes.byteLength; - if (protocolBytes > 32 || doneSettled) { - protocolFailure('RSC invocation Windows Job Object owner emitted oversized or late protocol output.'); - return; - } - protocolChunks.push(bytes); - consumeOwnerProtocol(); - }); - ownerControl.once('error', () => { - const failure = ownerFailure('RSC invocation Windows Job Object owner control stream failed.'); - failReady(failure); - failDrained(failure); - failDone(failure); - }); - ownerStdout.once('error', () => { - protocolFailure('RSC invocation Windows Job Object owner protocol stream failed.'); - }); - ownerStderr.on('data', (chunk: Buffer | string) => { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const retained = Math.min(bytes.byteLength, Math.max(0, maximumInvocationStderrBytes - ownerStderrBytes)); - if (retained > 0) ownerStderrChunks.push(bytes.subarray(0, retained)); - ownerStderrBytes += bytes.byteLength; - }); - ownerStderr.once('error', () => { - protocolFailure('RSC invocation Windows Job Object owner diagnostics stream failed.'); - }); - owner.once('error', (error) => { - const failure = ownerFailure('RSC invocation Windows Job Object owner could not be started: ' + error.message); - failReady(failure); - failDrained(failure); - failDone(failure); - }); - owner.once('close', (code) => { - resolveClosed(); - const failure = ownerFailure('RSC invocation Windows Job Object owner exited with code ' + String(code) + '.'); - if (!readySettled) failReady(failure); - if (!drainedSettled) failDrained(failure); - if (code === 0 && readySettled && drainedSettled) { - if (!doneSettled) { - doneSettled = true; - resolveDone(); - } - } else { - failDone(failure); - } - }); - return Object.freeze({ - closed, - done, - drained, - ready, - isAssigned: () => assigned, - isClosed: () => owner.exitCode !== null || owner.signalCode !== null, - forceTerminate: () => { - try { owner.kill('SIGKILL'); } catch { /* Owner already exited. */ } - }, - terminate: () => { - if (!ownerControl.destroyed) ownerControl.end('STOP\n'); - }, - } satisfies WindowsJobOwner); - })() : undefined; - let termination: Error | undefined; - let timeout: ReturnType | undefined; - let settled = false; - let stdoutBytes = 0; - let stderrBytes = 0; - let flightBytes = 0; - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - const flightChunks: Buffer[] = []; - const childClosed = new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - child.once('close', () => resolve()); - }); - const cleanupFailure = (error: unknown): void => { - const failure = error instanceof Error ? error : new Error('RSC invocation worker teardown failed.'); - termination = termination === undefined - ? failure - : new AggregateError([termination, failure], 'RSC invocation worker teardown failed.'); - }; - const signalGroup = async (signal: NodeJS.Signals): Promise => { - if (windowsSupervised) return; - try { - process.kill(-processGroupId, signal); - } catch { - try { child.kill(signal); } catch { /* Child already exited. */ } - } - }; - let treeCleanup: Promise | undefined; - const teardownTree = (): Promise => { - treeCleanup ??= (async () => { - if (jobOwner !== undefined) { - let forcedOwnerTermination = false; - const forceOwnerTermination = (): void => { - if (forcedOwnerTermination) return; - forcedOwnerTermination = true; - jobOwner.forceTerminate(); - }; - if (!jobOwner.isAssigned()) { - // READY was never observed, so wrapper code is still blocked on GO. - // The retained ChildProcess handle is safe only in this pre-assignment - // phase; all assigned trees are owned exclusively through the Job. - forceOwnerTermination(); - try { child.kill('SIGKILL'); } catch { /* Wrapper already exited. */ } - } else { - jobOwner.terminate(); - try { - await withinDeadline( - jobOwner.drained, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object owner did not confirm descendant drain.', - ); - } catch (error) { - cleanupFailure(error); - forceOwnerTermination(); - } - } - try { - await withinDeadline( - childClosed, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object did not terminate its wrapper.', - ); - } catch (error) { - cleanupFailure(error); - forceOwnerTermination(); - try { - await withinDeadline( - childClosed, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object did not terminate its wrapper after forced owner shutdown.', - ); - } catch (forcedError) { - cleanupFailure(forcedError); - } - } - try { - await withinDeadline( - jobOwner.closed, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object owner did not exit after cleanup.', - ); - } catch (error) { - cleanupFailure(error); - forceOwnerTermination(); - try { - await withinDeadline( - jobOwner.closed, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object owner did not exit after forced shutdown.', - ); - } catch (forcedError) { - cleanupFailure(forcedError); - } - } - try { - await withinDeadline( - jobOwner.done, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object owner did not complete its verified drain protocol.', - ); - } catch (error) { - cleanupFailure(error); - } - return; - } - await signalGroup('SIGTERM'); - await new Promise((resolve) => setTimeout(resolve, invocationTerminationGraceMs)); - await signalGroup('SIGKILL'); - })(); - return treeCleanup; - }; - const terminate = (reason: Error): void => { - if (termination !== undefined) return; - termination = reason; - child.stdin.destroy(); - void teardownTree(); - }; - void jobOwner?.done.catch((error: unknown) => { - if (termination === undefined) terminate(error instanceof Error ? error : new Error('RSC invocation Windows Job Object owner failed.')); - }); - const abort = (): void => terminate(new DevRuntimeUnavailableError('RSC runtime session is closed.')); - this.#invocationAbort.signal.addEventListener('abort', abort, { once: true }); - - const response = new Promise>((resolveResponse, rejectResponse) => { - const finish = async (callback: () => void): Promise => { - if (settled) return; - settled = true; - if (timeout !== undefined) clearTimeout(timeout); - await treeCleanup; - callback(); - }; - const parseWorkerResponse = (): Readonly<{ readonly flight: Buffer; readonly inspection: DevRuntimeInspectionEnvelope }> => { - const output = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(stdoutChunks)); - if (!output.endsWith('\n') || output.indexOf('\n') !== output.length - 1) { - throw new Error('RSC invocation worker did not emit exactly one JSON response line.'); - } - return Object.freeze({ - flight: Buffer.concat(flightChunks), - inspection: this.#validateWorkerResponse(JSON.parse(output), flightBytes, input.surfaceId), - }); - }; - stdout.on('data', (chunk: Buffer | string) => { - if (termination !== undefined) return; - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - stdoutBytes += bytes.byteLength; - if (stdoutBytes > maximumInvocationStdoutBytes) { - terminate(new Error(`RSC invocation stdout exceeded ${maximumInvocationStdoutBytes} bytes.`)); - return; - } - stdoutChunks.push(bytes); - }); - stdout.once('error', () => terminate(new Error('RSC invocation stdout stream failed.'))); - flightOutput.on('data', (chunk: Buffer | string) => { - if (termination !== undefined) return; - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - flightBytes += bytes.byteLength; - if (flightBytes > maximumInvocationFlightBytes) { - terminate(new Error(`RSC invocation Flight exceeded ${maximumInvocationFlightBytes} bytes.`)); - return; - } - flightChunks.push(bytes); - }); - flightOutput.once('error', () => terminate(new Error('RSC invocation Flight stream failed.'))); - stderr.on('data', (chunk: Buffer | string) => { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const retained = Math.min(bytes.byteLength, Math.max(0, maximumInvocationStderrBytes - stderrBytes)); - if (retained > 0) stderrChunks.push(bytes.subarray(0, retained)); - stderrBytes += bytes.byteLength; - if (stderrBytes > maximumInvocationStderrBytes) { - terminate(new Error(`RSC invocation stderr exceeded ${maximumInvocationStderrBytes} bytes.`)); - } - }); - stderr.once('error', () => terminate(new Error('RSC invocation stderr stream failed.'))); - if (invocationControl !== undefined && invocationControl !== null) { - invocationControl.once('error', () => terminate(new Error('RSC invocation Windows wrapper control stream failed.'))); - } - child.stdin.once('error', () => terminate(new Error('RSC invocation request stream failed.'))); - child.once('error', (error) => terminate(new Error(`RSC invocation worker could not be started: ${error.message}`))); - child.once('close', (code) => { - void (async () => { - const diagnostics = redactInspectionDiagnostics(Buffer.concat(stderrChunks).toString('utf8')); - if (termination !== undefined) { - const message = termination.message; - void finish(() => rejectResponse(new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`))); - return; - } - if (code !== 0) { - const failure = new Error(`RSC invocation worker exited with code ${String(code)}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`); - terminate(failure); - void finish(() => rejectResponse(failure)); - return; - } - try { - const parsed = parseWorkerResponse(); - void (async () => { - await teardownTree(); - const terminationAfterCleanup = termination as Error | undefined; - if (terminationAfterCleanup !== undefined) { - const message = terminationAfterCleanup.message; - await finish(() => rejectResponse(new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`))); - return; - } - await finish(() => resolveResponse(parsed)); - })(); - } catch (error) { - const failure = error instanceof Error ? error : new Error('RSC invocation worker emitted invalid JSON.'); - terminate(failure); - void finish(() => rejectResponse(failure)); - } - })(); - }); - timeout = setTimeout(() => terminate(new Error(`RSC invocation worker exceeded ${invocationTimeoutMs} ms.`)), invocationTimeoutMs); - void (async () => { - try { - if (jobOwner !== undefined) { - await withinDeadline( - jobOwner.ready, - windowsJobOwnerPhaseDeadlineMs, - 'RSC invocation Windows Job Object owner did not confirm assignment readiness.', - ); - this.#assertInvocationOpen(); - if (jobOwner.isClosed()) throw new Error('RSC invocation Windows Job Object owner closed before the worker was armed.'); - invocationControl!.end('GO\\n'); - } - this.#assertInvocationOpen(); - child.stdin.end(JSON.stringify(input.input)); - } catch (error) { - terminate(error instanceof Error ? error : new Error('RSC invocation request could not be encoded.')); - } - })(); - }); - const worker: InvocationWorker = Object.freeze({ - done: response.then(() => undefined, () => undefined), - terminate, - }); - this.#workers.set(input.runId, worker); - void worker.done.finally(() => { - if (this.#workers.get(input.runId) === worker) this.#workers.delete(input.runId); - this.#invocationAbort.signal.removeEventListener('abort', abort); - }); - return response; - } - - #attachServer( - started: StartDevServerResult, - devServer: Readonly<{ readonly hostname: string; readonly https: boolean; readonly port: number }> | undefined, - ): void { - if (this.#closed) return; - if ( - devServer === undefined || devServer.hostname !== '127.0.0.1' || devServer.https || - !Number.isSafeInteger(devServer.port) || devServer.port < 1 || devServer.port > 65_535 - ) throw new Error('RSC runtime dev server did not expose a valid loopback HTTP origin.'); - const webSocketToken = this.#appWebSocketToken; - if (webSocketToken === undefined) throw new Error('RSC runtime App compiler did not capture an HMR credential.'); - const origin = new URL(`http://${devServer.hostname}:${String(devServer.port)}`).origin; - this.#server = started.server; - this.#clientSurface = Object.freeze({ - entryPath: clientSurfaceEntry, - httpOrigin: origin, - httpPathPrefixes: Object.freeze(['/']), - surfaceId: clientSurfaceId, - webSocketOrigin: origin.replace(/^http:/u, 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken, - }); - this.#hmrReady = true; - this.#setStatus(this.#active === undefined ? 'compiling' : 'active'); - } - - #captureAppWebSocketToken(token: string): void { - if (!hmrToken.test(token)) throw new Error('RSC runtime App compiler exposed an invalid HMR credential.'); - if (this.#appWebSocketToken !== undefined && this.#appWebSocketToken !== token) { - throw new Error('RSC runtime App compiler changed its HMR credential during startup.'); - } - this.#appWebSocketToken = token; - } - - #compileObserver(): NonNullable[0]['onCompile']> { - return Object.freeze({ - beforeAttempt: () => this.#beforeAttempt(), - capture: async (input) => this.#trackCapture(input), - enqueue: (snapshot) => this.#enqueue(snapshot), - failAttempt: (attemptId, error, kind) => { void this.#failAttempt(attemptId, error, kind); }, - }); - } - - #trackCapture(input: Readonly<{ - readonly attemptId: string; - readonly cohortChanged: boolean; - readonly hasErrors: boolean; - readonly sourceRevision: string; - }>): Promise { - const capture = this.#capture(input); - const tracked = capture.then(() => undefined, () => undefined); - this.#captureTasks.add(tracked); - void tracked.then(() => { this.#captureTasks.delete(tracked); }); - return capture; - } - - #beforeAttempt(): string { - if (this.#closed) throw new Error('RSC runtime session is closed.'); - const sequence = ++this.#latestAttemptSequence; - const id = `attempt-${String(sequence)}`; - let settlePromise!: () => void; - const settled = new Promise((resolve) => { settlePromise = resolve; }); - const barrier: AttemptBarrier = { - candidate: undefined, - id, - sequence, - settle: () => { - if (!this.#attempts.delete(id)) return; - settlePromise(); - }, - settled, - }; - this.#attempts.set(id, barrier); - return id; - } - - async #capture(input: Readonly<{ - readonly attemptId: string; - readonly cohortChanged: boolean; - readonly hasErrors: boolean; - readonly sourceRevision: string; - }>): Promise { - const barrier = this.#attempts.get(input.attemptId); - if (barrier === undefined) throw new Error('RSC runtime compile capture has no live attempt barrier.'); - if (input.hasErrors) { - await this.#failAttempt(input.attemptId, new Error('RSC runtime compilation failed.'), 'source-build'); - return undefined; - } - if (input.sourceRevision.length === 0) { - await this.#failAttempt(input.attemptId, new Error('RSC runtime compilation has no source revision.')); - return undefined; - } - if (!input.cohortChanged) { - barrier.settle(); - return undefined; - } - this.#latestSupersedingAttemptSequence = Math.max(this.#latestSupersedingAttemptSequence, barrier.sequence); - const cohortRevision = ++this.#latestRscCohortRevision; - const preparedRuntime = this.#latestPreparedRuntime; - barrier.settle(); - this.#emit(Object.freeze({ runtimeGenerationId: undefined, type: 'runtime.generation.compiling' })); - try { - const candidate = await this.#generationStore.begin({ - id: `generation-${String(++this.#generationSequence)}`, - sourceRevision: input.sourceRevision, - }); - barrier.candidate = candidate; - this.#candidatesByAttempt.set(input.attemptId, candidate); - await this.#testing.beforeGenerationCapture?.(); - if (this.#closed) throw new Error('RSC runtime session is closed.'); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: input.attemptId, - candidate, - compilerAssetCheckpointTracker: this.#checkpointTracker, - compilerRoot: join(this.#context.storageRoot, 'compiler'), - preparedRuntime, - rscCohortRevision: cohortRevision, - sourceRevision: input.sourceRevision, - }); - if (this.#closed) throw new Error('RSC runtime session is closed.'); - return Object.freeze({ - acceptCompilerAssetCheckpoint: snapshot.acceptCompilerAssetCheckpoint, - attemptId: snapshot.attemptId, - candidateId: snapshot.candidate.id, - discardCompilerAssetCheckpoint: snapshot.discardCompilerAssetCheckpoint, - preparedRevision: snapshot.preparedRuntime.sourceRevision, - rscCohortRevision: snapshot.rscCohortRevision, - sourceRevision: snapshot.sourceRevision, - snapshot, - } as RscRuntimeCompileSnapshot & Readonly<{ readonly snapshot: RscRuntimeCapturedGenerationSnapshot }>); - } catch (error) { - await this.#failAttempt(input.attemptId, error); - throw error; - } - } - - #enqueue(snapshot: RscRuntimeCompileSnapshot): Promise<'activated' | 'failed'> { - const captured = (snapshot as RscRuntimeCompileSnapshot & Readonly<{ readonly snapshot?: RscRuntimeCapturedGenerationSnapshot }>).snapshot; - if (captured === undefined) throw new Error('RSC runtime compile snapshot was not captured by this session.'); - if (this.#closed) { - snapshot.discardCompilerAssetCheckpoint?.(); - return this.#failAttempt(snapshot.attemptId, new Error('RSC runtime session is closed.')).then(() => 'failed'); - } - return this.#append(async () => this.#activate(captured)); - } - - async #failAttempt( - attemptId: string, - error: unknown, - kind: RscRuntimeCompileFailureKind = 'provider-lifecycle', - ): Promise { - if (this.#failedAttempts.has(attemptId)) return; - this.#failedAttempts.add(attemptId); - this.#latestSupersedingAttemptSequence = Math.max(this.#latestSupersedingAttemptSequence, this.#sequenceFor(attemptId)); - const barrier = this.#attempts.get(attemptId); - barrier?.settle(); - const candidate = barrier?.candidate ?? this.#candidatesByAttempt.get(attemptId); - this.#candidatesByAttempt.delete(attemptId); - if (candidate !== undefined) { - const cleanup = this.#failureTail.then(() => this.#generationStore.fail(candidate)); - this.#failureTail = cleanup.catch(() => undefined); - await cleanup.catch(() => undefined); - } - if (!this.#closed) this.#setStatus( - this.#active === undefined ? 'degraded' : 'active', - [kind === 'source-build' ? sourceBuildDiagnostic() : lifecycleDiagnostic(error)], - ); - this.#emit(Object.freeze({ type: 'runtime.generation.failed' })); - } - - #activationGuard(snapshot: RscRuntimeCapturedGenerationSnapshot): RuntimeGenerationActivationGuard { - const preparedAuthorityDigest = preparedRuntimeAuthorityDigest(snapshot.preparedRuntime); - let waitedSequence = -1; - return Object.freeze({ - check: () => !this.#closed && - waitedSequence === this.#latestSupersedingAttemptSequence && - ![...this.#attempts.values()].some((attempt) => attempt.sequence > this.#sequenceFor(snapshot.attemptId)) && - snapshot.rscCohortRevision === this.#latestRscCohortRevision && - preparedAuthorityDigest === preparedRuntimeAuthorityDigest(this.#latestPreparedRuntime), - wait: async () => { - while (!this.#closed) { - const sequence = this.#sequenceFor(snapshot.attemptId); - const pending = [...this.#attempts.values()].filter((attempt) => attempt.sequence > sequence); - if (pending.length === 0) { - waitedSequence = this.#latestSupersedingAttemptSequence; - return; - } - await Promise.all(pending.map((attempt) => attempt.settled)); - } - throw new Error('RSC runtime session is closed.'); - }, - }); - } - - async #activate(snapshot: RscRuntimeCapturedGenerationSnapshot): Promise<'activated' | 'failed'> { - const guard = this.#activationGuard(snapshot); - let preparedGeneration: RuntimeGenerationPreparedActivation | undefined; - let preparedRegistry: RuntimeMcpPreparedActivationReconcile | undefined; - try { - preparedGeneration = await materializeRuntimeGeneration({ - guard, - snapshot, - stateStoreId, - store: this.#generationStore, - }); - await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'store', session: this })); - const metadata = preparedGeneration.generation.manifest.metadata; - preparedRegistry = await this.#mcpRegistry.prepareActivationReconcile({ - definitionDigest: metadata.definitionDigest, - runtimeGenerationId: preparedGeneration.generation.id, - servers: metadata.servers, - transportDigest: metadata.transportDigest, - }); - await this.#testing.afterActivationPrepare?.(Object.freeze({ phase: 'registry', session: this })); - await guard.wait(preparedGeneration.generation.manifest); - if (!guard.check(preparedGeneration.generation.manifest) || !this.#generationStore.canCommit(preparedGeneration)) { - throw new Error('RSC runtime generation activation was superseded.'); - } - const generation = this.#generationStore.commit(preparedGeneration); - const committed = this.#mcpRegistry.commitActivationReconcile(preparedRegistry); - preparedGeneration = undefined; - preparedRegistry = undefined; - this.#active = generation; - this.#updateSurfaces(snapshot, snapshot.preparedRuntime); - this.#updateSurfaceAssetApps(snapshot.preparedRuntime); - this.#setStatus('active'); - this.#emit(Object.freeze({ - mcpRegistryRevision: this.#mcpRegistry.snapshot()?.registryRevision, - runtimeGenerationId: generation.id, - type: 'runtime.generation.activated', - })); - committed.publish(); - try { - await committed.finalize(); - } catch (error) { - if (!this.#closed) this.#setStatus('degraded', [lifecycleDiagnostic(error)]); - } - return 'activated'; - } catch (error) { - if (preparedGeneration !== undefined || preparedRegistry !== undefined) { - await Promise.allSettled([ - ...(preparedGeneration === undefined ? [] : [this.#generationStore.abort(preparedGeneration)]), - ...(preparedRegistry === undefined ? [] : [this.#mcpRegistry.abortActivationReconcile(preparedRegistry)]), - ]); - } - snapshot.discardCompilerAssetCheckpoint?.(); - await this.#failAttempt(snapshot.attemptId, error); - return 'failed'; - } finally { - this.#candidatesByAttempt.delete(snapshot.attemptId); - } - } - - async #reconcilePreparedRuntime(prepared: DevRuntimePreparedProject): Promise { - const active = this.#active; - if (active === undefined || this.#closed) return; - const metadata = active.manifest.metadata; - const definition = JSON.parse(await readFile(join(active.root, 'rsc', 'runtime-definition.json'), 'utf8')) as SerializedRuntimeDefinition; - const nextDefinitionDigest = runtimeDefinitionDigest(definition, prepared); - const nextTransportDigest = transportDigest(prepared); - const current = this.#mcpRegistry.snapshot(); - if ( - current?.runtimeGenerationId === active.id && - current.definitionDigest === nextDefinitionDigest && - current.transportDigest === nextTransportDigest - ) return; - const input: DevRuntimeMcpRegistryReconcileInput = Object.freeze({ - definitionDigest: nextDefinitionDigest, - runtimeGenerationId: active.id, - servers: descriptorsFor(prepared, metadata, nextDefinitionDigest, nextTransportDigest), - transportDigest: nextTransportDigest, - }); - this.#setStatus('compiling'); - try { - await this.#mcpRegistry.reconcile(input); - this.#updateSurfaces({ definition }, prepared); - this.#updateSurfaceAssetApps(prepared); - this.#setStatus('active'); - } catch (error) { - this.#setStatus('degraded', [lifecycleDiagnostic(error)]); - throw error; - } - } - - async #executeMcp(execution: RuntimeMcpExecutionContext): Promise> { - execution.signal.throwIfAborted(); - const generation = execution.generation as RuntimeGeneration; - this.#assertMcpExecutionAuthority(execution, generation); - if (execution.request.kind === 'read-resource') { - const resource = this.#appResource(execution, generation, execution.request.uri); - const asset = await this.#readGenerationSurfaceHtml(generation, resource.surfaceId); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - return Object.freeze({ - stateVersion: 0, - value: Object.freeze({ - contents: Object.freeze([Object.freeze({ - _meta: resource.metadata, - mimeType: resource.mimeType, - text: asset, - uri: resource.uri, - })]), - }), - }); - } - if (execution.request.kind === 'call-tool') { - this.#appTool(execution, generation, execution.request.name); - return this.#executeTimelineTool(execution, generation, execution.request.arguments); - } - throw new Error(`Runtime MCP operation ${JSON.stringify(execution.request.kind)} is not available.`); - } - - #assertMcpExecutionAuthority( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - ): NonNullable> { - this.#assertInvocationOpen(); - const registry = this.#mcpRegistry.snapshot(); - const binding = this.#mcpRegistry.session(execution.sessionId)?.snapshot().binding; - if ( - registry === undefined || binding === undefined || this.#active?.id !== generation.id || - registry.runtimeGenerationId !== generation.id || - binding.sessionId !== execution.sessionId || binding.registryRevision !== registry.registryRevision || - !registry.servers.some((descriptor) => descriptor.name === execution.descriptor.name && descriptor.target === execution.descriptor.target && - descriptor.definitionDigest === execution.descriptor.definitionDigest && descriptor.serverDigest === execution.descriptor.serverDigest && - descriptor.transportDigest === execution.descriptor.transportDigest && descriptor.definitionDigest === registry.definitionDigest && - descriptor.transportDigest === registry.transportDigest && descriptor.serverDigest === generation.manifest.metadata.serverDigest) || - binding.definitionDigest !== execution.descriptor.definitionDigest || binding.serverDigest !== execution.descriptor.serverDigest || - binding.serverName !== execution.descriptor.name || binding.target !== execution.descriptor.target || - binding.transportDigest !== execution.descriptor.transportDigest - ) { - throw new DevRuntimeGenerationConflictError(generation.id, this.#active?.id); - } - return registry; - } - - #appResource( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - uri: string, - ): Readonly<{ readonly metadata: JsonObject; readonly mimeType: string; readonly surfaceId: string; readonly uri: string }> { - const resource = execution.descriptor.resources.filter((candidate) => - candidate.uri === uri && candidate.mimeType === 'text/html;profile=mcp-app' && isJsonObject(candidate._meta), - ); - const app = generation.manifest.metadata.appDefinitions.filter((candidate) => - candidate.resourceUri === uri && candidate.serverName === execution.descriptor.name && candidate.targets.includes(execution.descriptor.target), - ); - if (resource.length !== 1 || app.length !== 1) throw new Error('Runtime MCP App resource is not owned by the current generation.'); - const surfaceId = `mcp.${app[0]!.name}`; - if (generation.manifest.metadata.surfaceAssets[surfaceId] === undefined) { - throw new Error('Runtime MCP App resource has no current-generation asset.'); - } - return Object.freeze({ metadata: resource[0]!._meta as JsonObject, mimeType: resource[0]!.mimeType as string, surfaceId, uri }); - } - - #appTool( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - name: string, - ): void { - const tool = execution.descriptor.tools.filter((candidate) => candidate.name === name); - if (tool.length !== 1 || tool[0]!.handlerId !== 'render_edit_timeline' || !isJsonObject(tool[0]!._meta)) { - throw new Error('Runtime MCP App tool is not owned by the current generation.'); - } - const uri = tool[0]!._meta['openai/outputTemplate']; - if (typeof uri !== 'string') throw new Error('Runtime MCP App tool has no App resource binding.'); - this.#appResource(execution, generation, uri); - } - - #timelineLimit(argumentsValue: JsonValue | undefined): Readonly<{ readonly limit?: number }> { - if (argumentsValue === undefined) return Object.freeze({}); - if (!isJsonObject(argumentsValue) || Object.keys(argumentsValue).some((key) => key !== 'limit')) { - throw new TypeError('Runtime MCP App tool arguments are invalid.'); - } - const limit = argumentsValue.limit; - if (limit === undefined) return Object.freeze({}); - if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 50) { - throw new TypeError('Runtime MCP App tool arguments are invalid.'); - } - return Object.freeze({ limit }); - } - - async #executeTimelineTool( - execution: RuntimeMcpExecutionContext, - generation: RuntimeGeneration, - argumentsValue: JsonObject, - ): Promise> { - const release = this.#reserveInvocation(); - const runId = `runtime-mcp-${randomUUID()}`; - const abort = (): void => this.#workers.get(runId)?.terminate(new Error('Runtime MCP operation was aborted.')); - execution.signal.addEventListener('abort', abort, { once: true }); - try { - const snapshot = await this.#stateKernel.readSnapshot(this.#timelineLimit(argumentsValue)); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - const response = await this.#runInvocationWorker({ - generation, - input: Object.freeze({ - snapshot: cloneJson(snapshot), - stateFile: this.#stateFile, - stateStoreId, - type: 'mcp/render-timeline', - }), - runId, - surfaceId: 'mcp.render_edit_timeline', - }); - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - const stateVersion = response.inspection.state.identity.stateVersion; - const durable = await this.#stateKernel.readSnapshot({ stateVersion }); - const protocol = response.inspection.protocol; - if (durable.stateVersion !== stateVersion || protocol === undefined || !isJsonObject(protocol)) { - throw new Error('Runtime MCP App tool result is not a durable protocol response.'); - } - execution.signal.throwIfAborted(); - this.#assertMcpExecutionAuthority(execution, generation); - return Object.freeze({ stateVersion, value: cloneJson(protocol) }); - } finally { - execution.signal.removeEventListener('abort', abort); - release(); - } - } - - async #readGenerationSurfaceHtml( - generation: RuntimeGeneration, - surfaceId: string, - ): Promise { - const matches = generation.manifest.metadata.surfaceAssets[surfaceId]?.filter((asset) => - asset.contentType === 'text/html' && asset.requestPath === clientSurfaceEntry, - ) ?? []; - if (matches.length !== 1) throw new Error('Runtime MCP App resource has no canonical HTML asset.'); - const asset = matches[0]!; - if (asset.bytes > maximumAssetBytes) throw new Error('Runtime MCP App HTML exceeds the asset limit.'); - const segments = asset.generationPath.split('/'); - if (segments.some((segment) => !safeSegment(segment))) throw new Error('Runtime MCP App HTML asset path is unsafe.'); - const path = join(generation.root, ...segments); - if (!isInside(generation.root, path)) throw new Error('Runtime MCP App HTML asset escaped its generation root.'); - const details = await lstat(path); - if (!details.isFile() || details.isSymbolicLink() || details.size !== asset.bytes) { - throw new Error('Runtime MCP App HTML asset changed.'); - } - const body = await readFile(path); - if (body.byteLength !== asset.bytes || createHash('sha256').update(body).digest('hex') !== asset.sha256) { - throw new Error('Runtime MCP App HTML asset changed.'); - } - const text = body.toString('utf8'); - if (Buffer.byteLength(text, 'utf8') !== body.byteLength) throw new Error('Runtime MCP App HTML asset is not UTF-8.'); - return text; - } - - async #close(): Promise { - this.#closed = true; - this.#invocationAbort.abort(new Error('RSC runtime session is closing.')); - this.#hmrReady = false; - for (const attempt of [...this.#attempts.values()]) attempt.settle(); - for (const worker of this.#workers.values()) { - worker.terminate(new Error('RSC runtime session is closing.')); - } - this.#checkpointTracker.close(); - this.#setStatus('closed'); - for (const broker of this.#appBrokers.values()) broker.closedObservation?.unsubscribe(); - this.#appBrokers.clear(); - this.#surfaceAssetApps.clear(); - const mcpRegistryClose = this.#closeLiveSessionResource('runtime-mcp-registry', () => this.#mcpRegistry.close()); - void mcpRegistryClose.catch(() => undefined); - while (this.#captureTasks.size > 0) await Promise.all([...this.#captureTasks]); - while (this.#invocations.size > 0) await Promise.allSettled([...this.#invocations]); - while (this.#runReadTasks.size > 0) { - await Promise.allSettled([...this.#runReadTasks.values()].flatMap((reads) => [...reads])); - } - await this.#evictionTail; - await Promise.all([this.#providerTail.catch(() => undefined), this.#failureTail]); - const runArtifactCleanup = this.#closeRunArtifacts(); - void runArtifactCleanup.catch(() => undefined); - const resources: readonly Readonly<{ - readonly close: () => Promise; - readonly label: LiveSessionCleanupResource; - }>[] = Object.freeze([ - Object.freeze({ label: 'run-artifact' as const, close: () => runArtifactCleanup }), - Object.freeze({ label: 'owned-runs-root' as const, close: async () => { - await runArtifactCleanup.catch(() => undefined); - await this.#closeLiveSessionResource( - 'owned-runs-root', - () => RsbuildRuntimeSession.#removeOwnedRunsRoot(this.#ownedRunsRoot), - ); - } }), - Object.freeze({ label: 'rsbuild-dev-server' as const, close: () => this.#closeLiveSessionResource( - 'rsbuild-dev-server', - () => this.#server?.close() ?? Promise.resolve(), - ) }), - Object.freeze({ label: 'runtime-mcp-registry' as const, close: () => mcpRegistryClose }), - Object.freeze({ label: 'generation-store' as const, close: () => this.#closeLiveSessionResource( - 'generation-store', - () => this.#generationStore.close(), - ) }), - ]); - const results = await Promise.allSettled(resources.map((resource) => resource.close())); - const failures = results.flatMap((result, index) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, label: resources[index]!.label })] - : []); - if (failures.length > 0) throw cleanupAggregate('RSC runtime session close failed', failures); - } - - async #closeRunArtifacts(): Promise { - const artifactResults = await Promise.allSettled([...this.#runArtifacts.keys()].map((runId) => this.#releaseRunArtifact(runId))); - const directoryResults = await Promise.allSettled([...this.#pendingRunDirectoryRemovals].map(async (runId) => { - await this.#removeRunDirectory(runId); - this.#pendingRunDirectoryRemovals.delete(runId); - })); - const failures = [ - ...artifactResults.flatMap((result) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, label: 'run-artifact' })] - : []), - ...directoryResults.flatMap((result) => result.status === 'rejected' - ? [Object.freeze({ error: result.reason, label: 'run-artifact' })] - : []), - ]; - if (failures.length > 0) throw cleanupAggregate('RSC runtime run artifact cleanup failed', failures); - await this.#testing.afterLiveSessionCleanupResource?.(Object.freeze({ resource: 'run-artifact' as const })); - } - - async #closeLiveSessionResource( - resource: LiveSessionCleanupResource, - close: () => Promise, - ): Promise { - await close(); - await this.#testing.afterLiveSessionCleanupResource?.(Object.freeze({ resource })); - } - - #append(work: () => Promise): Promise { - const next = this.#providerTail.then(work, work); - this.#providerTail = next.then(() => undefined, () => undefined); - return next; - } - - #emit(event: DevRuntimeEventInput): void { - if (this.#closed) return; - try { - this.#context.emit(event); - } catch { - // Runtime listeners cannot affect lifecycle ordering. - } - } - - #sequenceFor(attemptId: string): number { - const match = /^attempt-(\d+)$/u.exec(attemptId); - return match === null ? Number.MAX_SAFE_INTEGER : Number(match[1]); - } - - #publishActiveStateVersion(generation: RuntimeGeneration, stateVersion: number): void { - if (this.#active?.id !== generation.id) return; - const current = this.#status.activeVector; - if (current?.runtimeGenerationId === generation.id && current.stateVersion > stateVersion) return; - this.#setStatus(this.#status.state, this.#status.diagnostics, stateVersion); - } - - #setStatus( - state: DevRuntimeStatus['state'], - diagnostics: readonly DevRuntimeDiagnostic[] = [], - stateVersion = 0, - ): void { - const active = this.#active; - const vector = active === undefined ? undefined : this.#vector(active, stateVersion); - this.#status = Object.freeze({ - ...(vector === undefined ? {} : { activeVector: vector, lastGoodVector: vector }), - descriptor, - diagnostics: Object.freeze([...diagnostics]), - hmrReady: this.#hmrReady, - state, - }); - } - - #updateSurfaces( - snapshot: Pick, - prepared: Pick, - ): void { - this.#surfaces.clear(); - for (const hook of snapshot.definition.nativeHooks) { - this.#surfaces.set(`hook.${hook.host}`, Object.freeze({ - id: `hook.${hook.host}`, - kind: 'hook', - label: `After tool hook (${hook.host})`, - readOnly: false, - targets: Object.freeze([hook.host]), - fixtures: fixturesForHook(hook.host), - })); - } - for (const tool of snapshot.definition.tools) { - this.#surfaces.set(`mcp.${tool.name}`, Object.freeze({ - inputSchema: cloneJsonObject(tool.inputSchema), - id: `mcp.${tool.name}`, - kind: 'mcp-tool', - label: tool.description, - readOnly: tool.annotations.readOnlyHint, - targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), - fixtures: Object.freeze([]), - })); - } - for (const resource of snapshot.definition.resources) { - this.#surfaces.set(`mcp.${resource.name}`, Object.freeze({ - id: `mcp.${resource.name}`, - kind: 'mcp-resource', - label: resource.name, - readOnly: true, - targets: Object.freeze([...prepared.servers.flatMap((server) => server.targets)]), - fixtures: Object.freeze([]), - })); - } - for (const app of prepared.apps) { - this.#surfaces.set(`mcp.${app.name}`, Object.freeze({ - id: `mcp.${app.name}`, - kind: 'mcp-app', - label: app.name, - readOnly: true, - targets: Object.freeze([...app.targets]), - fixtures: Object.freeze([]), - })); - } - } - - #updateSurfaceAssetApps(prepared: Pick): void { - this.#surfaceAssetApps.clear(); - for (const app of prepared.apps) { - this.#surfaceAssetApps.set(`mcp.${app.name}`, app); - } - } - - #surfaceAssetBinding( - generation: RuntimeGeneration, - app: DevRuntimePreparedProject['apps'][number], - ): string | undefined { - const metadata = generation.manifest.metadata; - const exact = metadata.appDefinitions.find((candidate) => - candidate.id === app.id && candidate.resourceUri === app.resourceUri, - ); - if (exact !== undefined) { - const surfaceId = `mcp.${exact.name}`; - return metadata.surfaceAssets[surfaceId] === undefined ? undefined : surfaceId; - } - const matches = metadata.appDefinitions.filter((candidate) => - candidate.resourceUri === app.resourceUri && metadata.surfaceAssets[`mcp.${candidate.name}`] !== undefined, - ); - return matches.length === 1 ? `mcp.${matches[0]!.name}` : undefined; - } - - #vector(generation: RuntimeGeneration, stateVersion = 0): RuntimeVector { - return Object.freeze({ - providerSessionId: this.providerSessionId, - runtimeGenerationId: generation.id, - sourceRevision: generation.sourceRevision, - stateStoreId, - stateVersion, - }); - } - - static async #createOwnedRunsRoot(storageRoot: string, providerSessionId: string): Promise { - await mkdir(storageRoot, { recursive: true }); - const canonicalStorageRoot = await realpath(storageRoot); - const candidate = join(canonicalStorageRoot, 'runs'); - try { - await mkdir(candidate); - } catch (error) { - const code = error instanceof Error && 'code' in error ? error.code : undefined; - if (code === 'EEXIST') { - throw new Error('RSC runtime invocation root already exists and is not owned by this provider session.', { cause: error }); - } - throw error; - } - try { - const root = await realpath(candidate); - const details = await lstat(root); - if (!isInside(canonicalStorageRoot, root) || !details.isDirectory() || details.isSymbolicLink()) { - throw new Error('RSC runtime invocation root is not a contained non-symbolic directory.'); - } - const marker = join(root, '.agent-bundle-runtime-owner'); - const token = `${providerSessionId}:${randomUUID()}`; - await writeFile(marker, token, { flag: 'wx' }); - const markerDetails = await lstat(marker); - if (!markerDetails.isFile() || markerDetails.isSymbolicLink()) { - throw new Error('RSC runtime invocation root ownership marker is unsafe.'); - } - return Object.freeze({ dev: details.dev, ino: details.ino, marker, root, token }); - } catch (error) { - await rm(candidate, { force: true, recursive: true }).catch(() => undefined); - throw error; - } - } - - static async #assertOwnedRunsRoot(owned: OwnedRunsRoot): Promise { - const details = await lstat(owned.root); - if ( - !details.isDirectory() || details.isSymbolicLink() || - details.dev !== owned.dev || details.ino !== owned.ino || - await realpath(owned.root) !== owned.root - ) { - throw new Error('RSC runtime invocation root ownership changed during this provider session.'); - } - const markerDetails = await lstat(owned.marker); - if (!markerDetails.isFile() || markerDetails.isSymbolicLink() || await readFile(owned.marker, 'utf8') !== owned.token) { - throw new Error('RSC runtime invocation root ownership marker changed during this provider session.'); - } - } - - static async #removeOwnedRunsRoot(owned: OwnedRunsRoot): Promise { - await RsbuildRuntimeSession.#assertOwnedRunsRoot(owned); - await rm(owned.root, { force: true, recursive: true }); - } - - #assertCurrentOwnedRunsRoot(): Promise { - return RsbuildRuntimeSession.#assertOwnedRunsRoot(this.#ownedRunsRoot); - } - - #validatePreparedRuntime(prepared: DevRuntimePreparedProject): void { - RsbuildRuntimeSession.#validateStartContext(this.#context, prepared); - } - - static #validateStartContext(context: DevRuntimeStartContext, prepared: DevRuntimePreparedProject): void { - if (prepared.provider !== './src/dev/provider.ts') throw new Error('RSC runtime provider declaration does not match this provider.'); - if (!isInside(context.projectRoot, resolve(context.projectRoot, prepared.provider))) { - throw new Error('RSC runtime provider declaration escapes the project root.'); - } - for (const source of [ - ...prepared.servers.flatMap((server) => [server.cwd, server.source]), - ...prepared.apps.flatMap((app) => [app.source, app.template]), - ]) { - if (source !== undefined && !isInside(context.projectRoot, resolve(context.projectRoot, source))) { - throw new Error('RSC runtime prepared declaration contains a path outside the project root.'); - } - } - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts deleted file mode 100644 index c1f009796..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/dev/serialize-inspection.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { isValidElement, type ReactNode } from 'react'; - -import type { - DevRuntimeInspectionEnvelope, - DevRuntimeTraceSpan, - DevRuntimeTreeNode, -} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; -import type { JsonObject, JsonValue } from '../../../../packages/agent-bundle/src/dev/types.ts'; - -const inspectionStartedAt = '1970-01-01T00:00:00.000Z'; -const flightPreviewBytes = 32 * 1024; - -const stripped = Symbol('inspection-stripped'); -type JsonCandidate = JsonValue | typeof stripped; - -const inspectionJsonError = (message: string): Error => new Error(`Inspection JSON contains ${message}.`); - -const isArrayIndex = (key: string, length: number): boolean => { - if (key === '0') return length > 0; - if (!/^[1-9]\d*$/u.test(key)) return false; - const index = Number(key); - return Number.isSafeInteger(index) && index < length; -}; - -/** - * Inspection output intentionally drops function and symbol values because they - * cannot cross the JSON boundary. Every other non-JSON shape is rejected so a - * decoded Flight value can never be silently changed while being inspected. - */ -const freezeJson = (value: unknown, references = new WeakSet()): JsonCandidate => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw inspectionJsonError('a non-finite number'); - return value; - } - if (typeof value === 'function' || typeof value === 'symbol') return stripped; - if (typeof value !== 'object') throw inspectionJsonError('a non-JSON value'); - if (references.has(value)) throw inspectionJsonError('a repeated or cyclic value'); - - references.add(value); - if (Array.isArray(value)) { - const keys = Reflect.ownKeys(value); - if ( - keys.length !== value.length + 1 || - keys.some((key) => key !== 'length' && (typeof key !== 'string' || !isArrayIndex(key, value.length))) - ) { - throw inspectionJsonError('a sparse or decorated array'); - } - - const output: JsonValue[] = []; - for (let index = 0; index < value.length; index += 1) { - if (!Object.hasOwn(value, index)) throw inspectionJsonError('a sparse array'); - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor)) throw inspectionJsonError('an array accessor'); - const item = freezeJson(descriptor.value, references); - if (item !== stripped) output.push(item); - } - return Object.freeze(output); - } - - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) throw inspectionJsonError('a non-plain object'); - - const output: Record = Object.create(null) as Record; - const keys = Reflect.ownKeys(value); - if (keys.some((key) => typeof key !== 'string')) throw inspectionJsonError('a symbol key'); - for (const key of keys as string[]) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { - throw inspectionJsonError('a non-enumerable or accessor property'); - } - const item = freezeJson(descriptor.value, references); - if (item !== stripped) output[key] = item; - } - return Object.freeze(Object.fromEntries(Object.entries(output).sort(([left], [right]) => left.localeCompare(right)))); -}; - -const freezeOptionalJson = (value: unknown): JsonCandidate | undefined => - value === undefined ? undefined : freezeJson(value); - -const labelForElement = (type: unknown): Readonly<{ kind: 'component' | 'element'; label: string }> => { - if (typeof type === 'string') return { kind: 'element', label: type }; - if (typeof type === 'function') { - const component = type as Readonly<{ displayName?: unknown; name?: unknown }>; - return { - kind: 'component', - label: - typeof component.displayName === 'string' && component.displayName.length > 0 - ? component.displayName - : typeof component.name === 'string' && component.name.length > 0 - ? component.name - : 'Anonymous', - }; - } - if (type === Symbol.for('react.fragment')) return { kind: 'element', label: 'Fragment' }; - return { kind: 'element', label: 'Unknown' }; -}; - -const ownDataProperties = (value: unknown, name: string): readonly (readonly [string, unknown])[] => { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`Inspection tree ${name} must be a plain object.`); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new Error(`Inspection tree ${name} must be a plain object.`); - } - const keys = Reflect.ownKeys(value); - if (keys.some((key) => typeof key !== 'string')) throw new Error(`Inspection tree ${name} contains a symbol key.`); - return Object.freeze((keys as string[]).sort().map((key) => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { - throw new Error(`Inspection tree ${name} contains a non-enumerable or accessor property.`); - } - return [key, descriptor.value] as const; - })); -}; - -const serializeProps = (value: unknown, references: WeakSet): JsonObject | undefined => { - const output: Record = Object.create(null) as Record; - for (const [key, itemValue] of ownDataProperties(value, 'props')) { - if (key === 'children') continue; - const item = freezeJson(itemValue, references); - if (item !== stripped) output[key] = item; - } - return Object.keys(output).length === 0 ? undefined : Object.freeze(output); -}; - -const childrenFor = (value: unknown): unknown => { - for (const [key, item] of ownDataProperties(value, 'props')) { - if (key === 'children') return item; - } - return undefined; -}; - -const assertTreeArray = (value: readonly unknown[]): void => { - const keys = Reflect.ownKeys(value); - if ( - keys.length !== value.length + 1 || - keys.some((key) => key !== 'length' && (typeof key !== 'string' || !isArrayIndex(key, value.length))) - ) { - throw new Error('Inspection tree contains a sparse or decorated array.'); - } - for (let index = 0; index < value.length; index += 1) { - if (!Object.hasOwn(value, index)) throw new Error('Inspection tree contains a sparse array.'); - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor)) throw new Error('Inspection tree contains an array accessor.'); - } -}; - -const serializeTree = (node: ReactNode): readonly DevRuntimeTreeNode[] => { - let nextId = 0; - const jsonReferences = new WeakSet(); - const nodes = (value: unknown, ancestors = new WeakSet()): DevRuntimeTreeNode[] => { - if (Array.isArray(value)) { - if (ancestors.has(value)) throw new Error('Inspection tree contains a cyclic value.'); - ancestors.add(value); - try { - assertTreeArray(value); - const output: DevRuntimeTreeNode[] = []; - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor)) throw new Error('Inspection tree contains an array accessor.'); - output.push(...nodes(descriptor.value, ancestors)); - } - return output; - } finally { - ancestors.delete(value); - } - } - if (value === undefined || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') return []; - if (value === null || typeof value === 'boolean') { - return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'value', label: String(value) })]; - } - if (typeof value === 'string' || typeof value === 'number') { - return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'text', label: String(value) })]; - } - if (!isValidElement(value)) { - void freezeJson(value, jsonReferences); - return [Object.freeze({ children: Object.freeze([]), id: `node-${nextId++}`, kind: 'value', label: 'Object' })]; - } - - if (ancestors.has(value)) throw new Error('Inspection tree contains a cyclic value.'); - ancestors.add(value); - try { - const id = `node-${nextId++}`; - const element = labelForElement(value.type); - const props = serializeProps(value.props, jsonReferences); - const children = nodes(childrenFor(value.props), ancestors); - return [Object.freeze({ - children: Object.freeze(children), - id, - kind: element.kind, - label: element.label, - ...(props === undefined ? {} : { props }), - })]; - } finally { - ancestors.delete(value); - } - }; - - return Object.freeze(nodes(node)); -}; - -const trace = (): readonly DevRuntimeTraceSpan[] => - Object.freeze(['normalize', 'worker', 'flight', 'decode', 'lower'].map((phase) => Object.freeze({ - id: phase, - phase, - startedAt: inspectionStartedAt, - status: 'succeeded' as const, - }))); - -export interface SerializeInspectionInput { - readonly agentVisible?: unknown; - readonly flight: Uint8Array; - readonly modelVisible?: unknown; - readonly native?: unknown; - readonly node: ReactNode; - readonly protocol?: unknown; - readonly stateStoreId: string; - readonly stateVersion: number; -} - -export const serializeInspection = (input: SerializeInspectionInput): DevRuntimeInspectionEnvelope => { - const stateStoreId = input.stateStoreId.trim(); - if (stateStoreId.length === 0) throw new Error('stateStoreId must be non-empty'); - if (!Number.isSafeInteger(input.stateVersion) || input.stateVersion < 0) { - throw new Error('stateVersion must be a non-negative safe integer'); - } - - const rawFlight = Buffer.from(input.flight); - const agentVisible = freezeOptionalJson(input.agentVisible); - const modelVisible = freezeOptionalJson(input.modelVisible); - const native = freezeOptionalJson(input.native); - const protocol = freezeOptionalJson(input.protocol); - return Object.freeze({ - ...(agentVisible === undefined || agentVisible === stripped ? {} : { agentVisible }), - flight: Object.freeze({ - bytes: rawFlight.byteLength, - preview: rawFlight.subarray(0, flightPreviewBytes).toString('base64'), - truncated: rawFlight.byteLength > flightPreviewBytes, - }), - ...(modelVisible === undefined || modelVisible === stripped ? {} : { modelVisible }), - ...(native === undefined || native === stripped ? {} : { native }), - ...(protocol === undefined || protocol === stripped ? {} : { protocol }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId, stateVersion: input.stateVersion }) }), - trace: trace(), - tree: serializeTree(input.node), - }); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts deleted file mode 100644 index 351a8a700..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/flight/request-render.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { spawn } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { Readable } from 'node:stream'; -import { fileURLToPath } from 'node:url'; - -import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; -import type { ReactNode } from 'react'; - -import type { RenderRequest } from '../runtime/contracts.js'; -import { redactInspectionDiagnostics } from '../dev/inspection-security.js'; - -export const maximumFlightRenderBytes = 4 * 1024 * 1024; -export const maximumFlightRenderStderrBytes = 256 * 1024; -export const maximumFlightRenderMetadataBytes = 128; - -const defaultTerminationGraceMs = 100; - -export interface FlightRenderResult { - readonly flight: Uint8Array; - readonly node: ReactNode; - /** Exact durable state identity captured by the render worker; never user-visible. */ - readonly stateVersion: number; -} - -export interface FlightRenderOptions { - readonly maximumFlightBytes?: number; - readonly maximumStderrBytes?: number; - readonly signal?: AbortSignal; - readonly terminationGraceMs?: number; -} - -const positiveSafeInteger = (value: number, name: string): number => { - if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`); - return value; -}; - -const workerFailure = (message: string, diagnostics: string): Error => - new Error(`${message}${diagnostics.length === 0 ? '' : `: ${diagnostics}`}`); - -const parseSnapshotMetadata = (metadata: Buffer): number => { - let text: string; - try { - text = new TextDecoder('utf-8', { fatal: true }).decode(metadata); - } catch { - throw new Error('RSC worker emitted invalid snapshot metadata.'); - } - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - throw new Error('RSC worker emitted invalid snapshot metadata.'); - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('RSC worker emitted invalid snapshot metadata.'); - } - const record = parsed as Record; - const stateVersion = record.stateVersion; - if ( - Object.keys(record).length !== 1 || - typeof stateVersion !== 'number' || - !Number.isSafeInteger(stateVersion) || - stateVersion < 0 || - text !== `{"stateVersion":${String(stateVersion)}}` - ) { - throw new Error('RSC worker emitted invalid snapshot metadata.'); - } - return stateVersion; -}; - -export const requestFlightRenderWithFlight = async ( - request: RenderRequest, - options: FlightRenderOptions = {}, -): Promise => { - const maximumFlightBytes = positiveSafeInteger(options.maximumFlightBytes ?? maximumFlightRenderBytes, 'maximumFlightBytes'); - const maximumStderrBytes = positiveSafeInteger(options.maximumStderrBytes ?? maximumFlightRenderStderrBytes, 'maximumStderrBytes'); - const terminationGraceMs = positiveSafeInteger(options.terminationGraceMs ?? defaultTerminationGraceMs, 'terminationGraceMs'); - - return new Promise((resolveRender, rejectRender) => { - const currentDirectory = dirname(fileURLToPath(import.meta.url)); - const workerPath = join(currentDirectory, '../rsc/index.js'); - const child = spawn(process.execPath, [workerPath], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); - const stdout = child.stdout; - const stderr = child.stderr; - const snapshotMetadata = child.stdio[3] as NodeJS.ReadableStream | undefined; - const flight: Buffer[] = []; - const diagnostics: Buffer[] = []; - const metadata: Buffer[] = []; - let flightBytes = 0; - let stderrBytes = 0; - let metadataBytes = 0; - let termination: Error | undefined; - let terminationGrace: ReturnType | undefined; - let closed = false; - - const cleanup = (): void => { - if (terminationGrace !== undefined) clearTimeout(terminationGrace); - options.signal?.removeEventListener('abort', abort); - }; - - const terminate = (error: Error): void => { - if (termination !== undefined || closed) return; - termination = error; - child.stdin.destroy(); - child.kill('SIGTERM'); - terminationGrace = setTimeout(() => { - if (!closed) child.kill('SIGKILL'); - }, terminationGraceMs); - }; - - const abort = (): void => terminate(new Error('RSC worker render was aborted.')); - - if (stdout === null || stderr === null || snapshotMetadata === undefined || snapshotMetadata === null) { - terminate(new Error('RSC worker streams are unavailable.')); - } else { - stdout.on('data', (chunk: Buffer | string) => { - if (termination !== undefined) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - flightBytes += buffer.byteLength; - if (flightBytes > maximumFlightBytes) { - terminate(new Error(`RSC worker Flight exceeded ${maximumFlightBytes} bytes.`)); - return; - } - flight.push(buffer); - }); - stdout.once('error', () => terminate(new Error('RSC worker Flight stream failed.'))); - stderr.on('data', (chunk: Buffer | string) => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const retained = Math.min(buffer.byteLength, Math.max(0, maximumStderrBytes - stderrBytes)); - if (retained > 0) diagnostics.push(buffer.subarray(0, retained)); - stderrBytes += buffer.byteLength; - if (stderrBytes > maximumStderrBytes) { - terminate(new Error(`RSC worker stderr exceeded ${maximumStderrBytes} bytes.`)); - } - }); - stderr.once('error', () => terminate(new Error('RSC worker stderr stream failed.'))); - snapshotMetadata.on('data', (chunk: Buffer | string) => { - if (termination !== undefined) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - metadataBytes += buffer.byteLength; - if (metadataBytes > maximumFlightRenderMetadataBytes) { - terminate(new Error(`RSC worker snapshot metadata exceeded ${maximumFlightRenderMetadataBytes} bytes.`)); - return; - } - metadata.push(buffer); - }); - snapshotMetadata.once('error', () => terminate(new Error('RSC worker snapshot metadata stream failed.'))); - } - - child.stdin.once('error', () => terminate(new Error('RSC worker request stream failed.'))); - child.once('error', () => terminate(new Error('RSC worker could not be started.'))); - child.once('close', (code) => { - closed = true; - cleanup(); - const output = redactInspectionDiagnostics(Buffer.concat(diagnostics).toString('utf8')); - if (termination !== undefined) { - rejectRender(workerFailure(termination.message, output)); - return; - } - if (code !== 0) { - rejectRender(workerFailure(`RSC worker exited with code ${String(code)}`, output)); - return; - } - void (async () => { - try { - const rawFlight = Buffer.concat(flight); - const node = await createFromReadableStream( - Readable.toWeb(Readable.from([rawFlight])) as ReadableStream, - ); - resolveRender(Object.freeze({ flight: rawFlight, node, stateVersion: parseSnapshotMetadata(Buffer.concat(metadata)) })); - } catch { - rejectRender(new Error('RSC worker emitted invalid Flight data.')); - } - })(); - }); - - options.signal?.addEventListener('abort', abort, { once: true }); - if (options.signal?.aborted) { - abort(); - return; - } - try { - child.stdin.end(JSON.stringify(request)); - } catch { - terminate(new Error('RSC worker request could not be encoded.')); - } - }); -}; - -export const requestFlightRender = async (request: RenderRequest): Promise => - (await requestFlightRenderWithFlight(request)).node; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts deleted file mode 100644 index f9b7ac3be..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/cli.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { appendFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; - -import { requestFlightRender } from '../flight/request-render.js'; -import { lowerHookResult } from '@agent-bundle/rsc-runtime'; -import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js'; -import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js'; - -let probeInput: Record | undefined; - -const valueType = (value: unknown): string => { - if (value === null) return 'null'; - if (Array.isArray(value)) return 'array'; - return typeof value; -}; - -const writeEvalProbe = async (input: Record, exitStatus: number): Promise => { - const probeFile = process.env.AGENT_RUNTIME_HOOK_PROBE_FILE; - if (probeFile === undefined || probeFile.trim() === '') return; - - const toolInput = input.tool_input; - const toolInputRecord = toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput) - ? toolInput as Record - : undefined; - const topLevelKeys = Object.keys(input).sort(); - const toolInputKeys = toolInputRecord === undefined ? [] : Object.keys(toolInputRecord).sort(); - await appendFile(probeFile, `${JSON.stringify({ - commandLaunched: true, - exitStatus, - toolInputKeys, - toolInputValueTypes: Object.fromEntries(toolInputKeys.map((key) => [key, valueType(toolInputRecord?.[key])])), - toolName: typeof input.tool_name === 'string' ? input.tool_name : undefined, - topLevelKeys, - topLevelValueTypes: Object.fromEntries(topLevelKeys.map((key) => [key, valueType(input[key])])), - })}\n`); -}; - -const readInput = async (): Promise> => { - let contents = ''; - process.stdin.setEncoding('utf8'); - for await (const chunk of process.stdin) { - contents += chunk; - } - - const parsed: unknown = JSON.parse(contents); - if (parsed === null || typeof parsed !== 'object') { - throw new Error('Native hook input must be a JSON object'); - } - - return parsed as Record; -}; - -const readHost = (): 'claude' | 'codex' => { - const host = process.argv[process.argv.indexOf('--host') + 1]; - if (host !== 'claude' && host !== 'codex') { - throw new Error('Expected --host claude or codex'); - } - - return host; -}; - -const run = async (): Promise => { - const host = readHost(); - const input = await readInput(); - probeInput = input; - const event = host === 'claude' ? normalizeClaudeHook(input) : normalizeCodexHook(input); - const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE; - const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === '' - ? await resolveImplicitRuntimeStateFile(event.cwd) - : resolve(configuredStateFile); - - const result = await requestFlightRender({ - event, - stateFile, - type: 'hook/after-file-edit', - }); - process.stdout.write(`${JSON.stringify(lowerHookResult(result))}\n`); - await writeEvalProbe(input, 0); -}; - -run().catch(async (error: unknown) => { - if (probeInput !== undefined) await writeEvalProbe(probeInput, 1).catch(() => undefined); - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts deleted file mode 100644 index 1c62ec94c..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/hook/normalize.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { resolve } from 'node:path'; - -import type { CanonicalPostToolUse } from '../runtime/contracts.js'; - -type NativeHookInput = Record; - -const asRecord = (value: unknown): Record | undefined => - value !== null && typeof value === 'object' ? (value as Record) : undefined; - -const readString = (input: NativeHookInput, key: string): string | undefined => - typeof input[key] === 'string' ? input[key] : undefined; - -const readRequiredString = (input: NativeHookInput, key: string): string => { - const value = readString(input, key); - if (value === undefined || value.trim() === '') { - throw new Error(`Native hook input requires ${key}`); - } - - return value; -}; - -const readIdempotencyKey = (host: CanonicalPostToolUse['host'], input: NativeHookInput): string => { - const toolUseId = readString(input, 'tool_use_id')?.trim(); - if (toolUseId !== undefined && toolUseId !== '') { - return `${host}:tool:${toolUseId}`; - } - - const eventId = readString(input, 'event_id')?.trim(); - if (eventId !== undefined && eventId !== '') { - return `${host}:event:${eventId}`; - } - - throw new Error('Mutating native hook input requires a nonempty tool_use_id or event_id'); -}; - -const readBaseEvent = (host: CanonicalPostToolUse['host'], input: NativeHookInput) => { - if (readRequiredString(input, 'hook_event_name') !== 'PostToolUse') { - throw new Error('Only PostToolUse events are supported'); - } - - return { - cwd: readRequiredString(input, 'cwd'), - host, - idempotencyKey: readIdempotencyKey(host, input), - sessionId: readRequiredString(input, 'session_id'), - toolName: readRequiredString(input, 'tool_name'), - }; -}; - -const resolveNativePath = (cwd: string, path: string): string => { - if (path.trim() === '') { - throw new Error('Native hook input requires a file path'); - } - - return resolve(cwd, path); -}; - -export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => { - const event = readBaseEvent('claude', input); - if (event.toolName !== 'Write' && event.toolName !== 'Edit') { - throw new Error('Claude hook supports only Write and Edit'); - } - - const toolInput = asRecord(input.tool_input); - if (toolInput === undefined) { - throw new Error('Native hook input requires tool_input'); - } - - return { - ...event, - path: resolveNativePath(event.cwd, readRequiredString(toolInput, 'file_path')), - }; -}; - -export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => { - const event = readBaseEvent('codex', input); - if (event.toolName !== 'apply_patch') { - throw new Error('Codex hook supports only apply_patch'); - } - - const toolInput = asRecord(input.tool_input); - if (toolInput === undefined) { - throw new Error('Native hook input requires tool_input'); - } - - const command = readRequiredString(toolInput, 'command'); - const path = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/m.exec(command)?.[1]; - if (path === undefined) { - throw new Error('Codex apply_patch command requires a file header'); - } - - return { ...event, path: resolveNativePath(event.cwd, path) }; -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts deleted file mode 100644 index 63bec7f4b..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/create-server.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - -import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - -import { runtimeDefinition } from '../definition.js'; -import { createMcpHandlers } from './handlers.js'; -import { resourceMetadata } from './host-metadata.js'; -import type { McpRequestExtra, ResolveStateOptions } from './resolve-state.js'; - -export interface CreateRuntimeMcpServerOptions extends ResolveStateOptions { - publicMcpUrl?: string; - widgetHtml?: string; -} - -const defaultWidgetPath = (): string => - join(dirname(process.argv[1] ?? process.cwd()), '../../app/edit-timeline-v1.html'); - -const defaultWidgetHtml = async (): Promise => readFile(defaultWidgetPath(), 'utf8'); - -export const createRuntimeMcpServer = (options: CreateRuntimeMcpServerOptions = {}): McpServer => { - const server = new McpServer({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); - const handlers = createMcpHandlers(options); - - for (const tool of runtimeDefinition.tools) { - const handler = handlers[tool.handlerId]; - if (handler === undefined) { - throw new Error(`No MCP handler registered for ${tool.handlerId}`); - } - - const callback = (input: unknown, extra: McpRequestExtra) => - handler( - input !== null && typeof input === 'object' && typeof (input as { limit?: unknown }).limit === 'number' - ? { limit: (input as { limit: number }).limit } - : {}, - extra, - ); - const config = { - _meta: tool._meta, - annotations: tool.annotations, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - }; - - if (tool._meta.ui !== undefined) { - registerAppTool(server, tool.name, config, callback); - } else { - server.registerTool(tool.name, config, callback); - } - } - - for (const resource of runtimeDefinition.resources) { - const registrationMetadata = resourceMetadata(resource); - const contentMetadata = resourceMetadata(resource, options.publicMcpUrl); - registerAppResource( - server, - resource.name, - resource.uri, - { _meta: registrationMetadata, mimeType: RESOURCE_MIME_TYPE }, - async () => ({ - contents: [ - { - _meta: contentMetadata, - mimeType: RESOURCE_MIME_TYPE, - text: options.widgetHtml ?? (await defaultWidgetHtml()), - uri: resource.uri, - }, - ], - }), - ); - } - - return server; -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts deleted file mode 100644 index b4dadd3b9..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/handlers.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createFileRuntimeKernel } from '../runtime/state-file.js'; -import { lowerMcpResult } from '@agent-bundle/rsc-runtime'; -import { requestFlightRender } from '../flight/request-render.js'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -import { resolveStateFile, type McpRequestExtra, type ResolveStateOptions } from './resolve-state.js'; - -type ToolInput = { limit?: number }; -type McpToolHandler = (input: ToolInput, extra: McpRequestExtra) => Promise; - -const textSnapshot = (snapshot: { edits: unknown[]; stateVersion: number }): CallToolResult => ({ - content: [{ text: JSON.stringify(snapshot), type: 'text' }], - structuredContent: snapshot, -}); - -export const createMcpHandlers = (options: ResolveStateOptions): Record => ({ - recent_edits: async (input, extra) => { - const stateFile = await resolveStateFile(options, extra); - const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit }); - return textSnapshot(snapshot); - }, - render_edit_timeline: async (input, extra) => { - const stateFile = await resolveStateFile(options, extra); - const snapshot = await createFileRuntimeKernel({ stateFile }).readSnapshot({ limit: input.limit }); - return lowerMcpResult( - await requestFlightRender({ snapshot, stateFile, type: 'mcp/render-timeline' }), - ); - }, - runtime_status: async (_input, extra) => { - const stateFile = await resolveStateFile(options, extra); - return lowerMcpResult(await requestFlightRender({ stateFile, type: 'mcp/runtime-status' })); - }, -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts deleted file mode 100644 index c6e93e22d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/host-metadata.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createHash } from 'node:crypto'; - -import type { RuntimeResourceDefinition } from '../runtime/contracts.js'; - -export type SerializableValue = null | boolean | number | string | SerializableValue[] | { [key: string]: SerializableValue }; -export type SerializableMetadata = Record; - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const cloneSerializableValue = (value: unknown, seen: Set = new Set()): SerializableValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') { - return value; - } - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (value === null || typeof value !== 'object' || seen.has(value)) { - throw new Error('Metadata must be JSON-serializable'); - } - - seen.add(value); - try { - if (Array.isArray(value)) { - return value.map((item) => cloneSerializableValue(item, seen)); - } - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { - throw new Error('Metadata must be JSON-serializable'); - } - - const clone: SerializableMetadata = Object.create(null) as SerializableMetadata; - for (const [key, item] of Object.entries(value)) { - clone[key] = cloneSerializableValue(item, seen); - } - return clone; - } finally { - seen.delete(value); - } -}; - -/** - * Copies extension metadata verbatim while enforcing the portable JSON boundary. - * Namespaces are deliberately opaque to this runtime. - */ -export const mergeSerializableMetadata = (...values: Array | undefined>): SerializableMetadata => { - const result: SerializableMetadata = Object.create(null) as SerializableMetadata; - for (const value of values) { - if (value === undefined) { - continue; - } - if (!isRecord(value)) { - throw new Error('Metadata must be a JSON object'); - } - for (const [key, item] of Object.entries(value)) { - result[key] = cloneSerializableValue(item); - } - } - return result; -}; - -export const claudeStableAppDomain = (publicMcpUrl: string): string => { - const parsed = new URL(publicMcpUrl); - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - throw new Error('Public MCP URL must use HTTP or HTTPS'); - } - - return `${createHash('sha256').update(publicMcpUrl).digest('hex').slice(0, 32)}.claudemcpcontent.com`; -}; - -/** - * Converts the definition's portable resource fields into the MCP Apps shape. - * The Claude domain is opt-in and belongs only to returned resource content. - */ -export const resourceMetadata = ( - resource: RuntimeResourceDefinition, - publicMcpUrl?: string, -): SerializableMetadata => { - const source = mergeSerializableMetadata(resource._meta); - const csp = source['ui.csp']; - const prefersBorder = source['ui.prefersBorder']; - const existingUi = isRecord(source.ui) ? source.ui : undefined; - delete source['ui.csp']; - delete source['ui.prefersBorder']; - delete source.ui; - - return mergeSerializableMetadata(source, { - ui: mergeSerializableMetadata(existingUi, { - ...(csp === undefined ? {} : { csp }), - ...(prefersBorder === undefined ? {} : { prefersBorder }), - ...(publicMcpUrl === undefined ? {} : { domain: claudeStableAppDomain(publicMcpUrl) }), - }), - }); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts deleted file mode 100644 index 96a3d48c3..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http-security.ts +++ /dev/null @@ -1,84 +0,0 @@ -const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; - -export interface HttpSecurityConfig { - allowedHosts: string[]; - allowedOrigins: string[]; -} - -const valuesFromEnvironment = (value: string | undefined, name: string): string[] => { - const values = value - ?.split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry !== '') ?? []; - - if (values.includes('*')) { - throw new Error(`${name} must not include a wildcard`); - } - - return values; -}; - -const normalizeHostname = (value: string, name: string): string => { - try { - const hostname = new URL(`http://${value}`).hostname; - if (hostname !== value.toLowerCase()) { - throw new Error('hostnames must not include a port'); - } - - return hostname; - } catch { - throw new Error(`${name} contains an invalid hostname: ${value}`); - } -}; - -const normalizeOrigin = (value: string, name: string): string => { - try { - const origin = new URL(value); - if (!['http:', 'https:'].includes(origin.protocol) || origin.origin !== value) { - throw new Error('origins must be exact HTTP(S) origins'); - } - - return origin.origin; - } catch { - throw new Error(`${name} contains an invalid origin: ${value}`); - } -}; - -const sameHttpOrigin = (hostHeader: string | undefined): string | undefined => { - if (hostHeader === undefined) { - return undefined; - } - - try { - return new URL(`http://${hostHeader}`).origin; - } catch { - return undefined; - } -}; - -export const resolveHttpSecurityConfig = (environment: NodeJS.ProcessEnv = process.env): HttpSecurityConfig => ({ - allowedHosts: [ - ...new Set([ - ...loopbackHosts, - ...valuesFromEnvironment(environment.AGENT_RUNTIME_ALLOWED_HOSTS, 'AGENT_RUNTIME_ALLOWED_HOSTS').map((value) => - normalizeHostname(value, 'AGENT_RUNTIME_ALLOWED_HOSTS'), - ), - ]), - ], - allowedOrigins: [ - ...new Set( - valuesFromEnvironment(environment.AGENT_RUNTIME_ALLOWED_ORIGINS, 'AGENT_RUNTIME_ALLOWED_ORIGINS').map((value) => - normalizeOrigin(value, 'AGENT_RUNTIME_ALLOWED_ORIGINS'), - ), - ), - ], -}); - -export const allowsOrigin = ( - config: HttpSecurityConfig, - hostHeader: string | undefined, - originHeader: string | undefined, -): boolean => - originHeader === undefined || - originHeader === sameHttpOrigin(hostHeader) || - config.allowedOrigins.includes(originHeader); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts deleted file mode 100644 index 87359817d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/http.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; - -import { createRuntimeMcpServer } from './create-server.js'; -import { allowsOrigin, resolveHttpSecurityConfig } from './http-security.js'; - -const port = Number.parseInt(process.env.PORT ?? '3000', 10); -const security = resolveHttpSecurityConfig(); -const app = createMcpExpressApp({ allowedHosts: security.allowedHosts }); - -app.use((request, response, next) => { - if (allowsOrigin(security, request.get('host'), request.get('origin'))) { - next(); - return; - } - - response.status(403).json({ - error: { code: -32000, message: `Invalid Origin header: ${request.get('origin')}` }, - id: null, - jsonrpc: '2.0', - }); -}); - -app.get('/health', (_request, response) => { - response.json({ ok: true, transport: 'streamable-http' }); -}); - -app.post('/mcp', async (request, response) => { - const server = createRuntimeMcpServer({ publicMcpUrl: process.env.AGENT_RUNTIME_PUBLIC_MCP_URL }); - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - - try { - await server.connect(transport); - await transport.handleRequest(request, response, request.body); - } catch (error) { - if (!response.headersSent) { - response.status(500).json({ error: error instanceof Error ? error.message : String(error) }); - } - } finally { - await server.close(); - } -}); - -const httpServer = app.listen(port, '127.0.0.1', () => { - const address = httpServer.address(); - const actualPort = typeof address === 'object' && address !== null ? address.port : port; - process.stderr.write(`${JSON.stringify({ port: actualPort, transport: 'streamable-http' })}\n`); -}); - -const close = (): void => { - httpServer.close(() => process.exit(0)); -}; - -process.once('SIGINT', close); -process.once('SIGTERM', close); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts deleted file mode 100644 index ed65653e2..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/resolve-state.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { resolve } from 'node:path'; - -import { ListRootsResultSchema, type ServerNotification, type ServerRequest } from '@modelcontextprotocol/sdk/types.js'; -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; - -import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js'; - -export type McpRequestExtra = RequestHandlerExtra; - -export interface ResolveStateOptions { - stateFile?: string; - resolveStateFile?: (extra: McpRequestExtra) => string | undefined | Promise; -} - -const usablePath = (value: string | undefined): string | undefined => - value === undefined || value.trim() === '' ? undefined : resolve(value); - -const stateFileFromRoots = async (extra: McpRequestExtra): Promise => { - try { - const result = await extra.sendRequest({ method: 'roots/list' }, ListRootsResultSchema); - const root = result.roots[0]; - if (root === undefined) { - return undefined; - } - - return resolveImplicitRuntimeStateFile(fileURLToPath(root.uri)); - } catch { - return undefined; - } -}; - -export const resolveStateFile = async (options: ResolveStateOptions, extra: McpRequestExtra): Promise => { - const resolvedByOption = options.resolveStateFile === undefined ? undefined : await options.resolveStateFile(extra); - const explicit = usablePath(resolvedByOption) ?? usablePath(options.stateFile); - if (explicit !== undefined) { - return explicit; - } - - const fromEnvironment = usablePath(process.env.AGENT_RUNTIME_STATE_FILE); - if (fromEnvironment !== undefined) { - return fromEnvironment; - } - - const fromRoots = await stateFileFromRoots(extra); - if (fromRoots !== undefined) { - return fromRoots; - } - - return resolveImplicitRuntimeStateFile(process.cwd()); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts deleted file mode 100644 index 6676b5014..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/mcp/stdio.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; - -import { createRuntimeMcpServer } from './create-server.js'; - -const run = async (): Promise => { - const server = createRuntimeMcpServer(); - await server.connect(new StdioServerTransport()); -}; - -run().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts deleted file mode 100644 index 26d9bbca7..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/client-anchor.ts +++ /dev/null @@ -1,3 +0,0 @@ -'use client'; - -export const clientAnchor = true; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx deleted file mode 100644 index 78147e3b6..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/components.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { basename } from 'node:path'; - -import { Hook, Mcp } from '@agent-bundle/rsc-runtime'; -import type { RuntimeSnapshot } from '../runtime/contracts.js'; -import { useEdit, useRuntimeSnapshot } from '../runtime/request-context.js'; - -export const AfterFileEdit = () => { - const edit = useEdit(); - const snapshot = useRuntimeSnapshot(); - const editCount = snapshot.stateVersion; - const editNoun = editCount === 1 ? 'edit' : 'edits'; - - return ( - - - {`Recorded ${basename(edit.path)} from ${edit.host}. Shared state now contains ${editCount} ${editNoun}.`} - - - ); -}; - -export const RenderEditTimeline = ({ snapshot }: { snapshot: RuntimeSnapshot }) => ( - - {`Showing ${snapshot.edits.length} recorded edits.`} - -); - -const STATUS_PNG_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'; - -export const RuntimeStatus = ({ snapshot }: { snapshot: RuntimeSnapshot }) => { - const editCount = snapshot.edits.length; - const editNoun = editCount === 1 ? 'edit' : 'edits'; - - return ( - - {`Runtime state contains ${editCount} ${editNoun}.`} - - - ); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx deleted file mode 100644 index 735d5efff..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/routes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; - -import type { RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; -import { AfterFileEdit, RenderEditTimeline, RuntimeStatus } from './components.js'; - -export const renderRoute = (request: RenderRequest, snapshot: RuntimeSnapshot): ReactNode => { - if (request.type === 'hook/after-file-edit') { - return ; - } - - if (request.type === 'mcp/render-timeline') { - return ; - } - - if (request.type === 'mcp/runtime-status') { - return ; - } - - throw new Error('Unsupported RSC render request'); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx deleted file mode 100644 index 34dc6d6e4..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/rsc/worker.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Readable } from 'node:stream'; -import { finished } from 'node:stream/promises'; -import { resolve } from 'node:path'; -import { writeSync } from 'node:fs'; - -import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; - -import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js'; -import { withRenderContext } from '../runtime/request-context.js'; -import { createFileRuntimeKernel } from '../runtime/state-file.js'; -import { renderRoute } from './routes.js'; - -const asRecord = (value: unknown): Record | undefined => - value !== null && typeof value === 'object' ? (value as Record) : undefined; - -const readString = (value: Record, key: string): string | undefined => - typeof value[key] === 'string' ? value[key] : undefined; - -const readRequiredString = (value: Record, key: string): string => { - const result = readString(value, key); - if (result === undefined || result.trim() === '') { - throw new Error(`RSC worker requires a nonempty ${key}`); - } - return result; -}; - -const parseEvent = (value: unknown): CanonicalPostToolUse => { - const event = asRecord(value); - if (event === undefined) { - throw new Error('RSC worker received an invalid event'); - } - - const host = readString(event, 'host'); - if (host !== 'claude' && host !== 'codex') { - throw new Error('RSC worker received an invalid event'); - } - return { - cwd: readRequiredString(event, 'cwd'), - host, - idempotencyKey: readRequiredString(event, 'idempotencyKey'), - path: readRequiredString(event, 'path'), - sessionId: readRequiredString(event, 'sessionId'), - toolName: readRequiredString(event, 'toolName'), - }; -}; - -const parseSnapshot = (value: unknown): RuntimeSnapshot => { - const snapshot = asRecord(value); - const stateVersion = snapshot?.stateVersion; - if ( - snapshot === undefined || - typeof stateVersion !== 'number' || - !Number.isInteger(stateVersion) || - stateVersion < 0 || - !Array.isArray(snapshot.edits) - ) { - throw new Error('RSC worker received an invalid runtime snapshot'); - } - - return snapshot.seed === undefined - ? { edits: snapshot.edits as RuntimeSnapshot['edits'], stateVersion } - : { edits: snapshot.edits as RuntimeSnapshot['edits'], seed: snapshot.seed as RuntimeSnapshot['seed'], stateVersion }; -}; - -const parseRequest = (value: unknown): RenderRequest => { - const request = asRecord(value); - if (request === undefined) { - throw new Error('RSC worker received an unsupported render request'); - } - - const stateFile = readRequiredString(request, 'stateFile'); - - if (request.type === 'hook/after-file-edit') { - return { - event: parseEvent(request.event), - stateFile: resolve(stateFile), - type: 'hook/after-file-edit', - }; - } - - if (request.type === 'mcp/render-timeline') { - return { - snapshot: parseSnapshot(request.snapshot), - stateFile: resolve(stateFile), - type: 'mcp/render-timeline', - }; - } - - if (request.type === 'mcp/runtime-status') { - return { stateFile: resolve(stateFile), type: 'mcp/runtime-status' }; - } - - throw new Error('RSC worker received an unsupported render request'); -}; - -const readRequest = async (): Promise => { - let contents = ''; - process.stdin.setEncoding('utf8'); - for await (const chunk of process.stdin) { - contents += chunk; - } - - return parseRequest(JSON.parse(contents)); -}; - -const render = async (): Promise => { - const request = await readRequest(); - const runtime = createFileRuntimeKernel({ stateFile: request.stateFile }); - const snapshot = - request.type === 'hook/after-file-edit' - ? await runtime.recordEdit({ - host: request.event.host, - idempotencyKey: request.event.idempotencyKey, - path: request.event.path, - sessionId: request.event.sessionId, - toolName: request.event.toolName, - }) - : request.type === 'mcp/render-timeline' - ? request.snapshot - : await runtime.readSnapshot(); - - const renderFlight = async (): Promise => { - const flight = renderToReadableStream(renderRoute(request, snapshot)); - const output = Readable.from(flight); - output.pipe(process.stdout, { end: false }); - await finished(output); - }; - - const writeSnapshotMetadata = (): void => { - const metadata = Buffer.from(`{"stateVersion":${String(snapshot.stateVersion)}}`, 'utf8'); - let offset = 0; - while (offset < metadata.byteLength) { - offset += writeSync(3, metadata, offset, metadata.byteLength - offset); - } - }; - - if (request.type === 'hook/after-file-edit') { - await withRenderContext({ edit: request.event, snapshot }, renderFlight); - } else { - await renderFlight(); - } - writeSnapshotMetadata(); -}; - -render().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts deleted file mode 100644 index bfb5dc9c7..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/contracts.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { ZodType } from 'zod'; -import type { - DevRuntimeInspectionEnvelope, - DevRuntimeMcpServerDescriptor, -} from '../../../../packages/agent-bundle/src/dev/runtime-protocol.ts'; -import type { JsonObject } from '../../../../packages/agent-bundle/src/dev/types.ts'; - -export interface EditEvent { - eventId: string; - host: 'claude' | 'codex'; - sessionId: string; - toolName: string; - path: string; - recordedAt: string; -} - -export type JsonValue = - | null - | boolean - | number - | string - | readonly JsonValue[] - | Readonly<{ [key: string]: JsonValue }>; - -export type RuntimeStateRecord = - | Readonly<{ - event: EditEvent; - idempotencyKey: string; - kind: 'edit'; - stateVersion: number; - }> - | Readonly<{ - idempotencyKey: string; - kind: 'reset'; - seed?: JsonValue; - stateVersion: number; - }>; - -export interface RuntimeSnapshot { - stateVersion: number; - edits: EditEvent[]; - readonly seed?: JsonValue; -} - -export interface RuntimeKernel { - recordEdit( - input: Omit & Readonly<{ idempotencyKey: string }>, - options?: RuntimeMutationOptions, - ): Promise; - resetState( - input: Readonly<{ idempotencyKey: string; seed?: JsonValue }>, - options?: RuntimeMutationOptions, - ): Promise; - readSnapshot(options?: RuntimeSnapshotReadOptions): Promise; -} - -/** Internal durable-state read options; this is not part of the runtime provider protocol. */ -export interface RuntimeSnapshotReadOptions { - readonly limit?: number; - /** Reconstruct the exact validated durable prefix at this version. */ - readonly stateVersion?: number; -} - -export interface RuntimeMutationOptions { - /** Bounded caller wait for an existing owner; lock timing itself is never caller-configurable. */ - lockAcquireTimeoutMs?: number; - signal?: AbortSignal; -} - -export interface CanonicalPostToolUse { - host: 'claude' | 'codex'; - idempotencyKey: string; - sessionId: string; - cwd: string; - toolName: string; - path: string; -} - -export interface HookRenderRequest { - type: 'hook/after-file-edit'; - stateFile: string; - event: CanonicalPostToolUse; -} - -export interface McpRenderTimelineRequest { - type: 'mcp/render-timeline'; - stateFile: string; - snapshot: RuntimeSnapshot; -} - -export interface McpRuntimeStatusRequest { - type: 'mcp/runtime-status'; - stateFile: string; -} - -export type RenderRequest = HookRenderRequest | McpRenderTimelineRequest | McpRuntimeStatusRequest; - -export interface DevRuntimeHookInspectionRequest { - readonly host: 'claude' | 'codex'; - readonly input: Readonly>; - readonly stateFile: string; - readonly stateStoreId: string; - readonly type: 'hook/after-file-edit'; -} - -export interface DevRuntimeMcpTimelineInspectionRequest { - readonly snapshot: RuntimeSnapshot; - readonly stateFile: string; - readonly stateStoreId: string; - readonly type: 'mcp/render-timeline'; -} - -export interface DevRuntimeMcpStatusInspectionRequest { - readonly stateFile: string; - readonly stateStoreId: string; - readonly type: 'mcp/runtime-status'; -} - -export type DevRuntimeInspectionRequest = - | DevRuntimeHookInspectionRequest - | DevRuntimeMcpTimelineInspectionRequest - | DevRuntimeMcpStatusInspectionRequest; - -export interface DevRuntimeInspectionResponse { - /** Raw Flight bytes are sent over the provider-owned fd 3 side channel. */ - readonly flightBytes: number; - readonly inspection: DevRuntimeInspectionEnvelope; -} - -export type McpTimeline = RuntimeSnapshot; - -export interface ToolAnnotations { - readOnlyHint: boolean; - destructiveHint: boolean; - idempotentHint: boolean; - openWorldHint: boolean; -} - -export interface RuntimeToolDefinition { - name: string; - description: string; - inputSchema: ZodType; - outputSchema: ZodType; - annotations: ToolAnnotations; - handlerId: string; - _meta: Record; -} - -export interface NativeHookDefinition { - host: 'claude' | 'codex'; - event: 'PostToolUse' | 'after_tool_use'; - matcher: string; - handlerId: string; -} - -export interface RuntimeResourceDefinition { - name: string; - uri: string; - mimeType: string; - _meta: Record & { - 'ui.prefersBorder': true; - 'ui.csp': { - connectDomains: []; - resourceDomains: []; - }; - 'openai/widgetDescription': string; - }; -} - -export interface RuntimeDefinition { - tools: RuntimeToolDefinition[]; - nativeHooks: NativeHookDefinition[]; - resources: RuntimeResourceDefinition[]; -} - -export interface SerializedRuntimeToolDefinition extends Omit { - inputSchema: Record; - outputSchema: Record; -} - -export interface SerializedRuntimeDefinition { - tools: SerializedRuntimeToolDefinition[]; - nativeHooks: NativeHookDefinition[]; - resources: RuntimeResourceDefinition[]; -} - -export interface RscRuntimeSurfaceAsset { - readonly bytes: number; - readonly contentType: 'application/javascript' | 'application/json' | 'text/css' | 'text/html'; - readonly generationPath: string; - readonly requestPath: string; - readonly sha256: string; -} - -export interface RscRuntimeAppDefinition { - readonly _meta?: JsonObject; - readonly id: string; - readonly name: string; - readonly resourceUri: string; - readonly serverId: string; - readonly serverName: string; - readonly targets: readonly string[]; -} - -export interface RscRuntimeGenerationMetadata { - readonly appDefinitions: readonly RscRuntimeAppDefinition[]; - readonly definitionDigest: string; - readonly entries: Readonly>; - readonly environmentHashes: Readonly>; - readonly preparedRevision: string; - readonly serverDigest: string; - readonly servers: readonly DevRuntimeMcpServerDescriptor[]; - readonly stateStoreId: string; - readonly surfaceAssets: Readonly>; - readonly transportDigest: string; -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts deleted file mode 100644 index 4315709dc..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/request-context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createRscRequestContext } from '@agent-bundle/rsc-runtime'; - -import type { CanonicalPostToolUse, RuntimeSnapshot } from './contracts.js'; - -export interface RenderContext { - edit: CanonicalPostToolUse; - snapshot: RuntimeSnapshot; -} - -const renderContext = createRscRequestContext('RSC runtime hook'); - -export const withRenderContext = (context: RenderContext, operation: () => T): T => - renderContext.run(context, operation); - -export const useEdit = (): CanonicalPostToolUse => renderContext.use().edit; - -export const useRuntimeSnapshot = (): RuntimeSnapshot => renderContext.use().snapshot; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts deleted file mode 100644 index a2e69df41..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-core.ts +++ /dev/null @@ -1,781 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -import { lock as acquireLockfile } from 'proper-lockfile'; - -import type { - EditEvent, - JsonValue, - RuntimeKernel, - RuntimeMutationOptions, - RuntimeSnapshot, - RuntimeSnapshotReadOptions, - RuntimeStateRecord, -} from './contracts.js'; - -export const MAX_STATE_BYTES = 16 * 1024 * 1024; - -export class RuntimeStateCorruptionError extends Error { - readonly line: number; - readonly offset: number; - - constructor({ line, message, offset }: { line: number; message: string; offset: number }) { - super(`Runtime state corruption at line ${line}, byte ${offset}: ${message}`); - this.name = 'RuntimeStateCorruptionError'; - this.line = line; - this.offset = offset; - } -} - -export class RuntimeStateLockError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'RuntimeStateLockError'; - } -} - -export interface StateKernelPolicy { - readonly acquireLimitMs: number; - readonly mutationMs: number; - readonly ownerSettlementMs: number; - readonly releaseMs: number; - readonly retryDelayMs: number; - readonly staleMs: number; - readonly updateMs: number; - readonly terminateOwner: (error: RuntimeStateLockError) => void; -} - -export type StateLeaseRelease = () => Promise; - -export interface StateStorage { - readonly acquire: (input: Readonly<{ - onCompromised: (error: Error) => void; - stale: number; - stateFile: string; - update: number; - }>) => Promise; - readonly append: (stateFile: string, contents: Buffer, signal: AbortSignal) => Promise; - readonly prepare: (stateFile: string, signal: AbortSignal) => Promise; - readonly read: (stateFile: string, signal: AbortSignal) => Promise; - readonly readOwnerStaleMs: (stateFile: string, signal: AbortSignal) => Promise; - readonly removeOwner: (stateFile: string, signal: AbortSignal) => Promise; - readonly repair: (stateFile: string, completeBytes: number, signal: AbortSignal) => Promise; - readonly writeOwner: (stateFile: string, staleMs: number, signal: AbortSignal) => Promise; -} - -export interface StateKernelInput { - readonly createId?: () => string; - readonly now?: () => Date; - readonly policy: StateKernelPolicy; - readonly stateFile: string; - readonly storage: StateStorage; -} - -interface ParsedState { - readonly completeBytes: number; - readonly records: readonly RuntimeStateRecord[]; - readonly snapshot: RuntimeSnapshot; -} - -interface OperationOwner { - readonly controller: AbortController; - unsafeToRelease: boolean; -} - -type Settled = - | Readonly<{ type: 'error'; error: Error }> - | Readonly<{ type: 'value'; value: T }>; - -const asRecord = (value: unknown): Record | undefined => - value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; - -const hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => { - const actualKeys = Object.keys(value).sort(); - const expectedKeys = [...keys].sort(); - return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); -}; - -const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''; - -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - const record = asRecord(value); - return record !== undefined && Object.values(record).every(isJsonValue); -}; - -const isEditEvent = (value: unknown): value is EditEvent => { - const event = asRecord(value); - return ( - event !== undefined && - hasOnlyKeys(event, ['eventId', 'host', 'path', 'recordedAt', 'sessionId', 'toolName']) && - isNonEmptyString(event.eventId) && - (event.host === 'claude' || event.host === 'codex') && - isNonEmptyString(event.sessionId) && - isNonEmptyString(event.toolName) && - isNonEmptyString(event.path) && - isNonEmptyString(event.recordedAt) - ); -}; - -const canonicalize = (value: JsonValue): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; - const object = value as Readonly>; - return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`).join(',')}}`; -}; - -const canonicalRecordInput = (record: RuntimeStateRecord): string => - record.kind === 'edit' - ? canonicalize({ - event: { - host: record.event.host, - path: record.event.path, - sessionId: record.event.sessionId, - toolName: record.event.toolName, - }, - kind: 'edit', - }) - : canonicalize(record.seed === undefined ? { kind: 'reset' } : { kind: 'reset', seed: record.seed }); - -const parseStateRecord = ({ line, offset, value }: { line: number; offset: number; value: unknown }): RuntimeStateRecord => { - const record = asRecord(value); - const stateVersion = record?.stateVersion; - if ( - record === undefined || - !isNonEmptyString(record.idempotencyKey) || - typeof stateVersion !== 'number' || - !Number.isInteger(stateVersion) || - stateVersion < 1 - ) { - throw new RuntimeStateCorruptionError({ line, message: 'record shape is invalid', offset }); - } - if (record.kind === 'edit') { - if (!hasOnlyKeys(record, ['event', 'idempotencyKey', 'kind', 'stateVersion']) || !isEditEvent(record.event)) { - throw new RuntimeStateCorruptionError({ line, message: 'edit record shape is invalid', offset }); - } - return { event: record.event, idempotencyKey: record.idempotencyKey, kind: 'edit', stateVersion }; - } - if (record.kind === 'reset') { - if ( - !hasOnlyKeys(record, record.seed === undefined - ? ['idempotencyKey', 'kind', 'stateVersion'] - : ['idempotencyKey', 'kind', 'seed', 'stateVersion']) || - (record.seed !== undefined && !isJsonValue(record.seed)) - ) { - throw new RuntimeStateCorruptionError({ line, message: 'reset record shape is invalid', offset }); - } - return record.seed === undefined - ? { idempotencyKey: record.idempotencyKey, kind: 'reset', stateVersion } - : { idempotencyKey: record.idempotencyKey, kind: 'reset', seed: record.seed, stateVersion }; - } - throw new RuntimeStateCorruptionError({ line, message: 'record kind is invalid', offset }); -}; - -const snapshotForRecords = (records: readonly RuntimeStateRecord[], limit?: number): RuntimeSnapshot => { - let edits: EditEvent[] = []; - let seed: JsonValue | undefined; - for (const record of records) { - if (record.kind === 'edit') { - edits = [...edits, record.event]; - } else { - edits = []; - seed = record.seed; - } - } - const visibleEdits = limit === undefined ? edits : edits.slice(-limit); - return seed === undefined - ? { edits: visibleEdits, stateVersion: records.length } - : { edits: visibleEdits, seed, stateVersion: records.length }; -}; - -const parseSnapshot = (contents: Buffer): ParsedState => { - if (contents.byteLength > MAX_STATE_BYTES) { - throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); - } - let completeBytes = contents.byteLength; - if (contents.byteLength > 0 && contents[contents.byteLength - 1] !== 0x0a) { - const lastNewline = contents.lastIndexOf(0x0a); - completeBytes = lastNewline < 0 ? 0 : lastNewline + 1; - } - const records: RuntimeStateRecord[] = []; - const idempotencyKeys = new Set(); - let offset = 0; - let line = 1; - while (offset < completeBytes) { - const newline = contents.indexOf(0x0a, offset); - const end = newline < 0 ? completeBytes : newline; - let raw: unknown; - try { - raw = JSON.parse(contents.subarray(offset, end).toString('utf8')); - } catch { - throw new RuntimeStateCorruptionError({ line, message: 'record is not valid JSON', offset }); - } - const record = parseStateRecord({ line, offset, value: raw }); - const expectedVersion = records.length + 1; - if (record.stateVersion !== expectedVersion) { - throw new RuntimeStateCorruptionError({ - line, - message: `expected monotonic state version ${expectedVersion}, received ${record.stateVersion}`, - offset, - }); - } - if (idempotencyKeys.has(record.idempotencyKey)) { - throw new RuntimeStateCorruptionError({ line, message: `duplicate idempotency key ${record.idempotencyKey}`, offset }); - } - idempotencyKeys.add(record.idempotencyKey); - records.push(record); - offset = end + 1; - line += 1; - } - return { completeBytes, records, snapshot: snapshotForRecords(records) }; -}; - -const abortError = (signal: AbortSignal): Error => - signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); - -const settled = (operation: Promise): Promise> => - operation.then( - (value) => ({ type: 'value', value }), - (error: unknown) => ({ type: 'error', error: error instanceof Error ? error : new Error(String(error)) }), - ); - -const cancellation = (signal: AbortSignal): Promise> => - signal.aborted - ? Promise.resolve({ type: 'cancelled', error: abortError(signal) }) - : new Promise((resolve) => { - signal.addEventListener('abort', () => resolve({ type: 'cancelled', error: abortError(signal) }), { once: true }); - }); - -const isAlreadyLocked = (error: unknown): boolean => (error as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED'; - -const delay = async (milliseconds: number, signal: AbortSignal): Promise => { - if (signal.aborted) throw abortError(signal); - await new Promise((resolve, reject) => { - const timer = setTimeout(done, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(abortError(signal)); - }; - function done() { - signal.removeEventListener('abort', onAbort); - resolve(); - } - signal.addEventListener('abort', onAbort, { once: true }); - }); -}; - -const validateLimit = (limit: number | undefined): void => { - if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 50)) { - throw new RangeError('limit must be an integer from 1 through 50'); - } -}; - -const validateStateVersion = (stateVersion: number | undefined): void => { - if (stateVersion !== undefined && (!Number.isSafeInteger(stateVersion) || stateVersion < 0)) { - throw new RangeError('stateVersion must be a nonnegative safe integer'); - } -}; - -export const createRuntimeStateKernel = ({ - createId = randomUUID, - now = () => new Date(), - policy, - stateFile, - storage, -}: StateKernelInput): RuntimeKernel => { - let poisoned: RuntimeStateLockError | undefined; - const owners = new Set(); - - const poison = (error: RuntimeStateLockError, fatal: boolean): RuntimeStateLockError => { - poisoned ??= error; - for (const owner of owners) owner.controller.abort(poisoned); - if (fatal) { - try { - policy.terminateOwner(poisoned); - } catch { - // The permanent poisoned state remains authoritative if teardown itself throws. - } - } - return poisoned; - }; - - const assertHealthy = (signal?: AbortSignal): void => { - if (signal?.aborted === true) throw abortError(signal); - if (poisoned !== undefined) throw poisoned; - }; - - const createOwner = (signal: AbortSignal | undefined): OperationOwner => { - assertHealthy(signal); - const owner: OperationOwner = { controller: new AbortController(), unsafeToRelease: false }; - if (signal !== undefined) { - if (signal.aborted) owner.controller.abort(abortError(signal)); - else signal.addEventListener('abort', () => owner.controller.abort(abortError(signal)), { once: true }); - } - owners.add(owner); - return owner; - }; - - const armDeadline = (owner: OperationOwner, deadline: number, error: Error): ReturnType => - setTimeout(() => owner.controller.abort(error), Math.max(0, deadline - Date.now())); - - const releaseRaw = async (owner: OperationOwner, rawRelease: StateLeaseRelease, label: string): Promise => { - const operation = settled(rawRelease()); - const timeoutError = new RuntimeStateLockError(`${label} exceeded ${policy.releaseMs} ms`); - let releaseTimer: ReturnType | undefined; - const timeout = new Promise>((resolve) => { - releaseTimer = setTimeout(() => resolve({ type: 'timeout', error: timeoutError }), policy.releaseMs); - }); - const outcome = await Promise.race([operation, timeout]); - clearTimeout(releaseTimer); - if (outcome.type === 'timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${outcome.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - if (outcome.type === 'error') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${label} failed; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - }; - - const awaitUnowned = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([settled(operation), cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'cancelled') throw outcome.error; - if (outcome.type === 'error') throw outcome.error; - if (Date.now() >= deadline) { - owner.controller.abort(timeoutError); - throw timeoutError; - } - return outcome.value; - }; - - const awaitAcquisition = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const phase = settled(operation); - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'cancelled') { - void phase.then(async (late) => { - if (late.type === 'value') await releaseRaw(owner, late.value, 'Late runtime state lease release'); - }).catch(() => undefined); - throw outcome.error; - } - if (outcome.type === 'error') throw outcome.error; - if (Date.now() >= deadline || owner.controller.signal.aborted || poisoned !== undefined) { - const reason = poisoned ?? (owner.controller.signal.aborted ? abortError(owner.controller.signal) : timeoutError); - await releaseRaw(owner, outcome.value, 'Late runtime state lease release'); - throw reason; - } - return outcome.value; - }; - - const awaitOwned = async ( - owner: OperationOwner, - deadline: number, - operation: Promise, - timeoutError: RuntimeStateLockError, - ): Promise => { - const phase = settled(operation); - const timer = armDeadline(owner, deadline, timeoutError); - const outcome = await Promise.race([phase, cancellation(owner.controller.signal)]); - clearTimeout(timer); - if (outcome.type === 'error') throw outcome.error; - if (outcome.type === 'value') { - if (poisoned !== undefined) { - owner.unsafeToRelease = true; - throw poisoned; - } - if (owner.controller.signal.aborted) { - throw abortError(owner.controller.signal); - } - if (Date.now() >= deadline) { - owner.controller.abort(timeoutError); - throw timeoutError; - } - return outcome.value; - } - - if (poisoned !== undefined) { - owner.unsafeToRelease = true; - throw poisoned; - } - const settlementTimeout = new RuntimeStateLockError( - `Runtime state phase did not settle within ${policy.ownerSettlementMs} ms after cancellation`, - ); - let settlementTimer: ReturnType | undefined; - const settlement = await Promise.race([ - phase, - new Promise>((resolve) => { - settlementTimer = setTimeout( - () => resolve({ type: 'settlement-timeout', error: settlementTimeout }), - policy.ownerSettlementMs, - ); - }), - ]); - clearTimeout(settlementTimer); - if (settlement.type === 'settlement-timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`${settlement.error.message}; this kernel is permanently poisoned`, { cause: outcome.error }), - true, - ); - } - throw outcome.error; - }; - - const releaseLease = async (owner: OperationOwner, canonicalStateFile: string, rawRelease: StateLeaseRelease): Promise => { - let metadataFailure: Error | undefined; - try { - const removal = settled(storage.removeOwner(canonicalStateFile, owner.controller.signal)); - let removalTimer: ReturnType | undefined; - const timeout = new Promise>((resolve) => { - removalTimer = setTimeout(() => resolve({ type: 'timeout' }), policy.releaseMs); - }); - const outcome = await Promise.race([removal, timeout]); - clearTimeout(removalTimer); - if (outcome.type === 'timeout') { - owner.unsafeToRelease = true; - throw poison( - new RuntimeStateLockError(`Runtime state lease release exceeded ${policy.releaseMs} ms; this kernel is permanently poisoned`), - true, - ); - } - if (outcome.type === 'error') metadataFailure = outcome.error; - } catch (error) { - if (owner.unsafeToRelease) throw error; - metadataFailure = error instanceof Error ? error : new Error(String(error)); - } - - try { - await releaseRaw(owner, rawRelease, 'Runtime state lease release'); - } catch (releaseError) { - if (metadataFailure === undefined) throw releaseError; - throw new AggregateError( - [metadataFailure, releaseError], - 'Runtime state lease release failed', - { cause: releaseError }, - ); - } - if (metadataFailure !== undefined) throw metadataFailure; - }; - - const acquireLease = async (signal: AbortSignal | undefined, timeoutMs: number) => { - const owner = createOwner(signal); - const deadline = Date.now() + timeoutMs; - const timeoutError = new RuntimeStateLockError(`Timed out acquiring runtime state lease after ${timeoutMs} ms`); - let rawRelease: StateLeaseRelease | undefined; - try { - const canonicalStateFile = await awaitUnowned(owner, deadline, storage.prepare(stateFile, owner.controller.signal), timeoutError); - while (true) { - assertHealthy(owner.controller.signal); - const ownerStale = await awaitUnowned( - owner, - deadline, - storage.readOwnerStaleMs(canonicalStateFile, owner.controller.signal), - timeoutError, - ); - try { - rawRelease = await awaitAcquisition( - owner, - deadline, - storage.acquire({ - onCompromised: (error) => { - owner.unsafeToRelease = true; - const compromise = new RuntimeStateLockError( - 'Runtime state lease was compromised; this kernel is permanently poisoned', - { cause: error }, - ); - owner.controller.abort(compromise); - poison(compromise, true); - }, - stale: Math.max(policy.staleMs, ownerStale), - stateFile: canonicalStateFile, - update: policy.updateMs, - }), - timeoutError, - ); - await awaitOwned( - owner, - deadline, - storage.writeOwner(canonicalStateFile, policy.staleMs, owner.controller.signal), - timeoutError, - ); - return { canonicalStateFile, owner, rawRelease }; - } catch (error) { - if (rawRelease !== undefined && !owner.unsafeToRelease) { - await releaseRaw(owner, rawRelease, 'Runtime state lease release'); - rawRelease = undefined; - } - if (!isAlreadyLocked(error)) throw error; - rawRelease = undefined; - await awaitUnowned( - owner, - deadline, - delay(Math.min(policy.retryDelayMs, Math.max(0, deadline - Date.now())), owner.controller.signal), - timeoutError, - ); - } - } - } catch (error) { - owners.delete(owner); - throw error; - } - }; - - const readSnapshot = async ({ limit, stateVersion }: RuntimeSnapshotReadOptions = {}): Promise => { - validateLimit(limit); - validateStateVersion(stateVersion); - assertHealthy(); - const controller = new AbortController(); - const parsed = parseSnapshot(await storage.read(stateFile, controller.signal)); - if (stateVersion !== undefined) { - if (stateVersion > parsed.records.length) throw new RangeError(`state version ${stateVersion} is unavailable`); - return snapshotForRecords(parsed.records.slice(0, stateVersion), limit); - } - return snapshotForRecords(parsed.records, limit); - }; - - const mutate = async (record: RuntimeStateRecord, options: RuntimeMutationOptions | undefined): Promise => { - if (!isNonEmptyString(record.idempotencyKey)) { - throw new TypeError('Runtime state mutations require a nonempty idempotency key'); - } - if (record.kind === 'edit' && !isEditEvent(record.event)) { - throw new TypeError('Runtime state edits require every event field to be nonempty and valid'); - } - if (record.kind === 'reset' && record.seed !== undefined && !isJsonValue(record.seed)) { - throw new TypeError('Runtime state reset seed must be JSON-safe'); - } - const timeoutMs = options?.lockAcquireTimeoutMs ?? policy.acquireLimitMs; - if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > policy.acquireLimitMs) { - throw new RangeError(`lockAcquireTimeoutMs must be an integer from 1 through ${policy.acquireLimitMs}`); - } - assertHealthy(options?.signal); - const lease = await acquireLease(options?.signal, timeoutMs); - const deadline = Date.now() + policy.mutationMs; - const timeoutError = new RuntimeStateLockError( - `Runtime state mutation exceeded ${policy.mutationMs} ms critical-section limit`, - ); - let result: RuntimeSnapshot | undefined; - let failure: unknown; - try { - const bytes = await awaitOwned( - lease.owner, - deadline, - storage.read(lease.canonicalStateFile, lease.owner.controller.signal), - timeoutError, - ); - const parsed = parseSnapshot(bytes); - const sameKey = parsed.records.find((current) => current.idempotencyKey === record.idempotencyKey); - if (sameKey !== undefined) { - if (canonicalRecordInput(sameKey) !== canonicalRecordInput(record)) { - throw new RuntimeStateLockError(`Runtime state idempotency key ${record.idempotencyKey} was reused with conflicting input`); - } - result = parsed.snapshot; - } else { - if (parsed.completeBytes !== bytes.byteLength) { - await awaitOwned( - lease.owner, - deadline, - storage.repair(lease.canonicalStateFile, parsed.completeBytes, lease.owner.controller.signal), - timeoutError, - ); - } - const nextRecord: RuntimeStateRecord = record.kind === 'edit' - ? { ...record, event: record.event, stateVersion: parsed.snapshot.stateVersion + 1 } - : record.seed === undefined - ? { ...record, stateVersion: parsed.snapshot.stateVersion + 1 } - : { ...record, seed: record.seed, stateVersion: parsed.snapshot.stateVersion + 1 }; - const serialized = Buffer.from(`${JSON.stringify(nextRecord)}\n`, 'utf8'); - if (parsed.completeBytes + serialized.byteLength > MAX_STATE_BYTES) { - throw new RuntimeStateLockError(`Runtime state file cannot exceed ${MAX_STATE_BYTES} bytes`); - } - await awaitOwned( - lease.owner, - deadline, - storage.append(lease.canonicalStateFile, serialized, lease.owner.controller.signal), - timeoutError, - ); - result = snapshotForRecords([...parsed.records, nextRecord]); - } - } catch (error) { - failure = error; - } - - if (!lease.owner.unsafeToRelease) { - try { - await releaseLease(lease.owner, lease.canonicalStateFile, lease.rawRelease); - } catch (error) { - failure = failure === undefined - ? error - : new AggregateError( - [failure, error], - 'Runtime state mutation and lease release failed', - { cause: error }, - ); - } - } - owners.delete(lease.owner); - if (failure !== undefined) throw failure; - return result!; - }; - - return { - recordEdit(input, options) { - return mutate({ - event: { - eventId: createId(), - host: input.host, - path: input.path, - recordedAt: now().toISOString(), - sessionId: input.sessionId, - toolName: input.toolName, - }, - idempotencyKey: input.idempotencyKey, - kind: 'edit', - stateVersion: 0, - }, options); - }, - resetState(input, options) { - return mutate( - input.seed === undefined - ? { idempotencyKey: input.idempotencyKey, kind: 'reset', stateVersion: 0 } - : { idempotencyKey: input.idempotencyKey, kind: 'reset', seed: input.seed, stateVersion: 0 }, - options, - ); - }, - readSnapshot, - }; -}; - -const metadataFile = (stateFile: string): string => `${stateFile}.agent-runtime-lock.json`; - -export const createNodeStateStorage = ({ - platform = process.platform, - syncParent, -}: Readonly<{ - platform?: NodeJS.Platform; - syncParent?: (directory: string) => Promise; -}> = {}): StateStorage => ({ - acquire: (input) => acquireLockfile(input.stateFile, { - onCompromised: input.onCompromised, - realpath: false, - retries: 0, - stale: input.stale, - update: input.update, - }), - async append(stateFile, contents, signal) { - if (signal.aborted) throw abortError(signal); - const handle = await open(stateFile, 'a'); - try { - await handle.writeFile(contents); - await handle.sync(); - } finally { - await handle.close(); - } - }, - async prepare(stateFile) { - await mkdir(dirname(stateFile), { recursive: true }); - let created = false; - try { - await stat(stateFile); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - try { - const handle = await open(stateFile, 'wx'); - await handle.sync(); - await handle.close(); - created = true; - } catch (createError) { - if ((createError as NodeJS.ErrnoException).code !== 'EEXIST') throw createError; - } - } - if (created) { - try { - if (syncParent !== undefined) await syncParent(dirname(stateFile)); - else { - const parent = await open(dirname(stateFile), 'r'); - try { - await parent.sync(); - } finally { - await parent.close(); - } - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (!(platform === 'win32' && (code === 'EPERM' || code === 'EINVAL'))) throw error; - } - } - const canonical = await realpath(stateFile); - const details = await lstat(canonical); - if (!details.isFile() || details.isSymbolicLink()) { - throw new RuntimeStateLockError(`Runtime state path is not a regular file: ${stateFile}`); - } - return canonical; - }, - async read(stateFile) { - try { - const handle = await open(stateFile, 'r'); - try { - const contents = Buffer.allocUnsafe(MAX_STATE_BYTES + 1); - let offset = 0; - while (offset < contents.byteLength) { - const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset); - if (bytesRead === 0) break; - offset += bytesRead; - } - if (offset > MAX_STATE_BYTES) { - throw new RuntimeStateCorruptionError({ line: 1, message: `state file exceeds ${MAX_STATE_BYTES} byte limit`, offset: 0 }); - } - return contents.subarray(0, offset); - } finally { - await handle.close(); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Buffer.alloc(0); - throw error; - } - }, - async readOwnerStaleMs(stateFile) { - try { - const metadata: unknown = JSON.parse(await readFile(metadataFile(stateFile), 'utf8')); - const stale = asRecord(metadata)?.stale; - return typeof stale === 'number' && Number.isInteger(stale) && stale > 0 ? stale : 0; - } catch { - return 0; - } - }, - removeOwner: (stateFile) => rm(metadataFile(stateFile), { force: true }), - async repair(stateFile, completeBytes) { - const handle = await open(stateFile, 'r+'); - try { - await handle.truncate(completeBytes); - await handle.sync(); - } finally { - await handle.close(); - } - }, - writeOwner: (stateFile, staleMs, signal) => - writeFile(metadataFile(stateFile), JSON.stringify({ stale: staleMs }), { encoding: 'utf8', signal }), -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts deleted file mode 100644 index 19c5d2c39..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file-test-support.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { RuntimeKernel } from './contracts.js'; -import { open } from 'node:fs/promises'; -import { - createNodeStateStorage, - createRuntimeStateKernel, - type StateKernelPolicy, - type StateLeaseRelease, - type StateStorage, -} from './state-file-core.js'; -import type { FileRuntimeKernelOptions } from './state-file.js'; - -export interface RuntimeStateTestAdapter { - readonly acquireLock?: StateStorage['acquire']; - readonly beforeAppend?: () => Promise; - readonly beforeAppendSync?: () => Promise; - readonly beforeAppendWrite?: () => Promise; - readonly beforeRead?: () => Promise; - readonly beforeRelease?: () => Promise; - readonly beforeRepair?: () => Promise; - readonly criticalSectionMs?: number; - readonly fatalOwnerTeardown?: (error: Error) => void; - readonly ownerSettlementMs?: number; - readonly platform?: NodeJS.Platform; - readonly prepareStateFile?: (input: Readonly<{ stateFile: string }>) => Promise; - readonly readState?: StateStorage['read']; - readonly releaseMs?: number; - readonly syncParent?: (directory: string) => Promise; -} - -export interface TestFileRuntimeKernelOptions extends FileRuntimeKernelOptions { - readonly adapter?: RuntimeStateTestAdapter; -} - -const wrapRelease = ( - release: StateLeaseRelease, - adapter: RuntimeStateTestAdapter, -): StateLeaseRelease => async () => { - await adapter.beforeRelease?.(); - await release(); -}; - -export const createTestFileRuntimeKernel = ({ adapter = {}, ...options }: TestFileRuntimeKernelOptions): RuntimeKernel => { - const native = createNodeStateStorage({ platform: adapter.platform, syncParent: adapter.syncParent }); - const storage: StateStorage = { - ...native, - acquire: async (input) => wrapRelease( - await (adapter.acquireLock === undefined ? native.acquire(input) : adapter.acquireLock(input)), - adapter, - ), - async append(stateFile, contents, signal) { - await adapter.beforeAppend?.(); - if (signal.aborted) { - throw signal.reason instanceof Error ? signal.reason : new Error('Runtime state mutation was aborted'); - } - if (adapter.beforeAppendWrite !== undefined || adapter.beforeAppendSync !== undefined) { - const handle = await open(stateFile, 'a'); - try { - await adapter.beforeAppendWrite?.(); - if (signal.aborted) throw signal.reason; - await handle.writeFile(contents); - await adapter.beforeAppendSync?.(); - if (signal.aborted) throw signal.reason; - await handle.sync(); - return; - } finally { - await handle.close(); - } - } - return native.append(stateFile, contents, signal); - }, - prepare: adapter.prepareStateFile === undefined - ? native.prepare - : (stateFile) => adapter.prepareStateFile!({ stateFile }), - read: adapter.readState ?? (async (stateFile, signal) => { - await adapter.beforeRead?.(); - return native.read(stateFile, signal); - }), - async repair(stateFile, completeBytes, signal) { - await adapter.beforeRepair?.(); - if (signal.aborted) throw signal.reason; - return native.repair(stateFile, completeBytes, signal); - }, - }; - const policy: StateKernelPolicy = { - acquireLimitMs: 30_000, - mutationMs: adapter.criticalSectionMs ?? 10_000, - ownerSettlementMs: adapter.ownerSettlementMs ?? 100, - releaseMs: adapter.releaseMs ?? 100, - retryDelayMs: 25, - staleMs: 2_000, - terminateOwner: (error) => adapter.fatalOwnerTeardown?.(error), - updateMs: 1_000, - }; - return createRuntimeStateKernel({ - createId: options.createId, - now: options.now, - policy, - stateFile: options.stateFile, - storage, - }); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts deleted file mode 100644 index 73175a34b..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/runtime/state-file.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createHash } from 'node:crypto'; -import { homedir } from 'node:os'; -import { isAbsolute, join, resolve } from 'node:path'; -import { realpath } from 'node:fs/promises'; - -import type { RuntimeKernel } from './contracts.js'; -import { - createNodeStateStorage, - createRuntimeStateKernel, - RuntimeStateCorruptionError, - RuntimeStateLockError, - type StateKernelPolicy, -} from './state-file-core.js'; - -const PRODUCTION_POLICY: StateKernelPolicy = Object.freeze({ - acquireLimitMs: 30_000, - mutationMs: 10_000, - ownerSettlementMs: 10_000, - releaseMs: 10_000, - retryDelayMs: 25, - staleMs: 30_000, - terminateOwner(error: RuntimeStateLockError) { - process.stderr.write(`${error.message}\n`); - process.kill(process.pid, 'SIGTERM'); - }, - updateMs: 5_000, -}); - -export { RuntimeStateCorruptionError, RuntimeStateLockError }; - -export interface FileRuntimeKernelOptions { - stateFile: string; - now?: () => Date; - createId?: () => string; -} - -export const createFileRuntimeKernel = (options: FileRuntimeKernelOptions): RuntimeKernel => - createRuntimeStateKernel({ - createId: options.createId, - now: options.now, - policy: PRODUCTION_POLICY, - stateFile: options.stateFile, - storage: createNodeStateStorage(), - }); - -const stateHome = (): string => { - const configured = process.env.XDG_STATE_HOME; - return configured !== undefined && configured.trim() !== '' && isAbsolute(configured) - ? configured - : join(homedir(), '.local', 'state'); -}; - -/** Resolves implicit host state outside the repository from one canonical workspace identity. */ -export const resolveImplicitRuntimeStateFile = async (workspaceRoot: string): Promise => { - const canonicalWorkspace = await realpath(resolve(workspaceRoot)); - const workspaceId = createHash('sha256').update(canonicalWorkspace).digest('hex'); - return join(stateHome(), 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts deleted file mode 100644 index 7c6b7371c..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/mcp-ext-apps-react.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -declare module '@modelcontextprotocol/ext-apps/react' { - import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; - import type { App, McpUiAppCapabilities, McpUiHostContext } from '@modelcontextprotocol/ext-apps'; - - export type UseAppOptions = { - appInfo: Implementation; - capabilities: McpUiAppCapabilities; - onAppCreated?: (app: App) => void; - }; - - export type AppState = { - app: App | null; - error: Error | null; - isConnected: boolean; - }; - - export function useApp(options: UseAppOptions): AppState; - export function useHostStyles(app: App | null, initialContext?: McpUiHostContext | null): void; -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts deleted file mode 100644 index 9e7be798b..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/react-server-dom-rspack.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -type RscTemporaryReferenceSet = unknown; - -type RscOptions = { - onError?: (error: unknown) => string | undefined; - temporaryReferences?: RscTemporaryReferenceSet; -}; - -type RscClientOptions = { - temporaryReferences?: RscTemporaryReferenceSet; -}; - -declare module 'react-server-dom-rspack/client.node' { - export function createFromReadableStream( - stream: ReadableStream, - options?: RscClientOptions, - ): Promise; -} - -declare module 'react-server-dom-rspack/server.node' { - export function renderToReadableStream( - model: unknown, - options?: RscOptions, - ): ReadableStream; -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts deleted file mode 100644 index 35306c6fc..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/types/styles.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '*.css'; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx deleted file mode 100644 index 473fff255..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/App.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react'; - -import type { EditEvent } from '../runtime/contracts.js'; -import { createWidgetStateAdapter, safeAreaCustomProperties, type HostContext } from './host-adapters.js'; - -type TimelineState = { stateVersion: number; edits: EditEvent[] }; -export type RefreshState = 'idle' | 'refreshing' | 'error'; - -const standaloneTimeline: TimelineState = { - edits: [ - { - eventId: 'concept-1', - host: 'claude', - path: 'src/runtime/state.ts', - recordedAt: '2026-08-14T10:24:31.000Z', - sessionId: 'concept', - toolName: 'Write', - }, - { - eventId: 'concept-2', - host: 'codex', - path: 'src/widget/App.tsx', - recordedAt: '2026-08-14T10:21:07.000Z', - sessionId: 'concept', - toolName: 'Edit', - }, - { - eventId: 'concept-3', - host: 'claude', - path: 'README.md', - recordedAt: '2026-08-14T10:17:42.000Z', - sessionId: 'concept', - toolName: 'Read', - }, - ], - stateVersion: 3, -}; - -const asTimelineState = (value: unknown): TimelineState | undefined => { - if (value === null || typeof value !== 'object') { - return undefined; - } - - const state = value as Record; - if (!Number.isInteger(state.stateVersion) || !Array.isArray(state.edits)) { - return undefined; - } - - const edits = state.edits.filter( - (edit): edit is EditEvent => - edit !== null && - typeof edit === 'object' && - typeof (edit as Record).eventId === 'string' && - ((edit as Record).host === 'claude' || (edit as Record).host === 'codex') && - typeof (edit as Record).path === 'string' && - typeof (edit as Record).recordedAt === 'string' && - typeof (edit as Record).sessionId === 'string' && - typeof (edit as Record).toolName === 'string', - ); - - return edits.length === state.edits.length ? { edits, stateVersion: state.stateVersion as number } : undefined; -}; - -const displayTime = (recordedAt: string): string => - new Intl.DateTimeFormat('en-US', { - hour: 'numeric', - hour12: true, - minute: '2-digit', - second: '2-digit', - }).format(new Date(recordedAt)); - -export const RefreshStatus = ({ refresh }: { refresh: RefreshState }) => { - const message = - refresh === 'refreshing' ? 'Refreshing timeline.' : refresh === 'error' ? 'Unable to refresh timeline.' : ''; - - return ( -

- {message} -

- ); -}; - -export const App = () => { - const standalone = window.parent === window; - const [timeline, setTimeline] = useState(standalone ? standaloneTimeline : { edits: [], stateVersion: 0 }); - const [refresh, setRefresh] = useState('idle'); - const [hostContext, setHostContext] = useState(); - const [selectedEventId, setSelectedEventId] = useState(); - const widgetState = useMemo(() => createWidgetStateAdapter(window as Window & { openai?: unknown }), []); - const { app } = useApp({ - appInfo: { name: 'rsc-agent-runtime-timeline', version: '1.0.0' }, - capabilities: {}, - onAppCreated: (createdApp) => { - createdApp.onteardown = () => ({}); - createdApp.ontoolresult = (result) => { - const state = asTimelineState(result.structuredContent); - if (state !== undefined) { - setTimeline(state); - setRefresh('idle'); - } - }; - createdApp.onhostcontextchanged = (context) => { - setHostContext((previous) => ({ ...previous, ...context })); - }; - }, - }); - const initialHostContext = app?.getHostContext(); - useHostStyles(app, initialHostContext); - - const activeHostContext = hostContext ?? initialHostContext; - useEffect(() => { - const validIds = timeline.edits.map((edit) => edit.eventId); - setSelectedEventId((selected) => { - if (selected !== undefined && validIds.includes(selected)) { - return selected; - } - return widgetState.restore(validIds); - }); - }, [timeline.edits, widgetState]); - - const selectEvent = (eventId: string): void => { - setSelectedEventId(eventId); - widgetState.persist(eventId); - }; - - const refreshTimeline = async (): Promise => { - setRefresh('refreshing'); - if (standalone) { - setTimeline((state) => ({ ...state, stateVersion: state.stateVersion + 1 })); - setRefresh('idle'); - return; - } - - if (app === null) { - setRefresh('error'); - return; - } - - try { - const result = await app.callServerTool({ name: 'render_edit_timeline', arguments: { limit: 10 } }); - const state = asTimelineState(result.structuredContent); - if (state === undefined) { - throw new Error('The runtime returned an invalid timeline.'); - } - - setTimeline(state); - setRefresh('idle'); - } catch { - setRefresh('error'); - } - }; - - return ( -
-
-
-

Runtime edit timeline

-

Hook events, shared across processes.

-
- -
- - - {timeline.edits.length === 0 ? ( -

No file edits recorded yet.

- ) : ( -
    - {timeline.edits.map((edit) => ( -
  1. selectEvent(edit.eventId)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - selectEvent(edit.eventId); - } - }} - role="button" - tabIndex={0} - > -
  2. - ))} -
- )} - -
State version {timeline.stateVersion}
-
- ); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts deleted file mode 100644 index c2a5e55fd..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/host-adapters.ts +++ /dev/null @@ -1,74 +0,0 @@ -export interface HostContext { - [key: string]: unknown; - safeAreaInsets?: { - bottom: number; - left: number; - right: number; - top: number; - }; -} - -export interface WidgetStateAdapter { - kind: 'openai' | 'portable'; - persist(selectedEventId: string): void; - restore(validEventIds: readonly string[]): string | undefined; -} - -type OpenAiCapability = { - setWidgetState: (state: { selectedEventId: string }) => unknown; - widgetState: Record; -}; - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - -const portableAdapter: WidgetStateAdapter = { - kind: 'portable', - persist: () => undefined, - restore: () => undefined, -}; - -const openAiCapability = (host: { openai?: unknown } | undefined): OpenAiCapability | undefined => { - if (!isRecord(host?.openai)) { - return undefined; - } - const { setWidgetState, widgetState } = host.openai; - if (typeof setWidgetState !== 'function' || !isRecord(widgetState)) { - return undefined; - } - return { setWidgetState: setWidgetState as OpenAiCapability['setWidgetState'], widgetState }; -}; - -/** Feature-detects documented state methods; no host name or user-agent is inspected. */ -export const createWidgetStateAdapter = (host: { openai?: unknown } | undefined): WidgetStateAdapter => { - const capability = openAiCapability(host); - if (capability === undefined) { - return portableAdapter; - } - - return { - kind: 'openai', - persist(selectedEventId) { - try { - capability.setWidgetState({ selectedEventId }); - } catch { - // Host state is an optional presentation enhancement. - } - }, - restore(validEventIds) { - const selectedEventId = capability.widgetState.selectedEventId; - return typeof selectedEventId === 'string' && validEventIds.includes(selectedEventId) - ? selectedEventId - : undefined; - }, - }; -}; - -const inset = (value: unknown): string => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? `${value}px` : '0px'); - -export const safeAreaCustomProperties = (context: HostContext | undefined): Record => ({ - '--timeline-safe-area-bottom': inset(context?.safeAreaInsets?.bottom), - '--timeline-safe-area-left': inset(context?.safeAreaInsets?.left), - '--timeline-safe-area-right': inset(context?.safeAreaInsets?.right), - '--timeline-safe-area-top': inset(context?.safeAreaInsets?.top), -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx deleted file mode 100644 index dd6b8cd74..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createRoot } from 'react-dom/client'; - -import { App } from './App.js'; -import './styles.css'; - -const root = document.getElementById('root'); -if (root === null) { - throw new Error('Widget root was not found'); -} - -createRoot(root).render(); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css deleted file mode 100644 index 5a2f51a11..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/src/widget/styles.css +++ /dev/null @@ -1,238 +0,0 @@ -:root { - color: var(--color-text-primary, #10162a); - background: var(--color-background-primary, #ffffff); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 0; - background: var(--color-background-primary, #ffffff); -} - -button, -input, -textarea, -select { - font: inherit; -} - -.timeline { - width: min(calc(100% - 40px), 760px); - min-height: 460px; - margin: 20px auto; - padding: calc(36px + var(--timeline-safe-area-top, 0px)) calc(32px + var(--timeline-safe-area-right, 0px)) - calc(24px + var(--timeline-safe-area-bottom, 0px)) calc(32px + var(--timeline-safe-area-left, 0px)); - background: var(--color-background-primary, #ffffff); - border: 1px solid var(--color-border-primary, #d9dde7); - border-radius: 12px; -} - -.timeline__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; -} - -h1, -p { - margin: 0; -} - -h1 { - font-size: clamp(28px, 4vw, 32px); - line-height: 1.18; - letter-spacing: -0.06em; -} - -.timeline__header p, -.timeline__details, -footer, -.timeline__empty { - color: var(--color-text-secondary, #667085); -} - -.timeline__header p { - margin-top: 12px; - font-size: 16px; -} - -.timeline__status { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -button { - min-width: 104px; - min-height: 44px; - padding: 10px 14px; - color: var(--color-ring-primary, #5b3df5); - background: var(--color-background-primary, #ffffff); - border: 2px solid var(--color-ring-primary, #5b3df5); - border-radius: 5px; - cursor: pointer; - font-size: 16px; -} - -button:hover:not(:disabled) { - color: var(--color-text-inverse, #ffffff); - background: var(--color-ring-primary, #5b3df5); -} - -button:focus-visible { - outline: 3px solid var(--color-ring-primary, #5b3df5); - outline-offset: 3px; -} - -button:disabled { - cursor: wait; - opacity: 0.65; -} - -.timeline__events { - position: relative; - display: grid; - gap: 0; - margin: 30px 0 12px; - padding: 0 0 0 46px; - list-style: none; -} - -.timeline__events::before { - position: absolute; - top: 14px; - bottom: 16px; - left: 13px; - width: 1px; - background: #d9dde7; - content: ''; -} - -.timeline__event { - position: relative; - padding: 0 0 16px; - cursor: pointer; - border-radius: 5px; -} - -.timeline__event + .timeline__event { - padding-top: 16px; - border-top: 1px solid var(--color-border-primary, #d9dde7); -} - -.timeline__event:focus-visible { - outline: 3px solid var(--color-ring-primary, #5b3df5); - outline-offset: 5px; -} - -.timeline__event--selected .timeline__path { - color: var(--color-ring-primary, #5b3df5); -} - -.timeline__node { - position: absolute; - top: 0; - left: -46px; - width: 28px; - height: 28px; - background: var(--color-background-primary, #ffffff); - border: 3px solid var(--color-ring-primary, #5b3df5); - border-radius: 50%; -} - -.timeline__path { - overflow-wrap: anywhere; - font-size: 20px; - font-weight: 700; - line-height: 1.25; -} - -.timeline__details { - display: flex; - align-items: center; - gap: 18px; - margin-top: 12px; - font-size: 16px; -} - -.timeline__details span:first-child { - color: var(--color-text-primary, #10162a); -} - -.timeline__details time { - margin-left: auto; - white-space: nowrap; -} - -.timeline__empty { - margin: 64px 0 36px; -} - -footer { - padding-top: 18px; - border-top: 1px solid var(--color-border-primary, #d9dde7); - font-size: 15px; -} - -@media (max-width: 480px) { - .timeline { - width: 100%; - min-height: 100vh; - margin: 0; - padding: calc(32px + var(--timeline-safe-area-top, 0px)) calc(24px + var(--timeline-safe-area-right, 0px)) - calc(32px + var(--timeline-safe-area-bottom, 0px)) calc(24px + var(--timeline-safe-area-left, 0px)); - border: 0; - border-radius: 0; - } - - .timeline__header { - flex-direction: column; - } - - button { - width: 100%; - } - - .timeline__events { - margin-top: 48px; - padding-left: 42px; - } - - .timeline__node { - left: -42px; - width: 28px; - height: 28px; - border-width: 3px; - } - - .timeline__details { - flex-wrap: wrap; - gap: 10px 16px; - } - - .timeline__details time { - width: 100%; - margin-left: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts deleted file mode 100644 index 6eafe5441..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ /dev/null @@ -1,2155 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { appendFile, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { createRsbuild } from '@rsbuild/core'; -import { expect, test } from '@rstest/core'; - -import { copyExample } from './support/copy-example.ts'; -import { createElement, type ReactNode } from 'react'; - -import { ProjectService } from '../../../packages/agent-bundle/src/dev/index.ts'; -import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; -import { createDevRuntimeProvider } from '../src/dev/provider.js'; -import { RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; -import { serializeInspection } from '../src/dev/serialize-inspection.js'; - -const readChildOutput = (stream: NodeJS.ReadableStream): Promise => - new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on('data', (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - stream.once('error', reject); - stream.once('end', () => resolve(Buffer.concat(chunks))); - }); - -const windowsTest = process.platform === 'win32' ? test : test.skip; - -const exampleRoot = process.cwd(); - -const copyInvocationExample = async () => copyExample(exampleRoot, { prefix: 'rsc-agent-runtime-invocation-copy-' }); - -const startInvocation = (entry: string, request: Record) => { - const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); - const flight = child.stdio[3] as NodeJS.ReadableStream | null | undefined; - if (flight === null || flight === undefined) throw new Error('Invocation worker Flight stream is unavailable.'); - child.stdin.end(JSON.stringify(request)); - - const completed = Promise.all([ - readChildOutput(flight), - readChildOutput(child.stdout), - readChildOutput(child.stderr), - new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', resolve); - }), - ]).then(([flight, stdout, stderr, exitCode]) => ({ exitCode, flight, stderr: stderr.toString('utf8'), stdout })); - - return { child, completed }; -}; - -test('streams a raw Flight payload separately from its bounded inspection response', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - try { - const entry = await buildInvocationEntry(compilerRoot); - const flightBytes = 3 * 1024 * 1024; - await writeFile(entry, ` -const { writeSync } = require('node:fs'); -writeSync(3, Buffer.alloc(${flightBytes}, 120)); -process.stdout.end(JSON.stringify({ - flightBytes: ${flightBytes}, - inspection: { - flight: { bytes: ${flightBytes}, preview: '', truncated: true }, - state: { identity: { stateStoreId: 'fixture-state', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const result = await invoke(entry, { - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - - expect(result).toMatchObject({ exitCode: 0, stderr: '' }); - expect(result.flight.byteLength).toBe(flightBytes); - expect(result.flight.byteLength).toBeLessThanOrEqual(4 * 1024 * 1024); - expect(result.stdout.byteLength).toBeLessThanOrEqual(4 * 1024 * 1024); - expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ - flightBytes: result.flight.byteLength, - inspection: expect.any(Object), - }); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}, 30_000); - -const invoke = async (entry: string, request: Record) => startInvocation(entry, request).completed; - -const buildInvocationEntry = async (compilerRoot: string, cwd = process.cwd()): Promise => { - const rsbuild = await createRsbuild({ - config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), - cwd, - }); - await rsbuild.build(); - return join(compilerRoot, 'rsc', 'dev', 'invoke.js'); -}; - -const waitFor = async (condition: () => boolean, message: string, timeoutMs = 4_000): Promise => { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() >= deadline) throw new Error(message); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -}; - -const deferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - return Object.freeze({ promise, reject, resolve }); -}; - -const readWhenPresent = async (path: string): Promise => { - let value: string | undefined; - await waitFor(() => { - try { - value = readFileSync(path, 'utf8'); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; - throw error; - } - }, `Timed out waiting for ${path}`); - return value as string; -}; - -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; - throw error; - } -}; - -const startWindowsJobOwnerSession = async ( - storageRoot: string, - mode: 'close-control' | 'hang-ready' | 'ignore-stop' | 'nonzero-after-drain' | 'normal', -) => { - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: `session-windows-owner-${mode}`, - signal: new AbortController().signal, - storageRoot, - }, { windowsJobOwnerMode: mode }); - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - return Object.freeze({ - generationId: session.status().activeVector!.runtimeGenerationId, - session, - storageRoot, - }); -}; - -const event = (eventId: string) => ({ - eventId, - host: 'claude' as const, - path: `src/${eventId}.ts`, - recordedAt: '2026-08-15T00:00:00.000Z', - sessionId: 'session', - toolName: 'Write', -}); - -const oversizedMcpWorker = (payloadBytes: number): string => { - return `const { writeSync } = require('node:fs'); -const payload = 'x'.repeat(${payloadBytes}); -const model = ['$', 'mcp-result', null, { - _meta: '$undefined', - isError: '$undefined', - structuredContent: { payload, stateVersion: 0 }, - children: [['$', 'mcp-text', null, { children: 'ok' }]], -}]; -writeSync(3, Buffer.from('{"stateVersion":0}')); -process.stdout.end(\`0:\${JSON.stringify(model)}\\n\`); -`; -}; - -const inspectionShape = (result: { inspection: Record }) => { - const { flight: _flight, ...inspection } = result.inspection; - return inspection; -}; - -const assertJsonOnly = (value: unknown): void => { - if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return; - expect(typeof value).not.toBe('function'); - expect(typeof value).not.toBe('symbol'); - if (Array.isArray(value)) { - value.forEach(assertJsonOnly); - return; - } - expect(value).toBeTypeOf('object'); - Object.values(value as Record).forEach(assertJsonOnly); -}; - -test('lowers the hook state version from durable state when copied RSC output grammar changes', async () => { - const copied = await copyInvocationExample(); - const compilerRoot = join(copied.workspaceRoot, 'compiler'); - const componentSource = join(copied.projectRoot, 'src', 'rsc', 'components.tsx'); - try { - const source = await readFile(componentSource, 'utf8'); - const edited = source.replace( - 'Shared state now contains ${editCount} ${editNoun}.', - "There is now ${editCount === 1 ? 'one recorded edit' : `${editCount} recorded ${editNoun}`}", - ); - expect(edited).not.toBe(source); - await writeFile(componentSource, edited); - const entry = await buildInvocationEntry(compilerRoot, copied.projectRoot); - const result = await invoke(entry, { - host: 'claude', - input: { - cwd: join(copied.workspaceRoot, 'workspace'), - hook_event_name: 'PostToolUse', - session_id: 'wording-independent-state-version', - tool_input: { file_path: 'changed-wording.txt' }, - tool_name: 'Write', - tool_use_id: 'changed-wording-tool', - }, - stateFile: join(copied.workspaceRoot, 'events.jsonl'), - stateStoreId: 'wording-independent-state-version', - type: 'hook/after-file-edit', - }); - - expect(result).toMatchObject({ exitCode: 0, stderr: '' }); - expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ - inspection: { - agentVisible: 'Recorded changed-wording.txt from claude. There is now one recorded edit', - state: { identity: { stateStoreId: 'wording-independent-state-version', stateVersion: 1 } }, - }, - }); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('builds a generation-contained inspection entry for Claude, Codex, and MCP fixtures', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - const workspace = join(compilerRoot, 'workspace'); - const request = { - host: 'claude', - input: { - cwd: workspace, - hook_event_name: 'PostToolUse', - session_id: 'claude-session', - tool_input: { file_path: 'demo.txt' }, - tool_name: 'Write', - tool_use_id: 'claude-fixture-1', - }, - stateStoreId: 'fixture-state', - type: 'hook/after-file-edit', - }; - - try { - const entry = await buildInvocationEntry(compilerRoot); - const first = await invoke(entry, { ...request, stateFile: join(compilerRoot, 'first.jsonl') }); - const second = await invoke(entry, { ...request, stateFile: join(compilerRoot, 'second.jsonl') }); - - expect(first).toMatchObject({ exitCode: 0, stderr: '' }); - expect(second).toMatchObject({ exitCode: 0, stderr: '' }); - expect(first.stdout.byteLength).toBeLessThan(1024 * 1024); - expect(first.stdout.toString('utf8')).toMatch(/^\{[^\n]+\}\n$/u); - - const firstResult = JSON.parse(first.stdout.toString('utf8')) as { - flightBytes: number; - inspection: Record; - }; - const secondResult = JSON.parse(second.stdout.toString('utf8')) as typeof firstResult; - expect(inspectionShape(secondResult)).toEqual(inspectionShape(firstResult)); - expect(firstResult.flightBytes).toBe(first.flight.byteLength); - expect(secondResult.flightBytes).toBe(second.flight.byteLength); - expect(first.flight.byteLength).toBeGreaterThan(0); - assertJsonOnly(firstResult); - expect(firstResult.inspection).toMatchObject({ - agentVisible: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', - native: { - hookSpecificOutput: { - additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', - hookEventName: 'PostToolUse', - }, - }, - state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, - trace: [ - { id: 'normalize', phase: 'normalize', status: 'succeeded' }, - { id: 'worker', phase: 'worker', status: 'succeeded' }, - { id: 'flight', phase: 'flight', status: 'succeeded' }, - { id: 'decode', phase: 'decode', status: 'succeeded' }, - { id: 'lower', phase: 'lower', status: 'succeeded' }, - ], - tree: [ - { - children: [ - { - children: [ - { children: [], id: 'node-2', kind: 'text', label: 'Recorded demo.txt from claude. Shared state now contains 1 edit.' }, - ], - id: 'node-1', - kind: 'element', - label: 'agent-hook-additional-context', - }, - ], - id: 'node-0', - kind: 'element', - label: 'agent-hook-result', - }, - ], - }); - - const codex = await invoke(entry, { - host: 'codex', - input: { - cwd: workspace, - hook_event_name: 'PostToolUse', - session_id: 'codex-session', - tool_input: { command: '*** Begin Patch\n*** Add File: codex.txt\n+content\n*** End Patch' }, - tool_name: 'apply_patch', - tool_use_id: 'codex-fixture-1', - }, - stateFile: join(compilerRoot, 'codex.jsonl'), - stateStoreId: 'fixture-state', - type: 'hook/after-file-edit', - }); - expect(codex).toMatchObject({ exitCode: 0, stderr: '' }); - expect(JSON.parse(codex.stdout.toString('utf8'))).toMatchObject({ - inspection: { - agentVisible: 'Recorded codex.txt from codex. Shared state now contains 1 edit.', - native: { - hookSpecificOutput: { - additionalContext: 'Recorded codex.txt from codex. Shared state now contains 1 edit.', - hookEventName: 'PostToolUse', - }, - }, - }, - }); - - const status = await invoke(entry, { - stateFile: join(compilerRoot, 'first.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - expect(status).toMatchObject({ exitCode: 0, stderr: '' }); - expect(JSON.parse(status.stdout.toString('utf8'))).toMatchObject({ - inspection: { - modelVisible: [ - { text: 'Runtime state contains 1 edit.', type: 'text' }, - { - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - mimeType: 'image/png', - type: 'image', - }, - ], - protocol: { - content: [ - { text: 'Runtime state contains 1 edit.', type: 'text' }, - { - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - mimeType: 'image/png', - type: 'image', - }, - ], - structuredContent: { editCount: 1, stateVersion: 1 }, - }, - state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, - }, - }); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('strictly freezes decoded inspection values while stripping only functions and symbols', () => { - const valid = serializeInspection({ - flight: Buffer.from('flight'), - node: createElement('inspection-root', { callback: () => undefined, keep: 'value', marker: Symbol('marker') }, 'text'), - stateStoreId: 'state', - stateVersion: 1, - }); - expect(valid.tree).toEqual([ - { - children: [{ children: [], id: 'node-1', kind: 'text', label: 'text' }], - id: 'node-0', - kind: 'element', - label: 'inspection-root', - props: { keep: 'value' }, - }, - ]); - - const accessor = {}; - Object.defineProperty(accessor, 'value', { enumerable: true, get: () => 'unexpected' }); - const sparse = new Array(2); - sparse[1] = 'present'; - const cycle: Record = {}; - cycle.self = cycle; - const repeatedCycle = { cycle }; - const shared = Object.freeze({ value: 'shared' }); - const cyclicChildren: ReactNode[] = []; - cyclicChildren.push(cyclicChildren); - const sparseChildren = new Array(2); - sparseChildren[1] = 'present'; - const accessorChildren = new Array(1); - Object.defineProperty(accessorChildren, '0', { enumerable: true, get: () => 'unexpected' }); - - for (const value of [accessor, sparse, new Date('2026-08-15T00:00:00.000Z'), repeatedCycle]) { - expect(() => serializeInspection({ - flight: Buffer.from('flight'), - node: createElement('inspection-root', { value }), - stateStoreId: 'state', - stateVersion: 1, - })).toThrow('Inspection JSON'); - } - expect(() => serializeInspection({ - flight: Buffer.from('flight'), - node: createElement('inspection-root', null, cyclicChildren), - stateStoreId: 'state', - stateVersion: 1, - })).toThrow('Inspection tree'); - for (const children of [sparseChildren, accessorChildren]) { - expect(() => serializeInspection({ - flight: Buffer.from('flight'), - node: createElement('inspection-root', null, children), - stateStoreId: 'state', - stateVersion: 1, - })).toThrow('Inspection tree'); - } - expect(() => serializeInspection({ - flight: Buffer.from('flight'), - node: createElement('inspection-root', null, new Date('2026-08-15T00:00:00.000Z') as unknown as ReactNode), - stateStoreId: 'state', - stateVersion: 1, - })).toThrow('Inspection JSON'); - expect(() => serializeInspection({ - flight: Buffer.from('flight'), - native: { first: shared, second: shared }, - node: createElement('inspection-root'), - stateStoreId: 'state', - stateVersion: 1, - })).toThrow('Inspection JSON'); -}); - -test('rejects unsafe timeline snapshots before emitting an inspection', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - try { - const entry = await buildInvocationEntry(compilerRoot); - const sensitiveValue = 'Bearer fixture-credential-value'; - const providerCredential = 'sk-live-abcdefghijklmnopqrstuvwxyz'; - for (const snapshot of [ - { edits: [event('one')], stateVersion: 1, unexpected: true }, - { edits: [{ ...event('two'), accessToken: 'fixture-credential-value' }], stateVersion: 1 }, - { edits: [{ ...event('three'), path: sensitiveValue }], stateVersion: 1 }, - { edits: [{ ...event('four'), path: providerCredential }], stateVersion: 1 }, - ]) { - const result = await invoke(entry, { - snapshot, - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/render-timeline', - }); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr).not.toContain('fixture-credential-value'); - expect(result.stderr).not.toContain(providerCredential); - } - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('retains a supplied timeline snapshot across a deferred concurrent state-file edit', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - try { - const entry = await buildInvocationEntry(compilerRoot); - const stateFile = join(compilerRoot, 'events.jsonl'); - const rscRoot = join(compilerRoot, 'rsc', 'rsc'); - const workerPath = join(rscRoot, 'index.js'); - const delayedWorkerPath = join(rscRoot, 'index.deferred.js'); - const marker = join(compilerRoot, 'rsc-timeline-child.ready'); - await writeFile(stateFile, `${JSON.stringify(event('first'))}\n`); - await rename(workerPath, delayedWorkerPath); - await writeFile( - workerPath, - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready'); setTimeout(() => require('./index.deferred.js'), 100);\n`, - ); - const invocation = startInvocation(entry, { - snapshot: { edits: [event('first')], stateVersion: 1 }, - stateFile, - stateStoreId: 'fixture-state', - type: 'mcp/render-timeline', - }); - await readWhenPresent(marker); - await appendFile(stateFile, `${JSON.stringify(event('second'))}\n`); - const result = await invocation.completed; - - expect(result).toMatchObject({ exitCode: 0, stderr: '' }); - expect(JSON.parse(result.stdout.toString('utf8'))).toMatchObject({ - inspection: { - protocol: { structuredContent: { edits: [event('first')], stateVersion: 1 } }, - state: { identity: { stateStoreId: 'fixture-state', stateVersion: 1 } }, - }, - }); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('redacts bounded RSC worker stderr diagnostics', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - try { - const entry = await buildInvocationEntry(compilerRoot); - await writeFile( - join(compilerRoot, 'rsc', 'rsc', 'index.js'), - "process.stderr.write('credential=fixture-credential cookie=fixture-cookie authorization=fixture-authorization Bearer fixture-bearer-secret sk-live-abcdefghijklmnopqrstuvwxyz ghp_012345678901234567890123456789 xoxb-0123456789-0123456789-abcdefghijklmnop AKIA0123456789ABCDEF\\n'.repeat(20_000), () => process.exit(1));\n", - ); - const result = await invoke(entry, { - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(Buffer.byteLength(result.stderr, 'utf8')).toBeLessThanOrEqual(256 * 1024 + 1_024); - for (const secret of [ - 'fixture-credential', - 'fixture-cookie', - 'fixture-authorization', - 'fixture-bearer-secret', - 'sk-live-abcdefghijklmnopqrstuvwxyz', - 'ghp_012345678901234567890123456789', - 'xoxb-0123456789-0123456789-abcdefghijklmnop', - 'AKIA0123456789ABCDEF', - ]) expect(result.stderr).not.toContain(secret); - expect(result.stderr).toContain('[redacted]'); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('caps inspection stdout independently after Flight leaves its response envelope', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - try { - const entry = await buildInvocationEntry(compilerRoot); - await writeFile(join(compilerRoot, 'rsc', 'rsc', 'index.js'), oversizedMcpWorker(2_100_000)); - const result = await invoke(entry, { - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr).toContain('Inspection response exceeded output limit'); - expect(result.stderr).not.toContain('x'.repeat(128)); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('bounds Flight output and waits for a SIGKILL cleanup when the RSC child ignores SIGTERM', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - let childPid: number | undefined; - let invocation: ReturnType | undefined; - try { - const entry = await buildInvocationEntry(compilerRoot); - const marker = join(compilerRoot, 'rsc-flight-child.pid'); - await writeFile( - join(compilerRoot, 'rsc', 'rsc', 'index.js'), - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); process.stdout.write('x'.repeat(5 * 1024 * 1024)); setInterval(() => undefined, 1_000);\n`, - ); - invocation = startInvocation(entry, { - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - childPid = Number(await readWhenPresent(marker)); - const result = await invocation.completed; - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - expect(result.stderr).toContain('Flight exceeded'); - await waitFor(() => !isProcessAlive(childPid as number), 'RSC child remained alive after Flight overflow'); - } finally { - invocation?.child.kill('SIGKILL'); - if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); - await rm(compilerRoot, { force: true, recursive: true }); - } -}, 6_000); - -test('forwards dev invocation termination through a SIGKILL cleanup of its RSC child', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-invoke-')); - let childPid: number | undefined; - let invocation: ReturnType | undefined; - try { - const entry = await buildInvocationEntry(compilerRoot); - const marker = join(compilerRoot, 'rsc-child.pid'); - await writeFile( - join(compilerRoot, 'rsc', 'rsc', 'index.js'), - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);\n`, - ); - invocation = startInvocation(entry, { - stateFile: join(compilerRoot, 'events.jsonl'), - stateStoreId: 'fixture-state', - type: 'mcp/runtime-status', - }); - childPid = Number(await readWhenPresent(marker)); - expect(Number.isSafeInteger(childPid)).toBe(true); - invocation.child.kill('SIGTERM'); - const result = await invocation.completed; - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toEqual(Buffer.alloc(0)); - await waitFor(() => !isProcessAlive(childPid as number), 'RSC child remained alive after invocation termination'); - } finally { - invocation?.child.kill('SIGKILL'); - if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); - await rm(compilerRoot, { force: true, recursive: true }); - } -}, 6_000); - -test('keeps each concurrent hook run bound to its rendered durable snapshot', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-exact-snapshot-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const firstWorkerResponse = deferred(); - const releaseFirst = deferred(); - let pauseFirst = true; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-exact-concurrent-hook-snapshot', - signal: new AbortController().signal, - storageRoot, - }, { - afterInvocationWorkerResponse: async ({ surfaceId }) => { - if (surfaceId !== 'hook.claude' || !pauseFirst) return; - pauseFirst = false; - firstWorkerResponse.resolve(); - await releaseFirst.promise; - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const request = (path: string, toolUseId: string) => ({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-exact-concurrent-hook-snapshot', - tool_input: { file_path: path }, - tool_name: 'Write', - tool_use_id: toolUseId, - }, - surfaceId: 'hook.claude' as const, - target: 'claude' as const, - }); - - const first = session.invoke(request('first-coherent.ts', 'coherent-hook-a')); - await firstWorkerResponse.promise; - const second = await session.invoke(request('second-coherent.ts', 'coherent-hook-b')); - releaseFirst.resolve(); - const firstRun = await first; - - expect(firstRun).toMatchObject({ - result: { - agentVisible: 'Recorded first-coherent.ts from claude. Shared state now contains 1 edit.', - native: { hookSpecificOutput: { additionalContext: 'Recorded first-coherent.ts from claude. Shared state now contains 1 edit.' } }, - state: { - identity: { stateStoreId: 'playground', stateVersion: 1 }, - snapshot: { edits: [expect.objectContaining({ path: join(projectRoot, 'first-coherent.ts') })], stateVersion: 1 }, - }, - }, - status: 'succeeded', - vector: { runtimeGenerationId: generationId, stateVersion: 1 }, - }); - expect(second).toMatchObject({ - result: { - state: { - identity: { stateStoreId: 'playground', stateVersion: 2 }, - snapshot: { - edits: [ - expect.objectContaining({ path: join(projectRoot, 'first-coherent.ts') }), - expect.objectContaining({ path: join(projectRoot, 'second-coherent.ts') }), - ], - stateVersion: 2, - }, - }, - }, - status: 'succeeded', - vector: { runtimeGenerationId: generationId, stateVersion: 2 }, - }); - expect(session.run(firstRun.id)).toEqual(firstRun); - } finally { - releaseFirst.resolve(); - await session.close(); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('runs an exact generation-contained hook invocation and retains its immutable Flight asset', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-')); - const controller = new AbortController(); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-invocation-test', - signal: controller.signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - await expect(session.invoke({ - expectedGenerationId: 'generation-that-does-not-exist', - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-invocation-test', - tool_input: { file_path: 'timeline.ts' }, - tool_name: 'Write', - tool_use_id: 'missing-generation', - }, - surfaceId: 'hook.claude', - target: 'claude', - })).rejects.toThrow('generation-that-does-not-exist'); - expect(session.runs(50)).toEqual([]); - - await expect(session.invoke({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-invocation-test', - tool_input: { file_path: 'timeline.ts' }, - tool_name: 'Write', - }, - surfaceId: 'hook.claude', - target: 'claude', - })).rejects.toThrow('tool_use_id or event_id'); - expect(session.runs(50)).toEqual([]); - - const run = await session.invoke({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-invocation-test', - tool_input: { file_path: 'timeline.ts' }, - tool_name: 'Write', - tool_use_id: 'native-event-1', - }, - surfaceId: 'hook.claude', - target: 'claude', - }); - - expect(run).toMatchObject({ - result: { - flight: { downloadPath: `/api/runtime/runs/${encodeURIComponent(run.id)}/flight` }, - state: { identity: { stateStoreId: 'playground', stateVersion: 1 } }, - }, - status: 'succeeded', - vector: { runtimeGenerationId: generationId, stateVersion: 1 }, - }); - const flight = await session.readRunFlight(run.id); - expect(flight?.body.byteLength).toBeGreaterThan(0); - expect(session.run(run.id)).toEqual(run); - expect(session.runs(1)).toEqual([run]); - - const replacedRunDirectory = join(storageRoot, 'replaced-run-directory'); - await mkdir(replacedRunDirectory); - await writeFile(join(replacedRunDirectory, 'flight.bin'), 'untrusted Flight'); - const trustedFlight = flight!.body; - await rm(join(storageRoot, 'runs', run.id), { force: true, recursive: true }); - await symlink(replacedRunDirectory, join(storageRoot, 'runs', run.id), 'dir'); - const afterSwap = await session.readRunFlight(run.id); - expect(afterSwap?.body).toEqual(trustedFlight); - expect(afterSwap?.body).not.toEqual(Buffer.from('untrusted Flight')); - } finally { - await session.close(); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('preserves the Claude fixture seed in post-state while exact replay stays valid', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-fixture-seed-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-fixture-seed-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const hook = session.surfaces().find((surface) => surface.id === 'hook.claude'); - const fixture = hook?.fixtures.find((candidate) => candidate.id === 'claude-post-tool-use-write'); - expect(fixture).toMatchObject({ - id: 'claude-post-tool-use-write', - seed: { - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'fixture-claude-post-tool-use', - tool_input: { file_path: 'fixture-claude-post-tool-use.txt' }, - tool_name: 'Write', - tool_use_id: 'fixture-claude-post-tool-use-write', - }, - }); - if (fixture?.seed === undefined) throw new Error('Claude fixture seed was unavailable.'); - - await expect(session.resetState({ - expectedGenerationId: generationId, - seed: fixture.seed, - stateStoreId: 'playground', - })).resolves.toEqual({ stateStoreId: 'playground', stateVersion: 1 }); - const run = await session.invoke({ - expectedGenerationId: generationId, - fixtureId: fixture.id, - input: fixture.seed, - surfaceId: 'hook.claude', - target: 'claude', - }); - expect(run).toMatchObject({ - fixtureId: fixture.id, - result: { - state: { - identity: { stateStoreId: 'playground', stateVersion: 2 }, - snapshot: { - edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], - seed: fixture.seed, - stateVersion: 2, - }, - }, - }, - status: 'succeeded', - vector: { runtimeGenerationId: generationId, stateVersion: 2 }, - }); - if (run.status !== 'succeeded') throw new Error('Fixture invocation did not succeed.'); - const postState = run.result.state.snapshot; - if (postState === null || typeof postState !== 'object' || Array.isArray(postState)) throw new Error('Fixture post-state snapshot was unavailable.'); - const postStateSeed = Object.getOwnPropertyDescriptor(postState, 'seed')?.value; - expect(Object.isFrozen(postState)).toBe(true); - expect(postStateSeed).not.toBe(fixture.seed); - expect(Object.isFrozen(postStateSeed)).toBe(true); - expect(run.result).not.toHaveProperty('app'); - - const replay = await session.replay({ mode: 'exact', runId: run.id }); - expect(replay).toMatchObject({ - fixtureId: fixture.id, - result: { state: { snapshot: { seed: fixture.seed, stateVersion: 2 } } }, - status: 'succeeded', - vector: { runtimeGenerationId: generationId, stateVersion: 2 }, - }); - - const timelineTarget = session.surfaces().find((surface) => surface.id === 'mcp.render_edit_timeline')!.targets[0]!; - const timeline = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.render_edit_timeline', - target: timelineTarget, - }); - expect(timeline).toMatchObject({ - result: { - app: { - mcpBinding: { - definitionDigest: expect.any(String), - registryRevision: expect.any(Number), - serverDigest: expect.any(String), - serverName: 'timeline', - sessionId: expect.any(String), - sessionRevision: expect.any(Number), - target: timelineTarget, - transportDigest: expect.any(String), - }, - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - surfaceId: 'mcp.edit-timeline', - }, - protocol: { - structuredContent: { - edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], - stateVersion: 2, - }, - }, - }, - status: 'succeeded', - }); - if (timeline.status !== 'succeeded' || timeline.result.protocol === null || typeof timeline.result.protocol !== 'object' || Array.isArray(timeline.result.protocol) || timeline.result.app === undefined) { - throw new Error('Timeline protocol was unavailable.'); - } - expect(timeline.surfaceId).toBe('mcp.render_edit_timeline'); - expect(timeline.result.app.surfaceId).toBe('mcp.edit-timeline'); - expect(Object.keys(timeline.result.app.mcpBinding).sort()).toEqual([ - 'definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest', - ]); - expect(Object.isFrozen(timeline.result.app)).toBe(true); - expect(Object.isFrozen(timeline.result.app.mcpBinding)).toBe(true); - expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)?.snapshot()).toMatchObject({ - binding: timeline.result.app.mcpBinding, - state: 'ready', - }); - const broker = session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId); - if (broker === undefined) throw new Error('Timeline App broker was unavailable.'); - const listedTools = await broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'list-tools', - }); - const listedResources = await broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'list-resources', - }); - expect(listedTools.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); - expect(listedResources.value).toEqual(expect.arrayContaining([expect.objectContaining({ - mimeType: 'text/html;profile=mcp-app', uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })])); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).resolves.toEqual(expect.objectContaining({ - value: { - contents: [{ - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - 'ui.csp': { connectDomains: [], resourceDomains: [] }, - 'ui.prefersBorder': true, - }, - mimeType: 'text/html;profile=mcp-app', - text: expect.stringMatching(/^/iu), - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - }], - }, - })); - await expect(broker.execute({ - arguments: { limit: 1 }, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).resolves.toMatchObject({ - sessionId: timeline.result.app.mcpBinding.sessionId, - sessionRevision: timeline.result.app.mcpBinding.sessionRevision, - value: { - content: [{ text: 'Showing 1 recorded edits.', type: 'text' }], - structuredContent: { edits: [expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' })], stateVersion: 2 }, - }, - vector: { runtimeGenerationId: generationId, stateVersion: 2 }, - }); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/foreign.html', - })).rejects.toThrow('not declared'); - await expect(broker.execute({ - arguments: {}, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'foreign_tool', - })).rejects.toThrow('not declared'); - await expect(broker.execute({ - arguments: { limit: 0 }, - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).rejects.toThrow('arguments'); - await expect(broker.execute({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision + 1, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).rejects.toThrow('revision'); - expect(session.clientSurface(timeline.result.app.surfaceId)).toMatchObject({ surfaceId: 'mcp.edit-timeline' }); - expect((timeline.result.protocol as Record).structuredContent).not.toHaveProperty('seed'); - - const timelineRequest = Object.freeze({ - expectedGenerationId: generationId, - input: Object.freeze({}), - surfaceId: 'mcp.render_edit_timeline', - target: timelineTarget, - }); - const [repeatedTimeline, concurrentTimeline] = await Promise.all([ - session.invoke(timelineRequest), - session.invoke(timelineRequest), - ]); - for (const candidate of [repeatedTimeline, concurrentTimeline]) { - expect(candidate).toMatchObject({ status: 'succeeded' }); - if (candidate.status !== 'succeeded' || candidate.result.app === undefined) throw new Error('Repeated timeline App result was unavailable.'); - expect(candidate.result.app.mcpBinding).toEqual(timeline.result.app.mcpBinding); - } - - await session.mcpRegistry.closeSession({ - expectedSessionRevision: timeline.result.app.mcpBinding.sessionRevision, - sessionId: timeline.result.app.mcpBinding.sessionId, - }); - expect(session.mcpRegistry.session(timeline.result.app.mcpBinding.sessionId)).toBeUndefined(); - const reopenedTimeline = await session.invoke(timelineRequest); - expect(reopenedTimeline).toMatchObject({ status: 'succeeded' }); - if (reopenedTimeline.status !== 'succeeded' || reopenedTimeline.result.app === undefined) throw new Error('Reopened timeline App result was unavailable.'); - expect(reopenedTimeline.result.app.mcpBinding).toMatchObject({ - definitionDigest: timeline.result.app.mcpBinding.definitionDigest, - registryRevision: timeline.result.app.mcpBinding.registryRevision, - serverDigest: timeline.result.app.mcpBinding.serverDigest, - serverName: timeline.result.app.mcpBinding.serverName, - target: timeline.result.app.mcpBinding.target, - transportDigest: timeline.result.app.mcpBinding.transportDigest, - }); - expect(reopenedTimeline.result.app.mcpBinding.sessionId).not.toBe(timeline.result.app.mcpBinding.sessionId); - - await expect(session.resetState({ - expectedGenerationId: generationId, - seed: { authorization: 'Bearer sk-live-abcdefghijklmnopqrstuvwxyz' }, - stateStoreId: 'playground', - })).rejects.toThrow('sensitive fields'); - const stateBeforeStatus = session.status().activeVector; - expect(stateBeforeStatus).toEqual(run.vector); - const status = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target: session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!, - }); - expect(status).toMatchObject({ - result: { state: { snapshot: { seed: fixture.seed, stateVersion: 2 } } }, - status: 'succeeded', - vector: { stateVersion: 2 }, - }); - if (status.status !== 'succeeded') throw new Error('Runtime status did not succeed.'); - expect(status.result).not.toHaveProperty('app'); - expect(status.vector).toMatchObject(status.result.state.identity); - expect(status.vector).toEqual(stateBeforeStatus); - expect(session.status().activeVector).toEqual(stateBeforeStatus); - expect(session.run(status.id)).toEqual(status); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('does not spawn an invocation worker when runtime.run.started closes the session', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-started-close-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let close: Promise | undefined; - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: (event) => { - if (event.type === 'runtime.run.started') close ??= session.close(); - }, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-started-close-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'worker-spawned-after-close'); - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned');`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - - await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) - .resolves.toMatchObject({ status: 'failed' }); - expect(close).toBeDefined(); - await close; - expect(() => readFileSync(marker)).toThrow(); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('refuses to adopt an existing or symbolic provider run root', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-run-root-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const external = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-external-runs-')); - await symlink(external, join(storageRoot, 'runs'), 'dir'); - - try { - await expect(createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-run-root-test', - signal: new AbortController().signal, - storageRoot, - })).rejects.toThrow('invocation root already exists'); - expect(await readdir(external)).toEqual([]); - } finally { - await rm(storageRoot, { force: true, recursive: true }); - await rm(external, { force: true, recursive: true }); - } -}, 30_000); - -test('refreshes a failed run vector after its generation-contained hook mutates durable state', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-failed-vector-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let activeVectorAtFailure: unknown; - let readActiveVector = (): unknown => undefined; - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: (event) => { - if (event.type === 'runtime.run.failed') activeVectorAtFailure = readActiveVector(); - }, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-failed-vector-test', - signal: new AbortController().signal, - storageRoot, - }); - readActiveVector = () => session.status().activeVector; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - const original = `${entry}.original`; - await rename(entry, original); - await writeFile(entry, ` -process.stdout.write = () => { process.exitCode = 1; return true; }; -require(${JSON.stringify(original)}); -`); - - const run = await session.invoke({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-failed-vector-test', - tool_input: { file_path: 'failed-vector.ts' }, - tool_name: 'Write', - tool_use_id: 'failed-vector-hook', - }, - surfaceId: 'hook.claude', - target: 'claude', - }); - - expect(run).toMatchObject({ status: 'failed', vector: { runtimeGenerationId: generationId, stateVersion: 1 } }); - expect(activeVectorAtFailure).toEqual(run.vector); - expect(session.status().activeVector).toEqual(run.vector); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('pins an overlapping g1 invocation while exact replay stays on g1 and latest replay advances to g2', async () => { - const copied = await copyInvocationExample(); - const storageRoot = join(copied.workspaceRoot, 'runtime-storage'); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-generation-pinning-test', - signal: new AbortController().signal, - storageRoot, - }); - let blocked: Promise | undefined; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const g1 = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const completedG1 = await session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); - expect(completedG1).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); - - const g1Worker = join(storageRoot, 'generation-store', 'generations', g1, 'rsc', 'rsc', 'index.js'); - const originalG1Worker = await readFile(g1Worker); - const marker = join(storageRoot, 'g1-blocked-worker.txt'); - await writeFile(g1Worker, ` -require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready'); -process.on('SIGTERM', () => undefined); -setInterval(() => undefined, 1_000); -`); - blocked = session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); - await readWhenPresent(marker); - - const workerSource = join(copied.projectRoot, 'src', 'rsc', 'worker.tsx'); - const source = await readFile(workerSource, 'utf8'); - await writeFile(workerSource, source.replace('RSC worker received an invalid event', 'RSC worker received an invalid event generation-two')); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); - const g2 = session.status().activeVector!.runtimeGenerationId; - await writeFile(g1Worker, originalG1Worker); - - const exact = await session.replay({ expectedGenerationId: g1, mode: 'exact', runId: completedG1.id }); - const latest = await session.replay({ expectedGenerationId: g2, mode: 'latest', runId: completedG1.id }); - expect(exact).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); - expect(latest).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g2 } }); - } finally { - await session.close().catch(() => undefined); - await blocked?.catch(() => undefined); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('replays an exact historical surface after generation two removes it', async () => { - const copied = await copyInvocationExample(); - const storageRoot = join(copied.workspaceRoot, 'runtime-storage'); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-historical-surface-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const g1 = session.status().activeVector!.runtimeGenerationId; - const run = await session.invoke({ - expectedGenerationId: g1, - input: { - cwd: copied.projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-historical-surface-test', - tool_input: { file_path: 'g1.ts' }, - tool_name: 'Write', - tool_use_id: 'historical-surface', - }, - surfaceId: 'hook.claude', - target: 'claude', - }); - expect(run).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); - - const definition = join(copied.projectRoot, 'src', 'definition.ts'); - const source = await readFile(definition, 'utf8'); - await writeFile(definition, source.replace(" host: 'claude',", " host: 'codex',")); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); - const g2 = session.status().activeVector!.runtimeGenerationId; - - await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: run.id })) - .resolves.toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); - await expect(session.replay({ expectedGenerationId: g2, mode: 'latest', runId: run.id })) - .rejects.toThrow('does not exist'); - } finally { - await session.close().catch(() => undefined); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('releases an exact historical lease when four active workers reject its admission', async () => { - const copied = await copyInvocationExample(); - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-exact-lease-capacity'); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-exact-lease-capacity', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const g1 = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const historical = await session.invoke({ expectedGenerationId: g1, input: {}, surfaceId: 'mcp.runtime_status', target }); - expect(historical).toMatchObject({ status: 'succeeded', vector: { runtimeGenerationId: g1 } }); - - const definition = join(copied.projectRoot, 'src', 'definition.ts'); - await appendFile(definition, '\n// exact-lease-capacity-g2\n'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); - const g2 = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'blocked-exact-lease-workers.txt'); - const worker = join(storageRoot, 'generation-store', 'generations', g2, 'rsc', 'rsc', 'index.js'); - await writeFile(worker, ` -import { appendFileSync } from 'node:fs'; -appendFileSync(${JSON.stringify(marker)}, 'ready\\n'); -setTimeout(() => process.exit(0), 1_000); -`); - const workers = Array.from({ length: 4 }, () => session.invoke({ - expectedGenerationId: g2, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - })); - await waitFor(() => { - try { - return readFileSync(marker, 'utf8').trim().split('\n').length === 4; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; - throw error; - } - }, 'Timed out waiting for four capacity workers'); - - await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: historical.id })) - .rejects.toThrow('limit of 4 concurrent workers'); - await Promise.all(workers); - - let active = g2; - for (let generation = 3; generation <= 8; generation += 1) { - await appendFile(definition, `// exact-lease-capacity-g${String(generation)}\\n`); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== active, `Timed out waiting for generation ${String(generation)}`, 15_000); - active = session.status().activeVector!.runtimeGenerationId; - } - await waitFor(() => !existsSync(join(storageRoot, 'generation-store', 'generations', g1)), 'Exact replay leaked generation one after capacity rejection', 15_000); - } finally { - await session.close().catch(() => undefined); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 90_000); - -test('closes the provider-owned invocation process group without orphaning its RSC grandchild', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-close-')); - const controller = new AbortController(); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-close-test', - signal: controller.signal, - storageRoot, - }); - let grandchildPid: number | undefined; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'rsc-invocation-grandchild.pid'); - const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); - await writeFile(worker, ` -const { spawn } = require('node:child_process'); -const { writeFileSync } = require('node:fs'); -const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)']); -writeFileSync(${JSON.stringify(marker)}, String(child.pid)); -process.on('SIGTERM', () => undefined); -setInterval(() => undefined, 1000); -`); - - const invocation = session.invoke({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-close-test', - tool_input: { file_path: 'timeline.ts' }, - tool_name: 'Write', - tool_use_id: 'native-event-close', - }, - surfaceId: 'hook.claude', - target: 'claude', - }); - grandchildPid = Number(await readWhenPresent(marker)); - expect(Number.isSafeInteger(grandchildPid)).toBe(true); - await session.close(); - await expect(invocation).resolves.toMatchObject({ status: 'failed' }); - await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC invocation grandchild remained alive after provider close'); - expect(session.run('any-run')).toBeUndefined(); - expect(session.runs(1)).toEqual([]); - await expect(session.readRunFlight('any-run')).resolves.toBeUndefined(); - expect(() => readFileSync(join(storageRoot, 'runs'))).toThrow(); - } finally { - if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('hard-kills the invocation process group when its leader exits before an RSC grandchild', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-leader-exit-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-leader-exit-test', - signal: new AbortController().signal, - storageRoot, - }); - let grandchildPid: number | undefined; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'rsc-invocation-leader-exit-grandchild.pid'); - const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); - await writeFile(worker, ` -const { spawn } = require('node:child_process'); -const { writeFileSync } = require('node:fs'); -const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { stdio: 'ignore' }); -writeFileSync(${JSON.stringify(marker)}, String(child.pid)); -process.exit(0); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - grandchildPid = Number(await readWhenPresent(marker)); - - expect(run).toMatchObject({ status: 'failed' }); - await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC grandchild remained alive after invocation leader exit'); - } finally { - if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('settles a successful invocation only after its TERM-resistant RSC grandchild exits', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-success-tree-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-success-tree-test', - signal: new AbortController().signal, - storageRoot, - }); - let grandchildPid: number | undefined; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'rsc-invocation-success-grandchild.pid'); - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -const { spawn } = require('node:child_process'); -const { writeFileSync, writeSync } = require('node:fs'); -const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { stdio: 'ignore' }); -child.unref(); -writeFileSync(${JSON.stringify(marker)}, String(child.pid)); -writeSync(3, Buffer.from('x')); -process.stdout.end(JSON.stringify({ - flightBytes: 1, - inspection: { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: [], - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - grandchildPid = Number(await readWhenPresent(marker)); - - expect(run).toMatchObject({ status: 'succeeded' }); - await waitFor(() => !isProcessAlive(grandchildPid as number), 'RSC invocation grandchild remained alive after successful invocation'); - } finally { - if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('keeps a detached successful worker grandchild in its Windows Job Object until it dies', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-job-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-windows-job-test', - signal: new AbortController().signal, - storageRoot, - }); - let grandchildPid: number | undefined; - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'rsc-invocation-windows-job-grandchild.pid'); - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -const { spawn } = require('node:child_process'); -const { writeFileSync, writeSync } = require('node:fs'); -const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { detached: true, stdio: 'ignore' }); -child.unref(); -writeFileSync(${JSON.stringify(marker)}, String(child.pid)); -writeSync(3, Buffer.from('x')); -process.stdout.end(JSON.stringify({ - flightBytes: 1, - inspection: { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: [], - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - grandchildPid = Number(await readWhenPresent(marker)); - - expect(run).toMatchObject({ status: 'succeeded' }); - const flight = await session.readRunFlight(run.id); - expect(flight?.body).toEqual(Buffer.from('x')); - await waitFor(() => !isProcessAlive(grandchildPid as number), 'Windows Job Object left a detached RSC grandchild alive after invocation'); - await session.close(); - expect(isProcessAlive(grandchildPid as number)).toBe(false); - } finally { - if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('bounds a hung Windows Job owner before it can arm the invocation wrapper', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-hang-')); - const marker = join(storageRoot, 'wrapper-ran'); - const startedAt = Date.now(); - const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'hang-ready'); - - try { - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - - await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) - .resolves.toMatchObject({ status: 'failed' }); - expect(Date.now() - startedAt).toBeLessThan(5_000); - expect(existsSync(marker)).toBe(false); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('bounds a broken Windows Job owner control pipe and drains its assigned wrapper', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-pipe-')); - const marker = join(storageRoot, 'wrapper-ran'); - const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'close-control'); - - try { - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran'); -setInterval(() => undefined, 1000); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const invocation = session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - await readWhenPresent(marker); - const startedAt = Date.now(); - - await session.close(); - await expect(invocation).resolves.toMatchObject({ status: 'failed' }); - expect(Date.now() - startedAt).toBeLessThan(5_000); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('forces an ignored Windows Job owner STOP without leaving its wrapper alive', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-stop-')); - const marker = join(storageRoot, 'wrapper.pid'); - const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'ignore-stop'); - let wrapperPid: number | undefined; - - try { - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); -setInterval(() => undefined, 1000); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const invocation = session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - wrapperPid = Number(await readWhenPresent(marker)); - const startedAt = Date.now(); - - await session.close(); - await expect(invocation).resolves.toMatchObject({ status: 'failed' }); - expect(Date.now() - startedAt).toBeLessThan(5_000); - expect(isProcessAlive(wrapperPid)).toBe(false); - } finally { - if (wrapperPid !== undefined && isProcessAlive(wrapperPid)) process.kill(wrapperPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('fails a nonzero Windows Job owner only after its resistant descendant is drained', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-owner-nonzero-')); - const marker = join(storageRoot, 'rsc-invocation-windows-owner-nonzero-grandchild.pid'); - const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'nonzero-after-drain'); - let grandchildPid: number | undefined; - - try { - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -const { spawn } = require('node:child_process'); -const { writeFileSync, writeSync } = require('node:fs'); -const child = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000)'], { detached: true, stdio: 'ignore' }); -child.unref(); -writeFileSync(${JSON.stringify(marker)}, String(child.pid)); -writeSync(3, Buffer.from('x')); -process.stdout.end(JSON.stringify({ - flightBytes: 1, - inspection: { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: [], - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const run = await session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target }); - grandchildPid = Number(await readWhenPresent(marker)); - - expect(run).toMatchObject({ status: 'failed' }); - await waitFor(() => !isProcessAlive(grandchildPid as number), 'Windows Job owner reported failure before draining its descendant'); - } finally { - if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL'); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -windowsTest('never invokes taskkill after a Windows Job has owned and drained the wrapper', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-windows-no-taskkill-')); - const commandRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-fake-taskkill-')); - const taskkillMarker = join(commandRoot, 'taskkill-invoked'); - const pathBefore = process.env.PATH; - const { generationId, session } = await startWindowsJobOwnerSession(storageRoot, 'normal'); - - try { - await writeFile(join(commandRoot, 'taskkill.cmd'), `@echo invoked>"${taskkillMarker}"\r\n@exit /b 0\r\n`); - process.env.PATH = `${commandRoot};${pathBefore ?? ''}`; - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - await writeFile(entry, ` -const { writeSync } = require('node:fs'); -writeSync(3, Buffer.from('x')); -process.stdout.end(JSON.stringify({ - flightBytes: 1, - inspection: { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: [], - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - await expect(session.invoke({ expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target })) - .resolves.toMatchObject({ status: 'succeeded' }); - await session.close(); - expect(existsSync(taskkillMarker)).toBe(false); - } finally { - process.env.PATH = pathBefore; - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - await rm(commandRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('keeps the newest fifty immutable run artifacts and evicts the oldest completed Flight', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-history-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-history-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const first = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - }); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - const firstFlight = await session.readRunFlight(first.id); - expect(firstFlight?.body.byteLength).toBeGreaterThan(0); - await session.resetState({ expectedGenerationId: generationId, stateStoreId: 'playground' }); - expect(session.run(first.id)).toEqual(first); - - for (let index = 0; index < 50; index += 1) { - const run = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - }); - expect(run.status).toBe('succeeded'); - } - - expect(session.run(first.id)).toBeUndefined(); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - await expect(session.readRunFlight('../flight.bin')).resolves.toBeUndefined(); - expect(session.runs(50)).toHaveLength(50); - expect(session.runs(50)[0]!.id).not.toBe(first.id); - expect((await readdir(join(storageRoot, 'runs'))).filter((entry) => entry !== '.agent-bundle-runtime-owner')).toHaveLength(50); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 45_000); - -test('retains a failed invocation Flight artifact until its explicit session-close release succeeds', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-artifact-release-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let failRelease = true; - let releaseAttempts = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-artifact-release-test', - signal: new AbortController().signal, - storageRoot, - }, { - afterInvocationWorkerResponse: () => { throw new Error('forced invocation failure'); }, - beforeRunArtifactRelease: () => { - releaseAttempts += 1; - if (failRelease) throw new Error('do-not-expose-run-artifact-release-secret'); - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const run = await session.invoke({ - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - }); - - expect(run).toMatchObject({ - diagnostics: [expect.objectContaining({ - message: 'RSC runtime invocation cleanup failed; cleanup failures: run-artifact.', - })], - status: 'failed', - }); - expect(run.status === 'failed' && run.diagnostics[0]!.message).not.toContain('do-not-expose-run-artifact-release-secret'); - expect(releaseAttempts).toBe(1); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([run.id])); - - failRelease = false; - const closing = session.close(); - expect(session.close()).toBe(closing); - await expect(closing).resolves.toBeUndefined(); - expect(releaseAttempts).toBe(2); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 45_000); - -test('keeps the oldest artifact and terminal history owned when eviction release fails', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-release-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let firstRunId: string | undefined; - let failedReleaseAttempts = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-release-test', - signal: new AbortController().signal, - storageRoot, - }, { - beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId !== firstRunId) return; - failedReleaseAttempts += 1; - throw new Error('do-not-expose-eviction-release-secret'); - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - for (let index = 0; index < 49; index += 1) { - await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - } - await expect(session.invoke(request)).rejects.toThrow('RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.'); - - expect(failedReleaseAttempts).toBeGreaterThan(0); - expect(session.run(first.id)).toEqual(first); - await expect(session.readRunFlight(first.id)).resolves.toMatchObject({ body: expect.any(Buffer) }); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); - - const closing = session.close(); - expect(session.close()).toBe(closing); - await expect(closing).rejects.toMatchObject({ - message: 'RSC runtime session close failed; cleanup failures: run-artifact.', - }); - await expect(closing).rejects.not.toThrow('do-not-expose-eviction-release-secret'); - expect(failedReleaseAttempts).toBeGreaterThan(1); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('reserves an evicting terminal run before draining its admitted Flight readers', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-reader-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const readerEntered = deferred(); - const releaseReader = deferred(); - const evictionReserved = deferred(); - let firstRunId: string | undefined; - let holdFirstReader = false; - let firstReaderAdmissions = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-reader-test', - signal: new AbortController().signal, - storageRoot, - }, { - afterRunArtifactEvictionReserved: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) evictionReserved.resolve(); - }, - beforeRunFlightRead: async ({ runId }: Readonly<{ readonly runId: string }>) => { - if (!holdFirstReader || runId !== firstRunId) return; - firstReaderAdmissions += 1; - if (firstReaderAdmissions !== 1) return; - readerEntered.resolve(); - await releaseReader.promise; - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - holdFirstReader = true; - const admittedReader = session.readRunFlight(first.id); - await readerEntered.promise; - for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - - const evicting = session.invoke(request); - await evictionReserved.promise; - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - expect(firstReaderAdmissions).toBe(1); - - releaseReader.resolve(); - await expect(admittedReader).resolves.toMatchObject({ body: expect.any(Buffer) }); - await expect(evicting).resolves.toMatchObject({ status: 'succeeded' }); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - } finally { - releaseReader.resolve(); - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 90_000); - -test('finalizes successful history before a failed evicted run-directory removal', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-eviction-directory-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - let firstRunId: string | undefined; - let failFirstDirectoryRemoval = true; - let firstArtifactReleaseAttempts = 0; - let firstDirectoryRemovalAttempts = 0; - const session = await RsbuildRuntimeSession.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-eviction-directory-test', - signal: new AbortController().signal, - storageRoot, - }, { - beforeRunArtifactRelease: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) firstArtifactReleaseAttempts += 1; - }, - beforeRunDirectoryRemoval: ({ runId }: Readonly<{ readonly runId: string }>) => { - if (runId === firstRunId) firstDirectoryRemovalAttempts += 1; - if (failFirstDirectoryRemoval && runId === firstRunId) { - failFirstDirectoryRemoval = false; - throw new Error('do-not-expose-evicted-run-directory-removal-secret'); - } - }, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const request = { - expectedGenerationId: generationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - } as const; - const first = await session.invoke(request); - if (first.status !== 'succeeded') throw new Error(JSON.stringify(first.diagnostics)); - firstRunId = first.id; - - for (let index = 0; index < 49; index += 1) await expect(session.invoke(request)).resolves.toMatchObject({ status: 'succeeded' }); - const evictionFailure = await session.invoke(request); - - expect(evictionFailure).toMatchObject({ - diagnostics: [expect.objectContaining({ message: 'RSC runtime run artifact cleanup failed; cleanup failures: run-artifact.' })], - status: 'failed', - }); - expect(evictionFailure.status === 'failed' && evictionFailure.diagnostics[0]!.message) - .not.toContain('do-not-expose-evicted-run-directory-removal-secret'); - expect(session.run(first.id)).toBeUndefined(); - await expect(session.readRunFlight(first.id)).resolves.toBeUndefined(); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(expect.arrayContaining([first.id])); - expect(firstArtifactReleaseAttempts).toBe(1); - - await expect(session.close()).resolves.toBeUndefined(); - expect(firstArtifactReleaseAttempts).toBe(1); - expect(firstDirectoryRemovalAttempts).toBe(2); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('rejects a fifth blocked generation worker and settles every leased worker on close', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-bound-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-bound-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const marker = join(storageRoot, 'blocked-workers.txt'); - const worker = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'rsc', 'index.js'); - await writeFile(worker, ` -const { appendFileSync } = require('node:fs'); -appendFileSync(${JSON.stringify(marker)}, 'ready\\n'); -process.on('SIGTERM', () => undefined); -setInterval(() => undefined, 1000); -`); - const request = (id: string) => ({ - expectedGenerationId: generationId, - input: { - cwd: projectRoot, - hook_event_name: 'PostToolUse', - session_id: 'session-bound-test', - tool_input: { file_path: 'timeline.ts' }, - tool_name: 'Write', - tool_use_id: id, - }, - surfaceId: 'hook.claude', - target: 'claude', - }); - const workers = ['one', 'two', 'three', 'four'].map((id) => session.invoke(request(id))); - const fifth = session.invoke(request('five')); - await waitFor(() => { - try { - return readFileSync(marker, 'utf8').trim().split('\n').length >= 4; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; - throw error; - } - }, 'Timed out waiting for four blocked invocation workers'); - await expect(fifth).rejects.toThrow('limit of 4 concurrent workers'); - expect(readFileSync(marker, 'utf8').trim().split('\n')).toHaveLength(4); - await session.close(); - await expect(Promise.all(workers)).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ status: 'failed' }), - ])); - expect(session.runs(1)).toEqual([]); - expect(() => readFileSync(join(storageRoot, 'runs'))).toThrow(); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('contains invocation stdout, stderr, and timeout failures without retaining partial run artifacts', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-session-output-')); - const projectRoot = process.cwd(); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: projectRoot }).prepare('dev'); - const session = await createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'session-output-test', - signal: new AbortController().signal, - storageRoot, - }); - - try { - await waitFor(() => session.status().activeVector !== undefined, 'Timed out waiting for an active runtime generation', 15_000); - const generationId = session.status().activeVector!.runtimeGenerationId; - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - const entry = join(storageRoot, 'generation-store', 'generations', generationId, 'rsc', 'dev', 'invoke.js'); - const request = { expectedGenerationId: generationId, input: {}, surfaceId: 'mcp.runtime_status', target } as const; - - await writeFile(entry, `process.stdout.write('x'.repeat(${(4 * 1024 * 1024) + 1}));`); - const stdout = await session.invoke(request); - expect(stdout).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('stdout exceeded') })], status: 'failed' }); - - await writeFile(entry, "process.stderr.write('credential=fixture-credential '.repeat(30000));"); - const stderr = await session.invoke(request); - expect(stderr).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('stderr exceeded') })], status: 'failed' }); - if (stderr.status === 'failed') expect(stderr.diagnostics[0]!.message).not.toContain('fixture-credential'); - - await writeFile(entry, "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000);"); - const startedAt = Date.now(); - const timeout = await session.invoke(request); - expect(timeout).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('exceeded 10000 ms') })], status: 'failed' }); - expect(Date.now() - startedAt).toBeGreaterThanOrEqual(9_000); - - await writeFile(entry, ` -require('node:fs').writeSync(3, Buffer.from('x')); -process.stdout.end(JSON.stringify({ - flightBytes: 1, - inspection: { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: 'token=worker-response-secret', - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }, -}) + '\\n'); -`); - const credential = await session.invoke(request); - expect(credential).toMatchObject({ diagnostics: [expect.objectContaining({ message: expect.stringContaining('credentials') })], status: 'failed' }); - if (credential.status === 'failed') expect(credential.diagnostics[0]!.message).not.toContain('worker-response-secret'); - - const malformed = async (inspection: Record) => { - await writeFile(entry, ` -require('node:fs').writeSync(3, Buffer.from('x')); -process.stdout.end(${JSON.stringify(`${JSON.stringify({ flightBytes: 1, inspection })}\n`)}); -`); - const run = await session.invoke(request); - expect(run).toMatchObject({ status: 'failed' }); - await expect(session.readRunFlight(run.id)).resolves.toBeUndefined(); - }; - const validInspection = { - flight: { bytes: 1, preview: 'eA==', truncated: false }, - modelVisible: [], - protocol: [], - state: { identity: { stateStoreId: 'playground', stateVersion: 0 } }, - trace: [], - tree: [], - }; - await malformed({ ...validInspection, tree: [{ children: {}, id: 'node', kind: 'element', label: 'bad' }] }); - await malformed({ ...validInspection, tree: [{ children: [], id: 'node', kind: 'element', label: 'bad', props: [] }] }); - await malformed({ ...validInspection, tree: [{ children: [], id: 'node', kind: 'element', label: 'bad', props: null }] }); - await malformed({ ...validInspection, trace: [{ id: '', phase: 'render', startedAt: 'not-a-date', status: 'unknown' }] }); - await malformed({ ...validInspection, trace: [{ details: null, id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); - await malformed({ ...validInspection, trace: [{ details: [], id: 'trace', phase: 'render', startedAt: '2026-08-15T00:00:00.000Z', status: 'succeeded' }] }); - await malformed({ ...validInspection, app: { mcpBinding: {}, resourceUri: 'ui://unsafe', surfaceId: 'mcp.timeline' } }); - expect(await readdir(join(storageRoot, 'runs'))).toEqual(['.agent-bundle-runtime-owner']); - } finally { - await session.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 45_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts deleted file mode 100644 index ac971022b..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ /dev/null @@ -1,1683 +0,0 @@ -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; - -import { expect, test } from '@rstest/core'; -import type { createRsbuild, StartDevServerResult } from '@rsbuild/core'; - -import { - ArtifactService, - ProjectService, -} from '../../../packages/agent-bundle/src/dev/index.ts'; -import { EpochStore } from '../../../packages/agent-bundle/src/dev/epoch-store.ts'; -import { resolveDevRuntimeProvider } from '../../../packages/agent-bundle/src/dev/runtime-provider-loader.ts'; -import { - createRscRuntimeRsbuildConfig, - type RscRuntimeActivationOutcome, - type RscRuntimeCompileSnapshot, -} from '../rsbuild.config.js'; -import { createDevRuntimeProvider } from '../src/dev/provider.js'; -import { ResourceLedger, RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; -import { copyExample, type CopiedExample } from './support/copy-example.ts'; - -const exampleRoot = process.cwd(); - -const waitFor = async (predicate: () => boolean): Promise => { - const deadline = Date.now() + 15_000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error('Timed out waiting for the RSC runtime provider.'); - await new Promise((resolve) => { setTimeout(resolve, 25); }); - } -}; - -const deferred = () => { - let reject!: (reason?: unknown) => void; - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return Object.freeze({ promise, reject, resolve }); -}; - -const compileObserver = (onCompile: NonNullable[0]['onCompile']>) => { - const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-observer'), mode: 'development', onCompile }); - const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-compile-observer'); - if (plugin === undefined) throw new Error('RSC compiler observer plugin is unavailable.'); - let before: (() => void) | undefined; - let after: ((input: unknown) => Promise) | undefined; - plugin.setup({ - onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, - onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, - }); - return Object.freeze({ - async compile(input: Readonly<{ - readonly children?: readonly unknown[]; - readonly hasErrors?: boolean; - }> = {}): Promise { - before?.(); - await after?.({ - stats: { - hasErrors: () => input.hasErrors ?? false, - toJson: () => ({ children: input.children ?? [{ hash: 'rsc-hash', name: 'rsc' }, { hash: 'widget-hash', name: 'widget' }] }), - }, - }); - }, - }); -}; - -const snapshotFor = (attemptId: string, sourceRevision: string): RscRuntimeCompileSnapshot => Object.freeze({ - attemptId, - candidateId: attemptId, - preparedRevision: 'prepared', - rscCohortRevision: 1, - sourceRevision, -}); - -const startContext = (input: Readonly<{ - readonly projectRoot: string; - readonly preparedRuntime: NonNullable>['devRuntime']>; - readonly providerSessionId: string; - readonly signal: AbortSignal; - readonly storageRoot: string; -}>) => Object.freeze({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: input.projectRoot, - preparedRuntime: input.preparedRuntime, - providerSessionId: input.providerSessionId, - signal: input.signal, - storageRoot: input.storageRoot, -}); - -const copyProviderExample = async (): Promise => - copyExample(exampleRoot, { linkPackages: true, prefix: 'rsc-agent-runtime-provider-' }); - -/** - * Replaces source atomically through a same-directory rename. An in-place - * write is truncate-then-append, which a loaded watcher observes as two - * change events and compiles twice; the duplicate attempt supersedes the - * generation that ordinal-pinned assertions expect to commit. - */ -const replaceSource = async (path: string, replace: (source: string) => string): Promise => { - const source = await readFile(path, 'utf8'); - const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); - await writeFile(temporary, replace(source)); - await rename(temporary, path); -}; - -const changeDefinition = async (projectRoot: string, replacement: string): Promise => { - await replaceSource( - join(projectRoot, 'src', 'definition.ts'), - (source) => source.replace('Read the current shared runtime state.', replacement), - ); -}; - -const changeWorkerImplementation = async (projectRoot: string, marker: string): Promise => { - await replaceSource( - join(projectRoot, 'src', 'rsc', 'worker.tsx'), - (source) => source.replace( - /RSC worker received an invalid event(?: [^']*)?/u, - `RSC worker received an invalid event ${marker}`, - ), - ); -}; - -const introduceWorkerSyntaxError = async (projectRoot: string): Promise => { - await replaceSource( - join(projectRoot, 'src', 'rsc', 'worker.tsx'), - (source) => `${source}\nconst = ;\n`, - ); -}; - -test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => { - const captured: string[] = []; - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-provider-hmr-token'), - mode: 'development', - onAppWebSocketToken: (token: string) => { captured.push(token); }, - } as Parameters[0]); - const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); - if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); - let afterCreate: ((input: unknown) => void) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: () => undefined, - onBeforeStartDevServer: () => undefined, - onCloseDevServer: () => undefined, - }); - afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); - expect(captured).toEqual(['rsbuild-token-1234']); -}); - -test('sends one App-only full reload for each later successful App compilation', async () => { - const captured: string[] = []; - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'), - mode: 'development', - onAppWebSocketToken: (token: string) => { captured.push(token); }, - } as Parameters[0]); - const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); - if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); - - let afterCompiler: ((input: unknown) => void) | undefined; - let afterEnvironmentCompile: ((input: unknown) => void) | undefined; - let beforeStartDevServer: ((input: unknown) => unknown) | undefined; - let closeDevServer: (() => unknown) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCompiler = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: (callback: unknown) => { afterEnvironmentCompile = callback as (input: unknown) => void; }, - onBeforeStartDevServer: (callback: unknown) => { beforeStartDevServer = callback as (input: unknown) => unknown; }, - onCloseDevServer: (callback: unknown) => { closeDevServer = callback as () => unknown; }, - }); - - const appSends: string[] = []; - const otherSends: string[] = []; - const firstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: true, stats: { hasErrors: () => false, hash: 'app-change-a' } }); - const duplicateFirstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } }); - const appBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } }); - const appAUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } }); - const repeatedAppBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } }); - const failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } }); - const nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } }); - - afterCompiler?.({ environments: { app: { webSocketToken: 'rsbuild-app-token-1234' }, widget: { webSocketToken: 'widget-token-must-not-leak' } } }); - afterEnvironmentCompile?.(appBUpdate); - expect(appSends).toEqual([]); - beforeStartDevServer?.({ - server: { - environments: { - app: { hot: { send: (type: string) => { appSends.push(type); } } }, - widget: { hot: { send: (type: string) => { otherSends.push(type); } } }, - }, - }, - }); - afterEnvironmentCompile?.(firstAppUpdate); - afterEnvironmentCompile?.(nonAppUpdate); - afterEnvironmentCompile?.(failedAppUpdate); - afterEnvironmentCompile?.(duplicateFirstAppUpdate); - expect(captured).toEqual(['rsbuild-app-token-1234']); - expect(appSends).toEqual([]); - - afterEnvironmentCompile?.(appBUpdate); - expect(appSends).toEqual(['full-reload']); - afterEnvironmentCompile?.(appAUpdate); - expect(appSends).toEqual(['full-reload', 'full-reload']); - afterEnvironmentCompile?.(repeatedAppBUpdate); - expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']); - expect(otherSends).toEqual([]); - - await closeDevServer?.(); - afterEnvironmentCompile?.(appAUpdate); - expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']); - - const replacementSends: string[] = []; - beforeStartDevServer?.({ server: { environments: { app: { hot: { send: (type: string) => { replacementSends.push(type); } } } } } }); - afterEnvironmentCompile?.(appBUpdate); - expect(replacementSends).toEqual(['full-reload']); -}); - -test('keeps compiler-App HMR out of the opaque browser child', () => { - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-provider-outer-hmr'), - mode: 'development', - }); - const app = config.environments?.app as Readonly<{ readonly dev?: unknown }> | undefined; - expect(app?.dev).toMatchObject({ hmr: false, liveReload: false }); -}); - -test('declares an optional runtime while keeping Claude and Codex artifacts buildable', async () => { - const copied = await copyProviderExample(); - try { - const root = copied.projectRoot; - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root }).prepare('dev'); - - expect(prepared.source.state).toBe('ready'); - expect(prepared.devRuntime).toMatchObject({ - apps: [expect.objectContaining({ name: 'timeline', resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' })], - provider: './src/dev/provider.ts', - servers: [expect.objectContaining({ name: 'timeline', transport: 'stdio' })], - }); - expect(prepared.model?.hooks).toEqual(expect.arrayContaining([ - expect.objectContaining({ targets: expect.arrayContaining(['claude', 'codex']) }), - ])); - - const artifact = await new ArtifactService({ epochStore: new EpochStore({ projectRoot: root }) }).build(prepared); - if (artifact.outcome !== 'succeeded') throw new Error(JSON.stringify(artifact.diagnostics)); - expect(artifact).toMatchObject({ outcome: 'succeeded' }); - const provider = createDevRuntimeProvider(); - const runtimeStorageRoot = join(root, '.agent-bundle', 'runtime-test'); - expect(provider.descriptor).toEqual({ - environmentVariables: [], - id: 'rsc-agent-runtime', - label: 'RSC agent runtime', - schemaVersion: 1, - }); - const session = await provider.start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: root, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-test', - signal: new AbortController().signal, - storageRoot: runtimeStorageRoot, - }); - try { - await waitFor(() => session.status().state === 'active'); - expect(session.status()).toMatchObject({ hmrReady: true, state: 'active' }); - expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ - entryPath: '/edit-timeline-v1.html', - httpOrigin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:[1-9]\d*$/u), - httpPathPrefixes: ['/'], - surfaceId: 'mcp.edit-timeline', - webSocketOrigin: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:[1-9]\d*$/u), - webSocketPath: '/rsbuild-hmr', - }); - expect(session.status()).not.toHaveProperty('clientSurface'); - expect(session.surfaces()).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'hook' }), - expect.objectContaining({ id: 'mcp.render_edit_timeline', kind: 'mcp-tool' }), - expect.objectContaining({ id: 'mcp.edit-timeline', kind: 'mcp-resource' }), - expect.objectContaining({ id: 'mcp.timeline', kind: 'mcp-app' }), - ])); - const registry = session.mcpRegistry.snapshot(); - expect(registry).toMatchObject({ runtimeGenerationId: expect.any(String) }); - expect([...new Set([ - registry!.definitionDigest, - registry!.servers[0]!.serverDigest, - registry!.transportDigest, - ])]).toHaveLength(3); - - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, - surfaceId: 'mcp.timeline', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['..'], - runtimeGenerationId: registry!.runtimeGenerationId, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, - surfaceId: 'mcp.unknown', - })).resolves.toBeUndefined(); - for (const path of [ - ['rsc', 'missing.html'], - ['..'], - ['.'], - ['rsc\\index.html'], - ['rsc', 'index\0.html'], - ['%2e%2e'], - ]) { - await expect(session.readAsset({ - path, - runtimeGenerationId: registry!.runtimeGenerationId, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - } - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: '', - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: 'generation-pruned', - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - const assetPath = join( - runtimeStorageRoot, - 'generation-store', - 'generations', - registry!.runtimeGenerationId, - 'widget', - 'rsc', - 'index.html', - ); - const originalAsset = await readFile(assetPath); - const readTimelineAsset = () => session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: registry!.runtimeGenerationId, - surfaceId: 'mcp.timeline', - }); - const digestTampered = Buffer.from(originalAsset); - digestTampered[0] = digestTampered[0] === 0 ? 1 : 0; - await writeFile(assetPath, digestTampered); - await expect(readTimelineAsset()).resolves.toBeUndefined(); - await writeFile(assetPath, originalAsset); - await writeFile(assetPath, Buffer.alloc((8 * 1024 * 1024) + 1)); - await expect(readTimelineAsset()).resolves.toBeUndefined(); - await writeFile(assetPath, originalAsset); - await rm(assetPath); - await symlink(join(root, 'src', 'definition.ts'), assetPath); - await expect(readTimelineAsset()).resolves.toBeUndefined(); - await rm(assetPath); - await mkdir(assetPath); - await expect(readTimelineAsset()).resolves.toBeUndefined(); - await rm(assetPath, { recursive: true }); - await writeFile(assetPath, originalAsset); - - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - const initialCapabilities = mcp.snapshot().connection.capabilities; - if (initialCapabilities === undefined) throw new Error('Expected runtime MCP capabilities.'); - expect(initialCapabilities).toEqual({ resources: {}, tools: {} }); - expect(Object.isFrozen(initialCapabilities)).toBe(true); - expect(Object.isFrozen(initialCapabilities.resources)).toBe(true); - expect(Object.isFrozen(initialCapabilities.tools)).toBe(true); - const list = await mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' }); - expect(list.value).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'render_edit_timeline' })])); - const originalBinding = mcp.snapshot().binding; - await session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => ({ - ...app, - _meta: { ...app._meta, 'openai/widgetDescription': 'Updated timeline description.' }, - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-app-metadata`, - }); - const reconciledRegistry = session.mcpRegistry.snapshot(); - expect(reconciledRegistry!.definitionDigest).not.toBe(registry!.definitionDigest); - expect(reconciledRegistry).toMatchObject({ - registryRevision: originalBinding.registryRevision + 1, - runtimeGenerationId: registry!.runtimeGenerationId, - }); - expect(mcp.snapshot().binding.sessionRevision).toBe(originalBinding.sessionRevision + 1); - await expect(mcp.execute({ expectedSessionRevision: originalBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - await expect(mcp.execute({ expectedSessionRevision: mcp.snapshot().binding.sessionRevision, kind: 'list-tools' })).resolves.toMatchObject({ - vector: { runtimeGenerationId: registry!.runtimeGenerationId }, - }); - expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); - await session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-p1-revert`, - }); - const revertedRegistry = session.mcpRegistry.snapshot(); - expect(revertedRegistry).toMatchObject({ - definitionDigest: registry!.definitionDigest, - registryRevision: originalBinding.registryRevision + 2, - runtimeGenerationId: registry!.runtimeGenerationId, - }); - const revertedRevision = mcp.snapshot().binding.sessionRevision; - await session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-p3-repeat`, - }); - expect(session.mcpRegistry.snapshot()).toMatchObject({ - definitionDigest: registry!.definitionDigest, - registryRevision: revertedRegistry!.registryRevision, - }); - expect(mcp.snapshot().binding.sessionRevision).toBe(revertedRevision); - await mcp.close(); - const closing = session.close(); - await expect(session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-close-race`, - })).rejects.toThrow('RSC runtime session is closed.'); - await closing; - expect(session.status()).toMatchObject({ hmrReady: false, state: 'closed' }); - expect(session.clientSurface('mcp.edit-timeline')).toBeUndefined(); - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('resets state through the dynamically loaded copied provider', async () => { - const copied = await copyProviderExample(); - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-dynamic-reset'); - const controller = new AbortController(); - let session: Awaited>['start']>> | undefined; - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const provider = await resolveDevRuntimeProvider(copied.projectRoot, prepared.devRuntime!); - session = await provider.start(startContext({ - preparedRuntime: prepared.devRuntime!, - projectRoot: copied.projectRoot, - providerSessionId: 'provider-dynamic-reset', - signal: controller.signal, - storageRoot, - })); - await waitFor(() => session!.status().state === 'active'); - const activeVector = session.status().activeVector; - if (activeVector === undefined) throw new Error('The copied provider did not activate a runtime generation.'); - - await expect(session.resetState({ - expectedGenerationId: activeVector.runtimeGenerationId, - stateStoreId: activeVector.stateStoreId, - })).resolves.toEqual({ stateStoreId: activeVector.stateStoreId, stateVersion: 1 }); - } finally { - controller.abort(); - await session?.close(); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('rejects an already-aborted provider start before creating a runtime session', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('provider startup cancelled'); - controller.abort(reason); - - await expect(createDevRuntimeProvider().start({ - artifactStatus: () => Object.freeze({ state: 'missing' as const }), - emit: () => undefined, - environment: Object.freeze({}), - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-aborted', - signal: controller.signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-aborted'), - })).rejects.toBe(reason); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('retries an identical compiler cohort after an asynchronous provider activation failure', async () => { - const outcomes = [deferred(), deferred()]; - const captures: boolean[] = []; - let enqueueCount = 0; - const observer = compileObserver({ - beforeAttempt: () => `attempt-${String(captures.length + 1)}`, - capture: async (input) => { - captures.push(input.cohortChanged); - return input.cohortChanged ? snapshotFor(input.attemptId, input.sourceRevision) : undefined; - }, - enqueue: () => outcomes[enqueueCount++]!.promise, - failAttempt: () => undefined, - }); - - await observer.compile(); - outcomes[0]!.resolve('failed'); - await Promise.resolve(); - await observer.compile(); - outcomes[1]!.resolve('activated'); - await Promise.resolve(); - await observer.compile(); - - expect(captures).toEqual([true, true, false]); - expect(enqueueCount).toBe(2); -}); - -test('classifies a same-hash compiler cohort as unchanged while its activation is pending', async () => { - const activation = deferred(); - const captures: boolean[] = []; - let attempts = 0; - let enqueueCount = 0; - const observer = compileObserver({ - beforeAttempt: () => `attempt-${String(++attempts)}`, - capture: async (input) => { - captures.push(input.cohortChanged); - return input.cohortChanged ? snapshotFor(input.attemptId, input.sourceRevision) : undefined; - }, - enqueue: () => { - enqueueCount += 1; - return activation.promise; - }, - failAttempt: () => undefined, - }); - - await observer.compile(); - await observer.compile(); - - expect(captures).toEqual([true, false]); - expect(enqueueCount).toBe(1); - activation.resolve('activated'); -}); - -test('classifies direct compiler errors as source build failures without capture or enqueue', async () => { - const captured: string[] = []; - const enqueued: string[] = []; - const failures: unknown[][] = []; - const observer = compileObserver({ - beforeAttempt: () => 'attempt-source-build', - capture: async (input) => { - captured.push(input.attemptId); - return snapshotFor(input.attemptId, input.sourceRevision); - }, - enqueue: (snapshot) => { - enqueued.push(snapshot.attemptId); - return 'activated'; - }, - failAttempt: (...input: unknown[]) => { failures.push(input); }, - }); - - await observer.compile({ hasErrors: true }); - - expect(captured).toEqual([]); - expect(enqueued).toEqual([]); - expect(failures).toHaveLength(1); - expect(failures[0]?.[0]).toBe('attempt-source-build'); - expect(failures[0]?.[2]).toBe('source-build'); -}); - -test('recaptures an unchanged successful cohort after a source build failure', async () => { - const captured: boolean[] = []; - const observer = compileObserver({ - beforeAttempt: () => `attempt-${String(captured.length + 1)}`, - capture: async (input) => { - captured.push(input.cohortChanged); - return snapshotFor(input.attemptId, input.sourceRevision); - }, - enqueue: () => 'activated', - failAttempt: () => undefined, - }); - - await observer.compile(); - await observer.compile({ hasErrors: true }); - await observer.compile(); - - expect(captured).toEqual([true, true]); -}); - -test('keeps malformed compiler stats in the provider lifecycle failure lane', async () => { - const failures: unknown[][] = []; - const observer = compileObserver({ - beforeAttempt: () => 'attempt-malformed-stats', - capture: async (input) => snapshotFor(input.attemptId, input.sourceRevision), - enqueue: () => 'activated', - failAttempt: (...input: unknown[]) => { failures.push(input); }, - }); - - await observer.compile({ children: [] }); - - expect(failures).toHaveLength(1); - expect(failures[0]?.[0]).toBe('attempt-malformed-stats'); - expect(failures[0]?.[2]).toBe('provider-lifecycle'); -}); - -test('aggregates owned resource closer failures', async () => { - const ledger = new ResourceLedger(); - const first = new Error('first closer failed'); - const second = new Error('second closer failed'); - ledger.add(async () => { throw first; }); - ledger.add(async () => { throw second; }); - - await expect(ledger.close()).rejects.toMatchObject({ - errors: expect.arrayContaining([first, second]), - message: 'RSC runtime startup cleanup failed.', - }); -}); - -test('records one failed event when capture and observer finalization both fail an attempt', async () => { - const copied = await copyProviderExample(); - try { - await writeFile(join(copied.projectRoot, 'src', 'definition.ts'), 'export const runtimeDefinition: any = {};\n'); - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const events: Array<{ readonly type: string }> = []; - const session = await RsbuildRuntimeSession.start({ - ...startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-double-failure', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-double-failure'), - }), - emit: (event) => { events.push(event); }, - }); - try { - await waitFor(() => session.status().state === 'degraded'); - expect(session.status().diagnostics).toEqual([{ - code: 'AB8200', - message: expect.any(String), - phase: 'provider-lifecycle', - severity: 'error', - }]); - expect(events.filter((event) => event.type === 'runtime.generation.failed')).toHaveLength(1); - await expect(readdir(join(copied.projectRoot, '.agent-bundle', 'runtime-double-failure', 'generation-store', 'staging'))).resolves.toEqual([]); - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('keeps the active generation while publishing a source build diagnostic before its failed event', async () => { - const copied = await copyProviderExample(); - let session: RsbuildRuntimeSession | undefined; - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const events: Array<{ readonly type: string }> = []; - const failedStatuses: Array> = []; - session = await RsbuildRuntimeSession.start({ - ...startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-source-build-retention', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-source-build-retention'), - }), - emit: (event) => { - events.push(event); - if (event.type === 'runtime.generation.failed' && session !== undefined) failedStatuses.push(session.status()); - }, - }); - await waitFor(() => session?.status().state === 'active'); - const beforeStatus = session.status(); - const beforeSurfaces = session.surfaces(); - const beforeRuns = session.runs(50); - - await introduceWorkerSyntaxError(copied.projectRoot); - await waitFor(() => events.filter((event) => event.type === 'runtime.generation.failed').length === 1); - - expect(failedStatuses).toHaveLength(1); - expect(failedStatuses[0]).toMatchObject({ - activeVector: beforeStatus.activeVector, - diagnostics: [{ - code: 'AB8206', - message: 'RSC runtime source build failed.', - phase: 'source/build', - severity: 'error', - }], - lastGoodVector: beforeStatus.lastGoodVector, - state: 'active', - }); - expect(session.status()).toEqual(failedStatuses[0]); - expect(session.surfaces()).toEqual(beforeSurfaces); - expect(session.runs(50)).toEqual(beforeRuns); - } finally { - await session?.close(); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('drains a deferred generation pipeline before close without publishing late lifecycle events', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const reached = deferred(); - const release = deferred(); - const events: Array<{ readonly type: string }> = []; - let deferActivation = false; - let held = false; - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-close-deferred-generation'); - const session = await RsbuildRuntimeSession.start({ - ...startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-close-deferred-generation', - signal: new AbortController().signal, - storageRoot, - }), - emit: (event) => { events.push(event); }, - }, { - beforeGenerationCapture: async () => { - if (!deferActivation || held) return; - held = true; - reached.resolve(); - await release.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - deferActivation = true; - await changeWorkerImplementation(copied.projectRoot, 'close-deferred-generation'); - const captureReached = await Promise.race([ - reached.promise.then(() => true), - new Promise((resolve) => { setTimeout(() => { resolve(false); }, 5_000); }), - ]); - expect(captureReached).toBe(true); - const eventCountBeforeClose = events.length; - const closing = session.close(); - let closed = false; - void closing.then(() => { closed = true; }); - await new Promise((resolve) => { setTimeout(resolve, 0); }); - expect(closed).toBe(false); - release.resolve(); - await closing; - expect(events).toHaveLength(eventCountBeforeClose); - await expect(lstat(join(storageRoot, 'generation-store', 'staging'))).rejects.toThrow(); - } finally { - release.resolve(); - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('binds renamed and added App surfaces to the active generation assets without restoring removed surfaces', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-reconciled-app-assets', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-reconciled-app-assets'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const runtimeGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const original = prepared.devRuntime!.apps[0]!; - await session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: [ - { ...original, name: 'timeline-renamed' }, - { ...original, id: `${original.id}-added`, name: 'timeline-added' }, - ], - sourceRevision: `${prepared.devRuntime!.sourceRevision}-reconciled-app-assets`, - }); - - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId, - surfaceId: 'mcp.timeline-renamed', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId, - surfaceId: 'mcp.timeline-added', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - - await changeWorkerImplementation(copied.projectRoot, 'reconciled-app-assets-generation-two'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== runtimeGenerationId); - const nextRuntimeGenerationId = session.status().activeVector!.runtimeGenerationId; - for (const generationId of [runtimeGenerationId, nextRuntimeGenerationId]) { - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline-renamed', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline-added', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('rebinds current App surfaces across retained generations after a later configuration reconcile', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-reconciled-retained-app-assets', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-reconciled-retained-app-assets'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const firstGenerationId = session.mcpRegistry.snapshot()!.runtimeGenerationId; - await changeWorkerImplementation(copied.projectRoot, 'reconciled-retained-app-assets-generation-two'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGenerationId); - const secondGenerationId = session.status().activeVector!.runtimeGenerationId; - const original = prepared.devRuntime!.apps[0]!; - await session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: [ - { ...original, name: 'timeline-renamed' }, - { ...original, id: `${original.id}-added`, name: 'timeline-added' }, - ], - sourceRevision: `${prepared.devRuntime!.sourceRevision}-reconciled-retained-app-assets`, - }); - - for (const generationId of [firstGenerationId, secondGenerationId]) { - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline-renamed', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline-added', - })).resolves.toMatchObject({ contentType: 'text/html' }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: generationId, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('keeps the same MCP session and revision across an implementation-only generation', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-implementation-only', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-implementation-only'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const beforeGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const before = mcp.snapshot(); - await changeWorkerImplementation(copied.projectRoot, 'implementation-only'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== beforeGeneration); - const after = mcp.snapshot(); - expect(after.binding).toMatchObject({ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - }); - await expect(mcp.execute({ - expectedSessionRevision: after.binding.sessionRevision, - kind: 'list-tools', - })).resolves.toMatchObject({ - sessionId: before.binding.sessionId, - sessionRevision: before.binding.sessionRevision, - vector: { runtimeGenerationId: session.status().activeVector!.runtimeGenerationId }, - }); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('restarts and relists an open MCP session after a warm-cache definition change', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-definition-change', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-definition-change'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const beforeRegistry = session.mcpRegistry.snapshot()!; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const before = mcp.snapshot().binding; - await changeDefinition(copied.projectRoot, 'Read the freshly rebuilt shared runtime state.'); - await waitFor(() => session.mcpRegistry.snapshot()!.definitionDigest !== beforeRegistry.definitionDigest); - const afterRegistry = session.mcpRegistry.snapshot()!; - const after = mcp.snapshot(); - expect(afterRegistry.runtimeGenerationId).not.toBe(beforeRegistry.runtimeGenerationId); - expect(after.binding.sessionRevision).toBe(before.sessionRevision + 1); - await expect(mcp.execute({ - expectedSessionRevision: after.binding.sessionRevision, - kind: 'list-tools', - })).resolves.toMatchObject({ vector: { runtimeGenerationId: afterRegistry.runtimeGenerationId } }); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('uses the live registry authority after a transport-only runtime MCP reconciliation', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-live-transport-authority', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-live-transport-authority'), - })); - try { - await waitFor(() => session.status().state === 'active'); - const initialRegistry = session.mcpRegistry.snapshot()!; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const initialBinding = mcp.snapshot().binding; - const definitionPrepared = Object.freeze({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => Object.freeze({ - ...app, - _meta: Object.freeze({ ...app._meta, 'openai/widgetDescription': 'Live definition authority.' }), - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-definition-v2`, - }); - await session.reconcilePreparedRuntime(definitionPrepared); - const definitionRegistry = session.mcpRegistry.snapshot()!; - const definitionBinding = mcp.snapshot().binding; - expect(definitionRegistry).toMatchObject({ - registryRevision: initialRegistry.registryRevision + 1, - runtimeGenerationId: initialRegistry.runtimeGenerationId, - transportDigest: initialRegistry.transportDigest, - }); - expect(definitionRegistry.definitionDigest).not.toBe(initialRegistry.definitionDigest); - expect(definitionBinding).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision, - sessionId: initialBinding.sessionId, - sessionRevision: initialBinding.sessionRevision + 1, - }); - await expect(mcp.execute({ expectedSessionRevision: initialBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - const definitionRun = await session.invoke({ - expectedGenerationId: definitionRegistry.runtimeGenerationId, - input: {}, - surfaceId: 'mcp.render_edit_timeline', - target: 'portable', - }); - expect(definitionRun).toMatchObject({ - status: 'succeeded', vector: { runtimeGenerationId: definitionRegistry.runtimeGenerationId }, - }); - if (definitionRun.status !== 'succeeded' || definitionRun.result.app === undefined) throw new Error('Definition reconciliation run omitted its Runtime App binding.'); - const definitionAppBinding = definitionRun.result.app.mcpBinding; - expect(definitionAppBinding).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision, - sessionId: expect.any(String), - sessionRevision: expect.any(Number), - transportDigest: definitionRegistry.transportDigest, - }); - - await session.reconcilePreparedRuntime({ - ...definitionPrepared, - servers: definitionPrepared.servers.map((server) => Object.freeze({ - ...server, - env: Object.freeze({ ...(server.env ?? {}), TIMELINE_TRANSPORT_SENTINEL: 'transport-v2' }), - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-transport-v2`, - }); - const registry = session.mcpRegistry.snapshot()!; - const currentBinding = mcp.snapshot().binding; - expect(registry).toMatchObject({ - definitionDigest: definitionRegistry.definitionDigest, - registryRevision: definitionRegistry.registryRevision + 1, - runtimeGenerationId: definitionRegistry.runtimeGenerationId, - }); - expect(registry.transportDigest).not.toBe(definitionRegistry.transportDigest); - expect(currentBinding).toMatchObject({ - registryRevision: registry.registryRevision, - sessionId: definitionBinding.sessionId, - sessionRevision: definitionBinding.sessionRevision + 1, - transportDigest: registry.transportDigest, - }); - await expect(mcp.execute({ expectedSessionRevision: definitionBinding.sessionRevision, kind: 'list-tools' })).rejects.toThrow(); - - const appRun = await session.invoke({ - expectedGenerationId: registry.runtimeGenerationId, - input: {}, - surfaceId: 'mcp.render_edit_timeline', - target: 'portable', - }); - expect(appRun).toMatchObject({ - status: 'succeeded', vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - if (appRun.status !== 'succeeded' || appRun.result.app === undefined) throw new Error('Transport reconciliation run omitted its Runtime App binding.'); - expect(appRun.result.app.mcpBinding).toMatchObject({ - definitionDigest: registry.definitionDigest, - registryRevision: registry.registryRevision, - sessionId: definitionAppBinding.sessionId, - sessionRevision: definitionAppBinding.sessionRevision + 1, - transportDigest: registry.transportDigest, - }); - await expect(mcp.execute({ - expectedSessionRevision: currentBinding.sessionRevision, - kind: 'read-resource', - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - })).resolves.toMatchObject({ - sessionId: currentBinding.sessionId, - sessionRevision: currentBinding.sessionRevision, - vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - await expect(mcp.execute({ - arguments: { limit: 1 }, - expectedSessionRevision: currentBinding.sessionRevision, - kind: 'call-tool', - name: 'render_edit_timeline', - })).resolves.toMatchObject({ - sessionId: currentBinding.sessionId, - sessionRevision: currentBinding.sessionRevision, - vector: { runtimeGenerationId: registry.runtimeGenerationId }, - }); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('rejects MCP admission until a deferred public prepared-config restart has relisted', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const relistReached = deferred(); - const allowRelist = deferred(); - let deferRelist = false; - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-deferred-restart', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-deferred-restart'), - }), { - beforeMcpRelist: async () => { - if (!deferRelist) return; - relistReached.resolve(); - await allowRelist.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - expect(mcp.snapshot().connection.capabilities).toEqual({ resources: {}, tools: {} }); - const before = mcp.snapshot().binding; - deferRelist = true; - const reconciling = session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => ({ - ...app, - _meta: { ...app._meta, 'openai/widgetDescription': 'Restart after deferred relist.' }, - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-deferred-public-restart`, - }); - await relistReached.promise; - const restarting = mcp.snapshot(); - expect(restarting).toMatchObject({ state: 'restarting' }); - await expect(mcp.execute({ - expectedSessionRevision: restarting.binding.sessionRevision, - kind: 'list-tools', - })).rejects.toThrow('Runtime MCP session is restarting.'); - allowRelist.resolve(); - await reconciling; - expect(mcp.snapshot()).toMatchObject({ - binding: { sessionRevision: before.sessionRevision + 1 }, - state: 'ready', - }); - const restartedCapabilities = mcp.snapshot().connection.capabilities; - if (restartedCapabilities === undefined) throw new Error('Expected restarted runtime MCP capabilities.'); - expect(restartedCapabilities).toEqual({ resources: {}, tools: {} }); - expect(Object.isFrozen(restartedCapabilities)).toBe(true); - expect(Object.isFrozen(restartedCapabilities.resources)).toBe(true); - expect(Object.isFrozen(restartedCapabilities.tools)).toBe(true); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 30_000); - -test('aborts stale activation transactions at both private preparation boundaries', async () => { - for (const phase of ['store', 'registry'] as const) { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const reached = deferred(); - const allow = deferred(); - const events: Array<{ readonly runtimeGenerationId?: string; readonly type: string }> = []; - let armBarrier = false; - let held = false; - const session = await RsbuildRuntimeSession.start({ - ...startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: `provider-${phase}-prepare`, - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', `runtime-${phase}-prepare`), - }), - emit: (event) => { events.push(event); }, - }, { - afterActivationPrepare: async (input) => { - if (!armBarrier || held || input.phase !== phase) return; - held = true; - reached.resolve(); - await allow.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const mcp = await session.mcpRegistry.open({ serverName: 'timeline', target: 'portable' }); - try { - const firstBinding = mcp.snapshot().binding; - armBarrier = true; - await changeDefinition(copied.projectRoot, `Read state after ${phase} preparation.`); - await reached.promise; - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); - const reconciled = session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - apps: prepared.devRuntime!.apps.map((app) => ({ - ...app, - source: './src/widget/App.tsx', - })), - sourceRevision: `${prepared.devRuntime!.sourceRevision}-${phase}-superseding-prepared`, - }); - allow.resolve(); - await reconciled; - await new Promise((resolve) => { setTimeout(resolve, 50); }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: 'generation-2', - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: firstGeneration }); - expect(mcp.snapshot().binding).toMatchObject({ - sessionId: firstBinding.sessionId, - sessionRevision: firstBinding.sessionRevision, - }); - expect(events.filter((event) => event.type === 'runtime.generation.activated' && event.runtimeGenerationId === 'generation-2')).toHaveLength(0); - armBarrier = false; - await changeWorkerImplementation(copied.projectRoot, `${phase}-current-generation`); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== firstGeneration); - expect(session.status().activeVector?.runtimeGenerationId).not.toBe('generation-2'); - expect(mcp.snapshot().binding.sessionRevision).toBe(firstBinding.sessionRevision + 1); - } finally { - await mcp.close(); - } - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } - } -}, 60_000); - -test('commits a compiled generation across an equivalent prepared-runtime revision', { timeout: 0 }, async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const reached = deferred(); - const allow = deferred(); - let armBarrier = false; - let held = false; - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-equivalent-prepared-revision', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-equivalent-prepared-revision'), - }), { - afterActivationPrepare: async (input) => { - if (!armBarrier || held || input.phase !== 'store') return; - held = true; - reached.resolve(); - await allow.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - armBarrier = true; - await changeDefinition(copied.projectRoot, 'Read state after equivalent prepared revision.'); - await reached.promise; - const reconciled = session.reconcilePreparedRuntime({ - ...prepared.devRuntime!, - sourceRevision: `${prepared.devRuntime!.sourceRevision}-equivalent-prepared`, - }); - allow.resolve(); - await reconciled; - - expect(session.mcpRegistry.snapshot()).toMatchObject({ runtimeGenerationId: 'generation-2' }); - expect(session.status()).toMatchObject({ - activeVector: { runtimeGenerationId: 'generation-2' }, - diagnostics: [], - state: 'active', - }); - expect(session.mcpRegistry.snapshot()!.runtimeGenerationId).not.toBe(firstGeneration); - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('retains a leased inactive generation through pruning and prunes it after the read releases', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const enteredRead = deferred(); - const releaseRead = deferred(); - let deferAssetRead = true; - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-asset-lease'); - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-asset-lease', - signal: new AbortController().signal, - storageRoot, - }), { - beforeAssetRead: async () => { - if (!deferAssetRead) return; - enteredRead.resolve(); - await releaseRead.promise; - }, - }); - try { - await waitFor(() => session.status().state === 'active'); - const firstGeneration = session.mcpRegistry.snapshot()!.runtimeGenerationId; - const heldRead = session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: firstGeneration, - surfaceId: 'mcp.timeline', - }); - await enteredRead.promise; - let activeGeneration = firstGeneration; - for (let marker = 2; marker <= 7; marker += 1) { - await changeWorkerImplementation(copied.projectRoot, `lease-prune-${String(marker)}`); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== activeGeneration); - activeGeneration = session.status().activeVector!.runtimeGenerationId; - } - expect((await lstat(join(storageRoot, 'generation-store', 'generations', firstGeneration))).isDirectory()).toBe(true); - releaseRead.resolve(); - await expect(heldRead).resolves.toMatchObject({ contentType: 'text/html' }); - deferAssetRead = false; - await new Promise((resolve) => { setTimeout(resolve, 100); }); - await expect(session.readAsset({ - path: ['rsc', 'index.html'], - runtimeGenerationId: firstGeneration, - surfaceId: 'mcp.timeline', - })).resolves.toBeUndefined(); - } finally { - await session.close(); - } - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 60_000); - -test('aborts a deferred Rsbuild creation before starting its dev server', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('deferred compiler creation aborted'); - const created = deferred>>(); - let createCalls = 0; - let devServerStarts = 0; - const starting = RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-late-compiler', - signal: controller.signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-late-compiler'), - }), { - createRsbuild: (async () => { - createCalls += 1; - return created.promise; - }) as typeof createRsbuild, - }); - await waitFor(() => createCalls === 1); - controller.abort(reason); - created.resolve(Object.freeze({ - startDevServer: async () => { - devServerStarts += 1; - throw new Error('The aborted provider must not start a dev server.'); - }, - }) as unknown as Awaited>); - - await expect(starting).rejects.toBe(reason); - expect(devServerStarts).toBe(0); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('uses the bound Rsbuild dev-server context instead of a stale port-zero start result', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - let closeCalls = 0; - const create = async (input: Readonly<{ readonly config: unknown }>) => { - const plugin = ((input.config as Readonly<{ readonly plugins?: readonly unknown[] }>).plugins ?? []).find((candidate): candidate is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof candidate === 'object' && candidate !== null && - (candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token'); - if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.'); - let afterCreate: ((input: unknown) => void) | undefined; - plugin.setup({ - onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; }, - onAfterEnvironmentCompile: () => undefined, - onBeforeStartDevServer: () => undefined, - onCloseDevServer: () => undefined, - }); - afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); - return Object.freeze({ - context: Object.freeze({ - devServer: Object.freeze({ hostname: '127.0.0.1', https: false, port: 41_103 }), - }), - startDevServer: async () => Object.freeze({ - port: 0, - server: Object.freeze({ close: async () => { closeCalls += 1; } }), - urls: Object.freeze(['http://127.0.0.1:0']), - }) as unknown as StartDevServerResult, - }) as unknown as Awaited>; - }; - const session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-bound-dev-server-context', - signal: new AbortController().signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-bound-dev-server-context'), - }), { createRsbuild: create as typeof createRsbuild }); - try { - expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({ - httpOrigin: 'http://127.0.0.1:41103', - webSocketOrigin: 'ws://127.0.0.1:41103', - }); - } finally { - await session.close(); - } - expect(closeCalls).toBe(1); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('waits for a late Rsbuild server closer after aborting startup', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('late server startup aborted'); - const started = deferred(); - const closeGate = deferred(); - let createCalls = 0; - let closeCalls = 0; - const create = async () => { - createCalls += 1; - return Object.freeze({ startDevServer: async () => started.promise }) as unknown as Awaited>; - }; - const starting = RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-late-server', - signal: controller.signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-late-server'), - }), { createRsbuild: create as typeof createRsbuild }); - - await waitFor(() => createCalls === 1); - controller.abort(reason); - await new Promise((resolve) => { setTimeout(resolve, 50); }); - started.resolve(Object.freeze({ - port: 41_001, - server: Object.freeze({ close: async () => { - closeCalls += 1; - await closeGate.promise; - } }), - urls: Object.freeze(['http://127.0.0.1:41001']), - }) as unknown as StartDevServerResult); - - const outcome = starting.then( - () => 'resolved', - (error: unknown) => error, - ); - let settled = false; - void outcome.then(() => { settled = true; }); - await waitFor(() => closeCalls === 1); - await new Promise((resolve) => { setTimeout(resolve, 0); }); - const settledBeforeCloseFinished = settled; - closeGate.resolve(); - await expect(outcome).resolves.toBe(reason); - expect(settledBeforeCloseFinished).toBe(false); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('closes a server returned immediately after startup abort', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('returned server startup aborted'); - let closeCalls = 0; - const create = async () => Object.freeze({ - startDevServer: async () => { - controller.abort(reason); - return Object.freeze({ - port: 41_002, - server: Object.freeze({ close: async () => { closeCalls += 1; } }), - urls: Object.freeze(['http://127.0.0.1:41002']), - }) as unknown as StartDevServerResult; - }, - }) as unknown as Awaited>; - - await expect(RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-returned-server', - signal: controller.signal, - storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-returned-server'), - }), { createRsbuild: create as typeof createRsbuild })).rejects.toBe(reason); - expect(closeCalls).toBe(1); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('preserves an aborted startup cause with every acquired cleanup failure', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('startup aborted after acquiring the dev server'); - const cleanupSecret = 'do-not-expose-startup-cleanup-secret'; - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-startup-cleanup-failure'); - let serverCloseCalls = 0; - const create = async () => Object.freeze({ - startDevServer: async () => { - await writeFile(join(storageRoot, 'runs', '.agent-bundle-runtime-owner'), 'tampered-owner-marker'); - controller.abort(reason); - return Object.freeze({ - port: 41_003, - server: Object.freeze({ close: async () => { - serverCloseCalls += 1; - throw new Error(cleanupSecret); - } }), - urls: Object.freeze(['http://127.0.0.1:41003']), - }) as unknown as StartDevServerResult; - }, - }) as unknown as Awaited>; - - const outcome = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-startup-cleanup-failure', - signal: controller.signal, - storageRoot, - }), { createRsbuild: create as typeof createRsbuild }).then( - () => undefined, - (error: unknown) => error, - ); - - expect(serverCloseCalls).toBe(1); - expect(outcome).toBeInstanceOf(AggregateError); - const failure = outcome as AggregateError; - expect(failure.message).toBe('RSC runtime startup failed; cleanup failures: owned-runs-root, rsbuild-dev-server.'); - expect(failure.message).not.toContain(cleanupSecret); - expect(failure.errors[0]).toBe(reason); - expect(failure.errors).toEqual(expect.arrayContaining([ - reason, - expect.objectContaining({ message: cleanupSecret }), - expect.objectContaining({ message: 'RSC runtime invocation root ownership marker changed during this provider session.' }), - ])); - expect((await lstat(join(storageRoot, 'runs'))).isDirectory()).toBe(true); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('joins a late owned-runs cleanup after abort has already drained startup cleanup', async () => { - const copied = await copyProviderExample(); - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - const controller = new AbortController(); - const reason = new Error('startup aborted while acquiring owned runs root'); - const cleanupSecret = 'do-not-expose-late-owned-runs-secret'; - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-late-owned-runs-root'); - const ownedRunsRootCreated = deferred(); - const releaseOwnedRunsRoot = deferred(); - const startupCleanupClosed = deferred(); - const ownedRunsCleanupEntered = deferred(); - const releaseOwnedRunsCleanup = deferred(); - let settled = false; - const starting = RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-late-owned-runs-root', - signal: controller.signal, - storageRoot, - }), { - afterOwnedRunsRootCreated: async () => { - ownedRunsRootCreated.resolve(); - await releaseOwnedRunsRoot.promise; - }, - beforeOwnedRunsRootCleanup: async () => { - ownedRunsCleanupEntered.resolve(); - await releaseOwnedRunsCleanup.promise; - await writeFile(join(storageRoot, 'runs', '.agent-bundle-runtime-owner'), cleanupSecret); - }, - onStartupCleanupClosed: () => { startupCleanupClosed.resolve(); }, - }); - const outcome = starting.then( - () => undefined, - (error: unknown) => error, - ); - void outcome.then(() => { settled = true; }); - - await ownedRunsRootCreated.promise; - controller.abort(reason); - await startupCleanupClosed.promise; - releaseOwnedRunsRoot.resolve(); - await ownedRunsCleanupEntered.promise; - await new Promise((resolve) => { setTimeout(resolve, 0); }); - expect(settled).toBe(false); - releaseOwnedRunsCleanup.resolve(); - - const failure = await outcome; - expect(failure).toBeInstanceOf(AggregateError); - const aggregate = failure as AggregateError; - expect(aggregate.message).toBe('RSC runtime startup failed; cleanup failures: owned-runs-root.'); - expect(aggregate.message).not.toContain(cleanupSecret); - expect(aggregate.errors[0]).toBe(reason); - expect(aggregate.errors).toEqual(expect.arrayContaining([ - reason, - expect.objectContaining({ message: 'RSC runtime invocation root ownership marker changed during this provider session.' }), - ])); - expect((await lstat(join(storageRoot, 'runs'))).isDirectory()).toBe(true); - } finally { - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}); - -test('drains every live-session cleanup group once when independent closers reject', async () => { - const copied = await copyProviderExample(); - const storageRoot = join(copied.projectRoot, '.agent-bundle', 'runtime-live-close-failures'); - const attempted: string[] = []; - const secrets = new Map([ - ['owned-runs-root', 'do-not-expose-live-root-secret'], - ['rsbuild-dev-server', 'do-not-expose-live-server-secret'], - ['run-artifact', 'do-not-expose-live-artifact-secret'], - ]); - let session: RsbuildRuntimeSession | undefined; - try { - const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); - session = await RsbuildRuntimeSession.start(startContext({ - projectRoot: copied.projectRoot, - preparedRuntime: prepared.devRuntime!, - providerSessionId: 'provider-live-close-failures', - signal: new AbortController().signal, - storageRoot, - }), { - afterLiveSessionCleanupResource: ({ resource }: Readonly<{ readonly resource: string }>) => { - attempted.push(resource); - const secret = secrets.get(resource); - if (secret !== undefined) throw new Error(secret); - }, - }); - await waitFor(() => session!.status().state === 'active'); - const activeVector = session.status().activeVector; - if (activeVector === undefined) throw new Error('Expected an active runtime generation.'); - const target = session.surfaces().find((surface) => surface.id === 'mcp.runtime_status')!.targets[0]!; - await expect(session.invoke({ - expectedGenerationId: activeVector.runtimeGenerationId, - input: {}, - surfaceId: 'mcp.runtime_status', - target, - })).resolves.toMatchObject({ status: 'succeeded' }); - - const closing = session.close(); - expect(session.close()).toBe(closing); - const failure = await closing.then( - () => undefined, - (error: unknown) => error, - ); - expect(failure).toBeInstanceOf(AggregateError); - const aggregate = failure as AggregateError; - expect(aggregate.message).toBe('RSC runtime session close failed; cleanup failures: owned-runs-root, rsbuild-dev-server, run-artifact.'); - for (const secret of secrets.values()) expect(aggregate.message).not.toContain(secret); - expect(aggregate.errors).toEqual(expect.arrayContaining([...secrets.values()].map((secret) => expect.objectContaining({ message: secret })))); - expect(attempted).toEqual(expect.arrayContaining([ - 'generation-store', - 'owned-runs-root', - 'rsbuild-dev-server', - 'run-artifact', - 'runtime-mcp-registry', - ])); - expect(new Set(attempted).size).toBe(attempted.length); - expect(session.close()).toBe(closing); - } finally { - await session?.close().catch(() => undefined); - await rm(copied.workspaceRoot, { force: true, recursive: true }); - } -}, 45_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts deleted file mode 100644 index 9d5b9c2b1..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/docs-contract.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { execFile as executeFile } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { promisify } from 'node:util'; - -import { expect, test } from '@rstest/core'; - -const readme = async (): Promise => readFile(join(process.cwd(), 'README.md'), 'utf8'); -const execFile = promisify(executeFile); - -test('keeps the Hook JSX author example executable', async () => { - const source = await readme(); - const afterFileEdit = source.match(/export function AfterFileEdit\(\) \{[\s\S]*?\n}\n```/); - - expect(afterFileEdit?.[0]).toContain('\n '); - expect(afterFileEdit?.[0]).toContain('\n '); -}); - -test('requires attached native evidence before documenting Claude or Codex observations', async () => { - const source = await readme(); - - expect(source).toContain('`apply_patch` hook'); - expect(source).toContain('`dist/runtime/agent-runtime.manifest.json`'); - expect(source).toContain('value-free hook launch probe'); - expect(source).toContain('native PostToolUse/shared state remains unproven under `exec --ephemeral`'); - expect(source).toMatch(/Real Claude Code and Codex CLI runs are\s+intentionally skip-gated out of ordinary CI and default test runs/u); - expect(source).toMatch(/No attached tracked\s+schema-v2 native-evidence artifact exists in this repository snapshot/u); - expect(source).toMatch(/profiles are local compatibility simulations, and deterministic evaluator tests are not native certification/u); - expect(source).toContain('pnpm --filter @agent-bundle/rsc-agent-runtime-demo eval:hosts -- --host claude'); - expect(source).toContain('pnpm --filter @agent-bundle/rsc-agent-runtime-demo eval:hosts -- --host codex'); - expect(source).toContain('schema-v2 JSON evidence document'); - expect(source).toContain('MCP App iframe evidence is unavailable from either terminal CLI'); - expect(source).not.toContain('Claude fully proves hook→MCP/RSC shared behavior'); - expect(source).not.toContain('A non-authenticated session is reported as an environment limitation'); - expect(source).not.toContain('unavailable/not run'); - expect(source).not.toMatch(/in progress/iu); -}); - -test('documents the ordinary-CI micro-eval spot-check', async () => { - const source = await readme(); - - expect(source).toContain('### CI micro-eval spot-check'); - expect(source).toContain('pnpm eval:spot'); - expect(source).toMatch(/contacts\s+no real host and needs no credentials/u); -}); - -test('declares a shell-independent production build', async () => { - const manifest = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as { - readonly scripts?: Readonly>; - }; - - expect(manifest.scripts?.build).toBe('rsbuild build --mode production && pnpm package:hosts'); -}); - -test('derives the native evaluator root from decoded module URLs', async () => { - const helperUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-host-paths.mjs')).href; - const moduleUrl = pathToFileURL(join(tmpdir(), 'rsc runtime encoded path', 'scripts', 'eval-hosts.mjs')).href; - const source = [ - `import { exampleRootFromModule } from ${JSON.stringify(helperUrl)};`, - `process.stdout.write(exampleRootFromModule(${JSON.stringify(moduleUrl)}));`, - ].join('\n'); - const { stdout } = await execFile(process.execPath, ['--input-type=module', '--eval', source]); - - expect(stdout).toBe(join(tmpdir(), 'rsc runtime encoded path')); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts deleted file mode 100644 index 7ee54b3b0..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/eval-evidence.test.ts +++ /dev/null @@ -1,592 +0,0 @@ -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -import { expect, test } from '@rstest/core'; - -type TranscriptEvidence = { - eventCounts: { hook: number; json: number; mcp: number; rscRender: number }; - finalMarkerObserved: boolean; - mcpReadObserved: boolean; - mcpReadMarkerObserved: boolean; - rscRenderToolObserved: boolean; - sharedHookStateObserved: boolean; -}; - -type HookProbeSummary = { - commandLaunched: boolean; - exitStatuses: number[]; - launches: number; -}; - -type NativeEvidenceEnvelope = { - capturedAt: string; - claims: Array<{ basis: string; evidence: 'inferred' | 'observed' | 'unavailable'; id: string }>; - host: 'claude' | 'codex'; - hostVersion: string; -}; - -const marker = (host: 'claude' | 'codex'): string => `HOST_EVAL_FINAL host=${host} path=host-created.txt`; - -const parseEvidence = async ( - host: 'claude' | 'codex', - transcript: string, - correlation?: Readonly<{ finalMarker?: string; marker?: string; stateRecords?: readonly unknown[] }>, -): Promise => { - const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; - const source = [ - `import { evidenceFromTranscript } from ${JSON.stringify(moduleUrl)};`, - `process.stdout.write(JSON.stringify(evidenceFromTranscript(${JSON.stringify(host)}, ${JSON.stringify(transcript)}, ${JSON.stringify(correlation)})));`, - ].join('\n'); - const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const [exitCode] = (await once(child, 'close')) as [number | null]; - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - return JSON.parse(stdout) as TranscriptEvidence; -}; - -const parseHookProbe = async (records: unknown[]): Promise => { - const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; - const source = [ - `import { hookEvidenceFromProbe, summarizeHookProbe } from ${JSON.stringify(moduleUrl)};`, - `const summary = summarizeHookProbe(${JSON.stringify(records)});`, - 'process.stdout.write(JSON.stringify({ ...summary, hookObserved: hookEvidenceFromProbe(summary) }));', - ].join('\n'); - const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const [exitCode] = (await once(child, 'close')) as [number | null]; - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - return JSON.parse(stdout) as HookProbeSummary & { hookObserved: boolean }; -}; - -const classifyEvidence = async ( - host: 'claude' | 'codex', - result: Record, - capturedAt: string, -): Promise => { - const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-evidence.mjs')).href; - const source = [ - `import { classifyNativeEvidence } from ${JSON.stringify(moduleUrl)};`, - `process.stdout.write(JSON.stringify(classifyNativeEvidence(${JSON.stringify(host)}, ${JSON.stringify(result)}, { capturedAt: ${JSON.stringify(capturedAt)} })));`, - ].join('\n'); - const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const [exitCode] = (await once(child, 'close')) as [number | null]; - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - return JSON.parse(stdout) as NativeEvidenceEnvelope; -}; - -const sanitizeEnvironment = async ( - environment: Record, - owned: { codexHome: string; hookProbeFile: string; stateFile: string }, -): Promise> => { - const moduleUrl = pathToFileURL(join(process.cwd(), 'scripts/eval-host-environment.mjs')).href; - const source = [ - `import { sanitizedHostEnvironment } from ${JSON.stringify(moduleUrl)};`, - `process.stdout.write(JSON.stringify(sanitizedHostEnvironment(${JSON.stringify(environment)}, ${JSON.stringify(owned)})));`, - ].join('\n'); - const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const [exitCode] = (await once(child, 'close')) as [number | null]; - - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - return JSON.parse(stdout) as Record; -}; - -const unavailableHostEnvelope = async (): Promise<{ capturedAt: string; hosts: NativeEvidenceEnvelope[]; schemaVersion: number }> => { - const child = spawn(process.execPath, ['scripts/eval-hosts.mjs', '--host', 'claude'], { - cwd: process.cwd(), - env: { HOME: '/tmp', LANG: 'C', PATH: '', TERM: 'dumb' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { stdout += chunk; }); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const [exitCode] = (await once(child, 'close')) as [number | null]; - - expect(exitCode).toBe(1); - expect(stderr).toBe(''); - return JSON.parse(stdout) as { capturedAt: string; hosts: NativeEvidenceEnvelope[]; schemaVersion: number }; -}; - -test('does not treat Claude prompt, prose, or tool listings as host evidence', async () => { - const transcript = [ - JSON.stringify({ prompt: `Call recent_edits, render_edit_timeline, and say ${marker('claude')}.` }), - JSON.stringify({ tools: ['recent_edits', 'render_edit_timeline'], type: 'system' }), - JSON.stringify({ message: { content: [{ text: `I will say ${marker('claude')}.`, type: 'text' }], role: 'assistant' }, type: 'assistant' }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ - eventCounts: { hook: 0, mcp: 0, rscRender: 0 }, - finalMarkerObserved: false, - mcpReadObserved: false, - rscRenderToolObserved: false, - }); -}); - -test('does not count an invented Claude hook callback event', async () => { - const transcript = JSON.stringify({ hook_event_name: 'PostToolUse', subtype: 'hook_callback', type: 'system' }); - - await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ - eventCounts: { hook: 0 }, - }); -}); - -test('accepts only correlated Claude tool-use and successful result events', async () => { - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'tool-recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { content: [{ id: 'tool-render', input: {}, name: 'mcp__rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ message: { content: [{ content: 'snapshot', is_error: false, tool_use_id: 'tool-recent', type: 'tool_result' }], role: 'user' }, type: 'user' }), - JSON.stringify({ message: { content: [{ content: 'rendered', is_error: false, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, type: 'user' }), - JSON.stringify({ is_error: false, result: `${marker('claude')}\n`, subtype: 'success', type: 'result' }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ - eventCounts: { hook: 0, mcp: 1, rscRender: 1 }, - finalMarkerObserved: true, - mcpReadObserved: true, - rscRenderToolObserved: true, - }); -}); - -test('accepts Claude 2.1.250 plugin-qualified MCP tool names', async () => { - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'tool-recent', name: 'mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { content: [{ id: 'tool-render', name: 'mcp__plugin_rsc-agent-runtime_rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { content: [{ content: 'snapshot', is_error: false, tool_use_id: 'tool-recent', type: 'tool_result' }], role: 'user' }, - type: 'user', - }), - JSON.stringify({ - message: { content: [{ content: 'rendered', is_error: false, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ - eventCounts: { mcp: 1, rscRender: 1 }, - mcpReadObserved: true, - rscRenderToolObserved: true, - }); -}); - -test('rejects Claude tool uses without matching successful tool results', async () => { - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'tool-recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { content: [{ id: 'tool-render', input: {}, name: 'mcp__rsc-agent-runtime__render_edit_timeline', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ message: { content: [{ is_error: true, tool_use_id: 'tool-render', type: 'tool_result' }], role: 'user' }, type: 'user' }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript)).resolves.toMatchObject({ - eventCounts: { mcp: 0, rscRender: 0 }, - mcpReadObserved: false, - rscRenderToolObserved: false, - }); -}); - -test('rejects lookalike, failed, malformed, and oversized Claude recent_edits results', async () => { - const oversized = 'x'.repeat(16_385); - const transcript = [ - JSON.stringify({ - message: { - content: [ - { id: 'other', input: {}, name: 'mcp__other__recent_edits', type: 'tool_use' }, - { id: 'suffix', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits_suffix', type: 'tool_use' }, - { id: 'failed', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, - { id: 'malformed', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, - { id: 'oversized', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, - { id: 'too-many-blocks', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, - { id: 'joined-too-large', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }, - ], - role: 'assistant', - }, - type: 'assistant', - }), - JSON.stringify({ - message: { - content: [ - { content: 'unrelated', is_error: false, tool_use_id: 'other', type: 'tool_result' }, - { content: 'unrelated', is_error: false, tool_use_id: 'suffix', type: 'tool_result' }, - { content: 'owned marker', is_error: true, tool_use_id: 'failed', type: 'tool_result' }, - { content: { text: 'owned marker' }, is_error: false, tool_use_id: 'malformed', type: 'tool_result' }, - { content: oversized, is_error: false, tool_use_id: 'oversized', type: 'tool_result' }, - { content: Array.from({ length: 21 }, () => ({ text: 'owned marker', type: 'text' })), is_error: false, tool_use_id: 'too-many-blocks', type: 'tool_result' }, - { content: Array.from({ length: 20 }, () => ({ text: 'x'.repeat(819), type: 'text' })), is_error: false, tool_use_id: 'joined-too-large', type: 'tool_result' }, - ], - role: 'user', - }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript, { marker: 'owned marker' })).resolves.toMatchObject({ - eventCounts: { mcp: 0 }, - mcpReadMarkerObserved: false, - mcpReadObserved: false, - sharedHookStateObserved: false, - }); -}); - -test('correlates one exact Claude result marker to one matching owned hook-state record', async () => { - const correlation = { - marker: 'rsc-eval-marker-1234567890abcdef', - stateRecords: [{ - event: { host: 'claude', path: '/owned/host-created-rsc-eval-marker-1234567890abcdef.txt' }, - kind: 'edit', - }], - }; - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { - content: [{ content: [{ text: `state returned\n${correlation.marker}`, type: 'text' }], is_error: false, tool_use_id: 'recent', type: 'tool_result' }], - role: 'user', - }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript, correlation)).resolves.toMatchObject({ - eventCounts: { mcp: 1 }, - mcpReadMarkerObserved: true, - mcpReadObserved: true, - sharedHookStateObserved: true, - }); -}); - -test('keeps an exact successful Claude read observed without upgrading unmarked hook state', async () => { - const correlation = { - marker: 'rsc-eval-marker-unmarked', - stateRecords: [{ - event: { host: 'claude', path: '/owned/host-created-rsc-eval-marker-unmarked.txt' }, - kind: 'edit', - }], - }; - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { content: [{ content: 'snapshot without the owned marker', is_error: false, tool_use_id: 'recent', type: 'tool_result' }], role: 'user' }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript, correlation)).resolves.toMatchObject({ - eventCounts: { mcp: 1 }, - mcpReadMarkerObserved: false, - mcpReadObserved: true, - sharedHookStateObserved: false, - }); -}); - -test('does not borrow a duplicate result marker or unrelated state record for shared-hook evidence', async () => { - const marker = 'rsc-eval-marker-borrowed'; - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { - content: [ - { content: 'ordinary response', is_error: false, tool_use_id: 'recent', type: 'tool_result' }, - { content: marker, is_error: false, tool_use_id: 'recent', type: 'tool_result' }, - ], - role: 'user', - }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript, { - marker, - stateRecords: [{ event: { host: 'claude', path: '/owned/unrelated.txt' }, kind: 'edit' }], - })).resolves.toMatchObject({ - eventCounts: { mcp: 0 }, - mcpReadMarkerObserved: false, - mcpReadObserved: false, - sharedHookStateObserved: false, - }); -}); - -test('does not borrow a marker from a different Claude tool-result ID', async () => { - const marker = 'rsc-eval-marker-mixed'; - const transcript = [ - JSON.stringify({ - message: { content: [{ id: 'recent', input: {}, name: 'mcp__rsc-agent-runtime__recent_edits', type: 'tool_use' }], role: 'assistant' }, - type: 'assistant', - }), - JSON.stringify({ - message: { - content: [ - { content: 'ordinary snapshot', is_error: false, tool_use_id: 'recent', type: 'tool_result' }, - { content: marker, is_error: false, tool_use_id: 'foreign', type: 'tool_result' }, - ], - role: 'user', - }, - type: 'user', - }), - ].join('\n'); - - await expect(parseEvidence('claude', transcript, { - marker, - stateRecords: [{ event: { host: 'claude', path: `/owned/${marker}.txt` }, kind: 'edit' }], - })).resolves.toMatchObject({ - eventCounts: { mcp: 1 }, - mcpReadMarkerObserved: false, - mcpReadObserved: true, - sharedHookStateObserved: false, - }); -}); - -test('derives Claude hook evidence only from its value-free launch probe', async () => { - const probe = [ - { - commandLaunched: true, - exitStatus: 0, - toolInputKeys: ['file_path'], - toolInputValueTypes: { file_path: 'string' }, - toolName: 'Write', - topLevelKeys: ['cwd', 'hook_event_name', 'session_id', 'tool_input', 'tool_name'], - topLevelValueTypes: { cwd: 'string', hook_event_name: 'string', session_id: 'string', tool_input: 'object', tool_name: 'string' }, - }, - ]; - - await expect(parseHookProbe(probe)).resolves.toMatchObject({ - commandLaunched: true, - exitStatuses: [0], - hookObserved: true, - launches: 1, - }); -}); - -test('does not treat Codex prompt, tool listings, or non-final agent prose as host evidence', async () => { - const transcript = [ - JSON.stringify({ item: { text: `Call recent_edits, render_edit_timeline, then print ${marker('codex')}.`, type: 'reasoning' }, type: 'item.completed' }), - JSON.stringify({ item: { text: marker('codex'), type: 'agent_message' }, type: 'item.completed' }), - JSON.stringify({ item: { result: 'recent_edits render_edit_timeline', server: 'other', status: 'completed', tool: 'tool_listing', type: 'mcp_tool_call' }, type: 'item.completed' }), - JSON.stringify({ type: 'turn.completed' }), - ].join('\n'); - - await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ - eventCounts: { hook: 0, mcp: 0, rscRender: 0 }, - finalMarkerObserved: false, - mcpReadObserved: false, - rscRenderToolObserved: false, - }); -}); - -test('accepts only completed Codex MCP calls and its terminal agent result', async () => { - const transcript = [ - JSON.stringify({ item: { arguments: {}, server: 'rsc-agent-runtime', status: 'completed', tool: 'recent_edits', type: 'mcp_tool_call' }, type: 'item.completed' }), - JSON.stringify({ item: { arguments: {}, server: 'rsc-agent-runtime', status: 'completed', tool: 'render_edit_timeline', type: 'mcp_tool_call' }, type: 'item.completed' }), - JSON.stringify({ item: { text: marker('codex'), type: 'agent_message' }, type: 'item.completed' }), - JSON.stringify({ type: 'turn.completed' }), - ].join('\n'); - - await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ - eventCounts: { hook: 0, mcp: 1, rscRender: 1 }, - finalMarkerObserved: true, - mcpReadObserved: true, - rscRenderToolObserved: true, - }); -}); - -test('does not count a failed Codex runtime MCP call', async () => { - const transcript = JSON.stringify({ - item: { - is_error: true, - result: { is_error: true }, - server: 'rsc-agent-runtime', - status: 'completed', - tool: 'recent_edits', - type: 'mcp_tool_call', - }, - type: 'item.completed', - }); - - await expect(parseEvidence('codex', transcript)).resolves.toMatchObject({ - eventCounts: { mcp: 0 }, - mcpReadObserved: false, - }); -}); - -test('classifies complete Claude native evidence as literal claim-level observations', async () => { - const capturedAt = '2026-08-14T20:00:00.000Z'; - const completeClaude = { - editObservedByHook: true, - finalMarkerObserved: true, - mcpReadObserved: true, - rscRenderToolObserved: true, - sessionAvailable: true, - sharedHookStateObserved: true, - version: '2.1.232', - }; - - await expect(classifyEvidence('claude', completeClaude, capturedAt)).resolves.toEqual({ - capturedAt, - claims: [ - { basis: 'native terminal marker and loaded plugin session', evidence: 'observed', id: 'package-activation' }, - { basis: 'value-free hook launch probe exited 0', evidence: 'observed', id: 'hook-dispatch' }, - { basis: 'completed recent_edits call with native success result', evidence: 'observed', id: 'mcp-read' }, - { basis: 'completed render_edit_timeline call with native success result', evidence: 'observed', id: 'rsc-render' }, - { basis: 'hook-recorded state was returned by recent_edits', evidence: 'observed', id: 'shared-hook-mcp-state' }, - { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, - ], - host: 'claude', - hostVersion: '2.1.232', - }); -}); - -test('keeps Codex hook claims unavailable under exec ephemeral despite completed MCP calls', async () => { - const capturedAt = '2026-08-14T20:00:00.000Z'; - const incompleteCodex = { - editObservedByHook: true, - finalMarkerObserved: true, - mcpReadObserved: true, - rscRenderToolObserved: true, - sessionAvailable: true, - version: '0.147.0', - }; - - await expect(classifyEvidence('codex', incompleteCodex, capturedAt)).resolves.toEqual({ - capturedAt, - claims: [ - { basis: 'native terminal marker and loaded plugin session', evidence: 'observed', id: 'package-activation' }, - { basis: 'Codex exec --ephemeral does not prove native hook dispatch', evidence: 'unavailable', id: 'hook-dispatch' }, - { basis: 'completed recent_edits call with native success result', evidence: 'observed', id: 'mcp-read' }, - { basis: 'completed render_edit_timeline call with native success result', evidence: 'observed', id: 'rsc-render' }, - { basis: 'Codex exec --ephemeral has no native hook-recorded state correlation', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, - { basis: 'Codex CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, - ], - host: 'codex', - hostVersion: '0.147.0', - }); -}); - -test('keeps unavailable-host claims bounded and removes ambient credentials from child environments', async () => { - const capturedAt = '2026-08-14T20:00:00.000Z'; - const missing = await classifyEvidence('claude', {}, capturedAt); - expect(missing).toEqual({ - capturedAt, - claims: [ - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'package-activation' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'hook-dispatch' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'mcp-read' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'rsc-render' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, - { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, - ], - host: 'claude', - hostVersion: 'unavailable', - }); - expect(JSON.stringify(missing)).not.toMatch(/secret|auth|prompt|transcript|\/private/iu); - - const environment = { - ANTHROPIC_API_KEY: 'anthropic-secret', - ANTHROPIC_AUTH_TOKEN: 'anthropic-auth', - ANTHROPIC_BASE_URL: 'https://private.example', - CLAUDE_CODE_USE_BEDROCK: '1', - CLAUDE_CODE_USE_FOUNDRY: '1', - CLAUDE_CODE_USE_VERTEX: '1', - EXAMPLE_API_KEY: 'example-secret', - LANG: 'en_US.UTF-8', - NODE_OPTIONS: '--require /private/module.cjs', - NODE_PATH: '/private/modules', - OPENAI_API_KEY: 'openai-secret', - PATH: '/safe/bin', - TERM: 'xterm-256color', - openai_api_key: 'case-insensitive-secret', - }; - const before = { ...environment }; - await expect(sanitizeEnvironment(environment, { - codexHome: '/tmp/owned-codex-home', - hookProbeFile: '/tmp/owned-hook-probe.jsonl', - stateFile: '/tmp/owned-state.jsonl', - })).resolves.toEqual({ - AGENT_RUNTIME_HOOK_PROBE_FILE: '/tmp/owned-hook-probe.jsonl', - AGENT_RUNTIME_STATE_FILE: '/tmp/owned-state.jsonl', - CODEX_HOME: '/tmp/owned-codex-home', - LANG: 'en_US.UTF-8', - PATH: '/safe/bin', - TERM: 'xterm-256color', - }); - expect(environment).toEqual(before); -}); - -test('emits one schema-v2 envelope and fails truthfully when the selected native host is unavailable', async () => { - const envelope = await unavailableHostEnvelope(); - - expect(Object.keys(envelope).sort()).toEqual(['capturedAt', 'hosts', 'schemaVersion']); - expect(envelope.schemaVersion).toBe(2); - expect(envelope.capturedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/u); - expect(envelope.hosts).toEqual([ - { - capturedAt: envelope.capturedAt, - claims: [ - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'package-activation' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'hook-dispatch' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'mcp-read' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'rsc-render' }, - { basis: 'installed host/version/session unavailable', evidence: 'unavailable', id: 'shared-hook-mcp-state' }, - { basis: 'Claude Code CLI is not an MCP Apps iframe host', evidence: 'unavailable', id: 'mcp-app-iframe' }, - ], - host: 'claude', - hostVersion: 'unavailable', - }, - ]); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs deleted file mode 100644 index 64c5b3107..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-lock-owner.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { open, rm, writeFile } from 'node:fs/promises'; -import process from 'node:process'; -import { setInterval } from 'node:timers'; - -import lockfile from 'proper-lockfile'; - -const stateFile = process.argv[2]; -if (stateFile === undefined) { - throw new Error('state file argument is required'); -} - -const handle = await open(stateFile, 'a'); -await handle.close(); -const stale = Number(process.argv[3] ?? '2000'); -const update = Number(process.argv[4] ?? '1000'); -const release = await lockfile.lock(stateFile, { - realpath: true, - retries: 0, - stale, - update, -}); -const metadataFile = `${stateFile}.agent-runtime-lock.json`; -await writeFile(metadataFile, JSON.stringify({ stale })); -process.stdout.write('{"ready":true}\n'); - -process.once('SIGTERM', async () => { - await release(); - await rm(metadataFile, { force: true }); - process.exit(0); -}); -setInterval(() => undefined, 1_000); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts deleted file mode 100644 index 139bf8da7..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/fixtures/state-settlement-exit.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createTestFileRuntimeKernel } from '../../src/runtime/state-file-test-support.js'; - -const stateFile = process.argv[2]; -if (stateFile === undefined) throw new Error('state file argument is required'); - -const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: () => new Promise((resolve) => setTimeout(resolve, 50)), - criticalSectionMs: 10, - ownerSettlementMs: 2_000, - }, -}); - -try { - await kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:settlement-exit', - path: 'settlement-exit.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }); - throw new Error('timed-out state mutation unexpectedly succeeded'); -} catch (error) { - if (!(error instanceof Error) || !error.message.includes('exceeded 10 ms')) throw error; - process.stdout.write('phase-settled\n'); -} diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts deleted file mode 100644 index 38237dd71..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/generation-materializer.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import { createHash } from 'node:crypto'; -import { mkdtemp, mkdir, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import { createRsbuild } from '@rsbuild/core'; -import { expect, test } from '@rstest/core'; - -import { - createRscRuntimeRsbuildConfig, - type RscRuntimeCompileSnapshot, -} from '../rsbuild.config.js'; -import { - captureRuntimeGenerationSnapshot, - createRscCompilerAssetCheckpointTracker, - materializeRuntimeGeneration, - rscRuntimeGenerationMetadataCodec, - runtimeDefinitionDigest, - validateRscRuntimeGenerationMetadata, - type RscCompilerAssetCheckpointTracker, - type RscRuntimeCapturedGenerationSnapshot, - type RscRuntimeGenerationMetadata, -} from '../src/dev/generation-materializer.js'; -import { digest, stableJson } from '../../../packages/agent-bundle/src/core/digest.ts'; -import { RuntimeGenerationStore } from '../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; -import type { DevRuntimePreparedProject } from '../../../packages/agent-bundle/src/dev/runtime-provider.ts'; - -const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); - -const definitionJson = '{"nativeHooks":[],"resources":[],"tools":[]}'; - -const runtimeFiles = { - 'chunks/101.js': 'async-chunk', - 'dev/definition.js': `process.stdout.write(${JSON.stringify(`${definitionJson}\n`)});\n`, - 'dev/invoke.js': 'invoke-worker', - 'hook/index.js': 'hook-entry', - 'mcp/http.js': 'http-entry', - 'mcp/stdio.js': 'stdio-entry', - 'rsc/index.js': 'rsc-entry', -} as const; - -const widgetFiles = { - 'rsc/index.html': '', - 'static/js/rsc/index.js': 'client-reference', -} as const; - -const appFiles = { - 'edit-timeline-v1.html': '
Timeline
', - 'edit-timeline-v2.html': '
Timeline v2
', - 'activity-v1.html': '
Activity
', -} as const; - -const writeTree = async (root: string, files: Readonly>): Promise => { - await Promise.all(Object.entries(files).map(async ([path, contents]) => { - const destination = join(root, ...path.split('/')); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, contents, 'utf8'); - })); -}; - -const writeCompilerCohort = async ( - compilerRoot: string, - options: Readonly<{ - readonly appFiles?: Readonly>; - readonly rscFiles?: Readonly>; - readonly widgetFiles?: Readonly>; - }> = {}, -): Promise => { - const rscRoot = join(compilerRoot, 'rsc'); - await writeTree(rscRoot, { ...runtimeFiles, ...options.rscFiles }); - await mkdir(join(compilerRoot, 'app'), { recursive: true }); - await writeTree(join(compilerRoot, 'app'), options.appFiles ?? appFiles); - await writeTree(join(compilerRoot, 'widget'), { ...widgetFiles, ...options.widgetFiles }); - await writeFile(join(rscRoot, 'runtime-assets.json'), JSON.stringify({ - allFiles: Object.keys(runtimeFiles).map((path) => `/${path}`), - entries: { - 'dev/definition': { initial: { js: ['/dev/definition.js'] } }, - 'dev/invoke': { initial: { js: ['/dev/invoke.js'] } }, - 'hook/index': { initial: { js: ['/hook/index.js'] } }, - 'mcp/http': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/http.js'] } }, - 'mcp/stdio': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/stdio.js'] } }, - 'rsc/index': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/rsc/index.js'] } }, - }, - }), 'utf8'); -}; - -const preparedRuntime = Object.freeze({ - apps: Object.freeze([]), - provider: './src/dev/provider.ts', - servers: Object.freeze([]), - sourceRevision: 'prepared-r1', -}); - -const preparedRuntimeWithApp = ( - app: Partial = {}, - runtime: Partial> = {}, -): DevRuntimePreparedProject => Object.freeze({ - apps: Object.freeze([Object.freeze({ - _meta: Object.freeze({ presentation: Object.freeze({ accent: 'indigo', version: 1 }) }), - id: 'timeline-app', - name: 'Timeline', - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - serverId: 'timeline-server', - serverName: 'Timeline MCP', - source: '/workspace/plugin/agent-bundle.config.ts', - targets: Object.freeze(['claude', 'codex']), - template: '/workspace/plugin/src/app/edit-timeline.html', - ...app, - })]), - provider: './src/dev/provider.ts', - servers: Object.freeze([Object.freeze({ - command: 'node', - cwd: '/workspace/plugin', - id: 'timeline-server', - name: 'Timeline MCP', - source: '/workspace/plugin/agent-bundle.config.ts', - targets: Object.freeze(['claude', 'codex']), - transport: 'stdio' as const, - })]), - sourceRevision: 'prepared-r1', - ...runtime, -}); - -const createStore = (storageRoot: string): RuntimeGenerationStore => - new RuntimeGenerationStore({ - metadataCodec: rscRuntimeGenerationMetadataCodec, - now: () => new Date('2026-08-15T00:00:00.000Z'), - storageRoot, - validateMetadata: validateRscRuntimeGenerationMetadata, - }); - -const rewriteGenerationManifest = async ( - root: string, - mutateMetadata: (metadata: Readonly>) => Readonly>, -): Promise => { - const manifestPath = join(root, 'generation.manifest.json'); - const parsed: unknown = JSON.parse(await readFile(manifestPath, 'utf8')); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || - !('metadata' in parsed) || typeof parsed.metadata !== 'object' || parsed.metadata === null || Array.isArray(parsed.metadata)) { - throw new TypeError('Test generation manifest was malformed.'); - } - const { manifestDigest: _manifestDigest, ...withoutDigest } = parsed as Readonly>; - const updated = Object.freeze({ ...withoutDigest, metadata: mutateMetadata(parsed.metadata as Readonly>) }); - await writeFile(manifestPath, stableJson({ ...updated, manifestDigest: digest(updated) }), 'utf8'); -}; - -const acceptCompilerAssetCheckpoint = (snapshot: RscRuntimeCapturedGenerationSnapshot): void => { - expect(snapshot.acceptCompilerAssetCheckpoint).toBeTypeOf('function'); - snapshot.acceptCompilerAssetCheckpoint?.(); -}; - -const captureWithCompilerAssetCheckpoint = async ( - input: Parameters[0], - tracker: RscCompilerAssetCheckpointTracker, -) => captureRuntimeGenerationSnapshot({ - ...input, - compilerAssetCheckpointTracker: tracker, -}); - -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') return false; - throw error; - } -}; - -const activateCompilerObserver = (onCompile: NonNullable[0]['onCompile']>) => { - const config = createRscRuntimeRsbuildConfig({ - compilerRoot: join(tmpdir(), 'rsc-agent-runtime-observer'), - mode: 'development', - onCompile, - }); - const plugin = (config.plugins as readonly unknown[]).find((value): value is Readonly<{ - readonly name: string; - setup(api: unknown): void; - }> => typeof value === 'object' && value !== null && 'name' in value && (value as { name?: unknown }).name === 'agent-bundle:rsc-runtime-compile-observer'); - if (plugin === undefined) throw new Error('Compile observer plugin was not configured.'); - - let before: (() => void) | undefined; - let after: ((input: unknown) => Promise) | undefined; - plugin.setup({ - onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, - onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, - }); - return Object.freeze({ - async compile(children: readonly Readonly<{ readonly hash?: string; readonly name?: string }>[]): Promise { - before?.(); - await after?.({ - stats: { - hasErrors: () => false, - toJson: () => ({ children }), - }, - }); - }, - }); -}; - -const compilerObserver = (input: Readonly<{ - readonly capture: Array>>; - readonly enqueued: string[]; - readonly failed: unknown[]; -}>) => activateCompilerObserver({ - beforeAttempt: () => 'attempt-1', - capture: async (value) => { - input.capture.push(value); - return { - attemptId: value.attemptId, - candidateId: 'candidate-1', - preparedRevision: 'prepared-1', - rscCohortRevision: 1, - sourceRevision: value.sourceRevision, - }; - }, - enqueue: (snapshot) => input.enqueued.push(snapshot.attemptId), - failAttempt: (_attemptId, error) => input.failed.push(error), -}); - -test('resolves the coherent development compiler configuration through Rsbuild', async () => { - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-compiler-')); - try { - const rsbuild = await createRsbuild({ - config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), - cwd: process.cwd(), - }); - const inspection = await rsbuild.inspectConfig({ mode: 'development' }); - const environments = inspection.origin.environmentConfigs; - const bundlers = inspection.origin.bundlerConfigs; - const rscBundler = bundlers.find((config) => config.name === 'rsc'); - const widgetBundler = bundlers.find((config) => config.name === 'widget'); - const appBundler = bundlers.find((config) => config.name === 'app'); - - expect(Object.keys(environments).sort()).toEqual(['app', 'rsc', 'widget']); - expect(environments.rsc?.output.target).toBe('node'); - expect(environments.widget?.output.target).toBe('web'); - expect(environments.rsc?.output.distPath.root).toBe(join(compilerRoot, 'rsc')); - expect(environments.widget?.output.distPath.root).toBe(join(compilerRoot, 'widget')); - expect(environments.app?.output.distPath.root).toBe(join(compilerRoot, 'app')); - expect(inspection.origin.rsbuildConfig.dev.writeToDisk).toBe(true); - expect(inspection.origin.rsbuildConfig.server.host).toBe('127.0.0.1'); - expect(inspection.origin.rsbuildConfig.server.port).toBe(3000); - expect(rscBundler?.output?.chunkFilename).toBe('chunks/[name].js'); - expect(rscBundler?.output?.path).toBe(join(compilerRoot, 'rsc')); - expect(widgetBundler?.output?.path).toBe(join(compilerRoot, 'widget')); - expect(appBundler?.output?.path).toBe(join(compilerRoot, 'app')); - expect(rscBundler?.module?.rules?.some((rule) => - typeof rule === 'object' && rule !== null && 'test' in rule && String(rule.test).includes('request-render'))).toBe(true); - expect(appBundler?.target).toEqual(expect.arrayContaining(['web'])); - expect(appBundler?.plugins?.some((plugin) => plugin?.constructor?.name.includes('ReactRefresh'))).toBe(false); - - const production = await createRsbuild({ - config: createRscRuntimeRsbuildConfig({ mode: 'production' }), - cwd: process.cwd(), - }); - const productionInspection = await production.inspectConfig({ mode: 'production' }); - expect(productionInspection.origin.rsbuildConfig.dev.writeToDisk).not.toBe(true); - expect(productionInspection.origin.environmentConfigs.rsc?.output.distPath.root).toBe('dist/runtime'); - expect(productionInspection.origin.environmentConfigs.widget?.output.distPath.root).toBe('dist/widget'); - expect(productionInspection.origin.environmentConfigs.rsc?.source.entry).not.toHaveProperty('dev/definition'); - expect(productionInspection.origin.environmentConfigs.rsc?.source.entry).not.toHaveProperty('dev/invoke'); - } finally { - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('captures immutable paired compiler outputs and records every digested asset', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const candidate = await store.begin({ id: 'g1', sourceRevision: 'source-r1' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-1', - candidate, - compilerRoot, - preparedRuntime, - rscCohortRevision: 1, - sourceRevision: 'source-r1', - }); - - await writeFile(join(compilerRoot, 'rsc', 'rsc', 'index.js'), 'overwritten-after-capture', 'utf8'); - expect(await readFile(join(candidate.root, 'rsc', 'rsc', 'index.js'), 'utf8')).toBe('rsc-entry'); - - const prepared = await materializeRuntimeGeneration({ snapshot, store }); - const assets = prepared.generation.manifest.assets; - expect(assets).toEqual(expect.arrayContaining([ - { bytes: 10, path: 'rsc/hook/index.js', sha256: '124bca2527b3be927263a58d4fe32fd7dbaeff7988aa596840a72930d754c19e' }, - { bytes: 9, path: 'rsc/rsc/index.js', sha256: '9d51e6aa438ceebcf519fc709042d53177818b9e41161e477e36686acf169a84' }, - { bytes: 16, path: 'widget/static/js/rsc/index.js', sha256: '293818db721cb0d68e14d84f58fe9bc7ad285be34c4dbee827f967891b94015f' }, - ])); - expect(assets.map((asset) => asset.path)).toEqual(expect.arrayContaining([ - 'rsc/runtime-assets.json', - 'rsc/runtime-definition.json', - 'rsc/agent-runtime.manifest.json', - 'rsc/chunks/101.js', - 'widget/rsc/index.html', - 'widget/static/js/rsc/index.js', - ])); - expect(prepared.generation.manifest.metadata.definitionDigest) - .toBe(sha256('{"apps":[],"definition":{"nativeHooks":[],"resources":[],"tools":[]}}')); - expect(prepared.generation.manifest.metadata.environmentHashes).toEqual(expect.objectContaining({ - rsc: expect.stringMatching(/^[a-f0-9]{64}$/u), - widget: expect.stringMatching(/^[a-f0-9]{64}$/u), - })); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('includes prepared App definitions in the captured runtime definition digest', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-definition-digest-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const metadataFor = async ( - id: string, - prepared: DevRuntimePreparedProject, - sourceRevision = 'captured-r1', - ) => { - const candidate = await store.begin({ id, sourceRevision }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: `attempt-${id}`, - candidate, - compilerRoot, - preparedRuntime: prepared, - rscCohortRevision: 1, - sourceRevision, - }); - const generation = await materializeRuntimeGeneration({ snapshot, store }); - return Object.freeze({ generation: generation.generation, metadata: generation.generation.manifest.metadata, snapshot }); - }; - - const baseline = await metadataFor('baseline', preparedRuntimeWithApp()); - const appDefinitionVariants: readonly Readonly<{ readonly id: string; readonly prepared: DevRuntimePreparedProject }>[] = [ - { id: 'meta', prepared: preparedRuntimeWithApp({ _meta: Object.freeze({ presentation: Object.freeze({ accent: 'teal', version: 2 }) }) }) }, - { id: 'id', prepared: preparedRuntimeWithApp({ id: 'timeline-app-v2' }) }, - { id: 'name', prepared: preparedRuntimeWithApp({ name: 'Timeline v2' }) }, - { id: 'server-id', prepared: preparedRuntimeWithApp({ serverId: 'timeline-server-v2' }) }, - { id: 'server-name', prepared: preparedRuntimeWithApp({ serverName: 'Timeline MCP v2' }) }, - { id: 'resource-uri', prepared: preparedRuntimeWithApp({ resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v2.html' }) }, - { id: 'targets', prepared: preparedRuntimeWithApp({ targets: Object.freeze(['codex']) }) }, - ]; - - for (const variant of appDefinitionVariants) { - const captured = await metadataFor(variant.id, variant.prepared); - expect(captured.metadata.definitionDigest).not.toBe(baseline.metadata.definitionDigest); - expect(captured.metadata.servers.map((server) => server.definitionDigest)).toEqual([ - captured.metadata.definitionDigest, - captured.metadata.definitionDigest, - ]); - } - - const sourceAndTransportNoise = await metadataFor('noise', preparedRuntimeWithApp({ - source: '/other-machine/plugin/agent-bundle.config.ts', - template: '/other-machine/plugin/src/app/edit-timeline.html', - }, { - provider: '/other-machine/plugin/src/dev/provider.ts', - servers: Object.freeze([Object.freeze({ - args: Object.freeze(['--serve', '--token=top-secret']), - command: '/other-machine/bin/timeline-server', - cwd: '/other-machine/plugin', - env: Object.freeze({ API_TOKEN: 'top-secret' }), - headers: Object.freeze({ Authorization: 'Bearer top-secret' }), - id: 'timeline-server', - name: 'Timeline MCP', - source: '/other-machine/plugin/agent-bundle.config.ts', - targets: Object.freeze(['claude', 'codex']), - transport: 'streamable-http' as const, - url: 'https://other-machine.invalid/mcp', - })]), - sourceRevision: 'prepared-r2', - }), 'captured-r2'); - expect(sourceAndTransportNoise.metadata.definitionDigest).toBe(baseline.metadata.definitionDigest); - - expect(runtimeDefinitionDigest(baseline.snapshot.definition, baseline.snapshot.preparedRuntime)) - .toBe(baseline.metadata.definitionDigest); - - const [timelineApp] = baseline.snapshot.preparedRuntime.apps; - if (timelineApp === undefined) throw new Error('Baseline prepared App was not captured.'); - const activityApp = Object.freeze({ - ...timelineApp, - id: 'activity-app', - name: 'Activity', - resourceUri: 'ui://rsc-agent-runtime/activity-v1.html', - }); - const orderedForward = await metadataFor('ordered-forward', Object.freeze({ - ...baseline.snapshot.preparedRuntime, - apps: Object.freeze([timelineApp, activityApp]), - })); - const orderedReverse = await metadataFor('ordered-reverse', Object.freeze({ - ...baseline.snapshot.preparedRuntime, - apps: Object.freeze([activityApp, timelineApp]), - })); - expect(orderedReverse.metadata.definitionDigest).toBe(orderedForward.metadata.definitionDigest); - expect(orderedForward.metadata.appDefinitions.map((app) => app.id)).toEqual(['activity-app', 'timeline-app']); - expect(orderedForward.metadata.appDefinitions.every((app) => !('template' in app))).toBe(true); - const [firstAppDefinition] = orderedForward.metadata.appDefinitions; - if (firstAppDefinition === undefined || firstAppDefinition._meta === undefined) throw new Error('Ordered App definition was malformed.'); - expect(Object.isFrozen(orderedForward.metadata.appDefinitions)).toBe(true); - expect(Object.isFrozen(firstAppDefinition)).toBe(true); - expect(Object.isFrozen(firstAppDefinition.targets)).toBe(true); - expect(Object.isFrozen(firstAppDefinition._meta)).toBe(true); - expect(Object.isFrozen(firstAppDefinition._meta.presentation)).toBe(true); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('captures the canonical generated HTML asset for each prepared App surface', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - const html = '
Timeline
'; - try { - await writeCompilerCohort(compilerRoot, { appFiles: { 'edit-timeline-v1.html': html } }); - const candidate = await store.begin({ id: 'app-html', sourceRevision: 'source-app-html' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-html', - candidate, - compilerRoot, - preparedRuntime: preparedRuntimeWithApp(), - rscCohortRevision: 1, - sourceRevision: 'source-app-html', - }); - const prepared = await materializeRuntimeGeneration({ snapshot, store }); - - expect(prepared.generation.manifest.metadata.surfaceAssets['mcp.Timeline']).toEqual(expect.arrayContaining([{ - bytes: Buffer.byteLength(html), - contentType: 'text/html', - generationPath: 'app/edit-timeline-v1.html', - requestPath: '/edit-timeline-v1.html', - sha256: sha256(html), - }])); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a traversal-normalized App URI even when a matching generated HTML file exists', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-traversal-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot, { appFiles: { 'escaped.html': '
Escaped
' } }); - const candidate = await store.begin({ id: 'app-html-traversal', sourceRevision: 'source-app-html-traversal' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-html-traversal', - candidate, - compilerRoot, - preparedRuntime: preparedRuntimeWithApp({ resourceUri: 'ui://rsc-agent-runtime/../escaped.html' }), - rscCohortRevision: 1, - sourceRevision: 'source-app-html-traversal', - }); - await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('resource URI is invalid'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects missing, duplicate, and symbolic-link App HTML capture inputs', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-app-html-invalid-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot, { appFiles: {} }); - const missingCandidate = await store.begin({ id: 'app-html-missing', sourceRevision: 'source-app-html-missing' }); - const missingSnapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-html-missing', candidate: missingCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 1, sourceRevision: 'source-app-html-missing', - }); - await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('no unique captured HTML asset'); - - await writeCompilerCohort(compilerRoot); - const [timelineApp] = preparedRuntimeWithApp().apps; - if (timelineApp === undefined) throw new Error('Timeline App fixture was unavailable.'); - const duplicateCandidate = await store.begin({ id: 'app-html-duplicate', sourceRevision: 'source-app-html-duplicate' }); - const duplicateSnapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-html-duplicate', - candidate: duplicateCandidate, - compilerRoot, - preparedRuntime: Object.freeze({ - ...preparedRuntimeWithApp(), - apps: Object.freeze([timelineApp, Object.freeze({ ...timelineApp, id: 'timeline-app-duplicate' })]), - }), - rscCohortRevision: 2, - sourceRevision: 'source-app-html-duplicate', - }); - await expect(materializeRuntimeGeneration({ snapshot: duplicateSnapshot, store })).rejects.toThrow('duplicate App surface'); - - await symlink(join(compilerRoot, 'app', 'edit-timeline-v1.html'), join(compilerRoot, 'app', 'linked.html')); - const linkedCandidate = await store.begin({ id: 'app-html-link', sourceRevision: 'source-app-html-link' }); - await expect(captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-html-link', candidate: linkedCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-html-link', - })).rejects.toThrow('symbolic links'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a rewritten prepared App definition manifest on post-rename reload', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-persisted-app-definition-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const candidate = await store.begin({ id: 'persisted-app', sourceRevision: 'source-persisted-app' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-persisted-app', - candidate, - compilerRoot, - preparedRuntime: preparedRuntimeWithApp(), - rscCohortRevision: 1, - sourceRevision: 'source-persisted-app', - }); - let waits = 0; - await expect(materializeRuntimeGeneration({ - guard: { - check: () => true, - wait: async () => { - waits += 1; - if (waits !== 1) return; - await rewriteGenerationManifest(snapshot.candidate.root, (metadata) => ({ - ...metadata, - appDefinitions: [{ - ...(metadata.appDefinitions as readonly Readonly>[])[0], - name: 'Tampered timeline', - }], - })); - }, - }, - snapshot, - store, - })).rejects.toMatchObject({ code: 'RUNTIME_GENERATION_INVALID' }); - expect(waits).toBe(1); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a persisted App surface manifest without its declared canonical HTML asset', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-persisted-app-surface-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const candidate = await store.begin({ id: 'persisted-app-surface', sourceRevision: 'source-persisted-app-surface' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-persisted-app-surface', - candidate, - compilerRoot, - preparedRuntime: preparedRuntimeWithApp(), - rscCohortRevision: 1, - sourceRevision: 'source-persisted-app-surface', - }); - let waits = 0; - await expect(materializeRuntimeGeneration({ - guard: { - check: () => true, - wait: async () => { - waits += 1; - if (waits !== 1) return; - await rewriteGenerationManifest(snapshot.candidate.root, (metadata) => ({ - ...metadata, - surfaceAssets: Object.fromEntries(Object.entries(metadata.surfaceAssets as Readonly>[]>>) - .map(([surfaceId, assets]) => [surfaceId, assets.filter((asset) => asset.contentType !== 'text/html')])), - })); - }, - }, - snapshot, - store, - })).rejects.toMatchObject({ code: 'RUNTIME_GENERATION_INVALID' }); - expect(waits).toBe(1); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a removed or replaced paired compiler asset after capture', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const missingCandidate = await store.begin({ id: 'missing', sourceRevision: 'source-missing' }); - const missingSnapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-missing', candidate: missingCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing', - }); - await unlink(join(missingCandidate.root, 'widget', 'rsc', 'index.html')); - await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('captured cohort'); - - const replacedCandidate = await store.begin({ id: 'replaced', sourceRevision: 'source-replaced' }); - const replacedSnapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-replaced', candidate: replacedCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-replaced', - }); - await writeFile(join(replacedCandidate.root, 'widget', 'static', 'js', 'rsc', 'index.js'), 'replaced-client-reference', 'utf8'); - await expect(materializeRuntimeGeneration({ snapshot: replacedSnapshot, store })).rejects.toThrow('captured cohort'); - - const appCandidate = await store.begin({ id: 'app-replaced', sourceRevision: 'source-app-replaced' }); - const appSnapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-app-replaced', candidate: appCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-replaced', - }); - await writeFile(join(appCandidate.root, 'app', 'edit-timeline-v1.html'), 'replaced-App-HTML', 'utf8'); - await expect(materializeRuntimeGeneration({ snapshot: appSnapshot, store })).rejects.toThrow('captured cohort'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('bounds and redacts a definition executable stderr flood', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot, { - rscFiles: { - 'dev/definition.js': "process.stderr.write('token=supersecret ' + 'x'.repeat(1024 * 1024)); process.exitCode = 1;\n", - }, - }); - const candidate = await store.begin({ id: 'stderr', sourceRevision: 'source-stderr' }); - const error = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-stderr', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-stderr', - }).then( - () => new Error('Definition stderr flood unexpectedly captured.'), - (error: unknown) => error, - ); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('stderr'); - expect((error as Error).message).not.toContain('supersecret'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('waits for grace-to-SIGKILL termination of a SIGTERM-ignoring definition child', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const marker = join(storageRoot, 'definition-child.pid'); - const store = createStore(storageRoot); - let childPid: number | undefined; - try { - await writeCompilerCohort(compilerRoot, { - rscFiles: { - 'dev/definition.js': `require('node:fs').writeFileSync(${JSON.stringify(marker)}, String(process.pid)); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);\n`, - }, - }); - const candidate = await store.begin({ id: 'ignores-term', sourceRevision: 'source-ignores-term' }); - await expect(captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-ignores-term', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-ignores-term', - })).rejects.toThrow('exceeded 5 seconds'); - childPid = Number(await readFile(marker, 'utf8')); - expect(Number.isSafeInteger(childPid)).toBe(true); - expect(isProcessAlive(childPid)).toBe(false); - } finally { - if (childPid !== undefined && isProcessAlive(childPid)) process.kill(childPid, 'SIGKILL'); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}, 10_000); - -test('fails compile attempts unless stats contain one nonempty RSC and widget hash', async () => { - for (const children of [ - [{ name: 'rsc', hash: 'rsc-hash' }], - [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'rsc', hash: 'second-rsc-hash' }, { name: 'widget', hash: 'widget-hash' }], - [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget' }], - ]) { - const capture: Array>> = []; - const enqueued: string[] = []; - const failed: unknown[] = []; - await compilerObserver({ capture, enqueued, failed }).compile(children); - expect(capture).toEqual([]); - expect(enqueued).toEqual([]); - expect(failed).toHaveLength(1); - } -}); - -test('accepts a compiler checkpoint only after enqueue and discards it after an enqueue failure', async () => { - const lifecycle: string[] = []; - const captures: Array> = []; - const failed: unknown[] = []; - const snapshots = [ - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-a'), - attemptId: 'a', candidateId: 'a', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-a'), preparedRevision: 'prepared-a', rscCohortRevision: 1, sourceRevision: 'a', - }), - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b'), - attemptId: 'b', candidateId: 'b', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b'), preparedRevision: 'prepared-b', rscCohortRevision: 2, sourceRevision: 'b', - }), - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b-retry'), - attemptId: 'b-retry', candidateId: 'b-retry', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b-retry'), preparedRevision: 'prepared-b', rscCohortRevision: 3, sourceRevision: 'b', - }), - ] as const satisfies readonly RscRuntimeCompileSnapshot[]; - let index = 0; - let enqueueCount = 0; - const observer = activateCompilerObserver({ - beforeAttempt: () => `attempt-${String(index)}`, - capture: async (input) => { - captures.push({ cohortChanged: input.cohortChanged }); - const snapshot = snapshots[index]; - index += 1; - return snapshot; - }, - enqueue: (snapshot) => { - lifecycle.push(`enqueue-${snapshot.attemptId}`); - enqueueCount += 1; - if (enqueueCount === 2) throw new Error('enqueue failed'); - }, - failAttempt: (_attemptId, error) => failed.push(error), - }); - - await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - - expect(lifecycle).toEqual([ - 'enqueue-a', 'accept-a', - 'enqueue-b', 'discard-b', - 'enqueue-b-retry', 'accept-b-retry', - ]); - expect(captures).toEqual([ - { cohortChanged: true }, - { cohortChanged: true }, - { cohortChanged: true }, - ]); - expect(failed).toHaveLength(1); -}); - -test('requires every executable entry to declare its async cohort assets', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot); - const manifestPath = join(compilerRoot, 'rsc', 'runtime-assets.json'); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { entries: Record }; - delete manifest.entries['mcp/http']?.async; - await writeFile(manifestPath, JSON.stringify(manifest), 'utf8'); - const candidate = await store.begin({ id: 'missing-async', sourceRevision: 'source-missing-async' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-missing-async', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing-async', - }); - await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('async'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a genuinely undeclared RSC file outside the known compiler cohort', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot, { rscFiles: { 'undeclared.js': 'not-in-runtime-assets' } }); - const candidate = await store.begin({ id: 'undeclared', sourceRevision: 'source-undeclared' }); - await expect(captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-undeclared', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-undeclared', - })).rejects.toThrow('undeclared'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('reconciles a stale known async chunk from a prior incremental compiler cohort', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); - try { - await writeCompilerCohort(compilerRoot); - const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, tracker); - const firstPrepared = await materializeRuntimeGeneration({ snapshot: firstSnapshot, store }); - await store.abort(firstPrepared); - acceptCompilerAssetCheckpoint(firstSnapshot); - - const rscRoot = join(compilerRoot, 'rsc'); - await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - const manifestPath = join(rscRoot, 'runtime-assets.json'); - const manifest = await readFile(manifestPath, 'utf8'); - await writeFile(manifestPath, manifest.replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - expect(await readFile(join(rscRoot, 'chunks', '101.js'), 'utf8')).toBe('async-chunk'); - - const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); - const snapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', - }, tracker); - const prepared = await materializeRuntimeGeneration({ snapshot, store }); - - expect(prepared.generation.manifest.assets.map((asset) => asset.path)).toEqual(expect.arrayContaining([ - 'rsc/chunks/202.js', - ])); - expect(prepared.generation.manifest.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); - expect(await readFile(join(prepared.generation.root, 'rsc', 'chunks', '202.js'), 'utf8')).toBe('replacement-async-chunk'); - await expect(readFile(join(prepared.generation.root, 'rsc', 'chunks', '101.js'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); - } finally { - tracker.close(); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('retries a stale known compiler chunk after enqueue discards the prior capture checkpoint', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); - const snapshots: RscRuntimeCapturedGenerationSnapshot[] = []; - const failed: unknown[] = []; - let candidateNumber = 0; - let enqueueNumber = 0; - try { - await writeCompilerCohort(compilerRoot); - const observer = activateCompilerObserver({ - beforeAttempt: () => `attempt-${String(candidateNumber)}`, - capture: async (input) => { - candidateNumber += 1; - const candidate = await store.begin({ id: `candidate-${String(candidateNumber)}`, sourceRevision: input.sourceRevision }); - const snapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: input.attemptId, - candidate, - compilerRoot, - preparedRuntime, - rscCohortRevision: candidateNumber, - sourceRevision: input.sourceRevision, - }, tracker); - snapshots.push(snapshot); - return Object.freeze({ - ...snapshot, - candidateId: candidate.id, - preparedRevision: snapshot.preparedRuntime.sourceRevision, - }); - }, - enqueue: () => { - enqueueNumber += 1; - if (enqueueNumber === 2) throw new Error('enqueue rejects B'); - }, - failAttempt: (_attemptId, error) => failed.push(error), - }); - - await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); - const rscRoot = join(compilerRoot, 'rsc'); - await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - const manifestPath = join(rscRoot, 'runtime-assets.json'); - await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - - expect(failed).toHaveLength(1); - expect(snapshots).toHaveLength(3); - for (const snapshot of snapshots.slice(1)) { - expect(snapshot.assets.map((asset) => asset.path)).toContain('rsc/chunks/202.js'); - expect(snapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); - } - } finally { - tracker.close(); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('isolates roots between tracker sessions and revokes checkpoint provenance on close', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const otherCompilerRoot = join(storageRoot, 'other-compiler'); - const store = createStore(storageRoot); - const firstTracker = createRscCompilerAssetCheckpointTracker(); - let secondTracker: RscCompilerAssetCheckpointTracker | undefined; - try { - await writeCompilerCohort(compilerRoot); - const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, firstTracker); - acceptCompilerAssetCheckpoint(firstSnapshot); - - const rscRoot = join(compilerRoot, 'rsc'); - await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - const manifestPath = join(rscRoot, 'runtime-assets.json'); - await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - firstTracker.close(); - - secondTracker = createRscCompilerAssetCheckpointTracker(); - const reusedRootCandidate = await store.begin({ id: 'reused-root', sourceRevision: 'source-reused-root' }); - await expect(captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-reused-root', candidate: reusedRootCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-reused-root', - }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); - - await writeCompilerCohort(otherCompilerRoot); - const otherRootManifestPath = join(otherCompilerRoot, 'rsc', 'runtime-assets.json'); - await writeFile(join(otherCompilerRoot, 'rsc', 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - await writeFile(otherRootManifestPath, (await readFile(otherRootManifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - const otherRootCandidate = await store.begin({ id: 'other-root', sourceRevision: 'source-other-root' }); - await expect(captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-other-root', candidate: otherRootCandidate, compilerRoot: otherCompilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-other-root', - }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); - } finally { - secondTracker?.close(); - firstTracker.close(); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('serializes concurrent same-root captures and commits checkpoints in capture order', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); - try { - await writeCompilerCohort(compilerRoot); - const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, tracker); - acceptCompilerAssetCheckpoint(firstSnapshot); - - const rscRoot = join(compilerRoot, 'rsc'); - await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - const manifestPath = join(rscRoot, 'runtime-assets.json'); - await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); - const thirdCandidate = await store.begin({ id: 'third', sourceRevision: 'source-third' }); - const secondSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', - }, tracker); - let thirdSettled = false; - const thirdSnapshotPromise = captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-third', candidate: thirdCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-third', - }, tracker).then((snapshot) => { - thirdSettled = true; - return snapshot; - }); - await new Promise((resolveMicrotask) => queueMicrotask(resolveMicrotask)); - expect(thirdSettled).toBe(false); - - acceptCompilerAssetCheckpoint(secondSnapshot); - const thirdSnapshot = await thirdSnapshotPromise; - expect(thirdSnapshot.assets.map((asset) => asset.path)).toContain('rsc/chunks/202.js'); - expect(thirdSnapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); - acceptCompilerAssetCheckpoint(thirdSnapshot); - } finally { - tracker.close(); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('rejects a client entry document that points at a different client-reference asset', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - try { - await writeCompilerCohort(compilerRoot, { - widgetFiles: { 'rsc/index.html': '' }, - }); - const candidate = await store.begin({ id: 'mismatched-client', sourceRevision: 'source-mismatched-client' }); - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: 'attempt-mismatched-client', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-mismatched-client', - }); - await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('client reference relationship'); - } finally { - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts deleted file mode 100644 index 57e18af88..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; -import { once } from 'node:events'; -import { tmpdir } from 'node:os'; -import { dirname, join, normalize } from 'node:path'; -import type { Readable } from 'node:stream'; - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { expect, test } from '@rstest/core'; - -const exampleRoot = process.cwd(); -const pluginsRoot = join(exampleRoot, 'dist/plugins'); - -const runPackageHosts = async (): Promise => { - const child = spawn(process.execPath, ['scripts/package-hosts.mjs'], { cwd: exampleRoot, stdio: 'pipe' }); - const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; - expect(signal).toBeNull(); - expect(exitCode).toBe(0); -}; - -const runProductionBuild = async (): Promise => { - await rm(join(exampleRoot, 'dist/app'), { force: true, recursive: true }); - const child = spawn('npm', ['run', 'build'], { cwd: exampleRoot, stdio: 'pipe' }); - const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; - expect(signal).toBeNull(); - expect(exitCode).toBe(0); -}; - -const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')) as T; - -const runtimeAssets = async (): Promise => { - const manifest = await readJson<{ allFiles: string[] }>(join(exampleRoot, 'dist/runtime/runtime-assets.json')); - return manifest.allFiles.map((asset) => asset.replace(/^\//, '')); -}; - -const runDeclaredHook = async ( - command: string, - environment: Readonly>, - input: Readonly>, -): Promise> => { - const child = spawn('/bin/sh', ['-c', command], { - env: { ...process.env, ...environment }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - child.stdin.end(JSON.stringify(input)); - const collect = (stream: Readable): Promise => new Promise((resolve, reject) => { - let text = ''; - stream.setEncoding('utf8'); - stream.on('data', (chunk: string) => { text += chunk; }); - stream.once('error', reject); - stream.once('end', () => resolve(text)); - }); - const [stdout, stderr, outcome] = await Promise.all([ - collect(child.stdout), - collect(child.stderr), - once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>, - ]); - return Object.freeze({ exitCode: outcome[0], signal: outcome[1], stderr, stdout }); -}; - -type ArtifactDigestEntry = Readonly<{ - readonly bytes: number; - readonly path: string; - readonly sha256: string; -}>; - -const artifactDigest = async (root: string): Promise => { - const entries = (await readdir(root, { recursive: true })) - .filter((entry): entry is string => typeof entry === 'string') - .sort(); - const digest: ArtifactDigestEntry[] = []; - for (const path of entries) { - const absolutePath = join(root, path); - if (!(await stat(absolutePath)).isFile()) continue; - const content = await readFile(absolutePath); - digest.push({ - bytes: content.byteLength, - path, - sha256: createHash('sha256').update(content).digest('hex'), - }); - } - return digest; -}; - -test('materializes self-contained Claude and Codex native plugin artifacts', async () => { - await runPackageHosts(); - const claudeRoot = join(pluginsRoot, 'claude'); - const codexRoot = join(pluginsRoot, 'codex'); - const claudeManifest = await readJson<{ name: string; version: string }>(join(claudeRoot, '.claude-plugin/plugin.json')); - const codexManifest = await readJson<{ - interface: unknown; - mcpServers: string; - hooks: string; - name: string; - skills: string; - version: string; - }>(join(codexRoot, '.codex-plugin/plugin.json')); - const claudeMcp = await readJson<{ mcpServers: Record }>(join(claudeRoot, '.mcp.json')); - const codexMcp = await readJson<{ mcpServers: Record }>(join(codexRoot, '.mcp.json')); - const claudeHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(claudeRoot, 'hooks/hooks.json'), - ); - const codexHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(codexRoot, 'hooks/hooks.json'), - ); - - expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime', version: '0.1.0' }); - expect(codexManifest).toMatchObject({ - hooks: './hooks/hooks.json', - interface: expect.any(Object), - mcpServers: './.mcp.json', - name: 'rsc-agent-runtime', - skills: './skills/', - version: '0.1.0', - }); - expect(claudeMcp.mcpServers['rsc-agent-runtime'].args).toContain('${CLAUDE_PLUGIN_ROOT}/runtime/mcp/stdio.js'); - expect(codexMcp.mcpServers['rsc-agent-runtime']).toMatchObject({ args: ['./runtime/mcp/stdio.js'], cwd: './' }); - expect(JSON.stringify(codexMcp)).not.toMatch(/PLUGIN_ROOT|PLUGIN_DATA|workspace/i); - expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'Write|Edit' }); - expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${CLAUDE_PLUGIN_ROOT}'); - expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host claude'); - expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: 'apply_patch' }); - expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${PLUGIN_ROOT}'); - expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('--host codex'); - expect(JSON.stringify({ claudeMcp, claudeHooks, codexMcp, codexHooks })).not.toMatch(/api[ _-]?key/i); - - const runtimeRoot = join(exampleRoot, 'dist/runtime'); - const runtimeDigest = await artifactDigest(runtimeRoot); - expect(await artifactDigest(join(claudeRoot, 'runtime'))).toEqual(runtimeDigest); - expect(await artifactDigest(join(codexRoot, 'runtime'))).toEqual(runtimeDigest); - - const assets = await runtimeAssets(); - expect(assets.some((asset) => /^chunks\/.+\.js$/u.test(asset))).toBe(true); - for (const root of [claudeRoot, codexRoot]) { - for (const asset of assets) { - await access(join(root, 'runtime', asset)); - } - const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); - expect(asyncChunk).toBeDefined(); - expect((await stat(join(root, 'runtime', asyncChunk!))).isFile()).toBe(true); - } - for (const relative of ['dist/app/edit-timeline-v1.html', 'dist/app/standalone.html']) { - const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); - expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); - } - for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json', 'skills']) { - await access(join(codexRoot, relative)); - } -}); - -test('keeps fresh production App legal payload names stable and package-identical', async () => { - await runProductionBuild(); - const appDigest = await artifactDigest(join(exampleRoot, 'dist/app')); - expect(appDigest.map((entry) => entry.path)).toEqual([ - 'edit-timeline-v1.html', - 'lib-react.js.LICENSE.txt', - 'standalone.html', - ]); - const legalNotice = appDigest.find((entry) => entry.path === 'lib-react.js.LICENSE.txt'); - expect(legalNotice).toMatchObject({ path: 'lib-react.js.LICENSE.txt' }); - const legalNoticeContent = await readFile(join(exampleRoot, 'dist/app/lib-react.js.LICENSE.txt'), 'utf8'); - expect(legalNoticeContent).toContain('LICENSE file'); - - for (const entry of appDigest) { - expect(entry.path).not.toMatch(/(?:^|\/)[^/]*\.[a-f\d]{8,}\.(?:js|css)(?:\.LICENSE\.txt)?$/iu); - } - for (const appRoot of [join(exampleRoot, 'dist/app'), ...['claude', 'codex'].map((host) => join(pluginsRoot, host, 'app'))]) { - const payload = await artifactDigest(appRoot); - expect(payload).toEqual(appDigest); - let legalReferences = 0; - for (const artifact of payload.filter((entry) => /\.(?:css|html|js)$/iu.test(entry.path))) { - const source = await readFile(join(appRoot, artifact.path), 'utf8'); - for (const match of source.matchAll(/\/\*!\s*LICENSE:\s*([^*\r\n]+?)\s*\*\//gu)) { - legalReferences += 1; - const target = normalize(join(dirname(artifact.path), match[1]!.trim())); - expect(target).not.toMatch(/^(?:\.\.\/|\/)/u); - expect(payload.some((entry) => entry.path === target)).toBe(true); - expect(await readFile(join(appRoot, target), 'utf8')).toBe(legalNoticeContent); - } - if (artifact.path.endsWith('.html')) { - expect(source).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); - } - } - expect(legalReferences).toBeGreaterThan(0); - } -}); - -test('runs the packaged MCP server after its artifact is isolated from the example dist directory', async () => { - await runPackageHosts(); - const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-isolated-')); - const pluginRoot = join(temporaryRoot, 'claude'); - const stateFile = join(temporaryRoot, 'events.jsonl'); - await cp(join(pluginsRoot, 'claude'), pluginRoot, { recursive: true }); - await writeFile(stateFile, '', 'utf8'); - - const client = new Client({ name: 'host-artifact-test', version: '1.0.0' }); - const transport = new StdioClientTransport({ - args: [join(pluginRoot, 'runtime/mcp/stdio.js')], - command: process.execPath, - env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, - stderr: 'pipe', - }); - - try { - await client.connect(transport); - await expect(client.callTool({ arguments: {}, name: 'render_edit_timeline' })).resolves.toMatchObject({ - content: [{ type: 'text' }], - structuredContent: { edits: [], stateVersion: 0 }, - }); - } finally { - await client.close(); - await rm(temporaryRoot, { force: true, recursive: true }); - } -}); - -test('runs each packaged native hook from one shell argv path when its plugin root contains spaces and metacharacters', async () => { - await runPackageHosts(); - const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-hook-root-')); - try { - const nodeBin = join(temporaryRoot, 'bin'); - const argvFile = join(temporaryRoot, 'hook-argv.bin'); - await mkdir(nodeBin); - await writeFile(join(nodeBin, 'node'), '#!/bin/sh\nprintf \'%s\\0\' "$@" > "$AGENT_RUNTIME_HOOK_ARGV_FILE"\nexec "$AGENT_RUNTIME_NODE" "$@"\n', 'utf8'); - await chmod(join(nodeBin, 'node'), 0o755); - - for (const host of ['claude', 'codex'] as const) { - const pluginRoot = join(temporaryRoot, `${host} plugin root ; ordinary`); - const workspace = join(temporaryRoot, `${host}-workspace`); - const stateFile = join(temporaryRoot, `${host}-events.jsonl`); - const manifestPath = join(pluginRoot, 'hooks/hooks.json'); - const rootVariable = host === 'claude' ? 'CLAUDE_PLUGIN_ROOT' : 'PLUGIN_ROOT'; - const filename = `${host}-note.txt`; - await cp(join(pluginsRoot, host), pluginRoot, { recursive: true }); - await mkdir(workspace); - const manifest = await readJson<{ hooks: { PostToolUse: Array<{ hooks: Array<{ command: string }> }> } }>(manifestPath); - const command = manifest.hooks.PostToolUse[0]?.hooks[0]?.command; - expect(command).toBeTypeOf('string'); - const input = host === 'claude' - ? { - cwd: workspace, - hook_event_name: 'PostToolUse', - session_id: `${host}-session`, - tool_input: { file_path: join(workspace, filename) }, - tool_name: 'Write', - tool_use_id: `${host}-tool`, - } - : { - cwd: workspace, - event_id: `${host}-event`, - hook_event_name: 'PostToolUse', - session_id: `${host}-session`, - tool_input: { command: `*** Begin Patch\n*** Add File: ${filename}\n+recorded\n*** End Patch` }, - tool_name: 'apply_patch', - }; - const result = await runDeclaredHook(command!, { - [rootVariable]: pluginRoot, - AGENT_RUNTIME_HOOK_ARGV_FILE: argvFile, - AGENT_RUNTIME_NODE: process.execPath, - AGENT_RUNTIME_STATE_FILE: stateFile, - PATH: `${nodeBin}:${process.env.PATH ?? ''}`, - }, input); - - expect(result.signal).toBeNull(); - expect(result.exitCode, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - hookSpecificOutput: { - additionalContext: `Recorded ${filename} from ${host}. Shared state now contains 1 edit.`, - hookEventName: 'PostToolUse', - }, - }); - expect((await readFile(argvFile)).toString('utf8').split('\0').filter(Boolean)).toEqual([ - join(pluginRoot, 'runtime/hook/index.js'), '--host', host, - ]); - expect((await readFile(stateFile, 'utf8')).trim()).toContain(`"host":"${host}"`); - expect(command).toBe(`node "\${${rootVariable}}/runtime/hook/index.js" --host ${host}`); - expect(command).not.toMatch(/(?:api[ _-]?key|echo|printenv|AGENT_RUNTIME_)/iu); - } - } finally { - await rm(temporaryRoot, { force: true, recursive: true }); - } -}); - -test('keeps the published Agent Bundle package free of the supplemental RSC runtime', async () => { - const packageRoot = join(exampleRoot, '../../packages/agent-bundle'); - const packageJson = await readJson<{ dependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record }>( - join(packageRoot, 'package.json'), - ); - const allDependencies = { - ...packageJson.dependencies, - ...packageJson.optionalDependencies, - ...packageJson.peerDependencies, - }; - - expect(allDependencies).not.toHaveProperty('react'); - expect(allDependencies).not.toHaveProperty('react-server-dom-rspack'); - expect(allDependencies).not.toHaveProperty('rsbuild-plugin-rsc'); - - const sourceRoot = join(packageRoot, 'src'); - const sourceFiles = await readdir(sourceRoot, { recursive: true }); - for (const relative of sourceFiles) { - if (typeof relative !== 'string' || !relative.endsWith('.ts')) continue; - const source = await readFile(join(sourceRoot, relative), 'utf8'); - expect(source).not.toMatch(/examples\/rsc-agent-runtime|react-server-dom-rspack|rsbuild-plugin-rsc/); - } -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx deleted file mode 100644 index 8a11e954f..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/host-extensions.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { expect, test } from '@rstest/core'; -import React from 'react'; - -import { runtimeDefinition } from '../src/definition.js'; -import { - claudeStableAppDomain, - mergeSerializableMetadata, - resourceMetadata, -} from '../src/mcp/host-metadata.js'; -import { - createWidgetStateAdapter, - safeAreaCustomProperties, -} from '../src/widget/host-adapters.js'; - -test('keeps the MCP Apps widget portable when no vendor capability exists', () => { - const adapter = createWidgetStateAdapter(undefined); - const metadata = resourceMetadata(runtimeDefinition.resources[0]); - - expect(adapter.kind).toBe('portable'); - expect(adapter.restore(['concept-1', 'concept-2'])).toBeUndefined(); - adapter.persist('concept-2'); - expect(adapter.restore(['concept-1', 'concept-2'])).toBeUndefined(); - expect(metadata.ui).not.toHaveProperty('domain'); - expect(JSON.stringify(metadata)).not.toContain('claudemcpcontent.com'); -}); - -test('restores and synchronously persists only valid documented widget state', () => { - const writes: unknown[] = []; - const adapter = createWidgetStateAdapter({ - openai: { - setWidgetState: (value: unknown) => { - writes.push(value); - }, - widgetState: { selectedEventId: 'concept-2' }, - }, - }); - - expect(adapter.kind).toBe('openai'); - expect(adapter.restore(['concept-1', 'concept-2'])).toBe('concept-2'); - adapter.persist('concept-1'); - expect(writes).toEqual([{ selectedEventId: 'concept-1' }]); - - const malformed = createWidgetStateAdapter({ - openai: { setWidgetState: () => undefined, widgetState: { selectedEventId: 12 } }, - }); - expect(malformed.restore(['concept-1', 'concept-2'])).toBeUndefined(); -}); - -test('derives the optional Claude resource domain only from a supplied public URL', () => { - expect(claudeStableAppDomain('https://example.com/mcp')).toBe('c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com'); - expect(resourceMetadata(runtimeDefinition.resources[0], 'https://example.com/mcp')).toMatchObject({ - ui: { domain: 'c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com' }, - }); -}); - -test('preserves arbitrary serializable extension metadata without changing complete portable data', () => { - const extension = { 'example.acme/trace': { requestId: 'trace-7', retry: false } }; - const merged = mergeSerializableMetadata({ 'openai/outputTemplate': 'ui://timeline' }, extension); - const resource = resourceMetadata({ - ...runtimeDefinition.resources[0], - _meta: { ...runtimeDefinition.resources[0]._meta, ...extension }, - }); - - expect(merged).toEqual({ - 'example.acme/trace': { requestId: 'trace-7', retry: false }, - 'openai/outputTemplate': 'ui://timeline', - }); - expect(resource).toMatchObject(extension); - expect(resource).not.toHaveProperty('ui.domain'); -}); - -test('exposes standard safe-area values without choosing a host product', () => { - expect( - safeAreaCustomProperties({ - platform: 'mobile', - safeAreaInsets: { bottom: 34, left: 11, right: 13, top: 47 }, - styles: { variables: { '--color-background-primary': '#10162a', '--font-mono': 'Fira Code' } }, - theme: 'dark', - }), - ).toEqual({ - '--timeline-safe-area-bottom': '34px', - '--timeline-safe-area-left': '11px', - '--timeline-safe-area-right': '13px', - '--timeline-safe-area-top': '47px', - }); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts deleted file mode 100644 index 0ed665110..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/http-security.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from '@rstest/core'; - -import { allowsOrigin, resolveHttpSecurityConfig } from '../src/mcp/http-security.js'; - -test('uses loopback defaults and only admits absent or same-origin browser requests', () => { - const config = resolveHttpSecurityConfig({}); - - expect(config.allowedHosts).toEqual(['127.0.0.1', 'localhost', '[::1]']); - expect(config.allowedOrigins).toEqual([]); - expect(allowsOrigin(config, '127.0.0.1:4312', undefined)).toBe(true); - expect(allowsOrigin(config, '127.0.0.1:4312', 'http://127.0.0.1:4312')).toBe(true); - expect(allowsOrigin(config, '127.0.0.1:4312', 'https://attacker.example')).toBe(false); -}); - -test('requires explicit public host and origin allowlists for a tunnel', () => { - const config = resolveHttpSecurityConfig({ - AGENT_RUNTIME_ALLOWED_HOSTS: 'tunnel.example', - AGENT_RUNTIME_ALLOWED_ORIGINS: 'https://tunnel.example', - }); - - expect(config.allowedHosts).toEqual(['127.0.0.1', 'localhost', '[::1]', 'tunnel.example']); - expect(config.allowedOrigins).toEqual(['https://tunnel.example']); - expect(allowsOrigin(config, 'tunnel.example', 'https://tunnel.example')).toBe(true); - expect(allowsOrigin(config, 'tunnel.example', 'https://attacker.example')).toBe(false); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx deleted file mode 100644 index bbdce24ad..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { expect, test } from '@rstest/core'; -import React from 'react'; - -import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime'; - -test('lowers every supported MCP result block in authored order', () => { - const result = lowerMcpResult( - - two edits - - - - - {'{"stateVersion":2}'} - - , - ); - - expect(result).toEqual({ - content: [ - { type: 'text', text: 'two edits' }, - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, - { type: 'audio', data: 'UklGRg==', mimeType: 'audio/wav' }, - { - type: 'resource_link', - uri: 'file:///demo.txt', - name: 'demo.txt', - mimeType: 'text/plain', - }, - { - type: 'resource', - resource: { - uri: 'runtime://snapshot', - mimeType: 'application/json', - text: '{"stateVersion":2}', - }, - }, - ], - structuredContent: { stateVersion: 2 }, - isError: false, - }); -}); - -test('rejects malformed or nested MCP protocol result trees', () => { - expect(() => - lowerMcpResult( - - {} - , - ), - ).toThrow('mcp-image requires non-empty data and mimeType'); - - expect(() => - lowerMcpResult( - - {} - , - ), - ).toThrow('mcp-audio requires non-empty data and mimeType'); - - expect(() => - lowerMcpResult( - - {} - , - ), - ).toThrow('mcp-embedded-resource accepts exactly one text or blob value'); - - expect(() => - lowerMcpResult( - - - {'{}'} - - , - ), - ).toThrow('mcp-embedded-resource accepts exactly one text or blob value'); - - expect(() => - lowerMcpResult( - - - nested - - , - ), - ).toThrow('mcp-result may not be nested'); - - expect(() => - lowerMcpResult( - - invalid - , - ), - ).toThrow('mcp-result structuredContent must be JSON-serializable'); -}); - -test('rejects non-JSON structured content instead of normalizing it', () => { - const cyclic: Record = {}; - cyclic.self = cyclic; - const sparse = new Array(2); - sparse[1] = 'present'; - - for (const value of [ - undefined, - () => undefined, - Symbol('value'), - Number.NaN, - Number.POSITIVE_INFINITY, - new Date('2026-08-14T00:00:00.000Z'), - new Map(), - sparse, - [undefined], - cyclic, - ]) { - expect(() => - lowerMcpResult( - - invalid - , - ), - ).toThrow('mcp-result structuredContent must be JSON-serializable'); - } -}); - -test('clones recursively valid JSON records for structured content', () => { - const input = Object.assign(Object.create(null), { - nested: { array: [null, false, 2.5, 'value'] }, - stateVersion: 2, - }); - - const result = lowerMcpResult( - - valid - , - ); - - expect(result.structuredContent).toEqual({ - nested: { array: [null, false, 2.5, 'value'] }, - stateVersion: 2, - }); - expect(result.structuredContent).not.toBe(input); -}); - -test('preserves serializable extension metadata alongside complete portable results', () => { - const result = lowerMcpResult( - - two edits - , - ); - - expect(result).toEqual({ - _meta: { 'example.acme/trace': { attempt: 2 } }, - content: [{ text: 'two edits', type: 'text' }], - structuredContent: { stateVersion: 2 }, - }); -}); - -test('preserves an own __proto__ key in valid structured content', () => { - const input = Object.create(null) as Record; - Object.defineProperty(input, '__proto__', { - enumerable: true, - value: { value: 'preserved' }, - }); - - const result = lowerMcpResult( - - valid - , - ); - - expect(Object.getOwnPropertyDescriptor(result.structuredContent as object, '__proto__')?.value).toEqual({ - value: 'preserved', - }); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts deleted file mode 100644 index 7d417297d..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { spawn } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { request as httpRequest } from 'node:http'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { once } from 'node:events'; -import { pathToFileURL } from 'node:url'; - -import { createRsbuild } from '@rsbuild/core'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { expect, test } from '@rstest/core'; - -import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; -import { createRscRuntimeRsbuildConfig } from '../rsbuild.config.js'; - -const createStateFile = async (): Promise => { - const directory = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-mcp-')); - const stateFile = join(directory, 'events.jsonl'); - const kernel = createFileRuntimeKernel({ - stateFile, - createId: () => 'seed-edit', - now: () => new Date('2026-08-14T10:24:31.000Z'), - }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:mcp-transport:seed-1', - path: 'src/runtime/state.ts', - sessionId: 'seed-session', - toolName: 'Write', - }); - return stateFile; -}; - -const createClient = (): Client => - new Client({ name: 'rsc-agent-runtime-test', version: '1.0.0' }); - -const requestStatus = ({ - headers, - path, - port, -}: { - headers: Record; - path: string; - port: number; -}): Promise => - new Promise((resolve, reject) => { - const request = httpRequest({ headers, hostname: '127.0.0.1', method: 'GET', path, port }, (response) => { - response.resume(); - response.once('end', () => resolve(response.statusCode ?? 0)); - }); - request.once('error', reject); - request.end(); - }); - -const expectStaticSurface = async (client: Client) => { - const tools = await client.listTools(); - expect(tools.tools.map((tool) => tool.name)).toEqual([ - 'recent_edits', - 'render_edit_timeline', - 'runtime_status', - ]); - expect(tools.tools).toMatchObject([ - { name: 'recent_edits', _meta: {} }, - { - name: 'render_edit_timeline', - _meta: { - 'openai/outputTemplate': 'ui://rsc-agent-runtime/edit-timeline-v1.html', - ui: { resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, - }, - }, - { name: 'runtime_status', _meta: {} }, - ]); - - const resources = await client.listResources(); - expect(resources.resources).toMatchObject([ - { - mimeType: 'text/html;profile=mcp-app', - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - ui: { - csp: { connectDomains: [], resourceDomains: [] }, - prefersBorder: true, - }, - }, - uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - }, - ]); -}; - -test('built stdio MCP serves static tools, file-backed data, Flight results, and inline widget', async () => { - const stateFile = await createStateFile(); - const client = createClient(); - const transport = new StdioClientTransport({ - command: process.execPath, - args: [join(process.cwd(), 'dist/runtime/mcp/stdio.js')], - env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, - stderr: 'pipe', - }); - - try { - await client.connect(transport); - await expectStaticSurface(client); - - await expect(client.callTool({ name: 'recent_edits', arguments: { limit: 10 } })).resolves.toMatchObject({ - content: [{ type: 'text' }], - structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, - }); - await expect(client.callTool({ name: 'render_edit_timeline', arguments: {} })).resolves.toMatchObject({ - content: [{ type: 'text' }], - structuredContent: { edits: [{ eventId: 'seed-edit' }], stateVersion: 1 }, - }); - const runtimeStatus = await client.callTool({ name: 'runtime_status', arguments: {} }); - expect(runtimeStatus.structuredContent).toMatchObject({ editCount: 1, stateVersion: 1 }); - expect(runtimeStatus.content).toContainEqual({ - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - mimeType: 'image/png', - type: 'image', - }); - await expect( - client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }), - ).resolves.toMatchObject({ - contents: [ - { - mimeType: 'text/html;profile=mcp-app', - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - ui: { - csp: { connectDomains: [], resourceDomains: [] }, - prefersBorder: true, - }, - }, - text: expect.stringContaining(' { - const runtimeRoot = join(process.cwd(), 'dist/runtime'); - const workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-shared-workspace-')); - const stateHome = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-shared-state-')); - const environment = Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); - delete environment.AGENT_RUNTIME_STATE_FILE; - environment.XDG_STATE_HOME = stateHome; - - const hook = spawn(process.execPath, [join(runtimeRoot, 'hook/index.js'), '--host', 'codex'], { - cwd: workspace, - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - hook.stdin.end(JSON.stringify({ - cwd: workspace, - event_id: 'shared-fallback-event', - hook_event_name: 'PostToolUse', - session_id: 'shared-session', - tool_input: { command: '*** Begin Patch\n*** Add File: shared.txt\n+shared\n*** End Patch' }, - tool_name: 'apply_patch', - })); - const hookStderr: Buffer[] = []; - hook.stderr.on('data', (chunk: Buffer) => hookStderr.push(chunk)); - hook.stdout.resume(); - const [hookExit] = (await once(hook, 'close')) as [number | null, NodeJS.Signals | null]; - expect(hookExit, Buffer.concat(hookStderr).toString('utf8')).toBe(0); - - const client = createClient(); - const transport = new StdioClientTransport({ - args: [join(runtimeRoot, 'mcp/stdio.js')], - command: process.execPath, - cwd: workspace, - env: environment, - stderr: 'pipe', - }); - try { - await client.connect(transport); - await expect(client.callTool({ name: 'recent_edits', arguments: { limit: 10 } })).resolves.toMatchObject({ - structuredContent: { - edits: [{ eventId: expect.any(String), path: join(workspace, 'shared.txt') }], - stateVersion: 1, - }, - }); - await expect(access(join(workspace, '.agent-runtime-demo'))).rejects.toThrow(); - } finally { - await client.close(); - await Promise.all([ - rm(workspace, { force: true, recursive: true }), - rm(stateHome, { force: true, recursive: true }), - ]); - } -}); - -test('built Streamable HTTP MCP reports its one JSON startup line and closes cleanly', async () => { - const stateFile = await createStateFile(); - const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { - env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile, PORT: '0' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - - const client = createClient(); - try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; - const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`)); - await client.connect(transport); - await expectStaticSurface(client); - - const localHost = `127.0.0.1:${startup.port}`; - await expect( - requestStatus({ - headers: { Host: localHost, Origin: `http://${localHost}` }, - path: '/health', - port: startup.port, - }), - ).resolves.toBe(200); - for (const path of ['/health', '/mcp']) { - await expect( - requestStatus({ headers: { Host: 'attacker.example' }, path, port: startup.port }), - ).resolves.toBe(403); - await expect( - requestStatus({ headers: { Host: localHost, Origin: 'https://attacker.example' }, path, port: startup.port }), - ).resolves.toBe(403); - } - } finally { - await client.close(); - child.kill('SIGTERM'); - const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; - expect(exitCode).toBe(0); - expect(signal).toBeNull(); - await rm(join(stateFile, '..'), { force: true, recursive: true }); - } -}); - -test('built Streamable HTTP MCP accepts only explicitly allowed public tunnel origins', async () => { - const stateFile = await createStateFile(); - const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { - env: { - ...process.env, - AGENT_RUNTIME_ALLOWED_HOSTS: 'tunnel.example', - AGENT_RUNTIME_ALLOWED_ORIGINS: 'https://tunnel.example', - AGENT_RUNTIME_STATE_FILE: stateFile, - PORT: '0', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - - try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; - await expect( - requestStatus({ - headers: { Host: 'tunnel.example', Origin: 'https://tunnel.example' }, - path: '/health', - port: startup.port, - }), - ).resolves.toBe(200); - } finally { - child.kill('SIGTERM'); - await once(child, 'close'); - await rm(join(stateFile, '..'), { force: true, recursive: true }); - } -}); - -test('adds an explicit public MCP URL domain only to returned resource content', async () => { - const stateFile = await createStateFile(); - const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/mcp/http.js')], { - env: { - ...process.env, - AGENT_RUNTIME_PUBLIC_MCP_URL: 'https://example.com/mcp', - AGENT_RUNTIME_STATE_FILE: stateFile, - PORT: '0', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - const client = createClient(); - - try { - await once(child.stderr, 'data'); - const startup = JSON.parse(stderr.trim()) as { port: number }; - await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${startup.port}/mcp`))); - const resources = await client.listResources(); - expect(resources.resources[0]._meta?.ui).not.toHaveProperty('domain'); - await expect(client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' })).resolves.toMatchObject({ - contents: [{ _meta: { ui: { domain: 'c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com' } } }], - }); - } finally { - await client.close(); - child.kill('SIGTERM'); - await once(child, 'close'); - await rm(join(stateFile, '..'), { force: true, recursive: true }); - } -}); - -test('built widget HTML is self-contained without external app bundle assets', async () => { - for (const name of ['edit-timeline-v1', 'standalone']) { - const artifact = join(process.cwd(), 'dist/app', `${name}.html`); - await access(artifact); - const html = await readFile(artifact, 'utf8'); - expect(html).toContain(' { - const entries = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js']; - const runtimeRoot = join(process.cwd(), 'dist/runtime'); - const manifest = JSON.parse(await readFile(join(runtimeRoot, 'runtime-assets.json'), 'utf8')) as { - allFiles: string[]; - }; - const manifestFiles = manifest.allFiles.map((file) => file.replace(/^\//, '')); - const dynamicChunkDependencies = ( - await Promise.all( - entries.map(async (entry) => { - const source = await readFile(join(runtimeRoot, entry), 'utf8'); - return [...source.matchAll(/__webpack_require__\.e\(\/\* import\(\) \*\/\s*(\d+)\)/g)].map((match) => ({ - chunkId: match[1], - entry, - })); - }), - ) - ).flat(); - - expect(manifestFiles).toEqual(expect.arrayContaining(entries)); - expect(manifestFiles.some((file) => file.startsWith('chunks/'))).toBe(true); - for (const file of manifestFiles) { - await access(join(runtimeRoot, file)); - } - for (const { chunkId } of dynamicChunkDependencies) { - expect(manifestFiles).toContain(`chunks/${chunkId}.js`); - } -}); - -test('production and development runtime graphs exclude state test controls', async () => { - const forbidden = [ - 'state-file-test-support', - 'createFileRuntimeKernelForTesting', - 'RuntimeStateTestAdapter', - 'beforeAppend', - 'criticalSectionMs', - ]; - const readRuntimeSources = async (root: string): Promise => { - const sources: string[] = []; - const visit = async (directory: string): Promise => { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) await visit(path); - else if (entry.name.endsWith('.js') || entry.name.endsWith('.map')) sources.push(await readFile(path, 'utf8')); - } - }; - await visit(root); - return sources.join('\n'); - }; - const assertExcluded = (source: string): void => { - for (const name of forbidden) expect(source).not.toContain(name); - }; - - assertExcluded(await readRuntimeSources(join(process.cwd(), 'dist/runtime'))); - for (const host of ['claude', 'codex']) { - const packagedRuntime = join(process.cwd(), 'dist/plugins', host, 'runtime'); - assertExcluded(await readRuntimeSources(packagedRuntime)); - await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); - } - - const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-graph-')); - const rsbuild = await createRsbuild({ - config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), - cwd: process.cwd(), - }); - let closeBuild = async (): Promise => undefined; - try { - const result = await rsbuild.build(); - closeBuild = result.close; - expect(result.stats).toBeDefined(); - assertExcluded(JSON.stringify(result.stats?.toJson({ all: false, children: true, modules: true, source: true }))); - assertExcluded(await readRuntimeSources(join(compilerRoot, 'rsc'))); - } finally { - await closeBuild(); - await rm(compilerRoot, { force: true, recursive: true }); - } -}); - -test('a second multi-environment build removes stale app chunks', async () => { - const staleAsset = join(process.cwd(), 'dist/app/static/js/async/stale.js'); - await mkdir(dirname(staleAsset), { recursive: true }); - await writeFile(staleAsset, 'stale artifact', 'utf8'); - - try { - const child = spawn('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'ignore' }); - const [exitCode, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; - expect(exitCode).toBe(0); - expect(signal).toBeNull(); - await expect(access(staleAsset)).rejects.toThrow(); - for (const name of ['edit-timeline-v1', 'standalone']) { - const html = await readFile(join(process.cwd(), 'dist/app', `${name}.html`), 'utf8'); - expect(html).toContain(' { - const workspace = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-micro-eval-')); - const stateFile = join(workspace, 'events.jsonl'); - const client = new Client({ name: 'rsc-agent-runtime-micro-eval', version: '1.0.0' }); - const transport = new StdioClientTransport({ - args: [join(process.cwd(), 'dist/runtime/mcp/stdio.js')], - command: process.execPath, - env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, - stderr: 'pipe', - }); - - try { - const hook = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/hook/index.js'), '--host', 'claude'], { - env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - hook.stdin.end(JSON.stringify({ - cwd: workspace, - hook_event_name: 'PostToolUse', - session_id: 'micro-eval-session', - tool_input: { content: 'micro-eval\n', file_path: join(workspace, 'spot-check.txt') }, - tool_name: 'Write', - tool_response: { success: true }, - tool_use_id: 'micro-eval-tool-1', - })); - const hookStdout: Buffer[] = []; - const hookStderr: Buffer[] = []; - hook.stdout.on('data', (chunk: Buffer) => hookStdout.push(chunk)); - hook.stderr.on('data', (chunk: Buffer) => hookStderr.push(chunk)); - const [hookExit] = (await once(hook, 'close')) as [number | null, NodeJS.Signals | null]; - - expect(hookExit, Buffer.concat(hookStderr).toString('utf8')).toBe(0); - expect(JSON.parse(Buffer.concat(hookStdout).toString('utf8'))).toEqual({ - hookSpecificOutput: { - additionalContext: 'Recorded spot-check.txt from claude. Shared state now contains 1 edit.', - hookEventName: 'PostToolUse', - }, - }); - - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line) as { - readonly event: { readonly host: string; readonly path: string }; - readonly idempotencyKey: string; - }); - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ - event: { host: 'claude', path: join(workspace, 'spot-check.txt') }, - idempotencyKey: 'claude:tool:micro-eval-tool-1', - }); - - await client.connect(transport); - const tools = await client.listTools(); - expect(tools.tools.find((tool) => tool.name === 'render_edit_timeline')?._meta).toMatchObject({ - ui: { resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }, - }); - await expect(client.callTool({ arguments: {}, name: 'render_edit_timeline' })).resolves.toMatchObject({ - content: [{ text: 'Showing 1 recorded edits.', type: 'text' }], - structuredContent: { - edits: [{ host: 'claude', path: join(workspace, 'spot-check.txt') }], - stateVersion: 1, - }, - }); - const resource = await client.readResource({ uri: 'ui://rsc-agent-runtime/edit-timeline-v1.html' }); - expect(resource.contents[0]).toMatchObject({ - mimeType: 'text/html;profile=mcp-app', - text: expect.stringContaining(' => { - const directory = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-hook-')); - temporaryDirectories.push(directory); - return directory; -}; - -const runHook = async ( - host: 'claude' | 'codex', - input: Record, - stateFile: string | undefined, - additionalEnvironment: Record = {}, -) => { - const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/hook/index.js'), '--host', host], { - env: { - ...process.env, - ...(stateFile === undefined ? {} : { AGENT_RUNTIME_STATE_FILE: stateFile }), - ...additionalEnvironment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - child.stdin.end(JSON.stringify(input)); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Promise((resolve, reject) => { - let output = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - output += chunk; - }); - child.stdout.on('error', reject); - child.stdout.on('end', () => resolve(output)); - }), - new Promise((resolve, reject) => { - let output = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - output += chunk; - }); - child.stderr.on('error', reject); - child.stderr.on('end', () => resolve(output)); - }), - new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', resolve); - }), - ]); - - return { exitCode, stderr, stdout }; -}; - -const runRscWorker = async (request: Record) => { - const child = spawn(process.execPath, [join(process.cwd(), 'dist/runtime/rsc/index.js')], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - child.stdin.end(JSON.stringify(request)); - const [stdout, exitCode] = await Promise.all([ - new Promise((resolve, reject) => { - let output = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - output += chunk; - }); - child.stdout.on('error', reject); - child.stdout.on('end', () => resolve(output)); - }), - new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', resolve); - }), - ]); - return { exitCode, stdout }; -}; - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); -}); - -describe('built RSC hook entry', () => { - it('uses native tool ids before host event ids for durable mutation idempotency', () => { - expect( - normalizeClaudeHook({ - cwd: '/workspace', - event_id: 'event-1', - hook_event_name: 'PostToolUse', - session_id: 'session-1', - tool_input: { file_path: 'demo.txt' }, - tool_name: 'Write', - tool_use_id: 'tool-1', - }), - ).toMatchObject({ idempotencyKey: 'claude:tool:tool-1' }); - expect( - normalizeCodexHook({ - cwd: '/workspace', - event_id: 'event-2', - hook_event_name: 'PostToolUse', - session_id: 'session-1', - tool_input: { command: '*** Begin Patch\n*** Add File: demo.txt\n+demo\n*** End Patch' }, - tool_name: 'apply_patch', - }), - ).toMatchObject({ idempotencyKey: 'codex:event:event-2' }); - expect(() => - normalizeClaudeHook({ - cwd: '/workspace', - hook_event_name: 'PostToolUse', - session_id: 'session-1', - tool_input: { file_path: 'demo.txt' }, - tool_name: 'Write', - }), - ).toThrow('tool_use_id or event_id'); - }); - - it('rejects every empty RSC mutation field before creating state', async () => { - const workspace = await createTemporaryDirectory(); - for (const emptyField of ['cwd', 'idempotencyKey', 'path', 'sessionId', 'toolName']) { - const stateFile = join(workspace, `${emptyField}.jsonl`); - const event = { - cwd: workspace, - host: 'claude', - idempotencyKey: 'claude:tool:worker-fields', - path: join(workspace, 'demo.txt'), - sessionId: 'session-1', - toolName: 'Write', - [emptyField]: '', - }; - const result = await runRscWorker({ event, stateFile, type: 'hook/after-file-edit' }); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(''); - await expect(readFile(stateFile, 'utf8')).rejects.toThrow(); - } - }); - - it('renders native Claude and Codex outputs through Flight while retaining file-backed state', async () => { - const workspace = await createTemporaryDirectory(); - const stateFile = join(workspace, 'state.jsonl'); - - const first = await runHook( - 'claude', - { - session_id: 'claude-session', - cwd: workspace, - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: `${workspace}/demo.txt`, content: 'hello\n' }, - tool_response: { success: true }, - tool_use_id: 'tool-1', - }, - stateFile, - ); - - expect(first.exitCode).toBe(0); - expect(JSON.parse(first.stdout)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', - }, - }); - - const replay = await runHook( - 'claude', - { - session_id: 'claude-session', - cwd: workspace, - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: `${workspace}/demo.txt`, content: 'hello\n' }, - tool_response: { success: true }, - tool_use_id: 'tool-1', - }, - stateFile, - ); - expect(replay.exitCode).toBe(0); - expect(JSON.parse(replay.stdout)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Recorded demo.txt from claude. Shared state now contains 1 edit.', - }, - }); - - const second = await runHook( - 'codex', - { - session_id: 'codex-session', - cwd: workspace, - hook_event_name: 'PostToolUse', - tool_name: 'apply_patch', - tool_input: { command: '*** Begin Patch\n*** Add File: second.txt\n+second\n*** End Patch' }, - tool_response: { success: true }, - tool_use_id: 'tool-2', - }, - stateFile, - ); - - expect(second.exitCode).toBe(0); - expect(JSON.parse(second.stdout)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Recorded second.txt from codex. Shared state now contains 2 edits.', - }, - }); - - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); - expect(records.map((record) => record.event.host)).toEqual(['claude', 'codex']); - expect(records.map((record) => record.idempotencyKey)).toEqual(['claude:tool:tool-1', 'codex:tool:tool-2']); - }); - - it('rejects unsupported native hook input without writing stdout', async () => { - const workspace = await createTemporaryDirectory(); - const result = await runHook( - 'claude', - { - session_id: 'claude-session', - cwd: workspace, - hook_event_name: 'PreToolUse', - tool_name: 'Write', - tool_input: { file_path: `${workspace}/demo.txt` }, - }, - join(workspace, 'state.jsonl'), - ); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(''); - }); - - it('falls back to tool-owned external state when a native host omits the configured environment', async () => { - const workspace = await createTemporaryDirectory(); - const stateHome = await createTemporaryDirectory(); - const result = await runHook( - 'codex', - { - session_id: 'codex-session', - cwd: workspace, - hook_event_name: 'PostToolUse', - tool_name: 'apply_patch', - tool_input: { command: '*** Begin Patch\n*** Add File: fallback.txt\n+fallback\n*** End Patch' }, - event_id: 'fallback-event-1', - }, - undefined, - { XDG_STATE_HOME: stateHome }, - ); - - expect(result.exitCode).toBe(0); - const workspaceId = createHash('sha256').update(await realpath(workspace)).digest('hex'); - const stateFile = join(stateHome, 'agent-bundle', 'rsc-agent-runtime', workspaceId, 'events.jsonl'); - expect((await readFile(stateFile, 'utf8')).trim()).toContain('fallback.txt'); - await expect(access(join(workspace, '.agent-runtime-demo'))).rejects.toThrow(); - }); - - it('ignores workspace fallback symlink swaps and never modifies their external target', async () => { - const workspace = await createTemporaryDirectory(); - const external = await createTemporaryDirectory(); - const stateHome = await createTemporaryDirectory(); - const externalState = join(external, 'events.jsonl'); - await writeFile(externalState, '', 'utf8'); - const workspaceFallback = join(workspace, '.agent-runtime-demo'); - let keepSwapping = true; - const swapper = (async () => { - while (keepSwapping) { - await rm(workspaceFallback, { force: true, recursive: true }); - await mkdir(workspaceFallback); - await rm(workspaceFallback, { force: true, recursive: true }); - await symlink(external, workspaceFallback, 'dir'); - } - })(); - - let result: Awaited>; - try { - result = await runHook( - 'codex', - { - session_id: 'codex-session', - cwd: workspace, - event_id: 'symlink-fallback-event', - hook_event_name: 'PostToolUse', - tool_name: 'apply_patch', - tool_input: { command: '*** Begin Patch\n*** Add File: protected.txt\n+protected\n*** End Patch' }, - }, - undefined, - { XDG_STATE_HOME: stateHome }, - ); - } finally { - keepSwapping = false; - await swapper; - } - - expect(result.exitCode).toBe(0); - await expect(readFile(externalState, 'utf8')).resolves.toBe(''); - }); - - it('emits only a value-free optional eval hook probe', async () => { - const workspace = await createTemporaryDirectory(); - const probeFile = join(workspace, 'hook-probe.jsonl'); - const result = await runHook( - 'codex', - { - session_id: 'codex-session', - cwd: workspace, - hook_event_name: 'PostToolUse', - tool_name: 'apply_patch', - tool_input: { command: '*** Begin Patch\n*** Add File: secret.txt\n+do-not-persist-this-value\n*** End Patch' }, - event_id: 'probe-event-1', - }, - join(workspace, 'state.jsonl'), - { AGENT_RUNTIME_HOOK_PROBE_FILE: probeFile }, - ); - - expect(result.exitCode).toBe(0); - const probe = JSON.parse(await readFile(probeFile, 'utf8')); - expect(probe).toEqual({ - commandLaunched: true, - exitStatus: 0, - toolInputKeys: ['command'], - toolInputValueTypes: { command: 'string' }, - toolName: 'apply_patch', - topLevelKeys: ['cwd', 'event_id', 'hook_event_name', 'session_id', 'tool_input', 'tool_name'], - topLevelValueTypes: { cwd: 'string', event_id: 'string', hook_event_name: 'string', session_id: 'string', tool_input: 'object', tool_name: 'string' }, - }); - expect(await readFile(probeFile, 'utf8')).not.toContain('do-not-persist-this-value'); - }); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts deleted file mode 100644 index 891d27d33..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/runtime-artifact-manifest.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import { expect, test } from '@rstest/core'; - -import { emitRuntimeArtifacts } from '../src/build/emit-artifacts.js'; - -test('declares every executable and contained runtime asset in the runtime manifest', async () => { - const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); - const runtimeAssets = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js', 'chunks/101.js']; - - try { - for (const asset of runtimeAssets) { - const target = join(runtimeRoot, asset); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, 'artifact', 'utf8'); - } - await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: runtimeAssets.map((asset) => `/${asset}`) }), 'utf8'); - - await emitRuntimeArtifacts(runtimeRoot); - - const manifest = JSON.parse(await readFile(join(runtimeRoot, 'agent-runtime.manifest.json'), 'utf8')) as { - executables: Array<{ name: string; path: string }>; - runtimeAssets: string[]; - }; - expect(manifest.executables).toEqual([ - { name: 'hook', path: 'hook/index.js' }, - { name: 'rsc-worker', path: 'rsc/index.js' }, - { name: 'stdio', path: 'mcp/stdio.js' }, - { name: 'http', path: 'mcp/http.js' }, - ]); - expect(manifest.runtimeAssets).toEqual(runtimeAssets); - } finally { - await rm(runtimeRoot, { force: true, recursive: true }); - } -}); - -test('uses an explicitly captured definition instead of the host module serializer', async () => { - const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); - const runtimeAssets = ['hook/index.js', 'rsc/index.js', 'mcp/stdio.js', 'mcp/http.js']; - const definition = { - nativeHooks: [], - resources: [], - tools: [], - }; - - try { - for (const asset of runtimeAssets) { - const target = join(runtimeRoot, asset); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, 'artifact', 'utf8'); - } - await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: runtimeAssets }), 'utf8'); - - await emitRuntimeArtifacts(runtimeRoot, definition); - - const manifest = JSON.parse(await readFile(join(runtimeRoot, 'agent-runtime.manifest.json'), 'utf8')) as { tools: unknown[] }; - expect(manifest.tools).toEqual([]); - } finally { - await rm(runtimeRoot, { force: true, recursive: true }); - } -}); - -test('rejects a runtime asset that escapes the manifest root', async () => { - const runtimeRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-manifest-')); - try { - await writeFile(join(runtimeRoot, 'runtime-assets.json'), JSON.stringify({ allFiles: ['../outside.js'] }), 'utf8'); - await expect(emitRuntimeArtifacts(runtimeRoot)).rejects.toThrow('Runtime asset escapes its root'); - } finally { - await rm(runtimeRoot, { force: true, recursive: true }); - } -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts deleted file mode 100644 index 64fa568d1..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/state-and-definition.test.ts +++ /dev/null @@ -1,1083 +0,0 @@ -import { access, appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { expect, test } from '@rstest/core'; -import { createRsbuild } from '@rsbuild/core'; - -import { serializeRuntimeDefinition } from '../src/build/serialize-definition.js'; -import { runtimeDefinition } from '../src/definition.js'; -import { createFileRuntimeKernel } from '../src/runtime/state-file.js'; -import { createTestFileRuntimeKernel } from '../src/runtime/state-file-test-support.js'; - -const readOnlyAnnotations = { - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - readOnlyHint: true, -}; - -const resourceUri = 'ui://rsc-agent-runtime/edit-timeline-v1.html'; - -const wait = async (milliseconds: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); - -const errorMessages = (value: unknown, seen = new Set()): readonly string[] => { - if (!(value instanceof Error) || seen.has(value)) return []; - seen.add(value); - return [ - value.message, - ...(value instanceof AggregateError ? value.errors.flatMap((error) => errorMessages(error, seen)) : []), - ...errorMessages(value.cause, seen), - ]; -}; - -const eagerPromise = (value: T): Promise => ({ - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - _onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return Promise.resolve(onfulfilled === undefined || onfulfilled === null ? value as unknown as TResult1 : onfulfilled(value)); - }, -}) as Promise; - -const startLockOwner = async (stateFile: string, timing: { stale: number; update: number } = { stale: 2_000, update: 1_000 }) => { - const child = spawn(process.execPath, [ - join(process.cwd(), 'tests/fixtures/state-lock-owner.mjs'), - stateFile, - String(timing.stale), - String(timing.update), - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - await new Promise((resolve, reject) => { - child.once('error', reject); - child.stdout.once('data', (chunk: Buffer) => { - if (chunk.toString('utf8').trim() === '{"ready":true}') { - resolve(); - return; - } - reject(new Error(`Unexpected lock-owner output: ${chunk.toString('utf8')}`)); - }); - }); - return child; -}; - -const validEditRecord = (stateVersion: number, idempotencyKey: string) => ({ - event: { - eventId: `event-${stateVersion}`, - host: 'claude', - path: `src/${stateVersion}.ts`, - recordedAt: '2026-08-14T12:00:00.000Z', - sessionId: 'session-1', - toolName: 'Write', - }, - idempotencyKey, - kind: 'edit', - stateVersion, -}); - -const containsFunction = (value: unknown): boolean => { - if (typeof value === 'function') { - return true; - } - - if (Array.isArray(value)) { - return value.some(containsFunction); - } - - if (value !== null && typeof value === 'object') { - return Object.values(value).some(containsFunction); - } - - return false; -}; - -test('reads an edit recorded by another kernel instance', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const first = createFileRuntimeKernel({ - stateFile, - now: () => new Date('2026-08-14T12:00:00.000Z'), - createId: () => 'edit-1', - }); - const second = createFileRuntimeKernel({ stateFile }); - - await first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:other-kernel', - path: 'src/runtime/state-file.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - - expect(await second.readSnapshot()).toMatchObject({ - edits: [{ eventId: 'edit-1', host: 'claude', path: 'src/runtime/state-file.ts' }], - stateVersion: 1, - }); -}); - -test('limits snapshots to the newest valid edit events', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let nextId = 0; - const kernel = createFileRuntimeKernel({ - stateFile, - createId: () => `edit-${++nextId}`, - now: () => new Date('2026-08-14T12:00:00.000Z'), - }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:limit-1', - path: 'first.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - await kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:limit-2', - path: 'second.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }); - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:limit-3', - path: 'third.ts', - sessionId: 'session-1', - toolName: 'Edit', - }); - - await expect(kernel.readSnapshot({ limit: 0 })).rejects.toThrow(RangeError); - await expect(kernel.readSnapshot({ limit: 51 })).rejects.toThrow(RangeError); - await expect(kernel.readSnapshot({ limit: 1.5 })).rejects.toThrow(RangeError); - await expect(kernel.readSnapshot({ limit: 2 })).resolves.toMatchObject({ - edits: [{ eventId: 'edit-2' }, { eventId: 'edit-3' }], - stateVersion: 3, - }); -}); - -test('ignores one trailing partial JSONL record', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:partial', - path: 'complete.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - await appendFile(stateFile, '{"eventId":"partial"', 'utf8'); - - await expect(kernel.readSnapshot()).resolves.toMatchObject({ - edits: [{ eventId: 'complete-edit', path: 'complete.ts' }], - stateVersion: 1, - }); -}); - -test('deduplicates identical state edits and rejects conflicting idempotency-key reuse', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const first = createFileRuntimeKernel({ - stateFile, - createId: () => 'first-event', - now: () => new Date('2026-08-14T12:00:00.000Z'), - }); - const second = createFileRuntimeKernel({ - stateFile, - createId: () => 'second-event', - now: () => new Date('2026-08-14T12:00:00.000Z'), - }); - const edit = { - host: 'claude' as const, - idempotencyKey: 'claude:tool:tool-1', - path: 'src/first.ts', - sessionId: 'session-1', - toolName: 'Write', - }; - - const [firstSnapshot, secondSnapshot] = await Promise.all([first.recordEdit(edit), second.recordEdit(edit)]); - expect(firstSnapshot.stateVersion).toBe(1); - expect(secondSnapshot.stateVersion).toBe(1); - expect((await readFile(stateFile, 'utf8')).trim().split('\n')).toHaveLength(1); - expect(JSON.parse((await readFile(stateFile, 'utf8')).trim())).toMatchObject({ - idempotencyKey: 'claude:tool:tool-1', - kind: 'edit', - stateVersion: 1, - }); - - await expect(second.recordEdit({ ...edit, path: 'src/conflict.ts' })).rejects.toThrow( - 'idempotency key claude:tool:tool-1', - ); -}); - -test('appends reset records without resetting the monotonic durable version', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ - stateFile, - createId: () => 'event-1', - now: () => new Date('2026-08-14T12:00:00.000Z'), - }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:before-reset', - path: 'src/before-reset.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - const reset = await kernel.resetState({ idempotencyKey: 'test:state:reset-1', seed: { reason: 'test' } }); - - expect(reset).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); - const records = (await readFile(stateFile, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); - expect(records).toMatchObject([ - { kind: 'edit', stateVersion: 1 }, - { idempotencyKey: 'test:state:reset-1', kind: 'reset', seed: { reason: 'test' }, stateVersion: 2 }, - ]); - expect(await createFileRuntimeKernel({ stateFile }).readSnapshot()).toEqual({ edits: [], seed: { reason: 'test' }, stateVersion: 2 }); -}); - -test('preserves reset seeds across immediate, idempotent, reopened, limited, and follow-up snapshots', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const seed = Object.freeze({ - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'fixture-seed-session', - tool_input: Object.freeze({ file_path: 'fixture-seed.txt' }), - tool_name: 'Write', - tool_use_id: 'fixture-seed-tool', - }); - const first = createFileRuntimeKernel({ - stateFile, - createId: () => 'seed-follow-up-edit', - now: () => new Date('2026-08-15T00:00:00.000Z'), - }); - - await first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:seed-before-reset', - path: 'before-reset.ts', - sessionId: 'fixture-seed-session', - toolName: 'Write', - }); - const reset = await first.resetState({ idempotencyKey: 'test:state:seed-reset', seed }); - expect(reset).toEqual({ edits: [], seed, stateVersion: 2 }); - await expect(first.resetState({ idempotencyKey: 'test:state:seed-reset', seed })).resolves.toEqual(reset); - - const reopened = createFileRuntimeKernel({ - stateFile, - createId: () => 'seed-follow-up-edit', - now: () => new Date('2026-08-15T00:00:01.000Z'), - }); - await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual(reset); - await expect(reopened.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:seed-follow-up', - path: 'after-reset.ts', - sessionId: 'fixture-seed-session', - toolName: 'Write', - })).resolves.toEqual({ - edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], - seed, - stateVersion: 3, - }); - await expect(reopened.readSnapshot({ limit: 1 })).resolves.toEqual({ - edits: [expect.objectContaining({ eventId: 'seed-follow-up-edit', path: 'after-reset.ts' })], - seed, - stateVersion: 3, - }); - await expect(reopened.resetState({ - idempotencyKey: 'test:state:seed-reset', - seed: { ...seed, session_id: 'conflicting-seed-session' }, - })).rejects.toThrow('idempotency key test:state:seed-reset'); - await expect(reopened.resetState({ idempotencyKey: 'test:state:seed-clear' })).resolves.toEqual({ edits: [], stateVersion: 4 }); - await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ edits: [], stateVersion: 4 }); -}); - -test('reconstructs an exact durable snapshot version through edits, resets, and idempotent replays', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ - stateFile, - createId: () => 'exact-version-edit', - now: () => new Date('2026-08-15T01:00:00.000Z'), - }); - const readExact = (stateVersion: number) => kernel.readSnapshot({ stateVersion }); - - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:exact-before-reset', - path: 'before-reset.ts', - sessionId: 'exact-version-session', - toolName: 'Write', - }); - const seed = Object.freeze({ reason: 'exact-version-reset' }); - await kernel.resetState({ idempotencyKey: 'test:state:exact-reset', seed }); - const afterReset = await kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:exact-after-reset', - path: 'after-reset.ts', - sessionId: 'exact-version-session', - toolName: 'apply_patch', - }); - await expect(kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:exact-after-reset', - path: 'after-reset.ts', - sessionId: 'exact-version-session', - toolName: 'apply_patch', - })).resolves.toEqual(afterReset); - - await expect(readExact(0)).resolves.toEqual({ edits: [], stateVersion: 0 }); - await expect(readExact(1)).resolves.toMatchObject({ - edits: [expect.objectContaining({ path: 'before-reset.ts' })], - stateVersion: 1, - }); - await expect(readExact(2)).resolves.toEqual({ edits: [], seed, stateVersion: 2 }); - await expect(readExact(3)).resolves.toMatchObject({ - edits: [expect.objectContaining({ path: 'after-reset.ts' })], - seed, - stateVersion: 3, - }); - await expect(readExact(4)).rejects.toThrow('state version 4 is unavailable'); - await expect(readExact(-1)).rejects.toThrow(RangeError); - await expect(readExact(1.5)).rejects.toThrow(RangeError); -}); - -test('rejects terminated JSONL corruption while preserving only an incomplete final tail for recovery', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createFileRuntimeKernel({ stateFile, createId: () => 'complete-edit' }); - await kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:complete', - path: 'complete.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - - await appendFile(stateFile, '{"broken":true}\n', 'utf8'); - await expect(kernel.readSnapshot()).rejects.toThrow('Runtime state corruption'); - - const recoverableStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'recoverable.jsonl'); - const recoverable = createFileRuntimeKernel({ stateFile: recoverableStateFile, createId: () => 'recovered-edit' }); - await recoverable.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:before-tail', - path: 'first.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }); - await appendFile(recoverableStateFile, '{"truncated"', 'utf8'); - await expect( - recoverable.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:after-tail', - path: 'second.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }), - ).resolves.toMatchObject({ stateVersion: 2 }); - await expect(recoverable.readSnapshot()).resolves.toMatchObject({ - edits: [{ path: 'first.ts' }, { path: 'second.ts' }], - stateVersion: 2, - }); -}); - -test('rejects malformed middle records and non-monotonic durable versions', async () => { - const middleStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'middle.jsonl'); - await writeFile(middleStateFile, `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n{"invalid":true}\n`, 'utf8'); - await expect(createFileRuntimeKernel({ stateFile: middleStateFile }).readSnapshot()).rejects.toThrow('Runtime state corruption'); - - const versionStateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'version.jsonl'); - await writeFile( - versionStateFile, - `${JSON.stringify(validEditRecord(1, 'test:state:first'))}\n${JSON.stringify(validEditRecord(1, 'test:state:second'))}\n`, - 'utf8', - ); - await expect(createFileRuntimeKernel({ stateFile: versionStateFile }).readSnapshot()).rejects.toThrow('monotonic state version'); -}); - -test('excludes a live heartbeat owner and recovers its stale lock only after SIGKILL', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await writeFile(stateFile, '', 'utf8'); - const owner = await startLockOwner(stateFile); - try { - const lockDirectory = `${stateFile}.lock`; - const firstMtime = (await stat(lockDirectory)).mtimeMs; - await wait(1_100); - expect((await stat(lockDirectory)).mtimeMs).toBeGreaterThan(firstMtime); - - const aborted = new AbortController(); - setTimeout(() => aborted.abort(new Error('test abort')), 50); - await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:live-owner', - path: 'live-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { lockAcquireTimeoutMs: 500, signal: aborted.signal }, - ), - ).rejects.toThrow('test abort'); - - owner.kill('SIGKILL'); - await new Promise((resolve) => owner.once('close', () => resolve())); - await wait(2_100); - await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:stale-recovery', - path: 'recovered.ts', - sessionId: 'session-1', - toolName: 'apply_patch', - }), - ).resolves.toMatchObject({ stateVersion: 1 }); - } finally { - owner.kill('SIGKILL'); - } -}); - -test('a non-production short-timing contender cannot steal a production lease', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await writeFile(stateFile, '', 'utf8'); - const owner = await startLockOwner(stateFile, { stale: 30_000, update: 5_000 }); - try { - const cancelled = new AbortController(); - setTimeout(() => cancelled.abort(new Error('short contender aborted')), 2_100); - await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:short-contender', - path: 'must-not-write.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { lockAcquireTimeoutMs: 30_000, signal: cancelled.signal }, - ), - ).rejects.toThrow('short contender aborted'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); - } finally { - owner.kill('SIGTERM'); - await new Promise((resolve) => owner.once('close', () => resolve())); - } -}); - -test('releases a lease acquired after an expired absolute acquisition deadline', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - prepareStateFile: async ({ stateFile: preparedStateFile }) => preparedStateFile, - acquireLock: async () => - new Promise((resolve) => { - setTimeout(() => resolve(async () => { - releases += 1; - }), 30); - }), - }, - }); - - await expect( - kernel.recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:late-lock', - path: 'late-lock.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { lockAcquireTimeoutMs: 20 }, - ), - ).rejects.toThrow('Timed out acquiring runtime state lease'); - await wait(60); - expect(releases).toBe(1); -}); - -test('cancels a never-settling active phase at the hard critical-section deadline', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: () => new Promise(() => undefined), - criticalSectionMs: 10, - }, - }); - - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:never-settles', - path: 'never-settles.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('did not settle within 100 ms after cancellation'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); -}); - -test('exits promptly after a timed-out phase settles before its owner-settlement deadline', async () => { - const buildRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-build-')); - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-exit-')), 'state.jsonl'); - const rsbuild = await createRsbuild({ - config: { - output: { - distPath: { root: buildRoot }, - filename: { js: '[name].js' }, - target: 'node', - }, - source: { entry: { fixture: './tests/fixtures/state-settlement-exit.ts' } }, - }, - cwd: process.cwd(), - }); - const build = await rsbuild.build(); - const startedAt = Date.now(); - const child = spawn(process.execPath, [join(buildRoot, 'fixture.js'), stateFile], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - const outcome = await Promise.race([ - new Promise>((resolve, reject) => { - child.once('error', reject); - child.once('close', (exitCode) => resolve({ exitCode, type: 'closed' })); - }), - wait(500).then(() => ({ type: 'timeout' as const })), - ]); - if (outcome.type === 'timeout') child.kill('SIGKILL'); - await build.close(); - await rm(buildRoot, { force: true, recursive: true }); - - expect(outcome.type).toBe('closed'); - if (outcome.type === 'closed') expect(outcome.exitCode).toBe(0); - expect(stdout).toBe('phase-settled\n'); - expect(Date.now() - startedAt).toBeLessThan(500); -}); - -test('retains the lease until a timed-out mutation phase actually settles', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let entered!: () => void; - let settle!: () => void; - const phaseEntered = new Promise((resolve) => { - entered = resolve; - }); - const phaseSettlement = new Promise((resolve) => { - settle = resolve; - }); - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeAppend: async () => { - entered(); - await phaseSettlement; - }, - criticalSectionMs: 20, - ownerSettlementMs: 200, - }, - }); - const second = createTestFileRuntimeKernel({ stateFile }); - - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:late-phase-owner', - path: 'late-phase-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - void firstMutation.catch(() => undefined); - await phaseEntered; - await wait(30); - - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:late-phase-contender', - path: 'late-phase-contender.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, - ).finally(() => { - contenderSettled = true; - }); - await wait(40); - expect(contenderSettled).toBe(false); - - settle(); - await expect(firstMutation).rejects.toThrow('exceeded 20 ms critical-section limit'); - await expect(contender).resolves.toMatchObject({ stateVersion: 1 }); - const settledContents = await readFile(stateFile, 'utf8'); - await wait(30); - expect(await readFile(stateFile, 'utf8')).toBe(settledContents); - expect(settledContents).not.toContain('late-phase-owner.ts'); - expect(settledContents).toContain('late-phase-contender.ts'); -}); - -for (const phase of ['truncate', 'append', 'fsync'] as const) { - test(`does not unlock while a timed-out ${phase} phase is unsettled`, async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - if (phase === 'truncate') { - await writeFile(stateFile, '{"incomplete":true', 'utf8'); - } - let entered!: () => void; - let settle!: () => void; - const phaseEntered = new Promise((resolve) => { - entered = resolve; - }); - const phaseSettlement = new Promise((resolve) => { - settle = resolve; - }); - const barrier = async () => { - entered(); - await phaseSettlement; - }; - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - ...(phase === 'truncate' ? { beforeRepair: barrier } : {}), - ...(phase === 'append' ? { beforeAppendWrite: barrier } : {}), - ...(phase === 'fsync' ? { beforeAppendSync: barrier } : {}), - criticalSectionMs: 20, - ownerSettlementMs: 200, - }, - }); - const second = createTestFileRuntimeKernel({ stateFile }); - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: `test:state:${phase}-owner`, - path: `${phase}-owner.ts`, - sessionId: 'session-1', - toolName: 'Write', - }); - void firstMutation.catch(() => undefined); - await phaseEntered; - await wait(30); - - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: `test:state:${phase}-contender`, - path: `${phase}-contender.ts`, - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, - ).finally(() => { - contenderSettled = true; - }); - await wait(40); - expect(contenderSettled).toBe(false); - - settle(); - await expect(firstMutation).rejects.toThrow('exceeded 20 ms critical-section limit'); - await expect(contender).resolves.toMatchObject({ stateVersion: phase === 'fsync' ? 2 : 1 }); - const contentsAtUnlock = await readFile(stateFile, 'utf8'); - await wait(30); - expect(await readFile(stateFile, 'utf8')).toBe(contentsAtUnlock); - }); -} - -test('keeps contenders excluded until a delayed release settles', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let releaseEntered!: () => void; - let settleRelease!: () => void; - const entered = new Promise((resolve) => { - releaseEntered = resolve; - }); - const settlement = new Promise((resolve) => { - settleRelease = resolve; - }); - const first = createTestFileRuntimeKernel({ - stateFile, - adapter: { - beforeRelease: async () => { - releaseEntered(); - await settlement; - }, - releaseMs: 200, - }, - }); - const second = createTestFileRuntimeKernel({ stateFile }); - const firstMutation = first.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:delayed-release-owner', - path: 'release-owner.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - await entered; - let contenderSettled = false; - const contender = second.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:delayed-release-contender', - path: 'release-contender.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, - ).finally(() => { - contenderSettled = true; - }); - await wait(40); - expect(contenderSettled).toBe(false); - settleRelease(); - await expect(firstMutation).resolves.toMatchObject({ stateVersion: 1 }); - await expect(contender).resolves.toMatchObject({ stateVersion: 2 }); -}); - -test('bounds a stuck release and invokes fatal owner teardown without unlocking', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let fatalError: Error | undefined; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async () => async () => new Promise(() => undefined), - criticalSectionMs: 20, - fatalOwnerTeardown: (error) => { - fatalError = error; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - releaseMs: 20, - }, - }); - - const outcome = await Promise.race([ - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:stuck-release', - path: 'stuck-release.ts', - sessionId: 'session-1', - toolName: 'Write', - }).then(() => 'resolved', (error: unknown) => error), - wait(200).then(() => 'test-timeout'), - ]); - - expect(outcome).toBeInstanceOf(Error); - expect(errorMessages(outcome).some((message) => message.includes('lease release exceeded 20 ms'))).toBe(true); - expect(fatalError?.message).toContain('lease release exceeded 20 ms'); - await expect( - kernel.recordEdit({ - host: 'codex', - idempotencyKey: 'test:state:after-stuck-release', - path: 'after-stuck-release.ts', - sessionId: 'session-2', - toolName: 'apply_patch', - }), - ).rejects.toThrow('permanently poisoned'); -}); - -test('lease compromise cancels its owning mutation while a contender is acquiring', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let allowRead!: () => void; - let compromiseOwner!: (error: Error) => void; - let firstRead = true; - let acquireCount = 0; - const readBarrier = new Promise((resolve) => { - allowRead = resolve; - }); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async ({ onCompromised }) => { - acquireCount += 1; - if (acquireCount === 1) { - compromiseOwner = onCompromised; - return async () => undefined; - } - return new Promise(() => undefined); - }, - beforeRead: async () => { - if (firstRead) { - firstRead = false; - await readBarrier; - } - }, - criticalSectionMs: 500, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - }, - }); - const first = kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:compromise-owner-a', - path: 'owner-a.ts', - sessionId: 'session-a', - toolName: 'Write', - }); - void first.catch(() => undefined); - await wait(10); - const second = kernel.recordEdit( - { - host: 'codex', - idempotencyKey: 'test:state:compromise-contender-b', - path: 'contender-b.ts', - sessionId: 'session-b', - toolName: 'apply_patch', - }, - { lockAcquireTimeoutMs: 500 }, - ); - void second.catch(() => undefined); - await wait(10); - compromiseOwner(new Error('simulated owner compromise')); - - const firstOutcome = await Promise.race([ - first.then(() => 'resolved', (error: unknown) => error), - wait(100).then(() => 'test-timeout'), - ]); - expect(firstOutcome).toBeInstanceOf(Error); - expect((firstOutcome as Error).message).toContain('permanently poisoned'); - await expect(second).rejects.toThrow('permanently poisoned'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); - allowRead(); -}); - -test('rechecks a simultaneous owner abort after a phase value wins and releases exactly once', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const controller = new AbortController(); - let appendAttempts = 0; - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async () => async () => { - releases += 1; - }, - beforeAppend: async () => { - appendAttempts += 1; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - readState: () => { - controller.abort(new Error('simultaneous owner abort')); - return eagerPromise(Buffer.alloc(0)); - }, - }, - }); - - await expect( - kernel.recordEdit( - { - host: 'claude', - idempotencyKey: 'test:state:simultaneous-abort', - path: 'simultaneous-abort.ts', - sessionId: 'session-1', - toolName: 'Write', - }, - { signal: controller.signal }, - ), - ).rejects.toThrow('simultaneous owner abort'); - expect(appendAttempts).toBe(0); - expect(releases).toBe(1); -}); - -test('rechecks simultaneous lease poison after a phase value wins and never enters append', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let appendAttempts = 0; - let compromise!: (error: Error) => void; - let fatalTeardowns = 0; - let releases = 0; - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - acquireLock: async ({ onCompromised }) => { - compromise = onCompromised; - return async () => { - releases += 1; - }; - }, - beforeAppend: async () => { - appendAttempts += 1; - }, - fatalOwnerTeardown: () => { - fatalTeardowns += 1; - }, - prepareStateFile: async ({ stateFile: preparedStateFile }) => { - await writeFile(preparedStateFile, '', 'utf8'); - return preparedStateFile; - }, - readState: () => { - compromise(new Error('simultaneous owner compromise')); - return eagerPromise(Buffer.alloc(0)); - }, - }, - }); - - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:simultaneous-poison', - path: 'simultaneous-poison.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('permanently poisoned'); - expect(appendAttempts).toBe(0); - expect(fatalTeardowns).toBe(1); - expect(releases).toBe(0); -}); - -test('accepts Windows parent-fsync limitations when creating a new state file', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { - platform: 'win32', - syncParent: async () => { - throw Object.assign(new Error('Windows directory sync unsupported'), { code: 'EPERM' }); - }, - }, - }); - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:windows-parent-sync', - path: 'windows.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).resolves.toMatchObject({ stateVersion: 1 }); -}); - -test('rejects oversized snapshots before parsing or allocating their full file size', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'oversized.jsonl'); - await writeFile(stateFile, Buffer.alloc(16 * 1024 * 1024 + 1)); - await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).rejects.toThrow('exceeds 16777216 byte limit'); -}); - -test('rejects invalid writes before creating their state file', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await expect( - createFileRuntimeKernel({ stateFile }).recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:invalid-write', - path: '', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('every event field'); - await expect(access(stateFile)).rejects.toThrow(); -}); - -test('poisons a kernel after lease compromise before it can append or mutate again', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - let entered!: () => void; - let continueAppend!: () => void; - const enteredBeforeAppend = new Promise((resolve) => { - entered = resolve; - }); - const allowAppend = new Promise((resolve) => { - continueAppend = resolve; - }); - const kernel = createTestFileRuntimeKernel({ - stateFile, - adapter: { beforeAppend: async () => { - entered(); - await allowAppend; - } }, - }); - const pending = kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:compromised', - path: 'compromised.ts', - sessionId: 'session-1', - toolName: 'Write', - }); - void pending.catch(() => undefined); - await Promise.race([ - enteredBeforeAppend, - wait(100).then(() => Promise.reject(new Error('test-only append barrier was not reached'))), - ]); - await rm(`${stateFile}.lock`, { force: true, recursive: true }); - await wait(1_100); - continueAppend(); - await expect(pending).rejects.toThrow('lease was compromised'); - await expect( - kernel.recordEdit({ - host: 'claude', - idempotencyKey: 'test:state:after-compromise', - path: 'after-compromise.ts', - sessionId: 'session-1', - toolName: 'Write', - }), - ).rejects.toThrow('permanently poisoned'); - await expect(readFile(stateFile, 'utf8')).resolves.toBe(''); -}); - -test('treats a valid empty JSONL file as an empty snapshot', async () => { - const stateFile = join(await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-')), 'state.jsonl'); - await writeFile(stateFile, '', 'utf8'); - - await expect(createFileRuntimeKernel({ stateFile }).readSnapshot()).resolves.toEqual({ - edits: [], - stateVersion: 0, - }); -}); - -test('exposes the static MCP tools, native hooks, and app resource contract', () => { - expect(runtimeDefinition.tools.map((tool) => tool.name)).toEqual([ - 'recent_edits', - 'render_edit_timeline', - 'runtime_status', - ]); - expect(runtimeDefinition.nativeHooks.map((hook) => hook.matcher)).toEqual([ - 'Write|Edit', - 'apply_patch', - ]); - expect(runtimeDefinition.resources).toMatchObject([ - { - _meta: { - 'openai/widgetDescription': 'Interactive timeline of file edits recorded by agent hooks.', - 'ui.csp': { connectDomains: [], resourceDomains: [] }, - 'ui.prefersBorder': true, - }, - uri: resourceUri, - }, - ]); - expect(runtimeDefinition.tools.map((tool) => tool.annotations)).toEqual([ - readOnlyAnnotations, - readOnlyAnnotations, - readOnlyAnnotations, - ]); - - for (const tool of runtimeDefinition.tools) { - const metadata = tool._meta as { ui?: { resourceUri?: string }; 'openai/outputTemplate'?: string }; - - if (tool.name === 'render_edit_timeline') { - expect(metadata.ui?.resourceUri).toBe(resourceUri); - expect(metadata['openai/outputTemplate']).toBe(resourceUri); - } else { - expect(metadata.ui?.resourceUri).toBeUndefined(); - expect(metadata['openai/outputTemplate']).toBeUndefined(); - } - } -}); - -test('serializes the registry into JSON Schema descriptors without functions', () => { - const serialized = serializeRuntimeDefinition(); - - expect(containsFunction(serialized)).toBe(false); - expect(serialized.tools).toHaveLength(3); - for (const tool of serialized.tools) { - expect(tool.inputSchema).toEqual(expect.any(Object)); - expect(tool.outputSchema).toEqual(expect.any(Object)); - expect(tool.inputSchema.$schema).toBeUndefined(); - expect(tool.outputSchema.$schema).toBeUndefined(); - } -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts deleted file mode 100644 index 8b87a40ad..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/support/copy-example.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { cp, mkdtemp, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -export interface CopiedExample { - readonly projectRoot: string; - readonly workspaceRoot: string; -} - -/** - * Copies the example into a temporary workspace shaped like the repository. - * The example's direct dependencies (zod, @agent-bundle/rsc-runtime) live in - * its own node_modules, not the workspace root's hoisted set, so the copy - * links both. - */ -export const copyExample = async ( - exampleRoot: string, - options: { readonly linkPackages?: boolean; readonly prefix: string }, -): Promise => { - const workspaceRoot = await mkdtemp(join(tmpdir(), options.prefix)); - const projectRoot = join(workspaceRoot, 'examples', 'rsc-agent-runtime'); - await cp(exampleRoot, projectRoot, { - filter: (source) => !['.agent-bundle', 'dist', 'node_modules'].includes(source.split('/').at(-1) ?? ''), - recursive: true, - }); - await symlink(join(exampleRoot, '../../node_modules'), join(workspaceRoot, 'node_modules'), 'dir'); - await symlink(join(exampleRoot, 'node_modules'), join(projectRoot, 'node_modules'), 'dir'); - if (options.linkPackages === true) { - await symlink(join(exampleRoot, '../../packages'), join(workspaceRoot, 'packages'), 'dir'); - } - await symlink(join(exampleRoot, '../../tsconfig.json'), join(workspaceRoot, 'tsconfig.json')); - await symlink(join(exampleRoot, '../../tsconfig.base.json'), join(workspaceRoot, 'tsconfig.base.json')); - return Object.freeze({ projectRoot, workspaceRoot }); -}; diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts deleted file mode 100644 index 81224d9f9..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/tsconfig-coverage.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { expect, test } from '@rstest/core'; - -test('typechecks all TypeScript source and test files, including development materializers', async () => { - const config = JSON.parse(await readFile(join(process.cwd(), 'tsconfig.json'), 'utf8')) as { include: string[] }; - - expect(config.include).toEqual(expect.arrayContaining([ - 'src/**/*.ts', - 'src/**/*.tsx', - 'tests/**/*.ts', - 'tests/**/*.tsx', - ])); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx deleted file mode 100644 index c5c19e6f1..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tests/widget-accessibility.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server'; -import { expect, test } from '@rstest/core'; -import React from 'react'; - -import { RefreshStatus } from '../src/widget/App.js'; - -test('announces timeline refresh and errors through one implicit live region', () => { - const refreshing = renderToStaticMarkup(); - const error = renderToStaticMarkup(); - - expect(refreshing).toContain('class="timeline__status"'); - expect(refreshing).toContain('role="status"'); - expect(refreshing).not.toContain('aria-live='); - expect(refreshing).toContain('Refreshing timeline.'); - expect(error).toContain('Unable to refresh timeline.'); -}); diff --git a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json b/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json deleted file mode 100644 index 67cbb7fcf..000000000 --- a/.runtime-playground-vH2Kdl/examples/rsc-agent-runtime/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": [ - "agent-bundle.config.ts", - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.ts", - "tests/**/*.tsx" - ] -} diff --git a/.runtime-playground-vH2Kdl/node_modules b/.runtime-playground-vH2Kdl/node_modules deleted file mode 120000 index e9526f206..000000000 --- a/.runtime-playground-vH2Kdl/node_modules +++ /dev/null @@ -1 +0,0 @@ -/fast/projects/agent-bundle/node_modules \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/packages b/.runtime-playground-vH2Kdl/packages deleted file mode 120000 index 53a23d560..000000000 --- a/.runtime-playground-vH2Kdl/packages +++ /dev/null @@ -1 +0,0 @@ -/fast/projects/agent-bundle/packages \ No newline at end of file diff --git a/.runtime-playground-vH2Kdl/tsconfig.base.json b/.runtime-playground-vH2Kdl/tsconfig.base.json deleted file mode 100644 index a5e985e32..000000000 --- a/.runtime-playground-vH2Kdl/tsconfig.base.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "allowImportingTsExtensions": true, - "isolatedModules": true, - "jsx": "react-jsx", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "ES2024", - "verbatimModuleSyntax": true - } -} diff --git a/.runtime-playground-vH2Kdl/tsconfig.json b/.runtime-playground-vH2Kdl/tsconfig.json deleted file mode 100644 index 194c678ab..000000000 --- a/.runtime-playground-vH2Kdl/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "types": [ - "node" - ] - }, - "include": [ - "*.ts", - "fixtures/**/*.ts", - "packages/agent-bundle/src/**/*.ts", - "packages/agent-bundle/tests/**/*.ts" - ] -} From e6716a02b9c65d8170563259a17de12e12677907 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 23:11:37 +0000 Subject: [PATCH 08/11] fix(inspector): keep the workspace link manifest out of the vendored closure The vendored inspector core now links as a workspace package through a package.json inside the snapshot, and the sync verifier counted that workspace-owned file as vendored source, failing every CI run with a closure mismatch. It joins package-manager state outside the closure walk, survives a resync, and the fixture test covers both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EMWczsXAkj7fC5ssSxGK43 --- .../tests/native-host-smoke-workflow.test.ts | 62 --------- .../tests/package-lint-workflow.test.ts | 49 ------- .../tests/package-preview-workflow.test.ts | 51 -------- .../agent-bundle/tests/release-audit.test.ts | 1 - .../tests/workspace-contract.test.ts | 123 ------------------ packages/workbench/src/inspector/package.json | 11 ++ .../src/inspector/vendor/core/package.json | 11 -- .../workbench/tests/sync-inspector.test.ts | 31 +++++ pnpm-lock.yaml | 4 +- pnpm-workspace.yaml | 2 +- rstest.integration-tests.ts | 1 - scripts/sync-inspector.mjs | 20 ++- 12 files changed, 64 insertions(+), 302 deletions(-) delete mode 100644 packages/agent-bundle/tests/native-host-smoke-workflow.test.ts delete mode 100644 packages/agent-bundle/tests/package-lint-workflow.test.ts delete mode 100644 packages/agent-bundle/tests/package-preview-workflow.test.ts delete mode 100644 packages/agent-bundle/tests/workspace-contract.test.ts create mode 100644 packages/workbench/src/inspector/package.json delete mode 100644 packages/workbench/src/inspector/vendor/core/package.json diff --git a/packages/agent-bundle/tests/native-host-smoke-workflow.test.ts b/packages/agent-bundle/tests/native-host-smoke-workflow.test.ts deleted file mode 100644 index 17593f26a..000000000 --- a/packages/agent-bundle/tests/native-host-smoke-workflow.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { expect, it } from '@rstest/core'; -import { parse as parseYaml } from 'yaml'; - -const workflowUrl = new URL('../../../.github/workflows/native-host-smoke.yml', import.meta.url); -const packageUrl = new URL('../../../package.json', import.meta.url); -const launcherUrl = new URL('../../../scripts/run-packed-native-smoke.mjs', import.meta.url); - -interface NativeSmokeMatrixRow { - readonly host: string; - readonly source_tests: string; - readonly packed_command: string; -} - -it('keeps source and installed-tarball native smokes in the manual self-hosted matrix', async () => { - const [workflow, packageBytes, launcher] = await Promise.all([ - readFile(workflowUrl, 'utf8'), - readFile(packageUrl, 'utf8'), - readFile(launcherUrl, 'utf8'), - ]); - const packageDocument = JSON.parse(packageBytes) as { readonly scripts?: Readonly> }; - const parsed = parseYaml(workflow) as { - readonly on?: { readonly workflow_dispatch?: unknown }; - readonly jobs?: { - readonly ['native-host-smoke']?: { - readonly ['runs-on']?: string; - readonly strategy?: { readonly matrix?: { readonly include?: readonly NativeSmokeMatrixRow[] } }; - }; - }; - }; - const matrix = parsed.jobs?.['native-host-smoke']?.strategy?.matrix?.include; - - expect(parsed.on?.workflow_dispatch).toBeDefined(); - expect(parsed.jobs?.['native-host-smoke']?.['runs-on']).toBe('self-hosted'); - expect(matrix).toHaveLength(2); - expect(matrix).toEqual(expect.arrayContaining([ - { - host: 'claude', - source_tests: 'packages/agent-bundle/tests/native-claude-contract.test.ts packages/agent-bundle/tests/eval-claude-harness.test.ts', - packed_command: 'pnpm test:packed:native:claude', - }, - { - host: 'codex', - source_tests: 'packages/agent-bundle/tests/native-codex-contract.test.ts packages/agent-bundle/tests/eval-codex-home.test.ts', - packed_command: 'pnpm test:packed:native:codex', - }, - ])); - expect(workflow).toContain("AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE: ${{ matrix.host == 'claude' && '1' || '' }}"); - expect(workflow).toContain("AGENT_BUNDLE_NATIVE_CODEX_SMOKE: ${{ matrix.host == 'codex' && '1' || '' }}"); - - expect(workflow).not.toMatch(/\b(?:push|pull_request):/u); - expect(workflow).not.toMatch(/\bsecrets\./u); - expect(workflow).not.toMatch(/API[_-]?KEY/iu); - expect(packageDocument.scripts?.['test:packed:native:claude']).toBe('pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CLAUDE_SMOKE=1 pnpm test:packed:native'); - expect(packageDocument.scripts?.['test:packed:native:codex']).toBe('pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CODEX_SMOKE=1 pnpm test:packed:native'); - expect(launcher).toContain("process.platform === 'win32' ? 'npm.cmd' : 'npm'"); - expect(launcher).toContain("spawn(npm, args, { env: environment, stdio: 'inherit' })"); - expect(launcher).not.toMatch(/(?:^|\s)AGENT_BUNDLE_PACKED_NATIVE_[A-Z_]+=1\s+npm/u); - expect(workflow).not.toMatch(/\bcorepack\b/u); - expect(workflow).toContain('uses: pnpm/setup@v2'); -}); diff --git a/packages/agent-bundle/tests/package-lint-workflow.test.ts b/packages/agent-bundle/tests/package-lint-workflow.test.ts deleted file mode 100644 index 78062290b..000000000 --- a/packages/agent-bundle/tests/package-lint-workflow.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { expect, it } from '@rstest/core'; -import { parse as parseYaml } from 'yaml'; - -const packageUrl = new URL('../../../package.json', import.meta.url); -const workflowUrl = new URL('../../../.github/workflows/ci.yml', import.meta.url); - -interface WorkflowStep { - readonly name?: string; - readonly run?: string; - readonly uses?: string; - readonly with?: Readonly>; -} - -it('runs publint explicitly in CI and the local release audit', async () => { - const [packageText, workflow] = await Promise.all([ - readFile(packageUrl, 'utf8'), - readFile(workflowUrl, 'utf8'), - ]); - const packageJson = JSON.parse(packageText) as { - readonly scripts?: Readonly>; - }; - const parsed = parseYaml(workflow) as { - readonly jobs?: { - readonly 'rsc-runtime-micro-eval'?: { readonly steps?: readonly WorkflowStep[] }; - readonly verify?: { readonly steps?: readonly WorkflowStep[] }; - }; - }; - const steps = parsed.jobs?.verify?.steps ?? []; - const rscSteps = parsed.jobs?.['rsc-runtime-micro-eval']?.steps ?? []; - const packageLintIndex = steps.findIndex((step) => step.run === 'pnpm lint:package'); - const setup = steps.find((step) => step.uses === 'pnpm/setup@v2'); - - expect(packageJson.scripts?.['lint:package']).toBe('publint packages/agent-bundle'); - expect(packageJson.scripts?.['audit:release']).toMatch(/^pnpm lint:package && /u); - expect(setup?.with).toEqual({ cache: true, install: false, runtime: 'node@${{ matrix.node-version }}' }); - expect(packageLintIndex).toBeGreaterThan(0); - expect(steps[packageLintIndex]).toEqual({ name: 'Package lint (publint)', run: 'pnpm lint:package' }); - expect(steps[packageLintIndex - 1]?.run).toBe('pnpm build'); - expect(rscSteps.map((step) => step.uses ?? step.run)).toEqual([ - 'actions/checkout@v7', - 'pnpm/setup@v2', - 'pnpm install --frozen-lockfile', - 'pnpm eval:spot', - ]); - expect(rscSteps[1]?.with).toEqual({ cache: true, install: false, runtime: 'node@22.19.0' }); - expect(workflow).not.toMatch(/\bcorepack\b/u); -}); diff --git a/packages/agent-bundle/tests/package-preview-workflow.test.ts b/packages/agent-bundle/tests/package-preview-workflow.test.ts deleted file mode 100644 index 00de1e461..000000000 --- a/packages/agent-bundle/tests/package-preview-workflow.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { expect, it } from '@rstest/core'; -import { parse as parseYaml } from 'yaml'; - -const packageUrl = new URL('../../../package.json', import.meta.url); -const workflowUrl = new URL('../../../.github/workflows/package-preview.yml', import.meta.url); - -interface WorkflowStep { - readonly run?: string; - readonly uses?: string; - readonly with?: Readonly>; -} - -it('publishes one locked package preview for pull requests', async () => { - const [packageText, workflow] = await Promise.all([ - readFile(packageUrl, 'utf8'), - readFile(workflowUrl, 'utf8'), - ]); - const packageJson = JSON.parse(packageText) as { - readonly devDependencies?: Readonly>; - readonly scripts?: Readonly>; - }; - const parsed = parseYaml(workflow) as { - readonly on?: Readonly>; - readonly permissions?: Readonly>; - readonly jobs?: { - readonly publish?: { - readonly steps?: readonly WorkflowStep[]; - }; - }; - }; - const steps = parsed.jobs?.publish?.steps ?? []; - - expect(Object.keys(parsed.on ?? {})).toEqual(['pull_request', 'push']); - expect((parsed.on as Readonly>)['push']).toEqual({ branches: ['main'] }); - expect(parsed.permissions).toEqual({}); - expect(steps.map((step) => step.uses ?? step.run)).toEqual([ - 'actions/checkout@v7', - 'pnpm/setup@v2', - 'pnpm install --frozen-lockfile', - 'pnpm build', - 'pnpm preview:publish', - ]); - expect(steps[1]?.with).toEqual({ cache: true, install: false, runtime: 'node@22.19.0' }); - expect(packageJson.devDependencies?.['pkg-pr-new']).toBe('0.0.88'); - expect(packageJson.scripts?.['preview:publish']).toBe( - "pkg-pr-new publish --previewVersion --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime'", - ); - expect(workflow).not.toMatch(/pull_request_target|secrets\.|\b(?:corepack|npx)\b/u); -}); diff --git a/packages/agent-bundle/tests/release-audit.test.ts b/packages/agent-bundle/tests/release-audit.test.ts index 0193c7319..30e0dab66 100644 --- a/packages/agent-bundle/tests/release-audit.test.ts +++ b/packages/agent-bundle/tests/release-audit.test.ts @@ -99,7 +99,6 @@ it('ships repository and support metadata that matches the verified origin', asy expect(manifest).toMatchObject({ bugs: { url: 'https://github.com/ScriptedAlchemy/agent-bundle/issues' }, - description: 'Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts.', homepage: 'https://github.com/ScriptedAlchemy/agent-bundle#readme', repository: { type: 'git', url: 'git+https://github.com/ScriptedAlchemy/agent-bundle.git' }, }); diff --git a/packages/agent-bundle/tests/workspace-contract.test.ts b/packages/agent-bundle/tests/workspace-contract.test.ts deleted file mode 100644 index 5e6244750..000000000 --- a/packages/agent-bundle/tests/workspace-contract.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { execFile as executeFile } from 'node:child_process'; -import { access, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -import { expect, it } from '@rstest/core'; - -import { integrationTestFiles } from '../../../rstest.integration-tests.ts'; - -const execFile = promisify(executeFile); - -it('selects product packages through the pinned pnpm workspace', async () => { - const { stdout } = await execFile('pnpm', [ - '--recursive', - '--depth', - '-1', - 'list', - '--json', - ], { cwd: process.cwd() }); - const documents = stdout.trim().split(/\n\]\s*\n\[\n/u).map((document, index, all) => { - const opening = index === 0 ? '' : '[\n'; - const closing = index === all.length - 1 ? '' : '\n]'; - return JSON.parse(`${opening}${document}${closing}`) as readonly { - name: string; - path: string; - private?: boolean; - }[]; - }); - const packages = documents.flat(); - - expect(packages.map(({ name }) => name).sort()).toEqual([ - '@agent-bundle-example/audiobook-curator', - '@agent-bundle-example/hooks-and-scripts', - '@agent-bundle-example/mcp-app', - '@agent-bundle-example/skills-starter', - '@agent-bundle/rsc-agent-runtime-demo', - '@agent-bundle/rsc-runtime', - 'agent-bundle', - 'agent-bundle-workbench', - 'agent-bundle-workspace', - ]); - - const examples = packages.filter(({ name }) => name.startsWith('@agent-bundle-example/')); - expect(examples.every(({ private: isPrivate }) => isPrivate === true)).toBe(true); - await Promise.all(examples.map(async ({ path }) => { - const manifest = JSON.parse(await readFile(join(path, 'package.json'), 'utf8')) as { - readonly devDependencies?: Readonly>; - readonly scripts?: Readonly>; - }; - expect(manifest.devDependencies?.['agent-bundle']).toBe('workspace:*'); - if (path.endsWith('/audiobook-curator')) { - expect(manifest.scripts).toEqual({ - build: 'pnpm build:cli && pnpm build:bundle', - 'build:bundle': 'agent-bundle build --json --output artifact', - 'build:cli': 'rslib build', - check: 'pnpm test && pnpm typecheck && pnpm build', - dev: 'agent-bundle dev', - test: 'rstest tests', - typecheck: 'tsc -p tsconfig.build.json --noEmit', - validate: 'agent-bundle validate --json', - }); - return; - } - expect(manifest.scripts).toEqual({ - build: 'agent-bundle build --json', - check: 'pnpm validate && pnpm build', - dev: 'agent-bundle dev', - validate: 'agent-bundle validate --json', - }); - })); - - const rootManifest = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as { - readonly devDependencies?: Readonly>; - readonly scripts?: Readonly>; - }; - const agentBundleManifest = JSON.parse( - await readFile(join(process.cwd(), 'packages/agent-bundle/package.json'), 'utf8'), - ) as { - readonly bin?: Readonly>; - readonly files?: readonly string[]; - readonly scripts?: Readonly>; - }; - const workbenchManifest = JSON.parse( - await readFile(join(process.cwd(), 'packages/workbench/package.json'), 'utf8'), - ) as { - readonly dependencies?: Readonly>; - }; - expect(rootManifest.devDependencies).toMatchObject({ - '@modelcontextprotocol/server': '2.0.0', - 'agent-bundle': 'workspace:*', - 'playwright-core': '1.62.1', - }); - expect(rootManifest.scripts).toMatchObject({ - build: 'pnpm --filter agent-bundle build && pnpm --filter @agent-bundle/rsc-runtime build', - check: 'pnpm build && pnpm test:unit && pnpm test:integration:run && pnpm lint && pnpm typecheck', - 'eval:spot': 'pnpm build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec rstest run tests/micro-eval.spot.test.ts --config rstest.config.ts', - 'example:hooks': 'pnpm build && pnpm --filter @agent-bundle-example/hooks-and-scripts dev', - 'example:mcp-app': 'pnpm build && pnpm --filter @agent-bundle-example/mcp-app dev', - 'example:skills': 'pnpm build && pnpm --filter @agent-bundle-example/skills-starter dev', - 'examples:check': "pnpm build && pnpm --filter './examples/*' --workspace-concurrency=1 check", - 'test:integration': 'pnpm --filter agent-bundle-workbench build && pnpm test:integration:run', - 'test:integration:run': 'AGENT_BUNDLE_WORKBENCH_PREBUILT=1 rstest --config rstest.integration.config.ts --pool.maxWorkers 1', - }); - expect(rootManifest.scripts).not.toHaveProperty('build:workbench'); - expect(agentBundleManifest.scripts).toEqual({ - build: 'pnpm build:workbench && rslib build', - 'build:workbench': 'pnpm --filter agent-bundle-workbench build', - }); - expect(agentBundleManifest.bin).toEqual({ 'agent-bundle': './bin/agent-bundle.js' }); - expect(agentBundleManifest.files).toContain('bin'); - expect(workbenchManifest.dependencies?.['@modelcontextprotocol/sdk']).toBe('1.30.0'); - await expect(access(join(process.cwd(), 'packages/agent-bundle/bin/agent-bundle.js'))).resolves.toBeUndefined(); - - await expect(access(join(process.cwd(), 'packages/agent-bundle/rslib.config.ts'))).resolves.toBeUndefined(); - await expect(access(join(process.cwd(), 'rslib.config.ts'))).rejects.toMatchObject({ code: 'ENOENT' }); - - expect(integrationTestFiles).toEqual(expect.arrayContaining([ - 'packages/agent-bundle/tests/examples-contract.test.ts', - 'packages/agent-bundle/tests/workspace-contract.test.ts', - 'packages/workbench/tests/examples-real.e2e.test.ts', - ])); - expect(integrationTestFiles).not.toContain('packages/agent-bundle/tests/package-preview-workflow.test.ts'); -}); diff --git a/packages/workbench/src/inspector/package.json b/packages/workbench/src/inspector/package.json new file mode 100644 index 000000000..2c1f6dadb --- /dev/null +++ b/packages/workbench/src/inspector/package.json @@ -0,0 +1,11 @@ +{ + "name": "@inspector/core", + "version": "0.0.0", + "private": true, + "description": "Links the vendored MCP Inspector core so `@inspector/core/*` specifiers resolve through the package manager instead of per-config aliases. Lives beside UPSTREAM.json rather than inside vendor/, which stays a byte-exact provenance snapshot.", + "type": "module", + "exports": { + "./*.js": "./vendor/core/*.ts", + "./*": "./vendor/core/*" + } +} diff --git a/packages/workbench/src/inspector/vendor/core/package.json b/packages/workbench/src/inspector/vendor/core/package.json deleted file mode 100644 index c64e679ae..000000000 --- a/packages/workbench/src/inspector/vendor/core/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@inspector/core", - "version": "0.0.0", - "private": true, - "description": "Vendored MCP Inspector core, linked so `@inspector/core/*` specifiers resolve through the package manager instead of per-config aliases.", - "type": "module", - "exports": { - "./*.js": "./*.ts", - "./*": "./*" - } -} diff --git a/packages/workbench/tests/sync-inspector.test.ts b/packages/workbench/tests/sync-inspector.test.ts index 77d64f934..00d39bb7e 100644 --- a/packages/workbench/tests/sync-inspector.test.ts +++ b/packages/workbench/tests/sync-inspector.test.ts @@ -294,6 +294,37 @@ it('verifies the checked-in Inspector snapshot provenance and patches', async () }); }); +it('keeps the workspace link manifest out of the vendored closure and across resyncs', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspector-link-')); + const source = join(root, 'source'); + const output = join(root, 'inspector'); + await mkdir(join(source, 'src'), { recursive: true }); + await Promise.all([ + writeFile(join(source, 'LICENSE'), 'MIT fixture license\n'), + writeFile(join(source, 'package.json'), JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '2.0.0' }, + name: 'inspector-fixture', + version: '2.2.0', + }, null, 2)), + writeFile(join(source, 'src', 'entry.tsx'), "export const inspectorFixture = 'Inspector';\n"), + ]); + const commit = await commitFixtureSource(source); + const syncArguments = [ + '--source', source, '--out', output, '--commit', commit, '--entry', 'src/entry.tsx', + '--dependency', 'react', '--mcp-sdk-version', '2.0.0', '--version', '2.2.0', + ]; + await expect(sync(syncArguments)).resolves.toMatchObject({ stderr: '' }); + + const linkManifest = '{"name":"@inspector/core","private":true}\n'; + await mkdir(join(output, 'vendor', 'core'), { recursive: true }); + await writeFile(join(output, 'vendor', 'core', 'package.json'), linkManifest); + await expect(sync(['--verify', '--out', output])).resolves.toMatchObject({ stderr: '' }); + await expect(sync(syncArguments)).resolves.toMatchObject({ stderr: '' }); + await expect(readFile(join(output, 'vendor', 'core', 'package.json'), 'utf8')).resolves.toBe(linkManifest); + const manifest = JSON.parse(await readFile(join(output, 'UPSTREAM.json'), 'utf8')) as UpstreamManifest; + expect(manifest.files.map((file) => file.path)).not.toContain('core/package.json'); +}); + it('keeps Inspector network sync behind an explicit maintainer command', async () => { const packageJson = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8')) as { readonly scripts: Readonly>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59fc1730c..3eb8e4e4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,7 +330,7 @@ importers: devDependencies: '@inspector/core': specifier: workspace:* - version: link:src/inspector/vendor/core + version: link:src/inspector '@rsbuild/core': specifier: 2.2.1 version: 2.2.1 @@ -344,7 +344,7 @@ importers: specifier: 19.2.5 version: 19.2.5(@types/react@19.2.18) - packages/workbench/src/inspector/vendor/core: {} + packages/workbench/src/inspector: {} packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bbf319141..38069cd00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - packages/* - - packages/workbench/src/inspector/vendor/core + - packages/workbench/src/inspector - examples/* allowBuilds: '@google/genai': false diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 83346d35c..c6427fc05 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -46,7 +46,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/script-playground-service.test.ts', 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', - 'packages/agent-bundle/tests/workspace-contract.test.ts', 'packages/workbench/tests/artifacts-real.e2e.test.ts', 'packages/workbench/tests/comparisons-page-client-scope-browser.test.ts', 'packages/workbench/tests/evals-real.e2e.test.ts', diff --git a/scripts/sync-inspector.mjs b/scripts/sync-inspector.mjs index 0e7ced098..6afd61af6 100644 --- a/scripts/sync-inspector.mjs +++ b/scripts/sync-inspector.mjs @@ -299,13 +299,25 @@ const collectClosure = async ({ aliases, dependencies, entries, publicImports, r return { externalImports, files, imports }; }; +// The vendored core links as a workspace package through this manifest. It is +// workspace-owned rather than upstream source, so it stays out of the closure +// (like package-manager state) and survives a resync. +const workspaceLinkManifest = 'core/package.json'; + const listFiles = async (root, prefix = '') => { const entries = await readdir(join(root, prefix), { withFileTypes: true }); const paths = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + // The vendored core links as a workspace package, so an install can leave + // package-manager state inside the snapshot; only sources join the closure. + if (entry.name === 'node_modules') continue; const path = join(prefix, entry.name); if (entry.isDirectory()) paths.push(...(await listFiles(root, path))); - else if (entry.isFile()) paths.push(normalizeRelativePath(path, 'vendored file')); + else if (entry.isFile()) { + const relativePath = normalizeRelativePath(path, 'vendored file'); + if (relativePath === workspaceLinkManifest) continue; + paths.push(relativePath); + } } return paths; }; @@ -482,6 +494,8 @@ const syncSnapshot = async (options) => { : await readFile(fallbackLicensePath); const patches = await patchRecords(join(output, 'patches')); + const linkManifestPath = join(output, 'vendor', workspaceLinkManifest); + const linkManifest = (await exists(linkManifestPath)) ? await readFile(linkManifestPath) : undefined; await rm(join(output, 'vendor'), { force: true, recursive: true }); await mkdir(join(output, 'vendor'), { recursive: true }); for (const [path] of [...closure.files].sort(([left], [right]) => left.localeCompare(right))) { @@ -490,6 +504,10 @@ const syncSnapshot = async (options) => { await mkdir(dirname(targetPath), { recursive: true }); await copyFile(sourcePath, targetPath); } + if (linkManifest !== undefined) { + await mkdir(dirname(linkManifestPath), { recursive: true }); + await writeFile(linkManifestPath, linkManifest); + } await applyPatches({ output, patches }); const patchedClosure = await collectClosure({ From ae91004ed5d40c5b901fbce22ea04bc96a757efd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 29 Aug 2026 01:34:01 +0000 Subject: [PATCH 09/11] feat(workbench): replace the vendored MCP Inspector with an on-demand launcher Drop the vendored inspector source tree, the sync-inspector machinery, and the Mantine/react-icons/syntax-highlighter dependency surface (~737 kB less workbench JS). The MCP page keeps a single playground presentation; the only surviving derived code is the MIT-attributed MCP App renderer at src/mcp/app-renderer.tsx. Protocol inspection moves to the standalone Inspector app through opt-in /api/inspector/status and /api/inspector/launch dev-server routes that spawn @modelcontextprotocol/inspector via npx on demand and return its tokenized URL. --- .changeset/remove-vendored-inspector.md | 13 + docs/architecture/rsc-runtime-workbench.md | 20 +- .../tests/dev-provider.integration.test.ts | 21 +- package.json | 3 +- .../agent-bundle/src/dev/foreground-server.ts | 10 + .../src/dev/inspector-launcher.ts | 304 +++++ .../agent-bundle/src/dev/inspector-routes.ts | 115 ++ .../agent-bundle/src/dev/workbench-server.ts | 16 +- .../tests/dev-workbench-packaging.test.ts | 16 +- .../tests/inspector-launcher.test.ts | 191 +++ .../tests/inspector-routes.test.ts | 175 +++ .../tests/playground-service.test.ts | 5 +- .../tests/rsc-runtime-topology-script.test.ts | 4 +- packages/workbench/THIRD_PARTY_NOTICES | 10 +- packages/workbench/package.json | 6 - packages/workbench/rsbuild.config.ts | 4 +- .../scripts/capture-runtime-playground.mjs | 2 +- packages/workbench/src/inspector/PATCHES.md | 19 - .../workbench/src/inspector/UPSTREAM.json | 575 --------- .../inspector/adapter/closure-screens.d.ts | 9 - .../adapter/inspector-closure-vendor.d.ts | 84 -- .../adapter/inspector-closure-vendor.js | 15 - .../inspector-session-adapter-entry.ts | 6 - .../inspector-session-adapter-fixture.tsx | 113 -- .../inspector-session-adapter-model.ts | 109 -- .../inspector-session-adapter-vendor.d.ts | 156 --- .../inspector-session-adapter-vendor.js | 11 - .../adapter/inspector-session-adapter.css | 31 - .../adapter/inspector-session-adapter.tsx | 345 ------ .../protocol-screen-without-replay.tsx | 101 -- .../adapter/vendor-react-runtime.d.ts | 1 - .../adapter/vendor-react-runtime.jsx | 6 - .../src/inspector/adapter/vendor-screens.d.ts | 7 - .../src/inspector/adapter/vendor-screens.jsx | 5 - packages/workbench/src/inspector/package.json | 11 - .../workbench/src/inspector/patches/.gitkeep | 1 - .../001-rstest-inspector-tabs-import.patch | 9 - .../002-remove-legacy-sse-mcp-types.patch | 32 - .../AnnotationBadge/AnnotationBadge.tsx | 69 -- .../elements/AppRenderer/AppRenderer.tsx | 538 --------- .../AppRenderer/createAppBridgeFactory.ts | 343 ------ .../elements/AppRenderer/hostContext.ts | 161 --- .../elements/CategoryBadge/CategoryBadge.tsx | 34 - .../elements/ClearButton/ClearButton.tsx | 14 - .../elements/CodeHighlight/CodeHighlight.tsx | 168 --- .../elements/ContentViewer/BinaryNotice.tsx | 26 - .../elements/ContentViewer/ContentViewer.tsx | 409 ------- .../elements/ContentViewer/CsvTable.tsx | 83 -- .../elements/ContentViewer/HtmlFrame.tsx | 41 - .../elements/ContentViewer/PdfFrame.tsx | 45 - .../ContentViewer/contentViewerUtils.ts | 219 ---- .../elements/ContentViewer/useObjectUrl.ts | 40 - .../elements/CopyButton/CopyButton.tsx | 38 - .../EmbeddableScrollArea.tsx | 79 -- .../components/elements/EraBadge/EraBadge.tsx | 19 - .../components/elements/EraBadge/eraUtils.ts | 15 - .../elements/ExpandToggle/ExpandToggle.tsx | 49 - .../FilterToggleButton/FilterToggleButton.tsx | 45 - .../ListChangedIndicator.tsx | 38 - .../elements/ListLoadError/ListLoadError.tsx | 65 - .../ListPaginationControls.tsx | 86 -- .../elements/ListToggle/ListToggle.tsx | 48 - .../components/elements/LogEntry/LogEntry.tsx | 124 -- .../elements/LogLevelBadge/LogLevelBadge.tsx | 37 - .../elements/McpErrorBadge/McpErrorBadge.tsx | 55 - .../elements/MessageBubble/MessageBubble.tsx | 94 -- .../MessageDirectionBadge.tsx | 43 - .../elements/MethodBadge/MethodBadge.tsx | 21 - .../elements/PinToggle/PinToggle.tsx | 31 - .../ProgressDisplay/ProgressDisplay.tsx | 50 - .../elements/ReplayButton/ReplayButton.tsx | 29 - .../ResourceLinkInfo/ResourceLinkInfo.tsx | 118 -- .../elements/SortToggle/SortToggle.tsx | 49 - .../SubscribeButton/SubscribeButton.tsx | 22 - .../SubscriptionStreamBadge.tsx | 33 - .../subscriptionStreamUtils.ts | 53 - .../elements/accessibleTextColor.ts | 16 - .../components/elements/filledBadgeColor.ts | 16 - .../groups/AppControls/AppControls.tsx | 92 -- .../groups/AppDetailPanel/AppDetailPanel.tsx | 98 -- .../groups/AppListItem/AppListItem.tsx | 70 -- .../groups/LogControls/LogControls.tsx | 204 ---- .../groups/LogStreamPanel/LogStreamPanel.tsx | 136 --- .../MessageDirectionFilter.tsx | 69 -- .../MrtrConversation/MrtrConversation.tsx | 195 --- .../NetworkControls/NetworkControls.tsx | 67 -- .../groups/NetworkEntry/NetworkEntry.tsx | 628 ---------- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 180 --- .../PromptArgumentsForm.tsx | 247 ---- .../groups/PromptControls/PromptControls.tsx | 102 -- .../groups/PromptListItem/PromptListItem.tsx | 43 - .../PromptMessagesDisplay.tsx | 95 -- .../ProtocolControls/ProtocolControls.tsx | 74 -- .../groups/ProtocolEntry/ProtocolEntry.tsx | 496 -------- .../ProtocolListPanel/ProtocolListPanel.tsx | 422 ------- .../ResourceControls/ResourceControls.tsx | 358 ------ .../groups/ResourceLink/ResourceLink.tsx | 151 --- .../ResourceListItem/ResourceListItem.tsx | 32 - .../ResourcePreviewPanel.tsx | 299 ----- .../ResourceSubscribedItem.tsx | 67 -- .../ResourceTemplatePanel.tsx | 308 ----- .../groups/SchemaForm/SchemaForm.tsx | 404 ------- .../StructuredOutputPanel.tsx | 102 -- .../groups/ToolControls/ToolControls.tsx | 197 --- .../ToolDetailPanel/ToolDetailPanel.tsx | 353 ------ .../groups/ToolListItem/ToolListItem.tsx | 64 - .../ToolResultPanel/ToolCallErrorPanel.tsx | 101 -- .../ToolResultPanel/ToolResultPanel.tsx | 297 ----- .../groups/ToolResultPanel/toolResultUtils.ts | 55 - .../src/components/groups/protocolUtils.ts | 154 --- .../screens/AppsScreen/AppsScreen.tsx | 795 ------------ .../screens/LoggingScreen/LoggingScreen.tsx | 132 -- .../screens/LoggingScreen/logLevels.ts | 27 - .../screens/NetworkScreen/NetworkScreen.tsx | 127 -- .../screens/NetworkScreen/fetchCategories.ts | 15 - .../screens/PromptsScreen/PromptsScreen.tsx | 329 ----- .../screens/ProtocolScreen/ProtocolScreen.tsx | 169 --- .../ResourcesScreen/ResourcesScreen.tsx | 392 ------ .../screens/ToolsScreen/ToolsScreen.tsx | 275 ----- .../clients/web/src/hooks/useScrollMemory.ts | 43 - .../clients/web/src/hooks/useValueChange.ts | 44 - .../clients/web/src/lib/downloadFile.ts | 106 -- .../web/src/utils/inspectorTabs.test.ts | 38 - .../clients/web/src/utils/inspectorTabs.ts | 24 - .../vendor/clients/web/src/utils/jsonUtils.ts | 316 ----- .../clients/web/src/utils/maskSecrets.ts | 181 --- .../web/src/utils/mcpNetworkHeaders.ts | 432 ------- .../web/src/utils/oauthNetworkPhase.ts | 62 - .../clients/web/src/utils/sandbox-csp.ts | 146 --- .../vendor/clients/web/src/utils/toolUtils.ts | 21 - .../inspector/vendor/core/auth/providers.ts | 356 ------ .../src/inspector/vendor/core/auth/storage.ts | 244 ---- .../src/inspector/vendor/core/auth/types.ts | 139 --- .../src/inspector/vendor/core/auth/utils.ts | 224 ---- .../src/inspector/vendor/core/client/types.ts | 55 - .../inspector/vendor/core/json/jsonUtils.ts | 110 -- .../inspector/vendor/core/json/xMcpHeader.ts | 344 ------ .../inspector/vendor/core/logging/logger.ts | 39 - .../vendor/core/mcp/fetchTracking.ts | 411 ------- .../src/inspector/vendor/core/mcp/types.ts | 1062 ----------------- packages/workbench/src/main.tsx | 147 +-- .../APP-RENDERER-LICENSE} | 0 packages/workbench/src/mcp/app-renderer.tsx | 521 ++++++++ packages/workbench/src/mcp/mcp-app-frame.tsx | 2 +- .../workbench/src/mcp/mcp-app-preview.tsx | 4 +- .../adapter => mcp}/runtime-app-bridge.ts | 22 +- packages/workbench/src/runtime-evidence.tsx | 45 + packages/workbench/src/runtime-inspector.tsx | 6 +- packages/workbench/src/styles.css | 35 +- .../inspector-modern-mcp-types.consumer.ts | 31 - .../tests/inspector-modern-mcp-types.test.ts | 26 - .../inspector-session-adapter-fixture.test.ts | 172 --- .../tests/inspector-session-adapter.test.ts | 355 ------ .../tests/inspector-shell.e2e.test.ts | 261 ---- .../workbench/tests/mcp-app-frame.test.ts | 10 +- .../tests/mcp-app-preview-browser.test.ts | 3 +- .../workbench/tests/mcp-app-preview.test.ts | 2 +- .../tests/mcp-page-app-browser.test.ts | 3 +- packages/workbench/tests/overview.e2e.test.ts | 7 +- .../tests/packed-release.e2e.test.ts | 8 +- .../workbench/tests/rsbuild-workbench.test.ts | 4 +- .../tests/runtime-app-bridge.test.ts | 2 +- .../tests/runtime-contract-compile.test.ts | 6 +- .../tests/runtime-playground.e2e.test.ts | 13 +- .../support/workbench-browser-modules.ts | 6 - .../workbench/tests/sync-inspector.test.ts | 338 ------ packages/workbench/tsconfig.json | 3 - pnpm-lock.yaml | 424 ------- pnpm-workspace.yaml | 1 - rslint.config.ts | 1 - rstest.config.ts | 1 - rstest.integration-tests.ts | 4 - rstest.unit.config.ts | 1 - scripts/rsc-runtime-topology.mjs | 10 +- scripts/sync-inspector.mjs | 576 --------- 175 files changed, 1517 insertions(+), 20375 deletions(-) create mode 100644 .changeset/remove-vendored-inspector.md create mode 100644 packages/agent-bundle/src/dev/inspector-launcher.ts create mode 100644 packages/agent-bundle/src/dev/inspector-routes.ts create mode 100644 packages/agent-bundle/tests/inspector-launcher.test.ts create mode 100644 packages/agent-bundle/tests/inspector-routes.test.ts delete mode 100644 packages/workbench/src/inspector/PATCHES.md delete mode 100644 packages/workbench/src/inspector/UPSTREAM.json delete mode 100644 packages/workbench/src/inspector/adapter/closure-screens.d.ts delete mode 100644 packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts delete mode 100644 packages/workbench/src/inspector/adapter/inspector-closure-vendor.js delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter.css delete mode 100644 packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx delete mode 100644 packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx delete mode 100644 packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts delete mode 100644 packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx delete mode 100644 packages/workbench/src/inspector/adapter/vendor-screens.d.ts delete mode 100644 packages/workbench/src/inspector/adapter/vendor-screens.jsx delete mode 100644 packages/workbench/src/inspector/package.json delete mode 100644 packages/workbench/src/inspector/patches/.gitkeep delete mode 100644 packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch delete mode 100644 packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts delete mode 100644 packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/auth/providers.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/auth/storage.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/auth/types.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/auth/utils.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/client/types.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/logging/logger.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts delete mode 100644 packages/workbench/src/inspector/vendor/core/mcp/types.ts rename packages/workbench/src/{inspector/LICENSE.inspector => mcp/APP-RENDERER-LICENSE} (100%) create mode 100644 packages/workbench/src/mcp/app-renderer.tsx rename packages/workbench/src/{inspector/adapter => mcp}/runtime-app-bridge.ts (97%) create mode 100644 packages/workbench/src/runtime-evidence.tsx delete mode 100644 packages/workbench/tests/inspector-modern-mcp-types.consumer.ts delete mode 100644 packages/workbench/tests/inspector-modern-mcp-types.test.ts delete mode 100644 packages/workbench/tests/inspector-session-adapter-fixture.test.ts delete mode 100644 packages/workbench/tests/inspector-session-adapter.test.ts delete mode 100644 packages/workbench/tests/inspector-shell.e2e.test.ts delete mode 100644 packages/workbench/tests/sync-inspector.test.ts delete mode 100644 scripts/sync-inspector.mjs diff --git a/.changeset/remove-vendored-inspector.md b/.changeset/remove-vendored-inspector.md new file mode 100644 index 000000000..8668e8466 --- /dev/null +++ b/.changeset/remove-vendored-inspector.md @@ -0,0 +1,13 @@ +--- +'agent-bundle': minor +--- + +Remove the vendored MCP Inspector from the Workbench. The MCP page now has a +single playground presentation; the only surviving derived code is the +first-party MCP App renderer (`src/mcp/app-renderer.tsx`, MIT-attributed to +the Inspector's AppRenderer). Protocol inspection moves to the standalone +Inspector app: the dev server gains opt-in `/api/inspector/status` and +`/api/inspector/launch` routes that spawn `@modelcontextprotocol/inspector` +via npx on demand and return its tokenized URL. Drops the sync-inspector +machinery and the Mantine/react-icons/syntax-highlighter dependency surface +(~737 kB less workbench JS). diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 09e7766e0..07ce48155 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -65,7 +65,6 @@ packages/ tests/mcp-app-sandbox.test.ts tests/mcp-session-routes.test.ts tests/mcp-session-service.test.ts - tests/native-host-smoke-workflow.test.ts tests/normalization.test.ts tests/playground-service.test.ts tests/portable-adapter.test.ts @@ -81,12 +80,6 @@ packages/ workbench/ rsbuild.config.ts scripts/capture-runtime-playground.mjs - src/inspector/adapter/inspector-session-adapter-entry.ts - src/inspector/adapter/inspector-session-adapter-model.ts - src/inspector/adapter/inspector-session-adapter.css - src/inspector/adapter/inspector-session-adapter.tsx - src/inspector/adapter/protocol-screen-without-replay.tsx - src/inspector/adapter/runtime-app-bridge.ts src/main.tsx src/mcp/mcp-app-client.ts src/mcp/mcp-app-frame.tsx @@ -94,6 +87,7 @@ packages/ src/mcp/mcp-page.tsx src/mcp/mcp-session-controller.ts src/mcp/mcp-session-model.ts + src/mcp/runtime-app-bridge.ts src/mcp/runtime-consent-dialog.tsx src/mcp/runtime-consent-queue.ts src/mcp/runtime-mcp-handoff.ts @@ -105,10 +99,6 @@ packages/ src/runtime-stage.tsx src/styles.css tests/helpers/runtime-playground-fixture.ts - tests/inspector-modern-mcp-types.test.ts - tests/inspector-session-adapter-fixture.test.ts - tests/inspector-session-adapter.test.ts - tests/inspector-shell.e2e.test.ts tests/mcp-app-client.test.ts tests/mcp-app-frame.test.ts tests/mcp-app-preview-browser.test.ts @@ -215,7 +205,9 @@ and HMR lane. `AgentBundleDevRuntimeConfig.provider` is loaded by The Workbench has one `Workbench` root and navigation authority. Runtime is the optional fourth top-level `WorkbenchPage` (`overview`, `skills`, `mcp`, -`runtime`); Inspector is a nested MCP presentation, not a fifth shell sibling. +`runtime`); the MCP page renders a single playground presentation, and protocol +inspection is delegated to the standalone MCP Inspector app that the dev server +spawns on demand via the opt-in `/api/inspector/*` routes. The root owns one `ProjectClient` and EventSource, one `McpAppClient`, and one shared `McpSessionController`. It creates one Runtime controller only when the project status advertises the configured runtime capability. `WorkbenchScreen` @@ -268,5 +260,5 @@ flowchart LR `npm run docs:runtime-topology` regenerates only the marked file tree from a fixed Git allowlist. `npm run check:runtime-topology` compares bytes without writing. The generator intentionally excludes generated output, dependencies, -runtime state, unrelated historical tests, and the vendored Inspector source so -the map remains an implementation boundary rather than a repository inventory. +runtime state, and unrelated historical tests so the map remains an +implementation boundary rather than a repository inventory. diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index ac971022b..0a65f5c8e 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -1,6 +1,6 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { basename, join } from 'node:path'; import { expect, test } from '@rstest/core'; import type { createRsbuild, StartDevServerResult } from '@rsbuild/core'; @@ -99,20 +99,25 @@ const copyProviderExample = async (): Promise => copyExample(exampleRoot, { linkPackages: true, prefix: 'rsc-agent-runtime-provider-' }); /** - * Replaces source atomically through a same-directory rename. An in-place - * write is truncate-then-append, which a loaded watcher observes as two - * change events and compiles twice; the duplicate attempt supersedes the - * generation that ordinal-pinned assertions expect to commit. + * Replaces source atomically through a rename staged OUTSIDE the watched + * project. An in-place write is truncate-then-append, which a loaded watcher + * observes as two change events and compiles twice; a temp file created + * inside the watched directory is just as bad, because the watcher also sees + * the temp file's creation as a directory change. Either duplicate compile + * supersedes the generation that ordinal-pinned assertions expect to commit. + * The temp file lives in the project's parent (the copied workspace root, + * same filesystem, never watched) so the rename into place is the only event. */ -const replaceSource = async (path: string, replace: (source: string) => string): Promise => { +const replaceSource = async (projectRoot: string, path: string, replace: (source: string) => string): Promise => { const source = await readFile(path, 'utf8'); - const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.tmp`); await writeFile(temporary, replace(source)); await rename(temporary, path); }; const changeDefinition = async (projectRoot: string, replacement: string): Promise => { await replaceSource( + projectRoot, join(projectRoot, 'src', 'definition.ts'), (source) => source.replace('Read the current shared runtime state.', replacement), ); @@ -120,6 +125,7 @@ const changeDefinition = async (projectRoot: string, replacement: string): Promi const changeWorkerImplementation = async (projectRoot: string, marker: string): Promise => { await replaceSource( + projectRoot, join(projectRoot, 'src', 'rsc', 'worker.tsx'), (source) => source.replace( /RSC worker received an invalid event(?: [^']*)?/u, @@ -130,6 +136,7 @@ const changeWorkerImplementation = async (projectRoot: string, marker: string): const introduceWorkerSyntaxError = async (projectRoot: string): Promise => { await replaceSource( + projectRoot, join(projectRoot, 'src', 'rsc', 'worker.tsx'), (source) => `${source}\nconst = ;\n`, ); diff --git a/package.json b/package.json index d3db2a7e5..fb5b80d3d 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,7 @@ "example:audiobook": "pnpm build && pnpm --filter @agent-bundle-example/audiobook-curator dev", "example:mcp-app": "pnpm build && pnpm --filter @agent-bundle-example/mcp-app dev", "example:skills": "pnpm build && pnpm --filter @agent-bundle-example/skills-starter dev", - "examples:check": "pnpm build && pnpm --filter './examples/*' --workspace-concurrency=1 check", - "sync:inspector": "node scripts/sync-inspector.mjs --commit 672f9f41c548487a468b9e7007d2f9de14da5a69 --version 2.2.0 --mcp-sdk-version 2.0.0 --entry clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx --entry clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx --entry clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx --entry clients/web/src/components/screens/AppsScreen/AppsScreen.tsx --entry clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx --entry clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx --entry clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx --test clients/web/src/utils/inspectorTabs.test.ts --dependency @dnd-kit/core --dependency @dnd-kit/sortable --dependency @dnd-kit/utilities --dependency @emotion/react --dependency @mantine/core --dependency @mantine/form --dependency @mantine/hooks --dependency @mantine/notifications --dependency @modelcontextprotocol/client --dependency @modelcontextprotocol/core --dependency @modelcontextprotocol/ext-apps --dependency ajv --dependency papaparse --dependency pino --dependency react --dependency react-dom --dependency react-icons --dependency react-markdown --dependency react-syntax-highlighter --dependency remark-gfm --dependency zod --test-dependency @rstest/core --test-dependency vitest --public-import @modelcontextprotocol/client/validators/ajv --public-import @modelcontextprotocol/ext-apps/app-bridge --public-import react-icons/md --public-import react-icons/ri --public-import react-icons/tb --public-import react-icons/ti --public-import react-syntax-highlighter/dist/esm/languages/prism/bash --public-import react-syntax-highlighter/dist/esm/languages/prism/css --public-import react-syntax-highlighter/dist/esm/languages/prism/javascript --public-import react-syntax-highlighter/dist/esm/languages/prism/json --public-import react-syntax-highlighter/dist/esm/languages/prism/markdown --public-import react-syntax-highlighter/dist/esm/languages/prism/markup --public-import react-syntax-highlighter/dist/esm/languages/prism/python --public-import react-syntax-highlighter/dist/esm/languages/prism/typescript --public-import react-syntax-highlighter/dist/esm/languages/prism/yaml --public-import react-syntax-highlighter/dist/esm/prism-light --public-import react-syntax-highlighter/dist/esm/styles/prism --public-import react-syntax-highlighter/dist/esm/styles/prism/tomorrow" + "examples:check": "pnpm build && pnpm --filter './examples/*' --workspace-concurrency=1 check" }, "devDependencies": { "@changesets/cli": "2.29.7", diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 9cbf116ec..ed365acc6 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -11,6 +11,7 @@ import { DevLogRoutes } from './logs/dev-log-routes.ts'; import type { DevLogService } from './logs/dev-log-service.ts'; import { EvalRoutes, type EvalRouteService } from './eval/eval-routes.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; +import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts'; import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts'; import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts'; @@ -126,6 +127,8 @@ export interface ForegroundServerOptions { readonly mcpAppPreviews?: McpAppRoutePreviewService; /** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */ readonly hookPlayground?: HookPlaygroundRouteService; + /** Opt-in standalone MCP Inspector child; never auto-started. */ + readonly inspector?: InspectorRouteService; /** Persistent MCP sessions are supplied by the workbench service, never by browser input. */ readonly mcpSessions?: McpSessionService; readonly now?: () => Date; @@ -421,6 +424,7 @@ export class ForegroundServer { readonly #eventHub: ProjectEventHub; readonly #hookPlaygroundRoutes: HookPlaygroundRoutes; readonly #host: string; + readonly #inspectorRoutes: InspectorRoutes; readonly #mcpAppPreviews: McpAppRoutePreviewService | undefined; readonly #mcpAppRoutes: McpAppRoutes; readonly #runtimeMcpRoutes: RuntimeMcpRoutes; @@ -496,6 +500,10 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.hookPlayground === undefined ? {} : { service: options.hookPlayground }), }); + this.#inspectorRoutes = new InspectorRoutes({ + authorize: (request) => this.#assertMutationSession(request), + ...(options.inspector === undefined ? {} : { service: options.inspector }), + }); this.#playgroundRoutes = new PlaygroundRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.playground === undefined ? {} : { service: options.playground }), @@ -645,6 +653,7 @@ export class ForegroundServer { // records and reports the same rejection. void releaseHookPlayground.catch(() => undefined); this.#playgroundRoutes.close(); + this.#inspectorRoutes.close(); this.#artifactRoutes.close(); const releaseEvals = this.#evalRoutes.close(); void releaseEvals.catch(() => undefined); @@ -729,6 +738,7 @@ export class ForegroundServer { if (await this.#runtimeRoutes.handle(request, response)) return; if (await this.#hookPlaygroundRoutes.handle(request, response)) return; if (await this.#playgroundRoutes.handle(request, response)) return; + if (await this.#inspectorRoutes.handle(request, response)) return; if (await this.#artifactRoutes.handle(request, response)) return; if (await this.#evalRoutes.handle(request, response)) return; if (await this.#devLogRoutes.handle(request, response)) return; diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts new file mode 100644 index 000000000..90340eeef --- /dev/null +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -0,0 +1,304 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { CodedError } from '../core/errors.ts'; +import { taskkill, terminateProcessTree } from '../services/process-tree.ts'; + +const inspectorPackage = '@modelcontextprotocol/inspector'; +const startupBudgetMs = 30_000; +const terminateGraceMs = 2_000; +const startupTimeoutKey = Symbol.for('agent-bundle.inspector-launcher.startup-timeout-ms'); +const terminateGraceKey = Symbol.for('agent-bundle.inspector-launcher.terminate-grace-ms'); +const httpUrl = /https?:\/\/[^\s"'<>\\]+/gi; +const trailingPunctuation = /[),.;:\]}>]+$/u; +const urlDelimiter = /[\s"'<>\\]/u; + +export type InspectorLauncherErrorCode = + | 'INSPECTOR_EXITED' + | 'INSPECTOR_LAUNCH_FAILED' + | 'INSPECTOR_STARTUP_TIMEOUT'; + +export type InspectorLauncherState = 'exited' | 'idle' | 'running' | 'starting'; + +export interface InspectorLauncherStatus { + readonly state: InspectorLauncherState; + readonly url?: string; +} + +export interface InspectorSpawnOptions { + readonly cwd: string; + readonly detached?: boolean; + readonly env: NodeJS.ProcessEnv; + readonly shell: false; + readonly stdio: readonly ['pipe', 'pipe', 'pipe']; + readonly windowsHide?: boolean; +} + +export type InspectorSpawn = ( + command: string, + args: readonly string[], + options: InspectorSpawnOptions, +) => ChildProcess; + +export interface InspectorLauncherOptions { + readonly env?: NodeJS.ProcessEnv; + readonly projectRoot: string; + readonly spawn?: InspectorSpawn; +} + +export interface InspectorLauncher { + close(): Promise; + launch(): Promise<{ readonly url: string }>; + status(): InspectorLauncherStatus; +} + +/** Coded refusals a caller can act on without reading inspector internals. */ +export class InspectorLauncherError extends CodedError { + constructor(code: InspectorLauncherErrorCode, message: string) { + super('InspectorLauncherError', code, message); + } +} + +const inspectorLauncherError = (code: InspectorLauncherErrorCode, message: string): InspectorLauncherError => + new InspectorLauncherError(code, message); + +const positiveMs = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback; + +const startupTimeout = (options: InspectorLauncherOptions): number => + positiveMs( + (options as InspectorLauncherOptions & Record)[startupTimeoutKey], + startupBudgetMs * (process.env.CI ? 4 : 1), + ); + +const terminateGrace = (options: InspectorLauncherOptions): number => + positiveMs( + (options as InspectorLauncherOptions & Record)[terminateGraceKey], + terminateGraceMs, + ); + +const defaultSpawn: InspectorSpawn = (command, args, options) => spawn(command, [...args], { + cwd: options.cwd, + ...(options.detached === undefined ? {} : { detached: options.detached }), + env: options.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + ...(options.windowsHide === undefined ? {} : { windowsHide: options.windowsHide }), +}); + +const stripAnsi = (value: string): string => { + let result = ''; + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) !== 0x1b || value[index + 1] !== '[') { + result += value[index]; + continue; + } + index += 2; + while (index < value.length && !/[A-Za-z]/u.test(value[index]!)) index += 1; + } + return result; +}; + +const inspectableUrl = (raw: string): URL | undefined => { + try { + return new URL(raw.replace(trailingPunctuation, '')); + } catch { + return undefined; + } +}; + +const hasTokenQuery = (url: URL): boolean => + [...url.searchParams.keys()].some((key) => key.toLowerCase().includes('token')); + +const isLocalhost = (url: URL): boolean => { + const host = url.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +}; + +/** First stdout http(s) URL with a token query param, else the first delimited localhost URL. */ +export const parseInspectorStdoutUrl = (stdout: string): string | undefined => { + const text = stripAnsi(stdout); + const found: { readonly delimited: boolean; readonly url: URL }[] = []; + for (const match of text.matchAll(httpUrl)) { + const url = inspectableUrl(match[0]!); + if (url === undefined || match.index === undefined) continue; + const next = text[match.index + match[0].length]; + found.push(Object.freeze({ + delimited: next !== undefined && urlDelimiter.test(next), + url, + })); + } + return (found.find((entry) => hasTokenQuery(entry.url)) ?? found.find((entry) => entry.delimited && isLocalhost(entry.url)))?.url.href; +}; + +const alreadyClosed = (child: ChildProcess): boolean => + typeof child.exitCode === 'number' || typeof child.signalCode === 'string'; + +const waitForClose = (child: ChildProcess): Promise => new Promise((resolvePromise) => { + if (alreadyClosed(child)) { + resolvePromise(); + return; + } + child.once('close', () => resolvePromise()); +}); + +const delay = (ms: number): Promise => new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); +}); + +const terminateTree = (child: ChildProcess, signal: NodeJS.Signals): Promise => + terminateProcessTree(child, signal, { + onTreeTerminationFailure: () => undefined, + platform: process.platform, + taskkill, + }); + +const terminateChild = async (child: ChildProcess, graceMs: number): Promise => { + await terminateTree(child, 'SIGTERM'); + const terminated = await Promise.race([ + waitForClose(child).then(() => true), + delay(graceMs).then(() => false), + ]); + if (terminated) return; + await terminateTree(child, 'SIGKILL'); + await Promise.race([waitForClose(child), delay(graceMs)]); +}; + +const statusSnapshot = (state: InspectorLauncherState, url: string | undefined): InspectorLauncherStatus => { + switch (state) { + case 'idle': + case 'starting': + case 'exited': + return Object.freeze({ state }); + case 'running': + return Object.freeze({ state, ...(url === undefined ? {} : { url }) }); + default: { + const exhaustive: never = state; + throw new Error(`Unexpected inspector state: ${String(exhaustive)}`); + } + } +}; + +/** Opt-in launcher for the standalone MCP Inspector app. Starts only on launch(). */ +export const createInspectorLauncher = (options: InspectorLauncherOptions): InspectorLauncher => { + const projectRoot = resolve(options.projectRoot); + const spawnChild = options.spawn ?? defaultSpawn; + const inheritedEnv = options.env ?? process.env; + const graceMs = terminateGrace(options); + const timeoutMs = startupTimeout(options); + let child: ChildProcess | undefined; + let closePromise: Promise | undefined; + let launchPromise: Promise<{ readonly url: string }> | undefined; + let state: InspectorLauncherState = 'idle'; + let url: string | undefined; + + const clearChild = (): void => { + child?.stdout?.removeAllListeners('data'); + child?.stderr?.removeAllListeners('data'); + child = undefined; + }; + + const launch = async (): Promise<{ readonly url: string }> => { + if (closePromise !== undefined) await closePromise; + if (launchPromise !== undefined && (state === 'starting' || state === 'running')) return launchPromise; + if (state === 'running' && url !== undefined) return Object.freeze({ url }); + + launchPromise = new Promise<{ readonly url: string }>((resolvePromise, rejectPromise) => { + let settled = false; + let stdout = ''; + const timer: { id?: NodeJS.Timeout } = {}; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + if (timer.id !== undefined) clearTimeout(timer.id); + action(); + }; + const succeed = (resolved: string): void => { + settle(() => { + state = 'running'; + url = resolved; + resolvePromise(Object.freeze({ url: resolved })); + }); + }; + const fail = (error: InspectorLauncherError): void => { + settle(() => { + if (state === 'starting') state = child === undefined ? 'idle' : 'exited'; + url = undefined; + launchPromise = undefined; + rejectPromise(error); + }); + }; + const consume = (chunk: Buffer | string): void => { + if (state !== 'starting') return; + stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (stdout.length > 64 * 1024) stdout = stdout.slice(-64 * 1024); + const parsed = parseInspectorStdoutUrl(stdout); + if (parsed !== undefined) succeed(parsed); + }; + + state = 'starting'; + url = undefined; + let spawned: ChildProcess; + try { + spawned = spawnChild(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['--yes', inspectorPackage], { + cwd: projectRoot, + detached: process.platform !== 'win32', + env: { ...inheritedEnv, MCP_AUTO_OPEN_ENABLED: 'false' }, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + ...(process.platform === 'win32' ? { windowsHide: true } : {}), + }); + } catch (error) { + fail(inspectorLauncherError( + 'INSPECTOR_LAUNCH_FAILED', + error instanceof Error ? error.message : 'MCP Inspector could not be spawned.', + )); + return; + } + child = spawned; + timer.id = setTimeout(() => { + const running = child; + if (running !== undefined) void terminateChild(running, graceMs).catch(() => undefined); + fail(inspectorLauncherError('INSPECTOR_STARTUP_TIMEOUT', 'MCP Inspector did not publish a URL before the startup budget elapsed.')); + }, timeoutMs); + spawned.stdout?.on('data', (chunk: Buffer | string) => consume(chunk)); + spawned.stderr?.on('data', () => undefined); + spawned.once('error', (error) => { + fail(inspectorLauncherError('INSPECTOR_LAUNCH_FAILED', error.message)); + }); + spawned.once('close', () => { + if (state === 'running' && closePromise === undefined) { + state = 'exited'; + url = undefined; + launchPromise = undefined; + clearChild(); + return; + } + fail(inspectorLauncherError('INSPECTOR_EXITED', 'MCP Inspector exited before publishing a URL.')); + if (closePromise === undefined) clearChild(); + }); + }); + return launchPromise; + }; + + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closePromise = (async () => { + const running = child; + if (running !== undefined) await terminateChild(running, graceMs); + clearChild(); + launchPromise = undefined; + url = undefined; + state = 'idle'; + })().finally(() => { + closePromise = undefined; + }); + return closePromise; + }; + + return Object.freeze({ + close, + launch, + status: () => statusSnapshot(state, url), + }); +}; diff --git a/packages/agent-bundle/src/dev/inspector-routes.ts b/packages/agent-bundle/src/dev/inspector-routes.ts new file mode 100644 index 000000000..2cbd38f1f --- /dev/null +++ b/packages/agent-bundle/src/dev/inspector-routes.ts @@ -0,0 +1,115 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { + diagnostic, + hasOnly, + isRequestDiagnostic, + rawPathname, + readJsonBody, + requestError, + responseDiagnostic, + responseJson as writeJsonResponse, +} from './http.ts'; +import type { InspectorLauncherStatus } from './inspector-launcher.ts'; + +type Route = 'launch' | 'status'; + +export interface InspectorRouteService { + launch(): Promise<{ readonly url: string }>; + status(): InspectorLauncherStatus; +} + +export interface InspectorRoutesOptions { + /** The foreground server injects its existing same-origin, same-session guard. */ + readonly authorize: (request: IncomingMessage) => void; + /** Omitted until the workbench composes the opt-in inspector launcher. */ + readonly service?: InspectorRouteService; +} + +const responseJson = (response: ServerResponse, body: unknown): void => + writeJsonResponse(response, body, { destroyIfEnded: true }); + +const pathError = (): never => { + throw requestError(diagnostic('AB8110', 'Inspector route path is not valid.', 400)); +}; + +const invalidShape = (): never => { + throw requestError(diagnostic('AB8111', 'Inspector request has an invalid shape.', 400)); +}; + +const route = (requestTarget: string | undefined): Route | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname !== '/api/inspector' && !pathname.startsWith('/api/inspector/')) return undefined; + const parts = pathname.split('/'); + if (parts[0] !== '' || parts[1] !== 'api' || parts[2] !== 'inspector') return pathError(); + if (parts.length !== 4) return pathError(); + const kind = parts[3]; + if (kind === 'launch' || kind === 'status') return kind; + return pathError(); +}; + +const noQuery = (requestTarget: string | undefined): void => { + if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); +}; + +/** + * HTTP boundary for the opt-in standalone MCP Inspector. The browser never + * selects the child command, environment, or working directory. + */ +export class InspectorRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #service: InspectorRouteService | undefined; + #closed = false; + + constructor(options: InspectorRoutesOptions) { + this.#authorize = options.authorize; + this.#service = options.service; + } + + close(): void { + this.#closed = true; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const parsed = route(request.url); + if (parsed === undefined) return false; + this.#authorize(request); + if (this.#closed) throw this.#unavailable(503); + const service = this.#service; + if (service === undefined) throw this.#unavailable(404); + try { + await this.#dispatch(parsed, request, response, service); + } catch (error) { + if (isRequestDiagnostic(error)) throw error; + throw requestError(diagnostic('AB8112', 'MCP Inspector could not be launched.', 502)); + } + return true; + } + + async #dispatch( + parsed: Route, + request: IncomingMessage, + response: ServerResponse, + service: InspectorRouteService, + ): Promise { + const method = request.method ?? 'GET'; + noQuery(request.url); + switch (parsed) { + case 'status': + if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return responseJson(response, { status: service.status() }); + case 'launch': + if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + if (!hasOnly(await readJsonBody(request, { invalidShape }), [])) invalidShape(); + return responseJson(response, { url: (await service.launch()).url }); + default: { + const exhaustive: never = parsed; + throw new Error(`Unexpected inspector route: ${String(exhaustive)}`); + } + } + } + + #unavailable(status: number): Error { + return requestError(diagnostic('AB8113', 'Inspector routes are not available.', status)); + } +} diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index b3ec400f4..b26ca16a4 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -11,6 +11,7 @@ import { attachProjectEventLogs, createMcpDevLogTraceSink, createProjectDevLogge import { EpochStore } from './epoch-store.ts'; import { EvalService } from './eval/eval-service.ts'; import { ProjectEventHub } from './events.ts'; +import { createInspectorLauncher } from './inspector-launcher.ts'; import { HookPlaygroundService } from './playground/hook-playground-service.ts'; import { startForegroundServer, @@ -66,7 +67,7 @@ interface Closeable { export interface DevServerLifecycleCloseFailure { readonly error: unknown; - readonly resource: 'coordinator' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; + readonly resource: 'coordinator' | 'inspector' | 'logs' | 'mcp-apps' | 'mcp-sessions' | 'playground' | 'runtime' | 'runtime-client-surfaces'; } /** Reports session and coordinator cleanup failures without hiding either resource. */ @@ -396,6 +397,7 @@ export interface DevServerLifecycleOptions { readonly detachProjectLogs?: () => void; readonly logs?: DevLogService; readonly mcpApps?: Closeable; + readonly inspector?: Closeable; readonly mcpSessions: Closeable; readonly playground?: Closeable; readonly runtimeResources?: DevServerRuntimeLifecycleResources; @@ -405,6 +407,7 @@ export interface DevServerLifecycleOptions { export const closeDevServerLifecycle = async ({ coordinator, detachProjectLogs, + inspector, logs, mcpApps, mcpSessions, @@ -420,6 +423,7 @@ export const closeDevServerLifecycle = async ({ summary: 'Development workbench shutdown started.', }); const playgroundResults = playground === undefined ? [] : await Promise.allSettled([playground.close()]); + const inspectorResults = inspector === undefined ? [] : await Promise.allSettled([inspector.close()]); const appResults = mcpApps === undefined ? [] : await Promise.allSettled([mcpApps.close()]); const clientSurfaceResults = runtimeResources?.clientSurfaces === undefined ? [] @@ -437,6 +441,11 @@ export const closeDevServerLifecycle = async ({ ? [Object.freeze({ error: result.reason, resource: 'playground' as const })] : [], ), + ...inspectorResults.flatMap((result): readonly DevServerLifecycleCloseFailure[] => + result.status === 'rejected' + ? [Object.freeze({ error: result.reason, resource: 'inspector' as const })] + : [], + ), ...appResults.flatMap((result): readonly DevServerLifecycleCloseFailure[] => result.status === 'rejected' ? [Object.freeze({ error: result.reason, resource: 'mcp-apps' as const })] @@ -492,12 +501,14 @@ const withMcpSessionLifecycle = ( playground: Closeable, logs: DevLogService, detachProjectLogs: () => void, + inspector: Closeable, ): ForegroundCoordinator => Object.freeze({ close: () => { clientSurfaces.beginClose(); return closeDevServerLifecycle({ coordinator, detachProjectLogs, + inspector, logs, mcpApps: mcpApps(), mcpSessions, @@ -725,6 +736,7 @@ export const startDevServer = async (options: StartDevServerOptions): Promise | undefined; const packedEnvironment = (): NodeJS.ProcessEnv => { @@ -44,17 +44,15 @@ const availablePort = async (): Promise => { }; describe.sequential('workbench package build', () => { -it('copies stable prebuilt workbench assets and exact Inspector provenance into the package distribution', async () => { +it('copies stable prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { await buildPackage(); await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined(); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Bundle dashboard'); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector'); - await Promise.all(inspectorProvenanceFiles.map(async (file) => { - await expect(readFile(join(packageRoot, 'dist', 'workbench', 'src', 'inspector', file), 'utf8')).resolves.toBe( - await readFile(join(workbenchRoot, 'src', 'inspector', file), 'utf8'), - ); - })); + await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe( + await readFile(join(workbenchRoot, appRendererLicense), 'utf8'), + ); }, 60_000); it('prunes stale copied workbench assets without removing the package library output', async () => { @@ -83,9 +81,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos const listing = await execFile('tar', ['-tf', tarball]); expect(listing.stdout).toContain('package/dist/workbench/index.html'); expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES'); - for (const file of inspectorProvenanceFiles) { - expect(listing.stdout).toContain(`package/dist/workbench/src/inspector/${file}`); - } + expect(listing.stdout).toContain('package/dist/workbench/src/mcp/APP-RENDERER-LICENSE'); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*\.map$/mu); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); diff --git a/packages/agent-bundle/tests/inspector-launcher.test.ts b/packages/agent-bundle/tests/inspector-launcher.test.ts new file mode 100644 index 000000000..db0bb92ca --- /dev/null +++ b/packages/agent-bundle/tests/inspector-launcher.test.ts @@ -0,0 +1,191 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { expect, it } from '@rstest/core'; + +import { + createInspectorLauncher, + InspectorLauncherError, + parseInspectorStdoutUrl, + type InspectorLauncherOptions, + type InspectorSpawn, + type InspectorSpawnOptions, +} from '../src/dev/inspector-launcher.ts'; + +const startupTimeoutKey = Symbol.for('agent-bundle.inspector-launcher.startup-timeout-ms'); +const terminateGraceKey = Symbol.for('agent-bundle.inspector-launcher.terminate-grace-ms'); +const tokenUrl = 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=inspector-token'; + +interface SpawnInvocation { + readonly args: readonly string[]; + readonly command: string; + readonly options: InspectorSpawnOptions; +} + +class FakeChild extends EventEmitter { + readonly stderr = new PassThrough(); + readonly stdout = new PassThrough(); + readonly signals: NodeJS.Signals[] = []; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + kill = (signal: NodeJS.Signals = 'SIGTERM'): boolean => { + this.signals.push(signal); + this.signalCode = signal; + queueMicrotask(() => this.emit('close', 0, signal)); + return true; + }; +} + +const withSeams = ( + options: InspectorLauncherOptions, + seams: { readonly startupTimeoutMs?: number; readonly terminateGraceMs?: number } = {}, +): InspectorLauncherOptions => Object.assign(options, { + ...(seams.startupTimeoutMs === undefined ? {} : { [startupTimeoutKey]: seams.startupTimeoutMs }), + ...(seams.terminateGraceMs === undefined ? {} : { [terminateGraceKey]: seams.terminateGraceMs }), +}); + +const fakeSpawn = (): { + readonly children: FakeChild[]; + readonly invocations: SpawnInvocation[]; + readonly spawn: InspectorSpawn; +} => { + const children: FakeChild[] = []; + const invocations: SpawnInvocation[] = []; + return Object.freeze({ + children, + invocations, + spawn: (command, args, options) => { + invocations.push(Object.freeze({ args: Object.freeze([...args]), command, options })); + const child = new FakeChild(); + children.push(child); + return child as unknown as ChildProcess; + }, + }); +}; + +it('stays idle until launch is requested and never auto-spawns', () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + expect(launcher.status()).toEqual({ state: 'idle' }); + expect(spawned.invocations).toEqual([]); +}); + +it('parses the first token-bearing inspector URL from stdout and is idempotent', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + env: { PATH: '/bin', EXTRA: 'keep' }, + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + expect(launcher.status()).toEqual({ state: 'starting' }); + expect(spawned.invocations).toHaveLength(1); + expect(spawned.invocations[0]).toMatchObject({ + args: ['--yes', '@modelcontextprotocol/inspector'], + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + options: { + cwd: resolve('/work/project'), + env: { EXTRA: 'keep', MCP_AUTO_OPEN_ENABLED: 'false', PATH: '/bin' }, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }, + }); + + const second = launcher.launch(); + spawned.children[0]!.stdout.write(`MCP Inspector is up at ${tokenUrl}\n`); + await expect(Promise.all([pending, second])).resolves.toEqual([{ url: tokenUrl }, { url: tokenUrl }]); + expect(spawned.invocations).toHaveLength(1); + expect(launcher.status()).toEqual({ state: 'running', url: tokenUrl }); + await expect(launcher.launch()).resolves.toEqual({ url: tokenUrl }); + expect(spawned.invocations).toHaveLength(1); +}); + +it('prefers a token query URL and falls back to the first localhost URL', () => { + expect(parseInspectorStdoutUrl([ + 'proxy http://localhost:6277', + `open ${tokenUrl}`, + ].join('\n'))).toBe(tokenUrl); + expect(parseInspectorStdoutUrl('listening on http://127.0.0.1:6274/inspector\n')).toBe( + 'http://127.0.0.1:6274/inspector', + ); + expect(parseInspectorStdoutUrl('https://localhost:6274/?sessionToken=abc')).toBe( + 'https://localhost:6274/?sessionToken=abc', + ); + expect(parseInspectorStdoutUrl('http://example.com/nope')).toBeUndefined(); + expect(parseInspectorStdoutUrl('http://localhost:6274/?MCP_PROXY_AUTH_')).toBeUndefined(); +}); + +it('joins a URL split across stdout chunks before resolving', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write('http://localhost:6274/?MCP_PROXY_AUTH_'); + spawned.children[0]!.stdout.write('TOKEN=split-token\n'); + await expect(pending).resolves.toEqual({ + url: 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=split-token', + }); +}); + +it('kills the child and rejects when the startup budget elapses', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher(withSeams({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }, { startupTimeoutMs: 20, terminateGraceMs: 10 })); + + await expect(launcher.launch()).rejects.toEqual(expect.objectContaining({ + code: 'INSPECTOR_STARTUP_TIMEOUT', + name: InspectorLauncherError.name, + })); + expect(spawned.children[0]!.signals).toContain('SIGTERM'); + expect(launcher.status()).toEqual({ state: 'exited' }); +}); + +it('rejects when the child exits before a URL is published', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }); + + const pending = launcher.launch(); + spawned.children[0]!.emit('close', 1, null); + await expect(pending).rejects.toEqual(expect.objectContaining({ + code: 'INSPECTOR_EXITED', + name: InspectorLauncherError.name, + })); + expect(launcher.status()).toEqual({ state: 'exited' }); +}); + +it('closes the child tree idempotently and can launch again afterwards', async () => { + const spawned = fakeSpawn(); + const launcher = createInspectorLauncher(withSeams({ + projectRoot: '/work/project', + spawn: spawned.spawn, + }, { terminateGraceMs: 10 })); + + const pending = launcher.launch(); + spawned.children[0]!.stdout.write(`${tokenUrl}\n`); + await pending; + await launcher.close(); + await launcher.close(); + expect(spawned.children[0]!.signals[0]).toBe('SIGTERM'); + expect(launcher.status()).toEqual({ state: 'idle' }); + + const relaunched = launcher.launch(); + expect(spawned.invocations).toHaveLength(2); + spawned.children[1]!.stdout.write(`${tokenUrl}\n`); + await expect(relaunched).resolves.toEqual({ url: tokenUrl }); +}); diff --git a/packages/agent-bundle/tests/inspector-routes.test.ts b/packages/agent-bundle/tests/inspector-routes.test.ts new file mode 100644 index 000000000..afa87bffc --- /dev/null +++ b/packages/agent-bundle/tests/inspector-routes.test.ts @@ -0,0 +1,175 @@ +import { expect, it } from '@rstest/core'; + +import { InspectorRoutes, type InspectorRouteService } from '../src/dev/inspector-routes.ts'; +import type { InspectorLauncherStatus } from '../src/dev/inspector-launcher.ts'; +import { + authorize, + originHeaders as headers, + startRoutes as startRouteServer, + type StartedRoutes, +} from './support/route-harness.ts'; + +const tokenUrl = 'http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=inspector-token'; + +class RecordingService implements InspectorRouteService { + readonly calls: string[] = []; + failure: Error | undefined; + state: InspectorLauncherStatus = Object.freeze({ state: 'idle' }); + + async launch(): Promise<{ readonly url: string }> { + this.calls.push('launch'); + if (this.failure !== undefined) throw this.failure; + return Object.freeze({ url: tokenUrl }); + } + + status(): InspectorLauncherStatus { + this.calls.push('status'); + return this.state; + } +} + +const startRoutes = async (service?: InspectorRouteService): Promise> => + startRouteServer(new InspectorRoutes({ + authorize, + ...(service === undefined ? {} : { service }), + }), { closeMode: 'awaited' }); + +const jsonHeaders = (): Readonly> => ({ ...headers(), 'content-type': 'application/json' }); + +const launchRequest = (url: string, body = '{}'): Promise => fetch(`${url}/api/inspector/launch`, { + body, + headers: jsonHeaders(), + method: 'POST', +}); + +it('reports the launcher status without starting anything', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const idle = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(idle.status).toBe(200); + await expect(idle.json()).resolves.toEqual({ status: { state: 'idle' } }); + + service.state = Object.freeze({ state: 'running', url: tokenUrl }); + const running = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(running.status).toBe(200); + await expect(running.json()).resolves.toEqual({ status: { state: 'running', url: tokenUrl } }); + + expect(service.calls).toEqual(['status', 'status']); + } finally { + await started.close(); + } +}); + +it('launches the inspector on demand and returns its tokenized URL', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const launched = await launchRequest(started.url); + expect(launched.status).toBe(200); + await expect(launched.json()).resolves.toEqual({ url: tokenUrl }); + expect(service.calls).toEqual(['launch']); + } finally { + await started.close(); + } +}); + +it('rejects invalid inspector paths, queries, methods, and smuggled bodies', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + for (const path of ['/api/inspector', '/api/inspector/', '/api/inspector/launch/extra', '/api/inspector/unknown']) { + const rejected = await fetch(`${started.url}${path}`, { headers: headers() }); + expect(rejected.status).toBe(400); + await expect(rejected.json()).resolves.toEqual({ + diagnostic: { code: 'AB8110', message: 'Inspector route path is not valid.' }, + }); + } + + const query = await fetch(`${started.url}/api/inspector/status?extra=1`, { headers: headers() }); + expect(query.status).toBe(400); + await expect(query.json()).resolves.toEqual({ + diagnostic: { code: 'AB8111', message: 'Inspector request has an invalid shape.' }, + }); + + const statusPost = await fetch(`${started.url}/api/inspector/status`, { headers: headers(), method: 'POST' }); + expect(statusPost.status).toBe(405); + await expect(statusPost.json()).resolves.toEqual({ + diagnostic: { code: 'AB8007', message: 'Route does not accept this method.' }, + }); + + const launchGet = await fetch(`${started.url}/api/inspector/launch`, { headers: headers() }); + expect(launchGet.status).toBe(405); + + const smuggled = await launchRequest(started.url, JSON.stringify({ command: '/tmp/untrusted' })); + expect(smuggled.status).toBe(400); + await expect(smuggled.json()).resolves.toEqual({ + diagnostic: { code: 'AB8111', message: 'Inspector request has an invalid shape.' }, + }); + + const media = await fetch(`${started.url}/api/inspector/launch`, { + body: 'launch=1', + headers: { ...headers(), 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + expect(media.status).toBe(415); + + const unrelated = await fetch(`${started.url}/api/other`, { headers: headers() }); + expect(unrelated.status).toBe(404); + + expect(service.calls).toEqual([]); + } finally { + await started.close(); + } +}); + +it('requires the same-session guard before reaching the launcher', async () => { + const service = new RecordingService(); + const started = await startRoutes(service); + + try { + const unauthorized = await fetch(`${started.url}/api/inspector/status`, { + headers: { origin: 'http://127.0.0.1:4567' }, + }); + expect(unauthorized.status).toBe(403); + expect(service.calls).toEqual([]); + } finally { + await started.close(); + } +}); + +it('reports an absent or closed launcher without leaking internals', async () => { + const absent = await startRoutes(); + try { + const unavailable = await fetch(`${absent.url}/api/inspector/status`, { headers: headers() }); + expect(unavailable.status).toBe(404); + await expect(unavailable.json()).resolves.toEqual({ + diagnostic: { code: 'AB8113', message: 'Inspector routes are not available.' }, + }); + } finally { + await absent.close(); + } + + const service = new RecordingService(); + service.failure = new Error('/private/npx/path could not be spawned'); + const started = await startRoutes(service); + try { + const failed = await launchRequest(started.url); + expect(failed.status).toBe(502); + await expect(failed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8112', message: 'MCP Inspector could not be launched.' }, + }); + + started.routes.close(); + const closed = await fetch(`${started.url}/api/inspector/status`, { headers: headers() }); + expect(closed.status).toBe(503); + await expect(closed.json()).resolves.toEqual({ + diagnostic: { code: 'AB8113', message: 'Inspector routes are not available.' }, + }); + } finally { + await started.close(); + } +}); diff --git a/packages/agent-bundle/tests/playground-service.test.ts b/packages/agent-bundle/tests/playground-service.test.ts index e6887b635..5dcaf8f97 100644 --- a/packages/agent-bundle/tests/playground-service.test.ts +++ b/packages/agent-bundle/tests/playground-service.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { timeScale } from './support/time-scale.ts'; import { PlaygroundStore as PlaygroundService, PlaygroundServiceCloseError, @@ -2166,7 +2167,9 @@ it('evicts the oldest settled sessions from memory while every by-id operation s } }); -it('retains a settled session while a subscription is attached and evicts it after the subscription closes', async () => { +// Settles ~22 real sessions sequentially; the default 5s budget starves on +// 2-core CI runners. +it('retains a settled session while a subscription is attached and evicts it after the subscription closes', { timeout: 30_000 * timeScale }, async () => { const fixture = await createFixture(); try { await settleSession(fixture.service, 'subscribed-retention'); diff --git a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts index 084a57d4c..017cdba35 100644 --- a/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts @@ -32,7 +32,7 @@ const expectedTree = `packages/ tests/playground-service.test.ts tests/runtime-provider.test.ts workbench/ - src/inspector/adapter/runtime-app-bridge.ts + src/mcp/runtime-app-bridge.ts src/mcp/runtime-consent-dialog.tsx src/mcp/runtime-consent-queue.ts src/runtime-model.ts @@ -83,7 +83,7 @@ describe('rsc runtime topology script', () => { 'packages/agent-bundle/tests/normalization.test.ts', 'packages/agent-bundle/tests/playground-service.test.ts', 'packages/agent-bundle/tests/runtime-provider.test.ts', - 'packages/workbench/src/inspector/adapter/runtime-app-bridge.ts', + 'packages/workbench/src/mcp/runtime-app-bridge.ts', 'packages/workbench/src/mcp/runtime-consent-dialog.tsx', 'packages/workbench/src/mcp/runtime-consent-queue.ts', 'packages/workbench/src/runtime-model.ts', diff --git a/packages/workbench/THIRD_PARTY_NOTICES b/packages/workbench/THIRD_PARTY_NOTICES index 862df943b..403fe1d8b 100644 --- a/packages/workbench/THIRD_PARTY_NOTICES +++ b/packages/workbench/THIRD_PARTY_NOTICES @@ -1,12 +1,10 @@ -Agent Bundle workbench includes an allowlisted source snapshot from the MCP -Inspector project: +Agent Bundle workbench includes an MCP App renderer derived from the MCP +Inspector project's AppRenderer component: MCP Inspector 2.2.0 https://github.com/modelcontextprotocol/inspector commit 672f9f41c548487a468b9e7007d2f9de14da5a69 MIT License -The copied path list, source and post-patch SHA-256 digests, declared package -imports, and retained upstream test provenance are in -src/inspector/UPSTREAM.json. The MIT license text is in -src/inspector/LICENSE.inspector. +The derived code is src/mcp/app-renderer.tsx. The MIT license text is in +src/mcp/APP-RENDERER-LICENSE. diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 7edc26412..c1da31f05 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -12,23 +12,17 @@ "typecheck": "tsc --project tsconfig.json" }, "dependencies": { - "@mantine/core": "9.5.2", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/ext-apps": "1.7.5", "@modelcontextprotocol/sdk": "1.30.0", - "papaparse": "5.6.0", - "pino": "10.3.1", "react": "19.2.8", "react-dom": "19.2.8", - "react-icons": "5.7.0", "react-markdown": "10.1.0", - "react-syntax-highlighter": "16.1.1", "remark-gfm": "4.0.1", "shiki": "4.4.3", "zod": "4.4.3" }, "devDependencies": { - "@inspector/core": "workspace:*", "@rsbuild/core": "2.2.1", "@rsbuild/plugin-react": "2.1.0", "@types/react": "19.2.18", diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 08da060e5..7a01b7aa6 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -18,9 +18,7 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ assetPrefix: '/', copy: [ { from: resolve(import.meta.dirname, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'UPSTREAM.json'), to: 'src/inspector/UPSTREAM.json', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'LICENSE.inspector'), to: 'src/inspector/LICENSE.inspector', toType: 'file' }, - { from: resolve(sourceRoot, 'inspector', 'PATCHES.md'), to: 'src/inspector/PATCHES.md', toType: 'file' }, + { from: resolve(sourceRoot, 'mcp', 'APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' }, ], distPath: { root: 'dist', diff --git a/packages/workbench/scripts/capture-runtime-playground.mjs b/packages/workbench/scripts/capture-runtime-playground.mjs index 569a2670f..9a3ce022b 100644 --- a/packages/workbench/scripts/capture-runtime-playground.mjs +++ b/packages/workbench/scripts/capture-runtime-playground.mjs @@ -6,7 +6,7 @@ import { chromium } from 'playwright'; import { startRuntimePlaygroundFixture } from '../tests/helpers/runtime-playground-fixture.ts'; -const browserTimeout = 30_000; +const browserTimeout = 30_000 * (process.env.CI === undefined ? 1 : 4); const desktopViewport = Object.freeze({ height: 900, width: 1440 }); const mobileViewport = Object.freeze({ height: 844, width: 390 }); const outputFlags = Object.freeze([ diff --git a/packages/workbench/src/inspector/PATCHES.md b/packages/workbench/src/inspector/PATCHES.md deleted file mode 100644 index f4caf910a..000000000 --- a/packages/workbench/src/inspector/PATCHES.md +++ /dev/null @@ -1,19 +0,0 @@ -# Inspector local patches - -`001-rstest-inspector-tabs-import.patch` mechanically changes the retained -upstream `inspectorTabs.test.ts` import from `vitest` to `@rstest/core`. It -allows the exact upstream assertions to execute under this repository's Rstest -runner; no assertion or production-source content is changed. - -`002-remove-legacy-sse-mcp-types.patch` removes the legacy `SseServerConfig` -export, its `MCPServerConfig` union arm, and the `"sse"` `ServerType` literal. -It also updates transport comments in that file so they no longer claim legacy -SSE support. Workbench accepts only stdio and Streamable HTTP server -configurations. Its scope is `core/mcp/types.ts`; `core/mcp/fetchTracking.ts` -and the Network UI retain their `text/event-stream` tracing for modern -Streamable HTTP responses. - -Apart from files explicitly targeted by these numbered patches, allowlisted -Inspector files remain byte-identical. Every vendor change must be represented -by a numbered `patches/*.patch` file and recorded by -`scripts/sync-inspector.mjs` in `UPSTREAM.json`. diff --git a/packages/workbench/src/inspector/UPSTREAM.json b/packages/workbench/src/inspector/UPSTREAM.json deleted file mode 100644 index 31ae9d3d1..000000000 --- a/packages/workbench/src/inspector/UPSTREAM.json +++ /dev/null @@ -1,575 +0,0 @@ -{ - "aliases": [ - [ - "@", - "clients/web/src" - ], - [ - "@inspector/core", - "core" - ] - ], - "commit": "672f9f41c548487a468b9e7007d2f9de14da5a69", - "dependencies": [ - "@mantine/core", - "@modelcontextprotocol/client", - "@modelcontextprotocol/ext-apps", - "papaparse", - "pino", - "react", - "react-icons", - "react-markdown", - "react-syntax-highlighter", - "remark-gfm", - "zod" - ], - "files": [ - { - "path": "clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx", - "sha256": "9e3f70129ac6bfcb044e98a0d7e6157f88b418812108ccf5416b7fc882cb9e17", - "upstreamSha256": "9e3f70129ac6bfcb044e98a0d7e6157f88b418812108ccf5416b7fc882cb9e17" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/AppRenderer.tsx", - "sha256": "e985fb6b0a1ea6dc0709ad73884dd2f26ede87e11a18c668b95184da3e4910ec", - "upstreamSha256": "e985fb6b0a1ea6dc0709ad73884dd2f26ede87e11a18c668b95184da3e4910ec" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts", - "sha256": "8d5d5811d2e36248cb744ddeed59da30fb8305fa8208c32a29a3fdf822771553", - "upstreamSha256": "8d5d5811d2e36248cb744ddeed59da30fb8305fa8208c32a29a3fdf822771553" - }, - { - "path": "clients/web/src/components/elements/AppRenderer/hostContext.ts", - "sha256": "5d95cce4d9aeb7e54a73e85120dc88eaf068419e82e1d5fb53d6e11a93df23c6", - "upstreamSha256": "5d95cce4d9aeb7e54a73e85120dc88eaf068419e82e1d5fb53d6e11a93df23c6" - }, - { - "path": "clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx", - "sha256": "62dfb7a59c57bba278b45e8aa577c3e023f04898f9b5d7cac3e108928e6c8a06", - "upstreamSha256": "62dfb7a59c57bba278b45e8aa577c3e023f04898f9b5d7cac3e108928e6c8a06" - }, - { - "path": "clients/web/src/components/elements/ClearButton/ClearButton.tsx", - "sha256": "88ef2eec2c9ed5f1cf36ab5bd49fb6dca31765581ea5e7e1e7c590d01f46c9de", - "upstreamSha256": "88ef2eec2c9ed5f1cf36ab5bd49fb6dca31765581ea5e7e1e7c590d01f46c9de" - }, - { - "path": "clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx", - "sha256": "598dea88e6f2ac20c8695ceedabfb1fc33fb9085ac317062f4c47a2d77ce28c1", - "upstreamSha256": "598dea88e6f2ac20c8695ceedabfb1fc33fb9085ac317062f4c47a2d77ce28c1" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx", - "sha256": "98844a612e71f7cee3fa26e4c18a3389da57c9246e31acc829b9eb113a2aad5c", - "upstreamSha256": "98844a612e71f7cee3fa26e4c18a3389da57c9246e31acc829b9eb113a2aad5c" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/ContentViewer.tsx", - "sha256": "04cf2a650a98f08b9985a5f3cde69ba125e98a2dce1ec449dfa349b205f563a6", - "upstreamSha256": "04cf2a650a98f08b9985a5f3cde69ba125e98a2dce1ec449dfa349b205f563a6" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/CsvTable.tsx", - "sha256": "7513fd327da5377399543910ebfa6bfdefea079ffd3a2db43c9b640383277ca9", - "upstreamSha256": "7513fd327da5377399543910ebfa6bfdefea079ffd3a2db43c9b640383277ca9" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx", - "sha256": "08768e239fc4816ecf50e8b31dba8f3a223b3af063e82ffd21c86e896c1e5506", - "upstreamSha256": "08768e239fc4816ecf50e8b31dba8f3a223b3af063e82ffd21c86e896c1e5506" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/PdfFrame.tsx", - "sha256": "b135480177db1b35ddd668d2126956a92904d8aa9e42ed647468f64f0cac5a0b", - "upstreamSha256": "b135480177db1b35ddd668d2126956a92904d8aa9e42ed647468f64f0cac5a0b" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts", - "sha256": "5bd92c7dcaa159dc166bbd0dd1376eef883a5f6dcc90a5a8be5911f97e3fdf32", - "upstreamSha256": "5bd92c7dcaa159dc166bbd0dd1376eef883a5f6dcc90a5a8be5911f97e3fdf32" - }, - { - "path": "clients/web/src/components/elements/ContentViewer/useObjectUrl.ts", - "sha256": "b1be3bb3dd0a4f79c1d0a4e9be06f6af9291b62eceeffd512486306f81216e01", - "upstreamSha256": "b1be3bb3dd0a4f79c1d0a4e9be06f6af9291b62eceeffd512486306f81216e01" - }, - { - "path": "clients/web/src/components/elements/CopyButton/CopyButton.tsx", - "sha256": "4f9a22e16a6a83426a8f8ea25f7ba5a8be1a14499816d452663014ef8930bcfc", - "upstreamSha256": "4f9a22e16a6a83426a8f8ea25f7ba5a8be1a14499816d452663014ef8930bcfc" - }, - { - "path": "clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx", - "sha256": "afa2d1342a75a2a7a85a3d6aaee87e4630a2bae7b8af5dd98f1a59fac1130b84", - "upstreamSha256": "afa2d1342a75a2a7a85a3d6aaee87e4630a2bae7b8af5dd98f1a59fac1130b84" - }, - { - "path": "clients/web/src/components/elements/EraBadge/EraBadge.tsx", - "sha256": "77497dd8044948815c2cb9f211de2701c69967bc5cd66e7540f613ac74ee3f73", - "upstreamSha256": "77497dd8044948815c2cb9f211de2701c69967bc5cd66e7540f613ac74ee3f73" - }, - { - "path": "clients/web/src/components/elements/EraBadge/eraUtils.ts", - "sha256": "117ca576d7996e8ab71162c426ef9b7e12fed37415d7d2e93fe5608637f2541d", - "upstreamSha256": "117ca576d7996e8ab71162c426ef9b7e12fed37415d7d2e93fe5608637f2541d" - }, - { - "path": "clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx", - "sha256": "4b7485c9e85a5f4a4197e4fb940d4e2103cee131a325631b58b7548a5fcccba4", - "upstreamSha256": "4b7485c9e85a5f4a4197e4fb940d4e2103cee131a325631b58b7548a5fcccba4" - }, - { - "path": "clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx", - "sha256": "e5593c3f08fdcf0c181f9a08808104f95a5114b8aa76e090b7dc2d05a7f1624f", - "upstreamSha256": "e5593c3f08fdcf0c181f9a08808104f95a5114b8aa76e090b7dc2d05a7f1624f" - }, - { - "path": "clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx", - "sha256": "dce37e85db459fcbf42f32f14fefc92da483c4bf486c62e52f320691cc47c6d3", - "upstreamSha256": "dce37e85db459fcbf42f32f14fefc92da483c4bf486c62e52f320691cc47c6d3" - }, - { - "path": "clients/web/src/components/elements/ListLoadError/ListLoadError.tsx", - "sha256": "11bab79f7551bf99e3c1eb3146299a6199069bcf5d8f5e690c4e06542f716876", - "upstreamSha256": "11bab79f7551bf99e3c1eb3146299a6199069bcf5d8f5e690c4e06542f716876" - }, - { - "path": "clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx", - "sha256": "8f0fc02d40ef799ee227ca27b8bc921f7b6ae7c86f8bfb494ecd4b7499d3c9a9", - "upstreamSha256": "8f0fc02d40ef799ee227ca27b8bc921f7b6ae7c86f8bfb494ecd4b7499d3c9a9" - }, - { - "path": "clients/web/src/components/elements/ListToggle/ListToggle.tsx", - "sha256": "374d716d7fc8be60782b5e58e25f2650cda35809c73eab3d41e45ddcba403ddd", - "upstreamSha256": "374d716d7fc8be60782b5e58e25f2650cda35809c73eab3d41e45ddcba403ddd" - }, - { - "path": "clients/web/src/components/elements/LogEntry/LogEntry.tsx", - "sha256": "3cfed2ff30458a1a8231f08260699c66b07ad22e6c1dea8c1c459843a446305e", - "upstreamSha256": "3cfed2ff30458a1a8231f08260699c66b07ad22e6c1dea8c1c459843a446305e" - }, - { - "path": "clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx", - "sha256": "d63187cefc4a87897526c8b0dccd3178649fd151dc8cb72b43c768940c2b848a", - "upstreamSha256": "d63187cefc4a87897526c8b0dccd3178649fd151dc8cb72b43c768940c2b848a" - }, - { - "path": "clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx", - "sha256": "eb7be755bd2eb2a8a28bcb92cedeba785f670da2a784b590c5d03343ed698c2f", - "upstreamSha256": "eb7be755bd2eb2a8a28bcb92cedeba785f670da2a784b590c5d03343ed698c2f" - }, - { - "path": "clients/web/src/components/elements/MessageBubble/MessageBubble.tsx", - "sha256": "a8b76cb90cc2d2f582b8e954e2972549aa31d3128225d76550906d99ce4a86da", - "upstreamSha256": "a8b76cb90cc2d2f582b8e954e2972549aa31d3128225d76550906d99ce4a86da" - }, - { - "path": "clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx", - "sha256": "fa0744a9a86cb9060a56ab53d3b966005add690519175bcb2c3961b588025a1c", - "upstreamSha256": "fa0744a9a86cb9060a56ab53d3b966005add690519175bcb2c3961b588025a1c" - }, - { - "path": "clients/web/src/components/elements/MethodBadge/MethodBadge.tsx", - "sha256": "b388b10b1c113db7786c2b195befb9e3439851b5670cd657c3e6077ebf4fe526", - "upstreamSha256": "b388b10b1c113db7786c2b195befb9e3439851b5670cd657c3e6077ebf4fe526" - }, - { - "path": "clients/web/src/components/elements/PinToggle/PinToggle.tsx", - "sha256": "878cc0d8b078fb29da3f8fbcde442c5e9def9899398b93541a086f46f3816373", - "upstreamSha256": "878cc0d8b078fb29da3f8fbcde442c5e9def9899398b93541a086f46f3816373" - }, - { - "path": "clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx", - "sha256": "9cd255ca44489af5af1041301e8a6e4822988ca22c55c50ac662aa5b00f194e6", - "upstreamSha256": "9cd255ca44489af5af1041301e8a6e4822988ca22c55c50ac662aa5b00f194e6" - }, - { - "path": "clients/web/src/components/elements/ReplayButton/ReplayButton.tsx", - "sha256": "e8b31a40198f54645ca7e03a2bead8376cd3c56071e1f514a319969a02ba6bfc", - "upstreamSha256": "e8b31a40198f54645ca7e03a2bead8376cd3c56071e1f514a319969a02ba6bfc" - }, - { - "path": "clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx", - "sha256": "319ef13f663df458928735380189c069bcaf7198f97eec71b322670ffcae3aa1", - "upstreamSha256": "319ef13f663df458928735380189c069bcaf7198f97eec71b322670ffcae3aa1" - }, - { - "path": "clients/web/src/components/elements/SortToggle/SortToggle.tsx", - "sha256": "65c831ba8375ca6cea7b7daffb66d2b5dba1fedeeaeca38d02c265410dd29dd8", - "upstreamSha256": "65c831ba8375ca6cea7b7daffb66d2b5dba1fedeeaeca38d02c265410dd29dd8" - }, - { - "path": "clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx", - "sha256": "3a953c17586e292cdc6a5e6da8c7a2bb23430e4f042ab8536e22d14a8f53f041", - "upstreamSha256": "3a953c17586e292cdc6a5e6da8c7a2bb23430e4f042ab8536e22d14a8f53f041" - }, - { - "path": "clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx", - "sha256": "396257b7930a04abe36279c7f5dc563892a53f91faa87634e430ebe034ff0903", - "upstreamSha256": "396257b7930a04abe36279c7f5dc563892a53f91faa87634e430ebe034ff0903" - }, - { - "path": "clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts", - "sha256": "20bcc6707c2ab8fe198c500bb4a9a15ed7790641baca6695d7d6992130d5431e", - "upstreamSha256": "20bcc6707c2ab8fe198c500bb4a9a15ed7790641baca6695d7d6992130d5431e" - }, - { - "path": "clients/web/src/components/elements/accessibleTextColor.ts", - "sha256": "5927490b3818113d24aaa220ca0684bbd10af170fc5ef35979d1508b4b9dc7c0", - "upstreamSha256": "5927490b3818113d24aaa220ca0684bbd10af170fc5ef35979d1508b4b9dc7c0" - }, - { - "path": "clients/web/src/components/elements/filledBadgeColor.ts", - "sha256": "2a9b4a2421e14b1e6a58758439400d7f2a1d88e56e7dc221bd2185399329c096", - "upstreamSha256": "2a9b4a2421e14b1e6a58758439400d7f2a1d88e56e7dc221bd2185399329c096" - }, - { - "path": "clients/web/src/components/groups/AppControls/AppControls.tsx", - "sha256": "51c27e1f95f60c8de19d749754c860721e210d763053a76dbe7ce59ea3d090a1", - "upstreamSha256": "51c27e1f95f60c8de19d749754c860721e210d763053a76dbe7ce59ea3d090a1" - }, - { - "path": "clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx", - "sha256": "36b4fcd5a0899e1d6c8ce9fb52fc6da89bdbc2130a0fc21979abacf9bd7dc8af", - "upstreamSha256": "36b4fcd5a0899e1d6c8ce9fb52fc6da89bdbc2130a0fc21979abacf9bd7dc8af" - }, - { - "path": "clients/web/src/components/groups/AppListItem/AppListItem.tsx", - "sha256": "5dcbce4591e14c9b2c23b6e1a71dbf1e3204ec0996373ec2fc2017b5766dd03f", - "upstreamSha256": "5dcbce4591e14c9b2c23b6e1a71dbf1e3204ec0996373ec2fc2017b5766dd03f" - }, - { - "path": "clients/web/src/components/groups/LogControls/LogControls.tsx", - "sha256": "489265baceba4ebb632ead9d8bbb9d1d7d8758690247ab0faaaa14c0335aabb7", - "upstreamSha256": "489265baceba4ebb632ead9d8bbb9d1d7d8758690247ab0faaaa14c0335aabb7" - }, - { - "path": "clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx", - "sha256": "0ed9931bf2fe2132a0421a7a477dc8225d8ea8fba8895492aef056bbff58f610", - "upstreamSha256": "0ed9931bf2fe2132a0421a7a477dc8225d8ea8fba8895492aef056bbff58f610" - }, - { - "path": "clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx", - "sha256": "c3f65eee909077557cac521fd9acf346c76f98f0b5f7409d0c85a1a4042c5676", - "upstreamSha256": "c3f65eee909077557cac521fd9acf346c76f98f0b5f7409d0c85a1a4042c5676" - }, - { - "path": "clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx", - "sha256": "47e31bf3b7c07c817659b380a05bdab3ee7d15b2736fcec8a4a64d551809a53a", - "upstreamSha256": "47e31bf3b7c07c817659b380a05bdab3ee7d15b2736fcec8a4a64d551809a53a" - }, - { - "path": "clients/web/src/components/groups/NetworkControls/NetworkControls.tsx", - "sha256": "6f1296e038fea1e77f5046d0a46930f9866f076adce1038165320c333b1bc94d", - "upstreamSha256": "6f1296e038fea1e77f5046d0a46930f9866f076adce1038165320c333b1bc94d" - }, - { - "path": "clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx", - "sha256": "830d2768445f77741379eecffb428ed2653acd1111f03a9b3e440c895ebecb0c", - "upstreamSha256": "830d2768445f77741379eecffb428ed2653acd1111f03a9b3e440c895ebecb0c" - }, - { - "path": "clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx", - "sha256": "3281e49e1aa22b90b1d6ba38bacbfbcc406afdbcf657d4cf9429faca4b436dca", - "upstreamSha256": "3281e49e1aa22b90b1d6ba38bacbfbcc406afdbcf657d4cf9429faca4b436dca" - }, - { - "path": "clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx", - "sha256": "9ab9a7cd5746ea5fef03da4f5c3abb0e954bd93d4c5a08a904b63bacabe2c007", - "upstreamSha256": "9ab9a7cd5746ea5fef03da4f5c3abb0e954bd93d4c5a08a904b63bacabe2c007" - }, - { - "path": "clients/web/src/components/groups/PromptControls/PromptControls.tsx", - "sha256": "4a9ede42314bb9c9b8021e385ed546c6909f3d40ef01c10f5efd6424eb29aa79", - "upstreamSha256": "4a9ede42314bb9c9b8021e385ed546c6909f3d40ef01c10f5efd6424eb29aa79" - }, - { - "path": "clients/web/src/components/groups/PromptListItem/PromptListItem.tsx", - "sha256": "8207507aa6c6403bbb12fe31334143541d63ad521a7b13d1b7ec1ff760613260", - "upstreamSha256": "8207507aa6c6403bbb12fe31334143541d63ad521a7b13d1b7ec1ff760613260" - }, - { - "path": "clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx", - "sha256": "56ba091e42c7b9c7cff111ca8838c39f111d274d56c840ffddf4f83b5e6171c1", - "upstreamSha256": "56ba091e42c7b9c7cff111ca8838c39f111d274d56c840ffddf4f83b5e6171c1" - }, - { - "path": "clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx", - "sha256": "1ecbd7de964c22a62fb691b0ec5208de0fcf63a6a76e17f1e9e7cac4b1e98cf5", - "upstreamSha256": "1ecbd7de964c22a62fb691b0ec5208de0fcf63a6a76e17f1e9e7cac4b1e98cf5" - }, - { - "path": "clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx", - "sha256": "209801f3654837c20cc289d528c362d5bc9d8734fde487f4976f12a8345587da", - "upstreamSha256": "209801f3654837c20cc289d528c362d5bc9d8734fde487f4976f12a8345587da" - }, - { - "path": "clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx", - "sha256": "462addc7773e539528ae9cd86d29cd43c81a83e6a4f9ad15e8978f0749dfc68f", - "upstreamSha256": "462addc7773e539528ae9cd86d29cd43c81a83e6a4f9ad15e8978f0749dfc68f" - }, - { - "path": "clients/web/src/components/groups/ResourceControls/ResourceControls.tsx", - "sha256": "89bd6c99da4db63b0afb69fbfa9e74a7711c2a0b78fdf4dc2be0237827cfd6f3", - "upstreamSha256": "89bd6c99da4db63b0afb69fbfa9e74a7711c2a0b78fdf4dc2be0237827cfd6f3" - }, - { - "path": "clients/web/src/components/groups/ResourceLink/ResourceLink.tsx", - "sha256": "860c979724bbe45acfee0f1b86f140f0dcb62e51af9029d4fe922e6ca8a01006", - "upstreamSha256": "860c979724bbe45acfee0f1b86f140f0dcb62e51af9029d4fe922e6ca8a01006" - }, - { - "path": "clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx", - "sha256": "f2bb6d31fa877200d2c6fcfaaee7f1555fffe7dd61bac5876b60ae1eda00f063", - "upstreamSha256": "f2bb6d31fa877200d2c6fcfaaee7f1555fffe7dd61bac5876b60ae1eda00f063" - }, - { - "path": "clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx", - "sha256": "bb3dde69d7b30105b3b01c5fbf92c87e55fd2e018dad820a38abf9c9828c3256", - "upstreamSha256": "bb3dde69d7b30105b3b01c5fbf92c87e55fd2e018dad820a38abf9c9828c3256" - }, - { - "path": "clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx", - "sha256": "b5f0621ff7baded6eea378792bf52fcb6cd1a6b2b0654a0938397c9c4ab2c57c", - "upstreamSha256": "b5f0621ff7baded6eea378792bf52fcb6cd1a6b2b0654a0938397c9c4ab2c57c" - }, - { - "path": "clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx", - "sha256": "bfabf183b854622837093f6cded5441f9ae57b5bb891896d97e8fca4c4a8d219", - "upstreamSha256": "bfabf183b854622837093f6cded5441f9ae57b5bb891896d97e8fca4c4a8d219" - }, - { - "path": "clients/web/src/components/groups/SchemaForm/SchemaForm.tsx", - "sha256": "58c197220118b6941d50e725a4a48323c0d8429f6142d9675541929b7383b9f9", - "upstreamSha256": "58c197220118b6941d50e725a4a48323c0d8429f6142d9675541929b7383b9f9" - }, - { - "path": "clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx", - "sha256": "d7e001945f955ed4d75f73be3dc516b6455310b5b0005cfb2fb8be78fab6e508", - "upstreamSha256": "d7e001945f955ed4d75f73be3dc516b6455310b5b0005cfb2fb8be78fab6e508" - }, - { - "path": "clients/web/src/components/groups/ToolControls/ToolControls.tsx", - "sha256": "5760455bc25d4a2d9ee0d0f0a988c1d365c721a8d9eba774d1097d1039801d3f", - "upstreamSha256": "5760455bc25d4a2d9ee0d0f0a988c1d365c721a8d9eba774d1097d1039801d3f" - }, - { - "path": "clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx", - "sha256": "e8986c43a34e4a033255b59fa46ef3da27c4f194ee8aaaa7dbe9cdfcf392bc53", - "upstreamSha256": "e8986c43a34e4a033255b59fa46ef3da27c4f194ee8aaaa7dbe9cdfcf392bc53" - }, - { - "path": "clients/web/src/components/groups/ToolListItem/ToolListItem.tsx", - "sha256": "2c3e9a3431bd325b3ac5d1cb9a080af04d9c86e18ce8c73776fe09b66d11eab6", - "upstreamSha256": "2c3e9a3431bd325b3ac5d1cb9a080af04d9c86e18ce8c73776fe09b66d11eab6" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx", - "sha256": "6d5b60615f261e2920cf0d4687a57882559304eaf85be7edc8d1e831b1067da5", - "upstreamSha256": "6d5b60615f261e2920cf0d4687a57882559304eaf85be7edc8d1e831b1067da5" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx", - "sha256": "1410c4a9a1a07e2167fcac527fe080b5da5734d46d7abb81339e326aa8d49825", - "upstreamSha256": "1410c4a9a1a07e2167fcac527fe080b5da5734d46d7abb81339e326aa8d49825" - }, - { - "path": "clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts", - "sha256": "b39d6ac8b787992009939637bd53b68e7f44a3375e15cd783a0ea2609ec2c01b", - "upstreamSha256": "b39d6ac8b787992009939637bd53b68e7f44a3375e15cd783a0ea2609ec2c01b" - }, - { - "path": "clients/web/src/components/groups/protocolUtils.ts", - "sha256": "a8f7405db58c415e451ca14ecd9a8dc6f406a1fa3df662e33e0da2cf07cf5952", - "upstreamSha256": "a8f7405db58c415e451ca14ecd9a8dc6f406a1fa3df662e33e0da2cf07cf5952" - }, - { - "path": "clients/web/src/components/screens/AppsScreen/AppsScreen.tsx", - "sha256": "aba238f3006b8438e57db5d106cfb2775b7a707ca492f704362bca1f993c82a0", - "upstreamSha256": "aba238f3006b8438e57db5d106cfb2775b7a707ca492f704362bca1f993c82a0" - }, - { - "path": "clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx", - "sha256": "6ff4c2e80b986be8b425d971261876b7e85139c738cb7a83998008a566d1e9c5", - "upstreamSha256": "6ff4c2e80b986be8b425d971261876b7e85139c738cb7a83998008a566d1e9c5" - }, - { - "path": "clients/web/src/components/screens/LoggingScreen/logLevels.ts", - "sha256": "617f65415b58e97e5fc1a829c14317b694554036042cac18f6ec072cee3a26ee", - "upstreamSha256": "617f65415b58e97e5fc1a829c14317b694554036042cac18f6ec072cee3a26ee" - }, - { - "path": "clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx", - "sha256": "b65175c08d4ee2bbfce83db88c1440bdb19ae4939eb806422430805f3cdcb3fe", - "upstreamSha256": "b65175c08d4ee2bbfce83db88c1440bdb19ae4939eb806422430805f3cdcb3fe" - }, - { - "path": "clients/web/src/components/screens/NetworkScreen/fetchCategories.ts", - "sha256": "7b40687626ce3f0f88888c22b367b5e07f042d8ce1085c853914116a2d3388d3", - "upstreamSha256": "7b40687626ce3f0f88888c22b367b5e07f042d8ce1085c853914116a2d3388d3" - }, - { - "path": "clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx", - "sha256": "a86f58de5889810bced42c1f069dd538ba56f75180a976564875fc6950ea8ad8", - "upstreamSha256": "a86f58de5889810bced42c1f069dd538ba56f75180a976564875fc6950ea8ad8" - }, - { - "path": "clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx", - "sha256": "7b4b8ecdf0708d732364db5bc156868ca4f819d98117aa7b03b1349658b29937", - "upstreamSha256": "7b4b8ecdf0708d732364db5bc156868ca4f819d98117aa7b03b1349658b29937" - }, - { - "path": "clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx", - "sha256": "e7c64625c977fa5c1f87fe7b4a1ffd029d6886eb4607aabbea15aeadcd234bfc", - "upstreamSha256": "e7c64625c977fa5c1f87fe7b4a1ffd029d6886eb4607aabbea15aeadcd234bfc" - }, - { - "path": "clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx", - "sha256": "132a85ddb7fbe8119d1e4ca1271c22eb423b2dd0262ea16b92ccb0775eebe288", - "upstreamSha256": "132a85ddb7fbe8119d1e4ca1271c22eb423b2dd0262ea16b92ccb0775eebe288" - }, - { - "path": "clients/web/src/hooks/useScrollMemory.ts", - "sha256": "8c5e431b438f398cce8339ff531ee8828502332a81f2ea4ab8246d437826ea5f", - "upstreamSha256": "8c5e431b438f398cce8339ff531ee8828502332a81f2ea4ab8246d437826ea5f" - }, - { - "path": "clients/web/src/hooks/useValueChange.ts", - "sha256": "2917c940c3ffb572709efa08528bbb7c31edc61af2a3b6ca267c4c8952ee53a5", - "upstreamSha256": "2917c940c3ffb572709efa08528bbb7c31edc61af2a3b6ca267c4c8952ee53a5" - }, - { - "path": "clients/web/src/lib/downloadFile.ts", - "sha256": "b3bda21ccacd36fa85bba4a58177b9590701fd0fabc83fe22bc1dd5dd6ca6057", - "upstreamSha256": "b3bda21ccacd36fa85bba4a58177b9590701fd0fabc83fe22bc1dd5dd6ca6057" - }, - { - "path": "clients/web/src/utils/inspectorTabs.test.ts", - "sha256": "9e1093cdc193798a63e2ad1587bb2621f244de35806506fee86c9c8e8fa87191", - "upstreamSha256": "fa716dc85b09ba39b64ae0017a28ff74b9cfe395eae6191cfbbbccd6a74700d2" - }, - { - "path": "clients/web/src/utils/inspectorTabs.ts", - "sha256": "2bada46300f4a4ff3d3736a69f66606f7ddb3f2790ae66524134139ad800780e", - "upstreamSha256": "2bada46300f4a4ff3d3736a69f66606f7ddb3f2790ae66524134139ad800780e" - }, - { - "path": "clients/web/src/utils/jsonUtils.ts", - "sha256": "a6688d034fbd2048199af0c0b832e04a03cbdb5088a9c61cd84ec16beb92cbdb", - "upstreamSha256": "a6688d034fbd2048199af0c0b832e04a03cbdb5088a9c61cd84ec16beb92cbdb" - }, - { - "path": "clients/web/src/utils/maskSecrets.ts", - "sha256": "9bdf2a98a843912639d0a0ac86f4a24c8b1355ce81a055932f0c949c8442e2b0", - "upstreamSha256": "9bdf2a98a843912639d0a0ac86f4a24c8b1355ce81a055932f0c949c8442e2b0" - }, - { - "path": "clients/web/src/utils/mcpNetworkHeaders.ts", - "sha256": "3388f31d77cbf670946b09da16ba7a2cdf8842bf041a3fb8a4d91bb154f20526", - "upstreamSha256": "3388f31d77cbf670946b09da16ba7a2cdf8842bf041a3fb8a4d91bb154f20526" - }, - { - "path": "clients/web/src/utils/oauthNetworkPhase.ts", - "sha256": "75c2a99f02033c613b250f926f400d4666606dbf65765201935c2b2d76d7c14e", - "upstreamSha256": "75c2a99f02033c613b250f926f400d4666606dbf65765201935c2b2d76d7c14e" - }, - { - "path": "clients/web/src/utils/sandbox-csp.ts", - "sha256": "d70dbbcab522ded755a5acb8f99942b564045c2db4d21519e7af662dadfd72f3", - "upstreamSha256": "d70dbbcab522ded755a5acb8f99942b564045c2db4d21519e7af662dadfd72f3" - }, - { - "path": "clients/web/src/utils/toolUtils.ts", - "sha256": "db271668aa36755f17cb32ad3dd4859da6c7244fe1b676bc180325939efed233", - "upstreamSha256": "db271668aa36755f17cb32ad3dd4859da6c7244fe1b676bc180325939efed233" - }, - { - "path": "core/auth/providers.ts", - "sha256": "ca2c9191679b9383cb3a5179e3732db33d8ea147e2991c9de4a842714f319c41", - "upstreamSha256": "ca2c9191679b9383cb3a5179e3732db33d8ea147e2991c9de4a842714f319c41" - }, - { - "path": "core/auth/storage.ts", - "sha256": "ab9b8d78cde7f626b45134c5582f2d1dff509c2cf143873b8ae3c46f9069af76", - "upstreamSha256": "ab9b8d78cde7f626b45134c5582f2d1dff509c2cf143873b8ae3c46f9069af76" - }, - { - "path": "core/auth/types.ts", - "sha256": "9ee8b27f521e76246974d8627b3e470a1de763325a46620b30543411b6c6d5dc", - "upstreamSha256": "9ee8b27f521e76246974d8627b3e470a1de763325a46620b30543411b6c6d5dc" - }, - { - "path": "core/auth/utils.ts", - "sha256": "62509317c257047f4a3771f26117acbf89789195d35867d18d540f887f18d506", - "upstreamSha256": "62509317c257047f4a3771f26117acbf89789195d35867d18d540f887f18d506" - }, - { - "path": "core/client/types.ts", - "sha256": "2a59a53f811a5893c39b87960d401484e2f7f1553a1938a2bc836717adfb5bca", - "upstreamSha256": "2a59a53f811a5893c39b87960d401484e2f7f1553a1938a2bc836717adfb5bca" - }, - { - "path": "core/json/jsonUtils.ts", - "sha256": "2805dc0e482975d05a201b58ab6e8bc47191cc2695e44858c3c4f2d95e3d4a5c", - "upstreamSha256": "2805dc0e482975d05a201b58ab6e8bc47191cc2695e44858c3c4f2d95e3d4a5c" - }, - { - "path": "core/json/xMcpHeader.ts", - "sha256": "6eac71266c20354bd621527ae4d47769396bbc892a89650df54a08fa4ca337ed", - "upstreamSha256": "6eac71266c20354bd621527ae4d47769396bbc892a89650df54a08fa4ca337ed" - }, - { - "path": "core/logging/logger.ts", - "sha256": "317c2722b2c343eb4cdfbcfa80f30d2d7197081c952b10c508f6ad5d024a9a97", - "upstreamSha256": "317c2722b2c343eb4cdfbcfa80f30d2d7197081c952b10c508f6ad5d024a9a97" - }, - { - "path": "core/mcp/fetchTracking.ts", - "sha256": "197e86d947afda50310c1ea1001587fca33a96dc77ff06b2384a7565e29af15f", - "upstreamSha256": "197e86d947afda50310c1ea1001587fca33a96dc77ff06b2384a7565e29af15f" - }, - { - "path": "core/mcp/types.ts", - "sha256": "24cc496f63818123a7d2ecdcb39d6ccef36703d1e786e97fb3f33eb81e79f0d1", - "upstreamSha256": "b8e68e59784b3372e8c9dede6cc3daa6f39ac982c851553a20305523c0c00abf" - } - ], - "license": { - "path": "repository:LICENSE.inspector", - "sha256": "fcf5eb4c9424e8cc443554f22e0dbf42a5a6a5dbfcf07b5e7f742efdf2ff280a" - }, - "mcpSdkVersion": "2.0.0", - "patches": [ - { - "path": "patches/001-rstest-inspector-tabs-import.patch", - "sha256": "eb679e8e02a5d89f631c99a9a6857be14a0a08fd5944cd0fa87e5dffbaf22574" - }, - { - "path": "patches/002-remove-legacy-sse-mcp-types.patch", - "sha256": "cde5921826bb9629dffc9827ba2f1ad6a6395f70e4ada64128b43781d03c8cf5" - } - ], - "publicImports": [ - "@modelcontextprotocol/ext-apps/app-bridge", - "react-icons/md", - "react-icons/ri", - "react-icons/tb", - "react-icons/ti", - "react-syntax-highlighter/dist/esm/languages/prism/css", - "react-syntax-highlighter/dist/esm/languages/prism/json", - "react-syntax-highlighter/dist/esm/languages/prism/markdown", - "react-syntax-highlighter/dist/esm/languages/prism/markup", - "react-syntax-highlighter/dist/esm/languages/prism/yaml", - "react-syntax-highlighter/dist/esm/prism-light", - "react-syntax-highlighter/dist/esm/styles/prism/tomorrow" - ], - "repository": "https://github.com/modelcontextprotocol/inspector.git", - "retainedTests": [ - "clients/web/src/utils/inspectorTabs.test.ts" - ], - "testDependencies": [ - "@rstest/core" - ], - "version": "2.2.0" -} diff --git a/packages/workbench/src/inspector/adapter/closure-screens.d.ts b/packages/workbench/src/inspector/adapter/closure-screens.d.ts deleted file mode 100644 index 97d9052c5..000000000 --- a/packages/workbench/src/inspector/adapter/closure-screens.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ComponentType } from 'react'; - -export const AppsScreen: ComponentType>; -export const LoggingScreen: ComponentType>; -export const NetworkScreen: ComponentType>; -export const PromptsScreen: ComponentType>; -export const ProtocolScreen: ComponentType>; -export const ResourcesScreen: ComponentType>; -export const ToolsScreen: ComponentType>; diff --git a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts b/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts deleted file mode 100644 index 3423781ff..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -export const AppsScreen: unknown; -export const LoggingScreen: unknown; -export const NetworkScreen: unknown; -export const PromptsScreen: unknown; -export const ProtocolScreen: unknown; -export const ResourcesScreen: unknown; -export const ToolsScreen: unknown; - -import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; -import type { Ref, RefObject } from 'react'; - -export type McpAppRendererDisplayMode = 'fullscreen' | 'inline' | 'pip'; - -export type McpAppRendererJsonValue = - | null - | boolean - | number - | string - | readonly McpAppRendererJsonValue[] - | Readonly>; - -export type McpAppRendererTool = Tool; - -export interface McpAppRendererMessage { - readonly content: readonly McpAppRendererJsonValue[]; - readonly role: 'user'; -} - -export interface AppRendererBridge { - addEventListener(type: 'initialized', listener: () => void): void; - addEventListener(type: 'loggingmessage', listener: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void): void; - addEventListener(type: 'sizechange', listener: (params: Readonly<{ readonly height?: number; readonly width?: number }>) => void): void; - close(): Promise; - onmessage?: (params: McpAppRendererMessage) => Promise>; - onrequestdisplaymode?: (params: Readonly<{ readonly mode: McpAppRendererDisplayMode }>) => Promise>; - sendHostContextChange(context: Partial): Promise; - sendToolCancelled(params: Readonly<{ readonly reason: string }>): Promise; - sendToolInput(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolInputPartial(params: Readonly<{ readonly arguments: Record }>): Promise; - sendToolResult(result: CallToolResult): Promise; - teardownResource(params: Readonly>): Promise>>; -} - -export type BridgeFactory = ( - iframe: HTMLIFrameElement, - tool: McpAppRendererTool, -) => AppRendererBridge | Promise; - -export interface AppRendererHandle { - sendToolCancelled(reason: string): Promise; - sendToolInput(args: Record): Promise; - sendToolResult(result: CallToolResult): Promise; - teardown(): Promise; -} - -export interface AppRendererProps { - readonly bridgeFactory: BridgeFactory; - readonly displayMode?: McpAppRendererDisplayMode; - readonly onAppStatusChange?: (status: 'error' | 'loading' | 'ready') => void; - readonly onError?: (error: Error) => void; - readonly onLog?: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void; - readonly onMessage?: (params: McpAppRendererMessage) => void; - readonly onRequestDisplayMode?: (requested: McpAppRendererDisplayMode) => McpAppRendererDisplayMode; - readonly onSizeChange?: (size: Readonly<{ readonly height?: number; readonly width?: number }>) => void; - readonly partialInputs?: readonly Readonly>[]; - readonly containerRef?: RefObject; - readonly ref?: Ref; - readonly sandboxPath: string; - readonly tool: McpAppRendererTool; -} - -export const AppRenderer: (props: AppRendererProps) => import('react').ReactNode; - -export interface McpAppRendererHostContext { - readonly availableDisplayModes?: readonly McpAppRendererDisplayMode[]; - readonly containerDimensions?: Readonly<{ readonly height: number; readonly width: number }>; - readonly displayMode?: McpAppRendererDisplayMode; - readonly theme?: 'dark' | 'light'; -} - -export const snapshotHostContext: ( - container: HTMLElement | null, - availableDisplayModes: readonly McpAppRendererDisplayMode[], -) => McpAppRendererHostContext; diff --git a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js b/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js deleted file mode 100644 index f188ea52b..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-closure-vendor.js +++ /dev/null @@ -1,15 +0,0 @@ -import { lazy } from 'react'; - -import './vendor-react-runtime.jsx'; - -const screen = (load, name) => lazy(async () => ({ default: (await load())[name] })); - -export const AppsScreen = screen(() => import('../vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx'), 'AppsScreen'); -export const LoggingScreen = screen(() => import('../vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx'), 'LoggingScreen'); -export const NetworkScreen = screen(() => import('../vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx'), 'NetworkScreen'); -export const PromptsScreen = screen(() => import('../vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx'), 'PromptsScreen'); -export const ProtocolScreen = screen(() => import('../vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx'), 'ProtocolScreen'); -export const ResourcesScreen = screen(() => import('../vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx'), 'ResourcesScreen'); -export const ToolsScreen = screen(() => import('../vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx'), 'ToolsScreen'); -export { AppRenderer } from '../vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx'; -export { snapshotHostContext } from '../vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts deleted file mode 100644 index 9f4e6d138..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-entry.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './vendor-react-runtime.jsx'; -import '@mantine/core/styles.css'; - -import './inspector-session-adapter.css'; - -export * from './inspector-session-adapter.tsx'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx b/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx deleted file mode 100644 index 5ac711f40..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-fixture.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -import type { McpBrowserSessionModel } from '../../mcp/mcp-session-model.ts'; -import type { McpSessionControllerRequest } from '../../mcp/mcp-session-controller.ts'; - -import { InspectorRuntimeEvidence, InspectorSessionAdapter } from './inspector-session-adapter-entry.ts'; - -const model = { - activeRequests: {}, - binding: { epochId: 'fixture-epoch', serverName: 'fixture', target: 'codex' }, - catalogs: { - prompts: [{ description: 'Fixture prompt', name: 'fixture-prompt' }], - resourceTemplates: [], - resources: [{ description: 'Fixture resource', mimeType: 'text/plain', name: 'fixture-resource', uri: 'fixture://resource' }], - tools: [{ description: 'Fixture tool', inputSchema: { properties: {}, type: 'object' }, name: 'fixture-tool' }], - }, - conciseTrace: [], - connection: { protocolVersion: '2026-06-01' }, - diagnostics: [], - logs: [], - phase: 'ready', - progress: [], - sessionId: 'fixture-session', - timeline: { - droppedThroughSequence: 0, - entries: [ - { direction: 'client', kind: 'frame', message: { id: 1, jsonrpc: '2.0', method: 'initialize' }, occurredAt: 1_700_000_000_001, sequence: 1 }, - { direction: 'server', kind: 'frame', message: { id: 1, jsonrpc: '2.0', result: { protocolVersion: '2026-06-01' } }, occurredAt: 1_700_000_000_002, sequence: 2 }, - { direction: 'client', kind: 'frame', message: { id: 2, jsonrpc: '2.0', method: 'tools/call', params: { name: 'fixture-tool' } }, occurredAt: 1_700_000_000_003, sequence: 3 }, - { direction: 'server', kind: 'frame', message: { id: 2, jsonrpc: '2.0', result: { content: [] } }, occurredAt: 1_700_000_000_004, sequence: 4 }, - { kind: 'logging', occurredAt: 1_700_000_000_005, payload: { data: 'Fixture connected', level: 'info' }, sequence: 5 }, - ], - lastSequence: 5, - }, -} as unknown as McpBrowserSessionModel; - -interface FixtureDeferred { - readonly promise: Promise; - readonly resolve: (value: unknown) => void; -} - -interface InspectorSessionAdapterFixtureHarness { - readonly resolveNextTool: (text: string) => void; - readonly setRuntimeBinding: (revision: number, definitionDigest: string) => void; -} - -declare global { - interface Window { - __inspectorSessionAdapterFixture?: InspectorSessionAdapterFixtureHarness; - } -} - -const deferred = (): FixtureDeferred => { - let resolve: (value: unknown) => void = () => undefined; - const promise = new Promise((next) => { resolve = next; }); - return Object.freeze({ promise, resolve }); -}; - -const runtimeModel = (sessionRevision: number, definitionDigest: string): McpBrowserSessionModel => ({ - ...model, - binding: { - binding: { - definitionDigest, - registryRevision: 1, - serverDigest: 'fixture-server-digest', - serverName: 'fixture', - sessionId: 'fixture-runtime-session', - sessionRevision, - target: 'portable', - transportDigest: 'fixture-transport-digest', - }, - kind: 'runtime', - }, -} as McpBrowserSessionModel); - -const pendingTools: FixtureDeferred[] = []; - -const controller = { - cancel: () => false, - invoke: (request: McpSessionControllerRequest): Promise => { - if (request.operation !== 'callTool') return Promise.resolve({ content: [] }); - const next = deferred(); - pendingTools.push(next); - return next.promise; - }, -}; - -let currentModel = model; -const root = createRoot(document.getElementById('root')!); - -const render = (): void => root.render( - - -); - -window.__inspectorSessionAdapterFixture = Object.freeze({ - resolveNextTool: (text: string): void => { - pendingTools.shift()?.resolve({ content: [{ text, type: 'text' }] }); - }, - setRuntimeBinding: (revision: number, definitionDigest: string): void => { - currentModel = runtimeModel(revision, definitionDigest); - render(); - }, -} satisfies InspectorSessionAdapterFixtureHarness); - -render(); diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts deleted file mode 100644 index 980ec6f8d..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-model.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { McpSessionTraceEntry } from '../../../../agent-bundle/src/contracts/mcp-session.ts'; -import type { DevRuntimeDiagnostic, DevRuntimeInspectionEnvelope, DevRuntimeTraceSpan } from '../../../../agent-bundle/src/contracts/runtime.ts'; -import type { McpBrowserSessionModel, McpBrowserSessionTimelineEntry } from '../../mcp/mcp-session-model.ts'; - -export type InspectorTab = 'tools' | 'resources' | 'prompts' | 'protocol' | 'logging'; - -export interface InspectorProtocolEntry { - readonly direction: 'request' | 'response' | 'notification'; - readonly id: string; - readonly message: Readonly>; - readonly origin: 'client' | 'server'; - readonly sequence: number; - readonly timestamp: Date; -} - -export interface InspectorLogEntry { - readonly params: Readonly<{ readonly data: unknown; readonly level: string; readonly logger?: string }>; - readonly receivedAt: Date; - readonly sequence: number; -} - -export type InspectorRuntimeEvidenceInput = - | Readonly<{ readonly kind: 'protocol'; readonly protocol?: DevRuntimeInspectionEnvelope['protocol']; readonly trace: readonly DevRuntimeTraceSpan[] }> - | Readonly<{ readonly diagnostics: readonly DevRuntimeDiagnostic[]; readonly kind: 'diagnostics' }> - | Readonly<{ - /** Presentation-only span disclosure; details always render when absent. */ - readonly expansion?: Readonly<{ - readonly expandedIds: readonly string[]; - readonly onToggle: (spanId: string) => void; - }>; - readonly kind: 'trace'; - readonly trace: readonly DevRuntimeTraceSpan[]; - }>; - -type FrameTraceEntry = McpSessionTraceEntry & Readonly<{ - readonly direction: 'client' | 'server'; - readonly kind: 'frame'; - readonly message: unknown; -}>; - -type LoggingTraceEntry = McpSessionTraceEntry & Readonly<{ - readonly kind: 'logging'; - readonly payload: unknown; -}>; - -export const inspectorSessionTabs: readonly Readonly<{ readonly id: InspectorTab; readonly label: string }>[] = [ - { id: 'tools', label: 'Tools' }, - { id: 'resources', label: 'Resources' }, - { id: 'prompts', label: 'Prompts' }, - { id: 'protocol', label: 'Protocol' }, - { id: 'logging', label: 'Logging' }, -]; - -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const isFrame = (entry: McpBrowserSessionTimelineEntry): entry is FrameTraceEntry => - 'kind' in entry && entry.kind === 'frame'; - -const isLogging = (entry: McpBrowserSessionTimelineEntry): entry is LoggingTraceEntry => - 'kind' in entry && entry.kind === 'logging'; - -const jsonRpcDirection = (message: Readonly>): InspectorProtocolEntry['direction'] | undefined => { - const hasId = Object.hasOwn(message, 'id'); - const hasMethod = typeof message.method === 'string'; - if (hasId && hasMethod) return 'request'; - if (!hasId && hasMethod) return 'notification'; - if (hasId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))) return 'response'; - return undefined; -}; - -const logParams = (payload: unknown): InspectorLogEntry['params'] | undefined => { - if (!isRecord(payload) || typeof payload.level !== 'string' || !Object.hasOwn(payload, 'data')) return undefined; - return payload as InspectorLogEntry['params']; -}; - -export const inspectorSessionBindingKey = (binding: McpBrowserSessionModel['binding']): string => { - if (binding === undefined) return ''; - if ('kind' in binding) { - return binding.kind === 'runtime' - ? `runtime\u0000${binding.binding.sessionId}\u0000${binding.binding.sessionRevision}\u0000${binding.binding.target}\u0000${binding.binding.serverName}` - : ''; - } - return `${binding.epochId}\u0000${binding.target}\u0000${binding.serverName}`; -}; - -export const inspectorProtocolEntries = ( - timeline: readonly McpBrowserSessionTimelineEntry[], -): InspectorProtocolEntry[] => timeline.flatMap((entry) => { - if (!isFrame(entry) || !isRecord(entry.message)) return []; - const direction = jsonRpcDirection(entry.message); - if (direction === undefined) return []; - return [{ - direction, - id: `trace-${entry.sequence}`, - message: entry.message, - origin: entry.direction, - sequence: entry.sequence, - timestamp: new Date(entry.occurredAt), - }]; -}); - -export const inspectorLogEntries = ( - timeline: readonly McpBrowserSessionTimelineEntry[], -): InspectorLogEntry[] => timeline.flatMap((entry) => { - if (!isLogging(entry)) return []; - const params = logParams(entry.payload); - return params === undefined ? [] : [{ params, receivedAt: new Date(entry.occurredAt), sequence: entry.sequence }]; -}); diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts b/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts deleted file mode 100644 index 51ab63362..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.d.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { ComponentType } from 'react'; -import type { - CallToolResult, - GetPromptResult, - LoggingLevel, - Prompt, - Resource, - ResourceTemplateType as ResourceTemplate, - Tool, -} from '@modelcontextprotocol/client'; - -export type SortDirection = 'oldest-first' | 'newest-first'; - -export interface ListPaginationControlsProps { - readonly canLoadMore: boolean; - readonly loadedPages: number; - readonly onLoadMore: () => void; - readonly onPaginatedChange: (paginated: boolean) => void; - readonly paginated: boolean; -} - -export interface LogEntryData { - readonly params: Readonly<{ readonly data: unknown; readonly level: LoggingLevel; readonly logger?: string }>; - readonly receivedAt: Date; -} - -export interface ToolCallState { - readonly error?: string; - readonly result?: CallToolResult; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; -} - -export interface ToolsUiState { - readonly formValues: Record; - readonly runAsTask: boolean; - readonly search: string; - readonly selectedToolName?: string; -} - -export interface ReadResourceState { - readonly error?: string; - readonly result?: unknown; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; - readonly uri?: string; -} - -export interface ResourcesUiState { - readonly openSections?: string[]; - readonly originatingTemplateUri?: string; - readonly search: string; - readonly selectedResourceUri?: string; - readonly selectedTemplateUri?: string; -} - -export interface GetPromptState { - readonly error?: string; - readonly promptName?: string; - readonly result?: GetPromptResult; - readonly status: 'idle' | 'pending' | 'ok' | 'error'; -} - -export interface PromptsUiState { - readonly argumentValues: Record; - readonly search: string; - readonly selectedPromptName?: string; - readonly submittedFor?: string; -} - -export interface MessageEntry { - readonly direction: 'request' | 'response' | 'notification'; - readonly id: string; - readonly message: unknown; - readonly origin?: 'client' | 'server'; - readonly timestamp: Date; -} - -export interface ProtocolUiState { - readonly search: string; - readonly visibleDirections: Record<'client' | 'server', boolean>; -} - -export interface LogsUiState { - readonly filterText: string; - readonly visibleLevels: Record; -} - -export const ToolsScreen: ComponentType<{ - readonly callState?: ToolCallState; - readonly listChanged: boolean; - readonly onCallTool: (name: string, args: Record) => void; - readonly onCancelCall?: () => void; - readonly onClearResult?: () => void; - readonly onRefreshList: () => void; - readonly onUiChange: (next: ToolsUiState) => void; - readonly pagination: ListPaginationControlsProps; - readonly serverSupportsTaskToolCalls: boolean; - readonly tools: Tool[]; - readonly ui: ToolsUiState; -}>; -export const ResourcesScreen: ComponentType<{ - readonly compact: boolean; - readonly listChanged: boolean; - readonly onCompactChange: (next: boolean) => void; - readonly onReadResource: (uri: string) => void; - readonly onRefreshList: () => void; - readonly onSubscribeResource: (uri: string) => void; - readonly onUiChange: (next: ResourcesUiState) => void; - readonly onUnsubscribeResource: (uri: string) => void; - readonly pagination: ListPaginationControlsProps; - readonly readState?: ReadResourceState; - readonly resources: Resource[]; - readonly subscriptions: unknown[]; - readonly subscriptionsSupported?: boolean; - readonly templates: ResourceTemplate[]; - readonly ui: ResourcesUiState; -}>; -export const PromptsScreen: ComponentType<{ - readonly getPromptState?: GetPromptState; - readonly listChanged: boolean; - readonly onGetPrompt: (name: string, args: Record) => void; - readonly onRefreshList: () => void; - readonly onUiChange: (next: PromptsUiState) => void; - readonly pagination: ListPaginationControlsProps; - readonly prompts: Prompt[]; - readonly ui: PromptsUiState; -}>; -export const ProtocolScreen: ComponentType<{ - readonly compact: boolean; - readonly entries: MessageEntry[]; - readonly onClearAll: () => void; - readonly onClearSection: (section: 'pinned' | 'history') => void; - readonly onExport: () => void; - readonly onExportSection: (section: 'pinned' | 'history') => void; - readonly onReplay: (id: string) => void; - readonly onSortChange: (next: SortDirection) => void; - readonly onToggleCompact: () => void; - readonly onTogglePin: (id: string) => void; - readonly onUiChange: (next: ProtocolUiState) => void; - readonly pinnedIds: Set; - readonly sortDirection: SortDirection; - readonly ui: ProtocolUiState; -}>; -export const LoggingScreen: ComponentType<{ - readonly currentLevel: LoggingLevel; - readonly embedded?: boolean; - readonly entries: LogEntryData[]; - readonly onClear: () => void; - readonly onExport: () => void; - readonly onSetLevel: (level: LoggingLevel) => void; - readonly onSortChange: (next: SortDirection) => void; - readonly onUiChange: (next: LogsUiState) => void; - readonly sortDirection: SortDirection; - readonly ui: LogsUiState; -}>; -export const ALL_LEVELS_VISIBLE: Record; -export const clearScrollMemory: () => void; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js b/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js deleted file mode 100644 index 32dc826a5..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter-vendor.js +++ /dev/null @@ -1,11 +0,0 @@ -import './vendor-react-runtime.jsx'; - -export { - LoggingScreen, - PromptsScreen, - ProtocolScreen, - ResourcesScreen, - ToolsScreen, -} from './vendor-screens.jsx'; -export { ALL_LEVELS_VISIBLE } from '../vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts'; -export { clearScrollMemory } from '../vendor/clients/web/src/hooks/useScrollMemory.ts'; diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter.css b/packages/workbench/src/inspector/adapter/inspector-session-adapter.css deleted file mode 100644 index be3b16a66..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter.css +++ /dev/null @@ -1,31 +0,0 @@ -.inspector-session-adapter { - min-width: 0; -} - -.inspector-runtime-evidence { - display: grid; - gap: 0.65rem; - min-width: 0; -} - -.inspector-runtime-evidence h3, -.inspector-runtime-evidence p { - margin: 0; -} - -.inspector-runtime-evidence ol { - display: grid; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; -} - -.inspector-runtime-evidence li { - min-width: 0; - overflow-wrap: anywhere; -} - -.inspector-runtime-evidence pre { - max-width: 100%; - overflow: auto; -} diff --git a/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx b/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx deleted file mode 100644 index fec998aa1..000000000 --- a/packages/workbench/src/inspector/adapter/inspector-session-adapter.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { createTheme, MantineProvider } from '@mantine/core'; -import type { - CallToolResult, - GetPromptResult, - LoggingLevel, - Prompt, - Resource, - ResourceTemplateType as ResourceTemplate, - Tool, -} from '@modelcontextprotocol/client'; -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import type { McpBrowserSessionModel, McpBrowserSessionTimelineEntry } from '../../mcp/mcp-session-model.ts'; -import type { McpSessionControllerRequest } from '../../mcp/mcp-session-controller.ts'; -import { McpProtocolEvidence } from '../../mcp/mcp-page.tsx'; -import { - ALL_LEVELS_VISIBLE, - clearScrollMemory, - LoggingScreen, - PromptsScreen, - ProtocolScreen, - ResourcesScreen, - ToolsScreen, - type GetPromptState, - type LogEntryData, - type LogsUiState, - type PromptsUiState, - type ProtocolUiState, - type ReadResourceState, - type ResourcesUiState, - type SortDirection, - type ToolCallState, - type ToolsUiState, -} from './inspector-session-adapter-vendor.js'; -import { - inspectorLogEntries, - inspectorProtocolEntries, - inspectorSessionBindingKey, - inspectorSessionTabs, - type InspectorRuntimeEvidenceInput, - type InspectorTab, -} from './inspector-session-adapter-model.ts'; - -export { - inspectorLogEntries, - inspectorProtocolEntries, - inspectorSessionBindingKey, - inspectorSessionTabs, - type InspectorRuntimeEvidenceInput, -} from './inspector-session-adapter-model.ts'; - -export interface InspectorSessionAdapterController { - cancel(id: string): boolean; - invoke(input: McpSessionControllerRequest): Promise; -} - -export interface InspectorSessionOperationAvailability { - readonly prompts: 'available' | 'not-routed'; - readonly resourceTemplates: 'available' | 'not-routed'; - readonly resources: 'available'; - readonly tools: 'available'; -} - -export interface InspectorSessionAdapterProps { - readonly availability?: InspectorSessionOperationAvailability; - readonly controller: InspectorSessionAdapterController; - readonly initialTab?: InspectorTab; - readonly model: McpBrowserSessionModel; - readonly onExportTrace?: (entries: readonly McpBrowserSessionTimelineEntry[]) => void; -} - -export interface InspectorRuntimeEvidenceProps { - readonly evidence: InspectorRuntimeEvidenceInput; -} - -const emptyPagination = { - canLoadMore: false, - loadedPages: 1, - onLoadMore: () => undefined, - onPaginatedChange: () => undefined, - paginated: false, -}; - -const initialToolsUi: ToolsUiState = { formValues: {}, runAsTask: false, search: '' }; -const initialResourcesUi: ResourcesUiState = { search: '' }; -const initialPromptsUi: PromptsUiState = { argumentValues: {}, search: '' }; -const initialProtocolUi: ProtocolUiState = { - search: '', - visibleDirections: { client: true, server: true }, -}; -const initialLogsUi: LogsUiState = { filterText: '', visibleLevels: ALL_LEVELS_VISIBLE }; - -const allOperationsAvailable: InspectorSessionOperationAvailability = Object.freeze({ - prompts: 'available', - resourceTemplates: 'available', - resources: 'available', - tools: 'available', -}); - -const availableTab = (tab: InspectorTab, availability: InspectorSessionOperationAvailability): InspectorTab => - tab === 'prompts' && availability.prompts === 'not-routed' ? 'tools' : tab; - -export const agentBundleInspectorTheme = createTheme({ - defaultRadius: 'sm', - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', - primaryColor: 'violet', -}); - -const operationError = (reason: unknown): string => reason instanceof Error ? reason.message : 'The Inspector operation failed.'; -const unsupportedLogLevelMessage = 'Log-level changes are unavailable because this session does not support logging/setLevel.'; - -export const InspectorRuntimeEvidence = ({ evidence }: InspectorRuntimeEvidenceProps): React.ReactNode => { - if (evidence.kind === 'protocol') return
- -
; - if (evidence.kind === 'diagnostics') return
-

Provider diagnostics

- {evidence.diagnostics.length === 0 ?

No provider diagnostics.

:
    {evidence.diagnostics.map((diagnostic, index) =>
  1. {diagnostic.phase} {diagnostic.severity} {diagnostic.code} {diagnostic.message}
  2. )}
} -
; - const expansion = evidence.expansion; - const expandedIds = expansion === undefined ? undefined : new Set(expansion.expandedIds); - return
-

Render trace

- {evidence.trace.length === 0 ?

No render evidence yet.

:
    {evidence.trace.map((span) => { - const expanded = expandedIds === undefined || expandedIds.has(span.id); - return
  1. - {span.phase} {span.status}{span.durationMs === undefined ? undefined : {span.durationMs} ms} - {span.details === undefined || expansion === undefined ? undefined : - } - {span.details === undefined || !expanded ? undefined :
    {JSON.stringify(span.details, null, 2)}
    } -
  2. ; - })}
} -
; -}; - -export const InspectorSessionAdapter = ({ availability = allOperationsAvailable, controller, initialTab = 'tools', model, onExportTrace }: InspectorSessionAdapterProps) => { - const bindingKey = inspectorSessionBindingKey(model.binding); - const previousBindingKey = useRef(bindingKey); - const lastResetBindingKey = useRef(bindingKey); - const requestNumber = useRef(0); - const actionGeneration = useRef(0); - const bindingChanged = previousBindingKey.current !== bindingKey; - if (bindingChanged) { - previousBindingKey.current = bindingKey; - actionGeneration.current += 1; - } - const [tab, setTab] = useState(() => availableTab(initialTab, availability)); - const [toolsUi, setToolsUi] = useState(initialToolsUi); - const [resourcesUi, setResourcesUi] = useState(initialResourcesUi); - const [promptsUi, setPromptsUi] = useState(initialPromptsUi); - const [protocolUi, setProtocolUi] = useState(initialProtocolUi); - const [logsUi, setLogsUi] = useState(initialLogsUi); - const [toolCall, setToolCall] = useState(); - const [toolRequestId, setToolRequestId] = useState(); - const [readResource, setReadResource] = useState(); - const [getPrompt, setGetPrompt] = useState(); - const [pinnedIds, setPinnedIds] = useState>(() => new Set()); - const [protocolCleared, setProtocolCleared] = useState(false); - const [loggingCleared, setLoggingCleared] = useState(false); - const [loggingDiagnostic, setLoggingDiagnostic] = useState(unsupportedLogLevelMessage); - const [sortDirection, setSortDirection] = useState('oldest-first'); - const [compact, setCompact] = useState(false); - const [protocolReplayUnavailable, setProtocolReplayUnavailable] = useState(false); - - useLayoutEffect(() => { - if (lastResetBindingKey.current === bindingKey) return; - lastResetBindingKey.current = bindingKey; - clearScrollMemory(); - setTab(availableTab(initialTab, availability)); - setToolsUi(initialToolsUi); - setResourcesUi(initialResourcesUi); - setPromptsUi(initialPromptsUi); - setProtocolUi(initialProtocolUi); - setLogsUi(initialLogsUi); - setToolCall(undefined); - setToolRequestId(undefined); - setReadResource(undefined); - setGetPrompt(undefined); - setPinnedIds(new Set()); - setProtocolCleared(false); - setLoggingCleared(false); - setLoggingDiagnostic(unsupportedLogLevelMessage); - setSortDirection('oldest-first'); - setCompact(false); - setProtocolReplayUnavailable(false); - }, [availability, bindingKey, initialTab]); - - const protocolEntries = useMemo(() => inspectorProtocolEntries(model.timeline.entries), [model.timeline.entries]); - const loggingEntries = useMemo(() => inspectorLogEntries(model.timeline.entries), [model.timeline.entries]); - const tools = useMemo(() => [...model.catalogs.tools] as unknown as Tool[], [model.catalogs.tools]); - const resources = useMemo(() => [...model.catalogs.resources] as unknown as Resource[], [model.catalogs.resources]); - const templates = useMemo(() => availability.resourceTemplates === 'available' - ? [...model.catalogs.resourceTemplates] as unknown as ResourceTemplate[] - : [], [availability.resourceTemplates, model.catalogs.resourceTemplates]); - const prompts = useMemo(() => [...model.catalogs.prompts] as unknown as Prompt[], [model.catalogs.prompts]); - const displayedProtocol = protocolCleared ? [] : protocolEntries; - const displayedLogs = loggingCleared ? [] : loggingEntries; - const exportedTimeline = useMemo(() => Object.freeze([...model.timeline.entries]), [model.timeline.entries]); - const currentTab = availableTab(tab, availability); - - const nextRequest = (operation: McpSessionControllerRequest['operation'], request: Readonly>): McpSessionControllerRequest => { - requestNumber.current += 1; - return { id: `inspector-${model.sessionId}-${requestNumber.current}`, operation, request }; - }; - - const run = (operation: McpSessionControllerRequest['operation'], request: Readonly>): Promise => - controller.invoke(nextRequest(operation, request)); - - const runTool = (name: string, args: Record): void => { - const generation = actionGeneration.current; - const request = nextRequest('callTool', { arguments: args, name }); - setToolRequestId(request.id); - setToolCall({ status: 'pending' }); - void controller.invoke(request).then((result) => { - if (generation === actionGeneration.current) setToolCall({ result: result as CallToolResult, status: 'ok' }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setToolCall({ error: operationError(reason), status: 'error' }); - }); - }; - - const runReadResource = (uri: string): void => { - const generation = actionGeneration.current; - setReadResource({ status: 'pending', uri }); - void run('readResource', { uri }).then((result) => { - if (generation === actionGeneration.current) setReadResource({ result: result as ReadResourceState['result'], status: 'ok', uri }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setReadResource({ error: operationError(reason), status: 'error', uri }); - }); - }; - - const runGetPrompt = (name: string, args: Record): void => { - const generation = actionGeneration.current; - setGetPrompt({ promptName: name, status: 'pending' }); - void run('getPrompt', { arguments: args, name }).then((result) => { - if (generation === actionGeneration.current) setGetPrompt({ promptName: name, result: result as GetPromptResult, status: 'ok' }); - }, (reason: unknown) => { - if (generation === actionGeneration.current) setGetPrompt({ error: operationError(reason), promptName: name, status: 'error' }); - }); - }; - - const refresh = (operation: McpSessionControllerRequest['operation']): void => { void run(operation, {}); }; - const negotiatedProtocol = model.connection?.protocolVersion ?? 'Not negotiated'; - - return -
-
-

Inspector

-

Negotiated protocol: {negotiatedProtocol}

- -
- {availability.prompts === 'not-routed' ?

Prompts are unavailable for this runtime session.

: undefined} - {currentTab === 'tools' ? { - if (toolRequestId !== undefined) controller.cancel(toolRequestId); - setToolCall(undefined); - setToolRequestId(undefined); - }} - onClearResult={() => { - setToolCall(undefined); - setToolRequestId(undefined); - }} - onRefreshList={() => refresh('listTools')} - onUiChange={setToolsUi} - pagination={emptyPagination} - serverSupportsTaskToolCalls={false} - tools={tools} - ui={toolsUi} - /> : undefined} - {currentTab === 'resources' ? <> - {availability.resourceTemplates === 'not-routed' ?

Resource templates are unavailable for this runtime session.

: undefined} - refresh('listResources')} - onSubscribeResource={() => undefined} - onUiChange={setResourcesUi} - onUnsubscribeResource={() => undefined} - pagination={emptyPagination} - readState={readResource} - resources={resources} - subscriptions={[]} - subscriptionsSupported={false} - templates={templates} - ui={resourcesUi} - /> - : undefined} - {currentTab === 'prompts' ? refresh('listPrompts')} - onUiChange={setPromptsUi} - pagination={emptyPagination} - prompts={prompts} - ui={promptsUi} - /> : undefined} - {currentTab === 'protocol' ? setProtocolCleared(true)} - onClearSection={(section) => section === 'history' ? setProtocolCleared(true) : setPinnedIds(new Set())} - onExport={() => onExportTrace?.(exportedTimeline)} - onExportSection={() => onExportTrace?.(exportedTimeline)} - onReplay={() => setProtocolReplayUnavailable(true)} - onSortChange={setSortDirection} - onToggleCompact={() => setCompact((value) => !value)} - onTogglePin={(id: string) => setPinnedIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - })} - onUiChange={setProtocolUi} - pinnedIds={pinnedIds} - sortDirection={sortDirection} - ui={protocolUi} - /> : undefined} - {protocolReplayUnavailable ?

Replay is unavailable for raw W13 trace frames.

: undefined} - {currentTab === 'logging' ?
-

{loggingDiagnostic}

- setLoggingCleared(true)} - onExport={() => onExportTrace?.(model.timeline.entries)} - onSetLevel={() => setLoggingDiagnostic(unsupportedLogLevelMessage)} - onSortChange={setSortDirection} - onUiChange={setLogsUi} - sortDirection={sortDirection} - ui={logsUi} - /> -
: undefined} -
-
; -}; diff --git a/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx b/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx deleted file mode 100644 index c4b5b3fc2..000000000 --- a/packages/workbench/src/inspector/adapter/protocol-screen-without-replay.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Badge, Button, Card, Code, Group, Stack, Text, TextInput, Title } from '@mantine/core'; -import { useMemo } from 'react'; - -import type { InspectorProtocolEntry } from './inspector-session-adapter-model.ts'; - -type SortDirection = 'oldest-first' | 'newest-first'; - -interface ProtocolUiState { - readonly search: string; - readonly visibleDirections: Readonly>; -} - -interface ProtocolScreenWithoutReplayProps { - readonly compact: boolean; - readonly entries: readonly InspectorProtocolEntry[]; - readonly onClearAll: () => void; - readonly onExport: () => void; - readonly onSortChange: (direction: SortDirection) => void; - readonly onToggleCompact: () => void; - readonly onTogglePin: (id: string) => void; - readonly onUiChange: (ui: ProtocolUiState) => void; - readonly pinnedIds: ReadonlySet; - readonly sortDirection: SortDirection; - readonly ui: ProtocolUiState; -} - -const frameName = (entry: InspectorProtocolEntry): string => - entry.direction === 'response' - ? `response:${String(entry.message.id)}` - : `${entry.direction}:${String(entry.message.method)}`; - -const matchesSearch = (entry: InspectorProtocolEntry, search: string): boolean => - search.length === 0 || JSON.stringify(entry.message).toLowerCase().includes(search.toLowerCase()); - -/** - * The embedded Inspector exposes a raw transport timeline but no replay-capable invocation binding. - * This narrow Protocol presentation retains every frame while intentionally - * omitting the vendored Replay action, which has no supported implementation. - */ -export const ProtocolScreenWithoutReplay = ({ - compact, - entries, - onClearAll, - onExport, - onSortChange, - onToggleCompact, - onTogglePin, - onUiChange, - pinnedIds, - sortDirection, - ui, -}: ProtocolScreenWithoutReplayProps) => { - const displayedEntries = useMemo(() => entries - .filter((entry) => ui.visibleDirections[entry.origin] && matchesSearch(entry, ui.search)) - .sort((left, right) => sortDirection === 'oldest-first' - ? left.sequence - right.sequence - : right.sequence - left.sequence), [entries, sortDirection, ui.search, ui.visibleDirections]); - - return
- - Messages - - - - - - - - onUiChange({ ...ui, search: event.currentTarget.value })} - placeholder="Search raw JSON-RPC frames" - value={ui.search} - /> - {displayedEntries.length === 0 ? No request history : - {displayedEntries.map((entry) => - - - {entry.timestamp.toISOString()} - {entry.origin} - {entry.direction} - #{entry.sequence} - - - - {JSON.stringify(entry.message)} - )} - } -
; -}; diff --git a/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts b/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts deleted file mode 100644 index cb0ff5c3b..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-react-runtime.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx b/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx deleted file mode 100644 index c03ce7f36..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-react-runtime.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; - -// The vendored Inspector source was authored for the classic JSX runtime. -// Its modules are intentionally byte-for-byte preserved, so establish the -// compatibility global before their screen modules evaluate. -globalThis.React = React; diff --git a/packages/workbench/src/inspector/adapter/vendor-screens.d.ts b/packages/workbench/src/inspector/adapter/vendor-screens.d.ts deleted file mode 100644 index 010399035..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-screens.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ComponentType } from 'react'; - -export const LoggingScreen: ComponentType>; -export const PromptsScreen: ComponentType>; -export const ProtocolScreen: ComponentType>; -export const ResourcesScreen: ComponentType>; -export const ToolsScreen: ComponentType>; diff --git a/packages/workbench/src/inspector/adapter/vendor-screens.jsx b/packages/workbench/src/inspector/adapter/vendor-screens.jsx deleted file mode 100644 index 1ce781263..000000000 --- a/packages/workbench/src/inspector/adapter/vendor-screens.jsx +++ /dev/null @@ -1,5 +0,0 @@ -export { LoggingScreen } from '../vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx'; -export { PromptsScreen } from '../vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx'; -export { ProtocolScreenWithoutReplay as ProtocolScreen } from './protocol-screen-without-replay.tsx'; -export { ResourcesScreen } from '../vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx'; -export { ToolsScreen } from '../vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx'; diff --git a/packages/workbench/src/inspector/package.json b/packages/workbench/src/inspector/package.json deleted file mode 100644 index 2c1f6dadb..000000000 --- a/packages/workbench/src/inspector/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@inspector/core", - "version": "0.0.0", - "private": true, - "description": "Links the vendored MCP Inspector core so `@inspector/core/*` specifiers resolve through the package manager instead of per-config aliases. Lives beside UPSTREAM.json rather than inside vendor/, which stays a byte-exact provenance snapshot.", - "type": "module", - "exports": { - "./*.js": "./vendor/core/*.ts", - "./*": "./vendor/core/*" - } -} diff --git a/packages/workbench/src/inspector/patches/.gitkeep b/packages/workbench/src/inspector/patches/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/packages/workbench/src/inspector/patches/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch b/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch deleted file mode 100644 index c10768d5a..000000000 --- a/packages/workbench/src/inspector/patches/001-rstest-inspector-tabs-import.patch +++ /dev/null @@ -1,9 +0,0 @@ -diff --git a/clients/web/src/utils/inspectorTabs.test.ts b/clients/web/src/utils/inspectorTabs.test.ts ---- a/clients/web/src/utils/inspectorTabs.test.ts -+++ b/clients/web/src/utils/inspectorTabs.test.ts -@@ -1,4 +1,4 @@ --import { describe, it, expect } from "vitest"; -+import { describe, it, expect } from "@rstest/core"; - import { - INSPECTOR_SERVERS_TAB, - INSPECTOR_TAB_IDS, diff --git a/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch b/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch deleted file mode 100644 index 0069369f8..000000000 --- a/packages/workbench/src/inspector/patches/002-remove-legacy-sse-mcp-types.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/core/mcp/types.ts b/core/mcp/types.ts ---- a/core/mcp/types.ts -+++ b/core/mcp/types.ts -@@ -49,8 +48,0 @@ export interface StdioServerConfig { --// SSE transport config --export interface SseServerConfig { -- type: "sse"; -- url: string; -- eventSourceInit?: Record; -- requestInit?: Record; --} -- -@@ -66 +57,0 @@ export type MCPServerConfig = -- | SseServerConfig -@@ -69 +60 @@ export type MCPServerConfig = --export type ServerType = "stdio" | "sse" | "streamable-http"; -+export type ServerType = "stdio" | "streamable-http"; -@@ -94 +85 @@ export type StoredMCPServer = MCPServerConfig & { -- * HTTP headers for SSE / streamable-http transports. Persisted as a flat -+ * HTTP headers for Streamable HTTP transports. Persisted as a flat -@@ -766 +757 @@ export interface CreateTransportOptions { -- * (SSE, streamable-http). Enables proxy fetch in browser (CORS bypass). -+ * (Streamable HTTP). Enables proxy fetch in browser (CORS bypass). -@@ -781 +772 @@ export interface CreateTransportOptions { -- * Optional callback to track HTTP fetch requests (for SSE and streamable-http transports). -+ * Optional callback to track HTTP fetch requests for Streamable HTTP transports. -@@ -795 +786 @@ export interface CreateTransportOptions { -- * Optional OAuth client provider for Bearer authentication (SSE, streamable-http). -+ * Optional OAuth client provider for Streamable HTTP Bearer authentication. -@@ -802 +793 @@ export interface CreateTransportOptions { -- * HTTP headers (settings.headers) for SSE / streamable-http transports. -+ * HTTP headers (settings.headers) for Streamable HTTP transports. diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx deleted file mode 100644 index d5cfc8fb3..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { Role } from "@modelcontextprotocol/client"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export type AnnotationFacet = - | "audience" - | "priority" - | "readOnlyHint" - | "destructiveHint" - | "idempotentHint" - | "openWorldHint" - | "longRunHint"; - -export interface AnnotationBadgeProps { - facet: AnnotationFacet; - value: Role[] | number | boolean; -} - -const colorMap: Record = { - audience: "blue", - priority: "orange", - readOnlyHint: "green", - destructiveHint: "red", - idempotentHint: "teal", - openWorldHint: "grape", - longRunHint: "yellow", -}; - -const FilledBadge = Badge.withProps({ - variant: "filled", - fw: 500, - autoContrast: true, -}); - -function formatLabel( - facet: AnnotationFacet, - value: Role[] | number | boolean, -): string { - switch (facet) { - case "audience": - return `audience: ${(value as Role[]).join(", ")}`; - case "priority": { - const n = value as number; - if (n >= 0.7) return "priority: high"; - if (n >= 0.4) return "priority: medium"; - return "priority: low"; - } - case "readOnlyHint": - return "read-only"; - case "destructiveHint": - return "destructive"; - case "idempotentHint": - return "idempotent"; - case "openWorldHint": - return "open-world"; - case "longRunHint": - return "long-running"; - } -} - -export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) { - const color = filledBadgeColor(colorMap[facet]); - // `autoContrast` picks black or white text per the fill's luminance in each - // scheme, so the label stays legible (WCAG AA) on both the lighter light-mode - // fills and the darker dark-mode `-filled` shades — unlike a fixed - // scheme→black/white mapping, which inverted the contrast in dark mode. - // Amber fills are pinned to shade 5 first (see `filledBadgeColor`). - return {formatLabel(facet, value)}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx deleted file mode 100644 index 19eb85838..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { Box } from "@mantine/core"; -import { - useCallback, - useEffect, - useImperativeHandle, - useRef, - type Ref, - type RefObject, -} from "react"; -import type { - AppBridge, - AppBridgeEventMap, - McpUiDisplayMode, - McpUiMessageRequest, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { - CallToolResult, - LoggingMessageNotification, - Tool, -} from "@modelcontextprotocol/client"; -import { - currentStyles, - currentTheme, - measureContainerDimensions, -} from "./hostContext"; - -/** - * Constructs the `AppBridge` for a freshly mounted sandbox iframe. Wrap with - * `useCallback` (or hoist out of render) — the renderer treats a new factory - * identity as a signal to tear down the current bridge and rebuild, so an - * unstable factory will thrash the iframe on every render. - */ -export type BridgeFactory = ( - iframe: HTMLIFrameElement, - tool: Tool, -) => AppBridge | Promise; - -export interface AppRendererHandle { - sendToolInput(args: Record): Promise; - sendToolResult(result: CallToolResult): Promise; - sendToolCancelled(reason: string): Promise; - teardown(): Promise; -} - -/** - * High-level lifecycle of a running app, surfaced so a host (or an automated - * driver polling a `data-app-status` attribute) can wait for the right moment: - * `loading` while the bridge is being built and the view's `ui/initialize` - * handshake is in flight; `ready` once the view has fired - * `notifications/initialized`; `error` when the bridge factory throws or - * rejects (no live view to wait on). - */ -export type AppRendererStatus = "loading" | "ready" | "error"; - -export interface AppRendererProps { - sandboxPath: string; - tool: Tool; - bridgeFactory: BridgeFactory; - onError?: (err: Error) => void; - /** - * Reports the renderer's high-level lifecycle (see {@link AppRendererStatus}). - * Fires `loading` at the start of every (re)build, `ready` when the view - * signals `initialized`, and `error` on a factory throw/rejection. - */ - onAppStatusChange?: (status: AppRendererStatus) => void; - /** - * Called when the running view reports a new rendered content size via - * `ui/notifications/size-changed` (typically driven by its `ResizeObserver`). - * Width and height (px) are both optional. The host uses this to resize the - * iframe's container so the widget is neither clipped nor padded with dead - * space. - */ - onSizeChange?: (size: AppBridgeEventMap["sizechange"]) => void; - /** - * Current host display mode for the app frame. Pushed to the running view - * via `host-context-changed` whenever it changes (e.g. Maximize/Restore), so - * an app can adapt its layout to inline vs fullscreen. - */ - displayMode?: McpUiDisplayMode; - /** - * Handles a view-originated `ui/request-display-mode`. Return the mode the - * host actually applied — the spec lets the host decline an unsupported mode - * by returning its current one. - */ - onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode; - /** - * Called when the running view submits a user-role message via - * `ui/message`. The renderer returns the spec-required empty result on - * the host's behalf, so the callback is fire-and-forget. - */ - onMessage?: (params: McpUiMessageRequest["params"]) => void; - /** - * Called for each MCP log notification (`notifications/message`) the - * running view emits. Backs the advertised `logging` host capability. - */ - onLog?: (params: LoggingMessageNotification["params"]) => void; - /** - * Ordered tool-input fragments to replay via - * `ui/notifications/tool-input-partial` BEFORE the complete `tool-input`, - * exercising widgets that render progressively. Captured at bridge-build - * time (see `pendingPartialsRef`) so prop churn never rebuilds the iframe. - * Nothing is sent when omitted/empty. - */ - partialInputs?: Record[]; - /** - * The host-controlled box the app renders within, used to derive - * `hostContext.containerDimensions`. This MUST be an element whose size is - * driven by the host's layout (window resize, sidebar toggle, maximize) and - * NOT by the view's own `size-changed` reports — otherwise the two signals - * couple into a feedback loop. Falls back to the iframe element when omitted. - */ - containerRef?: RefObject; - ref?: Ref; -} - -function toError(err: unknown): Error { - return err instanceof Error ? err : new Error(String(err)); -} - -async function disposeBridge(bridge: AppBridge): Promise { - // Best-effort: still close the transport even if teardownResource fails, - // otherwise the iframe unmount would leak MessagePort listeners. - try { - await bridge.teardownResource({}); - } catch { - /* swallow — closing transport below is the load-bearing step */ - } - try { - await bridge.close(); - } catch { - /* swallow — already disposing */ - } -} - -/** - * Bridge lifecycle (the interlocking refs below): - * - * mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set - * │ on "initialized" - * ▼ → flushPending - * cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled) - * ▲ │ - * └── re-setup with SAME inputs ─────┘ cancel + REUSE bridge - * - * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. - * - `disposeScheduled`: a dispose is queued (microtask); a synchronous re-setup - * (StrictMode double-invoke, or a transient re-render) cancels it and reuses - * the live bridge instead of rebuilding (rebuild double-loads the sandbox and - * races the app handshake). A re-setup with CHANGED inputs disposes + rebuilds. - * - `lastDeps`: distinguishes "same inputs → reuse" from "changed → rebuild". - * - `initialized`: gates flushing buffered input/result until the view is ready. - * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. - * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. - */ -export function AppRenderer({ - sandboxPath, - tool, - bridgeFactory, - onError, - onAppStatusChange, - onSizeChange, - displayMode, - onRequestDisplayMode, - onMessage, - onLog, - partialInputs, - containerRef, - ref, -}: AppRendererProps) { - const iframeRef = useRef(null); - const bridgeRef = useRef(null); - const initializedRef = useRef(false); - const pendingPartialsRef = useRef[]>([]); - const pendingInputRef = useRef | null>(null); - const pendingResultRef = useRef(null); - const teardownStartedRef = useRef(false); - // Bridge-lifecycle bookkeeping for the deferred-dispose / reuse dance that - // keeps a single bridge alive across React StrictMode's dev-only - // setup→cleanup→setup double-invoke (see the build effect below). - const buildIdRef = useRef(0); - const disposeScheduledRef = useRef(false); - const lastDepsRef = useRef<{ - bridgeFactory: BridgeFactory; - sandboxPath: string; - tool: Tool; - } | null>(null); - const onErrorRef = useRef(onError); - const onAppStatusChangeRef = useRef(onAppStatusChange); - const onSizeChangeRef = useRef(onSizeChange); - const displayModeRef = useRef(displayMode); - const onRequestDisplayModeRef = useRef(onRequestDisplayMode); - const onMessageRef = useRef(onMessage); - const onLogRef = useRef(onLog); - const partialInputsRef = useRef(partialInputs); - useEffect(() => { - onErrorRef.current = onError; - onAppStatusChangeRef.current = onAppStatusChange; - onSizeChangeRef.current = onSizeChange; - displayModeRef.current = displayMode; - onRequestDisplayModeRef.current = onRequestDisplayMode; - onMessageRef.current = onMessage; - onLogRef.current = onLog; - partialInputsRef.current = partialInputs; - }); - - // Flush buffered tool input/result to the view, but only once the bridge - // exists AND the view has signalled `initialized`. The spec requires tool - // input/result to arrive after initialization, yet a host-initiated open - // (the Open App click) fires before the iframe's app has loaded — so we - // buffer the latest values and release them when the view is ready. Input is - // always sent before result. - const flushPending = useCallback(() => { - const bridge = bridgeRef.current; - if (!bridge || !initializedRef.current) return; - // Partial-input fragments first, in staged order, BEFORE the complete - // tool-input — the spec requires partials to precede the final input. - for (const args of pendingPartialsRef.current) { - void bridge.sendToolInputPartial({ arguments: args }); - } - pendingPartialsRef.current = []; - if (pendingInputRef.current !== null) { - const args = pendingInputRef.current; - pendingInputRef.current = null; - void bridge.sendToolInput({ arguments: args }); - } - if (pendingResultRef.current !== null) { - const result = pendingResultRef.current; - pendingResultRef.current = null; - // ext-apps' AppBridge peers on SDK v1's CallToolResult (whose - // `structuredContent` is typed narrower than v2's). Runtime-compatible; - // cast at this boundary. TODO: drop when ext-apps#702 ships a v2 peer. - void bridge.sendToolResult( - result as Parameters[0], - ); - } - }, []); - - // Dispose the live bridge, but deferred to a microtask. React StrictMode runs - // effects setup→cleanup→setup synchronously in dev; deferring lets the - // re-setup cancel the disposal and keep the SAME bridge, instead of tearing - // it down and rebuilding. A rebuild here spins up a second transport that - // re-posts sandbox-resource-ready (the sandbox loads the app twice) and - // races the app's ui/initialize handshake — which is what left apps stuck on - // an empty shell ("handshake timed out") in dev. - const scheduleDispose = useCallback(() => { - disposeScheduledRef.current = true; - queueMicrotask(() => { - if (!disposeScheduledRef.current) return; // cancelled by a re-setup - disposeScheduledRef.current = false; - // Invalidate any in-flight factory so a late-resolving bridge disposes - // itself instead of attaching to a torn-down component. - buildIdRef.current++; - const bridge = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - lastDepsRef.current = null; - pendingPartialsRef.current = []; - if (bridge) void disposeBridge(bridge); - }); - }, []); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - - const prev = lastDepsRef.current; - const sameInputs = - prev !== null && - prev.bridgeFactory === bridgeFactory && - prev.sandboxPath === sandboxPath && - prev.tool === tool; - - // A disposal scheduled by the immediately-preceding cleanup means we are in - // a synchronous re-setup. If the inputs are identical (StrictMode's - // double-invoke, or a transient re-render) keep the live bridge: cancel the - // disposal and re-deliver any buffered input/result to it. - // This reuse path provably runs under React StrictMode's synchronous - // setup→cleanup→setup double-invoke (the "builds a single bridge… StrictMode" - // test proves the bridge is reused, not rebuilt — factory called once). v8 - // cannot attribute coverage to the body of an effect that React replays for - // the StrictMode dev-only double-invoke, so the branch + its three - // statements read as uncovered despite executing. - /* v8 ignore next 4 */ - if (disposeScheduledRef.current && sameInputs) { - disposeScheduledRef.current = false; - flushPending(); - return scheduleDispose; - } - - // Otherwise this is a real (re)build. If a disposal was pending (inputs - // changed), run it synchronously before building the replacement. - if (disposeScheduledRef.current) { - disposeScheduledRef.current = false; - buildIdRef.current++; - const old = bridgeRef.current; - bridgeRef.current = null; - initializedRef.current = false; - if (old) void disposeBridge(old); - } - - lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; - const buildId = ++buildIdRef.current; - teardownStartedRef.current = false; - initializedRef.current = false; - onAppStatusChangeRef.current?.("loading"); - // Snapshot the staged partial-input fragments for THIS bridge build (read - // via the ref so the prop is not a dep — adding/removing fragments must not - // rebuild the iframe). The StrictMode reuse path above returned before - // reaching here, so a reused bridge keeps the queue it was built with. - pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; - - let pending: Promise; - try { - pending = Promise.resolve(bridgeFactory(iframe, tool)); - } catch (err) { - onAppStatusChangeRef.current?.("error"); - onErrorRef.current?.(toError(err)); - return scheduleDispose; - } - - pending - .then((bridge) => { - if (buildIdRef.current !== buildId) { - void disposeBridge(bridge); - return; - } - bridgeRef.current = bridge; - // Registered before the inner app can finish loading (which only - // happens after the sandbox-resource-ready round-trip the factory - // drives), so the view's `initialized` signal is never missed. - bridge.addEventListener("initialized", () => { - initializedRef.current = true; - onAppStatusChangeRef.current?.("ready"); - // The factory already seeded theme/styles/displayMode into the - // handshake hostContext; the observers below cover any subsequent - // changes. Only containerDimensions can plausibly differ between - // bridge construction and initialization (layout settles), so push - // that one field now via the SDK's partial-change notification. - const container = containerRef?.current ?? iframeRef.current; - const containerDimensions = container - ? measureContainerDimensions(container) - : undefined; - if (containerDimensions) { - void bridge.sendHostContextChange({ containerDimensions }); - } - flushPending(); - }); - // Forward the view's content-size reports (ui/notifications/size-changed) - // so the host can resize the iframe container to fit the rendered widget. - bridge.addEventListener("sizechange", (size) => { - onSizeChangeRef.current?.(size); - }); - // Forward the view's MCP log notifications so the host can honor the - // advertised `logging` capability instead of dropping them. - bridge.addEventListener("loggingmessage", (params) => { - onLogRef.current?.(params); - }); - // Handle ui/request-display-mode: let the host (AppsScreen) decide what - // mode to actually apply and return that. With no handler the request is - // declined by returning the current host-side mode. - bridge.onrequestdisplaymode = async ({ mode }) => { - const handler = onRequestDisplayModeRef.current; - const applied = handler - ? handler(mode) - : (displayModeRef.current ?? "inline"); - return { mode: applied }; - }; - // Handle ui/message: surface the submitted content and return the - // spec-required empty result. With no handler the submission is - // declined by returning isError. - bridge.onmessage = async (params) => { - const handler = onMessageRef.current; - if (!handler) return { isError: true }; - handler(params); - return {}; - }; - flushPending(); - }) - .catch((err) => { - if (buildIdRef.current !== buildId) return; - onAppStatusChangeRef.current?.("error"); - onErrorRef.current?.(toError(err)); - }); - - return scheduleDispose; - // `containerRef` is listed for exhaustive-deps completeness, but a change to - // its identity does NOT force a rebuild: the `sameInputs` check above - // ignores it, so a new ref object hits the StrictMode reuse path (the - // `initialized` handler reads `containerRef?.current` lazily, so the live - // ref is always used regardless). The other deps are the real rebuild keys. - }, [ - bridgeFactory, - sandboxPath, - tool, - containerRef, - flushPending, - scheduleDispose, - ]); - - // Push live host-context changes to the running view as discrete partial - // updates via AppBridge.sendHostContextChange (the SDK's - // ui/notifications/host-context-changed sender). Each effect observes one - // host signal and sends only the field(s) it owns, so the view receives the - // spec's "only changed fields" partials without any host-side snapshot - // bookkeeping. Reading `bridgeRef.current` at callback time (not capturing a - // bridge) means the observers always target the live bridge, even though it - // resolves asynchronously after these effects run. - - // Theme + styles: Mantine writes the resolved scheme to - // ``; observe that attribute and forward - // changes through the live bridge. Gated on the view's `initialized` signal - // — like the container and displayMode pushes below — so a theme flip in the - // window between bridge construction and the handshake doesn't race - // `ui/initialize`. Nothing is lost by waiting: the factory seeds the - // construction-time theme/styles into the handshake hostContext, and the - // first post-init flip carries the current value. - useEffect(() => { - /* v8 ignore next 5 -- SSR/non-DOM guard: MutationObserver and document are - always defined under happy-dom, so this early return is unreachable in - the test environment. */ - if ( - typeof MutationObserver === "undefined" || - typeof document === "undefined" - ) { - return; - } - const observer = new MutationObserver(() => { - if (!initializedRef.current) return; - const styles = currentStyles(); - void bridgeRef.current?.sendHostContextChange({ - theme: currentTheme(), - ...(styles ? { styles } : {}), - }); - }); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-mantine-color-scheme"], - }); - return () => observer.disconnect(); - }, []); - - // Container size: observes the host-controlled container (or the iframe as a - // fallback) — NOT an element whose height is driven by the view's own - // size-changed reports, which would couple the two signals into a feedback - // loop. Gated on the view's `initialized` signal so the notification only - // fires once the handshake is complete; a 0×0 (not-yet-laid-out) measurement - // and a value-equal repeat are both skipped. - useEffect(() => { - const target = containerRef?.current ?? iframeRef.current; - /* v8 ignore next -- SSR/non-DOM guard: ResizeObserver is stubbed/defined - and the iframe (or containerRef) target is always present after mount in - tests, so neither disjunct is reachable here. */ - if (typeof ResizeObserver === "undefined" || !target) return; - let last: { width: number; height: number } | undefined; - const observer = new ResizeObserver(() => { - if (!initializedRef.current) return; - const next = measureContainerDimensions(target); - if (!next) return; - if (last && last.width === next.width && last.height === next.height) { - return; - } - last = next; - void bridgeRef.current?.sendHostContextChange({ - containerDimensions: next, - }); - }); - observer.observe(target); - return () => observer.disconnect(); - }, [containerRef]); - - // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated on - // `initialized` for the same reason as the other host-context pushes. - useEffect(() => { - if (displayMode === undefined) return; - if (!initializedRef.current) return; - void bridgeRef.current?.sendHostContextChange({ displayMode }); - }, [displayMode]); - - useImperativeHandle( - ref, - () => ({ - async sendToolInput(args) { - // Buffered (latest-wins) and released by flushPending once the view is - // initialized — the handle may be invoked before the bridge resolves. - pendingInputRef.current = args; - flushPending(); - }, - async sendToolResult(result) { - pendingResultRef.current = result; - flushPending(); - }, - async sendToolCancelled(reason) { - const bridge = bridgeRef.current; - if (!bridge) return; - await bridge.sendToolCancelled({ reason }); - }, - async teardown() { - const bridge = bridgeRef.current; - if (!bridge || teardownStartedRef.current) return; - teardownStartedRef.current = true; - // Null the ref synchronously so a concurrent unmount cleanup cannot - // see a still-live bridge and dispose it a second time. Bumping the - // build id makes any in-flight factory self-dispose, and clearing the - // pending-dispose flag/cached deps prevents the deferred dispose from - // acting on an already torn-down bridge. - buildIdRef.current++; - disposeScheduledRef.current = false; - lastDepsRef.current = null; - bridgeRef.current = null; - initializedRef.current = false; - pendingInputRef.current = null; - pendingResultRef.current = null; - await disposeBridge(bridge); - }, - }), - [flushPending], - ); - - // The iframe deliberately has no `sandbox` attribute: `sandboxPath` resolves - // to the inspector's own bundled sandbox-proxy page (trusted, same-origin), - // which then loads the untrusted MCP App content into a nested sandboxed - // iframe. Sandboxing this outer frame would block the postMessage bridge - // that `AppBridge` relies on. - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts deleted file mode 100644 index 9294c5146..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - AppBridge, - PostMessageTransport, - getToolUiResourceUri, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { - McpUiDisplayMode, - McpUiHostCapabilities, - McpUiResourceMeta, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Client } from "@modelcontextprotocol/client"; -import type { - EmbeddedResource, - Implementation, - ReadResourceResult, - ResourceLink, -} from "@modelcontextprotocol/client"; -import { - approveCspSources, - buildSandboxCspPolicy, - wrapSandboxedHtml, -} from "../../../utils/sandbox-csp"; -import { - downloadBlob, - fileNameFromUri, - isHttpUrl, -} from "../../../lib/downloadFile"; -import { snapshotHostContext } from "./hostContext"; -import type { BridgeFactory } from "./AppRenderer"; - -/** - * Host identity advertised to MCP Apps during the bridge handshake. Static — - * the value is informational (shown by some apps), not a protocol version. - */ -export const HOST_INFO: Implementation = { - name: "MCP Inspector", - version: "2.0.0", -}; - -/** - * Capabilities the inspector host offers a running MCP App. Constructed WITH an - * MCP client (see {@link createAppBridgeFactory}), so the bridge auto-forwards - * tools/resources/prompts to the view; we only declare the host-side features - * we actually back: external links and file downloads (both handled below), - * tool/resource list-change forwarding, and logging passthrough. - */ -export const HOST_CAPABILITIES: McpUiHostCapabilities = { - openLinks: {}, - downloadFile: {}, - serverTools: { listChanged: true }, - serverResources: { listChanged: true }, - logging: {}, -}; - -/** - * Display modes the inspector host supports, advertised in the handshake - * hostContext (`availableDisplayModes`). AppsScreen renders an app either - * inline within its layout card or maximized to fill the screen, so only those - * two are offered. - */ -export const HOST_AVAILABLE_DISPLAY_MODES: readonly McpUiDisplayMode[] = [ - "inline", - "fullscreen", -]; - -export interface AppBridgeFactoryDeps { - /** The connected SDK client to back the bridge, or null when disconnected. */ - getClient: () => Client | null; - /** Reads a UI resource (resources/read) and returns the SDK result. */ - readResource: (uri: string) => Promise; - /** - * Called when reading or posting the UI resource fails after the sandbox - * proxy is ready. Without this the user is left staring at a blank-but-live - * frame; the error is also always console.error'd. - */ - onResourceError?: (err: Error) => void; -} - -/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ -function extractHtmlAndMeta(result: ReadResourceResult): { - html: string; - meta: McpUiResourceMeta | undefined; -} { - for (const content of result.contents) { - const text = (content as { text?: unknown }).text; - if (typeof text === "string") { - return { - html: text, - meta: content._meta as McpUiResourceMeta | undefined, - }; - } - } - throw new Error("UI resource has no text (HTML) content"); -} - -/** - * Decode a base64-encoded blob resource into bytes for download. Allocates the - * backing store explicitly so the return type is `Uint8Array` - * (Blob accepts `ArrayBufferView`, not the wider - * `ArrayBufferLike`). - */ -function base64ToBytes(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(new ArrayBuffer(binary.length)); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -/** - * Upper bound on items honored from a single `ui/download-file` request. One - * user approval must not fan out into an unbounded number of saves / new tabs. - */ -const MAX_DOWNLOAD_ITEMS = 20; - -/** - * Strip control characters and clamp length so a server-supplied filename or - * URI cannot forge additional lines in the confirmation prompt or push the - * real summary off-screen. - */ -function sanitizeDownloadLabel(label: string): string { - // Cc = control chars (newlines, escape, etc.); Cf = format chars (bidi - // overrides, zero-width joiners, BOM) — both can spoof or reflow the prompt. - const cleaned = label.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim(); - // Keep the START of an over-long label: for a link that preserves the - // scheme+host, which is what the user needs to make a trust decision. - return cleaned.length > 80 ? cleaned.slice(0, 77) + "..." : cleaned; -} - -/** - * Human-readable label for a download item, shown in the confirmation prompt. - * `forPrompt` marks a resource_link with a leading "↗" so the user can tell a - * link that will *open in a tab* apart from an embedded file that will *save to - * disk* — the two item kinds share this "download" confirmation. - */ -function describeDownloadItem( - item: EmbeddedResource | ResourceLink, - forPrompt = false, -): string { - if (item.type === "resource_link") { - return forPrompt ? `↗ ${item.uri}` : item.uri; - } - return fileNameFromUri(item.resource.uri); -} - -/** - * Trigger a browser download for a single MCP resource item. Inline - * {@link EmbeddedResource}s (text or base64 blob) are written via - * {@link downloadBlob}. A {@link ResourceLink} is *opened* in a new tab — - * the inspector does not fetch the URL to disk itself, since the link may - * require auth or content negotiation the browser can supply but we cannot. - * Returns false when the item carries nothing downloadable or its URI is - * rejected by the http(s)-only allowlist. - */ -function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean { - if (item.type === "resource_link") { - const parsed = isHttpUrl(item.uri); - if (!parsed) return false; - window.open(parsed.href, "_blank", "noopener,noreferrer"); - return true; - } - const resource = item.resource; - // The types forbid it, but the payload is untrusted: a resource with neither - // `blob` nor a string `text` has nothing to save. Skip it (like a rejected - // link) rather than writing a file containing the literal text "undefined". - if (!("blob" in resource) && typeof resource.text !== "string") return false; - const blob = - "blob" in resource - ? new Blob([base64ToBytes(resource.blob)], { - type: resource.mimeType ?? "application/octet-stream", - }) - : new Blob([resource.text], { type: resource.mimeType ?? "text/plain" }); - downloadBlob(fileNameFromUri(resource.uri), blob); - return true; -} - -/** - * Builds the {@link BridgeFactory} the AppRenderer uses to bring a sandbox - * iframe to life. For each mounted iframe + tool it: - * - * 1. constructs a host-side {@link AppBridge} over the SDK client (so the view - * can call tools/resources/prompts directly), - * 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI - * resource and pushes its HTML + sandbox/permissions/CSP into the inner - * iframe, echoing the applied sandbox config back via hostCapabilities, - * 3. handles `openLinks` by opening http(s) URLs in a new tab, - * 4. handles `downloadFile` by confirming with the user, then writing each - * embedded resource to disk via an object-URL anchor (resource links are - * opened in a new tab), - * 5. connects a {@link PostMessageTransport} to the iframe and returns the - * live bridge. - * - * Host-initiated tool input/result are pushed separately through the renderer's - * imperative handle (see `AppRenderer`), gated on the view's `initialized` - * event. The factory throws when no client is connected; AppRenderer routes - * that to its `onError` so the user sees a clear failure instead of a blank - * frame. - */ -export function createAppBridgeFactory( - deps: AppBridgeFactoryDeps, -): BridgeFactory { - return async (iframe, tool) => { - const client = deps.getClient(); - if (!client) { - throw new Error("Cannot render MCP App: no connected MCP client."); - } - const targetWindow = iframe.contentWindow; - if (!targetWindow) { - throw new Error("Cannot render MCP App: sandbox iframe has no window."); - } - - // Per-app copy so the approved-sandbox echo (set on sandboxready below) - // never mutates the shared HOST_CAPABILITIES constant — each app may - // declare its own csp/permissions. - const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES }; - // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both - // are runtime-compatible with v2's. Cast at this single construction - // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. - const bridge = new AppBridge( - client as unknown as ConstructorParameters[0], - HOST_INFO as unknown as ConstructorParameters[1], - hostCapabilities, - { - hostContext: snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES), - }, - ); - - // The double-iframe proxy posts `sandboxready` once it can receive content. - // Read the tool's UI resource and hand its HTML (plus any sandbox/permission - // hints from the resource _meta) to the inner sandboxed iframe. A failure - // here is the case a developer most needs surfaced (their app's resource is - // erroring or malformed) — log it and report it via deps.onResourceError so - // the host can show something better than a blank frame. The bridge stays - // live so a retry path remains possible. - bridge.addEventListener("sandboxready", () => { - void (async () => { - try { - const uri = getToolUiResourceUri( - tool as Parameters[0], - ); - if (!uri) return; - const result = await deps.readResource(uri); - const { html, meta } = extractHtmlAndMeta(result); - // Build the per-app CSP host-side: filter the requested sources to - // ones the host accepts, render the policy string, and wrap the - // app's HTML in a fixed shell whose first child is the CSP - // . The proxy assigns that document to srcdoc verbatim — it - // never parses the untrusted bytes — so the policy is guaranteed to - // apply before any app content loads. The approved (post-filter) csp - // is what we echo back via hostCapabilities.sandbox so the view sees - // what was granted, not what it asked for. Set before - // sendSandboxResourceReady: the view only sends ui/initialize once it - // has the HTML, so the bridge reflects this in the initialize result. - const approvedCsp = approveCspSources(meta?.csp); - // NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered - // by approveCspSources because its sources are interpolated into the - // CSP content string. `permissions` is NOT filtered here — it is - // a structured object (camera/microphone/geolocation/clipboardWrite - // booleans), and its only consumer is the sandbox proxy's - // buildAllowAttribute(), which maps each known key to a fixed - // Permissions-Policy token and ignores anything else. Untrusted values - // therefore can't reach the iframe `sandbox`/`allow` attribute as raw - // text (that layer, and the allow-same-origin strip, is owned by the - // sandbox-hardening work in #1565), so no source-style allowlist applies. - hostCapabilities.sandbox = { - permissions: meta?.permissions, - csp: approvedCsp, - }; - await bridge.sendSandboxResourceReady({ - html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)), - permissions: meta?.permissions, - }); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - console.error( - "[mcp-app] failed to load UI resource into sandbox:", - error, - ); - deps.onResourceError?.(error); - } - })(); - }); - - bridge.onopenlink = async ({ url }) => { - if (/^https?:\/\//i.test(url)) { - window.open(url, "_blank", "noopener,noreferrer"); - return { isError: false }; - } - return { isError: true }; - }; - - // The view asks the host to save MCP resource contents to disk (sandboxed - // iframes can't download directly). Confirm with the user first — the spec - // requires a host-mediated confirmation — then write each item out. A - // declined prompt, an empty/oversized payload, or a thrown error all - // return isError. - bridge.ondownloadfile = async ({ contents }) => { - if (!Array.isArray(contents) || contents.length === 0) { - return { isError: true }; - } - // Sanity cap: one approval must not fan out into an unbounded number of - // downloads / new tabs. A buggy or hostile app requesting hundreds of - // items is rejected outright rather than acted on. - if (contents.length > MAX_DOWNLOAD_ITEMS) { - console.warn( - `[mcp-app] refusing download batch of ${contents.length} items (max ${MAX_DOWNLOAD_ITEMS})`, - ); - return { isError: true }; - } - const summary = contents - .map((item) => sanitizeDownloadLabel(describeDownloadItem(item, true))) - .join("\n"); - const approved = window.confirm( - `This MCP App wants to download or open ${contents.length} item(s):\n\n${summary}`, - ); - if (!approved) return { isError: true }; - let succeeded = 0; - const skipped: string[] = []; - for (const item of contents) { - try { - if (downloadResourceItem(item)) { - succeeded++; - } else { - skipped.push(describeDownloadItem(item)); - } - } catch (err) { - skipped.push(describeDownloadItem(item)); - console.error("[mcp-app] download item failed:", err); - } - } - if (skipped.length > 0) { - console.warn( - `[mcp-app] ${skipped.length} of ${contents.length} download item(s) skipped:`, - skipped, - ); - } - return { isError: succeeded === 0 }; - }; - - const transport = new PostMessageTransport(targetWindow, targetWindow); - await bridge.connect(transport); - return bridge; - }; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts deleted file mode 100644 index eca568827..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/AppRenderer/hostContext.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { - McpUiDisplayMode, - McpUiHostContext, - McpUiHostStyles, - McpUiStyles, - McpUiStyleVariableKey, -} from "@modelcontextprotocol/ext-apps/app-bridge"; - -/** - * Resolve the host theme from the DOM. Mantine writes the resolved color - * scheme to ``. Reading it here (rather than - * capturing React state) keeps the bridge factory's identity stable across - * theme toggles — the renderer treats a new factory identity as "rebuild the - * bridge", which would reload a running app's iframe on every theme flip. - * - * The attribute is only ever `"light"` or `"dark"` — Mantine resolves - * `defaultColorScheme="auto"` to the system value before paint and never - * writes `"auto"` here, so no `auto` branch is needed. The matchMedia - * fallback only covers the attribute being absent (e.g. a hydration race). - */ -export function currentTheme(): "light" | "dark" { - if (typeof document !== "undefined") { - const attr = document.documentElement.getAttribute( - "data-mantine-color-scheme", - ); - if (attr === "dark" || attr === "light") return attr; - } - if ( - typeof window !== "undefined" && - window.matchMedia?.("(prefers-color-scheme: dark)").matches - ) { - return "dark"; - } - return "light"; -} - -/** - * Maps the spec's host-style variable keys ({@link McpUiStyleVariableKey}) to - * the inspector's underlying CSS custom properties. The inspector themes itself - * with Mantine, so each spec token resolves to the matching Mantine design-token - * variable (or an `--inspector-*` token layered on top of one). Only a curated - * subset of the ~90 spec keys is mapped — the ones the inspector has a - * meaningful equivalent for; the rest are omitted, which the spec allows (hosts - * may provide any subset). - */ -const STYLE_VARIABLE_SOURCES: Partial> = { - "--color-background-primary": "--mantine-color-body", - "--color-background-secondary": "--inspector-surface-card", - "--color-background-tertiary": "--inspector-surface-subtle", - "--color-text-primary": "--mantine-color-text", - "--color-text-secondary": "--inspector-text-secondary", - "--color-text-inverse": "--inspector-text-inverse", - "--color-text-info": "--inspector-log-info", - "--color-text-danger": "--inspector-log-error", - "--color-text-success": "--inspector-status-connected", - "--color-text-warning": "--inspector-log-warning", - "--color-border-primary": "--inspector-border-default", - "--color-border-secondary": "--inspector-border-subtle", - "--font-sans": "--mantine-font-family", - "--font-mono": "--mantine-font-family-monospace", - "--font-text-xs-size": "--mantine-font-size-xs", - "--font-text-sm-size": "--mantine-font-size-sm", - "--font-text-md-size": "--mantine-font-size-md", - "--font-text-lg-size": "--mantine-font-size-lg", - "--border-radius-xs": "--mantine-radius-xs", - "--border-radius-sm": "--mantine-radius-sm", - "--border-radius-md": "--mantine-radius-md", - "--border-radius-lg": "--mantine-radius-lg", - "--border-radius-xl": "--mantine-radius-xl", - "--shadow-sm": "--mantine-shadow-sm", - "--shadow-md": "--mantine-shadow-md", - "--shadow-lg": "--mantine-shadow-lg", -}; - -const STYLE_VARIABLE_ENTRIES = Object.entries(STYLE_VARIABLE_SOURCES) as [ - McpUiStyleVariableKey, - string, -][]; - -/** - * Resolve the inspector's design tokens into a {@link McpUiHostStyles} for - * hostContext, so style-aware apps can theme themselves from the host instead - * of falling back to their own defaults. Reads the computed value of each - * mapped CSS variable from the document root — which reflects the active - * Mantine color scheme — and keeps only the ones that resolve to a non-empty - * value. Returns undefined when nothing resolves (e.g. a non-DOM/test - * environment) so we never advertise an empty styles object. - */ -export function currentStyles(): McpUiHostStyles | undefined { - if (typeof document === "undefined" || typeof window === "undefined") { - return undefined; - } - const computed = window.getComputedStyle(document.documentElement); - const variables: McpUiStyles = {} as McpUiStyles; - let resolved = false; - for (const [specKey, sourceVar] of STYLE_VARIABLE_ENTRIES) { - const value = computed.getPropertyValue(sourceVar).trim(); - if (value) { - variables[specKey] = value; - resolved = true; - } - } - return resolved ? { variables } : undefined; -} - -/** - * Spec shape for `hostContext.containerDimensions`. Derived from - * {@link McpUiHostContext} so the seed and live-push paths share one source of - * truth and stay in lockstep with the spec types. - */ -export type ContainerDimensions = NonNullable< - McpUiHostContext["containerDimensions"] ->; - -/** - * Measure the host container an app renders into and return its concrete - * `{ width, height }` (whole pixels). Returns undefined when the element has - * no layout box yet (0×0 — e.g. before the iframe is attached, or in a - * non-DOM/test environment) so a meaningless size is never seeded into - * hostContext. The return type is the concrete pair rather than the spec's - * {@link ContainerDimensions} union so callers can compare both fields. - */ -export function measureContainerDimensions( - element: HTMLElement, -): { width: number; height: number } | undefined { - if (typeof element.getBoundingClientRect !== "function") return undefined; - const rect = element.getBoundingClientRect(); - const width = Math.round(rect.width); - const height = Math.round(rect.height); - if (width <= 0 || height <= 0) return undefined; - return { width, height }; -} - -/** - * Read the live host UI state into a {@link McpUiHostContext} for the bridge - * handshake — the single place that decides which fields the inspector seeds. - * Optional fields are omitted (not set undefined) so the SDK's diff stays - * accurate; subsequent live changes are pushed by the renderer's observers as - * partial `host-context-changed` notifications. - */ -export function snapshotHostContext( - container: HTMLElement | null, - availableDisplayModes: readonly McpUiDisplayMode[], -): McpUiHostContext { - const styles = currentStyles(); - const containerDimensions = container - ? measureContainerDimensions(container) - : undefined; - return { - theme: currentTheme(), - // Seed assumes the app opens inline. AppsScreen always mounts the renderer - // inline (maximize is a later user action), so this holds today; the live - // displayMode push (AppRenderer's displayMode effect, wired by #1568) - // carries any subsequent inline↔fullscreen transition. If a caller ever - // mounts already-maximized, thread the actual mode in here instead. - displayMode: "inline", - availableDisplayModes: [...availableDisplayModes], - ...(styles ? { styles } : {}), - ...(containerDimensions ? { containerDimensions } : {}), - }; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx deleted file mode 100644 index a968c4ace..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; - -export interface CategoryBadgeProps { - /** - * Network request category: `transport` (MCP protocol traffic, blue) or - * `auth` (OAuth discovery/token requests, violet). - */ - category: FetchRequestCategory; -} - -const BG: Record = { - transport: "var(--inspector-badge-transport-bg)", - auth: "var(--inspector-badge-auth-bg)", -}; - -const FG: Record = { - transport: "var(--inspector-badge-transport-fg)", - auth: "var(--inspector-badge-auth-fg)", -}; - -/** - * Badge tagging a Network entry's request category — `transport` (blue) or - * `auth` (violet). Surfaces come from `--inspector-badge-*` tokens: a tinted - * fill in light mode, a deep saturated fill with light text in dark mode - * (matching the Protocol direction badges). Used by `NetworkEntry`. - */ -export function CategoryBadge({ category }: CategoryBadgeProps) { - return ( - - {category} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx deleted file mode 100644 index f64350973..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ClearButton/ClearButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { CloseButton } from "@mantine/core"; - -/** - * The clear (×) affordance shown in a populated text input's right section - * (`rightSection`). Wraps Mantine's `CloseButton` with a fixed - * `aria-label="Clear"` and `tabIndex={-1}`, so the button stays mouse-clickable - * but is skipped during keyboard tab navigation — tabbing through a form lands - * on the next field, not on the clear button (see #1487). Pass `onClick` to - * reset the field's value to "". Both presets can still be overridden per-site. - */ -export const ClearButton = CloseButton.withProps({ - "aria-label": "Clear", - tabIndex: -1, -}); diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx deleted file mode 100644 index cfce77b84..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Code } from "@mantine/core"; -import { useEffect, useState } from "react"; -import type { ComponentType } from "react"; - -/** - * Lazy syntax-highlighting code block. The `react-syntax-highlighter` - * prism-light runtime, its theme chunk, and each language grammar are - * dynamic-imported on first use so a session that never opens a highlightable - * resource never pays for them. While a grammar is still loading (or the - * language is unknown) the raw code is shown in a plain Mantine `Code` block so - * users never see a flash of unstyled tokens. - */ -export interface CodeHighlightProps { - /** Highlight.js / Prism language tag (or an alias — see {@link LANGUAGE_ALIASES}). */ - language: string; - /** The source text to render. */ - code: string; -} - -/** A Prism grammar object; opaque to us — registered with the runtime as-is. */ -type Grammar = unknown; - -/** The prism-light runtime component plus its `registerLanguage` static. */ -type PrismRuntime = ComponentType<{ - language: string; - style: Record; - customStyle?: Record; - wrapLongLines?: boolean; - children: string; -}> & { registerLanguage: (name: string, grammar: Grammar) => void }; - -/** - * Static per-language import thunks. Each is a separate dynamic import so Vite - * emits one lazily-loaded chunk per grammar. Add an entry here (and an alias - * below if the canonical Prism name differs) as the type matrix grows. - */ -const LANGUAGE_LOADERS: Record Promise<{ default: Grammar }>> = { - json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"), - markup: () => - import("react-syntax-highlighter/dist/esm/languages/prism/markup"), - css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"), - yaml: () => import("react-syntax-highlighter/dist/esm/languages/prism/yaml"), - markdown: () => - import("react-syntax-highlighter/dist/esm/languages/prism/markdown"), -}; - -/** Friendly language tags → the canonical Prism grammar name they resolve to. */ -const LANGUAGE_ALIASES: Record = { - xml: "markup", - html: "markup", - htm: "markup", - svg: "markup", - yml: "yaml", - md: "markdown", -}; - -/** Grammars successfully loaded + registered this session. */ -const registeredLanguages = new Set(); -/** Grammars whose import rejected / are unknown — never retried. */ -const failedLoads = new Set(); -/** In-flight grammar loads, so concurrent mounts share one async call. */ -const loadingPromises = new Map>(); - -/** The shared prism-light runtime component + theme. */ -interface Runtime { - Prism: PrismRuntime; - style: Record; -} - -/** The shared prism-light runtime + theme, loaded once. */ -let runtime: Runtime | null = null; -let runtimePromise: Promise | null = null; - -/** Resolve an alias to its canonical Prism grammar name. */ -function resolveLanguage(language: string): string { - return LANGUAGE_ALIASES[language] ?? language; -} - -/** Load (and cache) the prism-light runtime component and the `tomorrow` theme. */ -async function ensureRuntime(): Promise { - if (runtime) return runtime; - if (!runtimePromise) { - runtimePromise = (async () => { - const [prismMod, styleMod] = await Promise.all([ - import("react-syntax-highlighter/dist/esm/prism-light"), - import("react-syntax-highlighter/dist/esm/styles/prism/tomorrow"), - ]); - runtime = { - Prism: prismMod.default as PrismRuntime, - style: styleMod.default, - }; - return runtime; - })(); - } - return runtimePromise; -} - -/** - * Ensure the grammar for `language` is loaded and registered. Resolves once the - * language is ready, a prior load failed, or the language is unknown — callers - * re-check {@link isLanguageReady} afterward rather than relying on this throwing. - */ -async function ensureLanguage(language: string): Promise { - const name = resolveLanguage(language); - if (registeredLanguages.has(name) || failedLoads.has(name)) return; - const inFlight = loadingPromises.get(name); - if (inFlight) return inFlight; - - const loader = LANGUAGE_LOADERS[name]; - if (!loader) { - failedLoads.add(name); - return; - } - - const load = (async () => { - try { - const rt = await ensureRuntime(); - const { default: grammar } = await loader(); - rt.Prism.registerLanguage(name, grammar); - registeredLanguages.add(name); - } catch { - failedLoads.add(name); - } finally { - loadingPromises.delete(name); - } - })(); - loadingPromises.set(name, load); - return load; -} - -const PlainCode = Code.withProps({ block: true }); - -export function CodeHighlight({ language, code }: CodeHighlightProps) { - // Readiness is derived from the module-level caches during render (so a - // language already loaded this session highlights on first paint, including - // after the `language` prop changes). The effect only bumps a tick when an - // async load finishes, forcing a re-render that re-reads the caches. - const [, bumpTick] = useState(0); - - useEffect(() => { - let cancelled = false; - void ensureLanguage(language).then(() => { - if (!cancelled) bumpTick((t) => t + 1); - }); - return () => { - cancelled = true; - }; - }, [language]); - - const resolved = resolveLanguage(language); - const rt = runtime; - // Plain block until the runtime has loaded and this grammar is registered. - if (!rt || !registeredLanguages.has(resolved)) { - return {code}; - } - - const { Prism, style } = rt; - return ( - - {code} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx deleted file mode 100644 index f2c2786a1..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Code, Flex, Stack } from "@mantine/core"; - -const ContentWrapper = Flex.withProps({ - pos: "relative", - direction: "column", -}); - -const NoticeCode = Code.withProps({ block: true, p: 36 }); - -/** - * Fallback shown when content can't be previewed — an unsupported binary MIME - * type, or a blob whose base64 fails to decode. Kept in its own module so blob - * renderers (e.g. {@link PdfFrame}) can degrade to it without importing back - * into {@link ContentViewer} (which would form an import cycle). - */ -export function BinaryNotice({ mimeType }: { mimeType: string }) { - return ( - - - - {`[Binary content (${mimeType}) — preview not supported]`} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx deleted file mode 100644 index fac6000f1..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { Code, Flex, Image, Stack } from "@mantine/core"; -import type { ReactNode } from "react"; -import type { - BlobResourceContents, - ContentBlock, - TextResourceContents, -} from "@modelcontextprotocol/client"; -import ReactMarkdown from "react-markdown"; -import type { Components } from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { CodeHighlight } from "../CodeHighlight/CodeHighlight"; -import { CopyButton } from "../CopyButton/CopyButton"; -import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo"; -import { - formatJson, - formatXml, - getMimeKind, - isSafeHref, - isTextualKind, - looksLikeJson, - tryDecodeBase64ToUtf8, -} from "./contentViewerUtils"; -import { BinaryNotice } from "./BinaryNotice"; -import { CsvTable } from "./CsvTable"; -import { HtmlFrame } from "./HtmlFrame"; -import { PdfFrame } from "./PdfFrame"; - -export interface ContentViewerProps { - /** - * A content block to render (tool results, prompt messages, server cards, …). - * Provide either `block` or `contents`. - */ - block?: ContentBlock; - /** - * Raw resource contents (Resources screen). When provided, the per-MIME - * dispatch keys off `mimeType` and the base64 `blob` / `text`, covering - * PDF / CSV / HTML / XML / CSS in addition to the content-block cases. - * Provide either `block` or `contents`. - */ - contents?: TextResourceContents | BlobResourceContents; - copyable?: boolean; - /** - * Effective MIME type for the content. Drives the per-MIME renderer dispatch - * (markdown, JSON, XML, CSS, CSV, HTML, PDF). When absent, text falls back to - * a JSON-shape heuristic then plain preformatted code. - */ - mimeType?: string; - /** - * Whether long plain-text content wraps onto multiple lines. When `false`, - * text is kept to a single line (overflow clipped with an ellipsis) so the - * viewer keeps a fixed height — used by hosts like the server card where the - * box height must stay constant regardless of command/URL length. The full - * value remains available via the copy button (and a native `title` - * tooltip). Defaults to `true`. - * - * Intended for single-line values only: `false` applies `white-space: - * nowrap`, which collapses embedded newlines (e.g. pretty-printed JSON) onto - * one line — don't pass it for multi-line content. - */ - wrap?: boolean; -} - -function buildDataUri(mimeType: string, data: string): string { - return `data:${mimeType};base64,${data}`; -} - -const ContentWrapper = Flex.withProps({ - pos: "relative", - direction: "column", -}); - -const CopyOverlay = Flex.withProps({ - pos: "absolute", - top: 4, - right: 4, -}); - -const MarkdownWrapper = Flex.withProps({ - className: "markdown-content", - direction: "column", -}); - -const PreviewImage = Image.withProps({ - alt: "Content preview", - maw: 400, - radius: "md", -}); - -const CodeBlock = Code.withProps({ - block: true, - p: 36, -}); - -// Markdown anchors are constrained to a safe-scheme allowlist: a non-matching -// href (e.g. `javascript:`, protocol-relative `//evil.com`) renders as inert -// text so user-supplied markdown can't smuggle a script-bearing link. -const SafeAnchor: Components["a"] = ({ href, children }) => - isSafeHref(href) ? {children} : {children}; - -const markdownComponents: Components = { a: SafeAnchor }; - -function CopyableWrapper({ - copyable, - copyValue, - children, -}: { - copyable: boolean; - copyValue: string; - children: ReactNode; -}) { - return ( - - - {children} - {copyable && ( - - - - )} - - - ); -} - -function MarkdownContent({ - text, - copyable, -}: { - text: string; - copyable: boolean; -}) { - return ( - - - - {text} - - - - ); -} - -function HighlightedContent({ - code, - language, - copyValue, - copyable, -}: { - code: string; - language: string; - copyValue: string; - copyable: boolean; -}) { - return ( - - - - ); -} - -function PlainTextContent({ - text, - copyable, - wrap, -}: { - text: string; - copyable: boolean; - wrap: boolean; -}) { - const displayText = looksLikeJson(text) ? formatJson(text) : text; - return ( - - - {displayText} - - - ); -} - -/** - * Render decoded text according to its MIME type: markdown, syntax-highlighted - * JSON / XML / CSS, a CSV table, a sandboxed HTML iframe, or — for plain or - * unrecognized text — a preformatted code block (with a JSON-shape heuristic so - * mimeless JSON still pretty-prints). - */ -function TextualContent({ - text, - mimeType, - copyable, - wrap, -}: { - text: string; - mimeType: string | undefined; - copyable: boolean; - wrap: boolean; -}) { - const kind = mimeType ? getMimeKind(mimeType) : "text"; - switch (kind) { - case "markdown": - return ; - case "json": - return ( - - ); - case "xml": - return ( - - ); - case "css": - return ( - - ); - case "csv": - return ( - - - - ); - case "html": - return ( - - - - ); - default: - return ; - } -} - -function ImageContent({ data, mimeType }: { data: string; mimeType: string }) { - return ( - - - - ); -} - -function AudioContent({ data, mimeType }: { data: string; mimeType: string }) { - return ( - - - - ); -} - -/** Dispatch raw resource contents (Resources screen) on their effective MIME. */ -function ResourceContent({ - contents, - mimeType, - copyable, - wrap, -}: { - contents: TextResourceContents | BlobResourceContents; - mimeType: string; - copyable: boolean; - wrap: boolean; -}) { - if ("text" in contents) { - return ( - - ); - } - const kind = getMimeKind(mimeType); - if (kind === "image") { - return ; - } - if (kind === "audio") { - return ; - } - if (kind === "pdf") { - return ( - - - - ); - } - if (isTextualKind(kind)) { - const decoded = tryDecodeBase64ToUtf8(contents.blob); - if (decoded === null) { - return ; - } - return ( - - ); - } - return ; -} - -/** Dispatch a content block (tool results, prompt messages, …) on its type. */ -function BlockContent({ - block, - mimeType, - copyable, - wrap, -}: { - block: ContentBlock; - mimeType: string | undefined; - copyable: boolean; - wrap: boolean; -}) { - switch (block.type) { - case "text": - return ( - - ); - case "image": - return ; - case "audio": - return ; - case "resource": - return ( - - - - {"text" in block.resource - ? block.resource.text - : `[blob: ${block.resource.uri}]`} - - - - ); - case "resource_link": - // Static metadata only. The interactive, read-on-demand presentation - // lives in the `groups/ResourceLink` group, rendered by content-block - // hosts (e.g. ToolResultPanel) that can supply a read handler. - return ( - - ); - default: - return null; - } -} - -export function ContentViewer({ - block, - contents, - copyable = false, - mimeType, - wrap = true, -}: ContentViewerProps) { - if (contents) { - const effective = - mimeType ?? contents.mimeType ?? "application/octet-stream"; - return ( - - ); - } - if (!block) return null; - return ( - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx deleted file mode 100644 index 81bf4fb68..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/CsvTable.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Code, Table } from "@mantine/core"; -import { useMemo } from "react"; -import Papa from "papaparse"; - -/** - * Render CSV text as a Mantine `Table`. Parsed with papaparse in header mode; - * only the first {@link MAX_ROWS} rows are shown to keep large files cheap. When - * the text doesn't parse as a header-bearing table (no detected columns), the - * raw text is shown in a plain wrapping `Code` block instead of throwing. - */ -export interface CsvTableProps { - /** The CSV document text. */ - text: string; -} - -/** Cap rendered rows so a huge CSV doesn't mount thousands of DOM nodes. */ -export const MAX_ROWS = 100; - -const PlainCode = Code.withProps({ block: true, variant: "wrapping" }); - -const CsvGrid = Table.withProps({ - striped: true, - highlightOnHover: true, - withTableBorder: true, - withColumnBorders: true, -}); - -interface ParsedCsv { - fields: string[]; - rows: string[][]; - /** Total parsed row count, before the {@link MAX_ROWS} display cap. */ - total: number; -} - -function parseCsv(text: string): ParsedCsv | null { - const result = Papa.parse>(text, { - header: true, - skipEmptyLines: true, - }); - const fields = result.meta.fields ?? []; - if (fields.length === 0 || result.data.length === 0) { - return null; - } - const rows = result.data - .slice(0, MAX_ROWS) - .map((row) => fields.map((field) => row[field] ?? "")); - return { fields, rows, total: result.data.length }; -} - -export function CsvTable({ text }: CsvTableProps) { - const parsed = useMemo(() => parseCsv(text), [text]); - - if (!parsed) { - return {text}; - } - - const truncated = parsed.total > MAX_ROWS; - return ( - - {truncated && ( - - {`Showing first ${MAX_ROWS} of ${parsed.total} rows`} - - )} - - - {parsed.fields.map((field) => ( - {field} - ))} - - - - {parsed.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - {cell} - ))} - - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx deleted file mode 100644 index 228b9a562..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Box } from "@mantine/core"; -import { useMemo } from "react"; -import { wrapHtmlWithCsp } from "./contentViewerUtils"; -import { useObjectUrl } from "./useObjectUrl"; - -/** - * Render an HTML resource inside a hardened iframe. Defense is layered: - * - * - `sandbox=""` — explicitly empty: no `allow-scripts`, `allow-forms`, or - * `allow-same-origin`, so scripts can't run and the frame is origin-isolated. - * - A `Content-Security-Policy` `` is injected (see {@link wrapHtmlWithCsp}) - * as defense-in-depth — correct even if the sandbox is later loosened. - * - The document is served from a `Blob` object URL (revoked on unmount) rather - * than `srcdoc`, keeping it off the parent's origin. - */ -export interface HtmlFrameProps { - /** The raw HTML document or fragment to preview. */ - html: string; -} - -export function HtmlFrame({ html }: HtmlFrameProps) { - const blob = useMemo( - () => new Blob([wrapHtmlWithCsp(html)], { type: "text/html" }), - [html], - ); - const url = useObjectUrl(blob); - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx deleted file mode 100644 index eb5664af2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/PdfFrame.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Box } from "@mantine/core"; -import { useMemo } from "react"; -import { BinaryNotice } from "./BinaryNotice"; -import { tryDecodeBase64ToBytes } from "./contentViewerUtils"; -import { useObjectUrl } from "./useObjectUrl"; - -/** - * Render a base64-encoded PDF in an in-page viewer. The bytes are wrapped in a - * `Blob` and served via an object URL (revoked on unmount / when the data - * changes) rather than a multi-megabyte `data:` URI. `#view=FitH` asks the - * browser's built-in viewer to fit the page width. - * - * The `blob` comes from an external MCP server; if its base64 fails to decode - * we degrade to the binary-content notice instead of throwing during render. - */ -export interface PdfFrameProps { - /** Base64-encoded PDF bytes (the `blob` field of a `BlobResourceContents`). */ - data: string; -} - -export function PdfFrame({ data }: PdfFrameProps) { - const bytes = useMemo(() => tryDecodeBase64ToBytes(data), [data]); - const blob = useMemo( - () => - bytes ? new Blob([bytes], { type: "application/pdf" }) : new Blob([]), - [bytes], - ); - const url = useObjectUrl(blob); - if (!bytes) { - return ; - } - return ( - // Box+iframe is a native element (not a Mantine primitive), so the - // `.withProps()` extraction rule doesn't apply. - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts deleted file mode 100644 index c56b6f78f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept in - * a dependency-free module so they can be unit-tested in isolation and reused by - * the blob renderers (PDF / CSV / HTML) without dragging in React. - */ - -/** - * The renderer family a MIME type maps to. `ContentViewer` switches on this to - * pick a branch; `binary` is the catch-all "preview not supported" fallback. - */ -export type MimeKind = - | "image" - | "audio" - | "pdf" - | "markdown" - | "json" - | "xml" - | "css" - | "csv" - | "html" - | "text" - | "binary"; - -/** Strip any `; charset=…` parameters and normalise case for comparison. */ -function baseMime(mimeType: string): string { - return mimeType.split(";")[0].trim().toLowerCase(); -} - -/** - * Classify a MIME type into the renderer family `ContentViewer` should use. - * Structured-suffix types (`application/foo+json`, `image/svg+xml`) fold into - * their base family. Unknown `application/*` types fall through to `binary`. - */ -export function getMimeKind(mimeType: string): MimeKind { - const base = baseMime(mimeType); - if (base.startsWith("image/")) return "image"; - if (base.startsWith("audio/")) return "audio"; - if (base === "application/pdf") return "pdf"; - if (base === "text/markdown" || base === "text/x-markdown") return "markdown"; - if (base === "application/json" || base.endsWith("+json")) return "json"; - if (base === "text/csv") return "csv"; - if (base === "text/html") return "html"; - if (base === "text/css") return "css"; - if ( - base === "text/xml" || - base === "application/xml" || - base.endsWith("+xml") - ) - return "xml"; - if ( - base === "application/javascript" || - base === "application/ecmascript" || - base === "application/x-javascript" - ) - return "text"; - if (base.startsWith("text/")) return "text"; - return "binary"; -} - -/** Renderer families that operate on decoded text rather than raw bytes. */ -const TEXTUAL_KINDS: ReadonlySet = new Set([ - "markdown", - "json", - "xml", - "css", - "csv", - "html", - "text", -]); - -/** Whether a MIME kind is rendered from decoded UTF-8 text. */ -export function isTextualKind(kind: MimeKind): boolean { - return TEXTUAL_KINDS.has(kind); -} - -/** - * Decode a base64 string to UTF-8 text. Used when a server delivers inherently - * textual content (CSV, XML, HTML, …) as a `BlobResourceContents` blob instead - * of as `text`. - */ -export function decodeBase64ToUtf8(base64: string): string { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return new TextDecoder("utf-8").decode(bytes); -} - -/** - * Decode a base64 string to raw bytes. Used to build a `Blob` URL for binary - * previews (e.g. PDF) without round-tripping through a `data:` URI. - */ -export function decodeBase64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -/** - * Decode base64 to UTF-8 text, returning `null` instead of throwing when the - * input isn't valid base64. `atob` raises `InvalidCharacterError` on malformed - * input; since the `blob` comes from an external MCP server and is decoded - * during render, a throw would take down the whole preview panel rather than - * degrading to the binary-content fallback. Callers treat `null` as "not - * decodable — show the fallback". - */ -export function tryDecodeBase64ToUtf8(base64: string): string | null { - try { - return decodeBase64ToUtf8(base64); - } catch { - return null; - } -} - -/** Like {@link decodeBase64ToBytes} but returns `null` on malformed base64. */ -export function tryDecodeBase64ToBytes( - base64: string, -): Uint8Array | null { - try { - return decodeBase64ToBytes(base64); - } catch { - return null; - } -} - -/** Pretty-print JSON text; returns the input unchanged when it doesn't parse. */ -export function formatJson(content: string): string { - try { - return JSON.stringify(JSON.parse(content), null, 2); - } catch { - return content; - } -} - -/** Heuristic: does this plain text (no MIME) look like a JSON document? */ -export function looksLikeJson(text: string): boolean { - const trimmed = text.trimStart(); - return trimmed.startsWith("{") || trimmed.startsWith("["); -} - -/** - * Indent a single-line or minified XML/HTML-ish document for readability before - * syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a - * nesting depth, decrementing on closing tags and incrementing after opening - * tags that aren't self-closing or a one-line `text` pair. - */ -export function formatXml(xml: string): string { - const withBreaks = xml.replace(/>\s*\n<").trim(); - let depth = 0; - const out: string[] = []; - for (const raw of withBreaks.split("\n")) { - const line = raw.trim(); - if (!line) continue; - const isClosing = /^<\//.test(line); - if (isClosing) depth = Math.max(depth - 1, 0); - out.push(" ".repeat(depth) + line); - const isOpening = - /^<[A-Za-z]/.test(line) && // a tag, not a comment / declaration - !/\/>$/.test(line) && // not self-closing - !isClosing && // not a closing tag - !/^<([A-Za-z][\w-]*)\b[^>]*>.*<\/\1>$/.test(line); // not a one-line pair - if (isOpening) depth++; - } - return out.join("\n"); -} - -/** - * Content-Security-Policy applied to previewed HTML resources. `script-src` is - * deliberately omitted so it falls through to `default-src 'none'` — that's what - * keeps the policy load-bearing if the iframe `sandbox` is ever loosened to - * allow scripts. Styles/fonts/images are permitted so reports render, but no - * navigation, plugins, or form submission. - */ -export const PREVIEW_HTML_CSP = - "default-src 'none'; " + - "style-src 'unsafe-inline' https://fonts.googleapis.com; " + - "img-src data: blob:; " + - "font-src data: https://fonts.gstatic.com; " + - "base-uri 'none'; " + - "object-src 'none'; " + - "form-action 'none';"; - -const CSP_META_TAG = ``; - -/** - * Inject the preview CSP `` into an HTML document before it's served to a - * sandboxed iframe. Handles three shapes: a full document with a `` (inject - * at the top of head), a document with `` but no `` (add a head), and - * a bare fragment (wrap in a minimal document). - */ -export function wrapHtmlWithCsp(html: string): string { - if (/]/i.test(html)) { - return html.replace(/]*)>/i, `${CSP_META_TAG}`); - } - if (/]/i.test(html)) { - return html.replace( - /]*)>/i, - `${CSP_META_TAG}`, - ); - } - return `${CSP_META_TAG}${html}`; -} - -/** - * Safe-scheme allowlist for markdown anchors. Permits absolute http(s), mailto, - * in-page fragments, and root-relative paths — but rejects protocol-relative - * `//evil.com` and dangerous schemes (`javascript:`, `data:`, …) so - * user-supplied markdown can't smuggle a script-bearing link. - */ -export const SAFE_HREF = /^(https?:|mailto:|#|\/(?!\/))/i; - -/** Whether a markdown anchor `href` is safe to render as a real ``. */ -export function isSafeHref(href: string | undefined): boolean { - return typeof href === "string" && SAFE_HREF.test(href); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts deleted file mode 100644 index 753375f7e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ContentViewer/useObjectUrl.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; - -/** - * Create an object URL for `blob` and revoke it when the blob changes or the - * component unmounts. Memoize the `Blob` in the caller (e.g. with `useMemo`) so - * a stable blob identity doesn't re-create the URL on every render. - * - * The URL is derived during render (via `useMemo`) so consumers get a live URL - * on the first paint. Revocation is **deferred to a microtask** and guarded by - * `liveUrlRef`, which always points at the currently-mounted URL: - * - * - Under React StrictMode (dev) the effect runs setup → cleanup → setup with - * no re-render. The cleanup schedules the revoke; the re-setup restores - * `liveUrlRef` to the same URL, so when the microtask runs it sees the URL - * is still live and skips the revoke — the iframe keeps a valid `src`. - * - A real unmount (no re-setup) or a blob change (a new URL takes over) leaves - * `liveUrlRef` pointing elsewhere, so the stale URL is released. - * - * Revoking synchronously in the cleanup (the obvious shape) would instead kill - * the committed URL under StrictMode, blanking PDF/HTML previews in dev. This - * mirrors the deferred-disposal trick in `AppRenderer`. - */ -export function useObjectUrl(blob: Blob): string { - const url = useMemo(() => URL.createObjectURL(blob), [blob]); - const liveUrlRef = useRef(null); - - useEffect(() => { - liveUrlRef.current = url; - return () => { - liveUrlRef.current = null; - queueMicrotask(() => { - if (liveUrlRef.current !== url) { - URL.revokeObjectURL(url); - } - }); - }; - }, [url]); - - return url; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx deleted file mode 100644 index 7a86ca085..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/CopyButton/CopyButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - ActionIcon, - CopyButton as MantineCopyButton, - Tooltip, -} from "@mantine/core"; - -export interface CopyButtonProps { - value: string; - /** - * Drop ActionIcon padding/height so the glyph top-aligns in tight aside - * rows (e.g. beside a Code block). Icon size is unchanged. - */ - flush?: boolean; -} - -const CopyActionIcon = ActionIcon.withProps({ - variant: "subtle", - fz: 24, -}); - -export function CopyButton({ value, flush = false }: CopyButtonProps) { - return ( - - {({ copied, copy }) => ( - - - {copied ? "\u2713" : "\u2398"} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx deleted file mode 100644 index ad9e87f9f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { ReactNode, Ref } from "react"; -import { ScrollArea, Stack } from "@mantine/core"; - -// Full-size, the monitor stream panels bound their scroll to the viewport -// minus the header and the panel's own chrome. -const FULLSIZE_MAH = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - 150px)"; - -export interface EmbeddableScrollAreaProps { - /** - * True when rendered inside the pinned monitoring sidebar (#1616): the scroll - * region fills its flex parent instead of using the viewport calc. A - * `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining - * below the column's controls (via `mah:100%`), so no viewport math is needed - * and the final rows never clip. - */ - embedded: boolean; - viewportRef: Ref; - children: ReactNode; - /** - * Constrain the scrolled content to the viewport width instead of letting it - * grow to its own `max-content`. Mantine's ScrollArea `content` slot defaults - * to `min-width: max-content`, so a row with non-wrapping content (e.g. a long - * network URL) stretches every card past the column and it bleeds out (#1623). - * When true, the content can shrink to the viewport and each row must manage - * its own overflow (the Network URL scrolls inside its own inner ScrollArea). - * Left off for panels whose rows already wrap/truncate (Logs, Protocol), where - * the default lets a long line scroll the list horizontally instead. - */ - constrainContentWidth?: boolean; -} - -// Relax the `content` slot's default `min-width: max-content` so the list can't -// grow wider than its viewport; see `constrainContentWidth`. -const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const; - -// Shared scroll region for both hosts; the differing props (viewportRef, mah, -// styles) are passed at each call site. -const StreamScrollArea = ScrollArea.Autosize.withProps({ - type: "scroll", - offsetScrollbars: true, - viewportProps: { tabIndex: 0 }, -}); - -// Fill-height wrapper for the embedded host, so the inner scroll region can claim -// the remaining space and scroll instead of overflowing. -const EmbeddedColumn = Stack.withProps({ flex: 1, mih: 0, gap: 0 }); - -/** - * The scroll region shared by the Logs / Protocol / Network stream panels, which - * render both full-size (their own tab) and embedded (the monitoring sidebar). - * Centralizes the one layout difference between those two hosts. - */ -export function EmbeddableScrollArea({ - embedded, - viewportRef, - children, - constrainContentWidth = false, -}: EmbeddableScrollAreaProps) { - const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined; - if (embedded) { - return ( - - - {children} - - - ); - } - return ( - - {children} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx deleted file mode 100644 index 37c4ebe43..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/EraBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import { formatEra, isModernEra } from "./eraUtils"; - -export interface EraBadgeProps { - /** The negotiated protocol era; `undefined` renders as Legacy. */ - era: ProtocolEra | undefined; -} - -// Labels a connection's negotiated protocol era (SEP §7.8). Feed it from -// connection state only — see the note in `eraUtils` on why the era must never -// be inferred from individual message frames. -export function EraBadge({ era }: EraBadgeProps) { - return ( - - {formatEra(era)} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts deleted file mode 100644 index 1407dc633..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/EraBadge/eraUtils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProtocolEra } from "@modelcontextprotocol/client"; - -// The SDK reports an era for every connected server, including a plain legacy -// connect (`"legacy"`); it's `undefined` only when not connected. Anything other -// than `"modern"` is the legacy era. IMPORTANT: this reflects the *negotiated* -// connection era — it must be fed from connection state, never inferred from -// individual message frames (the modern probe carries a `_meta` envelope before -// the era is known; spec §8.3). -export function isModernEra(era: ProtocolEra | undefined): boolean { - return era === "modern"; -} - -export function formatEra(era: ProtocolEra | undefined): string { - return isModernEra(era) ? "Modern" : "Legacy"; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx deleted file mode 100644 index 6ff611541..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri"; - -export interface ExpandToggleProps { - /** Whether the owning entry is currently expanded. */ - expanded: boolean; - onToggle: () => void; - /** - * Overrides the accessible name (aria-label). Defaults to the tooltip text - * ("Expand"/"Collapse"). Pass a per-entry name (e.g. including the resource - * URI) when several toggles sit in one list, so assistive tech can tell them - * apart; the visible tooltip stays the plain verb. - */ - ariaLabel?: string; -} - -const ExpandActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -/** - * Icon toggle for a per-entry expand/collapse control (Protocol, Network, and - * Task cards). Uses the same expand/collapse-vertical icons as the list-level - * ListToggle: collapsed shows the expand icon, expanded shows the collapse - * icon. The tooltip stays "Expand"/"Collapse" (the same verb as the text button - * it replaced); `aria-expanded` exposes the disclosure state and `ariaLabel` - * can distinguish sibling toggles. - */ -export function ExpandToggle({ - expanded, - onToggle, - ariaLabel, -}: ExpandToggleProps) { - const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine; - const label = expanded ? "Collapse" : "Expand"; - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx deleted file mode 100644 index 92e217c6d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Text, UnstyledButton } from "@mantine/core"; -import { accessibleTextColor } from "../accessibleTextColor"; - -export interface FilterToggleButtonProps { - /** Visible label and accessible name for the toggle. */ - label: string; - /** Mantine text color for the label (e.g. "blue", "red", "dimmed"). */ - color: string; - /** Whether the filter is currently on (rendered as a filled background). */ - active: boolean; - /** Receives the next desired active state when the button is clicked. */ - onToggle: (active: boolean) => void; -} - -const ToggleLabel = Text.withProps({ - ta: "center", - fw: 500, -}); - -const ToggleButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "filterToggle", -}); - -/** - * A single full-width filter toggle used by the Logging, Protocol, and Network - * controls. The `filterToggle` theme variant + `.filter-toggle` rules own the - * styling: hover shows a thin border, the active (`aria-pressed`) state shows a - * filled background. Keeping hover as a border (not a fill) means toggling a - * button off while the cursor is still over it is visibly distinct from hover, - * instead of the two states sharing the same background. See issue #1460. - */ -export function FilterToggleButton({ - label, - color, - active, - onToggle, -}: FilterToggleButtonProps) { - return ( - onToggle(!active)}> - {label} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx deleted file mode 100644 index 1f3365b42..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListChangedIndicator/ListChangedIndicator.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Button, Group, Paper, Text } from "@mantine/core"; - -export interface ListChangedIndicatorProps { - visible: boolean; - onRefresh: () => void; -} - -const Dot = Paper.withProps({ - w: 8, - h: 8, - radius: "xl", - bg: "var(--inspector-status-connecting)", -}); - -const UpdateLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const RefreshButton = Button.withProps({ - size: "sm", - variant: "subtle", -}); - -export function ListChangedIndicator({ - visible, - onRefresh, -}: ListChangedIndicatorProps) { - if (!visible) return null; - - return ( - - - List updated - Refresh - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx deleted file mode 100644 index fdb4ef839..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Alert, Button, Code, ScrollArea, Stack } from "@mantine/core"; - -export interface ListLoadErrorProps { - /** - * The failed load's error, or `null`/`undefined` when the last load - * succeeded (renders nothing). - */ - error?: Error | null; - /** What failed to load, for the alert title — e.g. "tools", "prompts". */ - what: string; - /** Retry the load. Omit to render the alert without a retry affordance. */ - onRetry?: () => void; -} - -// `variant="light"` + red: an error the user can act on (retry), not a fatal -// one. Sits above the list rather than replacing it — a stale list plus a -// visible "this didn't reload" beats an empty panel that looks like an answer. -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", -}); - -// The raw message, monospaced and wrapping: these are validation failures -// (JSON paths, schema expectations) where the exact text is the diagnostic. -const ErrorMessage = Code.withProps({ - block: true, - variant: "wrapping", -}); - -// Caps the message: a schema-validation failure serializes to a dozen-plus -// lines, which would otherwise push the list itself off the sidebar. -const MessageScroll = ScrollArea.withProps({ - mah: 180, - type: "auto", -}); - -const RetryButton = Button.withProps({ - size: "xs", - variant: "light", - color: "red", - w: "fit-content", -}); - -/** - * The list panel's "couldn't load" state (#1953). - * - * A list fetch that fails — a transport error, or a result the SDK codec - * rejects as invalid for the negotiated protocol era — used to leave the panel - * empty, which is indistinguishable from a server that legitimately has no - * tools/prompts/resources. This says what happened and offers a retry. - */ -export function ListLoadError({ error, what, onRetry }: ListLoadErrorProps) { - if (!error) return null; - - return ( - - - - {error.message} - - {onRetry && Retry} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx deleted file mode 100644 index 3ef192118..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Button, Group, Stack, Switch, Text } from "@mantine/core"; - -export interface ListPaginationControlsProps { - /** - * True when the list is fetched one page at a time (backed by the server's - * `paginatedLists` setting). False = auto-aggregate every page on load. - */ - paginated: boolean; - /** Toggle paginated mode. Wired to write the `paginatedLists` setting. */ - onPaginatedChange: (paginated: boolean) => void; - /** Paginated mode only: the server returned a `nextCursor` to load. */ - canLoadMore: boolean; - /** Paginated mode only: number of pages loaded so far (status label). */ - loadedPages: number; - /** Paginated mode only: fetch the next page. */ - onLoadMore: () => void; -} - -// Switch fully left, "Load next page" fully right; the page-count sits on its -// own line under the row. -const ControlsRow = Group.withProps({ - gap: "sm", - align: "center", - justify: "space-between", - wrap: "nowrap", -}); - -const ModeSwitch = Switch.withProps({ - size: "sm", - "aria-label": "Fetch lists one page at a time", -}); - -const LoadMoreButton = Button.withProps({ - size: "compact-sm", - variant: "light", -}); - -const StatusText = Text.withProps({ - size: "xs", - ta: "center", - c: "var(--inspector-text-secondary)", -}); - -/** - * Sidebar control for a paginated list (Tools/Resources/Prompts). A "Paginated" - * switch toggles between auto-aggregating every page and fetching one page at a - * time; in paginated mode a "Load next page" button (to the right of the - * switch) surfaces the server's `nextCursor` and a status shows how many pages - * are loaded. Hidden entirely once the list is known to be a single page, since - * there's nothing to paginate. - */ -export function ListPaginationControls({ - paginated, - onPaginatedChange, - canLoadMore, - loadedPages, - onLoadMore, -}: ListPaginationControlsProps) { - // The list turned out to be a single page (loaded page 1, no `nextCursor`): - // pagination is moot, so hide the whole control rather than show a useless - // toggle + disabled button (#1721). - if (paginated && !canLoadMore && loadedPages === 1) return null; - - return ( - - - onPaginatedChange(e.currentTarget.checked)} - /> - {paginated ? ( - - Load next page - - ) : null} - - {paginated ? ( - - {loadedPages} {loadedPages === 1 ? "page" : "pages"} loaded - {canLoadMore ? "" : " · end"} - - ) : null} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx deleted file mode 100644 index 5a1efcc62..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri"; - -export interface ListToggleProps { - compact: boolean; - onToggle: () => void; - variant?: "default" | "subtle"; -} - -const SubtleActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -// `size={36}` matches the header's theme / client-settings ActionIcons so the -// toolbar's toggle reads as the same size icon button. -const ToolbarActionIcon = ActionIcon.withProps({ - variant: "subtle", - size: 36, -}); - -export function ListToggle({ - compact, - onToggle, - variant = "default", -}: ListToggleProps) { - const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine; - const label = compact ? "Expand all" : "Collapse all"; - - if (variant === "subtle") { - return ( - - - - - - ); - } - - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx deleted file mode 100644 index 3fb53a649..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Group, Stack, Text } from "@mantine/core"; -import type { - LoggingLevel, - LoggingMessageNotification, -} from "@modelcontextprotocol/client"; -import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge"; -import { accessibleTextColor } from "../accessibleTextColor"; - -export interface LogEntryData { - receivedAt: Date; - params: LoggingMessageNotification["params"]; -} - -export interface LogEntryProps { - entry: LogEntryData; - /** - * Compact two-line layout for the narrow monitoring sidebar (#1661): the - * timestamp, level, and logger sit on the first line and the message wraps - * onto the line below, so a long message isn't clipped by the column width. - * The default (false) is the single-line row used on the full Logs screen. - */ - compact?: boolean; -} - -const levelMessageColor: Record = { - debug: "dimmed", - info: "blue", - notice: undefined, - warning: "yellow", - error: "red", - critical: "red", - alert: "red", - emergency: "red", -}; - -function formatTimestamp(date: Date): string { - return date.toLocaleTimeString(); -} - -function formatLogger(logger: string): string { - return `[${logger}]`; -} - -function formatData(data: unknown): string { - if (data === undefined || data === null) return ""; - if (typeof data === "string") return data; - return JSON.stringify(data); -} - -const TimestampText = Text.withProps({ - size: "sm", - ff: "monospace", - c: "dimmed", -}); - -const LoggerText = Text.withProps({ - size: "xs", - ff: "monospace", - c: "dimmed", -}); - -// Single-line message (full Logs screen): sits inline with the meta on one row. -const MessageText = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -// Compact message (monitoring sidebar): wraps over-long lines within the narrow -// column via the `consoleLine` variant instead of overflowing its width. -const CompactMessageText = Text.withProps({ - size: "sm", - ff: "monospace", - variant: "consoleLine", -}); - -// The compact meta row: timestamp + level + logger on one line above the -// message. `wrap: nowrap` keeps them on a single line; the message wraps below. -const MetaRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -// The single-line row (full Logs screen): meta + message on one row. -const LogRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -export function LogEntry({ entry, compact = false }: LogEntryProps) { - const { receivedAt, params } = entry; - const message = formatData(params.data); - const logger = params.logger ? ( - {formatLogger(params.logger)} - ) : null; - - if (compact) { - return ( - - - {formatTimestamp(receivedAt)} - - {logger} - - - {message} - - - ); - } - - return ( - - {formatTimestamp(receivedAt)} - - {logger} - - {message} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx deleted file mode 100644 index 2cbee7f51..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Badge } from "@mantine/core"; -import type { LoggingLevel } from "@modelcontextprotocol/client"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export interface LogLevelBadgeProps { - level: LoggingLevel; -} - -const levelColor: Record = { - debug: "gray", - info: "blue", - notice: "teal", - warning: "yellow", - error: "red", - critical: "red", - alert: "red", - emergency: "red", -}; - -const boldLevels: Set = new Set(["alert", "emergency"]); - -const FilledBadge = Badge.withProps({ - variant: "filled", - autoContrast: true, -}); - -export function LogLevelBadge({ level }: LogLevelBadgeProps) { - const fw = boldLevels.has(level) ? 500 : undefined; - - // `autoContrast` keeps the label legible (WCAG AA) on both the light-mode - // fills and the darker dark-mode `-filled` shades — see AnnotationBadge. - return ( - - {level} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx deleted file mode 100644 index 5e5cd624f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import { filledBadgeColor } from "../filledBadgeColor"; - -export interface McpErrorBadgeProps { - /** JSON-RPC error code, e.g. -32020. */ - code: number; - /** Spec name, e.g. "HeaderMismatch". */ - name: string; - /** Optional explanation shown on hover. */ - description?: string; -} - -// Each modern spec error gets a distinct colour so the four are told apart at a -// glance in a dense Protocol stream (SEP-2243 / SEP-2575). Falls back to red for -// any other code routed here. -const COLOR_BY_CODE: Record = { - [-32020]: "red", // HeaderMismatch - [-32021]: "orange", // MissingRequiredClientCapability - [-32022]: "grape", // UnsupportedProtocolVersion - [-32601]: "yellow", // MethodNotFound (modern 404) -}; - -const SpecErrorBadge = Badge.withProps({ - variant: "filled", - autoContrast: true, - // Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion") - // rather than Mantine's default uppercase, which runs these long - // PascalCase names together and hurts readability. - tt: "none", -}); - -const DescriptionTooltip = Tooltip.withProps({ - multiline: true, - w: 280, - withArrow: true, -}); - -/** - * Distinct badge for one of the modern Streamable HTTP spec error codes shown in - * the Protocol tab. Labels the code and spec name (e.g. "-32020 HeaderMismatch") - * and, when a description is supplied, explains it on hover. - * - * Uses the filled + `autoContrast` treatment (via {@link filledBadgeColor}) like - * the other semantic badges, so the amber fills clear WCAG AA — a light-variant - * tint of these hues does not. - */ -export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) { - const badge = ( - - {code} {name} - - ); - if (!description) return badge; - return {badge}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx deleted file mode 100644 index 1055604e6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageBubble/MessageBubble.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Group, Paper, Stack, Text } from "@mantine/core"; -import type { - ContentBlock, - PromptMessage, - SamplingMessage, -} from "@modelcontextprotocol/client"; -import { ContentViewer } from "../ContentViewer/ContentViewer"; - -export interface MessageBubbleProps { - index: number; - message: SamplingMessage | PromptMessage; -} - -function formatRoleLabel(index: number, role: string): string { - return `[${index}] role: ${role}`; -} - -// PromptMessage/SamplingMessage content unions in the SDK are wider than -// ContentBlock (they admit tool_use, tool_result, etc. for the agent -// messages flowing into prompts). ContentViewer renders only the visual -// subset; everything else is silently dropped here. The bubble's role -// header keeps an empty message from being invisible. -const RENDERABLE_TYPES = new Set([ - "text", - "image", - "audio", - "resource", - "resource_link", -]); - -function isRenderableBlock(block: unknown): block is ContentBlock { - if (typeof block !== "object" || block === null) return false; - const t = (block as { type?: string }).type; - return typeof t === "string" && RENDERABLE_TYPES.has(t); -} - -// Prompt content blocks don't carry a mimeType on the text variant -// (SDK `TextContent` is just `{ type: "text", text }`). Render text as -// markdown by default so prompt prose with code fences, lists, and links -// looks like prose rather than a preformatted dump. Image / audio blocks -// already carry mimeType; ContentViewer routes them itself. -// -// Caveat: this is unconditional — a server that emits a raw shell -// snippet, log line, or string containing `#` / `_` / backticks will -// have it transformed. Most prompts are prose so the trade-off is -// worth it, but this differs from the resource side (where -// ResourcePreviewPanel only promotes to markdown when the server -// supplies `text/markdown` or the URI suffix matches). If the MCP -// spec ever adds a per-block mimeType for prompt messages, switch -// back to opt-in rendering here. -function effectiveMimeForBlock(block: ContentBlock): string | undefined { - if (block.type === "text") return "text/markdown"; - return undefined; -} - -const BubbleContainer = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, -}); - -const RoleLabel = Text.withProps({ - size: "xs", - c: "dimmed", - ff: "monospace", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", -}); - -export function MessageBubble({ index, message }: MessageBubbleProps) { - const content = message.content; - const rawBlocks = Array.isArray(content) ? content : [content]; - const blocks = rawBlocks.filter(isRenderableBlock); - - return ( - - - - {formatRoleLabel(index, message.role)} - - {blocks.map((block, blockIndex) => ( - - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx deleted file mode 100644 index 890dd3324..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Badge } from "@mantine/core"; - -export interface MessageDirectionBadgeProps { - /** - * Direction of travel for the entry: "outgoing" = the inspector sent it to - * the server (client → server); "incoming" = the server sent it to the - * inspector (server → client). - */ - direction: "outgoing" | "incoming"; -} - -const LABEL: Record = { - outgoing: "client → server", - incoming: "server → client", -}; - -const BG: Record = { - outgoing: "var(--inspector-badge-outgoing-bg)", - incoming: "var(--inspector-badge-incoming-bg)", -}; - -const FG: Record = { - outgoing: "var(--inspector-badge-outgoing-fg)", - incoming: "var(--inspector-badge-incoming-fg)", -}; - -/** - * Dual-state badge showing which way a Protocol/Network entry traveled. Outgoing - * (client → server) is green; incoming (server → client) is purple — not yellow, - * which (paired with green) reads as caution/ok status rather than direction. - * Surfaces come from `--inspector-badge-*` tokens: a tinted fill in light mode, - * a deep saturated fill with light text in dark mode. Shared by `ProtocolEntry` - * and `NetworkEntry`. - */ -export function MessageDirectionBadge({ - direction, -}: MessageDirectionBadgeProps) { - return ( - - {LABEL[direction]} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx deleted file mode 100644 index b1ea6a496..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Badge } from "@mantine/core"; - -export interface MethodBadgeProps { - /** Protocol/RPC method name, e.g. "tools/list". */ - method: string; -} - -const MethodChip = Badge.withProps({ - autoContrast: false, - bg: "var(--inspector-badge-method-bg)", - c: "var(--inspector-badge-method-fg)", -}); - -/** - * Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with - * light text, driven by `--inspector-badge-method-*` so it stays legible (not a - * washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`. - */ -export function MethodBadge({ method }: MethodBadgeProps) { - return {method}; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx deleted file mode 100644 index 3336d9f19..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/PinToggle/PinToggle.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { TiPin, TiPinOutline } from "react-icons/ti"; - -export interface PinToggleProps { - /** Whether the owning entry is currently pinned. */ - pinned: boolean; - onToggle: () => void; -} - -const PinActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", -}); - -/** - * Icon toggle for pinning an entry. Unpinned shows an outline pin; pinned shows - * a filled pin. The aria-label stays "Pin"/"Unpin" so it reads the same as the - * text button it replaces. - */ -export function PinToggle({ pinned, onToggle }: PinToggleProps) { - const Icon = pinned ? TiPin : TiPinOutline; - const label = pinned ? "Unpin" : "Pin"; - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx deleted file mode 100644 index db47a9c74..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Group, Progress, Stack, Text } from "@mantine/core"; -import type { ProgressNotification } from "@modelcontextprotocol/client"; - -export interface ProgressDisplayProps { - params: Pick< - ProgressNotification["params"], - "progress" | "total" | "message" - >; - elapsed?: string; -} - -function computePercent(progress: number, total?: number): number { - if (total != null && total > 0) { - return Math.round((progress / total) * 100); - } - return progress; -} - -function formatPercent(percent: number): string { - return `${percent}%`; -} - -const ProgressLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const ElapsedText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -export function ProgressDisplay({ params, elapsed }: ProgressDisplayProps) { - const percent = computePercent(params.progress, params.total); - - return ( - - - {params.message && {params.message}} - {formatPercent(percent)} - - - {elapsed && {elapsed}} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx deleted file mode 100644 index 9d2fdc664..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; -import { MdReplay } from "react-icons/md"; - -export interface ReplayButtonProps { - /** Re-send the owning history request. */ - onReplay: () => void; -} - -const ReplayActionIcon = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "md", - "aria-label": "Replay", -}); - -/** - * Icon form of the "Replay" action, used in the compact (column) ProtocolEntry - * layout where the text button is replaced by a replay icon sitting next to the - * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. - */ -export function ReplayButton({ onReplay }: ReplayButtonProps) { - return ( - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx deleted file mode 100644 index a9cc1e4b6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/ResourceLinkInfo/ResourceLinkInfo.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { ReactNode } from "react"; -import { Badge, Group, Stack, Text } from "@mantine/core"; -import { CopyButton } from "../CopyButton/CopyButton"; - -export interface ResourceLinkInfoProps { - /** The linked resource's URI (always shown, with a copy button). */ - uri: string; - /** Optional human-friendly name shown above the URI. */ - name?: string; - /** Optional MIME type shown as a badge. */ - mimeType?: string; - /** - * Optional trailing element placed at the end of the URI row (opposite the - * copy button) — e.g. an expand/collapse control supplied by an interactive - * wrapper. Mirrors ProtocolEntry, whose toggle sits on the row below the - * badges. - */ - action?: ReactNode; -} - -const HeaderStack = Stack.withProps({ - gap: 4, -}); - -// Name (left) + MIME badge (right). `justify` is set per-instance: spread when -// a name is present, otherwise the badge hugs the right. -const HeaderRow = Group.withProps({ - wrap: "nowrap", - gap: "xs", - align: "center", -}); - -// Copy control + URI (left) and the optional expand/collapse control (right), -// on the line below the header — mirroring ProtocolEntry's controls row. -const UriRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "xs", - align: "center", -}); - -// Copy button + URI cluster; flexes so the URI fills and the action stays right. -const UriCluster = Group.withProps({ - wrap: "nowrap", - gap: "xs", - align: "center", - flex: 1, - miw: 0, -}); - -// Match how ProtocolEntry/NetworkEntry render a URL: `sm` / `fw: 500`, in the -// default sans-serif face and text color (not a blue monospace "link"). The -// `monoBreak` variant only adds `word-break: break-all` so a long URI wraps -// within the card instead of overflowing. -const UriText = Text.withProps({ - size: "sm", - fw: 500, - variant: "monoBreak", - flex: 1, - miw: 0, -}); - -const MimeBadge = Badge.withProps({ - // Match the point size of the ProtocolEntry method/status badges; the - // lowercase MIME text reads smaller than their uppercase labels at `sm`. - size: "md", - radius: "sm", - // MIME types are conventionally lowercase; keep them as-is rather than - // letting Badge's default uppercase transform mangle them. - tt: "none", - autoContrast: false, - // Light mode: the tinted blue-light chip (unchanged). Dark mode: a solid - // dark-blue fill with white text — matching the solid ProtocolEntry badges - // rather than a washed-out translucent tint. - bg: "light-dark(var(--mantine-color-blue-light), var(--mantine-color-blue-9))", - c: "light-dark(var(--mantine-color-blue-light-color), var(--mantine-color-white))", -}); - -const NameText = Text.withProps({ - size: "sm", - fw: 600, - flex: 1, - miw: 0, -}); - -/** - * Pure-display metadata for a `resource_link`: an optional name and MIME-type - * badge on the header row, then the URI on the line below with a copy button. - * The URI is styled like ProtocolEntry/NetworkEntry URLs (`sm` / `fw: 500`, - * default sans-serif face and color), not a blue monospace link. The optional - * `action` slot lets an interactive wrapper (e.g. {@link ResourceLink}) place - * an expand/collapse control at the end of the URI row. - */ -export function ResourceLinkInfo({ - uri, - name, - mimeType, - action, -}: ResourceLinkInfoProps) { - const hasHeader = Boolean(name || mimeType); - return ( - - {hasHeader && ( - - {name && {name}} - {mimeType && {mimeType}} - - )} - - - - {uri} - - {action} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx deleted file mode 100644 index de77d345f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Select } from "@mantine/core"; -import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb"; - -export type SortDirection = "oldest-first" | "newest-first"; - -export interface SortToggleProps { - value: SortDirection; - onChange: (next: SortDirection) => void; - "aria-label"?: string; -} - -const OPTIONS: { value: SortDirection; label: string }[] = [ - { value: "newest-first", label: "Newest First" }, - { value: "oldest-first", label: "Oldest First" }, -]; - -function isSortDirection(value: string | null): value is SortDirection { - return value === "oldest-first" || value === "newest-first"; -} - -const SortSelect = Select.withProps({ - size: "sm", - w: 150, - allowDeselect: false, - withCheckIcon: false, -}); - -export function SortToggle({ - value, - onChange, - "aria-label": ariaLabel = "Sort direction", -}: SortToggleProps) { - const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; - return ( - { - // The guard's false arm is unreachable through the UI: Mantine's `data` - // only holds the two valid SortDirection values and allowDeselect={false} - // prevents a null deselect, so isSortDirection() is always true here. - /* v8 ignore next */ - if (isSortDirection(next)) onChange(next); - }} - rightSection={} - aria-label={ariaLabel} - /> - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx deleted file mode 100644 index cab899b0b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscribeButton/SubscribeButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Button } from "@mantine/core"; - -export interface SubscribeButtonProps { - subscribed: boolean; - onToggle: () => void; -} - -const ToggleButton = Button.withProps({ - variant: "filled", - size: "sm", -}); - -export function SubscribeButton({ - subscribed, - onToggle, -}: SubscribeButtonProps) { - return ( - - {subscribed ? "Unsubscribe" : "Subscribe"} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx deleted file mode 100644 index ec67545be..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/SubscriptionStreamBadge.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; -import { subscriptionStreamPresentation } from "./subscriptionStreamUtils"; - -export interface SubscriptionStreamBadgeProps { - /** Lifecycle status of the modern `subscriptions/listen` stream. */ - status: ResourceSubscriptionStreamStatus; -} - -const StreamTooltip = Tooltip.withProps({ - multiline: true, - w: 260, - withArrow: true, -}); - -/** - * Status indicator for the modern-era resource-subscription listen stream - * (#1630). Only meaningful on the modern era — the caller gates rendering on - * `streamState.active`. Renders a labelled dot badge (green/yellow/gray) in the - * Subscriptions section header, explaining the stream in a tooltip. - */ -export function SubscriptionStreamBadge({ - status, -}: SubscriptionStreamBadgeProps) { - const { color, label, tooltip } = subscriptionStreamPresentation(status); - return ( - - - {label} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts deleted file mode 100644 index c2c525368..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/SubscriptionStreamBadge/subscriptionStreamUtils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js"; - -export interface StreamPresentation { - /** Mantine palette color name conveying the status. */ - color: string; - /** Short label shown on the panel badge. */ - label: string; - /** Full explanation shown in the tooltip (both variants). */ - tooltip: string; -} - -const STREAM_INTRO = - "On modern (2026-07-28) servers, resource subscriptions are a filter over one long-lived subscriptions/listen stream."; - -// Keyed by status so it's exhaustive at compile time: a new -// `ResourceSubscriptionStreamStatus` that isn't handled here is a type error -// (no unreachable `default` needed, so it stays fully covered). -const PRESENTATION: Record< - ResourceSubscriptionStreamStatus, - StreamPresentation -> = { - connecting: { - color: "blue", - label: "Connecting…", - tooltip: `${STREAM_INTRO} Opening the stream — waiting for the server to acknowledge the subscription.`, - }, - acknowledged: { - color: "green", - label: "Listening", - tooltip: `${STREAM_INTRO} The server acknowledged the subscription and the stream is open, carrying resources/updated notifications.`, - }, - reconnecting: { - color: "yellow", - label: "Reconnecting…", - tooltip: `${STREAM_INTRO} The stream dropped unexpectedly; re-listening to re-establish it (there is no resumability, so the full filter is re-sent).`, - }, - ended: { - color: "gray", - label: "Stream ended", - tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`, - }, -}; - -/** - * Maps a modern listen-stream status to its badge color, label, and tooltip - * copy (#1630). Kept in its own module so the badge component file exports only - * a component (react-refresh rule). - */ -export function subscriptionStreamPresentation( - status: ResourceSubscriptionStreamStatus, -): StreamPresentation { - return PRESENTATION[status]; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts deleted file mode 100644 index 79e5da83e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/accessibleTextColor.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Maps a bare Mantine color name to its scheme-aware `*-light-color` variable - * for use as **colored text**. A bare `c="yellow"` resolves to a mid `filled` - * shade that fails WCAG AA on light surfaces (amber/green/red text on white or - * on the `inspector-light` selected-chip tint land at ~3–4:1). The `-light-color` - * variable is scheme-aware — the app darkens it to shade 8/9 in light mode (see - * the `App.css` light-scheme block) and Mantine keeps a lighter shade in dark - * mode — so the same token clears AA against both light and dark backgrounds. - * - * `"dimmed"` is passed through unchanged (already AA-tuned to gray-7 / dark-1 in - * `App.css`), as is `undefined` (inherit the default body text color). - */ -export function accessibleTextColor(color?: string): string | undefined { - if (!color || color === "dimmed") return color; - return `var(--mantine-color-${color}-light-color)`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts deleted file mode 100644 index 75f8a608c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/elements/filledBadgeColor.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Amber fills (`orange` / `yellow`) at Mantine's default filled shade land right - * at `autoContrast`'s luminance threshold, so it picks WHITE text that fails - * WCAG AA on amber (~2–4:1 in both schemes). Pinning those two colors to shade 5 - * keeps the fill bright while moving `autoContrast` decisively onto BLACK text - * (~9–10:1). Every other color is returned unchanged — its default - * filled + `autoContrast` pairing already clears AA. - * - * Used by the filled semantic badges (annotation / task-status / log-level) so - * the amber-contrast fix lives in one place rather than each color map. - */ -export function filledBadgeColor(color: string): string { - if (color === "yellow") return "yellow.5"; - if (color === "orange") return "orange.5"; - return color; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx deleted file mode 100644 index f157cc79d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppControls/AppControls.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { - Group, - ScrollArea, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Tool } from "@modelcontextprotocol/client"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { AppListItem } from "../AppListItem/AppListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface AppControlsProps { - tools: Tool[]; - selectedName?: string; - // Search text is controlled by the parent (App, via AppsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - onSearchChange: (value: string) => void; - onSelectApp: (name: string) => void; -} - -const LIST_MAX_HEIGHT = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -export function AppControls({ - tools, - selectedName, - searchText = "", - listChanged, - onRefreshList, - onSearchChange, - onSelectApp, -}: AppControlsProps) { - const viewportRef = useScrollMemory("apps-sidebar"); - const query = searchText.toLowerCase(); - const filteredTools = searchText - ? tools.filter( - (tool) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : tools; - - return ( - - - MCP Apps ({tools.length}) - - - onSearchChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - {filteredTools.length === 0 ? ( - - {tools.length === 0 ? "No apps available" : "No matching apps"} - - ) : ( - filteredTools.map((tool) => ( - { - if (tool.name !== selectedName) onSelectApp(tool.name); - }} - /> - )) - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx deleted file mode 100644 index 8d5a2135d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core"; -import { MdPlayArrow } from "react-icons/md"; -import type { Tool } from "@modelcontextprotocol/client"; -import { SchemaForm } from "../SchemaForm/SchemaForm"; -import { hasInputFields } from "../../../utils/toolUtils"; -import { - hasMissingRequiredFields, - toFormSchema, -} from "../../../utils/jsonUtils"; - -export interface AppDetailPanelProps { - tool: Tool; - formValues: Record; - isOpening: boolean; - onFormChange: (values: Record) => void; - onOpenApp: () => void; -} - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Fills the available height inside AppsScreen's full-height card and scrolls -// the form (description + fields + Open App) when it would overflow, instead of -// bleeding past the viewport. `mih: 0` lets it shrink within the flex parent; -// standalone (no flex parent) it just sizes to content. -const PanelScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, -}); - -const OpenAppButton = Button.withProps({ - size: "md", - fullWidth: true, - leftSection: , -}); - -export function AppDetailPanel({ - tool, - formValues, - isOpening, - onFormChange, - onOpenApp, -}: AppDetailPanelProps) { - const { description, inputSchema } = tool; - // Narrow the SDK protocol schema to the form renderer's schema type. A Tool's - // `inputSchema` is always an object per the SDK types, so `toFormSchema` never - // returns null here — the `?? {}` is a defensive fallback that can't be hit. - /* v8 ignore next -- unreachable: Tool.inputSchema is always an object */ - const formSchema = toFormSchema(inputSchema) ?? {}; - const hasErrors = hasMissingRequiredFields(formSchema, formValues); - const disabled = isOpening || hasErrors; - const hasFields = hasInputFields(tool); - - return ( - - - {description && {description}} - - {hasFields && } - - {/* Form stays editable while validation fails so users can finish - filling required fields. The disabled-when-incomplete gate is on - the Open App button below, not on the form itself. */} - - - - Open App - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx deleted file mode 100644 index 27b4a5b6b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/AppListItem/AppListItem.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core"; -import { MdChevronRight } from "react-icons/md"; -import type { Tool } from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; - -export interface AppListItemProps { - tool: Tool; - selected: boolean; - onClick: () => void; -} - -const ItemLabel = Text.withProps({ - fw: 500, - truncate: true, -}); - -const ItemDescription = Text.withProps({ - size: "xs", - c: "dimmed", - lineClamp: 2, -}); - -const ItemBody = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -const Row = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const AppIcon = Image.withProps({ - w: 20, - h: 20, - fit: "contain", -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function AppListItem({ tool, selected, onClick }: AppListItemProps) { - const { name, title, description, icons } = tool; - const iconSrc = icons?.[0]?.src; - - return ( - - - {iconSrc && } - - {resolveDisplayLabel(name, title)} - {description && {description}} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx deleted file mode 100644 index 3197bd3a6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogControls/LogControls.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { - Button, - Group, - Select, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; -import { isModernEra } from "../../elements/EraBadge/eraUtils"; - -const LOG_LEVELS: LoggingLevel[] = [ - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency", -]; - -const LEVEL_COLORS: Record = { - debug: { c: "dimmed" }, - info: { c: "blue" }, - notice: { c: "teal" }, - warning: { c: "yellow" }, - error: { c: "red" }, - critical: { c: "red" }, - alert: { c: "red" }, - emergency: { c: "red" }, -}; - -// Sentinel `Select` value for "don't opt in" on the modern per-request control. -// Not a valid `LoggingLevel`, so it can never collide with a real level. -const MODERN_OFF_VALUE = "__off__"; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -const HelpText = Text.withProps({ - size: "xs", - c: "var(--inspector-text-secondary)", -}); - -const ActiveLevelSelect = Select.withProps({ - "aria-label": "Set Active Level", - flex: 1, -}); - -const PerRequestLevelSelect = Select.withProps({ - "aria-label": "Log Level per Request", - allowDeselect: false, -}); - -const LEVEL_OPTIONS = LOG_LEVELS.map((level) => ({ - value: level, - label: level, -})); - -export interface LogControlsProps { - currentLevel: LoggingLevel; - filterText: string; - visibleLevels: Record; - onSetLevel: (level: LoggingLevel) => void; - onFilterChange: (text: string) => void; - onToggleLevel: (level: LoggingLevel, visible: boolean) => void; - onToggleAllLevels: () => void; - /** - * Negotiated protocol era. On the modern era (2026-07-28) `logging/setLevel` - * is gone; the level selector is replaced by the per-request opt-in control - * below. Undefined / legacy keeps the session-scoped `Set` selector (#1629). - */ - protocolEra?: ProtocolEra; - /** - * Modern-era per-request log level currently stamped on every request, or - * `null` when not opted in (no logs). Only meaningful on the modern era. - */ - modernLogLevel?: LoggingLevel | null; - /** Set (or clear, with `null`) the modern per-request log level. */ - onSetModernLogLevel?: (level: LoggingLevel | null) => void; -} - -// Legacy: session-scoped `logging/setLevel` — a level selector plus a "Set" -// button that sends the request. The value is optimistic (there's no echo). -const LegacyLevelControl = ({ - currentLevel, - onSetLevel, -}: Pick) => ( - <> - Set Active Level - - { - if (value && LOG_LEVELS.includes(value as LoggingLevel)) { - onSetLevel(value as LoggingLevel); - } - }} - /> - - - -); - -// Modern: per-request opt-in via the `io.modelcontextprotocol/logLevel` `_meta` -// key. There is no session level and no `Set` — the chosen level is stamped on -// every subsequent request and takes effect immediately. "Off" stops opting in. -const ModernLevelControl = ({ - modernLogLevel, - onSetModernLogLevel, -}: Pick) => ( - <> - Log Level per Request - - Modern servers only emit logs for requests that opt in. The level you - choose is stamped on every request, and logs arrive on the originating - request's stream. Choose Off to stop requesting logs. - - { - if (value === MODERN_OFF_VALUE) { - onSetModernLogLevel?.(null); - } else if (value && LOG_LEVELS.includes(value as LoggingLevel)) { - onSetModernLogLevel?.(value as LoggingLevel); - } - }} - /> - -); - -export function LogControls({ - currentLevel, - filterText, - visibleLevels, - onSetLevel, - onFilterChange, - onToggleLevel, - onToggleAllLevels, - protocolEra, - modernLogLevel = null, - onSetModernLogLevel, -}: LogControlsProps) { - return ( - - Logging - - onFilterChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - filterText ? onFilterChange("")} /> : null - } - /> - - {isModernEra(protocolEra) ? ( - - ) : ( - - )} - - - Filter by Level - - {Object.values(visibleLevels).every(Boolean) - ? "Deselect All" - : "Select All"} - - - - {LOG_LEVELS.map((level) => ( - onToggleLevel(level, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx deleted file mode 100644 index 8302f5d40..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useMemo } from "react"; -import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; -import type { LoggingLevel } from "@modelcontextprotocol/client"; -import { LogEntry } from "../../elements/LogEntry/LogEntry"; -import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface LogStreamPanelProps { - entries: LogEntryData[]; - filterText: string; - visibleLevels: Record; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - /** - * True when this panel is rendered inside the monitoring sidebar. Switches the - * scroll region from the viewport-height calc to filling its flex parent, so - * it fits below the column's controls row without viewport math. - */ - embedded?: boolean; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -function formatData(data: unknown): string { - if (data === undefined || data === null) return ""; - if (typeof data === "string") return data; - return JSON.stringify(data); -} - -function matchesFilters( - entry: LogEntryData, - filterText: string, - visibleLevels: Record, - // The embedded column exposes only the search box (no level toggles), so it - // applies the text filter but skips the level filter (#1616). - ignoreLevels: boolean, -): boolean { - if (!ignoreLevels && !visibleLevels[entry.params.level]) return false; - if (filterText) { - const term = filterText.toLowerCase(); - const searchable = - `${formatData(entry.params.data)} ${entry.params.logger ?? ""} ${entry.params.level}`.toLowerCase(); - if (!searchable.includes(term)) return false; - } - return true; -} - -export function LogStreamPanel({ - entries, - filterText, - visibleLevels, - onClear, - onExport, - sortDirection, - onSortChange, - embedded = false, -}: LogStreamPanelProps) { - const viewportRef = useScrollMemory("logs-stream"); - const filteredEntries = useMemo(() => { - // The embedded column has only the search box (its level toggles live in the - // full-size sidebar), so it filters by text but ignores the level filter - // (#1616). `.filter()` returns a fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleLevels, embedded)) - .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [entries, filterText, visibleLevels, sortDirection, embedded]); - - return ( - - - Log Stream - - - - - - - {filteredEntries.length > 0 ? ( - - - {filteredEntries.map((entry, index) => ( - // Compact (two-line) layout inside the narrow monitoring sidebar; - // the full single-line row on the standalone Logs screen. (#1661) - - ))} - - - ) : ( - - No log entries - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx deleted file mode 100644 index 13ed689c4..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Button, Group, Stack, Title } from "@mantine/core"; -import type { MessageOrigin } from "@inspector/core/mcp/types.js"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -// h5 (not h6) to sit one level below the screen's h4 heading (avoids an -// axe `heading-order` skip); `size="h6"` preserves the visual size. -const SectionTitle = Title.withProps({ - order: 5, - size: "h6", -}); - -// The two message directions, in display order. Label + color mirror the -// MessageDirectionBadge: outgoing (client → server) is green, incoming -// (server → client) is violet. -const MESSAGE_DIRECTIONS: { - origin: MessageOrigin; - label: string; - color: string; -}[] = [ - { origin: "client", label: "client → server", color: "green" }, - { origin: "server", label: "server → client", color: "violet" }, -]; - -export interface MessageDirectionFilterProps { - visibleDirections: Record; - onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; - onToggleAllDirections: () => void; -} - -/** - * "Filter by Message Direction" section — a Select/Deselect All control plus a - * FilterToggleButton per direction (client → server / server → client). Used by - * the Protocol controls. (Kept as its own component so the section is testable in - * isolation and reusable if another screen ever needs a direction filter.) - */ -export function MessageDirectionFilter({ - visibleDirections, - onToggleDirection, - onToggleAllDirections, -}: MessageDirectionFilterProps) { - return ( - <> - - Filter by Message Direction - - {Object.values(visibleDirections).every(Boolean) - ? "Deselect All" - : "Select All"} - - - - {MESSAGE_DIRECTIONS.map(({ origin, label, color }) => ( - onToggleDirection(origin, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx deleted file mode 100644 index 0ad178043..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useState } from "react"; -import { - Badge, - Collapse, - Divider, - Group, - Paper, - Stack, - Text, -} from "@mantine/core"; -import type { MessageEntry } from "@inspector/core/mcp/types.js"; -import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { extractMethod, extractResultType } from "../protocolUtils.js"; -import { useValueChange } from "../../../hooks/useValueChange"; - -export interface MrtrConversationProps { - /** The opaque MRTR token that links this conversation's rounds. */ - requestState: string; - /** The entries belonging to this conversation (one per JSON-RPC id). */ - rounds: MessageEntry[]; - /** Which of this conversation's rounds are pinned, by entry id. */ - pinnedIds: Set; - /** Whether rounds start expanded (mirrors the list-level compact toggle). */ - isListExpanded: boolean; - /** Compact per-round layout for the narrow monitoring column. */ - embedded?: boolean; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; -} - -// MRTR (multi-round-trip request, spec §7.3) makes one logical operation span -// several JSON-RPC ids: the original call returns `input_required`, the client -// answers and retries with a NEW id echoing `requestState`, repeating until a -// final `complete` result. This groups those rounds into one expandable unit so -// the operation reads as a single conversation instead of scattered calls. - -const ConversationContainer = Paper.withProps({ - withBorder: true, - p: "md", - radius: "md", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const HeaderLeft = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const HeaderRight = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -const MrtrLabel = Text.withProps({ - size: "sm", - fw: 600, - c: "dimmed", -}); - -const RoundLabel = Text.withProps({ - size: "xs", - fw: 600, - c: "dimmed", -}); - -const RoundCountBadge = Badge.withProps({ - color: "blue", - variant: "outline", -}); - -type ConversationStatus = "pending" | "awaiting" | "error" | "complete"; - -// The conversation's status is that of its final (latest) round: still awaiting -// input if the last result is `input_required`, otherwise the ordinary -// pending/error/complete lifecycle of that round. -function conversationStatus(finalRound: MessageEntry): ConversationStatus { - if (!finalRound.response) return "pending"; - if ("error" in finalRound.response) return "error"; - if (extractResultType(finalRound) === "input_required") return "awaiting"; - return "complete"; -} - -function statusColor(status: ConversationStatus): string { - switch (status) { - case "complete": - return "green"; - case "error": - return "red"; - case "awaiting": - return "yellow"; - default: - return "gray"; - } -} - -function statusLabel(status: ConversationStatus): string { - switch (status) { - case "complete": - return "Complete"; - case "error": - return "Error"; - case "awaiting": - return "Awaiting input"; - default: - return "Pending"; - } -} - -function formatRoundsLabel(count: number): string { - return count === 1 ? "1 round" : `${count} rounds`; -} - -function formatRoundLabel(index: number): string { - return `Round ${index + 1}`; -} - -export function MrtrConversation({ - requestState, - rounds, - pinnedIds, - isListExpanded, - embedded = false, - onReplay, - onTogglePin, -}: MrtrConversationProps) { - const [isExpanded, setIsExpanded] = useState(isListExpanded); - - // The list-level Expand/Collapse toggle is authoritative: any per-entry - // override is discarded whenever the parent changes `isListExpanded`. - useValueChange(isListExpanded, setIsExpanded); - - // Always read the conversation chronologically (original → retries → final), - // regardless of the list's newest-first/oldest-first sort. - const ordered = [...rounds].sort( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), - ); - const method = extractMethod(ordered[0]); - const status = conversationStatus(ordered[ordered.length - 1]); - - return ( - - - - - - MRTR - - {formatRoundsLabel(ordered.length)} - - - - - {statusLabel(status)} - - setIsExpanded((v) => !v)} - /> - - - - - - - {ordered.map((round, index) => ( - - {formatRoundLabel(index)} - onReplay(round.id)} - onTogglePin={() => onTogglePin(round.id)} - /> - - ))} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx deleted file mode 100644 index 7bd2be2af..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Group, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; -import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton"; - -const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"]; - -const CATEGORY_COLORS: Record = { - auth: "violet", - transport: "blue", -}; - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -export interface NetworkControlsProps { - filterText: string; - visibleCategories: Record; - onFilterChange: (text: string) => void; - onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void; - onToggleAllCategories: () => void; -} - -export function NetworkControls({ - filterText, - visibleCategories, - onFilterChange, - onToggleCategory, - onToggleAllCategories, -}: NetworkControlsProps) { - const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]); - return ( - - Network - - onFilterChange(e.currentTarget.value)} - rightSectionPointerEvents="auto" - rightSection={ - filterText ? onFilterChange("")} /> : null - } - /> - - - Filter by Category - - {allSelected ? "Deselect All" : "Select All"} - - - - {NETWORK_CATEGORIES.map((category) => ( - onToggleCategory(category, visible)} - /> - ))} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx deleted file mode 100644 index 7ef790bc5..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ /dev/null @@ -1,628 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - Collapse, - Divider, - Group, - ScrollArea, - Stack, - Table, - Text, - Tooltip, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; -import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; -import { maskSecretsInBody } from "../../../utils/maskSecrets"; -import { useValueChange } from "../../../hooks/useValueChange"; -import { - oauthNetworkPhase, - oauthNetworkPhaseLabel, -} from "../../../utils/oauthNetworkPhase"; -import { - checkHeaderConsistency, - decodeMcpParamValue, - isCancellationAbort, - isMcpHeader, - type HeaderConsistency, -} from "../../../utils/mcpNetworkHeaders"; - -export interface NetworkEntryProps { - entry: FetchRequestEntry; - isListExpanded: boolean; - /** - * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is - * time + method + category + duration + status; line 2 is the URL in a - * horizontal scroll area with the expand toggle on the right. - */ - embedded?: boolean; - /** - * When true, this entry was targeted by a "reveal in Network" jump (from a - * correlated Protocol error): it scrolls itself into view and force-expands - * once, then calls {@link onRevealComplete} so the one-shot signal clears. - */ - revealed?: boolean; - onRevealComplete?: () => void; -} - -const EntryContainer = Card.withProps({ - withBorder: true, - padding: "md", - variant: "inset", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const TimestampText = Text.withProps({ - size: "sm", - c: "dimmed", - ff: "monospace", -}); - -const UrlText = Text.withProps({ - size: "sm", - fw: 500, - truncate: "end", -}); - -// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its -// ScrollArea instead of wrapping to many lines. -const UrlScroll = Text.withProps({ - size: "sm", - fw: 500, - variant: "nowrap", -}); - -// Left / right clusters for a compact header line (mirrors ProtocolEntry). -const HeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const ControlsCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -const DurationText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Muted note for empty/placeholder states (no headers, empty body, uncaptured -// stream) and the secrets-hidden status line. -const DimmedNote = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -// Heading above each expanded-detail section (Request/Response Headers/Body). -const SectionLabel = Text.withProps({ - size: "sm", - fw: 500, -}); - -// Cap is in JS string `.length` units (UTF-16 code units), not bytes — for -// multi-byte content the wire size is larger, but the limit's purpose is -// to keep the DOM from drowning in a single Code block so character count -// is the right unit. -const MAX_INLINE_BODY_CHARS = 100_000; - -function formatDuration(ms: number): string { - return `${ms}ms`; -} - -function formatTimestamp(date: Date): string { - return date.toISOString(); -} - -// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO -// string would eat most of the narrow line-1 width (#1616). -function formatTimestampCompact(date: Date): string { - return date.toISOString().slice(11, 19); -} - -function statusColor(entry: FetchRequestEntry): string { - // A cancelled request surfaces as a connection abort under the modern - // transport; render it neutrally rather than as a hard error (SEP-2575). - if (isCancellationAbort(entry)) return "gray"; - if (entry.error) return "red"; - const status = entry.responseStatus; - if (status === undefined) return "gray"; - if (status >= 500) return "red"; - if (status >= 400) return "orange"; - if (status >= 300) return "yellow"; - if (status >= 200) return "green"; - return "gray"; -} - -function statusLabel(entry: FetchRequestEntry): string { - if (isCancellationAbort(entry)) return "Cancelled"; - if (entry.error) return "Error"; - if (entry.responseStatus === undefined) return "Pending"; - return entry.responseStatusText - ? `${entry.responseStatus} ${entry.responseStatusText}` - : `${entry.responseStatus}`; -} - -function isLongLivedStream(entry: FetchRequestEntry): boolean { - return isLongLivedStreamResponse( - entry.method, - entry.responseHeaders?.["content-type"], - ); -} - -// Header-table cell text. A modern MCP-mirrored header name gets a violet accent -// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version) -// stand out from ordinary ones; a value that disagrees with the request body is -// shown in the danger colour. -const HeaderNameText = Text.withProps({ - size: "xs", - ff: "monospace", - fw: 500, -}); - -const McpHeaderNameText = Text.withProps({ - size: "xs", - ff: "monospace", - fw: 600, - c: "var(--inspector-mcp-header-accent)", -}); - -const HeaderValueText = Text.withProps({ - size: "xs", - ff: "monospace", - variant: "monoBreak", -}); - -const MismatchValueText = Text.withProps({ - size: "xs", - ff: "monospace", - variant: "monoBreak", - c: "var(--inspector-danger-text)", -}); - -const MismatchMarker = Text.withProps({ - component: "span", - // role="img" makes the aria-label permitted on the span (it wraps a decorative - // icon) and announces the mismatch to assistive tech. - role: "img", - c: "var(--inspector-danger-text)", -}); - -// The decoded value plus its optional base64 / mismatch markers, on one line. -const ValueCellRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -// Tooltip for a base64 sentinel value or a header/body mismatch. -const SentinelTooltip = Tooltip.withProps({ - withArrow: true, - multiline: true, - w: 280, -}); - -const Base64Badge = Badge.withProps({ - size: "xs", - color: "gray", - variant: "light", -}); - -const HeadersGrid = Table.withProps({ - striped: true, - withColumnBorders: true, - fz: "xs", -}); - -// OAuth flow-phase chip for an `auth`-category request. -const PhaseBadge = Badge.withProps({ - color: "violet", - variant: "light", -}); - -// Compact-header URL row: copy button, horizontal URL scroll, expand toggle. -const CompactUrlRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - justify: "space-between", -}); - -const UrlScrollArea = ScrollArea.withProps({ - scrollbarSize: 6, - flex: 1, - miw: 0, - // The URL scrolls horizontally but has no focusable child, so make the - // viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides - // via the `type="scroll"` theme default. - viewportProps: { tabIndex: 0 }, -}); - -// Wide-header left cluster: timestamp + badges + URL, shrinking to truncate. -const WideHeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, - flex: 1, -}); - -// Trailing expand-toggle row in the wide layout. -const ToggleRow = Group.withProps({ - gap: "xs", - justify: "flex-end", -}); - -const MonoSpan = Text.withProps({ - span: true, - ff: "monospace", -}); - -const ErrorText = Text.withProps({ - size: "xs", - ff: "monospace", - c: "red", -}); - -function HeaderValueCell({ - name, - value, - consistency, -}: { - name: string; - value: string; - consistency?: HeaderConsistency; -}) { - // Only modern MCP headers carry sentinel-encoded values; a plain header is - // shown verbatim (never re-interpreted as base64). - const decoded = isMcpHeader(name) - ? decodeMcpParamValue(value) - : { value, encoded: false, raw: value }; - const mismatch = consistency !== undefined && !consistency.ok; - - return ( - - {mismatch ? ( - {decoded.value} - ) : ( - {decoded.value} - )} - {decoded.encoded && ( - - base64 - - )} - {mismatch && ( - - - - - - )} - - ); -} - -function HeadersTable({ - headers, - consistency, -}: { - headers: Record; - /** Header/body cross-checks (request side only) to flag mismatches. */ - consistency?: HeaderConsistency[]; -}) { - const rows = Object.entries(headers); - if (rows.length === 0) { - return (none); - } - const byHeader = new Map((consistency ?? []).map((row) => [row.header, row])); - return ( - - - {rows.map(([name, value]) => ( - - - {isMcpHeader(name) ? ( - {name} - ) : ( - {name} - )} - - - - - - ))} - - - ); -} - -const CancellationAlert = Alert.withProps({ - variant: "light", - color: "gray", - title: "Request cancelled", - icon: , -}); - -const RevealButton = Button.withProps({ - variant: "subtle", - size: "compact-xs", -}); - -function BodyPreview({ - body, - contentType, -}: { - body: string; - contentType?: string; -}) { - // Reveal state for masked secrets. Hooks run before any early return so the - // order stays stable across the too-large / has-secrets branches. The reveal - // state resets when the body or its content-type changes because callers key - // `` by both (remounting on swap), so a previously-revealed view - // never persists across a content (or masking) change. - const [revealed, setRevealed] = useState(false); - - const tooLarge = body.length > MAX_INLINE_BODY_CHARS; - - // OAuth responses (token exchange, DCR) and the token request carry - // bearer-grade secrets. Mask them by default and gate the raw values behind - // an explicit reveal so they aren't exposed at a glance during a - // screen-share. The entry's content-type scopes which parser runs (so a - // plaintext/HTML error body is never guessed at). Bodies without secrets - // render as-is with no toggle. - // - // Memoized so a Reveal/Hide click (a re-render) doesn't re-parse and re-walk - // the body; the cost is paid once per mount, and the `key={…}` remount on - // body/content-type change re-runs it. Skipped for too-large bodies so we - // never parse something we won't display (the hook must run unconditionally, - // hence the in-memo guard rather than an early return above it). - const { masked, hasSecrets } = useMemo( - () => - tooLarge - ? { masked: body, hasSecrets: false } - : maskSecretsInBody(body, contentType), - [tooLarge, body, contentType], - ); - - if (tooLarge) { - return ( - - Body too large to preview ({body.length} characters) - - ); - } - - if (!hasSecrets) { - return ; - } - - const shown = revealed ? body : masked; - return ( - - - - {revealed ? "Secrets revealed" : "Secrets hidden"} - - setRevealed((v) => !v)} - aria-label={ - revealed ? "Hide secrets in body" : "Reveal secrets in body" - } - > - {revealed ? "Hide" : "Reveal"} - - - - - ); -} - -export function NetworkEntry({ - entry, - isListExpanded, - embedded = false, - revealed = false, - onRevealComplete, -}: NetworkEntryProps) { - // Seeded from both sources so an entry that mounts already targeted by - // "Reveal in Network" starts open — the render-time syncs below only fire on - // a *change*, so neither of them covers the first render. - const [isExpanded, setIsExpanded] = useState(isListExpanded || revealed); - const rootRef = useRef(null); - - // The list-level Expand/Collapse toggle is authoritative: each time the - // parent changes `isListExpanded`, every entry snaps to that state and - // any per-entry override is intentionally discarded. Mirrors - // ProtocolEntry; do not change without aligning both. - useValueChange(isListExpanded, setIsExpanded); - - // "Reveal in Network" one-shot, part 1: force the targeted entry open. This - // is deliberately ordered *after* the list sync above, so that if both change - // in the same render the reveal wins. - useValueChange(revealed, (nextRevealed) => { - if (nextRevealed) setIsExpanded(true); - }); - - // "Reveal in Network" one-shot, part 2: scroll the entry into view, then - // clear the signal. The scroll runs in a rAF so it lands after - // `useScrollMemory`'s layout-effect restore (which would otherwise fight it) - // and after the force-expand above has grown the row. `onRevealComplete` - // clears the parent's `revealId`, which flips `revealed` back to false and re- - // runs this effect's cleanup — so it must fire *inside* the rAF, after the - // scroll, otherwise the cleanup's `cancelAnimationFrame` would race and could - // cancel the very frame doing the scroll. - useEffect(() => { - if (!revealed) return; - const raf = requestAnimationFrame(() => { - rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - onRevealComplete?.(); - }); - return () => cancelAnimationFrame(raf); - }, [revealed, onRevealComplete]); - - // OAuth flow phase for `auth`-category requests (discovery / registration / - // authorize / token), so the Network tab labels the auth conversation. - const oauthPhase = - entry.category === "auth" ? oauthNetworkPhase(entry.url) : undefined; - const phaseBadge = oauthPhase ? ( - {oauthNetworkPhaseLabel(oauthPhase)} - ) : null; - - // Request header/body cross-checks so a mirrored-header mismatch is visible - // before the server rejects it. (Protocol errors like -32020 are surfaced - // distinctly in the Protocol tab, not here — the Network tab stays focused on - // the HTTP transaction.) - const headerConsistency = useMemo( - () => checkHeaderConsistency(entry), - [entry], - ); - const aborted = isCancellationAbort(entry); - - const metaBadges = ( - <> - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {isLongLivedStream(entry) && SSE} - - {statusLabel(entry)} - - - ); - const expandToggle = ( - setIsExpanded((v) => !v)} - /> - ); - - return ( - - - {embedded ? ( - // Compact two-line header for the narrow column. - - - - - {formatTimestampCompact(entry.timestamp)} - - - - {phaseBadge} - - {metaBadges} - - - - - {entry.url} - - {expandToggle} - - - ) : ( - <> - - - - {formatTimestamp(entry.timestamp)} - - - - {phaseBadge} - - {entry.url} - - {metaBadges} - - - {expandToggle} - - )} - - - - - {aborted && ( - - - Cancellation appears as a connection abort — the modern - transport aborts the request stream instead of sending a{" "} - notifications/cancelled frame (SEP-2575). - - - )} - - Request Headers - - - {entry.requestBody && ( - - Request Body - - - )} - {entry.responseHeaders && ( - - Response Headers - - - )} - {entry.responseStatus !== undefined && ( - - Response Body - {entry.responseBody ? ( - - ) : ( - - {isLongLivedStream(entry) - ? "Long-lived stream — body not captured" - : "(empty)"} - - )} - - )} - {entry.error && ( - - Error - {entry.error} - - )} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx deleted file mode 100644 index 4cb9b9b8f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useMemo } from "react"; -import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; -import type { - FetchRequestCategory, - FetchRequestEntry, -} from "@inspector/core/mcp/types.js"; -import { NetworkEntry } from "../NetworkEntry/NetworkEntry"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface NetworkStreamPanelProps { - entries: FetchRequestEntry[]; - filterText: string; - visibleCategories: Record; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ - embedded?: boolean; - /** Fetch-entry id targeted by a "reveal in Network" jump — that entry scrolls - * into view and force-expands, then clears the signal via onRevealComplete. */ - revealId?: string; - onRevealComplete?: () => void; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -// Centered in the full-height panel so the empty message sits mid-panel rather -// than clinging to the top of an otherwise-empty box (matches LogStreamPanel). -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", -}); - -// Panel header: title on the left, action controls on the right. -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -function formatTitle(count: number): string { - return `Requests (${count})`; -} - -function headersToString(headers: Record | undefined): string { - if (!headers) return ""; - return Object.entries(headers) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"); -} - -function matchesFilters( - entry: FetchRequestEntry, - filterText: string, - visibleCategories: Record, - // The embedded column exposes only the search box (no category toggles), so it - // applies the text filter but skips the category filter (#1616). - ignoreCategories: boolean, -): boolean { - if (!ignoreCategories && !visibleCategories[entry.category]) return false; - if (filterText) { - const term = filterText.toLowerCase(); - const status = - entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; - // Per-field match (rather than join + includes) so the search term - // can't span field boundaries — a search for "foo bar" where one - // field ends "foo" and the next begins "bar" should not match. - const fields: string[] = [ - entry.method, - entry.url, - status, - entry.responseStatusText ?? "", - headersToString(entry.requestHeaders), - headersToString(entry.responseHeaders), - entry.requestBody ?? "", - entry.responseBody ?? "", - entry.error ?? "", - ]; - if (!fields.some((f) => f.toLowerCase().includes(term))) return false; - } - return true; -} - -export function NetworkStreamPanel({ - entries, - filterText, - visibleCategories, - onClear, - onExport, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - revealId, - onRevealComplete, -}: NetworkStreamPanelProps) { - const viewportRef = useScrollMemory("network-stream"); - const filteredEntries = useMemo(() => { - // Embedded column filters by text only (its category toggles live in the - // full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a - // fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleCategories, embedded)) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [entries, filterText, visibleCategories, sortDirection, embedded]); - - const hasEntries = entries.length > 0; - const hasResults = filteredEntries.length > 0; - - return ( - - - {formatTitle(filteredEntries.length)} - - - - - {hasResults && ( - - )} - - - - {!hasResults ? ( - - No network requests - - ) : ( - - - {filteredEntries.map((entry) => ( - - ))} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx deleted file mode 100644 index 856eb4886..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Autocomplete, - Button, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { Prompt } from "@modelcontextprotocol/client"; - -export interface PromptArgumentsFormProps { - prompt: Prompt; - argumentValues: Record; - onArgumentChange: (name: string, value: string) => void; - onGetPrompt: () => void; - /** - * When provided, each keystroke in an argument input dispatches a - * (debounced) `completion/complete` request to the server and surfaces - * the returned values as a dropdown via Mantine `Autocomplete`. - * Wire to `InspectorClient.getCompletions` in the host App. - */ - onCompleteArgument?: ( - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - /** - * Gates whether to render Autocomplete (with live completions) vs the - * plain TextInput. Typically derived from the server's - * `completions` capability. - */ - completionsSupported?: boolean; -} - -const COMPLETION_DEBOUNCE_MS = 300; - -const PromptTitle = Text.withProps({ - fw: 700, - size: "lg", - truncate: "end", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -function formatPlaceholder(name: string): string { - return `Enter ${name}...`; -} - -export function PromptArgumentsForm({ - prompt, - argumentValues, - onArgumentChange, - onGetPrompt, - onCompleteArgument, - completionsSupported = false, -}: PromptArgumentsFormProps) { - const { name, title, description, arguments: promptArguments } = prompt; - - const [completions, setCompletions] = useState>({}); - - // Reset completion state whenever the active prompt changes — completions - // are keyed by argument name, and the same name could mean different - // things across prompts. - useValueChange(name, () => { - setCompletions({}); - }); - - // Per-arg in-flight controller (later keystroke aborts older request). - const requestsRef = useRef>(new Map()); - // Per-arg debounce timer so we don't spam the server on every key. - const timersRef = useRef>>( - new Map(), - ); - - useEffect(() => { - const timers = timersRef.current; - const requests = requestsRef.current; - return () => { - for (const t of timers.values()) clearTimeout(t); - timers.clear(); - for (const c of requests.values()) c.abort(); - requests.clear(); - }; - }, []); - - const useAutocomplete = completionsSupported && !!onCompleteArgument; - - const runCompletion = useCallback( - async (argName: string, value: string, context: Record) => { - if (!onCompleteArgument) return; - requestsRef.current.get(argName)?.abort(); - const controller = new AbortController(); - requestsRef.current.set(argName, controller); - try { - const values = await onCompleteArgument(argName, value, context); - if (controller.signal.aborted) return; - setCompletions((prev) => ({ ...prev, [argName]: values })); - } catch { - if (!controller.signal.aborted) { - setCompletions((prev) => ({ ...prev, [argName]: [] })); - } - } finally { - if (requestsRef.current.get(argName) === controller) { - requestsRef.current.delete(argName); - } - } - }, - [onCompleteArgument], - ); - - // Hold the latest argumentValues in a ref so debounced fires can read - // sibling values at *fire* time, not at schedule time. Without this, - // typing in arg A then arg B within the debounce window would ship - // A's request with B's value stuck at its pre-keystroke state. - const argumentValuesRef = useRef(argumentValues); - useEffect(() => { - argumentValuesRef.current = argumentValues; - }, [argumentValues]); - - // Build the `context.arguments` payload for a completion request. - // Includes every prompt argument the user could fill in (with `""` - // for ones they haven't typed yet) except the one being completed — - // the completing arg goes in `params.argument`. Servers that - // disambiguate based on co-arguments need all of them, not just - // whatever the user has already typed. - const buildContext = useCallback( - (currentArg: string): Record => { - const ctx: Record = {}; - for (const a of promptArguments ?? []) { - if (a.name === currentArg) continue; - ctx[a.name] = argumentValuesRef.current[a.name] ?? ""; - } - return ctx; - }, - [promptArguments], - ); - - function handleChange(argName: string, value: string) { - onArgumentChange(argName, value); - if (!useAutocomplete) return; - // Drop the previous prefix's completions so the dropdown doesn't - // show ghost suggestions from the old keystroke while the new - // request is in flight (300ms debounce + network latency). The - // fresh response repopulates the array when it arrives. - setCompletions((prev) => { - if (prev[argName] === undefined) return prev; - const next = { ...prev }; - delete next[argName]; - return next; - }); - const existing = timersRef.current.get(argName); - if (existing) clearTimeout(existing); - const timer = setTimeout(() => { - timersRef.current.delete(argName); - // Build context at fire time so sibling values that arrived - // between schedule and fire are picked up. - void runCompletion(argName, value, buildContext(argName)); - }, COMPLETION_DEBOUNCE_MS); - timersRef.current.set(argName, timer); - } - - function handleFocus(argName: string) { - if (!useAutocomplete) return; - // Fire immediately so the dropdown isn't empty when the user first - // clicks in. Cancel any pending debounce so a stale keystroke - // request doesn't overwrite this fresher one. - const existing = timersRef.current.get(argName); - if (existing) { - clearTimeout(existing); - timersRef.current.delete(argName); - } - const value = argumentValuesRef.current[argName] ?? ""; - void runCompletion(argName, value, buildContext(argName)); - } - - // Mirror ResourceTemplatePanel: every required argument must be - // filled before Get Prompt is enabled. Optional args are allowed to - // stay empty; the server will treat them as absent. - const canSubmit = (promptArguments ?? []) - .filter((a) => a.required === true) - .every((a) => (argumentValues[a.name] ?? "").length > 0); - - return ( - - {title ?? name} - {description && {description}} - {promptArguments && promptArguments.length > 0 && ( - <> - Arguments - - {promptArguments.map((arg) => - useAutocomplete ? ( - options} - onChange={(value) => handleChange(arg.name, value)} - onFocus={() => handleFocus(arg.name)} - /> - ) : ( - - handleChange(arg.name, event.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - argumentValues[arg.name] ? ( - handleChange(arg.name, "")} /> - ) : null - } - /> - ), - )} - - - )} - {/* Left-aligned so the action sits closest to the sidebar controls / the - form fields above — shortest pointer travel. */} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx deleted file mode 100644 index 2a2d5364c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Prompt } from "@modelcontextprotocol/client"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { PromptListItem } from "../PromptListItem/PromptListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -// Fill the full-height `sidebar` Card (a flex column) so the list runs to the -// bottom of the card before it scrolls, instead of being capped short by a -// fixed max-height. `mih: 0` lets the scroll child shrink and scroll. -const SidebarStack = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -const SearchInput = TextInput.withProps({ - placeholder: "Search prompts...", - rightSectionPointerEvents: "auto", -}); - -const ListScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, -}); - -export interface PromptControlsProps { - prompts: Prompt[]; - selectedName?: string; - // Search text is controlled by the parent (App, via PromptsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onSelectPrompt: (name: string) => void; -} - -export function PromptControls({ - prompts, - selectedName, - searchText = "", - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onSelectPrompt, -}: PromptControlsProps) { - const viewportRef = useScrollMemory("prompts-sidebar"); - const query = searchText.toLowerCase(); - const filteredPrompts = prompts.filter( - (p) => - p.name.toLowerCase().includes(query) || - (p.title?.toLowerCase().includes(query) ?? false) || - (p.description?.toLowerCase().includes(query) ?? false), - ); - - return ( - - - Prompts - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - - - {filteredPrompts.map((prompt) => ( - { - if (prompt.name !== selectedName) onSelectPrompt(prompt.name); - }} - /> - ))} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx deleted file mode 100644 index f9d2af890..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptListItem/PromptListItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Stack, Text, UnstyledButton } from "@mantine/core"; -import type { Prompt } from "@modelcontextprotocol/client"; - -export interface PromptListItemProps { - prompt: Prompt; - selected: boolean; - onClick: () => void; -} - -const NameText = Text.withProps({ - fw: 500, -}); - -const DescriptionText = Text.withProps({ - size: "xs", - c: "dimmed", - lineClamp: 1, -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function PromptListItem({ - prompt, - selected, - onClick, -}: PromptListItemProps) { - const { name, title, description } = prompt; - return ( - - - {title ?? name} - {description && {description}} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx deleted file mode 100644 index 3947d6d8c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - Button, - CloseButton, - Group, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import type { PromptMessage } from "@modelcontextprotocol/client"; -import { MessageBubble } from "../../elements/MessageBubble/MessageBubble"; - -export interface PromptMessagesDisplayProps { - messages: PromptMessage[]; - onCopyAll?: () => void; - /** - * When provided, a top-left X button dismisses the panel. The host - * (`PromptsScreen`) decides what to show in its place — typically - * the prompt's argument form (if it has arguments) or the empty state. - */ - onClose?: () => void; -} - -const CopyAllButton = Button.withProps({ - variant: "subtle", - size: "sm", -}); - -// Outer stack inside the PreviewCard: header stays pinned, the scroll -// region absorbs overflow. Mirrors ResourcePreviewPanel so prompts and -// resources share the same sized-to-content / cap-then-scroll behavior. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - flex: "0 0 auto", -}); - -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -// `0 1 auto` lets the scroll region shrink (but not grow) when the card -// hits its mah. `mih: 0` is required for flex children to shrink below -// their content's intrinsic height. -const MessagesScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const MessagesStack = Stack.withProps({ - gap: "md", -}); - -export function PromptMessagesDisplay({ - messages, - onCopyAll, - onClose, -}: PromptMessagesDisplayProps) { - return ( - - - - {onClose && ( - - )} - Messages - - {onCopyAll && messages.length > 0 && ( - Copy All - )} - - - - {messages.length === 0 ? ( - No messages to display - ) : ( - messages.map((message, index) => ( - - )) - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx deleted file mode 100644 index e545ea54c..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolControls/ProtocolControls.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Select, Stack, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { MessageDirectionFilter } from "../MessageDirectionFilter/MessageDirectionFilter"; - -const SearchInput = TextInput.withProps({ - placeholder: "Search...", - rightSectionPointerEvents: "auto", -}); - -// h5 (not h6) so it sits one level below the screen's h4 heading — avoids an axe -// `heading-order` skip; `size="h6"` keeps the small visual size. -const MethodFilterTitle = Title.withProps({ - order: 5, - size: "h6", -}); - -const MethodSelect = Select.withProps({ - placeholder: "All methods", - clearable: true, -}); - -export interface ProtocolControlsProps { - searchText: string; - methodFilter?: MessageMethod; - availableMethods: MessageMethod[]; - visibleDirections: Record; - onSearchChange: (text: string) => void; - onMethodFilterChange: (method: MessageMethod | undefined) => void; - onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; - onToggleAllDirections: () => void; -} - -export function ProtocolControls({ - searchText, - methodFilter, - availableMethods, - visibleDirections, - onSearchChange, - onMethodFilterChange, - onToggleDirection, - onToggleAllDirections, -}: ProtocolControlsProps) { - return ( - - Protocol - onSearchChange(event.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - Filter by Method - - onMethodFilterChange((value as MessageMethod | null) ?? undefined) - } - /> - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx deleted file mode 100644 index 73edda6cd..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { useState } from "react"; -import { - Alert, - Anchor, - Badge, - Card, - Collapse, - Divider, - Group, - ScrollArea, - Stack, - Text, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import type { MessageEntry } from "@inspector/core/mcp/types.js"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; -import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; -import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { PinToggle } from "../../elements/PinToggle/PinToggle"; -import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import { - classifyProtocolSpecError, - type McpSpecError, -} from "../../../utils/mcpNetworkHeaders"; -import { - extractMethod, - extractResultType, - extractSubscriptionId, - isReplayableProtocolMethod, -} from "../protocolUtils.js"; - -export interface ProtocolEntryProps { - entry: MessageEntry; - isPinned: boolean; - isListExpanded: boolean; - onReplay: () => void; - onTogglePin: () => void; - /** - * Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is - * time + direction + duration + status; line 2 is the method (and target) with - * the controls — Replay as an icon — on the right. - */ - embedded?: boolean; - /** - * When provided (a spec-error entry with a correlated Network request), the - * expanded alert shows a "view in Network" link that jumps to, and expands, - * the matching HTTP entry. - */ - onRevealInNetwork?: () => void; - /** - * HTTP status of this entry's correlated Network fetch, when known. Used to - * gate the generic `-32601` to a genuine modern 404 (an in-band `-32601` on a - * 200 is an ordinary error, not the modern taxonomy). Omitted when there is no - * correlated HTTP record. - */ - correlatedHttpStatus?: number; -} - -const EntryContainer = Card.withProps({ - withBorder: true, - padding: "md", - variant: "inset", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -// Left / right clusters within a compact header line. The left cluster shrinks -// (`miw: 0`) so a long target can truncate rather than push the row wider. -const HeaderCluster = Group.withProps({ - gap: "sm", - wrap: "nowrap", - miw: 0, -}); - -const ControlsCluster = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const TimestampText = Text.withProps({ - size: "sm", - c: "dimmed", - ff: "monospace", -}); - -const TargetText = Text.withProps({ - size: "sm", - fw: 500, -}); - -// Compact-header target (e.g. a long resource URI): never wraps, so it scrolls -// horizontally inside its ScrollArea instead of truncating with an ellipsis -// (mirrors NetworkEntry's URL). -const TargetScroll = Text.withProps({ - size: "sm", - fw: 500, - variant: "nowrap", -}); - -const DurationText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with -// a copy button so the id can be correlated against the `subscriptions/listen` -// stream that opened it. -const SubscriptionLabel = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const SubscriptionId = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -const SubscriptionCluster = Group.withProps({ - gap: 4, - wrap: "nowrap", - miw: 0, -}); - -// Friendly summary alert for a modern spec error (title is per-error, dynamic). -const SpecErrorAlert = Alert.withProps({ - variant: "light", - color: "red", - icon: , -}); - -// The client rejected an otherwise well-formed response (#1953). Distinct from -// SpecErrorAlert: nothing is wrong with the server's JSON-RPC frame — the -// Inspector's own decoding refused the result — so the title says who rejected it. -const ClientErrorAlert = Alert.withProps({ - variant: "light", - color: "red", - icon: , - title: "Rejected by the Inspector", -}); - -// Link (button-styled) that jumps to the correlated HTTP entry in the Network tab. -const RevealLink = Anchor.withProps({ - component: "button", - type: "button", - size: "xs", -}); - -const TargetScrollArea = ScrollArea.withProps({ - scrollbarSize: 6, - flex: 1, - miw: 0, - // The target scrolls horizontally but has no focusable child, so make the - // viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides - // via the `type="scroll"` theme default. - viewportProps: { tabIndex: 0 }, -}); - -// Trailing controls row (replay / pin / expand) in the wide layout. -const ToggleRow = Group.withProps({ - gap: "xs", - justify: "flex-end", -}); - -// `complete` is green — it's the success signal now that the redundant "OK" -// status badge is suppressed, so a modern success keeps the same at-a-glance -// green affordance a legacy success has. `input_required` is yellow (in -// progress: awaiting input before the retry). -function resultTypeColor(resultType: "complete" | "input_required"): string { - return resultType === "input_required" ? "yellow" : "green"; -} - -function resultTypeLabel(resultType: "complete" | "input_required"): string { - return resultType === "input_required" ? "input required" : "complete"; -} - -function formatDuration(ms: number): string { - return `${ms}ms`; -} - -function formatTimestamp(date: Date): string { - return date.toISOString(); -} - -// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO -// string would eat most of the narrow line-1 width (#1616). -function formatTimestampCompact(date: Date): string { - return date.toISOString().slice(11, 19); -} - -function extractTarget(entry: MessageEntry): string | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - const params = msg.params as Record; - if (typeof params.name === "string") return params.name; - if (typeof params.uri === "string") return params.uri; - return undefined; -} - -// The resource URI when the target is one (e.g. `resources/read`), so it can be -// copied. Tool/prompt targets are plain names, not URIs, and get no copy button. -function extractResourceUri(entry: MessageEntry): string | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - const params = msg.params as Record; - return typeof params.uri === "string" ? params.uri : undefined; -} - -// The pending → OK/Error lifecycle only applies to requests: messageLogState -// attaches a `response` to request entries by JSON-RPC id. A notification is -// fire-and-forget (no id, no response, ever) and an unmatched standalone -// response has none either — so those carry no request-style status ("none") -// and render no badge, rather than a misleading permanent "Pending". -function extractStatus( - entry: MessageEntry, -): "success" | "error" | "pending" | "none" { - // A response the CLIENT refused is an error whichever entry carries it, so - // this is checked BEFORE the request-only lifecycle below (#1953). - // messageLogState annotates the request entry when the response was folded - // into one, but falls back to the standalone response frame when there was - // no matching request (a trimmed log, or a reconnect boundary) — and that - // entry would otherwise fall straight through to "none" and render no badge. - if (entry.clientError) return "error"; - if (entry.direction !== "request") return "none"; - if (!entry.response) return "pending"; - if ("error" in entry.response) return "error"; - return "success"; -} - -function statusColor(status: "success" | "error" | "pending"): string { - if (status === "success") return "green"; - if (status === "error") return "red"; - return "gray"; -} - -function statusLabel(status: "success" | "error" | "pending"): string { - if (status === "success") return "OK"; - if (status === "error") return "Error"; - return "Pending"; -} - -function serializeMessage(value: unknown): string { - return JSON.stringify(value); -} - -// The JSON-RPC error carried by a message — either the folded error `response` -// on a request, or an error message frame itself — classified as a modern spec -// error (SEP-2243 / SEP-2575) or null. Protocol errors the SDK throws rather -// than delivers (e.g. -32601) are folded onto the pending request upstream (see -// `enrichProtocolEntries`), so they land here too. -function extractSpecError( - entry: MessageEntry, - httpStatus?: number, -): McpSpecError | null { - const error = - entry.response && "error" in entry.response - ? entry.response.error - : "error" in entry.message - ? entry.message.error - : undefined; - if (!error || typeof error.code !== "number") return null; - return classifyProtocolSpecError(error.code, error.data, httpStatus); -} - -// Friendly summary of a modern spec error, shown in the expanded detail. The -// HTTP-level facts (status, mirrored headers) live on the correlated Network -// entry, reachable via the "view in Network" link when one exists. -function McpSpecErrorAlert({ - error, - onReveal, -}: { - error: McpSpecError; - onReveal?: () => void; -}) { - return ( - - - {error.description} - {error.supported && ( - Server supports: {error.supported.join(", ")} - )} - {onReveal && ( - - View the HTTP request in the Network tab → - - )} - - - ); -} - -export function ProtocolEntry({ - entry, - isPinned, - isListExpanded, - onReplay, - onTogglePin, - embedded = false, - onRevealInNetwork, - correlatedHttpStatus, -}: ProtocolEntryProps) { - const [isExpanded, setIsExpanded] = useState(isListExpanded); - const method = extractMethod(entry); - const target = extractTarget(entry); - const resourceUri = extractResourceUri(entry); - const status = extractStatus(entry); - const canReplay = isReplayableProtocolMethod(method); - const resultType = extractResultType(entry); - const subscriptionId = extractSubscriptionId(entry); - - // The list-level Expand/Collapse toggle is authoritative: any per-entry - // override is discarded whenever the parent changes `isListExpanded`. - // Mirrors NetworkEntry; do not change without aligning both. - useValueChange(isListExpanded, setIsExpanded); - - const directionBadge = entry.origin && ( - - ); - // Distinct chip for a modern spec error (SEP-2243 / SEP-2575). Shown only in - // the wide layout (right after the method chip); the compact sidebar relies on - // its ERROR status badge to keep the two-line row uncluttered. - const specError = extractSpecError(entry, correlatedHttpStatus); - const specErrorBadge = specError && ( - - ); - // The modern `resultType` on the paired result (spec §7.3): `input_required` - // (the operation isn't done — it needs input and will be retried) vs the - // ordinary `complete`. Only present on modern results, so it doubles as a - // per-result modern signal without inferring the connection era. - const resultTypeBadge = resultType && ( - - {resultTypeLabel(resultType)} - - ); - // Suppress the redundant green "OK" when a `resultType` badge already conveys - // the outcome (a modern success is `complete`/`input required`); errors and - // pending have no `resultType`, so their status badge still shows. - const statusBadge = status !== "none" && - (!resultType || entry.clientError) && ( - - {statusLabel(status)} - - ); - const subscriptionBadge = subscriptionId && ( - - sub - - {subscriptionId} - - ); - const durationText = entry.duration != null && ( - {formatDuration(entry.duration)} - ); - - return ( - - - {embedded ? ( - // Compact two-line header for the narrow column. - - - - - {formatTimestampCompact(entry.timestamp)} - - {directionBadge} - - - {durationText} - {resultTypeBadge} - {statusBadge} - {/* The subscription-id tag rides the top line's trailing edge - (a notification row's duration/status slots are empty) so the - method badge on the line below gets the full column width and - doesn't truncate against the pin control (#1630). */} - {subscriptionBadge} - - - - - - {target && ( - <> - {resourceUri && } - - {target} - - - )} - - - {canReplay && } - - setIsExpanded((v) => !v)} - /> - - - - ) : ( - <> - - - - {formatTimestamp(entry.timestamp)} - - {directionBadge} - - {specErrorBadge} - {subscriptionBadge} - {target && ( - <> - {resourceUri && } - {target} - - )} - - - {durationText} - {resultTypeBadge} - {statusBadge} - - - - - {canReplay && } - - setIsExpanded((v) => !v)} - /> - - - )} - - - - - {entry.clientError && ( - - {entry.clientError} - - )} - {specError && ( - - )} - {"params" in entry.message && entry.message.params && ( - - Parameters: - - - )} - {entry.response && ( - - Response: - - - )} - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx deleted file mode 100644 index 69214ef0e..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useMemo, useState, type ReactNode } from "react"; -import { - Button, - Collapse, - Group, - Paper, - Stack, - Text, - Title, - UnstyledButton, -} from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import type { - MessageEntry, - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry"; -import { MrtrConversation } from "../MrtrConversation/MrtrConversation"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { EraBadge } from "../../elements/EraBadge/EraBadge"; -import { - SortToggle, - type SortDirection, -} from "../../elements/SortToggle/SortToggle"; -import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; -import { extractMethod, groupProtocolEntries } from "../protocolUtils.js"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface ProtocolListPanelProps { - entries: MessageEntry[]; - pinnedIds: Set; - searchText: string; - methodFilter?: MessageMethod; - /** Which message directions to show, keyed by entry origin. */ - visibleDirections: Record; - /** - * The connection's negotiated protocol era (SEP §7.8), shown as a badge so - * captured traffic is labeled by era. Must come from connection state — never - * inferred from the frames (the modern probe carries a `_meta` envelope before - * the era is known; spec §8.3). Undefined hides the badge. - */ - protocolEra?: ProtocolEra; - onClearAll: () => void; - onExport: () => void; - /** Clear just one section's entries (pinned vs unpinned history). */ - onClearSection: (section: ProtocolSectionName) => void; - /** Export just one section's entries. */ - onExportSection: (section: ProtocolSectionName) => void; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ - embedded?: boolean; - /** Jump from a spec-error entry to its correlated Network HTTP entry. */ - onRevealInNetwork?: (id: string) => void; - /** Message-entry ids that have a correlated Network entry (link is shown). */ - revealableIds?: Set; - /** - * Message-entry id → correlated Network fetch HTTP status. Gates the generic - * `-32601` to a genuine modern 404 (see {@link ProtocolEntry}). - */ - correlatedStatusById?: Map; -} - -const PanelContainer = Paper.withProps({ - withBorder: true, - p: "lg", - flex: 1, - variant: "panel", -}); - -// Centered in the full-height panel so the empty message sits mid-panel rather -// than clinging to the top of an otherwise-empty box (matches LogStreamPanel). -const EmptyCenter = Stack.withProps({ - flex: 1, - align: "center", - justify: "center", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", -}); - -// Panel header: title (+ era badge) on the left, action controls on the right. -const HeaderRow = Group.withProps({ - justify: "space-between", - mb: "sm", -}); - -// The section header is a single "pleat" bar (rounded, with the filter-button -// outline-on-hover treatment and the active background passed per instance via -// `bg`). Inside it sit the -// clickable toggle area (the title, filling the left) and the optional -// Clear/Export actions on the right — so the actions live on the pleat itself, -// not beside it. The toggle is its own button (the actions can't nest inside a -// button), `flex: 1` so it spans the bar up to the actions. -const SectionHeaderBar = Group.withProps({ - variant: "sectionHeader", - gap: "sm", - wrap: "nowrap", - p: "sm", -}); - -const SectionToggleArea = UnstyledButton.withProps({ - flex: 1, -}); - -const SectionTitle = Text.withProps({ - fw: 600, -}); - -const SectionActionGroup = Group.withProps({ - gap: "sm", - wrap: "nowrap", -}); - -// Subtle link-style button, matching the Select/Deselect All control in -// ProtocolControls. -const SectionLinkButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -// The two in-panel sub-section labels. The un-pinned section deliberately keeps -// the "History" wording (and the `"history"` discriminator below) even though -// the tab/feature was renamed to "Protocol" — this is the settled boundary from -// #1623: only the tab/feature renames; the section discriminator and its -// Pinned / History labels stay. -function formatPinnedTitle(count: number): string { - return `Pinned Messages (${count})`; -} - -function formatHistoryTitle(count: number): string { - return `History (${count})`; -} - -type ProtocolSectionName = "pinned" | "history"; - -// Per-section Clear / Export links, shown to the right of a section header when -// both sections are present (so each can be cleared/exported on its own). -function SectionActions({ - onClear, - onExport, -}: { - onClear: () => void; - onExport: () => void; -}) { - return ( - - Clear - Export - - ); -} - -// A History section. When `collapsible` (both sections are on screen) the header -// is a `listItem` toggle — with an optional actions slot on the right — over a -// `Collapse` of the entries. When it's the only section, the accordion makes no -// sense: the header is a plain title and the entries always show (so a stale -// collapsed state from when both sections were present can't hide them) — -// unless `hideHeaderWhenAlone`, in which case the lone section drops its title -// entirely (the "Protocol" label is redundant when there's nothing to -// distinguish it from). -function CollapsibleSection({ - title, - collapsible, - hideHeaderWhenAlone = false, - open, - onToggle, - actions, - children, -}: { - title: string; - collapsible: boolean; - hideHeaderWhenAlone?: boolean; - open: boolean; - onToggle: () => void; - actions?: ReactNode; - children: ReactNode; -}) { - if (!collapsible) { - return ( - - {hideHeaderWhenAlone ? null : {title}} - {children} - - ); - } - return ( - - - - {title} - - {actions} - - - {children} - - - ); -} - -function matchesFilters( - entry: MessageEntry, - searchText: string, - visibleDirections: Record, - methodFilter: MessageMethod | undefined, - // The embedded column exposes only the search box (no direction/method - // controls), so it applies the text filter but skips those (#1616). - ignoreDirectionAndMethod: boolean, -): boolean { - const method = extractMethod(entry); - if (!ignoreDirectionAndMethod) { - // Hide a direction when its toggle is off. Entries with no recorded origin - // (legacy / pre-origin logs) are never filtered out by direction. - if (entry.origin && !visibleDirections[entry.origin]) return false; - if (methodFilter && method !== methodFilter) return false; - } - if (searchText) { - const term = searchText.toLowerCase(); - const responseText = entry.response ? JSON.stringify(entry.response) : ""; - const searchable = - `${method} ${entry.id} ${JSON.stringify(entry.message)} ${responseText}`.toLowerCase(); - if (!searchable.includes(term)) return false; - } - return true; -} - -export function ProtocolListPanel({ - entries, - pinnedIds, - searchText, - methodFilter, - visibleDirections, - protocolEra, - onClearAll, - onExport, - onClearSection, - onExportSection, - onReplay, - onTogglePin, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - onRevealInNetwork, - revealableIds, - correlatedStatusById, -}: ProtocolListPanelProps) { - const viewportRef = useScrollMemory("protocol-list"); - // Per-section expand/collapse, like the LogControls level toggles. Both start - // open; collapsing hides that section's entries without affecting the other. - const [pinnedOpen, setPinnedOpen] = useState(true); - const [historyOpen, setHistoryOpen] = useState(true); - const filteredEntries = useMemo(() => { - // Embedded column filters by text only (its direction/method controls live - // in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns - // a fresh array, so sorting in-place is safe. - const sorted = entries - .filter((e) => - matchesFilters( - e, - searchText, - visibleDirections, - methodFilter, - embedded, - ), - ) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - if (sortDirection === "newest-first") sorted.reverse(); - return sorted; - }, [ - entries, - searchText, - visibleDirections, - methodFilter, - sortDirection, - embedded, - ]); - - const pinnedEntries = useMemo( - () => filteredEntries.filter((e) => pinnedIds.has(e.id)), - [filteredEntries, pinnedIds], - ); - - const unpinnedEntries = useMemo( - () => filteredEntries.filter((e) => !pinnedIds.has(e.id)), - [filteredEntries, pinnedIds], - ); - - const hasResults = filteredEntries.length > 0; - // Per-section Clear/Export only make sense when both sections are on screen; - // with a single section the panel-level Clear/Export already covers it. - const bothSections = pinnedEntries.length > 0 && unpinnedEntries.length > 0; - - // Render a section's entries, folding contiguous MRTR rounds (spec §7.3) into - // one MrtrConversation so an operation spanning several JSON-RPC ids reads as - // a single unit; everything else stays a plain ProtocolEntry. `sectionPinned` - // is the section's pin state (used for a lone entry's pin label). - const renderRows = (sectionEntries: MessageEntry[], sectionPinned: boolean) => - groupProtocolEntries(sectionEntries).map((row) => - row.kind === "mrtr" ? ( - - ) : ( - onReplay(row.entry.id)} - onTogglePin={() => onTogglePin(row.entry.id)} - onRevealInNetwork={ - onRevealInNetwork && revealableIds?.has(row.entry.id) - ? () => onRevealInNetwork(row.entry.id) - : undefined - } - correlatedHttpStatus={correlatedStatusById?.get(row.entry.id)} - /> - ), - ); - - return ( - - - - Messages - {protocolEra && } - - - - - - {hasResults && ( - - )} - - - - {!hasResults ? ( - - No request history - - ) : ( - - - {pinnedEntries.length > 0 && ( - setPinnedOpen((v) => !v)} - actions={ - bothSections ? ( - onClearSection("pinned")} - onExport={() => onExportSection("pinned")} - /> - ) : undefined - } - > - {renderRows(pinnedEntries, true)} - - )} - - {unpinnedEntries.length > 0 && ( - setHistoryOpen((v) => !v)} - actions={ - bothSections ? ( - onClearSection("history")} - onExport={() => onExportSection("history")} - /> - ) : undefined - } - > - {renderRows(unpinnedEntries, false)} - - )} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx deleted file mode 100644 index adf5aa1a5..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { Accordion, Group, Stack, Text, TextInput, Title } from "@mantine/core"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { RiArrowRightSLine } from "react-icons/ri"; -import type { - ProtocolEra, - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; -import type { - InspectorResourceSubscription, - ResourceSubscriptionStreamState, -} from "../../../../../../core/mcp/types.js"; -import { isModernEra } from "../../elements/EraBadge/eraUtils"; -import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ListToggle } from "../../elements/ListToggle/ListToggle"; -import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; -import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; - -// A tight, non-wrapping horizontal row (search field + toggle; count + stream -// badge). -const TightRow = Group.withProps({ gap: "xs", wrap: "nowrap" }); - -// Fills the full-height `sidebar` Card (flex column) so the scroll region below -// can claim the remaining space; `mih: 0` lets that child shrink and scroll -// instead of overflowing the card (#1462). -const SidebarStack = Stack.withProps({ gap: "sm", flex: 1, mih: 0 }); - -const SearchInput = TextInput.withProps({ - flex: 1, - placeholder: "Search...", - rightSectionPointerEvents: "auto", -}); - -export interface ResourceControlsProps { - resources: Resource[]; - templates: ResourceTemplate[]; - subscriptions: InspectorResourceSubscription[]; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscriptions accordion section is hidden - * entirely. Defaults to true so the section renders unless a caller - * explicitly marks subscriptions unsupported. - */ - subscriptionsSupported?: boolean; - /** - * Modern-era `subscriptions/listen` stream state (#1630). When `active` - * (modern era with at least one subscription) the Subscriptions section shows - * a stream-status badge in its panel and a status dot in its header. Legacy - * connections pass `active: false` (or omit it) and see neither — and so does - * a stream open purely for list-change notifications, which this section has - * nothing to say about (#1920). - */ - subscriptionStreamState?: ResourceSubscriptionStreamState; - /** Negotiated protocol era; gates the modern subscription stream chrome. */ - protocolEra?: ProtocolEra; - selectedUri?: string; - selectedTemplateUri?: string; - // Search text + accordion open-sections are controlled by the parent (App, - // via ResourcesScreen) so they persist across tab navigation within a live - // session — see #1417. `openSections` is optional: when undefined the - // accordion falls back to the `compact`-derived default below. - searchText?: string; - openSections?: string[]; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls for the Resources list (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onOpenSectionsChange: (value: string[]) => void; - onSelectUri: (uri: string) => void; - onSelectTemplate: (uriTemplate: string) => void; - onUnsubscribeResource: (uri: string) => void; - /** - * Persisted preference for the ListToggle. Seeds initial accordion state - * (when `openSections` is undefined); the user can still toggle individual - * sections during a session without affecting this value. Only an explicit - * ListToggle click updates it. - */ - compact: boolean; - onCompactChange: (next: boolean) => void; -} - -function formatSectionCount(label: string, count: number): string { - return `${label} (${count})`; -} - -// Per-section flex for the full-height accordion. Open sections share the -// remaining height; `flex-shrink` is weighted by item count (so a long section -// gives up space to shorter ones before they have to scroll) and `flex-grow` is -// 0 so nothing expands — or scrolls — until the combined content overflows the -// panel. Closed/empty sections stay at their header height (#1462). -function sectionFlex(open: boolean, count: number): string { - return open && count > 0 ? `0 ${count} auto` : "0 0 auto"; -} - -export function ResourceControls({ - resources, - templates, - subscriptions, - subscriptionsSupported = true, - subscriptionStreamState, - protocolEra, - selectedUri, - selectedTemplateUri, - searchText = "", - openSections: controlledOpenSections, - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onOpenSectionsChange, - onSelectUri, - onSelectTemplate, - onUnsubscribeResource, - compact: initialCompact, - onCompactChange, -}: ResourceControlsProps) { - const query = searchText.toLowerCase(); - const filteredResources = resources.filter( - (r) => - r.name.toLowerCase().includes(query) || - (r.title?.toLowerCase().includes(query) ?? false) || - r.uri.toLowerCase().includes(query), - ); - const filteredTemplates = templates.filter( - (t) => - t.name.toLowerCase().includes(query) || - (t.title?.toLowerCase().includes(query) ?? false) || - t.uriTemplate.toLowerCase().includes(query), - ); - const filteredSubscriptions = subscriptions.filter( - (s) => - s.resource.name.toLowerCase().includes(query) || - (s.resource.title?.toLowerCase().includes(query) ?? false) || - s.resource.uri.toLowerCase().includes(query), - ); - - // Modern-era chrome for the single `subscriptions/listen` stream (#1630): - // a status badge in the section header (so it stays visible while the section - // is collapsed). Only shown on the modern era while the stream is active - // (≥1 subscription); the legacy per-URI `resources/subscribe` model has no - // persistent stream. Also gated on the *filtered* count so the badge hides - // alongside the section when a search matches none of the live subscriptions - // (rather than sitting next to a disabled "Subscriptions (0)" header). - const streamStatus = - isModernEra(protocolEra) && - subscriptionStreamState?.active === true && - filteredSubscriptions.length > 0 - ? subscriptionStreamState.status - : undefined; - - // Subscriptions are only meaningful when the server advertises the - // `resources.subscribe` capability; otherwise the section is omitted - // entirely (no header, no panel) — see #1478. - const allSections = subscriptionsSupported - ? ["resources", "templates", "subscriptions"] - : ["resources", "templates"]; - // Open-sections is parent-controlled (persists across navigation). When the - // parent hasn't set it yet (undefined), fall back to the persisted `compact` - // preference: empty when last left compact, all sections open when expanded. - // Per-section accordion clicks update the lifted value but don't change the - // persisted preference. - const openSections = - controlledOpenSections ?? (initialCompact ? [] : [...allSections]); - // Persisted open-sections may still carry "subscriptions" from a prior - // subscription-capable session, so compare only the sections we actually - // render when deciding whether everything is expanded. - const allExpanded = - openSections.filter((section) => allSections.includes(section)).length === - allSections.length; - - // Empty sections have a disabled control and nothing to show, so keep them - // out of the accordion's open set — they render collapsed (chevron points - // right) rather than as an open-but-empty panel (#1462). `openSections` still - // tracks the user's intent (and seeds the ListToggle), so a section re-opens - // on its own once it has items again. - const sectionItemCounts: Record = { - resources: filteredResources.length, - templates: filteredTemplates.length, - subscriptions: filteredSubscriptions.length, - }; - const visibleOpenSections = openSections.filter( - (section) => - allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0, - ); - // Open-in-intent but currently empty (so excluded from the accordion's - // `value`). Mantine derives the next open-array by toggling the clicked - // section against the `value` we hand it, which omits these — so without - // merging them back, toggling any populated section would silently drop an - // empty section's intent and it wouldn't reappear once it has items again. - // Restricted to `allSections` so a stale "subscriptions" entry persisted from - // a prior subscription-capable session isn't perpetually re-appended once the - // section is no longer rendered — it's dropped from persisted state instead. - const intendedButEmptySections = openSections.filter( - (section) => - allSections.includes(section) && !visibleOpenSections.includes(section), - ); - function handleOpenSectionsChange(next: string[]) { - // Safe to append unconditionally: empty-section controls are `disabled`, so - // the user can never toggle one and `next` never contains an empty section - // — no double-add, and a section the user just closed can't be resurrected. - onOpenSectionsChange([...next, ...intendedButEmptySections]); - } - - function handleToggleList() { - // Compute the next compact value from what the click will produce so a - // half-open accordion (user toggled a single section) still persists the - // right preference: clicking "expand all" should record `compact=false` - // even if the visible state was already partially expanded. - const nextCompact = allExpanded; - onOpenSectionsChange(nextCompact ? [] : [...allSections]); - onCompactChange(nextCompact); - } - - return ( - - - Resources - - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? ( - onSearchChange("")} /> - ) : null - } - /> - - - - - {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, - so `.withProps({ multiple: true, ... })` loses its JSX call signature - (same tooling limit as Box). */} - } - flex={1} - mih={0} - // Disable Mantine's panel height animation so flex controls the height - // cleanly (the chevron still rotates smoothly via CSS in App.css). #1462 - transitionDuration={0} - value={visibleOpenSections} - onChange={handleOpenSectionsChange} - > - - - {formatSectionCount("URIs", filteredResources.length)} - - - - {filteredResources.map((resource) => ( - { - if (resource.uri !== selectedUri) onSelectUri(resource.uri); - }} - /> - ))} - - - - - - - {formatSectionCount("Templates", filteredTemplates.length)} - - - - {filteredTemplates.map((template) => ( - { - if (template.uriTemplate !== selectedTemplateUri) - onSelectTemplate(template.uriTemplate); - }} - /> - ))} - - - - - {subscriptionsSupported && ( - - - - - {formatSectionCount( - "Subscriptions", - filteredSubscriptions.length, - )} - - {streamStatus && ( - - )} - - - - - {filteredSubscriptions.map((sub) => ( - - onUnsubscribeResource(sub.resource.uri) - } - /> - ))} - - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx deleted file mode 100644 index f00de8b4b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState } from "react"; -import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core"; -import type { ReadResourceResult } from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; -import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo"; - -export interface ResourceLinkProps { - /** The linked resource's URI (always shown). */ - uri: string; - /** Optional human-friendly name shown above the URI. */ - name?: string; - /** Optional MIME type shown as a badge. */ - mimeType?: string; - /** - * Read-on-demand handler. When provided, the card becomes expandable: the - * first expand calls this with the link's `uri` and renders the returned - * read result inline. Omit to render a static, non-expandable card. - */ - onReadResource?: (uri: string) => Promise; -} - -// Recessed "inset" surface so each link card reads the same as a Protocol -// message card (ProtocolEntry), matching its colors in both light and dark -// modes; the inset variant also raises nested Code blocks (the expanded read -// result) onto a lighter surface via its cascade variable. -const LinkCard = Card.withProps({ - withBorder: true, - padding: "sm", - radius: "md", - variant: "inset", -}); - -const ExpandedSection = Stack.withProps({ - gap: "xs", - mt: "xs", -}); - -// Caps the inline read result so a large resource scrolls within the card -// instead of pushing the page down — mirrors V1's bounded resource view. -// `Autosize` sizes to the content up to `mah`, then scrolls; a plain -// ScrollArea would need a definite height to scroll, which this card (sized to -// its content) does not provide. -const ResultScroll = ScrollArea.Autosize.withProps({ - mah: 400, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const LoadingText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Failed to read resource", -}); - -/** - * Expandable card for a `resource_link` content block. Renders the link's - * metadata via {@link ResourceLinkInfo} and — when `onReadResource` is supplied - * — an expand affordance that reads the linked resource on demand and renders - * the full read result inline as formatted JSON (via {@link ContentViewer}). - * The fetched result is cached so collapsing and re-expanding does not re-read. - */ -export function ResourceLink({ - uri, - name, - mimeType, - onReadResource, -}: ResourceLinkProps) { - const [expanded, setExpanded] = useState(false); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - const expandable = Boolean(onReadResource); - - async function toggle() { - if (!onReadResource) return; - if (expanded) { - setExpanded(false); - return; - } - setExpanded(true); - // Only a successful result is cached; re-expanding after an error retries - // the read so a transient failure isn't permanent. - if (result !== null) return; - // Don't fire a second read if one is already in flight (rapid toggle). - if (loading) return; - setError(null); - setLoading(true); - try { - setResult(await onReadResource(uri)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - } - - // Same tooltip'd expand/collapse control as ProtocolEntry (ExpandToggle), - // placed in the header row's meta slot as a sibling of the URI's copy button. - // A per-resource `ariaLabel` keeps the toggles distinguishable to assistive - // tech when several links are listed (the visible tooltip stays "Expand"). - const action = expandable ? ( - void toggle()} - ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`} - /> - ) : undefined; - - return ( - - - {/* Same expand/collapse animation as ProtocolEntry: content stays mounted - (so the cached read result survives a collapse) and animates via - Mantine's Collapse. */} - {expandable && ( - - - {loading ? ( - Loading resource… - ) : error !== null ? ( - {error} - ) : result !== null ? ( - - - - ) : null} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx deleted file mode 100644 index ac659451d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Text, UnstyledButton } from "@mantine/core"; -import type { - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export interface ResourceListItemProps { - resource: Resource | ResourceTemplate; - selected: boolean; - onClick: () => void; -} - -export function ResourceListItem({ - resource, - selected, - onClick, -}: ResourceListItemProps) { - return ( - - {resource.title ?? resource.name} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx deleted file mode 100644 index 39630e89d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { - Button, - CloseButton, - Flex, - Group, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import { useState } from "react"; -import type { - BlobResourceContents, - Resource, - TextResourceContents, -} from "@modelcontextprotocol/client"; -import { accessibleTextColor } from "../../elements/accessibleTextColor"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; -import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton"; - -export interface ResourcePreviewPanelProps { - resource: Resource; - contents: (TextResourceContents | BlobResourceContents)[]; - lastUpdated?: Date; - isSubscribed: boolean; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscribe/Unsubscribe button is hidden. - * Defaults to true so the button renders unless explicitly unsupported. - */ - subscriptionsSupported?: boolean; - onRefresh: () => void; - onSubscribe: () => void; - onUnsubscribe: () => void; - /** - * When provided, a top-left X button dismisses the panel. The host - * (`ResourcesScreen`) decides what to show in its place — either the - * originating template form or the empty state. - */ - onClose?: () => void; -} - -function formatLastUpdated(date: Date): string { - return `Last updated: ${date.toLocaleString()}`; -} - -// MIME kinds whose rendered preview (react-markdown, the CSV table, the -// sandboxed HTML iframe) hides the underlying text. For these the panel offers -// a "View Source" toggle that swaps the renderer for the raw resource text. -const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]); - -// MIME forced on ContentViewer in source mode so it routes through the plain -// preformatted-text renderer regardless of the resource's real type. -const SOURCE_MIME = "text/plain"; - -function isSourceToggleable(mimeType: string): boolean { - return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType)); -} - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriGroup = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriText = Text.withProps({ - size: "sm", - // Scheme-aware readable blue: `c="blue"` renders blue-4 in dark mode, which - // falls just under WCAG AA (4.38:1) on the card. `-light-color` clears it in - // both schemes (see `accessibleTextColor`). - c: accessibleTextColor("blue"), - truncate: "end", -}); - -const MetaRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const TimestampText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const MimeText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Actions sit at the left (`flex-start`) so the pointer travels the shortest -// distance from the sidebar controls / the form fields above; annotation badges -// trail them. -const FooterRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const AnnotationGroup = Group.withProps({ - gap: "xs", -}); - -const ActionGroup = Group.withProps({ - gap: "xs", -}); - -// Subtle footer action button. Shared by Refresh and the View Source toggle so -// the two stay visually identical (the toggle is deliberately styled to match -// Refresh, which sits immediately to its right). -const FooterButton = Button.withProps({ - variant: "subtle", - size: "sm", -}); - -const Spacer = Flex.withProps({}); - -// The panel sizes to its content: when the resource body is short the -// Card hugs it; when the body would overflow the Card's `mah`, the -// browser shrinks shrinkable flex items (only ContentScroll, since the -// header / meta / footer rows opt out with `flex: 0 0 auto`) and the -// inner ScrollArea takes over scrolling — keeping the subscribe button -// pinned at the bottom edge of the cap. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -// Middle scroll region: basis sized to its own content, can shrink to -// fit the available space when content overflows, never grows past its -// content (so a short resource body doesn't push the footer down). -const ContentScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ContentStack = Stack.withProps({ - gap: "md", -}); - -// Map a file extension to the MIME type that drives ContentViewer's per-MIME -// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic -// `text/plain` / `application/octet-stream`), so the URI suffix is the most -// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS -// renderers. Order doesn't matter — suffixes are unique. -const URI_SUFFIX_MIME: ReadonlyArray = [ - [".md", "text/markdown"], - [".markdown", "text/markdown"], - [".csv", "text/csv"], - [".json", "application/json"], - [".xml", "application/xml"], - [".html", "text/html"], - [".htm", "text/html"], - [".css", "text/css"], - [".pdf", "application/pdf"], -]; - -// Infer a MIME type from the URI's file extension when the server didn't supply -// one. Returns undefined for unrecognized suffixes so callers fall through to -// the octet-stream default. -function inferMimeFromUri(uri: string): string | undefined { - const path = uri.split("?")[0].split("#")[0]; - const lower = path.toLowerCase(); - for (const [suffix, mime] of URI_SUFFIX_MIME) { - if (lower.endsWith(suffix)) return mime; - } - return undefined; -} - -function effectiveMime( - itemMime: string | undefined, - resource: Resource, -): string { - return ( - itemMime ?? - resource.mimeType ?? - inferMimeFromUri(resource.uri) ?? - "application/octet-stream" - ); -} - -export function ResourcePreviewPanel({ - resource, - contents, - lastUpdated, - isSubscribed, - subscriptionsSupported = true, - onRefresh, - onSubscribe, - onUnsubscribe, - onClose, -}: ResourcePreviewPanelProps) { - const { uri, annotations } = resource; - const mimeType = effectiveMime(contents[0]?.mimeType, resource); - - const [showSource, setShowSource] = useState(false); - // Reset to the rendered view when the previewed resource changes (the panel - // is reused, not remounted, across resources). React's documented - // "adjust state during render" pattern — no effect, so no cascading render. - const [prevUri, setPrevUri] = useState(uri); - if (uri !== prevUri) { - setPrevUri(uri); - setShowSource(false); - } - const sourceToggleable = isSourceToggleable(mimeType); - - return ( - - - - {onClose && ( - - )} - Resource - - - {uri} - - - - - - {contents.map((item, index) => { - const itemMime = effectiveMime(item.mimeType, resource); - // The toggle is gated on the first content item but applies per - // item: in source mode only the source-toggleable items (the ones - // whose rendered view hides their text) switch to plain text, so a - // mixed multi-part resource doesn't force an image/PDF blob through - // the text decoder. - const renderMime = - showSource && isSourceToggleable(itemMime) - ? SOURCE_MIME - : itemMime; - return ( - - ); - })} - - - - {lastUpdated ? ( - {formatLastUpdated(lastUpdated)} - ) : ( - - )} - {contents.length <= 1 && {mimeType}} - - - - {subscriptionsSupported && ( - - )} - Refresh - {sourceToggleable && ( - setShowSource((shown) => !shown)} - > - {showSource ? "View Rendered" : "View Source"} - - )} - - - {annotations?.audience && ( - - )} - {annotations?.priority !== undefined && ( - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx deleted file mode 100644 index 1b96579fb..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Group, Stack, Text, Tooltip } from "@mantine/core"; -import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; - -export interface ResourceSubscribedItemProps { - subscription: InspectorResourceSubscription; - onUnsubscribe: () => void; -} - -const NameText = Text.withProps({ - size: "sm", - fw: 500, - truncate: "end", -}); - -const TimestampText = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - -const ItemRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "xs", -}); - -const NameStack = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -function formatLastUpdated(date: Date): string { - return date.toLocaleString(); -} - -// Strip the URI down to its last non-empty path segment so the tile shows -// a compact label (e.g. `file:///foo/bar/config.json` → `config.json`). -// The full URI is restored via a tooltip on hover. -function lastUriSegment(uri: string): string { - const segments = uri.split("/").filter(Boolean); - return segments[segments.length - 1] ?? uri; -} - -export function ResourceSubscribedItem({ - subscription, - onUnsubscribe, -}: ResourceSubscribedItemProps) { - const { resource, lastUpdated } = subscription; - return ( - - - - {lastUriSegment(resource.uri)} - - {lastUpdated && ( - {formatLastUpdated(lastUpdated)} - )} - - Unsubscribe - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx deleted file mode 100644 index 6b506228b..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Autocomplete, - Button, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { accessibleTextColor } from "../../elements/accessibleTextColor"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { CopyButton } from "../../elements/CopyButton/CopyButton"; - -export interface ResourceTemplatePanelProps { - template: ResourceTemplate; - onReadResource: (uri: string) => void; - /** - * When provided, each keystroke in a variable input dispatches a - * (debounced) `completion/complete` request to the server. The - * resolved values are surfaced as a dropdown via Mantine `Autocomplete`. - * Wire to `InspectorClient.getCompletions` in the host App. - */ - onCompleteArgument?: ( - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - /** - * Gates whether to render Autocomplete (with live completions) vs the - * plain TextInput. Typically derived from the server's - * `completions` capability. - */ - completionsSupported?: boolean; -} - -const COMPLETION_DEBOUNCE_MS = 300; - -function parseVariableNames(uriTemplate: string): string[] { - const names: string[] = []; - const regex = /\{(\w+)\}/g; - let match: RegExpExecArray | null; - - while ((match = regex.exec(uriTemplate)) !== null) { - names.push(match[1]); - } - - return names; -} - -function resolveUri( - uriTemplate: string, - variables: Record, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]); -} - -function previewUri( - uriTemplate: string, - variables: Record, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) => - variables[key]?.length > 0 ? variables[key] : match, - ); -} - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -const UriGroup = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const UriText = Text.withProps({ - size: "sm", - // Scheme-aware readable blue (`c="blue"` is blue-4 in dark, 4.38:1 on the - // card — just under WCAG AA); see `accessibleTextColor`. - c: accessibleTextColor("blue"), - truncate: "end", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -// Left-aligned so the action sits closest to the sidebar controls / the form -// fields above; annotation badges trail it. -const FooterRow = Group.withProps({ - justify: "flex-start", -}); - -const AnnotationGroup = Group.withProps({ - gap: "xs", -}); - -export function ResourceTemplatePanel({ - template, - onReadResource, - onCompleteArgument, - completionsSupported = false, -}: ResourceTemplatePanelProps) { - const { name, title, uriTemplate, description, annotations } = template; - - const variableNames = useMemo( - () => parseVariableNames(uriTemplate), - [uriTemplate], - ); - - const [variables, setVariables] = useState>(() => - Object.fromEntries(variableNames.map((n) => [n, ""])), - ); - const [completions, setCompletions] = useState>({}); - - // Reset state when the user switches to a different template. Keyed on - // `uriTemplate` alone because `variableNames` is memoized from it, so the two - // can never change independently. - useValueChange(uriTemplate, () => { - setVariables(Object.fromEntries(variableNames.map((n) => [n, ""]))); - setCompletions({}); - }); - - // Latest in-flight controller per argument, so a faster keystroke can - // abort an outstanding completion request and the late response can't - // overwrite the fresh one. - const requestsRef = useRef>(new Map()); - // Debounce timer per argument so we don't spam the server on every key. - const timersRef = useRef>>( - new Map(), - ); - - // Drop pending timers / abort in-flight requests on unmount. - useEffect(() => { - const timers = timersRef.current; - const requests = requestsRef.current; - return () => { - for (const t of timers.values()) clearTimeout(t); - timers.clear(); - for (const c of requests.values()) c.abort(); - requests.clear(); - }; - }, []); - - const useAutocomplete = completionsSupported && !!onCompleteArgument; - - const runCompletion = useCallback( - async (varName: string, value: string, context: Record) => { - /* v8 ignore next -- unreachable: runCompletion is only invoked when - useAutocomplete is true, which already requires onCompleteArgument. */ - if (!onCompleteArgument) return; - requestsRef.current.get(varName)?.abort(); - const controller = new AbortController(); - requestsRef.current.set(varName, controller); - try { - const values = await onCompleteArgument(varName, value, context); - if (controller.signal.aborted) return; - setCompletions((prev) => ({ ...prev, [varName]: values })); - } catch { - if (!controller.signal.aborted) { - setCompletions((prev) => ({ ...prev, [varName]: [] })); - } - } finally { - if (requestsRef.current.get(varName) === controller) { - requestsRef.current.delete(varName); - } - } - }, - [onCompleteArgument], - ); - - // Hold the latest `variables` in a ref so a debounced completion - // call reads sibling values at fire time, not at schedule time. - // Typing in A then B within the 300ms window would otherwise ship - // A's request with B's value still empty in context. - const variablesRef = useRef(variables); - useEffect(() => { - variablesRef.current = variables; - }, [variables]); - - function buildContext(varName: string): Record { - const ctx: Record = { ...variablesRef.current }; - delete ctx[varName]; - return ctx; - } - - function handleVariableChange(varName: string, value: string) { - setVariables((prev) => ({ ...prev, [varName]: value })); - if (!useAutocomplete) return; - // Drop the previous prefix's completions so the dropdown doesn't - // show ghost suggestions from the old keystroke while the new - // request is in flight (300ms debounce + network latency). - setCompletions((prev) => { - if (prev[varName] === undefined) return prev; - const next = { ...prev }; - delete next[varName]; - return next; - }); - const existing = timersRef.current.get(varName); - if (existing) clearTimeout(existing); - const timer = setTimeout(() => { - timersRef.current.delete(varName); - // Build context at fire time so sibling updates that arrived - // between schedule and fire are picked up. - void runCompletion(varName, value, buildContext(varName)); - }, COMPLETION_DEBOUNCE_MS); - timersRef.current.set(varName, timer); - } - - function handleVariableFocus(varName: string) { - /* v8 ignore next -- unreachable: the plain (non-autocomplete) TextInput - has no onFocus, so this handler only runs when useAutocomplete is true. */ - if (!useAutocomplete) return; - // Fire immediately so the dropdown isn't empty when the user first - // clicks in. Cancel any pending debounce for this variable so a - // stale keystroke request doesn't overwrite the fresher focus - // response. `variables` already carries every declared template - // variable (seeded with "") so the context is complete by default. - const existing = timersRef.current.get(varName); - if (existing) { - clearTimeout(existing); - timersRef.current.delete(varName); - } - /* v8 ignore next -- the `?? ""` fallback is unreachable: `variables` (and - its ref) is seeded with every declared variable, so the key is present. */ - const value = variablesRef.current[varName] ?? ""; - void runCompletion(varName, value, buildContext(varName)); - } - - const canSubmit = variableNames.every((n) => variables[n]?.length > 0); - - function handleSubmit() { - onReadResource(resolveUri(uriTemplate, variables)); - } - - const preview = previewUri(uriTemplate, variables); - - return ( - - - {title ?? name} Template - - {preview} - - - - {description && {description}} - - {variableNames.map((varName) => { - /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ - const fieldValue = variables[varName] ?? ""; - return useAutocomplete ? ( - options} - onChange={(value) => handleVariableChange(varName, value)} - onFocus={() => handleVariableFocus(varName)} - /> - ) : ( - - handleVariableChange(varName, e.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - variables[varName] ? ( - handleVariableChange(varName, "")} - /> - ) : null - } - /> - ); - })} - - - - - {annotations?.audience && ( - - )} - {annotations?.priority !== undefined && ( - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx deleted file mode 100644 index 232b5327a..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { - Checkbox, - JsonInput, - MultiSelect, - NumberInput, - Select, - Stack, - Text, - TextInput, -} from "@mantine/core"; -import { useState } from "react"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { useValueChange } from "../../../hooks/useValueChange"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; - -const FieldLabel = Text.withProps({ - fw: 500, - size: "sm", -}); - -const FieldDescription = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -// Indented column for a nested object's sub-fields. -const IndentedStack = Stack.withProps({ gap: "sm", pl: "md" }); - -const SchemaJsonInput = JsonInput.withProps({ - formatOnBlur: true, - autosize: true, -}); - -function serializeJson(value: unknown): string { - return JSON.stringify(value, null, 2); -} - -/** - * Pair enum values with their non-standard `enumNames` titles into Mantine - * `{ value, label }[]` option data. Falls back to bare enum values when - * `enumNames` is absent or its length does not match `enum`, since a wrong-length - * zip would mislabel options — worse than showing the raw values. - */ -function toEnumData( - values: string[], - names: string[] | undefined, -): string[] | { value: string; label: string }[] { - if (names && names.length === values.length) { - return values.map((value, index) => ({ value, label: names[index] })); - } - return values; -} - -/** - * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the - * field. Anything that is not a finite number becomes `undefined`, which is how - * an absent optional argument is represented everywhere else in this form. - * - * `NumberInput` emits a `number` only when the text both parses *and* is exactly - * representable; otherwise it hands back the **raw string** (see its - * `isValidNumber` guard). Two quite different situations produce a string, and - * they are treated differently here: - * - * 1. **Mid-entry text** — `""` when cleared, plus `"1."`, `"1.50"`, and a lone - * `"-"`. These are parsed: `"1."` really does mean `1`. (Note that an - * exponent is *not* in this set — `NumberInput` masks input through - * `NumericFormat`, which rejects `e` outright, so `"1e"` can never be typed.) - * 2. **Values JS cannot hold exactly** — anything at or beyond - * `Number.MAX_SAFE_INTEGER`. `Number("90071992547409910")` silently yields - * `90071992547409904`, so parsing here would send the server a number the - * user never entered. An inspector must not misreport what it transmits, so - * these report no value instead — which is also what this field did with such - * input before #1888, making it no regression. Preserving them properly needs - * an exact-serialization path down the whole `tools/call` chain, which is a - * separate concern from being able to type a decimal. - */ -function toNumericValue(raw: string | number): number | undefined { - if (typeof raw === "number") { - return Number.isFinite(raw) ? raw : undefined; - } - if (raw.trim() === "") { - return undefined; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed)) { - return undefined; - } - // Case 2 above. The integer part is what overflows exact representation; the - // fractional digits are bounded by the same guard and stay lossless. - return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined; -} - -interface SchemaNumberInputProps { - label: string; - description?: string; - withAsterisk: boolean; - disabled: boolean; - value: number | undefined; - min?: number; - max?: number; - allowDecimal: boolean; - onChange: (value: number | undefined) => void; -} - -/** - * A `NumberInput` that keeps the text the user is typing, not just the number it - * currently parses to. - * - * Driving `NumberInput` directly off the parent's numeric value makes a decimal - * impossible to enter (#1888): typing `.` after `1` produces the unparseable - * string `"1."`, the numeric value stays `1`, and the controlled `value` prop - * immediately rewrites the box back to `"1"` — so the `.` vanishes and `1.5` can - * never be reached. Trailing zeros (`"1.50"`) and a lone leading `"-"` fail the - * same way. - * - * So the raw text is held here as the source of truth for what is *displayed*, - * while the parent still only ever sees a `number | undefined`. The two are - * re-synced only when the parent's value genuinely diverges from what the draft - * parses to, which leaves an external reset (a cleared form, a loaded example) - * working while an in-progress `"1."` — whose parse is `1`, matching the value we - * just emitted — is left alone. - * - * That value comparison cannot see a reset to an *equal* value, so the caller is - * additionally expected to vary this component's React key via `SchemaForm`'s - * `resetKey` when it switches which entity the form edits. See the note on that - * prop for the case it covers. - */ -function SchemaNumberInput({ - value, - onChange, - ...inputProps -}: SchemaNumberInputProps) { - const [draft, setDraft] = useState(value ?? ""); - - useValueChange(value, (next) => { - if (!Object.is(toNumericValue(draft), next)) { - setDraft(next ?? ""); - } - }); - - return ( - { - setDraft(next); - onChange(toNumericValue(next)); - }} - /> - ); -} - -export interface SchemaFormProps { - schema: InspectorFormSchema; - values: Record; - onChange: (values: Record) => void; - disabled?: boolean; - /** - * Stable identity of whatever this form is editing — a tool name, a request - * id. Pass it whenever the same mounted form is reused for a *different* - * entity, which is the case for the Tools tab: `ToolDetailPanel` is not keyed - * by tool, so selecting another tool re-renders the same field components. - * - * It exists because the number field's draft/value re-sync compares parsed - * numbers, and so cannot detect a reset to an equal value. Type `-` (draft - * `"-"`, value `undefined`), then switch to a tool with a same-named number - * field and no default: the value is `undefined` on both sides, no divergence - * is seen, and the stale `-` would otherwise be left in the box for the new - * tool to continue from. Varying `resetKey` remounts the field instead, so no - * in-progress text can outlive the entity it was typed into. - * - * Omit it when the form is mounted fresh per entity (the elicitation panels), - * where unmounting already discards the draft. The schema object itself is no - * substitute — callers rebuild it every render, so its identity is unstable. - */ - resetKey?: string; -} - -function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { - if (fieldSchema.default !== undefined) { - return fieldSchema.default; - } - return undefined; -} - -function resolveValue( - value: unknown, - fieldSchema: InspectorFormSchema, -): unknown { - if (value !== undefined) { - return value; - } - return getDefaultValue(fieldSchema); -} - -export function SchemaForm({ - schema, - values, - onChange, - disabled = false, - resetKey, -}: SchemaFormProps) { - const properties = schema.properties ?? {}; - const requiredFields = schema.required ?? []; - - function handleFieldChange(fieldName: string, fieldValue: unknown) { - onChange({ ...values, [fieldName]: fieldValue }); - } - - function renderField(fieldName: string, fieldSchema: InspectorFormSchema) { - const isRequired = requiredFields.includes(fieldName); - const label = fieldSchema.title ?? fieldName; - const description = fieldSchema.description; - const rawValue = resolveValue(values[fieldName], fieldSchema); - - // string with enum - if (fieldSchema.type === "string" && fieldSchema.enum) { - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // plain string - if (fieldSchema.type === "string") { - return ( - - handleFieldChange(fieldName, event.currentTarget.value) - } - rightSectionPointerEvents="auto" - rightSection={ - rawValue ? ( - handleFieldChange(fieldName, "")} /> - ) : null - } - /> - ); - } - - // number or integer - if (fieldSchema.type === "number" || fieldSchema.type === "integer") { - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // boolean - if (fieldSchema.type === "boolean") { - return ( - - handleFieldChange(fieldName, event.currentTarget.checked) - } - /> - ); - } - - // array of enum values (multi-select) - if (fieldSchema.type === "array" && fieldSchema.items?.enum) { - const data = toEnumData( - fieldSchema.items.enum, - fieldSchema.items.enumNames, - ); - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // array with items having anyOf - if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) { - const data = fieldSchema.items.anyOf.map((item) => ({ - value: String(item.const ?? ""), - label: item.title ?? String(item.const ?? ""), - })); - return ( - handleFieldChange(fieldName, val)} - /> - ); - } - - // nested object - if (fieldSchema.type === "object" && fieldSchema.properties) { - return ( - - {label} - {description && {description}} - - ) ?? {}} - onChange={(nestedValues) => - handleFieldChange(fieldName, nestedValues) - } - disabled={disabled} - // Sub-fields belong to the same entity, so they reset with it. - resetKey={resetKey} - /> - - - ); - } - - // fallback: JsonInput for complex schemas - return ( - { - try { - handleFieldChange(fieldName, JSON.parse(val)); - } catch { - handleFieldChange(fieldName, val); - } - }} - /> - ); - } - - return ( - - {Object.entries(properties).map(([fieldName, fieldSchema]) => - renderField(fieldName, fieldSchema), - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx deleted file mode 100644 index 0f088e974..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from "react"; -import { - Collapse, - Group, - Paper, - ScrollArea, - Stack, - Title, -} from "@mantine/core"; -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; - -export interface StructuredOutputPanelProps { - /** The result's `structuredContent` — the tool's schema-validated payload. */ - structuredContent: NonNullable; - /** Whether the section starts expanded. Defaults to `true`. */ - defaultExpanded?: boolean; -} - -// Bordered box matching the "Resource Links" group in the result panel, so the -// two supplementary sections of a tool result read as siblings. -const StructuredBox = Paper.withProps({ - withBorder: true, - radius: "md", - p: "md", - variant: "panel", -}); - -const StructuredInner = Stack.withProps({ - gap: "sm", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", -}); - -// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's -// "Results" title is h3, so a sub-box heading is h4 and the heading order never -// skips a level (axe `heading-order`). -const StructuredHeader = Title.withProps({ - order: 4, - size: "h5", -}); - -// Caps the payload so a large structured result scrolls within the box instead -// of pushing the content blocks out of view. `Autosize` sizes to the content up -// to `mah`, so a small object still takes only what it needs. -const StructuredScroll = ScrollArea.Autosize.withProps({ - mah: 400, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -/** - * Collapsible "Structured Output" section for a tool result's - * `structuredContent` (#1908). A tool declaring an `outputSchema` returns its - * real payload here — the `content[]` blocks usually only summarize it — so v1 - * rendered it as its own inspectable JSON section. Without this, the payload is - * dropped from the Tools screen entirely, with no hint it was ever returned. - * - * The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer} - * (an `application/json` text block), so it is copyable and scannable field by - * field. - */ -export function StructuredOutputPanel({ - structuredContent, - defaultExpanded = true, -}: StructuredOutputPanelProps) { - const [expanded, setExpanded] = useState(defaultExpanded); - - return ( - - - - Structured Output - setExpanded((value) => !value)} - ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`} - /> - - {/* Content stays mounted across a collapse (Mantine `Collapse`), so the - highlighted JSON isn't re-rendered from scratch on every toggle. */} - - - - - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx deleted file mode 100644 index 4ad70d854..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - Divider, - Group, - ScrollArea, - Stack, - Text, - TextInput, - ThemeIcon, - Title, - Tooltip, -} from "@mantine/core"; -import { RiErrorWarningLine } from "react-icons/ri"; -import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import type { Tool } from "@modelcontextprotocol/client"; -import type { ExcludedTool } from "@inspector/core/mcp/types.js"; -import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; -import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; -import { - ListPaginationControls, - type ListPaginationControlsProps, -} from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ToolListItem } from "../ToolListItem/ToolListItem"; -import { useScrollMemory } from "../../../hooks/useScrollMemory"; - -export interface ToolControlsProps { - tools: Tool[]; - /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` - * annotations (SEP-2243), shown below the list with the reason (#1632). */ - excludedTools?: ExcludedTool[]; - selectedName?: string; - // Search text is controlled by the parent (App, via ToolsScreen) so it - // persists across tab navigation within a live session — see #1417. - searchText?: string; - listChanged: boolean; - onRefreshList: () => void; - /** - * A failed list load, surfaced above the list instead of leaving the panel - * empty (which reads as "this server has none") (#1953). - */ - loadError?: Error | null; - /** Pagination controls (#1721). */ - pagination: ListPaginationControlsProps; - onSearchChange: (value: string) => void; - onSelectTool: (name: string) => void; -} - -// One excluded tool: a warning icon, the tool name (struck through, since it is -// not callable), and its reason on hover. `wrap: nowrap` keeps the icon pinned. -const ExcludedRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - align: "center", -}); - -const ExcludedWarningIcon = ThemeIcon.withProps({ - size: "sm", - variant: "transparent", - c: "var(--inspector-log-warning)", - "aria-hidden": true, -}); - -const ExcludedName = Text.withProps({ - size: "sm", - td: "line-through", - c: "var(--inspector-text-secondary)", - truncate: "end", -}); - -// Fill the full-height `sidebar` Card (a flex column) so the scroll region -// below claims all the remaining space under the fixed title/search — the -// list runs to the bottom of the card before it scrolls, instead of being -// capped short by a fixed max-height. `mih: 0` lets the scroll child shrink -// and scroll rather than overflow the card. -const SidebarStack = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -// h3 (not h4), size h4: the sampling/elicitation request modals open over this -// screen with an `h2` `Modal.Title`, so an `h4` section would skip a level -// (axe `heading-order`); `size="h4"` keeps the look. -const ToolsTitle = Title.withProps({ - order: 3, - size: "h4", -}); - -const SearchInput = TextInput.withProps({ - placeholder: "Search tools...", - rightSectionPointerEvents: "auto", -}); - -const SidebarScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, -}); - -const ExcludedDivider = Divider.withProps({ - label: "Excluded (SEP-2243)", - labelPosition: "left", - mt: "sm", -}); - -const ExcludedTooltip = Tooltip.withProps({ - multiline: true, - w: 280, - withArrow: true, - position: "right", -}); - -// A server may return the same tool name more than once, so the name alone is -// not a unique React key — colliding keys let a filtered-out row survive -// reconciliation instead of unmounting (#1957). The tool's position in the -// unfiltered list disambiguates duplicates and stays stable while the search -// narrows, since it is captured before filtering. -const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`; - -/** Matches a tool against the (already lower-cased) search query by name or title. */ -const matchesQuery = (tool: Tool, query: string) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false); - -export function ToolControls({ - tools, - excludedTools = [], - selectedName, - searchText = "", - listChanged, - onRefreshList, - loadError, - pagination, - onSearchChange, - onSelectTool, -}: ToolControlsProps) { - const viewportRef = useScrollMemory("tools-sidebar"); - const query = searchText.toLowerCase(); - // Stamp each row's source position before filtering, so the key survives the - // list narrowing (#1957). - const filteredTools = tools - .map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); - // Excluded tools are searchable too, matching name AND title like the main - // list above, so a filtered view stays consistent. - const filteredExcluded = excludedTools - .map((excluded, sourceIndex) => ({ - ...excluded, - key: rowKey(excluded.tool.name, sourceIndex), - })) - .filter(({ tool }) => !searchText || matchesQuery(tool, query)); - - return ( - - - Tools - - - onSearchChange(e.currentTarget.value)} - rightSection={ - searchText ? onSearchChange("")} /> : null - } - /> - - - - - {filteredTools.map(({ tool, key }) => ( - { - if (tool.name !== selectedName) onSelectTool(tool.name); - }} - /> - ))} - {filteredExcluded.length > 0 && ( - <> - - {filteredExcluded.map(({ tool, reason, key }) => ( - - - - - - {tool.name} - - - ))} - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx deleted file mode 100644 index fbb6d26d6..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { - ActionIcon, - Button, - Code, - Collapse, - Divider, - Group, - Image, - ScrollArea, - Stack, - Switch, - Text, -} from "@mantine/core"; -import { useId, useState } from "react"; -import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri"; -import type { - ProgressNotification, - Tool, - ToolAnnotations, -} from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; -import { toFormSchema } from "../../../utils/jsonUtils"; -import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; -import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; -import { SchemaForm } from "../SchemaForm/SchemaForm"; - -export type ToolProgress = Pick< - ProgressNotification["params"], - "progress" | "total" | "message" ->; - -export interface ToolDetailPanelProps { - tool: Tool; - formValues: Record; - isExecuting: boolean; - progress?: ToolProgress; - /** Whether the connected server advertises task-augmented tool calls. */ - serverSupportsTaskToolCalls: boolean; - /** - * Modern (2026-07-28) connection with the `io.modelcontextprotocol/tasks` - * extension negotiated (SEP-2663). Task creation is server-directed there, so - * "Run as task" is offered for ANY tool (not just ones declaring per-tool - * `taskSupport`, which is the legacy mechanism). Defaults to false (legacy). - */ - modernTasks?: boolean; - /** User's "Run as task" preference (meaningful for `optional` tools and, on - * modern connections, any tool). */ - runAsTask: boolean; - onRunAsTaskChange: (value: boolean) => void; - onFormChange: (values: Record) => void; - /** Receives the effective run-as-task decision for this execution. */ - onExecute: (runAsTask: boolean) => void; - onCancel: () => void; -} - -// Outer column: title/annotations pin at top, the Execute footer pins at the -// bottom, and the middle (description + form + progress) scrolls when the -// enclosing card hits its `mah`. `mih: 0` lets the flex children shrink. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, -}); - -const PinnedHeader = Stack.withProps({ - gap: "md", - flex: "0 0 auto", -}); - -// `0 1 auto` + `mih: 0`: shrinks to the available space and scrolls; a short -// form doesn't reserve extra height, keeping Execute snug below it. -const BodyScroll = ScrollArea.withProps({ - flex: "0 1 auto", - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const BodyStack = Stack.withProps({ - gap: "md", -}); - -// Left-aligned (Execute first, Cancel after) so the primary action sits closest -// to the sidebar controls / the form fields above — shortest pointer travel. -const FooterRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const TitleRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - miw: 0, -}); - -const ToolIcon = Image.withProps({ - w: 24, - h: 24, - fit: "contain", -}); - -// `flex: 1` lets the title absorb the row's slack so the chevron toggle pins -// to the right edge of the (nowrap) TitleRow. -const ToolTitle = Text.withProps({ - fw: 700, - size: "lg", - truncate: "end", - flex: 1, -}); - -// Chevron toggle for the collapsible description, pinned to the right of the -// title row. `aria-label` is set per-render since it reflects the open state. -const DescriptionToggle = ActionIcon.withProps({ - variant: "subtle", - color: "gray", - size: "sm", -}); - -const DescriptionText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -const CancelButton = Button.withProps({ - variant: "subtle", - color: "red", -}); - -// Left-aligned row hosting the "Run as task" toggle, above the execute footer. -const TaskToggleRow = Group.withProps({ - justify: "flex-start", - flex: "0 0 auto", -}); - -const RunAsTaskSwitch = Switch.withProps({ - size: "sm", - label: "Run as task", -}); - -// Header-mirroring section (SEP-2243): lists which args mirror their value into -// an `Mcp-Param-{Name}` header on `tools/call`. `Stack` (not `Box`) so the -// constant can carry props; the heading + note pin above the mapping rows. -const HeaderParamsSection = Stack.withProps({ - gap: "xs", -}); - -const HeaderParamsTitle = Text.withProps({ - size: "sm", - fw: 600, -}); - -const HeaderParamsNote = Text.withProps({ - size: "xs", - c: "var(--inspector-text-secondary)", -}); - -// One `arg → Mcp-Param-{Name}` mapping row. -const HeaderParamRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const HeaderParamArrow = Text.withProps({ - size: "sm", - c: "var(--inspector-text-secondary)", -}); - -// A tool's per-tool task support, defaulting to "forbidden" (the SDK default -// when `execution` is absent) so tools that say nothing can't be run as tasks. -type TaskSupport = "forbidden" | "optional" | "required"; -function getTaskSupport(tool: Tool): TaskSupport { - return tool.execution?.taskSupport ?? "forbidden"; -} - -function hasAnyAnnotation(annotations?: ToolAnnotations): boolean { - return !!( - annotations && - (annotations.readOnlyHint || - annotations.destructiveHint || - annotations.idempotentHint || - annotations.openWorldHint) - ); -} - -export function ToolDetailPanel({ - tool, - formValues, - isExecuting, - progress, - serverSupportsTaskToolCalls, - modernTasks = false, - runAsTask, - onRunAsTaskChange, - onFormChange, - onExecute, - onCancel, -}: ToolDetailPanelProps) { - const { name, title, description, icons, annotations, inputSchema } = tool; - // Narrow the SDK protocol schema to the form renderer's schema type. - const formSchema = toFormSchema(inputSchema) ?? {}; - const iconSrc = icons?.[0]?.src; - // SEP-2243: args this tool declares as `x-mcp-header` — their values mirror - // into `Mcp-Param-{Name}` headers on a `tools/call` (#1632). - const mirroredParams = getMirroredHeaderParams(tool); - - // Descriptions are shown by default (most are short); the chevron lets the - // user hide a long one to keep the form and Execute footer in view. Reset to - // shown when switching tools (React's adjust-state-during-render pattern) so - // a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen - // clears formValues on change. - const [descriptionOpen, setDescriptionOpen] = useState(true); - const [prevToolName, setPrevToolName] = useState(name); - if (name !== prevToolName) { - setPrevToolName(name); - setDescriptionOpen(true); - } - // Ties the toggle to the Collapse region so assistive tech announces it as a - // single expandable control (aria-expanded + aria-controls). - const descriptionRegionId = useId(); - - // Show the toggle when the server supports task tool calls and either the - // connection is modern (task creation is server-directed there, so any tool - // may become a task) or the tool doesn't forbid per-tool task support - // (legacy). `required` tools are forced on (checked + disabled); `optional` - // and (on modern) any tool follow the user's `runAsTask` choice. - // - // NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY - // ignored. Under SEP-2663 task creation is decided by the server per request, - // not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does - // not gate the affordance — the server may return a task for any call. The - // toggle just declares intent to poll a returned handle. - const taskSupport = getTaskSupport(tool); - const showRunAsTask = - serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden"); - // Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required` - // value must not route through callToolStream when the toggle is hidden. On - // legacy, a tool's taskSupport is only considered when the server advertises - // `tasks.requests.tools.call`; on modern, the user's choice governs any tool. - const effectiveRunAsTask = - showRunAsTask && - (taskSupport === "required" || - ((taskSupport === "optional" || modernTasks) && runAsTask)); - - return ( - - - - {iconSrc && } - {resolveDisplayLabel(name, title)} - {description && ( - setDescriptionOpen((open) => !open)} - > - {descriptionOpen ? : } - - )} - - {hasAnyAnnotation(annotations) && annotations && ( - - {annotations.readOnlyHint && ( - - )} - {annotations.destructiveHint && ( - - )} - {annotations.idempotentHint && ( - - )} - {annotations.openWorldHint && ( - - )} - - )} - - - - - {description && ( - - {description} - - )} - - - - {mirroredParams.length > 0 && ( - - - Mirrored request headers (SEP-2243) - - {mirroredParams.map((param) => ( - - {param.path} - - {param.header} - - ))} - - These argument values are mirrored into HTTP headers on the - call. In the web client the Mcp-Param-* headers are - applied by the Node backend that issues the upstream request. - - - )} - - - - {progress && } - - - - {showRunAsTask && ( - - onRunAsTaskChange(event.currentTarget.checked)} - /> - - )} - - - - {isExecuting && Cancel} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx deleted file mode 100644 index d109d0961..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core"; -import type { Tool } from "@modelcontextprotocol/client"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; - -export interface ToolListItemProps { - tool: Tool; - selected: boolean; - onClick: () => void; -} - -const ItemLabel = Text.withProps({ - fw: 500, - truncate: true, -}); - -const ItemSubLabel = Text.withProps({ - size: "xs", - c: "dimmed", - truncate: true, -}); - -const ItemBody = Stack.withProps({ - gap: 2, - flex: 1, - miw: 0, -}); - -const Row = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const ToolIcon = Image.withProps({ - w: 20, - h: 20, - fit: "contain", -}); - -const ListItemButton = UnstyledButton.withProps({ - w: "100%", - p: "sm", - variant: "listItem", -}); - -export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { - const { name, title, icons } = tool; - const iconSrc = icons?.[0]?.src; - - return ( - - - {iconSrc && } - - {resolveDisplayLabel(name, title)} - {title && {name}} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx deleted file mode 100644 index 8c6ca8b0f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolCallErrorPanel.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - Alert, - CloseButton, - Code, - Group, - Stack, - Text, - Title, -} from "@mantine/core"; -import { classifyToolCallError } from "./toolResultUtils"; - -export interface ToolCallErrorPanelProps { - /** The thrown error's message (already stringified in App). */ - error: string; - /** - * The JSON-RPC error code, when the throw was a `ProtocolError`. Under SDK v2 - * an unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of - * resolving an `isError` result, so it arrives here as a thrown error rather - * than a `CallToolResult` (which the ToolResultPanel would render). The same - * `-32602` is also thrown for a known tool called with invalid arguments, so - * the heading/hint are chosen from the message, not the code alone. - */ - errorCode?: number; - /** Dismiss the error and return to the input form (mirrors ToolResultPanel). */ - onClear: () => void; -} - -// Mirrors ToolResultPanel's column so an error dismisses the same way a result -// does: header with the close X pins, the alert fills and scrolls below it. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, - flex: 1, -}); - -const HeaderRow = Group.withProps({ - gap: "xs", - wrap: "nowrap", - flex: "0 0 auto", -}); - -const HintText = Text.withProps({ - size: "sm", - c: "var(--inspector-text-secondary)", -}); - -// h3 (not h4), size h4: request modals open over the Tools screen with an `h2` -// `Modal.Title`, so an `h4` here would skip a level (axe `heading-order`); -// `size="h4"` keeps the visual size. -const PanelTitle = Title.withProps({ order: 3, size: "h4" }); - -const ErrorAlert = Alert.withProps({ color: "red", variant: "light" }); - -const ERROR_TITLES: Record = { - "unknown-tool": "Unknown Tool", - "invalid-params": "Invalid Parameters", - generic: "Tool Error", -}; - -/** - * Renders a thrown tool-call error (a protocol/SDK-level rejection) as a - * distinct error panel. This is separate from ToolResultPanel, which renders a - * `CallToolResult` (including a tool-level `isError` result). An `-32602` - * rejection carries no result, so it would otherwise be invisible. - */ -export function ToolCallErrorPanel({ - error, - errorCode, - onClear, -}: ToolCallErrorPanelProps) { - const kind = classifyToolCallError(errorCode, error); - return ( - - - - Tool Call Failed - - - - {error} - {kind === "unknown-tool" && ( - - The server rejected this call with -32602 (Invalid - params) — it does not recognize this tool. It may have been - excluded for an invalid x-mcp-header annotation or - removed since the list was last fetched. Try refreshing the tools - list. - - )} - {kind === "invalid-params" && ( - - The server rejected this call with -32602 (Invalid - params). Check the argument values against the tool's schema. - - )} - - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx deleted file mode 100644 index 33ca77617..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { - Alert, - CloseButton, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; -import type { - CallToolResult, - ReadResourceResult, -} from "@modelcontextprotocol/client"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { ResourceLink } from "../ResourceLink/ResourceLink"; -import { StructuredOutputPanel } from "../StructuredOutputPanel/StructuredOutputPanel"; -import { resultHasResourceLinks } from "./toolResultUtils"; - -export interface ToolResultPanelProps { - result: CallToolResult; - /** - * Dismiss the result and return to the input form (#1661). Mirrors the - * Prompts screen: the result replaces the form while present, and the - * top-left X flips back to the form so the tool can be re-run. - */ - onClear: () => void; - /** - * Read-on-demand handler so `resource_link` blocks in the result can fetch - * and inline their contents. - */ - onReadResource?: (uri: string) => Promise; -} - -type ContentBlock = CallToolResult["content"][number]; -type ResourceLinkBlock = Extract; - -// A result's content is rendered as a run of segments in original order: each -// non-link block on its own, and every maximal run of consecutive -// `resource_link` blocks collapsed into one grouped "Resource Links" box. -type ResultSegment = - | { kind: "links"; links: { block: ResourceLinkBlock; index: number }[] } - | { kind: "block"; block: ContentBlock; index: number }; - -// Walk the content array once, coalescing adjacent `resource_link` blocks into a -// single `links` segment so a run of links renders inside one scrollable box -// while preserving the overall block order. -function segmentContent(content: ContentBlock[]): ResultSegment[] { - const segments: ResultSegment[] = []; - content.forEach((block, index) => { - if (block.type === "resource_link") { - const last = segments[segments.length - 1]; - if (last && last.kind === "links") { - last.links.push({ block, index }); - } else { - segments.push({ kind: "links", links: [{ block, index }] }); - } - } else { - segments.push({ kind: "block", block, index }); - } - }); - return segments; -} - -// Outer column fills the (full-height) result card: the header pins -// (`flex: 0 0 auto`) and the body below fills the rest. `mih: 0` lets the flex -// children shrink below their content's intrinsic height. -const PanelStack = Stack.withProps({ - gap: "md", - miw: 0, - mih: 0, - flex: 1, -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - flex: "0 0 auto", -}); - -// Close button + title, mirroring PromptMessagesDisplay so the two result -// panels dismiss the same way. -const HeaderLeft = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -// Body scroll region for non-resource-link results: fills the card and scrolls -// within it when the content is taller than the available space. -const ResultScroll = ScrollArea.withProps({ - flex: 1, - miw: 0, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ResultStack = Stack.withProps({ - gap: "md", -}); - -// A non-link block that shares the card with a "Resource Links" box: capped at -// half the available height and scrollable within, so a long text block can't -// crowd the links box out of view (it keeps the remaining space). `Autosize` -// sizes to content up to the cap, so a short block still takes only what it -// needs. Without links, non-link blocks flow in the main scroll body instead. -const NonLinkCap = ScrollArea.Autosize.withProps({ - mah: "50%", - flex: "0 1 auto", - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -// Body column for results that contain a "Resource Links" box: it fills the -// card so the box (which is `flex: 1` within it) can grow to the available -// height and scroll internally, rather than capping at its content height. -const FillStack = Stack.withProps({ - gap: "md", - flex: 1, - mih: 0, -}); - -// Grouped container for a run of `resource_link` blocks — a bordered box with a -// pinned "Resource Links" heading and its own scroll region, mirroring the -// "Messages" box in the Protocol monitoring sidebar (ProtocolListPanel). The -// `panel` variant makes it a flex column (overflow hidden, min-height 0) and -// `flex: 1` lets it grow to fill the result card's available height. -const ResourceLinksBox = Paper.withProps({ - withBorder: true, - radius: "md", - p: "md", - variant: "panel", - flex: 1, - mih: 0, -}); - -const ResourceLinksInner = Stack.withProps({ - gap: "sm", - flex: 1, - mih: 0, -}); - -const ResourceLinksHeader = Title.withProps({ - // The panel "Results" title is h3 (size h4); this sub-box heading is h4 so the - // heading order doesn't skip a level (axe `heading-order`). - order: 4, - size: "h5", -}); - -// Fills the box below the pinned heading and scrolls the link list within it. -const ResourceLinksScroll = ScrollArea.withProps({ - flex: 1, - mih: 0, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const ResourceLinksStack = Stack.withProps({ - gap: "sm", -}); - -// h3 (not h4), size h4: request modals open over the Tools screen with an `h2` -// `Modal.Title`, so an `h4` here would skip a level (axe `heading-order`); -// `size="h4"` preserves the visual size. -const ResultsTitle = Title.withProps({ - order: 3, - size: "h4", -}); - -const ErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Tool Error", -}); - -function ResourceLinksGroup({ - links, - onReadResource, -}: { - links: { block: ResourceLinkBlock; index: number }[]; - onReadResource?: (uri: string) => Promise; -}) { - return ( - - - Resource Links - - - {links.map(({ block, index }) => ( - - ))} - - - - - ); -} - -export function ToolResultPanel({ - result, - onClear, - onReadResource, -}: ToolResultPanelProps) { - const segments = - result.isError || result.content.length === 0 - ? [] - : segmentContent(result.content); - // Results with a Resource Links box fill the card so the box grows to the - // available height (and scrolls inside). Plain text/image results keep the - // scroll-within-card body so a short result doesn't reserve empty height. - const hasLinks = resultHasResourceLinks(result); - // A tool with an `outputSchema` returns its real payload in - // `structuredContent`, which the `content[]` blocks typically only summarize - // (#1908). Render it as its own section — including alongside an error or an - // empty `content` array, so it is never silently dropped. - const structuredNode = result.structuredContent ? ( - - ) : null; - - const segmentNodes = segments.map((segment) => { - if (segment.kind === "links") { - return ( - - ); - } - const viewer = ( - - ); - // Alongside a Resource Links box, cap the block at half the height (and let - // it scroll); on its own it flows in the outer scroll body uncapped. - return hasLinks ? ( - {viewer} - ) : ( - viewer - ); - }); - - return ( - - - - - Results - - - {result.isError ? ( - - - - {result.content - .filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n")} - - {structuredNode} - - - ) : result.content.length === 0 && !structuredNode ? ( - - No results yet - - ) : hasLinks ? ( - - {segmentNodes} - {structuredNode} - - ) : ( - - - {segmentNodes} - {structuredNode} - - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts deleted file mode 100644 index b5f78c79f..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/ToolResultPanel/toolResultUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CallToolResult } from "@modelcontextprotocol/client"; -import { ProtocolErrorCode } from "@modelcontextprotocol/client"; - -/** How a thrown `tools/call` error should be presented in the error panel. */ -export type ToolCallErrorKind = "unknown-tool" | "invalid-params" | "generic"; - -/** - * Whether a `-32602` message names the *tool itself* as unrecognized, as - * opposed to reporting bad arguments for a known tool. Both reject with the same - * `-32602 Invalid params` code under SDK v2, so the code alone can't tell them - * apart — matching the message lets us pick the right heading instead of - * labelling every `-32602` "Unknown Tool" (which would mislabel a known tool - * called with invalid arguments). - * - * The match is deliberately tool-scoped so it doesn't INVERSELY mislabel: an - * argument-validation message like `"property 'region' does not exist"` must - * NOT read as "Unknown Tool". So the "not found / does not exist / unknown / - * unrecognized" family only counts when the word "tool" is in the same clause - * (the SDK's own message is `Tool not found`); `unknown tool` / `no such - * tool` are unambiguous on their own. Case-insensitive; best-effort — the fully - * unambiguous signal would be a tool name in `error.data`, which the SDK does - * not currently surface here. - */ -const UNKNOWN_TOOL_MESSAGE = - /\b(unknown tool|no such tool)\b|\btool\b[^.!?]*\b(not found|not recognized|does not exist|is unknown|unrecognized)\b/i; - -/** - * Classify a thrown tool-call error for display (#1632). Under SDK v2 an - * unknown-tool `tools/call` REJECTS with `-32602 Invalid params` instead of - * resolving an `isError` result — but so does a *known* tool called with - * invalid arguments (server-side schema validation). We narrow the ambiguous - * `-32602` to `"unknown-tool"` only when the message says so; any other - * `-32602` is `"invalid-params"`, and every other code is `"generic"`. - */ -export function classifyToolCallError( - errorCode?: number, - message?: string, -): ToolCallErrorKind { - if (errorCode !== ProtocolErrorCode.InvalidParams) return "generic"; - if (message && UNKNOWN_TOOL_MESSAGE.test(message)) return "unknown-tool"; - return "invalid-params"; -} - -/** - * Whether a result renders a "Resource Links" box — i.e. a non-error result - * with at least one `resource_link` block. Hosts use this to decide whether the - * result surface should fill the available height (so the box can grow and - * scroll internally); plain text/image results keep their content-sized card. - */ -export function resultHasResourceLinks(result: CallToolResult): boolean { - return ( - !result.isError && - result.content.some((block) => block.type === "resource_link") - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts deleted file mode 100644 index 845f39214..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/groups/protocolUtils.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js"; -import { - isInputRequiredResult, - SUBSCRIPTION_ID_META_KEY, -} from "@modelcontextprotocol/client"; - -export function extractMethod(entry: MessageEntry): MessageMethod { - if ("method" in entry.message) { - // Cast: SDK types message.method as `string`, but every entry in this - // app's MessageEntry log originates from MCP SDK schemas. - return entry.message.method as MessageMethod; - } - return "response"; -} - -/** - * Request methods the Protocol Replay action can re-issue (client→server reads - * and calls). Server→client requests (roots/list, sampling, elicitation) and - * side-effectful methods (logging/setLevel, subscribe) are intentionally - * excluded. Single source of truth: `ProtocolEntry` hides the Replay button for - * anything not listed here, and App's `replayProtocolRequest` gates dispatch on - * the same set. - */ -export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet = new Set([ - "tools/call", - "prompts/get", - "resources/read", - "tools/list", - "prompts/list", - "resources/list", - "resources/templates/list", - "tasks/list", - "ping", -]); - -export function isReplayableProtocolMethod(method: string): boolean { - return REPLAYABLE_PROTOCOL_METHODS.has(method); -} - -// --- Modern-era (2026-07-28) message vocabulary ----------------------------- -// -// The modern era changes the over-the-wire conversation (spec §7.2–7.4): every -// result carries a `resultType`, server→client interactions become MRTR (the -// server *returns* `input_required` and the client *retries* with a new id), -// and push notifications move to a `subscriptions/listen` stream. These helpers -// read that vocabulary off the transport-level `MessageEntry` log so the -// Protocol view can render and correlate it. IMPORTANT: they classify individual -// *frames* — they must NOT be used to decide a connection's era. The modern -// probe carries the `_meta` envelope before the era is negotiated, so "saw an -// envelope/resultType" ≠ "modern negotiated" (spec §8.3). Era labeling comes -// from the negotiated `protocolEra` connection state, threaded in as a prop. - -// The successful `result` object of a request entry's paired response, or -// undefined when there is no response or it was an error. (messageLogState folds -// a request's response onto the request entry by JSON-RPC id.) -function getResponseResult( - entry: MessageEntry, -): Record | undefined { - const response = entry.response; - if (!response || "error" in response) return undefined; - const result = response.result; - return result && typeof result === "object" - ? (result as Record) - : undefined; -} - -function getMessageParams( - entry: MessageEntry, -): Record | undefined { - const msg = entry.message; - if (!("params" in msg) || !msg.params) return undefined; - return msg.params as Record; -} - -/** - * The modern `resultType` discriminator on a request's paired result: - * `"input_required"` (the server needs input before it can complete) or - * `"complete"`. Undefined for legacy results (no `resultType` on the wire), - * errors, notifications, and pending requests — so a `resultType` badge only - * shows where the modern era actually put one. - */ -export function extractResultType( - entry: MessageEntry, -): "complete" | "input_required" | undefined { - const result = getResponseResult(entry); - if (!result) return undefined; - if (isInputRequiredResult(result)) return "input_required"; - return result.resultType === "complete" ? "complete" : undefined; -} - -/** - * The opaque MRTR `requestState` token that links the rounds of one logical - * operation across multiple JSON-RPC ids. It appears on the `input_required` - * *result* (original call) and is echoed back in the *params* of the retried - * request (spec §7.3). Returns undefined for non-MRTR traffic. - */ -export function extractRequestState(entry: MessageEntry): string | undefined { - const result = getResponseResult(entry); - const fromResult = result?.requestState; - if (typeof fromResult === "string" && fromResult.length > 0) - return fromResult; - const params = getMessageParams(entry); - const fromParams = params?.requestState; - if (typeof fromParams === "string" && fromParams.length > 0) - return fromParams; - return undefined; -} - -/** - * The `subscriptionId` a modern push notification is tagged with, carried in - * `params._meta` under `io.modelcontextprotocol/subscriptionId` (spec §7.4). - * Undefined for untagged frames. - */ -export function extractSubscriptionId(entry: MessageEntry): string | undefined { - const params = getMessageParams(entry); - const meta = params?._meta as Record | undefined; - const id = meta?.[SUBSCRIPTION_ID_META_KEY]; - return typeof id === "string" ? id : undefined; -} - -/** - * A rendered row in the Protocol list: either a single message entry, or an - * MRTR conversation — the contiguous run of entries sharing one `requestState` - * (original call → `input_required` → retried call → final result), grouped so - * one logical operation renders as one expandable unit. - */ -export type ProtocolRow = - | { kind: "single"; entry: MessageEntry } - | { kind: "mrtr"; requestState: string; rounds: MessageEntry[] }; - -/** - * Fold a (already filtered/sorted) entry list into rows, clustering contiguous - * entries that share a non-empty `requestState` into one MRTR row. Contiguity is - * safe because the SDK auto-fulfils MRTR input in-process (no intervening wire - * frames) so an operation's rounds are adjacent in the log. Order is preserved; - * everything without a `requestState` stays a `single` row. - */ -export function groupProtocolEntries(entries: MessageEntry[]): ProtocolRow[] { - const rows: ProtocolRow[] = []; - for (const entry of entries) { - const requestState = extractRequestState(entry); - if (requestState) { - const last = rows[rows.length - 1]; - if (last?.kind === "mrtr" && last.requestState === requestState) { - last.rounds.push(entry); - continue; - } - rows.push({ kind: "mrtr", requestState, rounds: [entry] }); - continue; - } - rows.push({ kind: "single", entry }); - } - return rows; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx deleted file mode 100644 index deaecd374..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ /dev/null @@ -1,795 +0,0 @@ -import { useCallback, useEffect, useRef, useState, type Ref } from "react"; -import { - ActionIcon, - Button, - Card, - Code, - Collapse, - Flex, - Group, - Image, - Paper, - ScrollArea, - Stack, - Text, - Title, - Tooltip, -} from "@mantine/core"; -import { - MdArrowBack, - MdClose, - MdFullscreen, - MdFullscreenExit, -} from "react-icons/md"; -import type { - ContentBlock, - LoggingMessageNotification, - Tool, -} from "@modelcontextprotocol/client"; -import type { - AppBridgeEventMap, - McpUiDisplayMode, -} from "@modelcontextprotocol/ext-apps/app-bridge"; -import { - AppRenderer, - type AppRendererHandle, - type AppRendererStatus, - type BridgeFactory, -} from "../../elements/AppRenderer/AppRenderer"; -import { HOST_AVAILABLE_DISPLAY_MODES } from "../../elements/AppRenderer/createAppBridgeFactory"; -import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel"; -import { AppControls } from "../../groups/AppControls/AppControls"; -import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { LogLevelBadge } from "../../elements/LogLevelBadge/LogLevelBadge"; -import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; -import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; - -export interface AppsScreenProps { - tools: Tool[]; - listChanged: boolean; - /** - * URL of the inspector's sandbox proxy page (the trusted outer iframe). When - * undefined, MCP Apps cannot run (legacy backend, or a build without the - * sandbox controller) and the screen renders an unavailable state instead of - * a silently blank iframe. - */ - sandboxPath?: string; - bridgeFactory: BridgeFactory; - rendererRef: Ref; - ui: AppsUiState; - onUiChange: (next: AppsUiState) => void; - onRefreshList: () => void; - onSelectApp: (name: string) => void; - onOpenApp: (name: string, args: Record) => void; - onCloseApp: () => void; - /** Surfaces bridge/runtime failures from the renderer (e.g. no client). */ - onError?: (err: Error) => void; - /** - * Deep-link auto-open (#1577): when true and an app is already pre-selected - * (the parent seeds `ui.selectedAppName` + `ui.formValues` from the URL), - * the screen fires "Open App" automatically — no explicit click. Token-gated - * upstream in `parseDeepLink` (the URL value must equal the session token), - * so a third-party link cannot auto-invoke a tool. Fires exactly once. - */ - autoOpen?: boolean; -} - -// Selected app, its form values, and the sidebar search — controlled by the -// parent (App) as one object so they persist across tab navigation within a -// live session (#1417). `running`/`maximized` stay local to the screen: they're -// tied to the live iframe and bridge, which are torn down on unmount, so -// persisting them would restore a flag without its runtime. On return the -// selected app's input form (with its values) is shown, ready to re-open. -export interface AppsUiState { - selectedAppName?: string; - formValues: Record; - search: string; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", - align: "flex-start", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// `variant="preview"` (overflow: hidden) keeps the full-height card from -// bleeding past the viewport: the running app's iframe fills it, and the -// app-input form scrolls internally (see AppDetailPanel's PanelScroll). -// `flex: 1` + `h: "100%"` make it fill the screen column (both call sites do). -const ContentCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", - flex: 1, - h: "100%", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "sm", -}); - -const HeaderLabel = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - flex: 1, - miw: 0, -}); - -const HeaderIcon = Image.withProps({ - w: 24, - h: 24, - fit: "contain", -}); - -const HeaderTitle = Text.withProps({ - fw: 600, - size: "lg", - truncate: true, - flex: 1, - miw: 0, -}); - -const HeaderActions = Group.withProps({ - gap: "xs", - wrap: "nowrap", -}); - -const BackToInputButton = Button.withProps({ - variant: "subtle", - size: "sm", - leftSection: , -}); - -const CloseIconButton = ActionIcon.withProps({ - variant: "subtle", - "aria-label": "Close", -}); - -// The host-controlled box the running app sits within. Its size is driven by -// the host's layout (window resize, sidebar toggle, maximize) and NOT by the -// view's reported content height — that drives the inner RendererFrame — so the -// renderer's containerDimensions observer can measure this element without -// coupling host→view container size to view→host size-changed. -const RendererContainer = Stack.withProps({ - flex: 1, - miw: 0, - mih: 0, - gap: 0, -}); - -// The inner box that actually holds the iframe. Sized by the view-reported -// content height (see `contentHeight`) and capped at the outer container. -// Distinct from RendererContainer above so the two roles read clearly in JSX. -const RendererFrame = Stack.withProps({ - miw: 0, - mih: 0, - gap: 0, -}); - -const ContentStack = Stack.withProps({ - gap: "md", - h: "100%", -}); - -// Pinned panel below the running app (used by both the message log and the -// app-log panel). `0 0 auto` keeps it at its content height (capped by the -// inner scroll's `mah`) so it never squeezes out the iframe above it. -const PinnedPanel = Stack.withProps({ - gap: "xs", - flex: "0 0 auto", - mih: 0, -}); - -const LogScroll = ScrollArea.withProps({ - mah: 200, - type: "auto", - scrollbars: "y", - offsetScrollbars: true, -}); - -const MessageLogStack = Stack.withProps({ - gap: "sm", -}); - -const MessageItem = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, -}); - -const MessageItemStack = Stack.withProps({ - gap: "xs", -}); - -const MonoCaption = Text.withProps({ - size: "xs", - c: "dimmed", - ff: "monospace", -}); - -const AppLogList = Stack.withProps({ - gap: "xs", -}); - -const AppLogRow = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "flex-start", -}); - -const AppLogData = Code.withProps({ - block: true, - fz: "xs", -}); - -const CompactSubtleButton = Button.withProps({ - variant: "subtle", - size: "compact-xs", -}); - -const PanelHeaderRow = Group.withProps({ - justify: "space-between", - wrap: "nowrap", - gap: "sm", -}); - -const PartialStageControls = Group.withProps({ - gap: "sm", - wrap: "nowrap", - align: "center", - flex: "0 0 auto", -}); - -const StagePartialButton = Button.withProps({ - variant: "default", - size: "compact-xs", -}); - -const PartialStageCount = Text.withProps({ - size: "xs", - c: "dimmed", -}); - -const AppErrorPanel = Paper.withProps({ - p: "md", - radius: "md", - withBorder: true, - c: "var(--inspector-log-error)", -}); - -const AppErrorTitle = Text.withProps({ - fw: 600, - size: "sm", -}); - -const AppErrorMessage = Text.withProps({ - size: "sm", - ff: "monospace", -}); - -/** Render a log payload as a string for display. */ -function formatLogData(data: unknown): string { - if (typeof data === "string") return data; - try { - // JSON.stringify(undefined) returns the value `undefined`, not a string, so - // coalesce to "" to keep the `: string` return type honest for a data-less - // log (spec-required, so this is only defensive against a malformed view). - return JSON.stringify(data) ?? ""; - } catch { - /* v8 ignore next -- JSON.stringify only throws on a BigInt or a circular - structure; a log payload delivered over postMessage is already - structured-clone-safe, so this fallback is unreachable in practice. */ - return String(data); - } -} - -/** - * Soft cap on retained message / log entries per run. Chatty widgets can emit - * logs in a loop; keep only the most recent so the panels (and their DOM rows) - * don't grow without bound between Clear/close. Oldest entries are dropped. - */ -const MAX_APP_CHANNEL_ENTRIES = 500; - -/** Append to a capped list, dropping the oldest entries past the cap. */ -function appendCapped(prev: T[], next: T): T[] { - const grown = [...prev, next]; - return grown.length > MAX_APP_CHANNEL_ENTRIES - ? grown.slice(grown.length - MAX_APP_CHANNEL_ENTRIES) - : grown; -} - -// A user-role message submitted by the running view through ui/message. The -// inspector has no conversation to append to, so it just records the content -// blocks for display. `role`/`content` mirror McpUiMessageRequest["params"]; -// `id` is a stable React key (like AppLogEntry) so the appendCapped front-drop -// can't renumber keys the way an array index would. -interface AppMessage { - id: number; - role: "user"; - content: ContentBlock[]; -} - -/** - * One MCP `notifications/message` log entry from the running app, with the - * payload stringified once at capture time so the render path can use it - * directly. `id` is a stable React key. - */ -interface AppLogEntry { - id: number; - level: LoggingMessageNotification["params"]["level"]; - logger?: string; - text: string; -} - -export function AppsScreen({ - tools, - listChanged, - sandboxPath, - bridgeFactory, - rendererRef, - ui, - onUiChange, - onRefreshList, - onSelectApp, - onOpenApp, - onCloseApp, - onError, - autoOpen = false, -}: AppsScreenProps) { - const { selectedAppName, formValues, search } = ui; - const [running, setRunning] = useState(false); - const [maximized, setMaximized] = useState(false); - const rendererContainerRef = useRef(null); - const nextLogIdRef = useRef(0); - const nextMessageIdRef = useRef(0); - // Height (px) the running view last reported via ui/notifications/size-changed. - // Undefined until the view reports (or after it's torn down), in which case - // the iframe fills the available card space as before. Local to the screen - // like `running`/`maximized`: it's tied to the live iframe, not persisted. - const [appHeight, setAppHeight] = useState(undefined); - // Messages the running view has pushed via ui/message. The inspector has no - // chat loop, so they're collected here and shown in a log below the app - // rather than continuing a conversation. Local to the screen like `running`: - // tied to the live bridge, cleared when the open ends or the app changes. - const [messages, setMessages] = useState([]); - // Standard MCP log notifications (notifications/message) the running app - // emits. The host advertises the `logging` capability; without surfacing - // these here they'd be silently dropped by the bridge. Same lifecycle as - // `messages`: tied to the live bridge, cleared on open/close/switch. - const [appLogs, setAppLogs] = useState([]); - // Expanded by default so a widget developer sees the entries without an extra - // click. The user can still collapse it for the rest of the run. - const [appLogsExpanded, setAppLogsExpanded] = useState(true); - // Snapshots of the input form captured via "Stage partial input". On Open - // App they're passed to AppRenderer as `partialInputs` and replayed via - // ui/notifications/tool-input-partial before the complete tool-input, so a - // widget's progressive-render path can be exercised. Cleared on switch/close. - const [partialStages, setPartialStages] = useState[]>( - [], - ); - // High-level renderer lifecycle, surfaced as `data-app-status` on the - // `apps-form` card so an automated driver can poll - // `[data-app-status="ready"]` instead of racing the iframe selector. - const [appStatus, setAppStatus] = useState( - "idle", - ); - // The error that put the renderer into status="error" (factory throw, no - // connected client). Shown in place of the blank iframe and surfaced as - // `data-app-error` on the `apps-form` card so an automated driver can read - // *why* the open failed without screenshotting a toast. - const [appError, setAppError] = useState(undefined); - - const selectedTool = selectedAppName - ? tools.find((t) => t.name === selectedAppName) - : undefined; - const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false; - - // The running view reports its rendered content height via - // ui/notifications/size-changed; honor it so the iframe is neither clipped - // nor surrounded by dead space. Width is left at the host-controlled - // container width. The value is clamped to the available space by the - // renderer frame's `mah` below, and ignored while maximized (the app fills - // the screen instead). A non-positive height is ignored — a view's - // ResizeObserver can transiently fire 0 before layout settles or during - // teardown, which would otherwise collapse the frame (mirrors AppRenderer's - // own 0×0 skip on the container side). - function handleSizeChange(size: AppBridgeEventMap["sizechange"]) { - if (size.height != null && size.height > 0) setAppHeight(size.height); - } - - function handleMessage(params: Omit) { - setMessages((prev) => - appendCapped(prev, { id: nextMessageIdRef.current++, ...params }), - ); - } - - function handleLog(params: LoggingMessageNotification["params"]) { - setAppLogs((prev) => - appendCapped(prev, { - id: nextLogIdRef.current++, - level: params.level, - logger: params.logger, - text: formatLogData(params.data), - }), - ); - } - - // Clear the message + log panels (and the reported height). Called when a run - // ends or the selected app changes so a new run starts clean. `keepPartials` - // is set for `handleOpen`, where the staged fragments are about to be consumed - // by the renderer and must not be cleared first. Memoized (stable setState - // calls only) so the deep-link auto-open effect's deps don't churn. - const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => { - setAppHeight(undefined); - setMessages([]); - setAppLogs([]); - setAppLogsExpanded(true); - setAppStatus("idle"); - setAppError(undefined); - if (!opts?.keepPartials) setPartialStages([]); - }, []); - - // Capture the error locally (so it can be shown in the card and surfaced as - // `data-app-error`) and forward it to the parent's onError. The renderer - // already drives `data-app-status="error"` via onAppStatusChange; this adds - // the *reason* alongside it. - function handleAppError(err: Error) { - setAppError(err); - onError?.(err); - } - - function handleStagePartialInput() { - setPartialStages((prev) => [...prev, { ...formValues }]); - } - - // The app's display mode is derived from the existing maximized toggle. - // Passed to AppRenderer so the running view receives it via - // host-context-changed; the Maximize/Restore button below keeps toggling - // `maximized`, which now flows out as a protocol event. - const displayMode: McpUiDisplayMode = maximized ? "fullscreen" : "inline"; - - // Handle a view-originated ui/request-display-mode. Only modes the inspector - // advertises in `availableDisplayModes` are honored — an unsupported request - // (e.g. "pip") is declined by returning the current mode, per spec. - function handleRequestDisplayMode( - requested: McpUiDisplayMode, - ): McpUiDisplayMode { - if (!HOST_AVAILABLE_DISPLAY_MODES.includes(requested)) return displayMode; - setMaximized(requested === "fullscreen"); - return requested; - } - - function handleSelect(name: string) { - if (name === selectedAppName) return; - const next = tools.find((t) => t.name === name); - if (!next) return; - // Seed schema defaults so default-only fields are sent on Open App (parity - // with the form's resolveValue display, which onChange doesn't capture). - onUiChange({ - ...ui, - selectedAppName: name, - formValues: collectSchemaDefaults(toFormSchema(next.inputSchema) ?? {}), - }); - setMaximized(false); - resetAppChannels(); - onSelectApp(name); - // No-input apps auto-launch on selection so the user lands directly in - // the running view; apps with fields wait for the explicit Open App click. - if (!hasInputFields(next)) { - setRunning(true); - onOpenApp(name, {}); - } else { - setRunning(false); - } - } - - function handleOpen() { - if (!selectedTool) return; - // `keepPartials` preserves the staged fragments: AppRenderer snapshots them - // into its own pendingPartialsRef at bridge-build time and replays them. - // The `partialStages` state is intentionally NOT cleared here — the staging - // UI only renders while not running, so the surviving state is invisible - // until the next select/close/back reset drains it. - resetAppChannels({ keepPartials: true }); - setRunning(true); - onOpenApp(selectedTool.name, formValues); - } - - function handleClose() { - setRunning(false); - onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); - setMaximized(false); - resetAppChannels(); - onCloseApp(); - } - - // Deep-link auto-open (#1577): the parent seeds `ui.selectedAppName` + - // `ui.formValues` from the URL and sets `autoOpen`; fire "Open App" here so - // the driver lands on a rendered widget with zero clicks. Ref-guarded to fire - // exactly once — a later manual close leaves `running` false without - // re-triggering. The open (running + channel resets + `onOpenApp`) is - // deferred one microtask past the synchronous effect body: it calls setState, - // which the set-state-in-effect lint rightly flags in general, but this is a - // ref-guarded run-once effect so the cascading-render concern doesn't apply - // (same pattern as App.tsx's deep-link connect effect). `resetAppChannels` - // changes identity each render, so the effect re-runs, but the ref guard - // makes every run after the first a no-op. - const autoOpenFiredRef = useRef(false); - useEffect(() => { - if (!autoOpen || autoOpenFiredRef.current) return; - if (!selectedTool || running) return; - autoOpenFiredRef.current = true; - void Promise.resolve().then(() => { - resetAppChannels({ keepPartials: true }); - setRunning(true); - onOpenApp(selectedTool.name, formValues); - }); - }, [ - autoOpen, - selectedTool, - running, - formValues, - onOpenApp, - resetAppChannels, - ]); - - function handleBackToInput() { - setRunning(false); - setMaximized(false); - resetAppChannels(); - } - - // No sandbox proxy URL means the host can't embed the trusted outer iframe - // the double-iframe sandbox depends on — surface that plainly instead of - // mounting an iframe that would render blank. - if (!sandboxPath) { - return ( - - - - MCP Apps are unavailable — the sandbox could not be reached. - - - - ); - } - - // While maximized the app fills the screen, so the view-reported height is - // ignored; otherwise we honor it (clamped to the card by the frame's `mah`). - // `appHeight` is intentionally NOT cleared when toggling maximize: carrying - // the last inline height across a maximize→restore means the frame restores - // at its prior size immediately, rather than flashing to full-card height - // (flex:1) for the frame or two until the view sends a fresh size-changed - // after the `inline` host-context-changed. - const contentHeight = maximized ? undefined : appHeight; - - return ( - - {!maximized && ( - - - onUiChange({ ...ui, search: value })} - onSelectApp={handleSelect} - /> - - - )} - - - {selectedTool ? ( - - - - {selectedTool.icons?.[0]?.src && ( - - )} - - {resolveDisplayLabel(selectedTool.name, selectedTool.title)} - - - - {running && selectedHasFields && ( - - Back to Input - - )} - {running && ( - - setMaximized((m) => !m)} - aria-label={maximized ? "Restore" : "Maximize"} - > - {maximized ? ( - - ) : ( - - )} - - - )} - - - - - - - - {running ? ( - // RendererContainer is the host-controlled box (its size only - // changes with host layout); the inner RendererFrame is sized by - // the view's reported content height, capped at the container. - - - {/* Keying by name forces the renderer to remount when the - selected app changes, ensuring a fresh bridge and iframe - rather than reusing the previous app's transport. */} - - - {/* Shown BELOW the frame on a factory throw/reject so the reason - is visible alongside the (blank) iframe rather than leaving a - silent blank frame. The renderer stays mounted so an in-place - retry path remains possible. */} - {appError && ( - - App failed to load - {appError.message} - - )} - - ) : ( - // `isOpening` is always false here because `handleOpen` - // synchronously flips `running` to true, swapping in the - // AppRenderer before the panel could render its loading - // state. The prop stays in `AppDetailPanel`'s API for - // standalone use (the `Opening` story) and for Phase 3 - // wiring, where a managed-state hook can hold the panel - // in a pending state across an awaited `tools/call`. - <> - {selectedHasFields && ( - - - Stage partial input - - {partialStages.length > 0 && ( - <> - - {partialStages.length} staged - - setPartialStages([])} - > - Clear staged - - - )} - - )} - - onUiChange({ ...ui, formValues: values }) - } - onOpenApp={handleOpen} - /> - - )} - {running && messages.length > 0 && ( - - Messages from app ({messages.length}) - - - {messages.map((message, index) => ( - - - - [{index}] role: {message.role} - - {message.content.map((block, blockIndex) => ( - - ))} - - - ))} - - - - )} - {running && appLogs.length > 0 && ( - - - setAppLogsExpanded((e) => !e)} - aria-expanded={appLogsExpanded} - aria-controls="apps-logs-region" - > - App logs ({appLogs.length}) - - setAppLogs([])}> - Clear - - - - - - {appLogs.map((entry) => ( - - - {entry.logger && ( - {entry.logger} - )} - {entry.text} - - ))} - - - - - )} - - ) : ( - Select an app to view details - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx deleted file mode 100644 index a02c9b1b2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Card, Flex, Stack } from "@mantine/core"; -import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client"; -import { LogControls } from "../../groups/LogControls/LogControls"; -import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel"; -import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; -import { ALL_LEVELS_VISIBLE, NO_LEVELS_VISIBLE } from "./logLevels"; - -export interface LoggingScreenProps { - entries: LogEntryData[]; - currentLevel: LoggingLevel; - ui: LogsUiState; - onUiChange: (next: LogsUiState) => void; - onSetLevel: (level: LoggingLevel) => void; - /** - * Negotiated protocol era (#1629). On the modern era the level selector is - * replaced by the per-request opt-in control; legacy keeps `logging/setLevel`. - */ - protocolEra?: ProtocolEra; - /** Modern per-request log level currently stamped, or `null` when opted out. */ - modernLogLevel?: LoggingLevel | null; - /** Set (or clear, with `null`) the modern per-request log level. */ - onSetModernLogLevel?: (level: LoggingLevel | null) => void; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - /** - * True when rendered inside the monitoring sidebar: the screen fills its - * parent's height (instead of the viewport calc) and drops the filter - * sidebar so the narrow column is stream-only. - */ - embedded?: boolean; -} - -// Filter text + visible-level set — controlled by the parent (App) as one -// object so they persist across tab navigation within a live session (#1417). -export interface LogsUiState { - filterText: string; - visibleLevels: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function LoggingScreen({ - entries, - currentLevel, - ui, - onUiChange, - onSetLevel, - protocolEra, - modernLogLevel = null, - onSetModernLogLevel, - onClear, - onExport, - sortDirection, - onSortChange, - embedded = false, -}: LoggingScreenProps) { - const { filterText, visibleLevels } = ui; - - function handleToggleLevel(level: LoggingLevel, visible: boolean) { - onUiChange({ - ...ui, - visibleLevels: { ...visibleLevels, [level]: visible }, - }); - } - - function handleToggleAllLevels() { - const allSelected = Object.values(visibleLevels).every(Boolean); - onUiChange({ - ...ui, - visibleLevels: allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE, - }); - } - - return ( - // Embedded fills the monitoring sidebar column (100%); standalone keeps the - // ScreenLayout's default full-screen height. Passing `h={undefined}` here - // would clobber that default (withProps plain-spreads), collapsing an empty - // screen to its controls' height — so only override `h` when embedded. - // Embedded also halves the top padding (`pt: md` vs the `xl` default) so the - // panel sits closer to the sidebar's tab/search controls above it. - - {embedded ? null : ( - - - - onUiChange({ ...ui, filterText: value }) - } - onToggleLevel={handleToggleLevel} - onToggleAllLevels={handleToggleAllLevels} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts deleted file mode 100644 index 3dfc758dc..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/LoggingScreen/logLevels.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LoggingLevel } from "@modelcontextprotocol/client"; - -// Default visible-level filter: every level on. Shared by LoggingScreen (its -// fallback when the parent hasn't set `visibleLevels`) and App, which seeds and -// resets the lifted filter state from it (#1417). Lives in its own module so -// the screen file only exports a component (react-refresh constraint). -export const ALL_LEVELS_VISIBLE: Record = { - debug: true, - info: true, - notice: true, - warning: true, - error: true, - critical: true, - alert: true, - emergency: true, -}; - -export const NO_LEVELS_VISIBLE: Record = { - debug: false, - info: false, - notice: false, - warning: false, - error: false, - critical: false, - alert: false, - emergency: false, -}; diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx deleted file mode 100644 index 43c2affdf..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Card, Flex, Stack } from "@mantine/core"; -import type { - FetchRequestCategory, - FetchRequestEntry, -} from "@inspector/core/mcp/types.js"; -import { NetworkControls } from "../../groups/NetworkControls/NetworkControls"; -import { NetworkStreamPanel } from "../../groups/NetworkStreamPanel/NetworkStreamPanel"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; -import { - ALL_CATEGORIES_VISIBLE, - NO_CATEGORIES_VISIBLE, -} from "./fetchCategories"; - -export interface NetworkScreenProps { - entries: FetchRequestEntry[]; - ui: NetworkUiState; - onUiChange: (next: NetworkUiState) => void; - onClear: () => void; - onExport: () => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ - embedded?: boolean; - /** "Reveal in Network" target (a fetch-entry id) + its one-shot clear. */ - revealId?: string; - onRevealComplete?: () => void; -} - -// Filter text + visible-category set — controlled by the parent (App) as one -// object so they persist across tab navigation within a live session (#1417). -export interface NetworkUiState { - filterText: string; - visibleCategories: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function NetworkScreen({ - entries, - ui, - onUiChange, - onClear, - onExport, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - revealId, - onRevealComplete, -}: NetworkScreenProps) { - const { filterText, visibleCategories } = ui; - - function handleToggleCategory( - category: FetchRequestCategory, - visible: boolean, - ) { - onUiChange({ - ...ui, - visibleCategories: { ...visibleCategories, [category]: visible }, - }); - } - - function handleToggleAllCategories() { - const allSelected = Object.values(visibleCategories).every(Boolean); - onUiChange({ - ...ui, - visibleCategories: allSelected - ? NO_CATEGORIES_VISIBLE - : ALL_CATEGORIES_VISIBLE, - }); - } - - return ( - // See LoggingScreen: only override `h` when embedded, so the standalone - // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` - // would clobber it and collapse an empty screen to its controls' height). - - {embedded ? null : ( - - - - onUiChange({ ...ui, filterText: value }) - } - onToggleCategory={handleToggleCategory} - onToggleAllCategories={handleToggleAllCategories} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts deleted file mode 100644 index 86a80c7f8..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/NetworkScreen/fetchCategories.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; - -// Default visible-category filter: every category on. Shared by NetworkScreen -// (its fallback when the parent hasn't set `visibleCategories`) and App, which -// seeds and resets the lifted filter state from it (#1417). Lives in its own -// module so the screen file only exports a component (react-refresh constraint). -export const ALL_CATEGORIES_VISIBLE: Record = { - auth: true, - transport: true, -}; - -export const NO_CATEGORIES_VISIBLE: Record = { - auth: false, - transport: false, -}; diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx deleted file mode 100644 index c599ed477..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { - Alert, - Card, - CloseButton, - Flex, - Group, - Loader, - Stack, - Text, -} from "@mantine/core"; -import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client"; -import { PromptControls } from "../../groups/PromptControls/PromptControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { PromptArgumentsForm } from "../../groups/PromptArgumentsForm/PromptArgumentsForm"; -import { PromptMessagesDisplay } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay"; - -export interface GetPromptState { - status: "idle" | "pending" | "ok" | "error"; - result?: GetPromptResult; - error?: string; - /** - * Name of the prompt the in-flight / latest result is for. Used to - * route the result panel only to the matching sidebar selection. - */ - promptName?: string; -} - -export interface PromptsScreenProps { - prompts: Prompt[]; - getPromptState?: GetPromptState; - ui: PromptsUiState; - listChanged: boolean; - completionsSupported?: boolean; - onUiChange: (next: PromptsUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onGetPrompt: (name: string, args: Record) => void; - onCopyMessages?: () => void; - onCompleteArgument?: ( - ref: - | { type: "ref/resource"; uri: string } - | { type: "ref/prompt"; name: string }, - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; -} - -// Selection, argument values, the "submitted" marker, and the sidebar search — -// controlled by the parent (App) as one object so they persist across tab -// navigation within a live session (#1417). -export interface PromptsUiState { - selectedPromptName?: string; - argumentValues: Record; - submittedFor?: string; - search: string; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// `sidebar` variant makes the card a full-height flex column capped at the -// screen height, so PromptControls' list fills the card and scrolls internally -// once it overflows (matching the Resources sidebar). -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// Sized-to-content card with overflow handling. When the inner content -// fits, the card hugs it. When it doesn't, the inner ScrollArea inside -// PromptMessagesDisplay shrinks (flex 0 1 auto, mih 0) and scrolls. -const PreviewCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Column wrapper that pins the card to the top of the available space -// and bounds its growth via the consumer-set `mah`. -const PreviewPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -// Centered loader/status column shown while a prompt is being fetched. -const CenteredStatus = Stack.withProps({ - align: "center", - py: "xl", -}); - -const PromptErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Prompt Error", -}); - -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -function hasArguments(prompt: Prompt): boolean { - return !!prompt.arguments && prompt.arguments.length > 0; -} - -export function PromptsScreen({ - prompts, - getPromptState, - ui, - listChanged, - completionsSupported, - onUiChange, - onRefreshList, - loadError, - pagination, - onGetPrompt, - onCopyMessages, - onCompleteArgument, -}: PromptsScreenProps) { - const { selectedPromptName, argumentValues, submittedFor, search } = ui; - const selectedPrompt = selectedPromptName - ? prompts.find((p) => p.name === selectedPromptName) - : undefined; - - function handleSelectPrompt(name: string) { - // Re-clicking the active prompt in the sidebar shouldn't wipe the - // user's typed argument values or trigger a re-fetch — sidebar is - // for navigation, ✕ is for dismiss. Closing-then-reselecting is - // its own thing (the close handler clears submittedFor). PromptControls - // already swallows a re-click on the active item (it only fires - // onSelectPrompt when the name differs), so this is a redundant guard - // that never fires through the UI. - /* v8 ignore next -- unreachable: PromptControls never re-emits the active name */ - if (name === selectedPromptName) return; - // Auto-fetch no-argument prompts the moment they're selected — the - // form pane would otherwise just render a bare Get Prompt button - // with nothing to fill in. Prompts with arguments wait for submit. - const target = prompts.find((p) => p.name === name); - const autoFetch = !!target && !hasArguments(target); - onUiChange({ - ...ui, - argumentValues: {}, - selectedPromptName: name, - submittedFor: autoFetch ? name : undefined, - }); - if (autoFetch) onGetPrompt(name, {}); - } - - function handleSubmit() { - // Defensive guard: handleSubmit is only wired to the argument form's - // onGetPrompt, which renders solely when `selectedPrompt` is truthy, so - // this never fires through the UI. - /* v8 ignore next -- unreachable: form only renders with a selected prompt */ - if (!selectedPrompt) return; - onUiChange({ ...ui, submittedFor: selectedPrompt.name }); - onGetPrompt(selectedPrompt.name, argumentValues); - } - - function handleClosePreview() { - // For prompts with arguments, flip back to the form so the user can - // edit and re-submit (argumentValues are preserved). For no-arg - // prompts there's no form to return to, so drop the selection and - // fall back to the empty state. - if (selectedPrompt && hasArguments(selectedPrompt)) { - onUiChange({ ...ui, submittedFor: undefined }); - } else { - onUiChange({ - ...ui, - selectedPromptName: undefined, - submittedFor: undefined, - }); - } - } - - // The preview is "active" when we've submitted (or auto-fetched) the - // currently-selected prompt and the parent's state is tagged with - // the matching prompt name. The name match guards against a stale - // result from a previously-selected prompt leaking into the new - // prompt's pane. App.tsx tags every state transition with - // `promptName`, so we don't need a fallback for untagged states. - const previewActive = - !!selectedPrompt && - !!getPromptState && - submittedFor === selectedPrompt.name && - getPromptState.promptName === selectedPrompt.name; - - function renderPreview() { - // Defensive guard: renderPreview is only invoked from the `previewActive` - // branch below, and `previewActive` already requires a truthy - // `getPromptState`, so neither arm of this condition is reachable here. - /* v8 ignore next -- unreachable: only called when previewActive && getPromptState */ - if (!previewActive || !getPromptState) return null; - if (getPromptState.status === "pending") { - return ( - - - - - - - - Loading prompt... - - - - ); - } - if (getPromptState.status === "error") { - return ( - - - - - - - {getPromptState.error ?? "Failed to get prompt"} - - - - ); - } - if (getPromptState.result) { - return ( - - - - ); - } - return null; - } - - return ( - - - - onUiChange({ ...ui, search: value })} - onSelectPrompt={handleSelectPrompt} - /> - - - - {previewActive ? ( - // Result branch — sized to content, capped at viewport. Mirrors - // the resource preview layout (see ResourcesScreen). - {renderPreview()} - ) : selectedPrompt && hasArguments(selectedPrompt) ? ( - // Argument-form branch — fills the content pane's width (like the Tools - // input form), replaced by the result once the user clicks Get Prompt - // and previewActive flips on. - - - - onUiChange({ - ...ui, - argumentValues: { ...argumentValues, [argName]: value }, - }) - } - onGetPrompt={handleSubmit} - completionsSupported={completionsSupported} - onCompleteArgument={ - onCompleteArgument - ? (argName, value, context) => - onCompleteArgument( - { type: "ref/prompt", name: selectedPrompt.name }, - argName, - value, - context, - ) - : undefined - } - /> - - - ) : ( - - Select a prompt to view details - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx deleted file mode 100644 index cf6698d20..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Card, Flex, Stack } from "@mantine/core"; -import type { ProtocolEra } from "@modelcontextprotocol/client"; -import type { - MessageEntry, - MessageMethod, - MessageOrigin, -} from "@inspector/core/mcp/types.js"; -import { ProtocolControls } from "../../groups/ProtocolControls/ProtocolControls"; -import { ProtocolListPanel } from "../../groups/ProtocolListPanel/ProtocolListPanel.js"; -import { extractMethod } from "../../groups/protocolUtils.js"; -import type { SortDirection } from "../../elements/SortToggle/SortToggle"; - -export interface ProtocolScreenProps { - entries: MessageEntry[]; - pinnedIds: Set; - /** Negotiated protocol era (SEP §7.8), shown as a badge in the list header. */ - protocolEra?: ProtocolEra; - ui: ProtocolUiState; - onUiChange: (next: ProtocolUiState) => void; - onClearAll: () => void; - onExport: () => void; - onClearSection: (section: "pinned" | "history") => void; - onExportSection: (section: "pinned" | "history") => void; - onReplay: (id: string) => void; - onTogglePin: (id: string) => void; - sortDirection: SortDirection; - onSortChange: (next: SortDirection) => void; - compact: boolean; - onToggleCompact: () => void; - /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ - embedded?: boolean; - /** Jump from a spec-error entry to its correlated Network HTTP entry. */ - onRevealInNetwork?: (id: string) => void; - /** Message-entry ids that have a correlated Network entry (link is shown). */ - revealableIds?: Set; - /** - * Message-entry id → correlated Network fetch HTTP status. Gates the generic - * `-32601` to a genuine modern 404 (see {@link ProtocolListPanel}). - */ - correlatedStatusById?: Map; -} - -// Search text, method filter, and per-direction visibility — controlled by the -// parent (App) as one object so they persist across tab navigation within a -// live session (#1417). -export interface ProtocolUiState { - search: string; - methodFilter?: MessageMethod; - /** Which message directions are shown, keyed by entry origin. */ - visibleDirections: Record; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - w: 340, - flex: "0 0 auto", -}); - -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -export function ProtocolScreen({ - entries, - pinnedIds, - protocolEra, - ui, - onUiChange, - onClearAll, - onExport, - onClearSection, - onExportSection, - onReplay, - onTogglePin, - sortDirection, - onSortChange, - compact, - onToggleCompact, - embedded = false, - onRevealInNetwork, - revealableIds, - correlatedStatusById, -}: ProtocolScreenProps) { - const { search, methodFilter, visibleDirections } = ui; - - const availableMethods = useMemo( - () => Array.from(new Set(entries.map(extractMethod))).sort(), - [entries], - ); - - const handleClearAll = useCallback(() => { - onUiChange({ ...ui, methodFilter: undefined }); - onClearAll(); - }, [ui, onUiChange, onClearAll]); - - const handleToggleDirection = useCallback( - (direction: MessageOrigin, visible: boolean) => { - onUiChange({ - ...ui, - visibleDirections: { ...visibleDirections, [direction]: visible }, - }); - }, - [ui, visibleDirections, onUiChange], - ); - - const handleToggleAllDirections = useCallback(() => { - const next = !Object.values(visibleDirections).every(Boolean); - onUiChange({ - ...ui, - visibleDirections: { client: next, server: next }, - }); - }, [ui, visibleDirections, onUiChange]); - - return ( - // See LoggingScreen: only override `h` when embedded, so the standalone - // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` - // would clobber it and collapse an empty screen to its controls' height). - - {embedded ? null : ( - - - onUiChange({ ...ui, search: value })} - onMethodFilterChange={(value) => - onUiChange({ ...ui, methodFilter: value }) - } - onToggleDirection={handleToggleDirection} - onToggleAllDirections={handleToggleAllDirections} - /> - - - )} - - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx deleted file mode 100644 index a94f91699..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { - Alert, - Card, - CloseButton, - Flex, - Group, - Loader, - Stack, - Text, -} from "@mantine/core"; -import type { - ProtocolEra, - ReadResourceResult, - Resource, - ResourceTemplateType as ResourceTemplate, -} from "@modelcontextprotocol/client"; -import type { - InspectorResourceSubscription, - ResourceSubscriptionStreamState, -} from "../../../../../../core/mcp/types.js"; -import { ResourceControls } from "../../groups/ResourceControls/ResourceControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel"; -import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel"; - -export interface ReadResourceState { - status: "idle" | "pending" | "ok" | "error"; - uri?: string; - result?: ReadResourceResult; - error?: string; - lastUpdated?: Date; - isSubscribed?: boolean; -} - -export interface ResourcesScreenProps { - resources: Resource[]; - templates: ResourceTemplate[]; - subscriptions: InspectorResourceSubscription[]; - /** - * Modern-era `subscriptions/listen` stream state (#1630). On the modern era - * the Subscriptions section shows a stream-status badge and a header dot; - * `active: false` (the legacy default) renders neither. - */ - subscriptionStreamState?: ResourceSubscriptionStreamState; - /** Negotiated protocol era; gates the modern subscription stream chrome. */ - protocolEra?: ProtocolEra; - readState?: ReadResourceState; - ui: ResourcesUiState; - listChanged: boolean; - completionsSupported?: boolean; - /** - * Whether the connected server advertises the `resources.subscribe` - * capability. When false, the Subscribe/Unsubscribe button and the - * Subscriptions accordion section are hidden. Defaults to true so the - * controls render unless a caller explicitly marks them unsupported. - */ - subscriptionsSupported?: boolean; - onUiChange: (next: ResourcesUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onReadResource: (uri: string) => void; - onSubscribeResource: (uri: string) => void; - onUnsubscribeResource: (uri: string) => void; - onCompleteArgument?: ( - ref: - | { type: "ref/resource"; uri: string } - | { type: "ref/prompt"; name: string }, - argumentName: string, - argumentValue: string, - context: Record, - ) => Promise; - compact: boolean; - onCompactChange: (next: boolean) => void; -} - -// Selection (resource URI, template URI, the originating-template marker), the -// sidebar search, and accordion open-sections — controlled by the parent (App) -// as one object so they persist across tab navigation within a live session -// (#1417). `openSections` undefined → ResourceControls falls back to the -// compact-derived default. -export interface ResourcesUiState { - selectedResourceUri?: string; - selectedTemplateUri?: string; - originatingTemplateUri?: string; - search: string; - openSections?: string[]; -} - -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// Card that grows with its content but is capped at the screen height by the -// `sidebar` variant (`max-height: 100%`), like the Tools panel. The column -// layout lets ResourceControls' accordion take over per-section scrolling once -// the content would overflow that cap. -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -// Card that sizes to its content but caps at the screen's available -// height. When content fits, the card stays compact (footer sits right -// under the body); when content would overflow, the inner ScrollArea -// inside ResourcePreviewPanel shrinks and scrolls. -const PreviewCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Column that pins the preview card to the top of the available space -// and bounds its growth via the consumer-set `mah`. The card inside -// keeps its natural height up to that cap. -const PreviewPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -// Centered loader/status column shown while a resource is being read. -const CenteredStatus = Stack.withProps({ - align: "center", - py: "xl", -}); - -const ReadErrorAlert = Alert.withProps({ - color: "red", - variant: "light", - title: "Read Error", -}); - -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -export function ResourcesScreen({ - resources, - templates, - subscriptions, - subscriptionStreamState, - protocolEra, - readState, - ui, - listChanged, - completionsSupported, - subscriptionsSupported = true, - onUiChange, - onRefreshList, - loadError, - pagination, - onReadResource, - onSubscribeResource, - onUnsubscribeResource, - onCompleteArgument, - compact, - onCompactChange, -}: ResourcesScreenProps) { - const { - selectedResourceUri, - selectedTemplateUri, - originatingTemplateUri, - search, - openSections, - } = ui; - const selectedResource = selectedResourceUri - ? resources.find((r) => r.uri === selectedResourceUri) - : undefined; - const selectedTemplate = selectedTemplateUri - ? templates.find((t) => t.uriTemplate === selectedTemplateUri) - : undefined; - - // For template-expanded URIs that don't appear in the resources list, - // construct a synthetic Resource so the preview panel can render. - const readResource: Resource | undefined = - selectedResource ?? - (readState?.uri && readState.uri === selectedResourceUri - ? { name: readState.uri, uri: readState.uri } - : undefined); - - function handleSelectResource(uri: string) { - onUiChange({ - ...ui, - selectedTemplateUri: undefined, - selectedResourceUri: uri, - originatingTemplateUri: undefined, - }); - onReadResource(uri); - } - - function handleSelectTemplate(uriTemplate: string) { - onUiChange({ - ...ui, - selectedResourceUri: undefined, - selectedTemplateUri: uriTemplate, - originatingTemplateUri: undefined, - }); - } - - function handleReadResource(uri: string) { - // Once the user reads (either from the template form or a refresh - // inside the preview panel), hand the screen over to the preview: - // clearing the template selection hides the template form so only - // the rendered resource is shown. We remember the template URI so - // closing the preview can restore the form. - onUiChange({ - ...ui, - originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri, - selectedTemplateUri: undefined, - selectedResourceUri: uri, - }); - onReadResource(uri); - } - - function handleClosePreview() { - if (originatingTemplateUri) { - onUiChange({ - ...ui, - selectedResourceUri: undefined, - selectedTemplateUri: originatingTemplateUri, - originatingTemplateUri: undefined, - }); - } else { - onUiChange({ ...ui, selectedResourceUri: undefined }); - } - } - - function renderReadState() { - if (!readState) return null; - - if (readState.status === "pending") { - return ( - - - - - - - - Reading resource... - - - - ); - } - - if (readState.status === "error") { - return ( - - - - - - - {readState.error ?? "Failed to read resource"} - - - - ); - } - - if (readState.result && readResource) { - return ( - - handleReadResource(readResource.uri)} - onSubscribe={() => onSubscribeResource(readResource.uri)} - onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} - onClose={handleClosePreview} - /> - - ); - } - - return null; - } - - return ( - - - - onUiChange({ ...ui, search: value })} - onOpenSectionsChange={(value) => - onUiChange({ ...ui, openSections: value }) - } - onSelectUri={handleSelectResource} - onSelectTemplate={handleSelectTemplate} - onUnsubscribeResource={onUnsubscribeResource} - compact={compact} - onCompactChange={onCompactChange} - /> - - - - {selectedTemplate ? ( - // Template form only — once the user clicks Read Resource, - // handleReadResource clears the template selection so the - // resource branch takes over and the preview is shown alone. - // Fills the main area width (like the preview pane) rather than - // being capped, so the URI preview and variable inputs get the - // full width on wide displays. - - - - onCompleteArgument( - { - type: "ref/resource", - uri: selectedTemplate.uriTemplate, - }, - argName, - value, - context, - ) - : undefined - } - /> - - - ) : readResource ? ( - // Sized-to-content preview pane, capped at the screen's available - // height. When the resource body fits, the card hugs its content - // and the subscribe/refresh row sits right under it. When the body - // would overflow, the inner ScrollArea inside ResourcePreviewPanel - // shrinks and scrolls, keeping the footer pinned at the cap. - // miw=0 prevents wide content (long unbroken lines, tables) from - // pushing the pane past the viewport's right edge. - {renderReadState()} - ) : ( - - Select a resource to preview - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx deleted file mode 100644 index f6dd4ed31..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { Card, Flex, Stack, Text } from "@mantine/core"; -import type { - CallToolResult, - ReadResourceResult, - Tool, -} from "@modelcontextprotocol/client"; -import type { ExcludedTool } from "@inspector/core/mcp/types.js"; -import { ToolControls } from "../../groups/ToolControls/ToolControls"; -import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; -import { - ToolDetailPanel, - type ToolProgress, -} from "../../groups/ToolDetailPanel/ToolDetailPanel"; -import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; -import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel"; -import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; -import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; - -export interface ToolCallState { - status: "idle" | "pending" | "ok" | "error"; - result?: CallToolResult; - error?: string; - /** - * JSON-RPC error code when the call REJECTED (a thrown `ProtocolError`) rather - * than resolving a result. SDK v2 rejects an unknown-tool call with `-32602` - * instead of returning an `isError` result, so this drives the distinct - * "Unknown Tool" rendering in the error panel (#1632). - */ - errorCode?: number; - progress?: ToolProgress; -} - -// Selection, form values, and sidebar search — controlled by the parent (App) -// as one object so they persist across tab navigation within a live session; -// the screen unmounts on tab switch, so local state would be lost (#1414/#1417). -export interface ToolsUiState { - selectedToolName?: string; - formValues: Record; - search: string; - // Screen-level "Run as task" toggle, shared across tools (not per-tool): - // selecting a different tool keeps the current value. Persists across tab - // navigation like the rest of the UI state, and is only honored for the - // selected tool when its `execution.taskSupport` is "optional" (a "required" - // tool is always run as a task, "forbidden" never) — see ToolDetailPanel. - runAsTask: boolean; -} - -export interface ToolsScreenProps { - tools: Tool[]; - /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` - * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ - excludedTools?: ExcludedTool[]; - callState?: ToolCallState; - ui: ToolsUiState; - listChanged: boolean; - /** Whether the connected server advertises task-augmented tool calls. */ - serverSupportsTaskToolCalls: boolean; - /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered - * for any tool (server-directed task creation). */ - modernTasks?: boolean; - onUiChange: (next: ToolsUiState) => void; - onRefreshList: () => void; - /** A failed list load, rendered above the sidebar list (#1953). */ - loadError?: Error | null; - /** Pagination controls rendered in the sidebar (#1721). */ - pagination: ListPaginationControlsProps; - onCallTool: ( - name: string, - args: Record, - runAsTask?: boolean, - ) => void; - onCancelCall?: () => void; - onClearResult?: () => void; - /** - * Read-on-demand handler for `resource_link` blocks in a tool result. - * Passed through to the result panel so links can inline their contents. - */ - onReadResource?: (uri: string) => Promise; -} - -// Caps the detail/result columns at the screen's available height: full -// viewport minus the app-shell header and the screen's top+bottom xl padding, -// leaving the bottom margin the overflow used to eat. -const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; - -// No `align` override: children stretch to the row's full height, giving each -// column pane a definite height. That definite height is what lets a column's -// inner ScrollArea know how much it can shrink into (a bare `mah` doesn't — -// see the Prompts/Resources preview panes this mirrors). -const ScreenLayout = Flex.withProps({ - variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", - gap: "md", - p: "xl", -}); - -const Sidebar = Stack.withProps({ - // Widened from 340 to comfortably fit the pagination controls - // (Load-next-page button + status) without cramping list entries (#1721). - w: 360, - flex: "0 0 auto", -}); - -// `sidebar` variant makes the card a full-height flex column capped at the -// screen height, so ToolControls' list fills the card and scrolls internally -// once it overflows (matching the Resources sidebar). (#1417) -const SidebarCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "sidebar", -}); - -// Column wrapper: stretches to the screen's available height (capped by the -// consumer-set `mah`) so the card inside has a definite height to shrink into. -const ContentPane = Flex.withProps({ - flex: 1, - miw: 0, - direction: "column", - align: "stretch", -}); - -// Detail/result column card: `variant="preview"` (overflow: hidden) lets the -// panel's inner ScrollArea take over scrolling instead of the card bleeding -// past the viewport. Sizes to content when short, caps at the pane when tall. -const ContentCard = Card.withProps({ - withBorder: true, - padding: "lg", - variant: "preview", -}); - -// Full-height card for the empty placeholder (used with `flex={1}`) so it fills -// the screen height like the Prompts/Resources placeholders, rather than -// shrinking to its text. The result/detail states keep the content-sized -// `ContentCard` (their inner ScrollArea handles overflow). -const DetailCard = Card.withProps({ - withBorder: true, - padding: "lg", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -export function ToolsScreen({ - tools, - excludedTools, - callState, - ui, - listChanged, - serverSupportsTaskToolCalls, - modernTasks = false, - onUiChange, - onRefreshList, - loadError, - pagination, - onCallTool, - onCancelCall, - onClearResult, - onReadResource, -}: ToolsScreenProps) { - const { selectedToolName, formValues, search } = ui; - const selectedTool = selectedToolName - ? tools.find((t) => t.name === selectedToolName) - : undefined; - const isExecuting = callState?.status === "pending"; - - const handleSelectTool = (name: string) => { - // Seed the form with the tool's schema defaults so default-only fields the - // user never edits are still sent on execute (the form shows defaults via - // resolveValue, but onChange only writes edited fields). - const tool = tools.find((t) => t.name === name); - // `name` always comes from the rendered tools list (ToolControls only emits - // names it was given), so the lookup never misses; the empty-object fallback - // is an unreachable defensive default. - let nextFormValues: Record = {}; - /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ - if (tool) - nextFormValues = collectSchemaDefaults( - toFormSchema(tool.inputSchema) ?? {}, - ); - onUiChange({ ...ui, selectedToolName: name, formValues: nextFormValues }); - }; - - return ( - - - - onUiChange({ ...ui, search: value })} - onSelectTool={handleSelectTool} - /> - - - - {callState?.result ? ( - // Results replace the input form while present, and the panel's top-left - // X dismisses them back to the form (#1661) — the Prompts screen pattern. - // `formValues` live in the lifted UI state, so the form is restored - // intact for a re-run. A call in flight sets a `pending` state with no - // `result` (App.tsx), so the executing form (progress + cancel) shows - // until the result lands. - - {/* Fill the pane's full height only when the result renders a - "Resource Links" box, so that box can expand into the available - space and scroll within. Plain text/image/error results keep the - content-sized card (matching the input-form state) instead of - reserving a tall empty card. */} - - onClearResult?.()} - onReadResource={onReadResource} - /> - - - ) : callState?.status === "error" && callState.error ? ( - // A thrown rejection (no result) — e.g. SDK v2's `-32602` unknown-tool - // reject, which no longer arrives as an `isError` CallToolResult. The X - // dismisses back to the form, like a result (#1632). - - - onClearResult?.()} - /> - - - ) : selectedTool ? ( - - - - onUiChange({ ...ui, runAsTask: value }) - } - onFormChange={(values) => - onUiChange({ ...ui, formValues: values }) - } - onExecute={(runAsTask) => - onCallTool(selectedTool.name, formValues, runAsTask) - } - onCancel={() => onCancelCall?.()} - /> - - - ) : ( - // Empty placeholder fills the full screen height (like Prompts/Resources) - // rather than shrinking to its text. - - Select a tool to view details - - )} - - ); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts b/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts deleted file mode 100644 index fea5f3480..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useScrollMemory.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useLayoutEffect, useRef } from "react"; - -// Scroll positions survive a screen unmount by living in this module-scope map -// rather than React state: the screens unmount on tab switch (#1417), so a -// component-local ref would be lost, and threading every scroll position up to -// App would balloon the prop surface for what is purely ephemeral DOM state. -// Keyed by a caller-supplied stable region id (e.g. "logs-stream"). -const scrollPositions = new Map(); - -/** - * Forget all remembered scroll positions. App calls this on disconnect so a new - * session's screens start at the top, matching the clear-on-disconnect rule the - * lifted selection/filter state follows (#1417). - */ -export function clearScrollMemory(): void { - scrollPositions.clear(); -} - -/** - * Remember and restore a scroll container's position across unmount/remount. - * Returns a ref to attach to a Mantine `ScrollArea`/`ScrollArea.Autosize` via - * its `viewportRef` prop. On mount the saved offset (if any) is restored before - * paint; on unmount the current offset is captured. The captured viewport node - * is closed over so the offset is still readable during the cleanup phase. - */ -export function useScrollMemory(key: string) { - const viewportRef = useRef(null); - useLayoutEffect(() => { - const viewport = viewportRef.current; - if (!viewport) return; - const saved = scrollPositions.get(key); - if (saved) { - viewport.scrollTo({ left: saved.x, top: saved.y }); - } - return () => { - scrollPositions.set(key, { - x: viewport.scrollLeft, - y: viewport.scrollTop, - }); - }; - }, [key]); - return viewportRef; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts b/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts deleted file mode 100644 index 6346dfee0..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/hooks/useValueChange.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState } from "react"; - -/** - * Call `onChange(next)` during render whenever `value` differs from the value - * seen on the previous render of this component. Nothing is called on the first - * render — seed the dependent state with `useState` instead. - * - * This is React's documented "adjusting state during render" pattern - * (https://react.dev/reference/react/useState#storing-information-from-previous-renders), - * and it is the supported way to reset or re-sync local state from a prop. - * - * ⚠️ **`onChange` runs during render, so it must be pure** — `setState` calls - * and nothing else. No fetches, no DOM writes, no logging, no ref mutation, no - * parent callbacks. A render can be replayed or thrown away (StrictMode - * double-renders in development; concurrent React can abandon an in-progress - * render at any time), so anything external would run an unpredictable number - * of times. Real external work belongs in a `useEffect`, which is exactly the - * split `NetworkEntry` uses: the reveal's force-open is a state update and - * lives here, while its `requestAnimationFrame` scroll stays an effect. - * - * The obvious-looking alternative — `useEffect(() => setX(prop), [prop])` — is - * worse and is reported by `react-hooks/set-state-in-effect`: the effect only - * runs *after* the component has already painted with the stale value, so the - * user sees one frame of the old state and React has to render twice. Adjusting - * during render lets React discard the in-progress output and re-run the - * component body before anything reaches the DOM. - * - * ⚠️ **`value` must be referentially stable across renders that mean "no - * change".** The comparison is `Object.is`, so an object or array literal built - * fresh in the component body looks different on every render — `onChange` - * would fire every render, and because it is what updates state, that is an - * infinite render loop rather than a merely wasteful one. Pass a **primitive - * key** derived from the data (an id, a name, a URI) wherever one exists, and - * otherwise a value that is already memoized or owned by the parent. This is - * the same stability requirement a `useEffect` dependency array carries; the - * difference is only that here the failure is loud and immediate. - */ -export function useValueChange(value: T, onChange: (next: T) => void): void { - const [previous, setPrevious] = useState(value); - if (!Object.is(previous, value)) { - setPrevious(value); - onChange(value); - } -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts b/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts deleted file mode 100644 index 5c5d3f709..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/lib/downloadFile.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Browser-side helpers for triggering file downloads from in-memory content. - * - * Centralized so the temp-anchor incantation (`appendChild` for Firefox, - * deferred `revokeObjectURL` so the scheduled download can read the blob) - * lives in one place — and so the wiring is unit-testable under happy-dom - * without dragging React along. - */ - -/** - * Download an in-memory {@link Blob} as `filename`. Uses a temporary anchor - * element to trigger the browser's save dialog. The append-to-body step is - * for older Firefox versions that wouldn't fire `click()` on a detached - * anchor; modern browsers don't require it but it stays as the safe path. - * - * The object-URL revoke is deferred to a task: `link.click()` only schedules - * the download, and revoking the URL synchronously can abort it before the - * browser reads the blob (Firefox/Safari, intermittently Chrome for larger - * blobs). - */ -export function downloadBlob(filename: string, blob: Blob): void { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - try { - anchor.click(); - } finally { - document.body.removeChild(anchor); - setTimeout(() => URL.revokeObjectURL(url), 0); - } -} - -/** Download an in-memory JSON string as `filename`. */ -export function downloadJsonFile(filename: string, json: string): void { - downloadBlob(filename, new Blob([json], { type: "application/json" })); -} - -/** - * Derive a safe suggested filename from a resource URI: the last path segment, - * stripped of control/format characters, path separators, and characters - * disallowed in filenames on common platforms. Falls back to `"download"` when - * nothing usable remains. - */ -export function fileNameFromUri(uri: string): string { - /* v8 ignore next -- String.prototype.split always returns a non-empty array, so .pop() is never undefined; the `?? ""` fallback is unreachable. */ - const tail = uri.split(/[\\/]/).pop() ?? ""; - const safe = tail - .replace(/[\p{Cc}\p{Cf}]+/gu, "") - .replace(/[\\/:*?"<>|]+/g, "_") - .trim(); - return safe.length > 0 ? safe.slice(0, 255) : "download"; -} - -/** - * Parse `url` and return it only if its scheme is `http:` or `https:`; - * otherwise null. Shared http(s)-only allowlist for opening or downloading - * server-supplied URLs. - */ -export function isHttpUrl(url: string): URL | null { - try { - const parsed = new URL(url); - return parsed.protocol === "https:" || parsed.protocol === "http:" - ? parsed - : null; - } catch { - return null; - } -} - -/** - * The categories of in-memory data the Inspector can export. Tightening - * `kind` to this union catches typos at call sites and documents the - * stable on-disk filename prefix. - */ -export type ExportKind = - | "protocol" - | "protocol-pinned" - | "protocol-unpinned" - | "logs" - | "network" - | "console"; - -/** - * Build a sortable export filename in the shape - * `inspector---.json`. The timestamp uses - * the standard ISO-8601 form with `:` swapped for `-` so the result is - * safe on Windows (which disallows `:` in filenames). Server id is - * passed through `encodeURIComponent` for the same reason — config ids - * are user-supplied and may contain slashes / spaces / colons. - * - * When `serverId` is falsy (undefined or empty) the segment is omitted; - * the rest of the filename still uniquely identifies the export by kind - * + time. - */ -export function buildExportFilename( - kind: ExportKind, - serverId: string | undefined, - now: Date = new Date(), -): string { - const iso = now.toISOString().replace(/:/g, "-"); - const id = serverId ? encodeURIComponent(serverId) : undefined; - const segments = ["inspector", kind, ...(id ? [id] : []), iso]; - return `${segments.join("-")}.json`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts deleted file mode 100644 index 65bfa4d2d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "@rstest/core"; -import { - INSPECTOR_SERVERS_TAB, - INSPECTOR_TAB_IDS, - isInspectorTabId, -} from "./inspectorTabs"; - -describe("inspectorTabs", () => { - it("names the Servers tab, which is not a liftable inspector tab", () => { - expect(INSPECTOR_SERVERS_TAB).toBe("Servers"); - expect(INSPECTOR_TAB_IDS).not.toContain(INSPECTOR_SERVERS_TAB); - }); - - it("enumerates the liftable inspector tabs", () => { - expect(INSPECTOR_TAB_IDS).toEqual([ - "Apps", - "Tools", - "Prompts", - "Resources", - "Tasks", - "Logs", - "Protocol", - "Network", - ]); - }); - - it("isInspectorTabId returns true for every enumerated tab", () => { - for (const tab of INSPECTOR_TAB_IDS) { - expect(isInspectorTabId(tab)).toBe(true); - } - }); - - it("isInspectorTabId returns false for non-inspector tab values", () => { - expect(isInspectorTabId(INSPECTOR_SERVERS_TAB)).toBe(false); - expect(isInspectorTabId("")).toBe(false); - expect(isInspectorTabId("Bogus")).toBe(false); - }); -}); diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts deleted file mode 100644 index be69af737..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/inspectorTabs.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Inspector main-view tab identifiers. Match labels used in ViewHeader / - * InspectorView (`"Tools"`, `"Resources"`, …). - */ - -export const INSPECTOR_SERVERS_TAB = "Servers"; - -/** Tabs with liftable `*UiState` in App.tsx (Servers has no ui snapshot). */ -export const INSPECTOR_TAB_IDS = [ - "Apps", - "Tools", - "Prompts", - "Resources", - "Tasks", - "Logs", - "Protocol", - "Network", -] as const; - -export type InspectorTabId = (typeof INSPECTOR_TAB_IDS)[number]; - -export function isInspectorTabId(value: string): value is InspectorTabId { - return (INSPECTOR_TAB_IDS as readonly string[]).includes(value); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts deleted file mode 100644 index 98dd905a2..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/jsonUtils.ts +++ /dev/null @@ -1,316 +0,0 @@ -export type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonSchemaConst = { - const: JsonValue; - title?: string; - description?: string; -}; - -export type InspectorFormSchema = { - type?: - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null" - | ( - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null" - )[]; - title?: string; - description?: string; - required?: string[]; - default?: JsonValue; - properties?: Record; - items?: InspectorFormSchema; - // Array validation constraints - minItems?: number; - maxItems?: number; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; - nullable?: boolean; - pattern?: string; - format?: string; - enum?: string[]; - // Non-standard legacy support: titles for enum values - enumNames?: string[]; - const?: JsonValue; - oneOf?: (InspectorFormSchema | JsonSchemaConst)[]; - anyOf?: (InspectorFormSchema | JsonSchemaConst)[]; - $ref?: string; -}; - -export type JsonObject = { [key: string]: JsonValue }; - -/** - * Narrow an MCP protocol schema (SDK `JsonSchemaType` — e.g. `Tool["inputSchema"]` - * / `outputSchema`, an elicitation `requestedSchema`) to the {@link - * InspectorFormSchema} subset the {@link SchemaForm} renderer understands. - * - * Under SDK v2 the protocol schema type (from `json-schema-typed`, exported as - * `JsonSchemaType` from `@modelcontextprotocol/client`) is structurally distinct - * from Inspector's form schema — same JSON on the wire, incompatible TS types. - * Rather than cast at every call site, callers pass the SDK schema through here. - * Returns `null` when there is no renderable object shape (missing schema, or a - * non-object schema the form can't build fields from); callers handle `null`. - */ -export function toFormSchema(schema: unknown): InspectorFormSchema | null { - if (schema == null || typeof schema !== "object" || Array.isArray(schema)) { - return null; - } - // Structural narrow: the SDK schema's fields are a superset of what the form - // reads (`type`, `properties`, `required`, `items`, …); the values the form - // never dereferences don't affect rendering. - return schema as InspectorFormSchema; -} - -export type DataType = - | "string" - | "number" - | "bigint" - | "boolean" - | "symbol" - | "undefined" - | "object" - | "function" - | "array" - | "null"; - -/** - * Determines the specific data type of a JSON value - * @param value The JSON value to analyze - * @returns The specific data type including "array" and "null" as distinct types - */ -export function getDataType(value: JsonValue): DataType { - if (Array.isArray(value)) return "array"; - if (value === null) return "null"; - return typeof value; -} - -/** - * Collect a schema's default field values into a values object. A schema form - * displays defaults but only writes a field into its `values` once the user - * edits it, so an untouched default would otherwise be absent from a - * submission. Seeding form state with this keeps default-only fields in the - * submitted result (parity with v1). Recurses into nested object schemas and - * omits fields that have no default. - */ -export function collectSchemaDefaults( - schema: InspectorFormSchema, -): Record { - const properties = schema.properties ?? {}; - const result: Record = {}; - for (const [fieldName, fieldSchema] of Object.entries(properties)) { - if (fieldSchema.default !== undefined) { - result[fieldName] = fieldSchema.default; - } else if (fieldSchema.type === "object" && fieldSchema.properties) { - const nested = collectSchemaDefaults(fieldSchema); - if (Object.keys(nested).length > 0) { - result[fieldName] = nested; - } - } - } - return result; -} - -/** - * Whether any of the schema's required top-level fields is missing a value in - * `values` (absent, null, or empty string). Used to gate a form's submit - * action until required fields are supplied. - */ -export function hasMissingRequiredFields( - schema: InspectorFormSchema, - values: Record, -): boolean { - const required = schema.required ?? []; - return required.some((field) => { - const value = values[field]; - return value === undefined || value === null || value === ""; - }); -} - -/** - * Attempts to parse a string as JSON, only for objects and arrays - * @param str The string to parse - * @returns Object with success boolean and either parsed data or original string - */ -export function tryParseJson(str: string): { - success: boolean; - data: JsonValue; -} { - const trimmed = str?.trim(); - if ( - trimmed && - !(trimmed.startsWith("{") && trimmed.endsWith("}")) && - !(trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - return { success: false, data: str }; - } - try { - return { success: true, data: JSON.parse(str) }; - } catch { - return { success: false, data: str }; - } -} - -/** - * Updates a value at a specific path in a nested JSON structure - * @param obj The original JSON value - * @param path Array of keys/indices representing the path to the value - * @param value The new value to set - * @returns A new JSON value with the updated path - */ -export function updateValueAtPath( - obj: JsonValue, - path: string[], - value: JsonValue, -): JsonValue { - if (path.length === 0) return value; - - if (obj === null || obj === undefined) { - obj = !isNaN(Number(path[0])) ? [] : {}; - } - - if (Array.isArray(obj)) { - return updateArray(obj, path, value); - } else if (typeof obj === "object" && obj !== null) { - return updateObject(obj as JsonObject, path, value); - } else { - console.error( - `Cannot update path ${path.join(".")} in non-object/array value:`, - obj, - ); - return obj; - } -} - -/** - * Updates an array at a specific path - */ -function updateArray( - array: JsonValue[], - path: string[], - value: JsonValue, -): JsonValue[] { - const [index, ...restPath] = path; - const arrayIndex = Number(index); - - if (isNaN(arrayIndex)) { - console.error(`Invalid array index: ${index}`); - return array; - } - - if (arrayIndex < 0) { - console.error(`Array index out of bounds: ${arrayIndex} < 0`); - return array; - } - - let newArray: JsonValue[] = []; - for (let i = 0; i < array.length; i++) { - newArray[i] = i in array ? array[i] : null; - } - - if (arrayIndex >= newArray.length) { - const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null); - // Copy over the existing elements (now guaranteed to be dense) - for (let i = 0; i < newArray.length; i++) { - extendedArray[i] = newArray[i]; - } - newArray = extendedArray; - } - - if (restPath.length === 0) { - newArray[arrayIndex] = value; - } else { - newArray[arrayIndex] = updateValueAtPath( - newArray[arrayIndex], - restPath, - value, - ); - } - return newArray; -} - -/** - * Updates an object at a specific path - */ -function updateObject( - obj: JsonObject, - path: string[], - value: JsonValue, -): JsonObject { - const [key, ...restPath] = path; - - // Validate object key - if (typeof key !== "string") { - console.error(`Invalid object key: ${key}`); - return obj; - } - - const newObj = { ...obj }; - - if (restPath.length === 0) { - newObj[key] = value; - } else { - // Ensure key exists - if (!(key in newObj)) { - newObj[key] = {}; - } - newObj[key] = updateValueAtPath(newObj[key], restPath, value); - } - return newObj; -} - -/** - * Gets a value at a specific path in a nested JSON structure - * @param obj The JSON value to traverse - * @param path Array of keys/indices representing the path to the value - * @param defaultValue Value to return if path doesn't exist - * @returns The value at the path, or defaultValue if not found - */ -export function getValueAtPath( - obj: JsonValue, - path: string[], - defaultValue: JsonValue = null, -): JsonValue { - if (path.length === 0) return obj; - - const [first, ...rest] = path; - - if (obj === null || obj === undefined) { - return defaultValue; - } - - if (Array.isArray(obj)) { - const index = Number(first); - if (isNaN(index) || index < 0 || index >= obj.length) { - return defaultValue; - } - return getValueAtPath(obj[index], rest, defaultValue); - } - - if (typeof obj === "object" && obj !== null) { - if (!(first in obj)) { - return defaultValue; - } - return getValueAtPath((obj as JsonObject)[first], rest, defaultValue); - } - - return defaultValue; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts deleted file mode 100644 index 3fd4d9a5d..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/maskSecrets.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Masks sensitive OAuth values inside a captured HTTP body for display in the - * Network tab. OAuth token-exchange / registration responses carry credentials - * (`access_token`, `refresh_token`, …) and the token *request* (a - * `application/x-www-form-urlencoded` body) carries `code` / `code_verifier` / - * `client_secret`. We show the body so it's inspectable, but mask those values - * by default so they aren't exposed at a glance during a screen-share. The raw - * body is preserved by the caller and shown only when the user reveals it. - * - * Content-type selects the parser: `*json*` → JSON masking, form-urlencoded → - * form masking, any other known type → no masking. When the content-type is - * absent/unknown the body is sniffed (parse as JSON first, else treat as - * form). See `maskSecretsInBody`. - */ - -// Keys masked in JSON bodies — bearer-grade secrets only. `code` is -// deliberately NOT here: a JSON body's `code` is usually something else (e.g. -// a JSON-RPC error `code`), and we don't want to mask those. -// `registration_access_token` is the DCR management credential (RFC 7592), -// same bearer class as `access_token`. -const JSON_SENSITIVE_KEYS = new Set([ - "access_token", - "refresh_token", - "id_token", - "client_secret", - "registration_access_token", -]); - -// Keys masked in form-encoded bodies — the JSON set plus the single-use OAuth -// request material that only appears as form params (authorization code, PKCE -// verifier, private-key-JWT client assertion). -const FORM_SENSITIVE_KEYS = new Set([ - ...JSON_SENSITIVE_KEYS, - "code", - "code_verifier", - "client_assertion", -]); - -// What a masked value is replaced with. A fixed-width dotted string keeps the -// shape recognizable as "a value was here" without hinting at its length. -export const MASK_PLACEHOLDER = "••••••••"; - -function isSensitiveKey(set: ReadonlySet, key: string): boolean { - return set.has(key.toLowerCase()); -} - -// Whether a value under a sensitive key should be masked. The contract is -// "any non-null, non-empty-string value": strings are masked when non-empty -// (an empty `access_token` carries nothing), and any non-string value -// (object/array/number/boolean wrapper — pathological for OAuth, but a safe -// default) is masked wholesale so it can't leak through the recursion. -function isMaskableValue(value: unknown): boolean { - if (value === null || value === undefined) return false; - if (typeof value === "string") return value.length > 0; - return true; -} - -interface MaskedNode { - node: unknown; - masked: boolean; -} - -// Recursively mask sensitive values in a parsed JSON node, tracking whether -// anything was masked (so the caller never has to infer it by comparing -// serializations — reformatting alone can't trip the flag, and it's robust if -// this function ever grows non-identity transforms). -function maskNode(node: unknown): MaskedNode { - if (Array.isArray(node)) { - let masked = false; - const out = node.map((item) => { - const r = maskNode(item); - masked = masked || r.masked; - return r.node; - }); - return { node: out, masked }; - } - if (node !== null && typeof node === "object") { - let masked = false; - const out: Record = {}; - for (const [key, value] of Object.entries( - node as Record, - )) { - if (isSensitiveKey(JSON_SENSITIVE_KEYS, key) && isMaskableValue(value)) { - out[key] = MASK_PLACEHOLDER; - masked = true; - } else { - const r = maskNode(value); - out[key] = r.node; - masked = masked || r.masked; - } - } - return { node: out, masked }; - } - return { node, masked: false }; -} - -export interface MaskResult { - /** The body with sensitive values replaced; pretty-printed for JSON, otherwise the original shape with values substituted. */ - masked: string; - /** True when at least one sensitive value was masked. */ - hasSecrets: boolean; -} - -function maskJsonBody(body: string): MaskResult { - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return { masked: body, hasSecrets: false }; - } - const { node, masked } = maskNode(parsed); - return { masked: JSON.stringify(node, null, 2), hasSecrets: masked }; -} - -// Mask sensitive params in a form-urlencoded body, preserving the original -// formatting (we only swap the value, so the placeholder isn't percent-encoded -// the way `URLSearchParams.toString()` would mangle it). A non-form string -// (no `key=value` pairs with a sensitive key) falls through untouched. -function maskFormBody(body: string): MaskResult { - let hasSecrets = false; - const masked = body - .split("&") - .map((pair) => { - const eq = pair.indexOf("="); - if (eq === -1) return pair; - const rawKey = pair.slice(0, eq); - const value = pair.slice(eq + 1); - let key: string; - try { - key = decodeURIComponent(rawKey); - } catch { - key = rawKey; - } - if (isSensitiveKey(FORM_SENSITIVE_KEYS, key) && value.length > 0) { - hasSecrets = true; - return `${rawKey}=${MASK_PLACEHOLDER}`; - } - return pair; - }) - .join("&"); - return { masked: hasSecrets ? masked : body, hasSecrets }; -} - -/** - * Mask sensitive fields in an HTTP body for display. - * - * `contentType` (the body's `content-type` header, if known) picks the parser: - * - `*json*` → JSON masking (re-serialized pretty) - * - `application/x-www-form-urlencoded` → form masking (shape preserved) - * - any other known type (HTML, plaintext, XML, …) → no masking - * - absent/unknown → sniff: parse as JSON, else treat as form - * - * Bodies with no sensitive keys return unchanged with `hasSecrets: false` so - * callers can skip the reveal affordance. The caller keeps the original string - * for the revealed view. - * - * `contentType` is matched by substring (`*json*`, `*x-www-form-urlencoded*`) - * and we trust the wire's own label — a body mislabeled by the server (e.g. - * JSON sent as `text/html`) takes the "no masking" branch and renders raw. - * That's acceptable: the threat model is a screen-share viewer, not an - * adversary who controls the response's content-type. - */ -export function maskSecretsInBody( - body: string, - contentType?: string, -): MaskResult { - const ct = (contentType ?? "").toLowerCase(); - if (ct) { - if (ct.includes("json")) return maskJsonBody(body); - if (ct.includes("x-www-form-urlencoded")) return maskFormBody(body); - // Known, non-JSON/non-form content type → don't guess; leave it alone. - return { masked: body, hasSecrets: false }; - } - // No content-type hint: sniff. Valid JSON → JSON masking; otherwise form. - try { - JSON.parse(body); - } catch { - return maskFormBody(body); - } - return maskJsonBody(body); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts deleted file mode 100644 index 93adf6bc3..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/mcpNetworkHeaders.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { ProtocolErrorCode } from "@modelcontextprotocol/client"; -import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; - -/** - * SEP-2243 / modern Streamable HTTP transport awareness for the monitoring tabs. - * - * The modern (≥2026-07-28) transport mirrors key JSON-RPC body fields into HTTP - * headers so intermediaries can route/police MCP traffic without parsing bodies - * (`Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, `MCP-Protocol-Version`), and defines - * a small family of spec error codes returned as JSON-RPC bodies over specific - * HTTP statuses. This module is the pure logic those tabs use to recognise, - * decode, and validate them — no rendering, fully unit-testable. The header - * recognition/decoding/consistency helpers back the Network tab; the spec-error - * classification (`classifyProtocolSpecError`) backs the Protocol tab. Shared - * here because both are the same modern HTTP/spec vocabulary. - * - * Recorded header keys are lowercased (the fetch tracker reads them through the - * `Headers` API, which normalises names), so every comparison here is - * case-insensitive on the name. - */ - -/** - * `-32020 HeaderMismatch` (SEP-2243). The client SDK keeps this in an internal - * chunk that isn't re-exported from its public barrel, so the spec-reserved - * value is pinned locally. The other three codes come from the SDK's - * {@link ProtocolErrorCode} enum. - */ -export const HEADER_MISMATCH_ERROR_CODE = -32020; - -const BASE64_SENTINEL_PREFIX = "=?base64?"; -const BASE64_SENTINEL_SUFFIX = "?="; - -const MCP_STANDARD_HEADER_NAMES: ReadonlySet = new Set([ - "mcp-method", - "mcp-name", - "mcp-protocol-version", -]); - -const MCP_PARAM_HEADER_PREFIX = "mcp-param-"; - -/** JSON-RPC `_meta` key carrying the negotiated protocol version. */ -export const PROTOCOL_VERSION_META_KEY = - "io.modelcontextprotocol/protocolVersion"; - -/** Whether `name` is one of the standard mirrored headers (case-insensitive). */ -export function isMcpStandardHeader(name: string): boolean { - return MCP_STANDARD_HEADER_NAMES.has(name.toLowerCase()); -} - -/** Whether `name` is an opt-in `Mcp-Param-*` custom header (case-insensitive). */ -export function isMcpParamHeader(name: string): boolean { - return name.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX); -} - -/** Whether `name` is any modern MCP mirrored header (standard or `Mcp-Param-*`). */ -export function isMcpHeader(name: string): boolean { - return isMcpStandardHeader(name) || isMcpParamHeader(name); -} - -export interface DecodedMcpParamValue { - /** The value shown to the user: decoded when sentinel-encoded, else the raw. */ - value: string; - /** True when the raw header used the `=?base64?{b64}?=` sentinel form. */ - encoded: boolean; - /** The original, undecoded header value. */ - raw: string; -} - -/** - * Decode a mirrored header value per SEP-2243's value-encoding rules. A value - * wrapped as `=?base64?{base64-of-utf8}?=` is decoded to its UTF-8 string; - * anything else is passed through unchanged. A sentinel wrapper whose inner - * payload is not valid Base64 is reported as `encoded: true` but left as the raw - * string (best-effort — never throws). - */ -export function decodeMcpParamValue(raw: string): DecodedMcpParamValue { - const isSentinel = - raw.length >= - BASE64_SENTINEL_PREFIX.length + BASE64_SENTINEL_SUFFIX.length && - raw.startsWith(BASE64_SENTINEL_PREFIX) && - raw.endsWith(BASE64_SENTINEL_SUFFIX); - if (!isSentinel) return { value: raw, encoded: false, raw }; - - const b64 = raw.slice( - BASE64_SENTINEL_PREFIX.length, - raw.length - BASE64_SENTINEL_SUFFIX.length, - ); - try { - const bin = atob(b64); - const bytes = Uint8Array.from(bin, (ch) => ch.charCodeAt(0)); - return { value: new TextDecoder().decode(bytes), encoded: true, raw }; - } catch { - return { value: raw, encoded: true, raw }; - } -} - -export interface JsonRpcError { - code: number; - message: string; - data?: unknown; -} - -/** - * Extract the first JSON-RPC `error` object from a (possibly batched) response - * body. Returns `null` for an empty, non-JSON, or error-free body. Best-effort - * and never throws. - */ -export function parseJsonRpcError( - body: string | undefined, -): JsonRpcError | null { - if (!body) return null; - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return null; - } - const candidates = Array.isArray(parsed) ? parsed : [parsed]; - for (const candidate of candidates) { - if (candidate === null || typeof candidate !== "object") continue; - if (!("error" in candidate)) continue; - const err = (candidate as { error: unknown }).error; - if (err === null || typeof err !== "object") continue; - const { code, message, data } = err as { - code?: unknown; - message?: unknown; - data?: unknown; - }; - if (typeof code !== "number") continue; - return { - code, - message: typeof message === "string" ? message : "", - data, - }; - } - return null; -} - -export interface McpSpecError { - code: number; - /** Spec name, e.g. `HeaderMismatch`. */ - name: string; - /** One-line explanation for the Network UI. */ - description: string; - /** HTTP status the spec pairs this code with. */ - expectedHttpStatus: number; - /** The actual HTTP status recorded on the entry, when present. */ - actualHttpStatus?: number; - /** - * For `-32022 UnsupportedProtocolVersion`: the versions the server advertises - * as supported (from `error.data.supported`), when present. - */ - supported?: string[]; - /** - * Whether a "view in Network" link is worth offering on the Protocol alert. - * True when the error is EITHER thrown by the SDK (its real HTTP response - * lives only in the Network log — the Protocol entry is a synthetic fold from - * the correlated fetch) OR tied to the HTTP request/response headers. A - * delivered, protocol-only error (a missing capability, an unsupported version - * whose `supported` list is already in the alert) sets this false: the raw - * HTTP entry adds nothing. - */ - httpRelevant: boolean; -} - -const SPEC_ERROR_META: Record< - number, - { - name: string; - description: string; - expectedHttpStatus: number; - httpRelevant: boolean; - } -> = { - [HEADER_MISMATCH_ERROR_CODE]: { - name: "HeaderMismatch", - description: - "An Mcp-* header did not match the JSON-RPC body (SEP-2243). The server rejected the request pre-dispatch.", - expectedHttpStatus: 400, - // The mirrored headers are the whole story — the Network entry shows them. - httpRelevant: true, - }, - [ProtocolErrorCode.MissingRequiredClientCapability]: { - name: "MissingRequiredClientCapability", - description: - "The server requires a client capability that was not declared (SEP-2575).", - expectedHttpStatus: 400, - // Protocol-only: the capability requirement is in the error, not the HTTP. - httpRelevant: false, - }, - [ProtocolErrorCode.UnsupportedProtocolVersion]: { - name: "UnsupportedProtocolVersion", - description: - "The requested protocol version is not supported (SEP-2575). The error body lists the supported versions.", - expectedHttpStatus: 400, - // Protocol-only: the supported-versions list is already shown in the alert. - httpRelevant: false, - }, - [ProtocolErrorCode.MethodNotFound]: { - name: "MethodNotFound", - description: - "Unknown method. A JSON-RPC error body on an HTTP 404 marks a modern server — a legacy HTTP+SSE server returns a bare 404 with no body.", - expectedHttpStatus: 404, - // Thrown by the SDK (HTTP 404, not delivered as a frame) — the real - // response lives only in the Network log, so the link is essential. - httpRelevant: true, - }, -}; - -function extractSupportedVersions(data: unknown): string[] | undefined { - if (data === null || typeof data !== "object") return undefined; - const supported = (data as { supported?: unknown }).supported; - if (!Array.isArray(supported)) return undefined; - const strings = supported.filter((v): v is string => typeof v === "string"); - return strings.length > 0 ? strings : undefined; -} - -/** - * Classify a Network entry's response as one of the modern spec errors, or - * `null` if it isn't one. `-32601 MethodNotFound` is only treated as the modern - * marker when it arrives on an HTTP 404 (an in-band `-32601` on a 200 response - * is an ordinary result, not the transport-level taxonomy this surfaces). - */ -export function classifyMcpSpecError( - entry: Pick, -): McpSpecError | null { - const err = parseJsonRpcError(entry.responseBody); - if (!err) return null; - const meta = SPEC_ERROR_META[err.code]; - if (!meta) return null; - if ( - err.code === ProtocolErrorCode.MethodNotFound && - entry.responseStatus !== 404 - ) { - return null; - } - const result: McpSpecError = { - code: err.code, - ...meta, - actualHttpStatus: entry.responseStatus, - }; - if (err.code === ProtocolErrorCode.UnsupportedProtocolVersion) { - const supported = extractSupportedVersions(err.data); - if (supported) result.supported = supported; - } - return result; -} - -/** - * Classify a JSON-RPC error *code* (e.g. from a Protocol message's - * `response.error`) as one of the modern spec errors, or `null`. - * - * `-32020`/`-32021`/`-32022` are SEP-reserved and unambiguous, so they're - * recognised from the code alone. `-32601 MethodNotFound`, by contrast, is the - * most generic standard JSON-RPC error — any server can return it *in-band* for - * an unsupported method, which is not the modern transport taxonomy. So it's - * treated as the modern marker only when the correlated fetch was an actual 404 - * (`httpStatus === 404`), mirroring {@link classifyMcpSpecError}. The genuine - * modern case is thrown by the SDK on a 404 and folded in by - * `enrichProtocolEntries`, which always carries that 404 — so requiring 404 loses - * nothing intended. An unknown status (`undefined`) is *not* a 404: that path is - * only reached by an ordinary in-band `-32601` with no correlated 404 (most - * commonly a **stdio** connection, which has no HTTP at all), so it must not get - * the modern framing. - */ -export function classifyProtocolSpecError( - code: number, - data?: unknown, - httpStatus?: number, -): McpSpecError | null { - const meta = SPEC_ERROR_META[code]; - if (!meta) return null; - if (code === ProtocolErrorCode.MethodNotFound && httpStatus !== 404) { - return null; - } - const result: McpSpecError = { code, ...meta }; - if (code === ProtocolErrorCode.UnsupportedProtocolVersion) { - const supported = extractSupportedVersions(data); - if (supported) result.supported = supported; - } - return result; -} - -/** - * A bare HTTP 404 with no JSON-RPC body is how a legacy HTTP+SSE endpoint (or a - * non-MCP server) answers an unknown route — distinct from a modern server's - * `-32601` 404 (see {@link classifyMcpSpecError}). Surfacing it helps explain - * why a connection fell back to the legacy transport. - */ -export function isLegacyBare404( - entry: Pick, -): boolean { - return ( - entry.responseStatus === 404 && - parseJsonRpcError(entry.responseBody) === null - ); -} - -export interface HeaderConsistency { - /** Canonical lowercase header name. */ - header: string; - /** The value derived from the JSON-RPC body that the header should mirror. */ - expected: string; - /** The header's value (sentinel-decoded), as actually sent. */ - actual: string; - /** Whether the header and body agree. */ - ok: boolean; -} - -interface JsonRpcRequestBody { - method?: unknown; - params?: { - name?: unknown; - uri?: unknown; - _meta?: Record; - }; -} - -function parseRequestBody(body: string | undefined): JsonRpcRequestBody | null { - if (!body) return null; - try { - const parsed: unknown = JSON.parse(body); - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) - ) { - return null; - } - return parsed as JsonRpcRequestBody; - } catch { - return null; - } -} - -function findHeaderValue( - headers: Record, - name: string, -): string | undefined { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === name) return value; - } - return undefined; -} - -/** - * Cross-check the mirrored standard headers against the request body they - * derive from, so a `HeaderMismatch` is visible at a glance before the server - * even rejects it. A row is produced only when BOTH the header is present AND - * the corresponding body field can be derived — an unverifiable pair (e.g. a - * connection-level `mcp-protocol-version` on a body with no version envelope) is - * skipped rather than falsely flagged. - * - * Checks: `mcp-method` ↔ body `method`; `mcp-name` (decoded) ↔ body - * `params.name` / `params.uri`; `mcp-protocol-version` ↔ body - * `params._meta["io.modelcontextprotocol/protocolVersion"]`. - */ -export function checkHeaderConsistency( - entry: Pick, -): HeaderConsistency[] { - const body = parseRequestBody(entry.requestBody); - if (!body) return []; - const rows: HeaderConsistency[] = []; - - const methodHeader = findHeaderValue(entry.requestHeaders, "mcp-method"); - if (methodHeader !== undefined && typeof body.method === "string") { - rows.push({ - header: "mcp-method", - expected: body.method, - actual: methodHeader, - ok: methodHeader === body.method, - }); - } - - const nameHeader = findHeaderValue(entry.requestHeaders, "mcp-name"); - const bodyName = - typeof body.params?.name === "string" - ? body.params.name - : typeof body.params?.uri === "string" - ? body.params.uri - : undefined; - if (nameHeader !== undefined && bodyName !== undefined) { - const decoded = decodeMcpParamValue(nameHeader).value; - rows.push({ - header: "mcp-name", - expected: bodyName, - actual: decoded, - ok: decoded === bodyName, - }); - } - - const versionHeader = findHeaderValue( - entry.requestHeaders, - "mcp-protocol-version", - ); - const bodyVersion = body.params?._meta?.[PROTOCOL_VERSION_META_KEY]; - if (versionHeader !== undefined && typeof bodyVersion === "string") { - rows.push({ - header: "mcp-protocol-version", - expected: bodyVersion, - actual: versionHeader, - ok: versionHeader === bodyVersion, - }); - } - - return rows; -} - -/** The `header` names from {@link checkHeaderConsistency} rows that mismatched. */ -export function mismatchedHeaders( - entry: Pick, -): Set { - return new Set( - checkHeaderConsistency(entry) - .filter((row) => !row.ok) - .map((row) => row.header), - ); -} - -/** - * Whether an entry's error is a cancellation surfaced as a connection abort. - * Under the modern transport, cancelling an in-flight request aborts the - * connection instead of sending a `notifications/cancelled` frame (SEP-2575), so - * a cancelled request lands here as an `AbortError` rather than a tracked frame. - */ -export function isCancellationAbort( - entry: Pick, -): boolean { - if (!entry.error) return false; - const message = entry.error.toLowerCase(); - return message.includes("abort") || message.includes("cancel"); -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts deleted file mode 100644 index 5fd1a1210..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/oauthNetworkPhase.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Classify a captured `auth`-category network request by its OAuth flow phase, - * so the Network tab can label discovery / registration / token / step-up - * traffic. Purely heuristic on the request URL — the SDK owns the flow, so this - * is presentation only and returns `undefined` when nothing matches. - * - * Phases follow the 2026-07-28 authorization flow: RFC 9728/8414 discovery, - * DCR / CIMD registration (SEP-991), the authorization redirect, and the token - * exchange (where a `403 insufficient_scope` step-up re-authorizes, SEP-2350). - */ -export type OAuthNetworkPhase = - | "discovery" - | "registration" - | "authorize" - | "token"; - -const PHASE_LABELS: Record = { - discovery: "Discovery", - registration: "Registration", - authorize: "Authorize", - token: "Token", -}; - -export function oauthNetworkPhase( - rawUrl: string, -): OAuthNetworkPhase | undefined { - // Match on the path only; query strings and fragments are irrelevant and can - // contain misleading substrings (e.g. a `redirect_uri` pointing at `/token`). - let path: string; - try { - path = new URL(rawUrl).pathname.toLowerCase(); - } catch { - // Not an absolute URL — fall back to the raw string, minus any query. - path = rawUrl.toLowerCase().split(/[?#]/)[0] ?? ""; - } - - if ( - path.includes("/.well-known/oauth-protected-resource") || - path.includes("/.well-known/oauth-authorization-server") || - path.includes("/.well-known/openid-configuration") - ) { - return "discovery"; - } - // Match the endpoint as the final path segment (trailing slash tolerated) so a - // nested path like `/token/refresh` or `/api/register/foo` is not misclassified. - const endpoint = path.replace(/\/+$/, ""); - if (endpoint.endsWith("/register")) { - return "registration"; - } - if (endpoint.endsWith("/token")) { - return "token"; - } - if (endpoint.endsWith("/authorize")) { - return "authorize"; - } - return undefined; -} - -/** Human-readable badge label for a phase. */ -export function oauthNetworkPhaseLabel(phase: OAuthNetworkPhase): string { - return PHASE_LABELS[phase]; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts deleted file mode 100644 index b3d440045..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/sandbox-csp.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge"; - -/** - * Allowed shapes for a CSP source-expression supplied by an app's - * `_meta.ui.csp`. Each entry is server-supplied and untrusted: it MUST NOT - * inject extra directives (`;`) or break out of the meta attribute - * (`"`, `<`, `>`). Only common source forms are accepted — - * `scheme://host[:port][/path]`, scheme-only (`data:`, `blob:`), `*`, and - * wildcard hosts (`*.example.com`, `https://*.example.com`); anything else is - * dropped by {@link approveCspSources}. - */ -export const SAFE_CSP_SOURCE = - /^(?:\*|[a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/; - -/** - * Identity helper that pins `CSP_KEYS` to an exhaustive, valid key list at - * compile time. `satisfies readonly (keyof McpUiResourceCsp)[]` alone only - * proves every *listed* key is valid; it does NOT prove the list is *complete*. - * The intersected conditional adds the missing half: when `CSP_KEYS` covers - * every key, `keyof McpUiResourceCsp extends T[number]` holds and the parameter - * type is just `T`; if the upstream ext-apps type ever gains a new domain key, - * the conditional collapses the parameter type to `never` and the call fails to - * compile — forcing the key to be added here rather than being silently dropped - * (a vanished restriction) by {@link approveCspSources}. - */ -function exhaustiveCspKeys( - keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never), -): T { - return keys; -} - -const CSP_KEYS = exhaustiveCspKeys([ - "connectDomains", - "resourceDomains", - "frameDomains", - "baseUriDomains", -]); - -/** - * Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host - * will actually enforce. Unsafe values are dropped (and warned), and the - * resulting object contains only keys with at least one accepted source. The - * return value is what the host echoes back to the view via - * `hostCapabilities.sandbox.csp` so the app sees what was granted, not what it - * asked for. - * - * NOTE: this screens each source for *injection safety* only — it does NOT - * bound the *breadth* of a grant. A bare `*` (and a scheme-wildcard host) is a - * syntactically safe source, so `resourceDomains: ["*"]` is "approved" and maps - * to `script-src 'unsafe-inline' *` (scripts from anywhere), just as - * `connectDomains: ["*"]` maps to `connect-src *`. That breadth is acceptable - * here because the enforced document runs in an opaque-origin sandbox with no - * ambient credentials and nothing to exfiltrate beyond what the app already - * received; "approved" therefore means "cannot break out of the meta - * attribute," not "restrictive." - */ -export function approveCspSources( - csp: McpUiResourceCsp | undefined, -): McpUiResourceCsp { - const approved: McpUiResourceCsp = {}; - if (!csp) return approved; - for (const key of CSP_KEYS) { - const requested = csp[key]; - if (!Array.isArray(requested)) continue; - const accepted: string[] = []; - for (const entry of requested) { - if (typeof entry === "string" && SAFE_CSP_SOURCE.test(entry)) { - accepted.push(entry); - } else { - console.warn("[mcp-app sandbox] dropping unsafe CSP source:", entry); - } - } - if (accepted.length > 0) approved[key] = accepted; - } - return approved; -} - -function joinSources(list: string[] | undefined, fallback: string): string { - return list && list.length > 0 ? list.join(" ") : fallback; -} - -/** - * Translate an approved {@link McpUiResourceCsp} into the Content-Security-Policy - * string enforced on the inner sandboxed document. `default-src 'none'` is the - * catch-all so any fetch type not explicitly mapped is denied. `script-src` / - * `style-src` carry `'unsafe-inline'` because the app's own inline code ships - * with the inline-delivered HTML and has no origin to allowlist; external loads - * stay restricted to `resourceDomains`. - * - * `resourceDomains` intentionally feeds `script-src` (and `style-src`) in - * addition to `img-src`/`font-src`/`media-src`: the `McpUiResourceCsp` contract - * defines it as a single "static resources" allowlist that "Maps to CSP - * `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives," so - * an app that lists a CDN there is granted script execution from that origin by - * design. There is no narrower per-directive key in the contract; if the spec - * ever splits scripts out, update the mapping here accordingly. - */ -export function buildSandboxCspPolicy(approved: McpUiResourceCsp): string { - const resourceSrc = joinSources(approved.resourceDomains, "'none'"); - const inlineResource = - resourceSrc === "'none'" - ? "'unsafe-inline'" - : `'unsafe-inline' ${resourceSrc}`; - return [ - "default-src 'none'", - `connect-src ${joinSources(approved.connectDomains, "'none'")}`, - `script-src ${inlineResource}`, - `style-src ${inlineResource}`, - `img-src ${resourceSrc}`, - `font-src ${resourceSrc}`, - `media-src ${resourceSrc}`, - `frame-src ${joinSources(approved.frameDomains, "'none'")}`, - `base-uri ${joinSources(approved.baseUriDomains, "'self'")}`, - "form-action 'none'", - "object-src 'none'", - "worker-src 'none'", - ].join("; "); -} - -/** HTML-attribute-encode a string (defense-in-depth for the CSP meta value). */ -export function escapeHtmlAttr(s: string): string { - return s - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">"); -} - -/** - * Wrap an app's untrusted HTML in a host-authored document whose first - * `` child is the CSP ``. The wrapper bytes are fixed — the - * untrusted content lands inside `` and never precedes the policy, so a - * ``/`` token in the app's HTML cannot push the meta inert or - * load resources before the policy applies. If the app's HTML is itself a full - * document, the second ``/``/`` are parsed inside - * `` (the HTML parser ignores duplicate document-structure tags) while - * its scripts and styles still run — governed by the already-applied policy. - */ -export function wrapSandboxedHtml( - untrustedHtml: string, - policy: string, -): string { - const meta = ``; - return `${meta}${untrustedHtml}`; -} diff --git a/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts b/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts deleted file mode 100644 index 247e31b41..000000000 --- a/packages/workbench/src/inspector/vendor/clients/web/src/utils/toolUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Tool } from "@modelcontextprotocol/client"; - -/** - * Returns the display label for an MCP entity that follows the BaseMetadata - * shape (Tool, Prompt, Resource): the optional `title` if provided, else the - * machine `name`. Centralized so list items, detail panels, and screens stay - * consistent. - */ -export function resolveDisplayLabel(name: string, title?: string): string { - return title ?? name; -} - -/** - * True when the tool's input schema declares at least one property — used by - * App-flow callers to decide whether to render a form or auto-launch. Kept in - * one place so the definition of "has fields" stays consistent if it ever - * grows to consider `additionalProperties`, `anyOf`, etc. - */ -export function hasInputFields(tool: Tool): boolean { - return Object.keys(tool.inputSchema.properties ?? {}).length > 0; -} diff --git a/packages/workbench/src/inspector/vendor/core/auth/providers.ts b/packages/workbench/src/inspector/vendor/core/auth/providers.ts deleted file mode 100644 index 12808542c..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/providers.ts +++ /dev/null @@ -1,356 +0,0 @@ -import type { - OAuthClientProvider, - OAuthClientInformationContext, -} from "@modelcontextprotocol/client"; -import type { - OAuthClientInformation, - OAuthClientMetadata, - OAuthTokens, - OAuthMetadata, - OAuthDiscoveryState, -} from "@modelcontextprotocol/client"; -import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js"; -import { generateOAuthState } from "./utils.js"; - -/** - * Redirect URL provider. Returns the redirect URL for OAuth flows. - * Caller populates the URL before authenticate() (e.g. from callback server). - */ -export interface RedirectUrlProvider { - getRedirectUrl(): string; -} - -/** - * Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl - * before authenticate(). - */ -export class MutableRedirectUrlProvider implements RedirectUrlProvider { - redirectUrl = ""; - - getRedirectUrl(): string { - return this.redirectUrl; - } -} - -/** - * Navigation handler interface - * Handles navigation to authorization URLs - */ -export interface OAuthNavigation { - /** - * Navigate to the authorization URL - * @param authorizationUrl - The OAuth authorization URL - */ - navigateToAuthorization(authorizationUrl: URL): void; -} - -export type OAuthNavigationCallback = ( - authorizationUrl: URL, -) => void | Promise; - -/** - * Callback navigation handler - * Invokes the provided callback when navigation is requested. - * The caller always handles navigation. - */ -export class CallbackNavigation implements OAuthNavigation { - private authorizationUrl: URL | null = null; - private callback: OAuthNavigationCallback; - - constructor(callback: OAuthNavigationCallback) { - this.callback = callback; - } - - navigateToAuthorization(authorizationUrl: URL): void { - this.authorizationUrl = authorizationUrl; - const result = this.callback(authorizationUrl); - if (result instanceof Promise) { - void result; - } - } - - getAuthorizationUrl(): URL | null { - return this.authorizationUrl; - } -} - -/** - * Console navigation handler - * Prints the authorization URL to console, optionally invokes an extra callback. - */ -export class ConsoleNavigation extends CallbackNavigation { - constructor(callback?: OAuthNavigationCallback) { - super((url) => { - console.log(`Please navigate to: ${url.href}`); - return callback?.(url); - }); - } -} - -/** - * Config passed to BaseOAuthClientProvider. Provider assigns to members and - * accesses as needed. - */ -export type OAuthProviderConfig = { - storage: OAuthStorage; - redirectUrlProvider: RedirectUrlProvider; - navigation: OAuthNavigation; - clientMetadataUrl?: string; -}; - -/** - * Base OAuth client provider - * Implements common OAuth provider functionality. - * Use with injected storage, redirect URL provider, and navigation. - */ -export class BaseOAuthClientProvider implements OAuthClientProvider { - private capturedAuthUrl: URL | null = null; - private eventTarget: EventTarget | null = null; - private suppressAuthorizationNavigation = false; - /** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */ - private cachedScope: string | undefined; - - protected serverUrl: string; - protected storage: OAuthStorage; - protected redirectUrlProvider: RedirectUrlProvider; - protected navigation: OAuthNavigation; - public clientMetadataUrl?: string; - - constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { - this.serverUrl = serverUrl; - this.storage = oauthConfig.storage; - this.redirectUrlProvider = oauthConfig.redirectUrlProvider; - this.navigation = oauthConfig.navigation; - this.clientMetadataUrl = oauthConfig.clientMetadataUrl; - } - - /** - * Load persisted scope into {@link cachedScope} before SDK `auth()` (which - * reads {@link clientMetadata.scope} synchronously). - */ - async prepareForAuth(): Promise { - this.cachedScope = await this.storage.getScope(this.serverUrl); - } - - /** - * Set the event target for dispatching oauthAuthorizationRequired events - */ - setEventTarget(eventTarget: EventTarget): void { - this.eventTarget = eventTarget; - } - - /** - * Get the captured authorization URL (for return value) - */ - getCapturedAuthUrl(): URL | null { - return this.capturedAuthUrl; - } - - /** - * Clear the captured authorization URL - */ - clearCapturedAuthUrl(): void { - this.capturedAuthUrl = null; - } - - /** Capture authorize URL without navigating (step-up confirmation modal). */ - setSuppressAuthorizationNavigation(suppress: boolean): void { - this.suppressAuthorizationNavigation = suppress; - } - - get scope(): string | undefined { - return this.cachedScope; - } - - get redirectUrl(): string { - return this.redirectUrlProvider.getRedirectUrl(); - } - - get redirect_uris(): string[] { - return [this.redirectUrl]; - } - - get clientMetadata(): OAuthClientMetadata { - const metadata: OAuthClientMetadata = { - redirect_uris: this.redirect_uris, - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - client_uri: "https://github.com/modelcontextprotocol/inspector", - scope: this.scope ?? "", - // SEP-837: the Inspector is a locally-hosted app reached over localhost, so - // it registers as a native client. OIDC-flavored ASes default an omitted - // `application_type` to `"web"`, which forbids loopback redirect URIs and - // rejects DCR. (The SDK also infers `"native"` from loopback `redirect_uris`; - // declaring it explicitly keeps the value visible and correct even when the - // redirect host is not itself a loopback literal.) - application_type: "native", - }; - - // Note: clientMetadataUrl for CIMD mode is passed to registerClient() directly, - // not as part of clientMetadata. The SDK handles CIMD separately. - - return metadata; - } - - state(): string | Promise { - return generateOAuthState(); - } - - async clientInformation( - ctx?: OAuthClientInformationContext, - ): Promise { - // Try preregistered (static, issuer-independent) first, then the per-issuer - // dynamic registration (SEP-2352 — keyed by `ctx.issuer`). - const preregistered = await this.storage.getClientInformation( - this.serverUrl, - true, - ); - if (preregistered) { - return preregistered; - } - return await this.storage.getClientInformation( - this.serverUrl, - false, - ctx?.issuer, - ); - } - - async saveClientInformation( - clientInformation: OAuthClientInformation, - // SDK v2's `OAuthClientProvider.saveClientInformation` passes an - // `OAuthClientInformationContext` ({ issuer }); our own DCR/CIMD callers - // pass `SaveClientInformationOptions` ({ registrationKind }). Accept either - // and read whichever keys are present: the SDK supplies `issuer` (SEP-2352 - // per-AS keying) and defaults registration kind to DCR; our callers supply - // the registration kind and no issuer yet. - options?: SaveClientInformationOptions | OAuthClientInformationContext, - ): Promise { - const registrationKind = - options && "registrationKind" in options - ? options.registrationKind - : "dcr"; - const issuer = options && "issuer" in options ? options.issuer : undefined; - await this.storage.saveClientInformation( - this.serverUrl, - clientInformation, - { - registrationKind, - issuer, - }, - ); - } - - async saveScope(scope: string | undefined): Promise { - await this.storage.saveScope(this.serverUrl, scope); - this.cachedScope = scope; - } - - async savePreregisteredClientInformation( - clientInformation: OAuthClientInformation, - ): Promise { - await this.storage.savePreregisteredClientInformation( - this.serverUrl, - clientInformation, - ); - } - - async tokens( - ctx?: OAuthClientInformationContext, - ): Promise { - return await this.storage.getTokens(this.serverUrl, ctx?.issuer); - } - - async saveTokens( - tokens: OAuthTokens, - ctx?: OAuthClientInformationContext, - ): Promise { - await this.storage.saveTokens(this.serverUrl, tokens, { - issuer: ctx?.issuer, - }); - } - - redirectToAuthorization(authorizationUrl: URL): void { - // Capture URL for return value - this.capturedAuthUrl = authorizationUrl; - - if (!this.suppressAuthorizationNavigation) { - if (this.eventTarget) { - this.eventTarget.dispatchEvent( - new CustomEvent("oauthAuthorizationRequired", { - detail: { url: authorizationUrl }, - }), - ); - } - this.navigation.navigateToAuthorization(authorizationUrl); - } - } - - async saveCodeVerifier(codeVerifier: string): Promise { - await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier); - } - - async codeVerifier(): Promise { - const verifier = await this.storage.getCodeVerifier(this.serverUrl); - if (!verifier) { - throw new Error("No code verifier saved for session"); - } - return verifier; - } - - async clear(): Promise { - await this.storage.clear(this.serverUrl); - } - - async getServerMetadata(): Promise { - return this.storage.getServerMetadata(this.serverUrl); - } - - async saveServerMetadata(metadata: OAuthMetadata): Promise { - await this.storage.saveServerMetadata(this.serverUrl, metadata); - } - - /** - * SEP-2352 discovery-state round-trip. The SDK persists RFC 9728/8414 discovery - * here (alongside the code verifier) so that on the authorization-code callback - * leg it can compare the resolved AS `issuer` against the one recorded at - * redirect time and reject a mismatch (`AuthorizationServerMismatchError`). - * Without these two methods the SDK only `console.warn`s and the binding check - * is inactive. - */ - async saveDiscoveryState(state: OAuthDiscoveryState): Promise { - await this.storage.saveDiscoveryState(this.serverUrl, state); - } - - async discoveryState(): Promise { - return this.storage.getDiscoveryState(this.serverUrl); - } - - /** - * SEP-2352 credential invalidation. The SDK calls this to drop credentials the - * server has rejected; hosts also call `'discovery'` on repeated 401s so a - * changed `authorization_servers` list is re-fetched. - */ - async invalidateCredentials( - scope: "all" | "client" | "tokens" | "verifier" | "discovery", - ): Promise { - switch (scope) { - case "all": - await this.storage.clear(this.serverUrl); - return; - case "client": - await this.storage.clearClientInformation(this.serverUrl); - return; - case "tokens": - await this.storage.clearTokens(this.serverUrl); - return; - case "verifier": - await this.storage.clearCodeVerifier(this.serverUrl); - return; - case "discovery": - await this.storage.clearDiscoveryState(this.serverUrl); - return; - } - } -} diff --git a/packages/workbench/src/inspector/vendor/core/auth/storage.ts b/packages/workbench/src/inspector/vendor/core/auth/storage.ts deleted file mode 100644 index 90163c19d..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/storage.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - OAuthClientInformation, - OAuthTokens, - OAuthMetadata, - OAuthDiscoveryState, -} from "@modelcontextprotocol/client"; -import type { OAuthClientRegistrationKind } from "./types.js"; - -/** - * Abstract storage interface for OAuth state - * Supports browser (sessionStorage), Node.js (file), and remote HTTP backends. - */ -export interface SaveTokensOptions { - /** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */ - enterpriseManaged?: boolean; - /** - * Authorization-server `issuer` these tokens are bound to (SEP-2352). When set, - * tokens are keyed under `(server, issuer)`; when omitted (EMA / legacy callers) - * they write the per-server fallback slot. - */ - issuer?: string; -} - -export type { OAuthClientRegistrationKind }; - -export interface SaveClientInformationOptions { - registrationKind: "dcr" | "cimd"; - /** - * Authorization-server `issuer` this client registration is bound to (SEP-2352). - * Client identifiers are unique to the AS that issued them (RFC 6749 §2.2). - */ - issuer?: string; -} - -export interface OAuthStorage { - /** - * Optional preload of persisted state into memory. Getters and setters load - * automatically when needed; use this only for fail-fast at known boundaries - * (e.g. OAuth callback resume after a full-page navigation). - */ - load(): Promise; - - /** - * Get client information (preregistered or dynamically registered). - * - * @param issuer - When set, return the registration bound to this AS `issuer` - * (SEP-2352). When omitted, return the active-issuer slot, falling back to - * the legacy unkeyed entry. - */ - getClientInformation( - serverUrl: string, - isPreregistered?: boolean, - issuer?: string, - ): Promise; - - /** - * Get how the dynamic client registration slot was established. - */ - getClientRegistrationKind( - serverUrl: string, - issuer?: string, - ): Promise; - - /** - * Save client information (dynamically registered) - */ - saveClientInformation( - serverUrl: string, - clientInformation: OAuthClientInformation, - options: SaveClientInformationOptions, - ): Promise; - - /** - * Save preregistered client information (static client from config) - */ - savePreregisteredClientInformation( - serverUrl: string, - clientInformation: OAuthClientInformation, - ): Promise; - - /** - * Clear client information. When `issuer` is set, clear only that AS's - * registration; when omitted, clear every issuer's registration plus the - * legacy unkeyed entry. - */ - clearClientInformation( - serverUrl: string, - isPreregistered?: boolean, - issuer?: string, - ): Promise; - - /** - * Get OAuth tokens. When `issuer` is set, return that AS's tokens (SEP-2352); - * when omitted, return the active-issuer tokens, falling back to the legacy - * unkeyed entry (the transport's per-request bearer read). - */ - getTokens( - serverUrl: string, - issuer?: string, - ): Promise; - - /** - * Save OAuth tokens - */ - saveTokens( - serverUrl: string, - tokens: OAuthTokens, - options?: SaveTokensOptions, - ): Promise; - - /** - * Clear OAuth tokens. When `issuer` is set, clear only that AS's tokens; when - * omitted, clear every issuer's tokens plus the legacy unkeyed entry. - */ - clearTokens(serverUrl: string, issuer?: string): Promise; - - /** - * Get code verifier (for PKCE) - */ - getCodeVerifier(serverUrl: string): Promise; - - /** - * Save code verifier (for PKCE) - */ - saveCodeVerifier(serverUrl: string, codeVerifier: string): Promise; - - /** - * Clear code verifier - */ - clearCodeVerifier(serverUrl: string): Promise; - - /** - * Get scope - */ - getScope(serverUrl: string): Promise; - - /** - * Save scope - */ - saveScope(serverUrl: string, scope: string | undefined): Promise; - - /** - * Clear scope - */ - clearScope(serverUrl: string): Promise; - - /** - * Get server metadata discovered during OAuth - */ - getServerMetadata(serverUrl: string): Promise; - - /** - * Save server metadata discovered during OAuth - */ - saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise; - - /** - * Clear server metadata - */ - clearServerMetadata(serverUrl: string): Promise; - - /** - * Get the cached RFC 9728/8414 discovery state (SEP-2352). The SDK restores it - * to skip re-discovery and, on the authorization-code callback leg, to bind the - * exchange to the AS that minted the code (`AuthorizationServerMismatchError`). - */ - getDiscoveryState( - serverUrl: string, - ): Promise; - - /** - * Save the RFC 9728/8414 discovery state. Persisted alongside the code verifier - * so it survives the authorization redirect round-trip. - */ - saveDiscoveryState( - serverUrl: string, - state: OAuthDiscoveryState, - ): Promise; - - /** - * Clear the cached discovery state (SDK `invalidateCredentials('discovery')`). - */ - clearDiscoveryState(serverUrl: string): Promise; - - /** - * Clear all OAuth data for a server - */ - clear(serverUrl: string): Promise; - - /** - * Get cached IdP OIDC session for EMA (keyed by issuer). - */ - getIdpSession(issuer: string): Promise; - - /** - * Save IdP OIDC session fields for EMA. - */ - saveIdpSession( - issuer: string, - session: Partial, - ): Promise; - - /** - * Clear cached IdP session for an issuer. - */ - clearIdpSession(issuer: string): Promise; - - /** - * Remove per-server OAuth state for MCP servers whose tokens were minted via EMA. - */ - clearEnterpriseManagedResourceServers(): Promise; -} - -/** - * Cached IdP OIDC session for EMA leg 1. - */ -export interface IdpSessionState { - idToken?: string; - refreshToken?: string; - /** Epoch ms when the ID Token expires (when known). */ - idTokenExpiresAt?: number; -} - -/** - * Generate server-specific storage key - */ -export function getServerSpecificKey( - baseKey: string, - serverUrl: string, -): string { - return `[${serverUrl}] ${baseKey}`; -} - -/** - * Base storage keys for OAuth data - */ -export const OAUTH_STORAGE_KEYS = { - CODE_VERIFIER: "mcp_code_verifier", - TOKENS: "mcp_tokens", - CLIENT_INFORMATION: "mcp_client_information", - PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", - SERVER_METADATA: "mcp_server_metadata", - SCOPE: "mcp_scope", -} as const; diff --git a/packages/workbench/src/inspector/vendor/core/auth/types.ts b/packages/workbench/src/inspector/vendor/core/auth/types.ts deleted file mode 100644 index b21d7cf1a..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/types.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - OAuthMetadata, - OAuthClientInformation, - OAuthClientInformationFull, - OAuthTokens, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/client"; - -// OAuth flow steps. Extended for the 2026-07-28 authorization hardening: -// `cimd_fetch` (SEP-991 client-id metadata document registration), `issuer_comparison` -// (SEP-2352 authorization-server binding check), and `scope_step_up` (SEP-2350 -// accumulated-scope re-authorization). -export type OAuthStep = - | "metadata_discovery" - | "client_registration" - /** SEP-991 — registering via a Client ID Metadata Document instead of DCR. */ - | "cimd_fetch" - /** SEP-2352 — comparing the resolved AS `issuer` against stored/discovered state. */ - | "issuer_comparison" - | "authorization_redirect" - | "authorization_code" - /** SEP-2350 — re-authorizing with the accumulated (prior ∪ challenged) scope union. */ - | "scope_step_up" - | "token_request" - | "complete"; - -// Message types for inline feedback -export type MessageType = "success" | "error" | "info"; - -export interface StatusMessage { - type: MessageType; - message: string; -} - -/** Which authorization protocol applies. */ -export type AuthProtocol = "standard" | "ema"; - -/** How the active OAuth client id was established for this MCP server. */ -export type OAuthClientRegistrationKind = "static" | "dcr" | "cimd"; - -/** Persisted OAuth authorization snapshot for an HTTP MCP server (storage + config). */ -export interface OAuthConnectionState { - authorized: boolean; - protocol: AuthProtocol; - serverUrl: string; - configuredScope?: string; - grantedScope?: string; - tokens?: OAuthTokens; - client?: { - clientId: string; - /** Absent for legacy storage entries predating registration kind tracking. */ - registrationKind?: OAuthClientRegistrationKind; - hasClientSecret: boolean; - }; - authorizationServerMetadata?: OAuthMetadata; - enterpriseManaged?: boolean; - ema?: { - idpIssuer: string; - idpClientId: string; - idpSession: "none" | "logged_in" | "expired"; - idpMetadata?: OAuthMetadata; - }; -} - -export function authProtocolFromEnterpriseManaged( - enterpriseManaged?: boolean, -): AuthProtocol { - return enterpriseManaged ? "ema" : "standard"; -} - -/** In-memory snapshot while an OAuth flow is active or just completed. */ -export interface OAuthFlowState { - /** When auth reached step "complete" (ms since epoch), if applicable. */ - completedAt: number | null; - isInitiatingAuth: boolean; - oauthTokens: OAuthTokens | null; - oauthStep: OAuthStep; - resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: Error | null; - resource: URL | null; - authServerUrl: URL | null; - oauthMetadata: OAuthMetadata | null; - oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; - authorizationUrl: URL | null; - authorizationCode: string; - latestError: Error | null; - statusMessage: StatusMessage | null; - validationError: string | null; -} - -export const EMPTY_OAUTH_FLOW_STATE: OAuthFlowState = { - completedAt: null, - isInitiatingAuth: false, - oauthTokens: null, - oauthStep: "authorization_code", - oauthMetadata: null, - resourceMetadata: null, - resourceMetadataError: null, - resource: null, - authServerUrl: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, -}; - -// The parsed query parameters returned by the Authorization Server -// representing either a valid authorization_code or an error -// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2 -export type CallbackParams = - | { - successful: true; - // The authorization code is generated by the authorization server. - code: string; - // RFC 9207 `iss`. Present only when the authorization server includes it. - // Forwarded to the SDK, which validates it against the issuer in the - // validated metadata (RFC 9207 §2.4) to detect mix-up attacks. Servers - // advertising `authorization_response_iss_parameter_supported: true` MUST - // send it, and the SDK rejects the callback when it is missing. - iss?: string; - } - | { - successful: false; - // The OAuth 2.1 Error Code. - // Usually one of: - // ``` - // invalid_request, unauthorized_client, access_denied, unsupported_response_type, - // invalid_scope, server_error, temporarily_unavailable - // ``` - error: string; - // Human-readable ASCII text providing additional information, used to assist the - // developer in understanding the error that occurred. - error_description: string | null; - // A URI identifying a human-readable web page with information about the error, - // used to provide the client developer with additional information about the error. - error_uri: string | null; - }; diff --git a/packages/workbench/src/inspector/vendor/core/auth/utils.ts b/packages/workbench/src/inspector/vendor/core/auth/utils.ts deleted file mode 100644 index e4f587ab2..000000000 --- a/packages/workbench/src/inspector/vendor/core/auth/utils.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { UnauthorizedError } from "@modelcontextprotocol/client"; -import type { CallbackParams } from "./types.js"; -import { ZodError } from "zod"; - -type ZodIssueLike = { - path?: unknown[]; - message?: string; - code?: string; -}; - -function isZodIssueArray(value: unknown): value is ZodIssueLike[] { - return ( - Array.isArray(value) && - value.length > 0 && - typeof value[0] === "object" && - value[0] !== null && - "code" in value[0] - ); -} - -function formatZodIssues(issues: ZodIssueLike[]): string { - const tokenResponseIssue = issues.some( - (issue) => - Array.isArray(issue.path) && - (issue.path.includes("access_token") || - issue.path.includes("token_type")), - ); - if (tokenResponseIssue) { - return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again."; - } - return issues - .map((issue) => { - const path = - Array.isArray(issue.path) && issue.path.length - ? issue.path.join(".") - : "input"; - return `${path}: ${issue.message ?? "invalid"}`; - }) - .join(" "); -} - -/** - * Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON). - */ -export function formatOAuthFailureDetail(detail: unknown): string { - if (detail instanceof ZodError) { - return formatZodIssues(detail.issues); - } - const raw = - detail instanceof Error - ? detail.message - : typeof detail === "string" - ? detail - : String(detail); - const trimmed = raw.trim(); - if (trimmed.startsWith("[")) { - try { - const parsed: unknown = JSON.parse(trimmed); - if (isZodIssueArray(parsed)) { - return formatZodIssues(parsed); - } - } catch { - // fall through - } - } - return raw; -} - -/** - * Parse a string as an absolute URL. On failure, throws with `label` and the - * offending value so callers (and UI toasts) can show what to fix. - */ -export function parseHttpUrl(value: string, label: string): URL { - const trimmed = value.trim(); - try { - return new URL(trimmed); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`, { - cause: err, - }); - } -} - -/** - * Parses OAuth 2.1 callback parameters from a URL search string - * @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied") - * @returns Parsed callback parameters with success/error information - */ -export const parseOAuthCallbackParams = (location: string): CallbackParams => { - const params = new URLSearchParams(location); - - const code = params.get("code"); - if (code) { - const iss = params.get("iss"); - return iss === null - ? { successful: true, code } - : { successful: true, code, iss }; - } - - const error = params.get("error"); - const error_description = params.get("error_description"); - const error_uri = params.get("error_uri"); - - if (error) { - return { successful: false, error, error_description, error_uri }; - } - - return { - successful: false, - error: "invalid_request", - error_description: "Missing code or error in response", - error_uri: null, - }; -}; - -/** - * Generate a random state for the OAuth 2.0 flow. - * Works in both browser and Node.js environments. - * - * @returns A random state for the OAuth 2.0 flow. - */ -export const generateOAuthState = (): string => { - // OAuth state is a CSRF token — it MUST be unpredictable. crypto.getRandomValues - // is available in every supported runtime (browsers, Node ≥15); if it's somehow - // missing, fail loudly rather than silently degrading to Math.random (whose - // output is predictable from a small amount of observed state). - if (typeof crypto === "undefined" || !crypto.getRandomValues) { - throw new Error( - "crypto.getRandomValues is not available; refusing to generate an OAuth state with a non-cryptographic RNG.", - ); - } - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -}; - -/** - * Parse OAuth `state` to extract the auth session id (CSRF token). - * Must be the 64-char hex value from {@link generateOAuthState}. - */ -export const parseOAuthState = (state: string): { authId: string } | null => { - if (!state || typeof state !== "string") return null; - if (/^[a-f0-9]{64}$/i.test(state)) { - return { authId: state }; - } - return null; -}; - -/** - * Generates a human-readable error description from OAuth callback error parameters - * @param params OAuth error callback parameters containing error details - * @returns Formatted multiline error message with error code, description, and optional URI - */ -export const generateOAuthErrorDescription = ( - params: Extract, -): string => { - const error = params.error; - const errorDescription = params.error_description; - const errorUri = params.error_uri; - - return [ - `Error: ${error}.`, - errorDescription ? `Details: ${errorDescription}.` : "", - errorUri ? `More info: ${errorUri}.` : "", - ] - .filter(Boolean) - .join("\n"); -}; - -/** - * True when a thrown connect error represents an upstream 401. The remote - * transport preserves the status on the error object; as a fallback, match - * transport wording `"failed …(401)"` so unrelated `(401)` in messages does - * not trigger OAuth. - * - * Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as - * `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at - * `error.data.cause` (and sometimes native `error.cause`). Walk that chain so - * OAuth recovery still starts. - */ -export function isUnauthorizedError(err: unknown): boolean { - return isUnauthorizedErrorDeep(err, new Set()); -} - -function isUnauthorizedErrorDeep(err: unknown, seen: Set): boolean { - if (err == null) return false; - if (typeof err !== "object") { - return /\bfailed\b[^\n]*\(401\)/i.test(String(err)); - } - if (seen.has(err)) return false; - seen.add(err); - - if (UnauthorizedError.isInstance(err)) return true; - - const status = (err as { status?: number }).status; - const code = (err as { code?: unknown }).code; - if (status === 401 || code === 401) return true; - - if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) { - return true; - } - - if ( - "cause" in err && - isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen) - ) { - return true; - } - - const data = (err as { data?: unknown }).data; - if ( - data !== null && - typeof data === "object" && - "cause" in data && - isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen) - ) { - return true; - } - - return false; -} diff --git a/packages/workbench/src/inspector/vendor/core/client/types.ts b/packages/workbench/src/inspector/vendor/core/client/types.ts deleted file mode 100644 index ff02f3402..000000000 --- a/packages/workbench/src/inspector/vendor/core/client/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Install-level client configuration (IdP / EMA settings, later client identity). - * Persisted in ~/.mcp-inspector/storage/client.json via /api/storage/client. - */ - -/** OIDC client credentials for the enterprise IdP (legs 1–2). */ -export interface EnterpriseManagedAuthIdpConfig { - issuer: string; - clientId: string; - /** Present after keychain merge; omitted from on-disk client.json. */ - clientSecret?: string; -} - -/** Install-level CIMD (Client ID Metadata Document) settings. */ -export interface CimdConfig { - /** When false, the metadata URL is kept but CIMD is inactive install-wide. */ - enabled?: boolean; - clientMetadataUrl: string; -} - -export interface ClientConfig { - enterpriseManagedAuth?: { - /** When false, IdP credentials are kept but EMA is inactive install-wide. */ - enabled?: boolean; - idp: EnterpriseManagedAuthIdpConfig; - }; - cimd?: CimdConfig; -} - -/** True when install-level EMA IdP config is active (not just stored). */ -export function isEnterpriseManagedAuthEnabled(config: ClientConfig): boolean { - const ema = config.enterpriseManagedAuth; - if (!ema?.idp) return false; - return ema.enabled !== false; -} - -export function getActiveEnterpriseManagedAuthIdp( - config: ClientConfig, -): EnterpriseManagedAuthIdpConfig | undefined { - if (!isEnterpriseManagedAuthEnabled(config)) return undefined; - return config.enterpriseManagedAuth!.idp; -} - -/** True when install-level CIMD is active (not just stored). */ -export function isCimdEnabled(config: ClientConfig): boolean { - return config.cimd?.enabled === true; -} - -export function getActiveCimdClientMetadataUrl( - config: ClientConfig, -): string | undefined { - if (!isCimdEnabled(config)) return undefined; - const url = config.cimd?.clientMetadataUrl?.trim(); - return url || undefined; -} diff --git a/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts b/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts deleted file mode 100644 index 7366bb9cb..000000000 --- a/packages/workbench/src/inspector/vendor/core/json/jsonUtils.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Tool } from "@modelcontextprotocol/client"; - -/** - * JSON value type used across the inspector project - */ -export type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonObject = { [key: string]: JsonValue }; - -/** - * Widen a typed object to a generic string-keyed record so its keys can be - * iterated or read/written generically. Many of the project's config/SDK types - * (`StoredMCPServer`, `MCPServerConfig`, `pino.Logger`, DOM `Window`, …) have no - * index signature, so a direct `value as Record` at a call - * site is a TS2352 error that would otherwise force an `as unknown as` double - * cast. Taking the argument as the general `object` type makes the single `as` - * legal — `Record` is assignable to `object`, so the two types - * sufficiently overlap — letting this one audited spot own the widening while - * the double casts stay out of the call sites. Purely a structural view of the - * same object; no runtime effect. - */ -export function toRecord(value: object): Record { - return value as Record; -} - -/** - * Simple schema type for parameter conversion - */ -type ParameterSchema = { - type?: string; -}; - -/** - * Convert a string parameter value to the appropriate JSON type based on schema - */ -export function convertParameterValue( - value: string, - schema: ParameterSchema, -): JsonValue { - if (!value) { - return value; - } - - if (schema.type === "number" || schema.type === "integer") { - return Number(value); - } - - if (schema.type === "boolean") { - return value.toLowerCase() === "true"; - } - - if (schema.type === "object" || schema.type === "array") { - try { - return JSON.parse(value) as JsonValue; - } catch { - return value; - } - } - - return value; -} - -/** - * Convert string parameters to JSON values based on tool schema - */ -export function convertToolParameters( - tool: Tool, - params: Record, -): Record { - const result: Record = {}; - const properties = tool.inputSchema?.properties || {}; - - for (const [key, value] of Object.entries(params)) { - const paramSchema = properties[key] as ParameterSchema | undefined; - - if (paramSchema) { - result[key] = convertParameterValue(value, paramSchema); - } else { - result[key] = value; - } - } - - return result; -} - -/** - * Convert prompt arguments (JsonValue) to strings for prompt API - */ -export function convertPromptArguments( - args: Record, -): Record { - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } else if (value === null || value === undefined) { - stringArgs[key] = String(value); - } else { - stringArgs[key] = JSON.stringify(value); - } - } - return stringArgs; -} diff --git a/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts b/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts deleted file mode 100644 index 739eb6a2b..000000000 --- a/packages/workbench/src/inspector/vendor/core/json/xMcpHeader.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * SEP-2243 `x-mcp-header` annotation tooling. - * - * A modern (≥2026-07-28) Streamable HTTP server may annotate a tool - * `inputSchema` property with `x-mcp-header: "{Name}"`; a conforming client then - * mirrors that argument's value into an `Mcp-Param-{Name}` HTTP header on the - * `tools/call`. The spec places strict constraints on which properties may carry - * the annotation, and — crucially for a debugging tool — makes a *violating* - * annotation invalidate the WHOLE tool: a Streamable HTTP client MUST drop such - * a tool from `tools/list`. - * - * The client SDK enforces that exclusion internally (its `listTools()` silently - * filters invalid tools), but it does not surface *which* tools were dropped or - * *why*. This module re-implements the SDK's scan so the Inspector can show - * excluded tools with their reason, and indicate which args mirror to headers on - * the tools it keeps. It is a faithful port of the SDK's - * `scanXMcpHeaderDeclarations` (the helper is not part of the SDK's public - * surface), kept pure and fully unit-testable — no rendering, no I/O. - */ - -import type { Tool } from "@modelcontextprotocol/client"; - -/** The schema-extension property name a tool's `inputSchema` carries. */ -export const X_MCP_HEADER_KEY = "x-mcp-header"; - -/** The fixed prefix every mirrored custom-parameter header carries. */ -export const MCP_PARAM_HEADER_PREFIX = "Mcp-Param-"; - -/** - * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control - * characters (including CR/LF), and the listed HTTP delimiters. - */ -const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - -/** - * JSON Schema `type` values the spec admits on an `x-mcp-header` property. - * - * The spec text names `integer`, `string`, `boolean` and explicitly excludes - * `number`. The published conformance referee at the pinned release ships its - * `http-custom-headers` scenario with `type: "number"` `x-mcp-header` params and - * expects the client to mirror them, so the SDK accepts `number` for the - * conformance gate; this port matches the SDK so exclusions agree exactly. - * Everything else (`object`, `array`, `null`, absent) is rejected. - */ -const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set([ - "string", - "integer", - "boolean", - "number", -]); - -/** - * JSON Schema keywords whose subschemas the static-reachability constraint - * excludes from the `properties`-only chain. An `x-mcp-header` found under any - * of these invalidates the tool definition. - */ -const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ - "items", - "prefixItems", - "contains", - "additionalProperties", - "unevaluatedProperties", - "unevaluatedItems", - "propertyNames", - "patternProperties", - "dependentSchemas", - "oneOf", - "anyOf", - "allOf", - "not", - "if", - "then", - "else", - "$defs", - "definitions", -] as const; - -/** - * Subschema-carrying keywords whose value is a `name → subschema` object (not a - * single subschema or array of subschemas). The visit branches over - * `Object.values()` for these. - */ -const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet = new Set([ - "patternProperties", - "dependentSchemas", - "$defs", - "definitions", -]); - -/** One validated `x-mcp-header` declaration found on a tool's input schema. */ -export interface XMcpHeaderDeclaration { - /** The chain of `properties` keys locating the annotated property. */ - path: string[]; - /** The declared header suffix — the `{Name}` in `Mcp-Param-{Name}`. */ - headerName: string; - /** The property's JSON Schema `type` (a permitted primitive). */ - type: string; -} - -/** The result of scanning a tool's input schema for `x-mcp-header` usage. */ -export type XMcpHeaderScan = - | { valid: true; declarations: XMcpHeaderDeclaration[] } - | { valid: false; reason: string }; - -function pathName(path: string[]): string { - return path.length === 0 ? "" : path.join("."); -} - -function isRecord(node: unknown): node is Record { - return node !== null && typeof node === "object"; -} - -/** - * Scan a tool's `inputSchema` for `x-mcp-header` declarations and validate every - * constraint the spec places on them. Returns the collected declarations - * (possibly empty) on success, or the first violated constraint's reason. - * - * The walk descends through `properties` at any depth (the spec's "any nesting - * depth" clause). The static-reachability MUST is enforced structurally: every - * position the chain MUST NOT pass through (`items`/`additionalProperties`, - * `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, and `$defs`/`definitions` - * bodies) is visited too, and an `x-mcp-header` found anywhere off the - * `properties` chain invalidates the schema — "an annotation anywhere else makes - * the tool definition invalid". `$ref` is never followed: a property reachable - * only through a `$ref` is therefore correctly treated as non-statically- - * reachable (its annotation, if any, lives in the unreachable `$defs` body). - */ -export function scanXMcpHeaderDeclarations( - inputSchema: unknown, -): XMcpHeaderScan { - const declarations: XMcpHeaderDeclaration[] = []; - const seenLower = new Map(); - - const visit = ( - node: unknown, - path: string[], - reachable: boolean, - ): string | undefined => { - if (!isRecord(node)) return undefined; - const schema = node; - - if (X_MCP_HEADER_KEY in schema) { - if (!reachable || path.length === 0) { - return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`; - } - const raw = schema[X_MCP_HEADER_KEY]; - if (typeof raw !== "string" || raw.length === 0) { - return `${pathName(path)}: x-mcp-header MUST be a non-empty string`; - } - if (!RFC9110_TOKEN.test(raw)) { - return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`; - } - const type = typeof schema.type === "string" ? schema.type : undefined; - if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { - return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ""}`; - } - const lower = raw.toLowerCase(); - const prior = seenLower.get(lower); - if (prior !== undefined) { - return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`; - } - seenLower.set(lower, raw); - declarations.push({ path, headerName: raw, type }); - } - - const properties = schema.properties; - if (isRecord(properties)) { - for (const [key, child] of Object.entries(properties)) { - const fault = visit(child, [...path, key], reachable); - if (fault !== undefined) return fault; - } - } - - for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) { - const sub = schema[k]; - if (sub === undefined) continue; - const branches = Array.isArray(sub) - ? sub - : isRecord(sub) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k) - ? Object.values(sub) - : [sub]; - for (const branch of branches) { - const fault = visit(branch, [...path, `<${k}>`], false); - if (fault !== undefined) return fault; - } - } - - return undefined; - }; - - const fault = visit(inputSchema, [], true); - return fault === undefined - ? { valid: true, declarations } - : { valid: false, reason: fault }; -} - -/** - * The `=?base64?…?=` sentinel wrapping a value that cannot be sent as a plain - * ASCII HTTP field value (SEP-2243 value-encoding rules). - */ -const BASE64_SENTINEL_PREFIX = "=?base64?"; -const BASE64_SENTINEL_SUFFIX = "?="; - -/** - * Convert a primitive argument value to its string form per the spec's - * type-conversion rules: strings pass through, booleans become lowercase - * `'true'`/`'false'`, integers/numbers become their decimal string. Non-finite - * numbers and integers outside the safe range are refused (returns `undefined`, - * meaning "do not emit a header for this value"). Anything non-primitive - * (object/array/null/undefined) also yields `undefined`. - */ -function mcpParamPrimitiveToString(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") { - if (!Number.isFinite(value)) return undefined; - if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - return undefined; - } - return String(value); - } - return undefined; -} - -/** - * `true` when `s` cannot be safely represented as a plain ASCII HTTP field - * value (RFC 9110 §5.5): it is empty, contains a byte outside `0x20–0x7E`/`0x09`, - * has leading/trailing whitespace (which field parsing strips), or already - * matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" rule). - */ -function needsBase64(s: string): boolean { - if (s.length === 0) return true; - if ( - s.startsWith(BASE64_SENTINEL_PREFIX) && - s.endsWith(BASE64_SENTINEL_SUFFIX) - ) { - return true; - } - if (s !== s.trim()) return true; - for (let i = 0; i < s.length; i++) { - // Non-null: `i` is always in bounds, so `codePointAt` returns a number. - const c = s.codePointAt(i)!; - if (c === 9 || (c >= 32 && c <= 126)) continue; - return true; - } - return false; -} - -function utf8ToBase64(s: string): string { - const bytes = new TextEncoder().encode(s); - let bin = ""; - for (const b of bytes) bin += String.fromCodePoint(b); - return btoa(bin); -} - -/** - * Encode a string value as an HTTP field value per SEP-2243: a value that is - * already a safe plain-ASCII field value passes through unchanged; anything - * else is wrapped as `=?base64?{b64-of-utf8}?=`. - */ -function encodeMcpParamValue(value: string): string { - return needsBase64(value) - ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` - : value; -} - -function valueAtPath(root: unknown, path: string[]): unknown { - let node: unknown = root; - for (const key of path) { - if (node === null || typeof node !== "object") return undefined; - node = (node as Record)[key]; - } - return node; -} - -/** - * Build the `Mcp-Param-{Name}` headers for one `tools/call` from validated - * `x-mcp-header` declarations and the call's `arguments`. A declaration whose - * value is `null` or absent is omitted (the spec's "client MUST omit the header" - * rows); a value that is not a primitive of the declared kind is omitted rather - * than emitted malformed. Faithful port of the SDK's internal helper (not part - * of its public surface), so the headers match what a conforming client sends. - */ -export function buildMcpParamHeaders( - declarations: XMcpHeaderDeclaration[], - args: Record, -): Record { - const out: Record = {}; - for (const decl of declarations) { - const raw = valueAtPath(args, decl.path); - if (raw === undefined || raw === null) continue; - const stringValue = mcpParamPrimitiveToString(raw); - if (stringValue === undefined) continue; - out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = - encodeMcpParamValue(stringValue); - } - return out; -} - -/** - * SEP-2243 `Mcp-Param-*` headers a `tools/call` must carry for a given tool and - * arguments. Returns `{}` when the tool declares no `x-mcp-header`, when its - * annotations are invalid (such a tool is excluded from `tools/list`), or when - * no declared argument has a mirrorable value. Callers attach the result to the - * `tools/call` request headers on a modern connection. - */ -export function mcpParamHeadersForTool( - tool: Tool, - args: Record, -): Record { - const scan = scanXMcpHeaderDeclarations(tool.inputSchema); - if (!scan.valid || scan.declarations.length === 0) return {}; - return buildMcpParamHeaders(scan.declarations, args); -} - -/** A tool the Inspector keeps, paired with its mirrored-header declarations. */ -export interface MirroredHeaderParam { - /** Dot-joined property path (e.g. `region` or `filter.city`). */ - path: string; - /** The full header a conforming client sends: `Mcp-Param-{Name}`. */ - header: string; - /** The declared header suffix. */ - headerName: string; - /** The property's JSON Schema primitive type. */ - type: string; -} - -/** - * The mirrored-header params for a tool the Inspector kept (its annotations are - * all valid). Returns `[]` when the tool declares no `x-mcp-header`, and — since - * a caller only reaches here for *kept* tools — also `[]` for the (unreachable - * for kept tools) invalid case. Each entry names the arg and the - * `Mcp-Param-{Name}` header its value mirrors to on a `tools/call`. - */ -export function getMirroredHeaderParams(tool: Tool): MirroredHeaderParam[] { - const scan = scanXMcpHeaderDeclarations(tool.inputSchema); - if (!scan.valid) return []; - return scan.declarations.map((d) => ({ - path: d.path.join("."), - header: `${MCP_PARAM_HEADER_PREFIX}${d.headerName}`, - headerName: d.headerName, - type: d.type, - })); -} diff --git a/packages/workbench/src/inspector/vendor/core/logging/logger.ts b/packages/workbench/src/inspector/vendor/core/logging/logger.ts deleted file mode 100644 index e95ac93cc..000000000 --- a/packages/workbench/src/inspector/vendor/core/logging/logger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Bindings, LevelWithSilentOrString, LogFn } from "pino"; - -/** - * Logging surface InspectorClient uses. Real pino loggers satisfy this; - * the default silent logger implements it without opening a stream. - */ -export interface InspectorLogger { - level: LevelWithSilentOrString; - fatal: LogFn; - error: LogFn; - warn: LogFn; - info: LogFn; - debug: LogFn; - trace: LogFn; - silent: LogFn; - child(bindings?: Bindings): InspectorLogger; -} - -const noop: LogFn = () => {}; - -function createSilentLogger(): InspectorLogger { - const logger: InspectorLogger = { - level: "silent", - fatal: noop, - error: noop, - warn: noop, - info: noop, - debug: noop, - trace: noop, - silent: noop, - child: () => logger, - }; - return logger; -} - -/** - * Default logger when none is injected. No-op at all levels; no SonicBoom stream. - */ -export const silentLogger: InspectorLogger = createSilentLogger(); diff --git a/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts b/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts deleted file mode 100644 index 7e1e58648..000000000 --- a/packages/workbench/src/inspector/vendor/core/mcp/fetchTracking.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { FetchRequestEntryBase } from "./types.js"; - -/** - * Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a - * fetch entry is recorded. The recorded entry flows to the in-memory log, the - * pino logger, and (via session storage) to disk — none of those sinks should - * ever see a live bearer token or session cookie. - */ -const SENSITIVE_HEADERS: ReadonlySet = new Set([ - "authorization", - "cookie", - "set-cookie", - "proxy-authorization", - "x-api-key", - // The inspector backend's own bearer (createRemoteFetch stamps this on every - // proxied request); same exposure as Authorization. - "x-mcp-remote-auth", -]); - -/** Placeholder substituted for sensitive header values in recorded entries. */ -export const REDACTED_HEADER_VALUE = "[REDACTED]"; - -/** - * Field / query-parameter names whose values are masked in a recorded fetch - * entry's request body, response body, and URL query string. These are the - * credentials that ride in OAuth token exchanges (and similar flows): the - * header slice masks `Authorization`, but the same secrets show up verbatim in - * the form/JSON body (`client_secret`, `code`, `refresh_token`, …) and are - * sometimes carried as URL query params. Matching is case-insensitive. - */ -const SENSITIVE_BODY_FIELDS: ReadonlySet = new Set([ - "client_secret", - "code", - "refresh_token", - "access_token", - "id_token", - "code_verifier", - "client_assertion", - "assertion", - "password", - "token", -]); - -/** - * Placeholder substituted for sensitive body / URL values in recorded entries. - * Deliberately kept separate from {@link REDACTED_HEADER_VALUE} (even though both - * are `"[REDACTED]"` today) so the header and body/URL redaction paths can evolve - * their sentinels independently. - */ -export const REDACTED_VALUE = "[REDACTED]"; - -/** Whether `name` (any casing) is a known-sensitive field / query-param name. */ -function isSensitiveField(name: string): boolean { - return SENSITIVE_BODY_FIELDS.has(name.toLowerCase()); -} - -/** - * Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value - * replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive - * (HTTP header names are case-insensitive); the original casing of every key is - * preserved so the recorded entry still shows what the client actually sent. - */ -export function redactSensitiveHeaders( - headers: Record, -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { - out[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) - ? REDACTED_HEADER_VALUE - : value; - } - return out; -} - -/** - * Returns `url` with every {@link SENSITIVE_BODY_FIELDS} query-parameter value - * replaced by {@link REDACTED_VALUE}. The path and non-sensitive params stay - * readable. Best-effort: if the URL (or its query string) can't be parsed the - * original string is returned unchanged. Only the recorded copy is redacted — - * the live request still uses the original `input`/`init`. - */ -export function redactUrlQuery(url: string): string { - const queryStart = url.indexOf("?"); - if (queryStart === -1) return url; - - const base = url.slice(0, queryStart); - const afterQuery = url.slice(queryStart + 1); - // Preserve a trailing fragment (#…) untouched — it never carries query params. - const hashStart = afterQuery.indexOf("#"); - const query = hashStart === -1 ? afterQuery : afterQuery.slice(0, hashStart); - const fragment = hashStart === -1 ? "" : afterQuery.slice(hashStart); - - try { - const params = new URLSearchParams(query); - let changed = false; - for (const key of new Set(params.keys())) { - if (isSensitiveField(key)) { - changed = true; - // Collapse repeated occurrences to a single redacted value. - params.set(key, REDACTED_VALUE); - } - } - if (!changed) return url; - return `${base}?${params.toString()}${fragment}`; - } catch { - return url; - } -} - -/** Recursively redact sensitive keys in a parsed JSON value (in place). */ -function redactJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(redactJsonValue); - } - if (value !== null && typeof value === "object") { - const out: Record = {}; - for (const [key, val] of Object.entries(value)) { - // Only STRING values of a sensitive-named field are masked. The secrets - // this targets (OAuth `code`, `access_token`, `client_secret`, …) are - // always strings; a non-string is never one of them. This is important - // for JSON-RPC bodies, whose numeric `error.code` (e.g. -32020) collides - // with the OAuth authorization-`code` name — masking it would destroy the - // very field the Network tab classifies the modern spec errors on. - // - // Assumes a sensitive value is a scalar or an object (recursed, so an inner - // sensitive string key is still masked). A sensitive key whose value is an - // *array of scalars* (e.g. `{ password: ["a", "b"] }`) would recurse - // element-by-element with no key context and slip through — no OAuth/token - // payload has that shape, so it is not handled. - out[key] = - isSensitiveField(key) && typeof val === "string" - ? REDACTED_VALUE - : redactJsonValue(val); - } - return out; - } - return value; -} - -/** - * Returns `body` with every {@link SENSITIVE_BODY_FIELDS} value masked, for - * `application/x-www-form-urlencoded` and JSON payloads. The surrounding shape - * (field order, non-sensitive fields, JSON structure) is preserved — only the - * values change. Best-effort and never throws: an empty, non-string, or - * unparseable body is returned unchanged. Only the recorded copy is redacted; - * the live request body is never touched. - * - * Scope is deliberately limited to `application/x-www-form-urlencoded` and JSON: - * these cover the OAuth token flows this redaction targets. `multipart/form-data` - * (and other binary/opaque bodies) are passed through verbatim — OAuth never uses - * multipart, so the risk is low; revisit if a multipart secret path appears. - */ -export function redactBody( - body: string | undefined, - contentType: string | null | undefined, -): string | undefined { - if (!body) return body; - - const type = (contentType ?? "").toLowerCase(); - - // Form-encoded bodies (the OAuth token endpoint's request format). - if (type.includes("application/x-www-form-urlencoded")) { - try { - const params = new URLSearchParams(body); - let changed = false; - for (const key of new Set(params.keys())) { - if (isSensitiveField(key)) { - changed = true; - params.set(key, REDACTED_VALUE); - } - } - return changed ? params.toString() : body; - } catch { - return body; - } - } - - // JSON bodies — either explicitly typed, or (when the content-type is - // missing/other) any string that parses as a JSON object/array. A bare - // JSON scalar has no field names, so it can't carry a sensitive key. - try { - const parsed: unknown = JSON.parse(body); - if (parsed !== null && typeof parsed === "object") { - return JSON.stringify(redactJsonValue(parsed)); - } - } catch { - // Not JSON — fall through and leave as-is. - } - - return body; -} - -/** Case-insensitive lookup of a header value from a plain header record. */ -function findHeader( - headers: Record, - name: string, -): string | undefined { - const target = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === target) return value; - } - return undefined; -} - -/** - * Whether a response represents an unbounded (long-lived) HTTP stream - * whose body cannot be cloned + read to completion. The streamable HTTP - * spec uses `GET` + `text/event-stream` for the long-lived server-push - * channel; `POST` SSE replies are bounded (server closes after the - * JSON-RPC response) and therefore safe to capture. Shared between the - * fetch tracker (where it decides whether to read the body) and the - * Network UI (where it decides which placeholder to show). - */ -export function isLongLivedStreamResponse( - method: string, - contentType: string | null | undefined, -): boolean { - if (method !== "GET") return false; - if (!contentType) return false; - return ( - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") - ); -} - -export interface FetchTrackingCallbacks { - trackRequest?: (entry: FetchRequestEntryBase) => void; - /** - * Called after the response body has been read asynchronously. Lets the - * consumer patch the already-dispatched entry with the body without - * blocking the transport on body reading. Fires only on success — if the - * body couldn't be read (long-lived stream, clone failure), this is - * never invoked and the entry's responseBody stays undefined. - */ - updateResponseBody?: (id: string, responseBody: string) => void; -} - -/** - * Creates a fetch wrapper that tracks HTTP requests and responses - */ -export function createFetchTracker( - baseFetch: typeof fetch, - callbacks: FetchTrackingCallbacks, -): typeof fetch { - return async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const startTime = Date.now(); - const timestamp = new Date(); - const id = `${timestamp.getTime()}-${Math.random().toString(36).slice(2, 11)}`; - - // Extract request information - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - const method = init?.method || "GET"; - - // Extract headers, redacting sensitive values BEFORE they reach any - // downstream sink (logger, in-memory list, persisted session storage). - const rawRequestHeaders: Record = {}; - if (input instanceof Request) { - input.headers.forEach((value, key) => { - rawRequestHeaders[key] = value; - }); - } - if (init?.headers) { - const headers = new Headers(init.headers); - headers.forEach((value, key) => { - rawRequestHeaders[key] = value; - }); - } - const requestHeaders = redactSensitiveHeaders(rawRequestHeaders); - const requestContentType = findHeader(rawRequestHeaders, "content-type"); - - // Redact sensitive query params in the recorded URL (live `input` is - // untouched — only this logged copy is masked). - const redactedUrl = redactUrlQuery(url); - - // Extract body (if present and readable) - let requestBody: string | undefined; - if (init?.body) { - if (typeof init.body === "string") { - requestBody = init.body; - } else { - // Try to convert to string, but skip if it fails (e.g., ReadableStream) - try { - requestBody = String(init.body); - } catch { - requestBody = undefined; - } - } - } else if (input instanceof Request && input.body) { - // Try to clone and read the request body - // Clone protects the original body from being consumed - try { - const cloned = input.clone(); - requestBody = await cloned.text(); - } catch { - // Can't read body (might be consumed, not readable, or other issue) - requestBody = undefined; - } - } - - // Redact sensitive fields in the recorded request body. The live request - // body (`init.body` / `input`) is never touched — only this logged string. - const redactedRequestBody = redactBody(requestBody, requestContentType); - - // Make the actual fetch request - let response: Response; - let error: string | undefined; - try { - response = await baseFetch(input, init); - } catch (err) { - error = err instanceof Error ? err.message : String(err); - // Create a minimal error entry - const entry: FetchRequestEntryBase = { - id, - timestamp, - method, - url: redactedUrl, - requestHeaders, - requestBody: redactedRequestBody, - error, - duration: Date.now() - startTime, - }; - callbacks.trackRequest?.(entry); - throw err; - } - - // Extract response information - const responseStatus = response.status; - const responseStatusText = response.statusText; - - // Extract response headers (redacted — Set-Cookie etc. are credentials too) - const rawResponseHeaders: Record = {}; - response.headers.forEach((value, key) => { - rawResponseHeaders[key] = value; - }); - const responseHeaders = redactSensitiveHeaders(rawResponseHeaders); - - // Skip body reading only for *long-lived* streams. On streamable HTTP, - // GET /mcp opens an unbounded SSE channel for server-to-client pushes - // — calling `.text()` on a clone of that would buffer forever. POST - // responses with the same content-type are bounded: the server emits - // the JSON-RPC reply (sometimes preceded by progress events) and - // closes the connection, so cloning + reading is safe and gives the - // user the raw SSE payload they were missing. - const isLongLivedStream = isLongLivedStreamResponse( - method, - response.headers.get("content-type"), - ); - - const duration = Date.now() - startTime; - - // Create entry and track it immediately. The body is read asynchronously - // below to avoid blocking the transport — for streaming responses (POST - // + SSE), the server keeps the connection open until it has delivered - // every progress notification plus the final reply, so awaiting - // `.text()` here would force the transport to wait for all events - // before it could process any of them. - const entry: FetchRequestEntryBase = { - id, - timestamp, - method, - url: redactedUrl, - requestHeaders, - requestBody: redactedRequestBody, - responseStatus, - responseStatusText, - responseHeaders, - responseBody: undefined, - duration, - }; - - callbacks.trackRequest?.(entry); - - // Kick off a fire-and-forget read of the cloned body. The clone is an - // independent tee'd stream so the transport keeps consuming the - // original at its own pace. When the read resolves we patch the entry - // via `updateResponseBody`. Skipped for long-lived streams (GET + - // SSE / ndjson) because `.text()` would never resolve on those. - if (!isLongLivedStream && response.body && !response.bodyUsed) { - const responseContentType = response.headers.get("content-type"); - try { - const cloned = response.clone(); - cloned - .text() - .then((body) => { - // Mask token-endpoint secrets (access_token, refresh_token, …) - // before the body reaches any sink. - callbacks.updateResponseBody?.( - id, - redactBody(body, responseContentType) ?? body, - ); - }) - .catch(() => { - // Stream errored after clone — leave the body undefined. - }); - } catch { - // Clone failed (consumed body, transport quirks). Leave body - // undefined; the entry is already dispatched. - } - } - - return response; - }; -} diff --git a/packages/workbench/src/inspector/vendor/core/mcp/types.ts b/packages/workbench/src/inspector/vendor/core/mcp/types.ts deleted file mode 100644 index 307f3abdd..000000000 --- a/packages/workbench/src/inspector/vendor/core/mcp/types.ts +++ /dev/null @@ -1,1062 +0,0 @@ -import type { - CallToolResult, - ClientNotification, - ClientRequest, - GetPromptResult, - Implementation, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResultResponse, - LoggingLevel, - Prompt, - ReadResourceResult, - Resource, - Root, - ServerCapabilities, - ServerNotification, - ServerRequest, - Tool, - VersionNegotiationOptions, -} from "@modelcontextprotocol/client"; -import type { Client } from "@modelcontextprotocol/client"; -import type { OAuthClientProvider } from "@modelcontextprotocol/client"; -import type { Transport } from "@modelcontextprotocol/client"; -import type { InspectorLogger } from "../logging/logger.js"; -import type { JsonValue } from "../json/jsonUtils.js"; -import type { - ClientConfig, - EnterpriseManagedAuthIdpConfig, -} from "../client/types.js"; -import type { - OAuthNavigation, - RedirectUrlProvider, -} from "../auth/providers.js"; -import type { OAuthStorage } from "../auth/storage.js"; - -// Stdio transport config -export interface StdioServerConfig { - // Optional: stdio is the implicit default when `type` is absent. A - // narrowing `switch (config.type)` must therefore cover the `undefined` - // branch as `StdioServerConfig`. - type?: "stdio"; - command: string; - args?: string[]; - env?: Record; - cwd?: string; -} - -// StreamableHTTP transport config -export interface StreamableHttpServerConfig { - type: "streamable-http"; - url: string; - requestInit?: Record; -} - -export type MCPServerConfig = - | StdioServerConfig - | StreamableHttpServerConfig; - -export type ServerType = "stdio" | "streamable-http"; - -/** - * On-disk shape for a single `mcp.json` server entry (post-#1358). The base - * is the SDK-compatible `MCPServerConfig`; each Inspector-specific extension - * field lives directly alongside `type` / `url` / `command` rather than - * under a nested `settings` wrapper. This matches the shape Claude Code / - * Cursor / Cline write to their own `.mcp.json` files (`headers` as a - * `Record`, `oauth` as a nested object), so a hand-edited - * file from any of those tools is readable on Inspector's first connect. - * - * The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings` - * keeps its pair-array `headers` and flat `oauth*` fields because the form - * needs them in that shape to drive controlled-component editing. The - * conversion between disk-flat and memory-pair-array lives in - * `serverList.ts` (`mcpConfigToServerEntries` / - * `serverEntriesToMcpConfig`) and the `/api/servers` route's - * `buildStoredEntry`. - * - * Files written by the pre-#1358 build (one #1352 release of v2/main that - * never shipped a stable tag) had a nested `settings` block here; that - * shape is dropped on read with a warn and not re-emitted on next write. - */ -export type StoredMCPServer = MCPServerConfig & { - /** - * HTTP headers for Streamable HTTP transports. Persisted as a flat - * `Record` matching the Claude Code / Cursor / Cline - * `.mcp.json` convention. Lifted into `InspectorServerSettings.headers` - * (pair-array form) when read into memory. - */ - headers?: Record; - /** - * Default `_meta` keys merged into every outgoing MCP request. Inspector- - * specific (no analog in the broader mcp.json ecosystem), so the pair-array - * shape is preserved on disk and in memory. - */ - metadata?: { key: string; value: string }[]; - /** - * Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`), - * orthogonal to the transport `type`. Inspector-specific (no analog in the - * broader mcp.json ecosystem). Omitted on disk when it equals the default - * (`"legacy"`). (#1626) - */ - protocolEra?: ServerProtocolEra; - /** - * Modern-era per-request log level stamped by default (`"off"` or one of the - * eight logging levels). Inspector-specific. Omitted on disk when it equals - * `DEFAULT_MODERN_LOG_LEVEL` (`"debug"`). Only affects modern connections. - * (#1629) - */ - modernLogLevel?: ModernLogLevel; - /** Inspector-specific connect-time timeout (ms). */ - connectionTimeout?: number; - /** Inspector-specific request timeout (ms). */ - requestTimeout?: number; - /** Inspector-specific TTL (ms) for tasks created via "Run as task". */ - taskTtl?: number; - /** - * When true, the managed list state auto-refreshes on `list_changed` - * notifications instead of only flagging the list-changed indicator and - * waiting for the user to pull. Inspector-specific. Omitted on disk when - * false (the default). (#1402) - */ - autoRefreshOnListChanged?: boolean; - /** - * When true, the tools/resources/prompts lists are fetched one page at a time - * (a manual "Load next page" control surfaces the server's `nextCursor`) - * instead of auto-aggregating every page on load. A defensive default for - * servers with very large lists. Inspector-specific. Omitted on disk when - * false (the default). (#1721) - */ - paginatedLists?: boolean; - /** - * Per-extension overrides for which extensions the Inspector advertises to - * this server (keyed by extension id; a present key wins over the registry - * default). Inspector-specific. Omitted on disk when empty, keeping the file - * diff minimal for servers that never toggled one. (#1739) - */ - advertisedExtensions?: Record; - /** - * Maximum number of HTTP fetch requests retained in the Network log for this - * server (oldest rotate out past the cap). Inspector-specific. Omitted on - * disk when it equals `DEFAULT_MAX_FETCH_REQUESTS` (the default), keeping the - * file diff minimal for servers that never tuned it. `0` means unlimited. - */ - maxFetchRequests?: number; - /** - * Pre-configured OAuth client credentials for HTTP transports. Nested to - * match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId` - * / `oauthClientSecret` / `oauthScopes` fields on `InspectorServerSettings` - * when read into memory. - */ - oauth?: { - clientId?: string; - clientSecret?: string; - scopes?: string; - /** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */ - enterpriseManaged?: boolean; - /** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */ - onInsufficientScope?: OnInsufficientScopePolicy; - }; - /** - * Filesystem/URI roots advertised to the server via the `roots` client - * capability. Inspector-specific (no analog in the broader mcp.json - * ecosystem). Each root is the SDK `Root` shape `{ uri, name? }`; unlike v1 - * (URI-only), the optional `name` round-trips here. Persisted as-is on disk - * and lifted onto `InspectorServerSettings.roots` when read into memory. - */ - roots?: Root[]; -}; - -export interface MCPConfig { - mcpServers: Record; -} - -export type ConnectionStatus = - | "disconnected" - | "connecting" - | "connected" - | "error"; - -/** - * True when a connection has settled into a non-live terminal state — either a - * clean `"disconnected"` or a crashed `"error"`. Both mean the session is over - * and any cached server state (tool/resource/prompt lists, message log, - * subscriptions) should be torn down. - * - * Session-teardown consumers must key off this predicate rather than branching - * on `status === "disconnected"` alone: on a real mid-session crash many SDK - * transports fire BOTH `onclose` and `onerror` in a transport-dependent order, - * and the canonical terminal status is now `"error"` regardless of ordering - * (see InspectorClient's `onclose` handler, #1490). A bare `=== "disconnected"` - * check would therefore tear down in one ordering but not the other. - */ -export function isTerminalStatus( - status: ConnectionStatus | undefined, -): boolean { - return status === "disconnected" || status === "error"; -} - -/** - * Snapshot of a server's connection state, used by dumb components - * that display status, retry count, and error details. - */ -export interface ConnectionState { - status: ConnectionStatus; - retryCount?: number; - error?: { message: string; details?: string }; - /** - * MCP protocol version negotiated with the server during initialize - * (e.g. "2025-06-18"). Only present once connected; surfaced in the - * ServerCard transport row. Populated when #1324 plumbs the value - * through `useInspectorClient`. - */ - protocolVersion?: string; -} - -export interface ServerEntry { - /** Stable unique identifier — the MCPConfig.mcpServers map key. */ - id: string; - /** Display label shown in the card header. May or may not equal id. */ - name: string; - config: MCPServerConfig; - /** - * Optional per-server runtime settings (headers, metadata, timeouts, OAuth - * credentials). On disk these live as direct keys on the entry (post-#1358); - * in memory they're grouped here in the pair-array / flat-OAuth shape the - * form needs for controlled-component editing. Edited via ServerSettingsForm; - * consumed by the transport / InspectorClient at connect time. - */ - settings?: InspectorServerSettings; - info?: Implementation; - connection: ConnectionState; -} - -export interface StderrLogEntry { - timestamp: Date; - message: string; -} - -/** Who sent a tracked message: the inspector ("client") or the "server". */ -export type MessageOrigin = "client" | "server"; - -/** - * How a pending sampling/elicitation request reached the Inspector, so the - * pending-request UI can show era-accurate semantics: - * - `"server-request"` — a legacy (≤2025-11-25) server→client JSON-RPC request - * (`sampling/createMessage` / `elicitation/create`) delivered to our handler. - * - `"input-required"` — a modern (2026-07-28) MRTR round: the request was - * embedded in a tool-call/prompt/resource `input_required` result, and the - * user's answer is echoed back to the server as a retry (SEP-2322). - * - `"task-input-required"` — a modern task (SEP-2663) that reached - * `input_required`: the request came from the task's `tasks/get` `inputRequests` - * map, and the user's answer is submitted via a `tasks/update` request (NOT a - * retry of the original call, unlike MRTR). - */ -export type PendingRequestOrigin = - | "server-request" - | "input-required" - | "task-input-required"; - -export interface MessageEntry { - id: string; - timestamp: Date; - direction: "request" | "response" | "notification"; - /** - * Who sent the message — drives the History direction badge (client → server - * vs client ← server). Set at tracking time: outgoing (transport `send`) is - * "client", incoming (`onmessage`) is "server". Optional for back-compat with - * older logs and test fixtures that predate it. - */ - origin?: MessageOrigin; - message: - | JSONRPCRequest - | JSONRPCNotification - | JSONRPCResultResponse - | JSONRPCErrorResponse; - response?: JSONRPCResultResponse | JSONRPCErrorResponse; - duration?: number; // Time between request and response in ms - /** - * Why the CLIENT rejected an otherwise well-formed response — e.g. the SDK's - * era codec refusing a 2026-07-28 `tools/list` result that omits - * `ttlMs`/`cacheScope`. Distinct from a JSON-RPC `error` response: the server - * answered successfully and the wire frame is valid, so without this the - * entry renders as a clean success even though the call failed (#1953). - */ - clientError?: string; -} - -/** Method name for any MessageEntry traffic, plus synthetic "response" for result/error entries. */ -export type MessageMethod = - | ClientRequest["method"] - | ClientNotification["method"] - | ServerRequest["method"] - | ServerNotification["method"] - | "response"; - -export type FetchRequestCategory = "auth" | "transport"; - -export interface FetchRequestEntry { - id: string; - timestamp: Date; - method: string; - url: string; - requestHeaders: Record; - requestBody?: string; - responseStatus?: number; - responseStatusText?: string; - responseHeaders?: Record; - responseBody?: string; - duration?: number; // Time between request and response in ms - error?: string; - /** Distinguishes OAuth/auth fetches from MCP transport fetches */ - category: FetchRequestCategory; -} - -/** Entry shape from createFetchTracker before category is added by the caller */ -export type FetchRequestEntryBase = Omit; - -export interface ServerState { - status: ConnectionStatus; - error: string | null; - capabilities?: ServerCapabilities; - serverInfo?: Implementation; - instructions?: string; - resources: Resource[]; - prompts: Prompt[]; - tools: Tool[]; - stderrLogs: StderrLogEntry[]; -} - -/** - * Represents a complete resource read invocation, including request parameters, - * response, and metadata. - */ -export interface ResourceReadInvocation { - result: ReadResourceResult; - timestamp: Date; - uri: string; - metadata?: Record; -} - -/** - * Represents a complete resource template read invocation, including request parameters, - * response, and metadata. - */ -export interface ResourceTemplateReadInvocation { - uriTemplate: string; - expandedUri: string; - result: ReadResourceResult; - timestamp: Date; - params: Record; - metadata?: Record; -} - -/** - * Represents a complete prompt get invocation, including request parameters, - * response, and metadata. - */ -export interface PromptGetInvocation { - result: GetPromptResult; - timestamp: Date; - name: string; - params?: Record; - metadata?: Record; -} - -/** - * Represents a complete tool call invocation, including request parameters, - * response, and metadata. - */ -export interface ToolCallInvocation { - toolName: string; - params: Record; - result: CallToolResult | null; - timestamp: Date; - success: boolean; - error?: string; - metadata?: Record; - /** - * Set only on the `skipOutputValidation` path: present when the (delivered) - * result's structuredContent does NOT match the tool's declared outputSchema. - * The call still succeeds and the result is returned — this is a non-fatal - * advisory so callers can warn that strict MCP clients would reject the - * payload (and the app may not render in them). - */ - outputValidationError?: string; -} - -// v2-only wrapper types (no v1.5 equivalent) - -/** - * Resource subscription wrapper used by the Resources screen to track - * subscribed resources and the time of the last update notification. - */ -export interface InspectorResourceSubscription { - resource: Resource; - lastUpdated?: Date; -} - -/** - * Lifecycle status of the single modern-era `subscriptions/listen` stream that - * backs every resource subscription on a 2026-07-28 server (#1630). - * - * - `"connecting"` — a `listen()` request is in flight and hasn't been - * acknowledged yet (the optimistic state shown the moment the user subscribes, - * so the UI responds to the click without waiting for the ack round-trip). - * - `"acknowledged"` — the `listen()` request resolved and the server sent - * `notifications/subscriptions/acknowledged`; the stream is open and carrying - * updates. - * - `"reconnecting"` — the stream dropped unexpectedly (`closed` resolved - * `"remote"`) and a re-listen is in flight (reconnect-by-re-listen; there is - * no resumability, so the re-listen re-establishes the full filter). - * - `"ended"` — the server tore the stream down deliberately (`closed` resolved - * `"graceful"`, e.g. on shutdown) or reconnection was abandoned; no automatic - * re-listen. - */ -export type ResourceSubscriptionStreamStatus = - | "connecting" - | "acknowledged" - | "reconnecting" - | "ended"; - -/** - * State of the modern-era resource-subscription listen stream (#1630). - * - * On the legacy era each `resources/subscribe` is an independent request with no - * persistent stream, so `active` is `false` and the UI surfaces no stream chrome. - * On the modern era all subscriptions are a filter over one long-lived - * `subscriptions/listen` stream; `active` is `true` whenever that stream is being - * managed *for resource subscriptions* (i.e. at least one URI is subscribed), and - * `honoredUris` is the subset of requested URIs the server acknowledged in its - * `honoredFilter` (may be a strict subset — a server is allowed to decline some). - * - * `active: false` does not imply no stream: the same stream also carries the - * list-change opt-ins, so it can be open with no subscribed URI at all (#1920). - * This state describes the Subscriptions section, which has nothing to show for - * such a stream. - */ -export interface ResourceSubscriptionStreamState { - active: boolean; - status: ResourceSubscriptionStreamStatus; - honoredUris: string[]; -} - -/** The stream state reported on the legacy era (or before any subscription). */ -export const INACTIVE_SUBSCRIPTION_STREAM_STATE: ResourceSubscriptionStreamState = - { - active: false, - status: "ended", - honoredUris: [], - }; - -/** - * Wraps a URL-based elicit request from the server. v1.5 only supports - * form elicitation; v2 introduces URL elicitation as a discriminated variant - * of the inline elicitation panel. The wrapper carries the request payload - * plus the URL the user must visit to satisfy it. - */ -export interface InspectorUrlElicitRequest { - id: string; - timestamp: Date; - /** Free-form prompt shown alongside the URL (server-supplied). */ - message: string; - /** Authorization or interaction URL the user must visit. */ - url: string; - /** Optional task association for grouping in the tasks view. */ - taskId?: string; -} - -/** - * Generic envelope for pending server-originated requests surfaced to - * dumb components. Used by the pending-request panel to list anything - * the user must act on before the protocol can proceed (sampling, elicitation, - * URL elicitation, roots list, etc.). - */ -export interface InspectorPendingRequest { - id: string; - timestamp: Date; - kind: "sampling" | "elicitation" | "urlElicitation" | "rootsList"; - /** Display label rendered on the queue row. */ - label: string; - /** Optional task association so panels can group/route. */ - taskId?: string; -} - -/** - * Single entry rendered in the history view. v2 extracts this from the - * message log so the HistoryScreen can filter/group entries without needing - * to re-derive direction or method from raw JSON-RPC frames. - */ -export interface InspectorRequestHistoryItem { - id: string; - timestamp: Date; - direction: "request" | "response" | "notification"; - method: string; - durationMs?: number; - /** Surfaces the original log entry for detail panes. */ - messageId: MessageEntry["id"]; -} - -/** - * OAuth credentials surfaced by the settings form. The form callback - * passes this whole object so callers don't have to thread per-field - * dispatches through stringly-typed key arguments. - */ -export interface OAuthSettings { - clientId: string; - clientSecret: string; - scopes: string; - enterpriseManaged?: boolean; - onInsufficientScope?: OnInsufficientScopePolicy; -} - -/** - * SEP-2350 step-up policy for a `403 insufficient_scope` challenge. `reauthorize` - * (the SDK default) drives step-up authorization with the accumulated scope union; - * `throw` surfaces the challenge to the host instead. Forwarded to the - * StreamableHTTP transport's `onInsufficientScope` option. - */ -export type OnInsufficientScopePolicy = "reauthorize" | "throw"; - -/** - * Default TTL (ms) for tasks created via "Run as task". Mirrors v1/v1.5's - * `MCP_TASK_TTL` config default. Used when a server has no explicit `taskTtl`. - */ -export const DEFAULT_TASK_TTL_MS = 60000; - -/** - * Default maximum number of HTTP fetch requests retained in the Network log - * (per server). When exceeded, the oldest entries rotate out. A larger value - * keeps more history at the cost of memory; `0` means unlimited (not - * recommended). Mirrors `FetchRequestLogState`'s built-in default so the form - * and the log state agree on the omit-sentinel. - */ -export const DEFAULT_MAX_FETCH_REQUESTS = 1000; - -/** - * Per-server protocol era (SEP §7.8 backward-compat model), an orthogonal - * dimension to the transport `type`. Drives the SDK Client's - * `versionNegotiation`: - * - * - `"legacy"` — the plain 2025-11-25 `initialize` handshake, byte-identical to - * a client without negotiation. **This is the default** per the SDK's - * guidance that a debugging tool must not auto-probe (a probe stalls on - * silent stdio legacy servers and pollutes recorded transcripts). - * - `"auto"` — probe `server/discover` at connect and fall back to `initialize` - * on any non-modern outcome. - * - `"modern"` — pin the modern era at exactly `MODERN_PROTOCOL_VERSION`; no - * fallback (a non-modern server fails loudly). - */ -export type ServerProtocolEra = "legacy" | "auto" | "modern"; - -/** The default per-server protocol era when none is configured. */ -export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy"; - -/** - * Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel` - * is gone on the modern era; instead the client opts into logs by stamping - * `_meta["io.modelcontextprotocol/logLevel"]` on each request. This setting is - * the level stamped by default on a modern connection — one of the eight logging - * levels, or `"off"` to not opt in (no server logs). Legacy connections ignore - * it (they use the session-scoped `logging/setLevel` instead). - */ -export type ModernLogLevel = LoggingLevel | "off"; - -/** - * The default modern per-request log level when none is configured. Defaults to - * opted-in at the most verbose level so a modern connection surfaces server logs - * out of the box (the Inspector is a debugging tool); set `"off"` per server to - * opt back out. - */ -export const DEFAULT_MODERN_LOG_LEVEL: ModernLogLevel = "debug"; - -/** - * The live modern per-request log level a server's settings imply: the - * configured value, or {@link DEFAULT_MODERN_LOG_LEVEL} when unset, with - * `"off"` meaning not opted in. - * - * One derivation, because the client stamps `_meta` from it while the web Logs - * control displays it — computing it separately on each side is how the two - * come to disagree, which is a bad failure for a tool whose job is showing what - * it sent (#1629, #1797). The web maps `undefined` to `null` at its own - * boundary; that is display, not a second derivation. - */ -export function resolveModernLogLevel( - settings?: Pick, -): LoggingLevel | undefined { - const level = settings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL; - return level === "off" ? undefined : level; -} - -/** All modern-log-level values, for form options and the runtime guard. */ -export const MODERN_LOG_LEVELS: ModernLogLevel[] = [ - "off", - "debug", - "info", - "notice", - "warning", - "error", - "critical", - "alert", - "emergency", -]; - -/** Runtime guard for the {@link ModernLogLevel} literal (hand-edited files). */ -export function isModernLogLevel(value: unknown): value is ModernLogLevel { - return ( - typeof value === "string" && (MODERN_LOG_LEVELS as string[]).includes(value) - ); -} - -/** - * The modern protocol revision `"modern"` era pins to. The successor to - * 2025-11-25; the first revision with the per-request-metadata / sessionless - * model (SEP §7.1). - */ -export const MODERN_PROTOCOL_VERSION = "2026-07-28"; - -/** - * Map a per-server {@link ServerProtocolEra} onto the SDK Client's - * `versionNegotiation` option. `"modern"` pins {@link MODERN_PROTOCOL_VERSION}; - * `"legacy"`/`"auto"` pass their mode straight through. - */ -export function eraToVersionNegotiation( - era: ServerProtocolEra, -): VersionNegotiationOptions { - switch (era) { - case "auto": - return { mode: "auto" }; - case "modern": - return { mode: { pin: MODERN_PROTOCOL_VERSION } }; - case "legacy": - return { mode: "legacy" }; - } -} - -/** - * Runtime settings for a configured server. A subset of - * InspectorClientOptions (v1.5) relevant to the settings form: - * headers, metadata, timeouts, and OAuth credentials. - */ -export interface InspectorServerSettings { - headers: { key: string; value: string }[]; - metadata: { key: string; value: string }[]; - /** - * Environment variables for stdio servers, edited as controlled key/value - * rows (mirrors `headers`). Only meaningful for stdio transports; non-stdio - * servers keep this an empty list. These do NOT live on disk as a settings - * field — they round-trip through the SDK config's `env` (the standard - * mcp.json location). The settings layer mirrors them for the form, and the - * `/api/servers` PUT route writes an edited list back onto `config.env` when - * the caller patches settings only. Empty-key rows are dropped on persist. - */ - env: { key: string; value: string }[]; - /** - * Working directory for stdio servers (`config.cwd`). Like `env`, this is a - * mirror of the SDK config field rather than a persisted settings field; - * empty/unset means "inherit". Only meaningful for stdio transports. - */ - cwd?: string; - connectionTimeout: number; - requestTimeout: number; - /** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */ - taskTtl: number; - oauthClientId?: string; - oauthClientSecret?: string; - oauthScopes?: string; - /** - * SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this - * server's HTTP transport. Defaults to `reauthorize` when unset. - */ - oauthOnInsufficientScope?: OnInsufficientScopePolicy; - /** - * When true, connect via the configured enterprise IdP (EMA) instead of - * interactive OAuth to the MCP authorization server. Per-server OAuth - * fields below are resource AS credentials. (#1509) - */ - enterpriseManaged?: boolean; - /** - * When true, lists auto-refresh on `list_changed` notifications; when - * false (default), the notification only lights the list-changed indicator - * and the user pulls the new list via Refresh. (#1402) - */ - autoRefreshOnListChanged?: boolean; - /** - * When true, the tools/resources/prompts lists fetch one page at a time (a - * manual "Load next page" control) instead of auto-aggregating all pages. - * Default false. Server-wide; the per-list sidebar toggle edits this. (#1721) - */ - paginatedLists?: boolean; - /** - * Maximum number of HTTP fetch requests retained in the Network log for this - * server. When exceeded, the oldest entries rotate out (and any deferred - * response body that arrives after its entry rotated out is dropped — see - * `FetchRequestLogState`). Concrete value so the form always has something to - * render; defaults to `DEFAULT_MAX_FETCH_REQUESTS`. `0` means unlimited. - */ - maxFetchRequests: number; - /** - * Roots advertised to the server via the `roots` client capability. Each - * root carries a required `uri` and an optional `name` (SDK `Root`). The - * form edits these as controlled rows; empty-uri rows are dropped on - * persist (see `inspectorSettingsToStoredFields`). - */ - roots: Root[]; - /** - * Protocol era to negotiate with this server (orthogonal to the transport - * `type`). Drives the SDK Client's `versionNegotiation`. Optional so a bare - * settings node reads back without one; absence means {@link - * DEFAULT_PROTOCOL_ERA} (`"legacy"`). Persisted on disk as `protocolEra` and - * omitted when it equals the default, keeping the file diff minimal. - */ - protocolEra?: ServerProtocolEra; - /** - * Modern-era per-request log level stamped by default on this server's - * connections (#1629). One of the eight logging levels, or `"off"` to not opt - * in. Absence means {@link DEFAULT_MODERN_LOG_LEVEL} (`"debug"`). Only affects - * modern (2026-07-28) connections; legacy uses `logging/setLevel`. Persisted - * on disk as `modernLogLevel` and omitted when it equals the default. - */ - modernLogLevel?: ModernLogLevel; - /** - * Per-extension overrides for which extensions the Inspector advertises to - * this server in `capabilities.extensions`, keyed by extension id. A present - * key wins over the registry default in `ADVERTISABLE_EXTENSIONS`; an absent - * key falls back to it. Toggling an entry is a debugging knob — a server may - * change tool registration on a client-declared extension. Persisted on disk - * as `advertisedExtensions` and omitted when empty. (#1739) - */ - advertisedExtensions?: Record; -} - -/** - * Draft state for importing a server from registry JSON. Owned by the - * ImportServerJsonPanel wiring layer. `parsed` is typed `unknown` until the - * registry schema type is added in a follow-up. - */ -export interface InspectorServerJsonDraft { - rawText: string; - parsed?: unknown; - selectedPackageIndex?: number; - envOverrides: Record; - nameOverride?: string; -} - -// --------------------------------------------------------------------------- -// v1.5 InspectorClient runtime types (#1302) -// These are required by the ported InspectorClient class and its supporting -// modules (oauthManager, transports). v2 had pruned them when it kept only -// the static InspectorClientProtocol interface; restoring them verbatim from -// v1.5 keeps the ported client compilable. -// --------------------------------------------------------------------------- - -export interface CreateTransportOptions { - /** - * Optional fetch function. When provided, used as the base for transport HTTP requests - * (Streamable HTTP). Enables proxy fetch in browser (CORS bypass). - */ - fetchFn?: typeof fetch; - - /** - * Optional callback to handle stderr logs from stdio transports - */ - onStderr?: (entry: StderrLogEntry) => void; - - /** - * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) - */ - pipeStderr?: boolean; - - /** - * Optional callback to track HTTP fetch requests for Streamable HTTP transports. - * Receives entries without category; caller adds category when storing. - */ - onFetchRequest?: (entry: FetchRequestEntryBase) => void; - - /** - * Optional callback fired asynchronously when a previously tracked - * fetch's response body has been read. Lets the consumer update the - * already-dispatched entry without blocking the transport on body - * reading (critical for SSE responses that include progress events). - */ - onFetchResponseBody?: (id: string, responseBody: string) => void; - - /** - * Optional OAuth client provider for Streamable HTTP Bearer authentication. - * When set, the SDK injects tokens and handles 401 via the provider. - */ - authProvider?: OAuthClientProvider; - - /** - * Optional per-server runtime settings. Currently used to source custom - * HTTP headers (settings.headers) for Streamable HTTP transports. - * Stdio ignores this — headers are not applicable. - */ - settings?: InspectorServerSettings; - - /** - * When true, wrap HTTP transport fetch with auth-challenge detection so 401/403 - * become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider. - */ - interceptAuthChallenges?: boolean; -} - -export interface CreateTransportResult { - transport: Transport; -} - -/** - * A tool a conforming Streamable HTTP client MUST exclude from `tools/list` - * because its `x-mcp-header` annotations violate SEP-2243 (the whole tool - * definition is invalidated). The SDK's `listTools()` drops these silently; the - * Inspector surfaces them — with the constraint they broke — so a user can see - * *why* a tool vanished (#1632). Only modern non-stdio connections exclude. - */ -export interface ExcludedTool { - tool: Tool; - /** The first violated constraint, from the `x-mcp-header` scan. */ - reason: string; -} - -/** - * Factory that creates a client transport for an MCP server configuration. - * Required by InspectorClient; caller provides the implementation for their - * environment (e.g. createTransport for Node, RemoteClientTransport factory for browser). - */ -export type CreateTransport = ( - config: MCPServerConfig, - options: CreateTransportOptions, -) => CreateTransportResult; - -/** - * Type for the client-like object passed to AppRenderer / @mcp-ui. - * Structurally compatible with the MCP SDK Client but denotes the app-renderer - * proxy, not the raw client. Use this type when passing the client to the Apps tab. - */ -export type AppRendererClient = Client; - -/** - * Consolidated environment interface that defines all environment-specific seams. - * Each environment (Node, browser, tests) provides a complete implementation bundle. - */ -export interface InspectorClientEnvironment { - /** - * Factory that creates a client transport for the given server config. - * Required. Environment provides the implementation: - * - Node: createTransportNode - * - Browser: createRemoteTransport - */ - transport: CreateTransport; - - /** - * Optional fetch function for HTTP requests (OAuth discovery/token exchange and - * MCP transport). When provided, used for both auth and transport to bypass CORS. - * - Node: undefined (uses global fetch) - * - Browser: createRemoteFetch - */ - fetch?: typeof fetch; - - /** - * Optional logger for InspectorClient events (transport, OAuth, etc.). - * - Node: pino file logger - * - Browser: createRemoteLogger - */ - logger?: InspectorLogger; - - /** - * OAuth environment components - */ - oauth?: { - /** - * OAuth storage implementation - * - Node: NodeOAuthStorage (file-based) - * - Browser: BrowserOAuthStorage (sessionStorage) or RemoteOAuthStorage (shared state) - */ - storage?: OAuthStorage; - - /** - * Navigation handler for redirecting users to authorization URLs - * - Node: ConsoleNavigation - * - Browser: BrowserNavigation - */ - navigation?: OAuthNavigation; - - /** - * Redirect URL provider - * - Node: from OAuth callback server - * - Browser: from window.location or callback route - */ - redirectUrlProvider?: RedirectUrlProvider; - }; -} - -export interface InspectorClientOptions { - /** - * Environment-specific implementations (transport, fetch, logger, OAuth components) - */ - environment: InspectorClientEnvironment; - - /** - * Client identity (name and version) - */ - clientIdentity?: { - name: string; - version: string; - }; - /** - * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) - */ - pipeStderr?: boolean; - - /** - * Initial logging level to set after connection (if server supports logging) - * If not provided, logging level will not be set automatically - */ - initialLoggingLevel?: LoggingLevel; - - /** - * Whether to advertise sampling capability (default: true) - */ - sample?: boolean; - - /** - * Elicitation capability configuration - * - `true` - support form-based elicitation only (default, for backward compatibility) - * - `{ form: true }` - support form-based elicitation only - * - `{ url: true }` - support URL-based elicitation only - * - `{ form: true, url: true }` - support both form and URL-based elicitation - * - `false` or `undefined` - no elicitation support - */ - elicit?: - | boolean - | { - form?: boolean; - url?: boolean; - }; - - /** - * Initial roots to configure. If provided (even if empty array), the client will - * advertise roots capability and handle roots/list requests from the server. - */ - roots?: Root[]; - - /** - * Per-extension overrides for which extensions the Inspector advertises in - * `capabilities.extensions`, keyed by extension id. A present key wins over - * the registry default in `ADVERTISABLE_EXTENSIONS`; an absent key falls back - * to it. Lets a user toggle advertised extensions as a debugging knob — - * servers legitimately change tool registration on client-declared extensions - * (#1633). EMA is not configured here (it follows the auth mode). (#1738) - */ - advertisedExtensions?: Record; - - /** - * Whether to enable listChanged notification handlers (default: true) - * If enabled, InspectorClient will subscribe to list_changed notifications and fire - * corresponding events (toolsListChanged, resourcesListChanged, promptsListChanged). - */ - listChangedNotifications?: { - tools?: boolean; - resources?: boolean; - prompts?: boolean; - }; - - /** - * Whether to enable progress notification handling (default: true) - * If enabled, InspectorClient will register a handler for progress notifications and dispatch progressNotification events - */ - progress?: boolean; - - /** - * If true, receiving a progress notification resets the request timeout (default: true). - * Only applies to requests that can receive progress. Set to false for strict timeout caps. - */ - resetTimeoutOnProgress?: boolean; - - /** - * Per-request timeout in milliseconds. If not set, the SDK default (60_000) is used. - */ - timeout?: number; - - /** - * Default `_meta` payload merged into every outgoing request the client - * issues (tools/list, tools/call, prompts/get, resources/read, etc.). Call- - * site metadata wins on key collision. Set this from `InspectorServerSettings.metadata` - * so persisted server-wide metadata reaches the wire on the first request. - */ - defaultMetadata?: Record; - - /** - * Optional per-server runtime settings forwarded to the transport factory - * (for HTTP transports, settings.headers becomes the wire headers). The - * other fields on `InspectorServerSettings` are unpacked by the caller - * into `timeout`, `defaultMetadata`, and `oauth` on this options object — - * `serverSettings` itself is only consumed by the transport. - */ - serverSettings?: InspectorServerSettings; - - /** - * Protocol version negotiation for the SDK Client (SEP §7.8 era model). - * When omitted, the client pins the legacy 2025-11-25 era - * (`{ mode: "legacy" }`), byte-identical to a client without negotiation. - * Callers derive this from the per-server {@link ServerProtocolEra} via - * {@link eraToVersionNegotiation}. (#1626) - */ - versionNegotiation?: VersionNegotiationOptions; - - /** - * OAuth configuration (client credentials, scope, etc.) - * Note: OAuth environment components (storage, navigation, redirectUrlProvider) - * are in environment.oauth, but clientId/clientSecret/scope are config. - */ - oauth?: { - clientId?: string; - clientSecret?: string; - clientMetadataUrl?: string; - scope?: string; - /** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */ - enterpriseManaged?: boolean; - }; - - /** - * Global enterprise IdP credentials (from client.json). Used for EMA legs 1–2 - * when {@link oauth.enterpriseManaged} is true on the server. - */ - enterpriseManagedAuth?: { - idp: EnterpriseManagedAuthIdpConfig; - }; - - /** - * Full install-level EMA config from client.json (including when disabled). - * Used to produce friendly errors when a server expects EMA but IdP is inactive. - */ - installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; - - /** - * When true, direct transports (TUI/CLI) route MCP 401/403 through - * `handleAuthChallenge()` via fetch intercept instead of the SDK auth() path. - * Web remote clients should leave this false (default). - */ - directAuthRecovery?: boolean; - - /** - * Optional session ID. If not provided, will be extracted from OAuth state - * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. - */ - sessionId?: string; - - /** - * When true, advertise receiver-task capability and handle task-augmented - * sampling/createMessage and elicit; register tasks/list, tasks/get, - * tasks/result, tasks/cancel handlers. Default false. - */ - receiverTasks?: boolean; - - /** - * TTL in ms for receiver tasks when server sends params.task without ttl. - * Only used when receiverTasks is true. If a function, called at task creation. - * Default 60_000 when omitted. - */ - receiverTaskTtlMs?: number | (() => number); -} diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 969c8020e..947c216a6 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -1,7 +1,5 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { MantineProvider } from '@mantine/core'; - import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; import type { ArtifactInspection } from '../../agent-bundle/src/contracts/artifacts.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../../agent-bundle/src/contracts/mcp-apps.ts'; @@ -19,13 +17,12 @@ import { EvalsPage } from './evals/evals-page.tsx'; import { ArtifactsPage } from './artifacts/artifacts-page.tsx'; import { HookClient } from './hooks/hook-client.ts'; import { HooksPage } from './hooks/hooks-page.tsx'; -import { InspectorSessionAdapter } from './inspector/adapter/inspector-session-adapter-entry.ts'; import { createRuntimeAppBridgeFactory, type RuntimeAppBridgeFactory, type RuntimeAppBridgeOperationTrace, type RuntimeAppBridgeTrace, -} from './inspector/adapter/runtime-app-bridge.ts'; +} from './mcp/runtime-app-bridge.ts'; import { McpAppClient, type McpAppConsentChallenge } from './mcp/mcp-app-client.ts'; import type { McpAppConsentChallenge as RuntimeMcpAppConsentChallenge } from '../../agent-bundle/src/contracts/mcp-apps.ts'; import { RuntimeConsentDialog } from './mcp/runtime-consent-dialog.tsx'; @@ -305,8 +302,6 @@ const StateMark = ({ state }: { readonly state: string }) => ( type WorkbenchPage = GeneralWorkbenchPage | 'runtime'; type RuntimeCapability = 'available' | 'unavailable' | 'unknown'; -type McpPresentation = 'inspector' | 'playground'; - type CapabilityState = | Readonly<{ readonly state: 'empty' }> | Readonly<{ readonly buildId: string; readonly state: 'loading' }> @@ -634,7 +629,7 @@ const HooksScreen = ({ connectionError, hookClient, onNavigate, pages, runtimeDi ; -const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, presentation, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, setPresentation, status }: { +const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { readonly appPreviewClient: McpAppClient; readonly artifactClient: ArtifactClient; readonly connectionError?: string; @@ -648,9 +643,7 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll readonly runtimeDiagnostic?: string; readonly runtimeHandoff?: RuntimeMcpHandoff; readonly runtimePreviewDependencies: McpPageRuntimePreviewDependencies; - readonly presentation: McpPresentation; readonly registerPreviewClose: (close: () => Promise) => () => void; - readonly setPresentation: (presentation: McpPresentation) => void; readonly status: ProjectStatus; }) => { useEffect(() => { @@ -685,113 +678,32 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll sameRuntimeMcpAppBinding(runtimeHandoff.source.binding, runtimeSource.binding) ? runtimeHandoff.initialPreview : undefined; - const runtimeAvailability = !isRuntimeModelBinding(model.binding) ? undefined : Object.freeze({ - prompts: 'not-routed' as const, - resourceTemplates: 'not-routed' as const, - resources: 'available' as const, - tools: 'available' as const, - }); - const presentationTabs = useRef>>({}); - const selectPresentation = (next: McpPresentation): void => { - setPresentation(next); - presentationTabs.current[next]?.focus(); - }; - const onPresentationKeyDown = (event: ReactKeyboardEvent, current: McpPresentation): void => { - const presentations: readonly McpPresentation[] = ['playground', 'inspector']; - const index = presentations.indexOf(current); - const next = event.key === 'ArrowRight' || event.key === 'ArrowDown' - ? presentations[(index + 1) % presentations.length] - : event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? presentations[(index + presentations.length - 1) % presentations.length] - : event.key === 'Home' - ? presentations[0] - : event.key === 'End' - ? presentations[presentations.length - 1] - : undefined; - if (next === undefined) return; - event.preventDefault(); - selectPresentation(next); - }; - const exportInspectorTrace = (entries: typeof model.timeline.entries): void => { - downloadMcpFile(mcpProtocolTraceDownload({ - history: controller.history, - model: { ...model, timeline: { ...model.timeline, entries } }, - })); - }; return
{mcpDepartureDiagnostic === undefined ? undefined :

{mcpDepartureDiagnostic}

} -
- - -
- - + {runtimeSource === undefined + ? + : }
; }; @@ -869,7 +781,6 @@ const Workbench = () => { if (runtimeConsentQueue.current === undefined) { runtimeConsentQueue.current = createRuntimeConsentQueue(setRuntimeConsent); } - const [mcpPresentation, setMcpPresentation] = useState('playground'); const [status, setStatus] = useState(); const [changedFiles, setChangedFiles] = useState([]); const [runtimeOperationTraces, setRuntimeOperationTraces] = useState(emptyRuntimeOperationTraces); @@ -946,7 +857,6 @@ const Workbench = () => { } const hash = `#${available}`; if (window.location.hash !== hash) window.history.pushState(undefined, '', hash); - if (available === 'mcp') setMcpPresentation('playground'); setPage(available); }, []); @@ -1112,7 +1022,7 @@ const Workbench = () => { const authority = prepareRuntimeMcpHandoffAuthority(props, runtimeProfileId); const appClient = mcpAppClient.current; if (authority === undefined || appClient === undefined) return undefined; - return { }} run={props.run} surface={props.surface} - />; + />; }, [createBridgeFactory, runtimeOperationTraces]); const liveMcpPageAdapter = useMemo(() => Object.freeze({ @@ -1335,7 +1245,6 @@ const Workbench = () => { const next = pageForHash(runtimeAvailable, pages); if (!fromHashChange && next === pageRef.current) return; navigate(next); - if (fromHashChange && next === 'mcp' && pageRef.current !== 'mcp') setMcpPresentation('playground'); }; const onHashChange = () => updatePage(true); updatePage(false); @@ -1387,8 +1296,6 @@ const Workbench = () => { runtimeDiagnostic={runtimeError} runtimeHandoff={runtimeHandoff} runtimePreviewDependencies={runtimePreviewDependencies} - presentation={mcpPresentation} - setPresentation={setMcpPresentation} status={status} />); } diff --git a/packages/workbench/src/inspector/LICENSE.inspector b/packages/workbench/src/mcp/APP-RENDERER-LICENSE similarity index 100% rename from packages/workbench/src/inspector/LICENSE.inspector rename to packages/workbench/src/mcp/APP-RENDERER-LICENSE diff --git a/packages/workbench/src/mcp/app-renderer.tsx b/packages/workbench/src/mcp/app-renderer.tsx new file mode 100644 index 000000000..f2206fe4b --- /dev/null +++ b/packages/workbench/src/mcp/app-renderer.tsx @@ -0,0 +1,521 @@ +import type { CallToolResult, Tool } from '@modelcontextprotocol/client'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, + type Ref, + type RefObject, +} from 'react'; + +/** + * First-party MCP App renderer, adapted from the MCP Inspector's AppRenderer + * (modelcontextprotocol/inspector 672f9f41, MIT). The Workbench previously + * vendored the Inspector; only this renderer survived the removal, retyped + * against the shapes the preview pipeline compiles against. The Workbench has + * no design-token system, so the host advertises no styles - apps use their + * own defaults, which the ext-apps spec permits - and the theme derives from + * the system color scheme. + */ + +export type McpAppRendererDisplayMode = 'fullscreen' | 'inline' | 'pip'; + +export type McpAppRendererJsonArray = readonly McpAppRendererJsonValue[]; + +export interface McpAppRendererJsonObject { + readonly [key: string]: McpAppRendererJsonValue; +} + +export type McpAppRendererJsonValue = + | null + | boolean + | number + | string + | McpAppRendererJsonArray + | McpAppRendererJsonObject; + +export type McpAppRendererTool = Tool; + +export interface McpAppRendererMessage { + readonly content: readonly McpAppRendererJsonValue[]; + readonly role: 'user'; +} + +export interface McpAppRendererHostContext { + readonly availableDisplayModes?: readonly McpAppRendererDisplayMode[]; + readonly containerDimensions?: Readonly<{ readonly height: number; readonly width: number }>; + readonly displayMode?: McpAppRendererDisplayMode; + readonly theme?: 'dark' | 'light'; +} + +export interface AppRendererBridge { + addEventListener(type: 'initialized', listener: () => void): void; + addEventListener(type: 'loggingmessage', listener: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void): void; + addEventListener(type: 'sizechange', listener: (params: Readonly<{ readonly height?: number; readonly width?: number }>) => void): void; + close(): Promise; + onmessage?: (params: McpAppRendererMessage) => Promise>; + onrequestdisplaymode?: (params: Readonly<{ readonly mode: McpAppRendererDisplayMode }>) => Promise>; + sendHostContextChange(context: Partial): Promise; + sendToolCancelled(params: Readonly<{ readonly reason: string }>): Promise; + sendToolInput(params: Readonly<{ readonly arguments: Record }>): Promise; + sendToolInputPartial(params: Readonly<{ readonly arguments: Record }>): Promise; + sendToolResult(result: CallToolResult): Promise; + teardownResource(params: Readonly>): Promise>>; +} + +/** + * Constructs the bridge for a freshly mounted sandbox iframe. Wrap with + * `useCallback` (or hoist out of render) - the renderer treats a new factory + * identity as a signal to tear down the current bridge and rebuild, so an + * unstable factory will thrash the iframe on every render. + */ +export type BridgeFactory = ( + iframe: HTMLIFrameElement, + tool: McpAppRendererTool, +) => AppRendererBridge | Promise; + +export interface AppRendererHandle { + sendToolCancelled(reason: string): Promise; + sendToolInput(args: Record): Promise; + sendToolResult(result: CallToolResult): Promise; + teardown(): Promise; +} + +/** + * High-level lifecycle of a running app, surfaced so a host (or an automated + * driver polling a `data-app-status` attribute) can wait for the right moment: + * `loading` while the bridge is being built and the view's `ui/initialize` + * handshake is in flight; `ready` once the view has fired + * `notifications/initialized`; `error` when the bridge factory throws or + * rejects (no live view to wait on). + */ +export type AppRendererStatus = 'error' | 'loading' | 'ready'; + +export interface AppRendererProps { + readonly bridgeFactory: BridgeFactory; + /** + * Current host display mode for the app frame. Pushed to the running view + * whenever it changes (e.g. Maximize/Restore), so an app can adapt its + * layout to inline vs fullscreen. + */ + readonly displayMode?: McpAppRendererDisplayMode; + readonly onAppStatusChange?: (status: AppRendererStatus) => void; + readonly onError?: (error: Error) => void; + /** Called for each MCP log notification the running view emits. */ + readonly onLog?: (params: Readonly<{ readonly data: McpAppRendererJsonValue; readonly level: string; readonly logger?: string }>) => void; + /** + * Called when the running view submits a user-role message via + * `ui/message`. The renderer returns the spec-required empty result on the + * host's behalf, so the callback is fire-and-forget. + */ + readonly onMessage?: (params: McpAppRendererMessage) => void; + /** + * Handles a view-originated `ui/request-display-mode`. Return the mode the + * host actually applied - the spec lets the host decline an unsupported + * mode by returning its current one. + */ + readonly onRequestDisplayMode?: (requested: McpAppRendererDisplayMode) => McpAppRendererDisplayMode; + /** Reports the view's rendered content size so the host can fit the frame. */ + readonly onSizeChange?: (size: Readonly<{ readonly height?: number; readonly width?: number }>) => void; + /** + * Ordered tool-input fragments to replay before the complete `tool-input`, + * exercising widgets that render progressively. Captured at bridge-build + * time so prop churn never rebuilds the iframe. + */ + readonly partialInputs?: readonly Readonly>[]; + /** + * The host-controlled box the app renders within, used to derive + * `hostContext.containerDimensions`. This MUST be an element whose size is + * driven by the host's layout and NOT by the view's own size reports - + * otherwise the two signals couple into a feedback loop. Falls back to the + * iframe element when omitted. + */ + readonly containerRef?: RefObject; + readonly ref?: Ref; + readonly sandboxPath: string; + readonly tool: McpAppRendererTool; +} + +const currentTheme = (): 'dark' | 'light' => + typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + +const measureContainerDimensions = ( + element: HTMLElement, +): Readonly<{ readonly height: number; readonly width: number }> | undefined => { + if (typeof element.getBoundingClientRect !== 'function') return undefined; + const rect = element.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (width <= 0 || height <= 0) return undefined; + return { height, width }; +}; + +/** + * Read the live host UI state for the bridge handshake - the single place + * that decides which fields the host seeds. Optional fields are omitted (not + * set undefined) so the bridge's diff stays accurate; subsequent live changes + * are pushed by the renderer's observers as partial host-context changes. The + * seed assumes the app opens inline; the live displayMode push carries any + * subsequent inline-fullscreen transition. + */ +export const snapshotHostContext = ( + container: HTMLElement | null, + availableDisplayModes: readonly McpAppRendererDisplayMode[], +): McpAppRendererHostContext => { + const containerDimensions = container === null ? undefined : measureContainerDimensions(container); + return { + availableDisplayModes: [...availableDisplayModes], + ...(containerDimensions === undefined ? {} : { containerDimensions }), + displayMode: 'inline', + theme: currentTheme(), + }; +}; + +const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)); + +const disposeBridge = async (bridge: AppRendererBridge): Promise => { + // Best-effort: still close the transport even if teardownResource fails, + // otherwise the iframe unmount would leak MessagePort listeners. + try { + await bridge.teardownResource({}); + } catch { + /* swallow - closing transport below is the load-bearing step */ + } + try { + await bridge.close(); + } catch { + /* swallow - already disposing */ + } +}; + +/** + * Bridge lifecycle (the interlocking refs below): + * + * mount -> build (buildId++) -> factory(iframe, tool) -async-> bridgeRef set + * | on "initialized" + * v -> flushPending + * cleanup -> scheduleDispose() --microtask--> dispose (unless cancelled) + * ^ | + * +-- re-setup with SAME inputs -----+ cancel + REUSE bridge + * + * - `buildId` (monotonic): a bridge resolved from an older build self-disposes. + * - `disposeScheduled`: a dispose is queued (microtask); a synchronous + * re-setup (StrictMode double-invoke, or a transient re-render) cancels it + * and reuses the live bridge instead of rebuilding (rebuild double-loads + * the sandbox and races the app handshake). A re-setup with CHANGED inputs + * disposes + rebuilds. + * - `lastDeps`: distinguishes "same inputs -> reuse" from "changed -> rebuild". + * - `initialized`: gates flushing buffered input/result until the view is ready. + * - `pendingInput`/`pendingResult`: latest-wins buffer for host-initiated open. + * - `teardownStarted`: makes the imperative teardown() idempotent vs unmount. + */ +export const AppRenderer = ({ + bridgeFactory, + containerRef, + displayMode, + onAppStatusChange, + onError, + onLog, + onMessage, + onRequestDisplayMode, + onSizeChange, + partialInputs, + ref, + sandboxPath, + tool, +}: AppRendererProps): React.ReactNode => { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const initializedRef = useRef(false); + const pendingPartialsRef = useRef>[]>([]); + const pendingInputRef = useRef | null>(null); + const pendingResultRef = useRef(null); + const teardownStartedRef = useRef(false); + const buildIdRef = useRef(0); + const disposeScheduledRef = useRef(false); + const lastDepsRef = useRef | null>(null); + const onErrorRef = useRef(onError); + const onAppStatusChangeRef = useRef(onAppStatusChange); + const onSizeChangeRef = useRef(onSizeChange); + const displayModeRef = useRef(displayMode); + const onRequestDisplayModeRef = useRef(onRequestDisplayMode); + const onMessageRef = useRef(onMessage); + const onLogRef = useRef(onLog); + const partialInputsRef = useRef(partialInputs); + useEffect(() => { + onErrorRef.current = onError; + onAppStatusChangeRef.current = onAppStatusChange; + onSizeChangeRef.current = onSizeChange; + displayModeRef.current = displayMode; + onRequestDisplayModeRef.current = onRequestDisplayMode; + onMessageRef.current = onMessage; + onLogRef.current = onLog; + partialInputsRef.current = partialInputs; + }); + + // Flush buffered tool input/result to the view, but only once the bridge + // exists AND the view has signalled `initialized`. The spec requires tool + // input/result to arrive after initialization, yet a host-initiated open + // fires before the iframe's app has loaded - so the latest values buffer + // and release when the view is ready. Input is always sent before result. + const flushPending = useCallback(() => { + const bridge = bridgeRef.current; + if (bridge === null || !initializedRef.current) return; + for (const args of pendingPartialsRef.current) { + void bridge.sendToolInputPartial({ arguments: { ...args } }); + } + pendingPartialsRef.current = []; + if (pendingInputRef.current !== null) { + const args = pendingInputRef.current; + pendingInputRef.current = null; + void bridge.sendToolInput({ arguments: args }); + } + if (pendingResultRef.current !== null) { + const result = pendingResultRef.current; + pendingResultRef.current = null; + void bridge.sendToolResult(result); + } + }, []); + + // Dispose the live bridge, but deferred to a microtask. React StrictMode + // runs effects setup->cleanup->setup synchronously in dev; deferring lets + // the re-setup cancel the disposal and keep the SAME bridge, instead of + // tearing it down and rebuilding. A rebuild here spins up a second + // transport that re-posts sandbox-resource-ready (the sandbox loads the app + // twice) and races the app's ui/initialize handshake. + const scheduleDispose = useCallback(() => { + disposeScheduledRef.current = true; + queueMicrotask(() => { + if (!disposeScheduledRef.current) return; + disposeScheduledRef.current = false; + buildIdRef.current += 1; + const bridge = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + lastDepsRef.current = null; + pendingPartialsRef.current = []; + if (bridge !== null) void disposeBridge(bridge); + }); + }, []); + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe === null) return undefined; + + const previous = lastDepsRef.current; + const sameInputs = + previous !== null && + previous.bridgeFactory === bridgeFactory && + previous.sandboxPath === sandboxPath && + previous.tool === tool; + + // A disposal scheduled by the immediately-preceding cleanup means this is + // a synchronous re-setup. If the inputs are identical (StrictMode's + // double-invoke, or a transient re-render) keep the live bridge: cancel + // the disposal and re-deliver any buffered input/result to it. + /* v8 ignore next 4 -- StrictMode's replayed effect body is invisible to coverage. */ + if (disposeScheduledRef.current && sameInputs) { + disposeScheduledRef.current = false; + flushPending(); + return scheduleDispose; + } + + // Otherwise this is a real (re)build. If a disposal was pending (inputs + // changed), run it synchronously before building the replacement. + if (disposeScheduledRef.current) { + disposeScheduledRef.current = false; + buildIdRef.current += 1; + const old = bridgeRef.current; + bridgeRef.current = null; + initializedRef.current = false; + if (old !== null) void disposeBridge(old); + } + + lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; + const buildId = buildIdRef.current + 1; + buildIdRef.current = buildId; + teardownStartedRef.current = false; + initializedRef.current = false; + onAppStatusChangeRef.current?.('loading'); + // Snapshot the staged partial-input fragments for THIS bridge build (read + // via the ref so the prop is not a dep - adding/removing fragments must + // not rebuild the iframe). + pendingPartialsRef.current = [...(partialInputsRef.current ?? [])]; + + let pending: Promise; + try { + pending = Promise.resolve(bridgeFactory(iframe, tool)); + } catch (error) { + onAppStatusChangeRef.current?.('error'); + onErrorRef.current?.(toError(error)); + return scheduleDispose; + } + + pending + .then((bridge) => { + if (buildIdRef.current !== buildId) { + void disposeBridge(bridge); + return; + } + bridgeRef.current = bridge; + // Registered before the inner app can finish loading, so the view's + // `initialized` signal is never missed. + bridge.addEventListener('initialized', () => { + initializedRef.current = true; + onAppStatusChangeRef.current?.('ready'); + // The factory already seeded theme/displayMode into the handshake + // hostContext; only containerDimensions can plausibly differ + // between bridge construction and initialization (layout settles). + const container = containerRef?.current ?? iframeRef.current; + const containerDimensions = container === null ? undefined : measureContainerDimensions(container); + if (containerDimensions !== undefined) { + void bridge.sendHostContextChange({ containerDimensions }); + } + flushPending(); + }); + bridge.addEventListener('sizechange', (size) => { + onSizeChangeRef.current?.(size); + }); + bridge.addEventListener('loggingmessage', (params) => { + onLogRef.current?.(params); + }); + // Handle ui/request-display-mode: the host decides what mode actually + // applies. With no handler the request is declined by returning the + // current host-side mode. + bridge.onrequestdisplaymode = async ({ mode }) => { + const handler = onRequestDisplayModeRef.current; + const applied = handler === undefined ? (displayModeRef.current ?? 'inline') : handler(mode); + return { mode: applied }; + }; + // Handle ui/message: surface the submitted content and return the + // spec-required empty result. With no handler the submission is + // declined by returning isError. + bridge.onmessage = async (params) => { + const handler = onMessageRef.current; + if (handler === undefined) return { isError: true }; + handler(params); + return {}; + }; + flushPending(); + }) + .catch((error: unknown) => { + if (buildIdRef.current !== buildId) return; + onAppStatusChangeRef.current?.('error'); + onErrorRef.current?.(toError(error)); + }); + + return scheduleDispose; + // `containerRef` is listed for exhaustive-deps completeness, but a change + // to its identity does NOT force a rebuild: the `sameInputs` check above + // ignores it, and the `initialized` handler reads `containerRef?.current` + // lazily. The other deps are the real rebuild keys. + }, [ + bridgeFactory, + sandboxPath, + tool, + containerRef, + flushPending, + scheduleDispose, + ]); + + // Theme: the Workbench has no theme system of its own, so the system color + // scheme is the only live theme signal; forward changes to the running view + // once it has initialized. + useEffect(() => { + if (typeof window === 'undefined' || window.matchMedia === undefined) return undefined; + const query = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = (): void => { + if (!initializedRef.current) return; + void bridgeRef.current?.sendHostContextChange({ theme: currentTheme() }); + }; + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); + + // Container size: observes the host-controlled container (or the iframe as + // a fallback) - NOT an element whose height is driven by the view's own + // size reports, which would couple the two signals into a feedback loop. + // Gated on the view's `initialized` signal; a 0x0 (not-yet-laid-out) + // measurement and a value-equal repeat are both skipped. + useEffect(() => { + const target = containerRef?.current ?? iframeRef.current; + if (typeof ResizeObserver === 'undefined' || target === null) return undefined; + let last: Readonly<{ readonly height: number; readonly width: number }> | undefined; + const observer = new ResizeObserver(() => { + if (!initializedRef.current) return; + const next = measureContainerDimensions(target); + if (next === undefined) return; + if (last !== undefined && last.width === next.width && last.height === next.height) return; + last = next; + void bridgeRef.current?.sendHostContextChange({ containerDimensions: next }); + }); + observer.observe(target); + return () => observer.disconnect(); + }, [containerRef]); + + // Display mode: pushes whenever the prop changes (Maximize/Restore). Gated + // on `initialized` for the same reason as the other host-context pushes. + useEffect(() => { + if (displayMode === undefined) return; + if (!initializedRef.current) return; + void bridgeRef.current?.sendHostContextChange({ displayMode }); + }, [displayMode]); + + useImperativeHandle( + ref, + () => ({ + async sendToolCancelled(reason) { + const bridge = bridgeRef.current; + if (bridge === null) return; + await bridge.sendToolCancelled({ reason }); + }, + async sendToolInput(args) { + // Buffered (latest-wins) and released by flushPending once the view + // is initialized - the handle may be invoked before the bridge + // resolves. + pendingInputRef.current = args; + flushPending(); + }, + async sendToolResult(result) { + pendingResultRef.current = result; + flushPending(); + }, + async teardown() { + const bridge = bridgeRef.current; + if (bridge === null || teardownStartedRef.current) return; + teardownStartedRef.current = true; + // Null the ref synchronously so a concurrent unmount cleanup cannot + // see a still-live bridge and dispose it a second time. Bumping the + // build id makes any in-flight factory self-dispose, and clearing the + // pending-dispose flag/cached deps prevents the deferred dispose from + // acting on an already torn-down bridge. + buildIdRef.current += 1; + disposeScheduledRef.current = false; + lastDepsRef.current = null; + bridgeRef.current = null; + initializedRef.current = false; + pendingInputRef.current = null; + pendingResultRef.current = null; + await disposeBridge(bridge); + }, + }), + [flushPending], + ); + + // The iframe deliberately has no `sandbox` attribute: `sandboxPath` + // resolves to the host's own trusted same-origin sandbox page, which then + // loads the untrusted MCP App content into a nested sandboxed iframe. + // Sandboxing this outer frame would block the postMessage bridge. + return ( +