From d2070d168d433022180ec77ca362f0bc0a5c99f2 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Fri, 28 Aug 2026 23:06:31 +0800 Subject: [PATCH 1/2] feat: implement Codex and standalone Host Adapters (#4) --- README.md | 5 +- apps/launcher/package.json | 1 + apps/launcher/src/standalone-runtime.ts | 10 +- docs/host-integration/codex-compatibility.md | 59 ++ package-lock.json | 568 ++++++++++++++++++ package.json | 2 + packages/host-adapter/codex-cdp/package.json | 2 + .../codex-cdp/src/adapter.test.ts | 527 ++++++++++++++++ .../host-adapter/codex-cdp/src/adapter.ts | 120 ++++ .../host-adapter/codex-cdp/src/connection.ts | 296 +++++++++ .../codex-cdp/src/csp-bypass.test.ts | 62 ++ .../host-adapter/codex-cdp/src/csp-bypass.ts | 35 ++ packages/host-adapter/codex-cdp/src/index.ts | 12 +- .../codex-cdp/src/managed-connection.ts | 186 ++++++ .../host-adapter/codex-cdp/src/renderer.ts | 25 + packages/host-adapter/src/index.ts | 27 +- .../host-adapter/standalone/src/adapter.ts | 14 +- tests/contract/host-adapter.contract.test.ts | 25 + .../standalone-host-adapter.contract.test.ts | 9 +- 19 files changed, 1966 insertions(+), 19 deletions(-) create mode 100644 docs/host-integration/codex-compatibility.md create mode 100644 packages/host-adapter/codex-cdp/src/adapter.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/adapter.ts create mode 100644 packages/host-adapter/codex-cdp/src/connection.ts create mode 100644 packages/host-adapter/codex-cdp/src/csp-bypass.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/csp-bypass.ts create mode 100644 packages/host-adapter/codex-cdp/src/managed-connection.ts create mode 100644 packages/host-adapter/codex-cdp/src/renderer.ts create mode 100644 tests/contract/host-adapter.contract.test.ts diff --git a/README.md b/README.md index bbc178c..9017b5f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Codex Git -Codex Git is a planned local Git surface for Codex Desktop. This repository currently contains only the initial application scaffold; Worktree discovery, Git commands, Codex injection, packaging, and other product features are not implemented. +Codex Git is a planned local Git surface for Codex Desktop. This repository contains the initial application scaffold and Host Adapter boundary; Worktree discovery, Git commands, packaging, and other product features are not implemented. ## Product and architecture - [Domain language](./CONTEXT.md) - [macOS MVP product requirements](./docs/product/mvp-prd.md) - [MVP technical architecture](./docs/architecture/mvp-technical-architecture.md) +- [Codex Host Adapter compatibility and trust boundary](./docs/host-integration/codex-compatibility.md) - Architecture decisions: - [Isolate Codex host integration behind a Host Adapter](./docs/adr/0001-isolate-codex-host-integration.md) - [Use the system Git CLI behind a local Repository Engine](./docs/adr/0002-use-system-git-behind-repository-engine.md) @@ -51,7 +52,7 @@ apps/ui Standalone React placeholder surface packages/protocol Shared protocol types packages/repository-engine Repository session boundary only packages/host-adapter Host Adapter boundary -packages/host-adapter/* Codex CDP and standalone adapter placeholders +packages/host-adapter/* Codex CDP/DOM and standalone Host Adapters tests/ Contract, integration, end-to-end, and fixture layers ``` diff --git a/apps/launcher/package.json b/apps/launcher/package.json index ffecb82..8e03268 100644 --- a/apps/launcher/package.json +++ b/apps/launcher/package.json @@ -6,6 +6,7 @@ "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { + "@codex-git/host-adapter": "*", "@codex-git/host-adapter-standalone": "*", "@codex-git/server": "*" }, diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 2fcf409..01e9f8a 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -1,6 +1,7 @@ import type { Server } from 'node:http'; import { fileURLToPath } from 'node:url'; +import type { HostConnection } from '@codex-git/host-adapter'; import { createAppServer } from '@codex-git/server'; import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; import { createServer as createViteServer, type ViteDevServer } from 'vite'; @@ -26,13 +27,11 @@ export async function startStandaloneRuntime( ): Promise { const healthServer = createAppServer(); let surfaceServer: ViteDevServer | undefined; - let hostConnection: Awaited< - ReturnType - > | null = null; + let hostConnection: HostConnection | null = null; async function closeResources(): Promise { await Promise.all([ - hostConnection?.dispose(), + hostConnection?.close(), surfaceServer?.close(), closeServer(healthServer), ]); @@ -53,10 +52,11 @@ export async function startStandaloneRuntime( await surfaceServer.listen(); const surfaceUrl = serverUrl(surfaceServer.httpServer, '/'); - hostConnection = await new StandaloneHostAdapter().attach({ + const hostResult = await new StandaloneHostAdapter().attach({ title: 'Codex Git', url: surfaceUrl, }); + hostConnection = hostResult.connection; let closed = false; diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md new file mode 100644 index 0000000..2417bd1 --- /dev/null +++ b/docs/host-integration/codex-compatibility.md @@ -0,0 +1,59 @@ +# Codex Host Adapter compatibility + +The Codex Host Adapter is an unsupported local CDP/DOM integration. It is not an +official Codex extension interface. The standalone Host Adapter remains the +supported fallback whenever discovery, compatibility, attachment, or remounting +cannot be proven safe. + +## Trust and ownership requirements + +Codex Git attaches only to a renderer selected through a loopback CDP endpoint +owned by a dedicated Codex Git profile or instance. A renderer name, window +title, route, or DOM resemblance is never ownership evidence. The renderer +source must provide a non-empty stable target ID and the exact +`codex-git-dedicated` ownership proof before the compatibility probe can mutate +the document. + +Codex Desktop's Content Security Policy does not allow the loopback Git Surface +as a frame. The dedicated renderer therefore grants a generation-scoped +`Page.setBypassCSP` lease before mounting. The lease is released after any +replacement, failed attachment, or connection close. Never grant this lease to +a normal user-owned Codex window: bypassing CSP expands the effect of any script +already executing in that renderer. + +CDP has no application-level authentication in this design. Treat access to the +dedicated loopback debugging endpoint as trusted local-process authority. Do not +bind it to a non-loopback interface, reuse a normal Codex profile, publish the +endpoint, or record it in ordinary logs. + +## Tested profile + +| Codex Desktop | Chromium framework | Required anchors | Evidence | +| ----------------------------- | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | + +The automated fixture covers read-only probing, fail-closed fallback, one-entry +mounting, native navigation, repeat attachment, renderer replacement, context +updates, opaque iframe sandboxing, generation/capability/challenge rejection, +CSP lease restoration, and complete teardown. + +Any Codex version or DOM shape not listed here fails closed before mutation. A +new version requires a new explicit profile and the same fixture and manual smoke +matrix; do not widen selectors to make an unknown build appear compatible. + +## Manual smoke matrix + +Run the matrix only against a disposable dedicated profile with a loopback CDP +endpoint. Record the exact Codex and Chromium versions with the result. + +- Open `Git` and confirm exactly one entry and one full-page frame. +- Select a native destination and confirm native content is restored with no + hidden overlay. +- Open `Git` again after a renderer reload and confirm one new frame generation. +- Change Current Project, theme, and task and confirm typed context updates. +- Send missing, altered, replayed, and stale capability/challenge messages and + confirm they cause no action. +- Close the connection and confirm all nodes, listeners, CDP sessions, and the + CSP bypass lease are gone. +- Break either required selector and confirm the native UI remains byte-for-byte + unchanged while the standalone fallback is reported. diff --git a/package-lock.json b/package-lock.json index 580319b..db71da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "devDependencies": { "@eslint/js": "10.0.1", + "@types/jsdom": "^30.0.0", "@types/node": "26.3.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", @@ -21,6 +22,7 @@ "eslint": "10.9.1", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.5", + "jsdom": "^30.0.1", "prettier": "3.9.6", "tsx": "4.23.12", "typescript": "5.9.3", @@ -58,6 +60,59 @@ "react-dom": "19.2.8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -298,6 +353,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codex-git/host-adapter": { "resolved": "packages/host-adapter", "link": true @@ -330,6 +398,146 @@ "resolved": "apps/ui", "link": true }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -900,6 +1108,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1345,6 +1571,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.0.tgz", + "integrity": "sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1382,6 +1628,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1841,6 +2094,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -1941,6 +2204,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1948,6 +2225,35 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1966,6 +2272,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1990,6 +2303,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -2412,6 +2738,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2455,6 +2794,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2469,6 +2815,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2849,6 +3246,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2972,6 +3376,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3105,6 +3522,16 @@ "react": "^19.2.8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", @@ -3139,6 +3566,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3209,6 +3649,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3253,6 +3700,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3336,6 +3829,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -3552,6 +4055,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3595,6 +4146,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index edc57da..11542e5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@types/jsdom": "^30.0.0", "@types/node": "26.3.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", @@ -33,6 +34,7 @@ "eslint": "10.9.1", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.5", + "jsdom": "^30.0.1", "prettier": "3.9.6", "tsx": "4.23.12", "typescript": "5.9.3", diff --git a/packages/host-adapter/codex-cdp/package.json b/packages/host-adapter/codex-cdp/package.json index f937668..a72175c 100644 --- a/packages/host-adapter/codex-cdp/package.json +++ b/packages/host-adapter/codex-cdp/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": "./src/index.ts", + "types": "./src/index.ts", "dependencies": { "@codex-git/host-adapter": "*" } diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts new file mode 100644 index 0000000..af5f447 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -0,0 +1,527 @@ +import { JSDOM } from 'jsdom'; +import { describe, expect, it } from 'vitest'; + +import type { HostContext } from '@codex-git/host-adapter'; + +import { + CodexCdpHostAdapter, + type CodexRenderer, + type CodexRendererSource, +} from './index.js'; + +describe('CodexCdpHostAdapter', () => { + it('fails closed without changing an incompatible renderer', async () => { + const dom = new JSDOM('
Codex
'); + const renderer = fixtureRenderer(dom, '26.820.60940'); + const before = dom.window.document.documentElement.outerHTML; + + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result).toEqual({ + kind: 'standalone-required', + reason: { + code: 'incompatible-host', + message: + 'Codex Desktop 26.820.60940 did not match the tested host structure; use the standalone surface.', + }, + }); + expect(dom.window.document.documentElement.outerHTML).toBe(before); + }); + + it('mounts one opaque Git surface and restores native navigation', async () => { + const dom = compatibleDom(); + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.820.60940'), + ), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result.kind).toBe('attached'); + if (result.kind !== 'attached') { + throw new Error('Expected the compatible Codex renderer to attach'); + } + + const document = dom.window.document; + const gitEntry = document.querySelector( + '[data-codex-git-sidebar-entry]', + ); + expect(gitEntry?.textContent).toBe('Git'); + expect(document.querySelector('[data-codex-git-surface]')).toBeNull(); + + gitEntry?.click(); + + const frame = document.querySelector( + '[data-codex-git-surface] iframe', + ); + const nativeSurface = document.querySelector( + '[data-app-shell-main-surface="default"]', + ); + expect({ + frameCount: document.querySelectorAll('[data-codex-git-surface]').length, + nativeHidden: nativeSurface?.hidden, + sandbox: frame?.getAttribute('sandbox'), + source: frame?.src, + }).toEqual({ + frameCount: 1, + nativeHidden: true, + sandbox: 'allow-scripts', + source: 'http://127.0.0.1:4173/', + }); + + document.querySelector('[data-native-entry]')?.click(); + expect({ + nativeHidden: nativeSurface?.hidden, + surface: document.querySelector('[data-codex-git-surface]'), + }).toEqual({ nativeHidden: false, surface: null }); + + await result.connection.close(); + expect(document.querySelector('[data-codex-git-sidebar-entry]')).toBeNull(); + expect(nativeSurface?.hidden).toBe(false); + }); + + it('keeps exactly one entry when the adapter attaches repeatedly', async () => { + const dom = compatibleDom(); + const adapter = new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.820.60940'), + ), + }); + const surface = { + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }; + + const first = await adapter.attach(surface); + documentEntry(dom)?.click(); + const second = await adapter.attach(surface); + + expect({ + entries: dom.window.document.querySelectorAll( + '[data-codex-git-sidebar-entry]', + ).length, + surfaces: dom.window.document.querySelectorAll('[data-codex-git-surface]') + .length, + }).toEqual({ entries: 1, surfaces: 0 }); + + if (first.kind === 'attached') { + await first.connection.close(); + } + expect(documentEntry(dom)).not.toBeNull(); + + if (second.kind === 'attached') { + await second.connection.close(); + } + }); + + it('accepts actions only from the current frame capability and challenge', async () => { + const dom = compatibleDom(); + const secrets = [ + 'capability-1', + 'challenge-1', + 'capability-2', + 'challenge-2', + ]; + const result = await new CodexCdpHostAdapter({ + createSecret: () => secrets.shift() ?? 'unexpected-secret', + rendererSource: new FixtureRendererSource( + fixtureRenderer(dom, '26.820.60940'), + ), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + if (result.kind !== 'attached') { + throw new Error('Expected the compatible Codex renderer to attach'); + } + + documentEntry(dom)?.click(); + const firstFrame = documentFrame(dom); + const hostContext = await captureHostContext(dom, firstFrame); + expect(hostContext).toEqual({ + capability: 'capability-1', + challenge: 'challenge-1', + context: { + projectPath: null, + task: null, + theme: 'system', + }, + generation: 1, + type: 'codex-git:host-context', + }); + + dispatchHostAction(dom, firstFrame, { + ...hostContext, + capability: 'wrong-capability', + action: { kind: 'restore-native-surface' }, + type: 'codex-git:host-action', + }); + expect(documentFrame(dom)).toBe(firstFrame); + + dispatchHostAction(dom, firstFrame, { + ...hostContext, + action: { kind: 'restore-native-surface' }, + type: 'codex-git:host-action', + }); + expect(documentFrame(dom)).toBeNull(); + + documentEntry(dom)?.click(); + const secondFrame = documentFrame(dom); + const secondContext = await captureHostContext(dom, secondFrame); + expect(secondContext).toMatchObject({ + capability: 'capability-2', + challenge: 'challenge-2', + generation: 2, + }); + + dispatchHostAction(dom, firstFrame, { + ...hostContext, + action: { kind: 'restore-native-surface' }, + type: 'codex-git:host-action', + }); + expect(documentFrame(dom)).toBe(secondFrame); + + await result.connection.close(); + }); + + it('forwards Current Project, theme, and current task context changes', async () => { + const dom = compatibleDom(); + const renderer = new FixtureRenderer(dom, '26.820.60940', { + projectPath: '/Users/example/codex-git', + task: { id: 'task-1', title: 'Implement Host Adapter' }, + theme: 'dark', + }); + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + if (result.kind !== 'attached') { + throw new Error('Expected the compatible Codex renderer to attach'); + } + + const contexts = result.connection.contexts()[Symbol.asyncIterator](); + expect(await contexts.next()).toEqual({ + done: false, + value: { + projectPath: '/Users/example/codex-git', + task: { id: 'task-1', title: 'Implement Host Adapter' }, + theme: 'dark', + }, + }); + + documentEntry(dom)?.click(); + const frame = documentFrame(dom); + await captureHostContext(dom, frame); + const nextFrameContext = captureNextFrameMessage(frame); + renderer.publishContext({ + projectPath: '/Users/example/another-project', + task: { id: 'task-2', title: 'Review changes' }, + theme: 'light', + }); + + expect(await contexts.next()).toEqual({ + done: false, + value: { + projectPath: '/Users/example/another-project', + task: { id: 'task-2', title: 'Review changes' }, + theme: 'light', + }, + }); + expect(await nextFrameContext).toMatchObject({ + context: { + projectPath: '/Users/example/another-project', + task: { id: 'task-2', title: 'Review changes' }, + theme: 'light', + }, + type: 'codex-git:host-context', + }); + + await result.connection.close(); + await expect(contexts.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); + + it('reattaches after a compatible renderer replacement', async () => { + const firstDom = compatibleDom(); + const secondDom = compatibleDom(); + const source = new FixtureRendererSource( + fixtureRenderer(firstDom, '26.820.60940'), + ); + const result = await new CodexCdpHostAdapter({ + rendererSource: source, + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + if (result.kind !== 'attached') { + throw new Error('Expected the compatible Codex renderer to attach'); + } + documentEntry(firstDom)?.click(); + + source.publish(fixtureRenderer(secondDom, '26.820.60940')); + await new Promise((resolve) => setImmediate(resolve)); + + expect({ + oldEntry: documentEntry(firstDom) !== null, + oldSurface: documentFrame(firstDom) !== null, + oldSurfaceHidden: nativeSurface(firstDom)?.hidden, + replacementEntries: secondDom.window.document.querySelectorAll( + '[data-codex-git-sidebar-entry]', + ).length, + replacementSurface: documentFrame(secondDom) !== null, + }).toEqual({ + oldEntry: false, + oldSurface: false, + oldSurfaceHidden: false, + replacementEntries: 1, + replacementSurface: true, + }); + + await result.connection.close(); + expect(documentEntry(secondDom)).toBeNull(); + expect(nativeSurface(secondDom)?.hidden).toBe(false); + }); + + it('scopes CSP bypass to a dedicated renderer lease', async () => { + const dom = compatibleDom(); + const leaseEvents: string[] = []; + const renderer = new FixtureRenderer( + dom, + '26.820.60940', + { + projectPath: null, + task: null, + theme: 'system', + }, + leaseEvents, + ); + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(leaseEvents).toEqual(['acquire:renderer-fixture']); + if (result.kind !== 'attached') { + throw new Error('Expected the dedicated Codex renderer to attach'); + } + + await result.connection.close(); + expect(leaseEvents).toEqual([ + 'acquire:renderer-fixture', + 'release:renderer-fixture', + ]); + }); + + it('retries CSP restoration when connection close is called again', async () => { + const dom = compatibleDom(); + const leaseEvents: string[] = []; + const renderer = new FixtureRenderer( + dom, + '26.820.60940', + { projectPath: null, task: null, theme: 'system' }, + leaseEvents, + ); + renderer.failNextCspRelease(); + const result = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + if (result.kind !== 'attached') { + throw new Error('Expected the dedicated Codex renderer to attach'); + } + + await expect(result.connection.close()).rejects.toThrow( + 'CSP restoration failed', + ); + await expect(result.connection.close()).resolves.toBeUndefined(); + expect(leaseEvents).toEqual([ + 'acquire:renderer-fixture', + 'release:renderer-fixture', + 'release:renderer-fixture', + ]); + }); +}); + +class FixtureRendererSource implements CodexRendererSource { + private readonly listeners = new Set< + (renderer: CodexRenderer | null) => void + >(); + + constructor(private renderer: CodexRenderer | null) {} + + async current(): Promise { + return this.renderer; + } + + publish(renderer: CodexRenderer | null): void { + this.renderer = renderer; + this.listeners.forEach((listener) => listener(renderer)); + } + + subscribe(listener: (renderer: CodexRenderer | null) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +function compatibleDom(): JSDOM { + return new JSDOM(` +
+ +
Native task
+
+ `); +} + +function documentEntry(dom: JSDOM): HTMLButtonElement | null { + return dom.window.document.querySelector('[data-codex-git-sidebar-entry]'); +} + +function documentFrame(dom: JSDOM): HTMLIFrameElement | null { + return dom.window.document.querySelector('[data-codex-git-surface] iframe'); +} + +function nativeSurface(dom: JSDOM): HTMLElement | null { + return dom.window.document.querySelector( + '[data-app-shell-main-surface="default"]', + ); +} + +async function captureHostContext( + dom: JSDOM, + frame: HTMLIFrameElement | null, +): Promise> { + if (frame?.contentWindow === null || frame?.contentWindow === undefined) { + throw new Error('Expected the Git iframe to have a content window'); + } + + const message = new Promise>((resolve, reject) => { + frame.contentWindow?.addEventListener( + 'message', + (event) => { + if (isRecord(event.data)) { + resolve(event.data); + } else { + reject(new Error('Expected a typed Host Context message')); + } + }, + { once: true }, + ); + }); + frame.dispatchEvent(new dom.window.Event('load')); + return message; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function dispatchHostAction( + dom: JSDOM, + frame: HTMLIFrameElement | null, + data: unknown, +): void { + dom.window.dispatchEvent( + new dom.window.MessageEvent('message', { + data, + source: frame?.contentWindow ?? null, + }), + ); +} + +function captureNextFrameMessage( + frame: HTMLIFrameElement | null, +): Promise> { + if (frame?.contentWindow === null || frame?.contentWindow === undefined) { + throw new Error('Expected the Git iframe to have a content window'); + } + + return new Promise((resolve, reject) => { + frame.contentWindow?.addEventListener( + 'message', + (event) => { + if (isRecord(event.data)) { + resolve(event.data); + } else { + reject(new Error('Expected a typed Host Context message')); + } + }, + { once: true }, + ); + }); +} + +function fixtureRenderer(dom: JSDOM, version: string): CodexRenderer { + return new FixtureRenderer(dom, version, { + projectPath: null, + task: null, + theme: 'system', + }); +} + +class FixtureRenderer implements CodexRenderer { + readonly document: Document; + readonly id = 'renderer-fixture'; + readonly ownership = 'codex-git-dedicated' as const; + readonly window: Window & typeof globalThis; + private readonly listeners = new Set<(context: HostContext) => void>(); + private releaseFailures = 0; + + constructor( + dom: JSDOM, + readonly version: string, + private context: HostContext, + private readonly leaseEvents: string[] = [], + ) { + this.document = dom.window.document; + this.window = dom.window as unknown as Window & typeof globalThis; + } + + currentContext(): HostContext { + return this.context; + } + + publishContext(context: HostContext): void { + this.context = context; + this.listeners.forEach((listener) => listener(context)); + } + + failNextCspRelease(): void { + this.releaseFailures++; + } + + subscribeContext(listener: (context: HostContext) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async acquireCspBypass(): Promise<{ release(): Promise }> { + this.leaseEvents.push(`acquire:${this.id}`); + return { + release: async () => { + this.leaseEvents.push(`release:${this.id}`); + if (this.releaseFailures > 0) { + this.releaseFailures--; + throw new Error('CSP restoration failed'); + } + }, + }; + } +} diff --git a/packages/host-adapter/codex-cdp/src/adapter.ts b/packages/host-adapter/codex-cdp/src/adapter.ts new file mode 100644 index 0000000..eb8034a --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/adapter.ts @@ -0,0 +1,120 @@ +import { randomBytes } from 'node:crypto'; + +import type { + HostAdapter, + HostAttachResult, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import type { + CodexRenderer, + CodexRendererSource, + CspBypassLease, +} from './renderer.js'; +import { ManagedCodexHostConnection } from './managed-connection.js'; + +const supportedCodexVersion = '26.820.60940'; +const sidebarSelector = '#app-shell-sidebar'; +const mainSurfaceSelector = '[data-app-shell-main-surface="default"]'; +const activeConnections = new WeakMap< + CodexRendererSource, + ManagedCodexHostConnection +>(); + +export interface CodexCdpHostAdapterOptions { + readonly createSecret?: () => string; + readonly rendererSource: CodexRendererSource; +} + +export class CodexCdpHostAdapter implements HostAdapter { + constructor(private readonly options: CodexCdpHostAdapterOptions) {} + + async attach(surface: SurfaceDescriptor): Promise { + const renderer = await this.options.rendererSource.current(); + + if (renderer === null) { + return standaloneRequired( + 'host-unavailable', + 'No explicitly selected Codex renderer was available; use the standalone surface.', + ); + } + + if (!isCompatible(renderer)) { + return standaloneRequired( + 'incompatible-host', + `Codex Desktop ${safeVersion(renderer.version)} did not match the tested host structure; use the standalone surface.`, + ); + } + + let cspBypass: CspBypassLease | null = null; + try { + await activeConnections.get(this.options.rendererSource)?.close(); + cspBypass = await renderer.acquireCspBypass(); + const connection = new ManagedCodexHostConnection( + renderer, + cspBypass, + this.options.rendererSource, + surface, + this.options.createSecret ?? createSecret, + isCompatible, + () => { + if ( + activeConnections.get(this.options.rendererSource) === connection + ) { + activeConnections.delete(this.options.rendererSource); + } + }, + ); + cspBypass = null; + activeConnections.set(this.options.rendererSource, connection); + return { + kind: 'attached', + connection, + }; + } catch { + await cspBypass?.release(); + return standaloneRequired( + 'attach-failed', + 'The compatible Codex renderer could not be attached; use the standalone surface.', + ); + } + } +} + +function safeVersion(version: string): string { + return /^\d+(?:\.\d+){1,3}$/.test(version) + ? version + : 'with an unknown version'; +} + +function createSecret(): string { + return randomBytes(32).toString('base64url'); +} + +function isCompatible(renderer: CodexRenderer): boolean { + if ( + renderer.version !== supportedCodexVersion || + renderer.ownership !== 'codex-git-dedicated' || + renderer.id.length === 0 + ) { + return false; + } + + const sidebar = renderer.document.querySelector(sidebarSelector); + const mainSurface = renderer.document.querySelector(mainSurfaceSelector); + + return ( + sidebar instanceof renderer.window.HTMLElement && + mainSurface instanceof renderer.window.HTMLElement + ); +} + +function standaloneRequired( + code: 'attach-failed' | 'host-unavailable' | 'incompatible-host', + message: string, +): HostAttachResult { + return { + kind: 'standalone-required', + reason: { code, message }, + }; +} diff --git a/packages/host-adapter/codex-cdp/src/connection.ts b/packages/host-adapter/codex-cdp/src/connection.ts new file mode 100644 index 0000000..78a1c4d --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -0,0 +1,296 @@ +import type { + HostConnection, + HostContext, + NativeActionResult, + NativeHostAction, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import type { CodexRenderer } from './renderer.js'; + +const sidebarEntrySelector = '[data-codex-git-sidebar-entry]'; +const surfaceSelector = '[data-codex-git-surface]'; + +interface ActiveFrame { + readonly capability: string; + readonly challenge: string; + readonly frame: HTMLIFrameElement; + readonly generation: number; +} + +export class CodexHostConnection implements HostConnection { + private closed = false; + private activeFrame: ActiveFrame | null = null; + private context: HostContext; + private readonly contextClosers = new Set<() => void>(); + private readonly contextSubscribers = new Set< + (context: HostContext) => void + >(); + private readonly gitEntry: HTMLButtonElement; + private readonly mainSurface: HTMLElement; + private mountedSurface: HTMLElement | null = null; + private readonly originalMainHidden: boolean; + private readonly sidebar: HTMLElement; + private readonly unsubscribeContext: () => void; + + constructor( + private readonly renderer: CodexRenderer, + private readonly surface: SurfaceDescriptor, + private readonly createSecret: () => string, + private readonly nextFrameGeneration: () => number, + private readonly onClose: () => void, + ) { + const { document, window } = renderer; + const sidebar = document.querySelector('#app-shell-sidebar'); + const mainSurface = document.querySelector( + '[data-app-shell-main-surface="default"]', + ); + if ( + !(sidebar instanceof window.HTMLElement) || + !(mainSurface instanceof window.HTMLElement) + ) { + throw new Error('Compatible Codex anchors disappeared before attachment'); + } + + this.sidebar = sidebar; + this.mainSurface = mainSurface; + this.originalMainHidden = mainSurface.hidden; + this.context = renderer.currentContext(); + this.unsubscribeContext = renderer.subscribeContext(this.handleContext); + document + .querySelectorAll(`${sidebarEntrySelector}, ${surfaceSelector}`) + .forEach((node) => node.remove()); + + this.gitEntry = document.createElement('button'); + this.gitEntry.dataset.codexGitSidebarEntry = ''; + this.gitEntry.type = 'button'; + this.gitEntry.textContent = 'Git'; + this.gitEntry.setAttribute('aria-label', 'Open Codex Git'); + const nativeEntry = sidebar.querySelector('button'); + if (nativeEntry instanceof window.HTMLButtonElement) { + this.gitEntry.className = nativeEntry.className; + } + this.gitEntry.addEventListener('click', this.openGitSurface); + this.sidebar.addEventListener('click', this.handleSidebarNavigation, true); + renderer.window.addEventListener('message', this.handleFrameMessage); + this.sidebar.append(this.gitEntry); + } + + currentContext(): HostContext { + return this.context; + } + + async *contexts(): AsyncIterable { + const queue = [this.currentContext()]; + let closed = this.closed; + let wake: (() => void) | null = null; + const publish = (context: HostContext) => { + queue.push(context); + wake?.(); + }; + const close = () => { + closed = true; + wake?.(); + }; + this.contextSubscribers.add(publish); + this.contextClosers.add(close); + + try { + while (!closed) { + const next = queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + + await new Promise((resolve) => { + wake = resolve; + }); + wake = null; + } + } finally { + this.contextSubscribers.delete(publish); + this.contextClosers.delete(close); + } + } + + async perform(action: NativeHostAction): Promise { + if (this.closed) { + return { status: 'rejected' }; + } + + switch (action.kind) { + case 'restore-native-surface': + this.restoreNativeSurface(); + return { status: 'completed' }; + } + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + this.unsubscribeContext(); + this.contextClosers.forEach((close) => close()); + this.contextClosers.clear(); + this.contextSubscribers.clear(); + this.gitEntry.removeEventListener('click', this.openGitSurface); + this.sidebar.removeEventListener( + 'click', + this.handleSidebarNavigation, + true, + ); + this.renderer.window.removeEventListener( + 'message', + this.handleFrameMessage, + ); + this.gitEntry.remove(); + this.removeMountedSurface(); + this.mainSurface.hidden = this.originalMainHidden; + this.onClose(); + } + + isGitSurfaceOpen(): boolean { + return this.activeFrame !== null; + } + + showGitSurface(): void { + if (this.closed) { + return; + } + + this.removeMountedSurface(); + const { document } = this.renderer; + const host = document.createElement('main'); + host.dataset.codexGitSurface = ''; + host.setAttribute('aria-label', this.surface.title); + Object.assign(host.style, { + display: 'flex', + flex: '1 1 auto', + minHeight: '0', + minWidth: '0', + overflow: 'hidden', + }); + + const frame = document.createElement('iframe'); + frame.src = this.surface.url.href; + frame.title = this.surface.title; + frame.setAttribute('sandbox', 'allow-scripts'); + Object.assign(frame.style, { + border: '0', + height: '100%', + width: '100%', + }); + this.activeFrame = { + capability: this.createSecret(), + challenge: this.createSecret(), + frame, + generation: this.nextFrameGeneration(), + }; + frame.addEventListener('load', this.publishHostContext); + host.append(frame); + + this.mainSurface.hidden = true; + this.mainSurface.after(host); + this.mountedSurface = host; + this.gitEntry.setAttribute('aria-current', 'page'); + } + + private readonly openGitSurface = () => { + this.showGitSurface(); + }; + + private readonly handleSidebarNavigation = (event: Event) => { + const target = event.target; + if ( + target instanceof this.renderer.window.Node && + this.gitEntry.contains(target) + ) { + return; + } + + this.restoreNativeSurface(); + }; + + private readonly handleFrameMessage = (event: MessageEvent) => { + const activeFrame = this.activeFrame; + if ( + activeFrame === null || + event.source !== activeFrame.frame.contentWindow || + !isHostActionMessage(event.data) || + event.data.capability !== activeFrame.capability || + event.data.challenge !== activeFrame.challenge || + event.data.generation !== activeFrame.generation + ) { + return; + } + + void this.perform(event.data.action); + }; + + private readonly handleContext = (context: HostContext) => { + if (this.closed) { + return; + } + + this.context = context; + this.contextSubscribers.forEach((publish) => publish(context)); + this.publishHostContext(); + }; + + private readonly publishHostContext = () => { + const activeFrame = this.activeFrame; + activeFrame?.frame.contentWindow?.postMessage( + { + capability: activeFrame.capability, + challenge: activeFrame.challenge, + context: this.currentContext(), + generation: activeFrame.generation, + type: 'codex-git:host-context', + }, + '*', + ); + }; + + private restoreNativeSurface(): void { + this.removeMountedSurface(); + this.mainSurface.hidden = this.originalMainHidden; + this.gitEntry.removeAttribute('aria-current'); + } + + private removeMountedSurface(): void { + this.activeFrame?.frame.removeEventListener( + 'load', + this.publishHostContext, + ); + this.activeFrame = null; + this.mountedSurface?.remove(); + this.mountedSurface = null; + } +} + +function isHostActionMessage(value: unknown): value is { + readonly action: NativeHostAction; + readonly capability: string; + readonly challenge: string; + readonly generation: number; + readonly type: 'codex-git:host-action'; +} { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as Record; + const action = candidate.action; + return ( + candidate.type === 'codex-git:host-action' && + typeof candidate.capability === 'string' && + typeof candidate.challenge === 'string' && + Number.isSafeInteger(candidate.generation) && + typeof action === 'object' && + action !== null && + (action as Record).kind === 'restore-native-surface' + ); +} diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts new file mode 100644 index 0000000..338c4ff --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + acquireDedicatedRendererCspBypass, + type CodexCdpCommandTransport, +} from './index.js'; + +describe('dedicated renderer CSP bypass', () => { + it('enables one exact renderer target and restores it idempotently', async () => { + const commands: Array<{ + method: string; + params: unknown; + rendererId: string; + }> = []; + const transport: CodexCdpCommandTransport = { + async send(rendererId, method, params) { + commands.push({ method, params, rendererId }); + }, + }; + + const lease = await acquireDedicatedRendererCspBypass( + transport, + 'renderer-target-42', + ); + await lease.release(); + await lease.release(); + + expect(commands).toEqual([ + { + method: 'Page.setBypassCSP', + params: { enabled: true }, + rendererId: 'renderer-target-42', + }, + { + method: 'Page.setBypassCSP', + params: { enabled: false }, + rendererId: 'renderer-target-42', + }, + ]); + }); + + it('allows restoration to be retried after a CDP failure', async () => { + let disableAttempts = 0; + const transport: CodexCdpCommandTransport = { + async send(_rendererId, _method, params) { + if (!params.enabled && ++disableAttempts === 1) { + throw new Error('renderer temporarily unavailable'); + } + }, + }; + const lease = await acquireDedicatedRendererCspBypass( + transport, + 'renderer-target-42', + ); + + await expect(lease.release()).rejects.toThrow( + 'renderer temporarily unavailable', + ); + await expect(lease.release()).resolves.toBeUndefined(); + expect(disableAttempts).toBe(2); + }); +}); diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.ts new file mode 100644 index 0000000..d0c4862 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.ts @@ -0,0 +1,35 @@ +import type { CspBypassLease } from './renderer.js'; + +export interface CodexCdpCommandTransport { + send( + rendererId: string, + method: 'Page.setBypassCSP', + params: { readonly enabled: boolean }, + ): Promise; +} + +export async function acquireDedicatedRendererCspBypass( + transport: CodexCdpCommandTransport, + rendererId: string, +): Promise { + if (rendererId.length === 0) { + throw new Error('A stable renderer ID is required for CSP bypass'); + } + + await transport.send(rendererId, 'Page.setBypassCSP', { enabled: true }); + let releaseAttempt: Promise | null = null; + + return { + release() { + if (releaseAttempt === null) { + releaseAttempt = transport + .send(rendererId, 'Page.setBypassCSP', { enabled: false }) + .catch((error: unknown) => { + releaseAttempt = null; + throw error; + }); + } + return releaseAttempt; + }, + }; +} diff --git a/packages/host-adapter/codex-cdp/src/index.ts b/packages/host-adapter/codex-cdp/src/index.ts index f5c9d95..07d7db5 100644 --- a/packages/host-adapter/codex-cdp/src/index.ts +++ b/packages/host-adapter/codex-cdp/src/index.ts @@ -1,3 +1,9 @@ -import type { HostAdapter } from '@codex-git/host-adapter'; - -export type CodexCdpHostAdapterContract = HostAdapter; +export { CodexCdpHostAdapter } from './adapter.js'; +export { acquireDedicatedRendererCspBypass } from './csp-bypass.js'; +export type { CodexCdpCommandTransport } from './csp-bypass.js'; +export type { + CodexRenderer, + CodexRendererSource, + CodexRendererSubscription, + CspBypassLease, +} from './renderer.js'; diff --git a/packages/host-adapter/codex-cdp/src/managed-connection.ts b/packages/host-adapter/codex-cdp/src/managed-connection.ts new file mode 100644 index 0000000..32c3952 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/managed-connection.ts @@ -0,0 +1,186 @@ +import type { + HostConnection, + HostContext, + NativeActionResult, + NativeHostAction, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import { CodexHostConnection } from './connection.js'; +import type { + CodexRenderer, + CodexRendererSource, + CspBypassLease, +} from './renderer.js'; + +export class ManagedCodexHostConnection implements HostConnection { + private closed = false; + private closeAttempt: Promise | null = null; + private closeNotified = false; + private context: HostContext; + private readonly contextClosers = new Set<() => void>(); + private readonly contextSubscribers = new Set< + (context: HostContext) => void + >(); + private frameGeneration = 0; + private mounted: CodexHostConnection | null; + private cspBypass: CspBypassLease | null; + private rendererContextSubscription: () => void; + private replacement = Promise.resolve(); + private readonly sourceSubscription: () => void; + + constructor( + renderer: CodexRenderer, + cspBypass: CspBypassLease, + source: CodexRendererSource, + private readonly surface: SurfaceDescriptor, + private readonly createSecret: () => string, + private readonly compatible: (renderer: CodexRenderer) => boolean, + private readonly onClose: () => void, + ) { + this.cspBypass = cspBypass; + this.context = renderer.currentContext(); + this.rendererContextSubscription = renderer.subscribeContext( + this.handleContext, + ); + this.mounted = this.mount(renderer); + this.sourceSubscription = source.subscribe((replacement) => { + this.replacement = this.replacement + .catch(() => undefined) + .then(() => this.replaceRenderer(replacement)); + }); + } + + currentContext(): HostContext { + return this.context; + } + + async *contexts(): AsyncIterable { + const queue = [this.context]; + let closed = this.closed; + let wake: (() => void) | null = null; + const publish = (context: HostContext) => { + queue.push(context); + wake?.(); + }; + const close = () => { + closed = true; + wake?.(); + }; + this.contextSubscribers.add(publish); + this.contextClosers.add(close); + + try { + while (!closed) { + const next = queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + wake = null; + } + } finally { + this.contextSubscribers.delete(publish); + this.contextClosers.delete(close); + } + } + + async perform(action: NativeHostAction): Promise { + if (this.closed || this.mounted === null) { + return { status: 'rejected' }; + } + return this.mounted.perform(action); + } + + close(): Promise { + if (this.closeAttempt === null) { + this.closeAttempt = this.closeOnce().catch((error: unknown) => { + this.closeAttempt = null; + throw error; + }); + } + return this.closeAttempt; + } + + private readonly handleContext = (context: HostContext) => { + if (this.closed) { + return; + } + this.context = context; + this.contextSubscribers.forEach((publish) => publish(context)); + }; + + private mount(renderer: CodexRenderer): CodexHostConnection { + return new CodexHostConnection( + renderer, + this.surface, + this.createSecret, + () => ++this.frameGeneration, + () => undefined, + ); + } + + private async closeOnce(): Promise { + if (!this.closed) { + this.closed = true; + this.sourceSubscription(); + this.rendererContextSubscription(); + this.contextClosers.forEach((close) => close()); + this.contextClosers.clear(); + this.contextSubscribers.clear(); + await this.replacement.catch(() => undefined); + await this.mounted?.close(); + this.mounted = null; + } + + if (this.cspBypass !== null) { + await this.cspBypass.release(); + this.cspBypass = null; + } + if (!this.closeNotified) { + this.closeNotified = true; + this.onClose(); + } + } + + private async replaceRenderer(renderer: CodexRenderer | null): Promise { + if (this.closed) { + return; + } + + const reopen = this.mounted?.isGitSurfaceOpen() ?? false; + this.rendererContextSubscription(); + await this.mounted?.close(); + this.mounted = null; + await this.cspBypass?.release(); + this.cspBypass = null; + + if (renderer === null || !this.compatible(renderer) || this.closed) { + return; + } + + try { + this.cspBypass = await renderer.acquireCspBypass(); + } catch { + return; + } + if (this.closed) { + await this.cspBypass.release(); + this.cspBypass = null; + return; + } + + this.context = renderer.currentContext(); + this.contextSubscribers.forEach((publish) => publish(this.context)); + this.rendererContextSubscription = renderer.subscribeContext( + this.handleContext, + ); + this.mounted = this.mount(renderer); + if (reopen) { + this.mounted.showGitSurface(); + } + } +} diff --git a/packages/host-adapter/codex-cdp/src/renderer.ts b/packages/host-adapter/codex-cdp/src/renderer.ts new file mode 100644 index 0000000..3b7aedd --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/renderer.ts @@ -0,0 +1,25 @@ +import type { HostContext } from '@codex-git/host-adapter'; + +export interface CspBypassLease { + release(): Promise; +} + +export interface CodexRenderer { + readonly document: Document; + readonly id: string; + readonly ownership: 'codex-git-dedicated'; + readonly version: string; + readonly window: Window & typeof globalThis; + acquireCspBypass(): Promise; + currentContext(): HostContext; + subscribeContext(listener: (context: HostContext) => void): () => void; +} + +export type CodexRendererSubscription = ( + renderer: CodexRenderer | null, +) => void; + +export interface CodexRendererSource { + current(): Promise; + subscribe(listener: CodexRendererSubscription): () => void; +} diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index c19fa70..ae1e29a 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -5,21 +5,40 @@ export interface SurfaceDescriptor { export interface HostContext { readonly projectPath: string | null; + readonly task: HostTaskContext | null; readonly theme: 'dark' | 'light' | 'system'; } -export type NativeHostAction = never; +export interface HostTaskContext { + readonly id: string; + readonly title: string; +} + +export type NativeHostAction = { readonly kind: 'restore-native-surface' }; export interface NativeActionResult { - readonly status: 'completed' | 'unsupported'; + readonly status: 'completed' | 'rejected' | 'unsupported'; +} + +export interface SanitizedDiagnostic { + readonly code: 'attach-failed' | 'host-unavailable' | 'incompatible-host'; + readonly message: string; } export interface HostAdapter { - attach(surface: SurfaceDescriptor): Promise; + attach(surface: SurfaceDescriptor): Promise; } +export type HostAttachResult = + | { readonly kind: 'attached'; readonly connection: HostConnection } + | { + readonly kind: 'standalone-required'; + readonly reason: SanitizedDiagnostic; + }; + export interface HostConnection { + currentContext(): HostContext; contexts(): AsyncIterable; perform(action: NativeHostAction): Promise; - dispose(): Promise; + close(): Promise; } diff --git a/packages/host-adapter/standalone/src/adapter.ts b/packages/host-adapter/standalone/src/adapter.ts index 191572b..e1e939a 100644 --- a/packages/host-adapter/standalone/src/adapter.ts +++ b/packages/host-adapter/standalone/src/adapter.ts @@ -8,10 +8,15 @@ import type { const standaloneContext = { projectPath: null, + task: null, theme: 'system', } satisfies HostContext; class StandaloneHostConnection implements HostConnection { + currentContext(): HostContext { + return standaloneContext; + } + async *contexts(): AsyncIterable { yield standaloneContext; } @@ -20,12 +25,15 @@ class StandaloneHostConnection implements HostConnection { return { status: 'unsupported' }; } - async dispose(): Promise {} + async close(): Promise {} } export class StandaloneHostAdapter implements HostAdapter { - async attach(surface: SurfaceDescriptor): Promise { + async attach(surface: SurfaceDescriptor) { void surface; - return new StandaloneHostConnection(); + return { + kind: 'attached' as const, + connection: new StandaloneHostConnection(), + }; } } diff --git a/tests/contract/host-adapter.contract.test.ts b/tests/contract/host-adapter.contract.test.ts new file mode 100644 index 0000000..9fedb39 --- /dev/null +++ b/tests/contract/host-adapter.contract.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; + +describe('HostAdapter contract', () => { + it('attaches the standalone surface with a current typed Host Context', async () => { + const result = await new StandaloneHostAdapter().attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result.kind).toBe('attached'); + if (result.kind !== 'attached') { + throw new Error('Expected the standalone Host Adapter to attach'); + } + + expect(result.connection.currentContext()).toEqual({ + projectPath: null, + task: null, + theme: 'system', + }); + + await result.connection.close(); + }); +}); diff --git a/tests/contract/standalone-host-adapter.contract.test.ts b/tests/contract/standalone-host-adapter.contract.test.ts index 7aec73f..0b4136a 100644 --- a/tests/contract/standalone-host-adapter.contract.test.ts +++ b/tests/contract/standalone-host-adapter.contract.test.ts @@ -5,10 +5,14 @@ import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; describe('StandaloneHostAdapter contract', () => { it('publishes the standalone Host Context after attaching a surface', async () => { const adapter = new StandaloneHostAdapter(); - const connection = await adapter.attach({ + const result = await adapter.attach({ title: 'Codex Git', url: new URL('http://127.0.0.1:4173'), }); + if (result.kind !== 'attached') { + throw new Error('Expected the standalone Host Adapter to attach'); + } + const { connection } = result; const contexts = connection.contexts()[Symbol.asyncIterator](); @@ -16,10 +20,11 @@ describe('StandaloneHostAdapter contract', () => { done: false, value: { projectPath: null, + task: null, theme: 'system', }, }); - await connection.dispose(); + await connection.close(); }); }); From a16ebd89d5ccb742ad7853975a39121c6b0ad86c Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Fri, 28 Aug 2026 23:19:58 +0800 Subject: [PATCH 2/2] fix: narrow Codex host foundation scope (#4) --- docs/host-integration/codex-compatibility.md | 22 ++- .../codex-cdp/src/adapter.test.ts | 102 +++++----- .../host-adapter/codex-cdp/src/adapter.ts | 53 ++--- .../codex-cdp/src/compatibility.ts | 30 +++ .../host-adapter/codex-cdp/src/connection.ts | 152 ++++++++------ packages/host-adapter/codex-cdp/src/index.ts | 1 - .../codex-cdp/src/managed-connection.ts | 186 ------------------ .../host-adapter/codex-cdp/src/renderer.ts | 5 - packages/host-adapter/src/index.ts | 2 +- 9 files changed, 199 insertions(+), 354 deletions(-) create mode 100644 packages/host-adapter/codex-cdp/src/compatibility.ts delete mode 100644 packages/host-adapter/codex-cdp/src/managed-connection.ts diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 2417bd1..5525fba 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -5,6 +5,12 @@ official Codex extension interface. The standalone Host Adapter remains the supported fallback whenever discovery, compatibility, attachment, or remounting cannot be proven safe. +This foundation implements the typed Host Adapter contract, strict compatibility +probe, DOM lifecycle, message boundary, and CSP lease primitive. Production CDP +discovery and transport, dedicated-instance ownership binding, renderer and DOM +replacement, launcher composition, fallback transitions, and manual smoke +verification are tracked in [#30](https://github.com/codeacme17/codex-git/issues/30). + ## Trust and ownership requirements Codex Git attaches only to a renderer selected through a loopback CDP endpoint @@ -32,19 +38,21 @@ endpoint, or record it in ordinary logs. | ----------------------------- | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | | `26.820.60940` (build `7119`) | `151.0.7922.170` | `#app-shell-sidebar`; `[data-app-shell-main-surface="default"]` | Installed renderer bundle inspection plus automated DOM fixture coverage | -The automated fixture covers read-only probing, fail-closed fallback, one-entry -mounting, native navigation, repeat attachment, renderer replacement, context -updates, opaque iframe sandboxing, generation/capability/challenge rejection, -CSP lease restoration, and complete teardown. +The automated fixture covers read-only probing, fail-closed fallback, +transactional attachment, one-entry mounting, native navigation, repeat +attachment, context updates, opaque iframe sandboxing, +generation/capability/challenge rejection, CSP lease restoration, and complete +teardown. Any Codex version or DOM shape not listed here fails closed before mutation. A new version requires a new explicit profile and the same fixture and manual smoke matrix; do not widen selectors to make an unknown build appear compatible. -## Manual smoke matrix +## Pending manual smoke matrix -Run the matrix only against a disposable dedicated profile with a loopback CDP -endpoint. Record the exact Codex and Chromium versions with the result. +Issue [#30](https://github.com/codeacme17/codex-git/issues/30) must run this +matrix against a disposable dedicated profile with a loopback CDP endpoint and +record the exact Codex and Chromium versions with the result. - Open `Git` and confirm exactly one entry and one full-page frame. - Select a native destination and confirm native content is restored with no diff --git a/packages/host-adapter/codex-cdp/src/adapter.test.ts b/packages/host-adapter/codex-cdp/src/adapter.test.ts index af5f447..dbfa5e9 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -76,6 +76,13 @@ describe('CodexCdpHostAdapter', () => { source: 'http://127.0.0.1:4173/', }); + await expect( + result.connection.perform({ kind: 'restore-native-surface' }), + ).resolves.toEqual({ status: 'succeeded' }); + expect(document.querySelector('[data-codex-git-surface]')).toBeNull(); + + gitEntry?.click(); + document.querySelector('[data-native-entry]')?.click(); expect({ nativeHidden: nativeSurface?.hidden, @@ -89,10 +96,9 @@ describe('CodexCdpHostAdapter', () => { it('keeps exactly one entry when the adapter attaches repeatedly', async () => { const dom = compatibleDom(); + const renderer = fixtureRenderer(dom, '26.820.60940'); const adapter = new CodexCdpHostAdapter({ - rendererSource: new FixtureRendererSource( - fixtureRenderer(dom, '26.820.60940'), - ), + rendererSource: new FixtureRendererSource(renderer), }); const surface = { title: 'Codex Git', @@ -101,7 +107,9 @@ describe('CodexCdpHostAdapter', () => { const first = await adapter.attach(surface); documentEntry(dom)?.click(); - const second = await adapter.attach(surface); + const second = await new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).attach(surface); expect({ entries: dom.window.document.querySelectorAll( @@ -252,45 +260,45 @@ describe('CodexCdpHostAdapter', () => { }); }); - it('reattaches after a compatible renderer replacement', async () => { - const firstDom = compatibleDom(); - const secondDom = compatibleDom(); - const source = new FixtureRendererSource( - fixtureRenderer(firstDom, '26.820.60940'), + it('rolls back attachment when mounting fails after subscription', async () => { + const dom = compatibleDom(); + const leaseEvents: string[] = []; + const renderer = new FixtureRenderer( + dom, + '26.820.60940', + { projectPath: null, task: null, theme: 'system' }, + leaseEvents, ); + const sidebar = dom.window.document.querySelector('#app-shell-sidebar'); + if (!(sidebar instanceof dom.window.HTMLElement)) { + throw new Error('Expected a compatible sidebar fixture'); + } + const before = dom.window.document.documentElement.outerHTML; + sidebar.append = () => { + throw new Error('Mount failed'); + }; + const result = await new CodexCdpHostAdapter({ - rendererSource: source, + rendererSource: new FixtureRendererSource(renderer), }).attach({ title: 'Codex Git', url: new URL('http://127.0.0.1:4173'), }); - if (result.kind !== 'attached') { - throw new Error('Expected the compatible Codex renderer to attach'); - } - documentEntry(firstDom)?.click(); - - source.publish(fixtureRenderer(secondDom, '26.820.60940')); - await new Promise((resolve) => setImmediate(resolve)); - expect({ - oldEntry: documentEntry(firstDom) !== null, - oldSurface: documentFrame(firstDom) !== null, - oldSurfaceHidden: nativeSurface(firstDom)?.hidden, - replacementEntries: secondDom.window.document.querySelectorAll( - '[data-codex-git-sidebar-entry]', - ).length, - replacementSurface: documentFrame(secondDom) !== null, - }).toEqual({ - oldEntry: false, - oldSurface: false, - oldSurfaceHidden: false, - replacementEntries: 1, - replacementSurface: true, + expect(result).toEqual({ + kind: 'standalone-required', + reason: { + code: 'attach-failed', + message: + 'The compatible Codex renderer could not be attached; use the standalone surface.', + }, }); - - await result.connection.close(); - expect(documentEntry(secondDom)).toBeNull(); - expect(nativeSurface(secondDom)?.hidden).toBe(false); + expect(dom.window.document.documentElement.outerHTML).toBe(before); + expect(renderer.contextSubscriberCount()).toBe(0); + expect(leaseEvents).toEqual([ + 'acquire:renderer-fixture', + 'release:renderer-fixture', + ]); }); it('scopes CSP bypass to a dedicated renderer lease', async () => { @@ -358,25 +366,11 @@ describe('CodexCdpHostAdapter', () => { }); class FixtureRendererSource implements CodexRendererSource { - private readonly listeners = new Set< - (renderer: CodexRenderer | null) => void - >(); - constructor(private renderer: CodexRenderer | null) {} async current(): Promise { return this.renderer; } - - publish(renderer: CodexRenderer | null): void { - this.renderer = renderer; - this.listeners.forEach((listener) => listener(renderer)); - } - - subscribe(listener: (renderer: CodexRenderer | null) => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } } function compatibleDom(): JSDOM { @@ -398,12 +392,6 @@ function documentFrame(dom: JSDOM): HTMLIFrameElement | null { return dom.window.document.querySelector('[data-codex-git-surface] iframe'); } -function nativeSurface(dom: JSDOM): HTMLElement | null { - return dom.window.document.querySelector( - '[data-app-shell-main-surface="default"]', - ); -} - async function captureHostContext( dom: JSDOM, frame: HTMLIFrameElement | null, @@ -503,6 +491,10 @@ class FixtureRenderer implements CodexRenderer { this.listeners.forEach((listener) => listener(context)); } + contextSubscriberCount(): number { + return this.listeners.size; + } + failNextCspRelease(): void { this.releaseFailures++; } diff --git a/packages/host-adapter/codex-cdp/src/adapter.ts b/packages/host-adapter/codex-cdp/src/adapter.ts index eb8034a..a1690f7 100644 --- a/packages/host-adapter/codex-cdp/src/adapter.ts +++ b/packages/host-adapter/codex-cdp/src/adapter.ts @@ -6,20 +6,11 @@ import type { SurfaceDescriptor, } from '@codex-git/host-adapter'; -import type { - CodexRenderer, - CodexRendererSource, - CspBypassLease, -} from './renderer.js'; -import { ManagedCodexHostConnection } from './managed-connection.js'; +import type { CodexRendererSource, CspBypassLease } from './renderer.js'; +import { findCompatibleCodexAnchors } from './compatibility.js'; +import { CodexHostConnection } from './connection.js'; -const supportedCodexVersion = '26.820.60940'; -const sidebarSelector = '#app-shell-sidebar'; -const mainSurfaceSelector = '[data-app-shell-main-surface="default"]'; -const activeConnections = new WeakMap< - CodexRendererSource, - ManagedCodexHostConnection ->(); +const activeConnections = new WeakMap(); export interface CodexCdpHostAdapterOptions { readonly createSecret?: () => string; @@ -39,7 +30,8 @@ export class CodexCdpHostAdapter implements HostAdapter { ); } - if (!isCompatible(renderer)) { + const anchors = findCompatibleCodexAnchors(renderer); + if (anchors === null) { return standaloneRequired( 'incompatible-host', `Codex Desktop ${safeVersion(renderer.version)} did not match the tested host structure; use the standalone surface.`, @@ -48,25 +40,22 @@ export class CodexCdpHostAdapter implements HostAdapter { let cspBypass: CspBypassLease | null = null; try { - await activeConnections.get(this.options.rendererSource)?.close(); + await activeConnections.get(renderer.document)?.close(); cspBypass = await renderer.acquireCspBypass(); - const connection = new ManagedCodexHostConnection( + const connection = new CodexHostConnection( renderer, cspBypass, - this.options.rendererSource, + anchors, surface, this.options.createSecret ?? createSecret, - isCompatible, () => { - if ( - activeConnections.get(this.options.rendererSource) === connection - ) { - activeConnections.delete(this.options.rendererSource); + if (activeConnections.get(renderer.document) === connection) { + activeConnections.delete(renderer.document); } }, ); cspBypass = null; - activeConnections.set(this.options.rendererSource, connection); + activeConnections.set(renderer.document, connection); return { kind: 'attached', connection, @@ -91,24 +80,6 @@ function createSecret(): string { return randomBytes(32).toString('base64url'); } -function isCompatible(renderer: CodexRenderer): boolean { - if ( - renderer.version !== supportedCodexVersion || - renderer.ownership !== 'codex-git-dedicated' || - renderer.id.length === 0 - ) { - return false; - } - - const sidebar = renderer.document.querySelector(sidebarSelector); - const mainSurface = renderer.document.querySelector(mainSurfaceSelector); - - return ( - sidebar instanceof renderer.window.HTMLElement && - mainSurface instanceof renderer.window.HTMLElement - ); -} - function standaloneRequired( code: 'attach-failed' | 'host-unavailable' | 'incompatible-host', message: string, diff --git a/packages/host-adapter/codex-cdp/src/compatibility.ts b/packages/host-adapter/codex-cdp/src/compatibility.ts new file mode 100644 index 0000000..a145747 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/compatibility.ts @@ -0,0 +1,30 @@ +import type { CodexRenderer } from './renderer.js'; + +const supportedCodexVersion = '26.820.60940'; +const sidebarSelector = '#app-shell-sidebar'; +const mainSurfaceSelector = '[data-app-shell-main-surface="default"]'; + +export interface CompatibleCodexAnchors { + readonly mainSurface: HTMLElement; + readonly sidebar: HTMLElement; +} + +export function findCompatibleCodexAnchors( + renderer: CodexRenderer, +): CompatibleCodexAnchors | null { + if ( + renderer.version !== supportedCodexVersion || + renderer.ownership !== 'codex-git-dedicated' || + renderer.id.length === 0 + ) { + return null; + } + + const sidebar = renderer.document.querySelector(sidebarSelector); + const mainSurface = renderer.document.querySelector(mainSurfaceSelector); + + return sidebar instanceof renderer.window.HTMLElement && + mainSurface instanceof renderer.window.HTMLElement + ? { mainSurface, sidebar } + : null; +} diff --git a/packages/host-adapter/codex-cdp/src/connection.ts b/packages/host-adapter/codex-cdp/src/connection.ts index 78a1c4d..511e75c 100644 --- a/packages/host-adapter/codex-cdp/src/connection.ts +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -6,10 +6,8 @@ import type { SurfaceDescriptor, } from '@codex-git/host-adapter'; -import type { CodexRenderer } from './renderer.js'; - -const sidebarEntrySelector = '[data-codex-git-sidebar-entry]'; -const surfaceSelector = '[data-codex-git-surface]'; +import type { CompatibleCodexAnchors } from './compatibility.js'; +import type { CodexRenderer, CspBypassLease } from './renderer.js'; interface ActiveFrame { readonly capability: string; @@ -20,6 +18,8 @@ interface ActiveFrame { export class CodexHostConnection implements HostConnection { private closed = false; + private closeAttempt: Promise | null = null; + private closeNotified = false; private activeFrame: ActiveFrame | null = null; private context: HostContext; private readonly contextClosers = new Set<() => void>(); @@ -27,53 +27,66 @@ export class CodexHostConnection implements HostConnection { (context: HostContext) => void >(); private readonly gitEntry: HTMLButtonElement; + private frameGeneration = 0; private readonly mainSurface: HTMLElement; private mountedSurface: HTMLElement | null = null; private readonly originalMainHidden: boolean; private readonly sidebar: HTMLElement; - private readonly unsubscribeContext: () => void; + private unsubscribeContext: () => void = () => undefined; constructor( private readonly renderer: CodexRenderer, + private cspBypass: CspBypassLease | null, + anchors: CompatibleCodexAnchors, private readonly surface: SurfaceDescriptor, private readonly createSecret: () => string, - private readonly nextFrameGeneration: () => number, private readonly onClose: () => void, ) { const { document, window } = renderer; - const sidebar = document.querySelector('#app-shell-sidebar'); - const mainSurface = document.querySelector( - '[data-app-shell-main-surface="default"]', - ); + this.sidebar = anchors.sidebar; + this.mainSurface = anchors.mainSurface; + this.originalMainHidden = anchors.mainSurface.hidden; + this.context = renderer.currentContext(); if ( - !(sidebar instanceof window.HTMLElement) || - !(mainSurface instanceof window.HTMLElement) + !anchors.sidebar.isConnected || + !anchors.mainSurface.isConnected || + anchors.sidebar.ownerDocument !== document || + anchors.mainSurface.ownerDocument !== document ) { throw new Error('Compatible Codex anchors disappeared before attachment'); } - this.sidebar = sidebar; - this.mainSurface = mainSurface; - this.originalMainHidden = mainSurface.hidden; - this.context = renderer.currentContext(); - this.unsubscribeContext = renderer.subscribeContext(this.handleContext); - document - .querySelectorAll(`${sidebarEntrySelector}, ${surfaceSelector}`) - .forEach((node) => node.remove()); - this.gitEntry = document.createElement('button'); this.gitEntry.dataset.codexGitSidebarEntry = ''; this.gitEntry.type = 'button'; this.gitEntry.textContent = 'Git'; this.gitEntry.setAttribute('aria-label', 'Open Codex Git'); - const nativeEntry = sidebar.querySelector('button'); + const nativeEntry = anchors.sidebar.querySelector('button'); if (nativeEntry instanceof window.HTMLButtonElement) { this.gitEntry.className = nativeEntry.className; } - this.gitEntry.addEventListener('click', this.openGitSurface); - this.sidebar.addEventListener('click', this.handleSidebarNavigation, true); - renderer.window.addEventListener('message', this.handleFrameMessage); - this.sidebar.append(this.gitEntry); + try { + this.unsubscribeContext = renderer.subscribeContext(this.handleContext); + this.gitEntry.addEventListener('click', this.openGitSurface); + this.sidebar.addEventListener( + 'click', + this.handleSidebarNavigation, + true, + ); + renderer.window.addEventListener('message', this.handleFrameMessage); + this.sidebar.append(this.gitEntry); + } catch (error) { + this.unsubscribeContext(); + this.gitEntry.removeEventListener('click', this.openGitSurface); + this.sidebar.removeEventListener( + 'click', + this.handleSidebarNavigation, + true, + ); + renderer.window.removeEventListener('message', this.handleFrameMessage); + this.gitEntry.remove(); + throw error; + } } currentContext(): HostContext { @@ -122,34 +135,50 @@ export class CodexHostConnection implements HostConnection { switch (action.kind) { case 'restore-native-surface': this.restoreNativeSurface(); - return { status: 'completed' }; + return { status: 'succeeded' }; } } - async close(): Promise { - if (this.closed) { - return; + close(): Promise { + if (this.closeAttempt === null) { + this.closeAttempt = this.closeOnce().catch((error: unknown) => { + this.closeAttempt = null; + throw error; + }); } + return this.closeAttempt; + } - this.closed = true; - this.unsubscribeContext(); - this.contextClosers.forEach((close) => close()); - this.contextClosers.clear(); - this.contextSubscribers.clear(); - this.gitEntry.removeEventListener('click', this.openGitSurface); - this.sidebar.removeEventListener( - 'click', - this.handleSidebarNavigation, - true, - ); - this.renderer.window.removeEventListener( - 'message', - this.handleFrameMessage, - ); - this.gitEntry.remove(); - this.removeMountedSurface(); - this.mainSurface.hidden = this.originalMainHidden; - this.onClose(); + private async closeOnce(): Promise { + if (!this.closed) { + this.closed = true; + this.unsubscribeContext(); + this.contextClosers.forEach((close) => close()); + this.contextClosers.clear(); + this.contextSubscribers.clear(); + this.gitEntry.removeEventListener('click', this.openGitSurface); + this.sidebar.removeEventListener( + 'click', + this.handleSidebarNavigation, + true, + ); + this.renderer.window.removeEventListener( + 'message', + this.handleFrameMessage, + ); + this.gitEntry.remove(); + this.removeMountedSurface(); + this.mainSurface.hidden = this.originalMainHidden; + } + + if (this.cspBypass !== null) { + await this.cspBypass.release(); + this.cspBypass = null; + } + if (!this.closeNotified) { + this.closeNotified = true; + this.onClose(); + } } isGitSurfaceOpen(): boolean { @@ -161,7 +190,7 @@ export class CodexHostConnection implements HostConnection { return; } - this.removeMountedSurface(); + this.restoreNativeSurface(); const { document } = this.renderer; const host = document.createElement('main'); host.dataset.codexGitSurface = ''; @@ -183,19 +212,26 @@ export class CodexHostConnection implements HostConnection { height: '100%', width: '100%', }); - this.activeFrame = { + const activeFrame = { capability: this.createSecret(), challenge: this.createSecret(), frame, - generation: this.nextFrameGeneration(), + generation: ++this.frameGeneration, }; frame.addEventListener('load', this.publishHostContext); - host.append(frame); - - this.mainSurface.hidden = true; - this.mainSurface.after(host); - this.mountedSurface = host; - this.gitEntry.setAttribute('aria-current', 'page'); + try { + host.append(frame); + this.mainSurface.after(host); + this.activeFrame = activeFrame; + this.mountedSurface = host; + this.mainSurface.hidden = true; + this.gitEntry.setAttribute('aria-current', 'page'); + } catch (error) { + frame.removeEventListener('load', this.publishHostContext); + host.remove(); + this.mainSurface.hidden = this.originalMainHidden; + throw error; + } } private readonly openGitSurface = () => { diff --git a/packages/host-adapter/codex-cdp/src/index.ts b/packages/host-adapter/codex-cdp/src/index.ts index 07d7db5..b031b95 100644 --- a/packages/host-adapter/codex-cdp/src/index.ts +++ b/packages/host-adapter/codex-cdp/src/index.ts @@ -4,6 +4,5 @@ export type { CodexCdpCommandTransport } from './csp-bypass.js'; export type { CodexRenderer, CodexRendererSource, - CodexRendererSubscription, CspBypassLease, } from './renderer.js'; diff --git a/packages/host-adapter/codex-cdp/src/managed-connection.ts b/packages/host-adapter/codex-cdp/src/managed-connection.ts deleted file mode 100644 index 32c3952..0000000 --- a/packages/host-adapter/codex-cdp/src/managed-connection.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - HostConnection, - HostContext, - NativeActionResult, - NativeHostAction, - SurfaceDescriptor, -} from '@codex-git/host-adapter'; - -import { CodexHostConnection } from './connection.js'; -import type { - CodexRenderer, - CodexRendererSource, - CspBypassLease, -} from './renderer.js'; - -export class ManagedCodexHostConnection implements HostConnection { - private closed = false; - private closeAttempt: Promise | null = null; - private closeNotified = false; - private context: HostContext; - private readonly contextClosers = new Set<() => void>(); - private readonly contextSubscribers = new Set< - (context: HostContext) => void - >(); - private frameGeneration = 0; - private mounted: CodexHostConnection | null; - private cspBypass: CspBypassLease | null; - private rendererContextSubscription: () => void; - private replacement = Promise.resolve(); - private readonly sourceSubscription: () => void; - - constructor( - renderer: CodexRenderer, - cspBypass: CspBypassLease, - source: CodexRendererSource, - private readonly surface: SurfaceDescriptor, - private readonly createSecret: () => string, - private readonly compatible: (renderer: CodexRenderer) => boolean, - private readonly onClose: () => void, - ) { - this.cspBypass = cspBypass; - this.context = renderer.currentContext(); - this.rendererContextSubscription = renderer.subscribeContext( - this.handleContext, - ); - this.mounted = this.mount(renderer); - this.sourceSubscription = source.subscribe((replacement) => { - this.replacement = this.replacement - .catch(() => undefined) - .then(() => this.replaceRenderer(replacement)); - }); - } - - currentContext(): HostContext { - return this.context; - } - - async *contexts(): AsyncIterable { - const queue = [this.context]; - let closed = this.closed; - let wake: (() => void) | null = null; - const publish = (context: HostContext) => { - queue.push(context); - wake?.(); - }; - const close = () => { - closed = true; - wake?.(); - }; - this.contextSubscribers.add(publish); - this.contextClosers.add(close); - - try { - while (!closed) { - const next = queue.shift(); - if (next !== undefined) { - yield next; - continue; - } - await new Promise((resolve) => { - wake = resolve; - }); - wake = null; - } - } finally { - this.contextSubscribers.delete(publish); - this.contextClosers.delete(close); - } - } - - async perform(action: NativeHostAction): Promise { - if (this.closed || this.mounted === null) { - return { status: 'rejected' }; - } - return this.mounted.perform(action); - } - - close(): Promise { - if (this.closeAttempt === null) { - this.closeAttempt = this.closeOnce().catch((error: unknown) => { - this.closeAttempt = null; - throw error; - }); - } - return this.closeAttempt; - } - - private readonly handleContext = (context: HostContext) => { - if (this.closed) { - return; - } - this.context = context; - this.contextSubscribers.forEach((publish) => publish(context)); - }; - - private mount(renderer: CodexRenderer): CodexHostConnection { - return new CodexHostConnection( - renderer, - this.surface, - this.createSecret, - () => ++this.frameGeneration, - () => undefined, - ); - } - - private async closeOnce(): Promise { - if (!this.closed) { - this.closed = true; - this.sourceSubscription(); - this.rendererContextSubscription(); - this.contextClosers.forEach((close) => close()); - this.contextClosers.clear(); - this.contextSubscribers.clear(); - await this.replacement.catch(() => undefined); - await this.mounted?.close(); - this.mounted = null; - } - - if (this.cspBypass !== null) { - await this.cspBypass.release(); - this.cspBypass = null; - } - if (!this.closeNotified) { - this.closeNotified = true; - this.onClose(); - } - } - - private async replaceRenderer(renderer: CodexRenderer | null): Promise { - if (this.closed) { - return; - } - - const reopen = this.mounted?.isGitSurfaceOpen() ?? false; - this.rendererContextSubscription(); - await this.mounted?.close(); - this.mounted = null; - await this.cspBypass?.release(); - this.cspBypass = null; - - if (renderer === null || !this.compatible(renderer) || this.closed) { - return; - } - - try { - this.cspBypass = await renderer.acquireCspBypass(); - } catch { - return; - } - if (this.closed) { - await this.cspBypass.release(); - this.cspBypass = null; - return; - } - - this.context = renderer.currentContext(); - this.contextSubscribers.forEach((publish) => publish(this.context)); - this.rendererContextSubscription = renderer.subscribeContext( - this.handleContext, - ); - this.mounted = this.mount(renderer); - if (reopen) { - this.mounted.showGitSurface(); - } - } -} diff --git a/packages/host-adapter/codex-cdp/src/renderer.ts b/packages/host-adapter/codex-cdp/src/renderer.ts index 3b7aedd..d0b9551 100644 --- a/packages/host-adapter/codex-cdp/src/renderer.ts +++ b/packages/host-adapter/codex-cdp/src/renderer.ts @@ -15,11 +15,6 @@ export interface CodexRenderer { subscribeContext(listener: (context: HostContext) => void): () => void; } -export type CodexRendererSubscription = ( - renderer: CodexRenderer | null, -) => void; - export interface CodexRendererSource { current(): Promise; - subscribe(listener: CodexRendererSubscription): () => void; } diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index ae1e29a..c20fd37 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -17,7 +17,7 @@ export interface HostTaskContext { export type NativeHostAction = { readonly kind: 'restore-native-surface' }; export interface NativeActionResult { - readonly status: 'completed' | 'rejected' | 'unsupported'; + readonly status: 'succeeded' | 'rejected' | 'unsupported'; } export interface SanitizedDiagnostic {