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..5525fba --- /dev/null +++ b/docs/host-integration/codex-compatibility.md @@ -0,0 +1,67 @@ +# 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. + +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 +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, +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. + +## Pending manual smoke matrix + +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 + 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..dbfa5e9 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/adapter.test.ts @@ -0,0 +1,519 @@ +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/', + }); + + 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, + 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 renderer = fixtureRenderer(dom, '26.820.60940'); + const adapter = new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }); + 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 new CodexCdpHostAdapter({ + rendererSource: new FixtureRendererSource(renderer), + }).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('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: new FixtureRendererSource(renderer), + }).attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + expect(result).toEqual({ + kind: 'standalone-required', + reason: { + code: 'attach-failed', + message: + 'The compatible Codex renderer could not be attached; use the standalone surface.', + }, + }); + 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 () => { + 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 { + constructor(private renderer: CodexRenderer | null) {} + + async current(): Promise { + return this.renderer; + } +} + +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'); +} + +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)); + } + + contextSubscriberCount(): number { + return this.listeners.size; + } + + 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..a1690f7 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/adapter.ts @@ -0,0 +1,91 @@ +import { randomBytes } from 'node:crypto'; + +import type { + HostAdapter, + HostAttachResult, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import type { CodexRendererSource, CspBypassLease } from './renderer.js'; +import { findCompatibleCodexAnchors } from './compatibility.js'; +import { CodexHostConnection } from './connection.js'; + +const activeConnections = new WeakMap(); + +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.', + ); + } + + 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.`, + ); + } + + let cspBypass: CspBypassLease | null = null; + try { + await activeConnections.get(renderer.document)?.close(); + cspBypass = await renderer.acquireCspBypass(); + const connection = new CodexHostConnection( + renderer, + cspBypass, + anchors, + surface, + this.options.createSecret ?? createSecret, + () => { + if (activeConnections.get(renderer.document) === connection) { + activeConnections.delete(renderer.document); + } + }, + ); + cspBypass = null; + activeConnections.set(renderer.document, 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 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/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 new file mode 100644 index 0000000..511e75c --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -0,0 +1,332 @@ +import type { + HostConnection, + HostContext, + NativeActionResult, + NativeHostAction, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import type { CompatibleCodexAnchors } from './compatibility.js'; +import type { CodexRenderer, CspBypassLease } from './renderer.js'; + +interface ActiveFrame { + readonly capability: string; + readonly challenge: string; + readonly frame: HTMLIFrameElement; + readonly generation: number; +} + +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>(); + private readonly contextSubscribers = new Set< + (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 unsubscribeContext: () => void = () => undefined; + + constructor( + private readonly renderer: CodexRenderer, + private cspBypass: CspBypassLease | null, + anchors: CompatibleCodexAnchors, + private readonly surface: SurfaceDescriptor, + private readonly createSecret: () => string, + private readonly onClose: () => void, + ) { + const { document, window } = renderer; + this.sidebar = anchors.sidebar; + this.mainSurface = anchors.mainSurface; + this.originalMainHidden = anchors.mainSurface.hidden; + this.context = renderer.currentContext(); + if ( + !anchors.sidebar.isConnected || + !anchors.mainSurface.isConnected || + anchors.sidebar.ownerDocument !== document || + anchors.mainSurface.ownerDocument !== document + ) { + throw new Error('Compatible Codex anchors disappeared before attachment'); + } + + 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 = anchors.sidebar.querySelector('button'); + if (nativeEntry instanceof window.HTMLButtonElement) { + this.gitEntry.className = nativeEntry.className; + } + 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 { + 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: 'succeeded' }; + } + } + + close(): Promise { + if (this.closeAttempt === null) { + this.closeAttempt = this.closeOnce().catch((error: unknown) => { + this.closeAttempt = null; + throw error; + }); + } + return this.closeAttempt; + } + + 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 { + return this.activeFrame !== null; + } + + showGitSurface(): void { + if (this.closed) { + return; + } + + this.restoreNativeSurface(); + 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%', + }); + const activeFrame = { + capability: this.createSecret(), + challenge: this.createSecret(), + frame, + generation: ++this.frameGeneration, + }; + frame.addEventListener('load', this.publishHostContext); + 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 = () => { + 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..b031b95 100644 --- a/packages/host-adapter/codex-cdp/src/index.ts +++ b/packages/host-adapter/codex-cdp/src/index.ts @@ -1,3 +1,8 @@ -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, + CspBypassLease, +} from './renderer.js'; 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..d0b9551 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/renderer.ts @@ -0,0 +1,20 @@ +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 interface CodexRendererSource { + current(): Promise; +} diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index c19fa70..c20fd37 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: 'succeeded' | '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(); }); });