From db246ecc0688bcfab9a24ec03930b08352dcdb40 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sat, 29 Aug 2026 16:06:28 +0800 Subject: [PATCH 1/3] feat: connect dedicated Codex CDP renderer (#30) --- apps/launcher/package.json | 1 + apps/launcher/src/codex-runtime.ts | 101 ++++ apps/launcher/src/index.ts | 1 + apps/launcher/src/main.ts | 8 +- docs/host-integration/codex-compatibility.md | 16 +- package-lock.json | 2 + .../codex-cdp/src/async-stream.ts | 51 ++ .../host-adapter/codex-cdp/src/cdp-session.ts | 115 ++++ .../host-adapter/codex-cdp/src/connection.ts | 2 + .../codex-cdp/src/csp-bypass.test.ts | 22 + .../host-adapter/codex-cdp/src/csp-bypass.ts | 60 +- .../codex-cdp/src/dedicated-adapter.test.ts | 169 ++++++ .../codex-cdp/src/dedicated-adapter.ts | 242 +++++++++ .../codex-cdp/src/dedicated-instance.test.ts | 117 ++++ .../codex-cdp/src/dedicated-instance.ts | 352 ++++++++++++ packages/host-adapter/codex-cdp/src/index.ts | 25 + .../codex-cdp/src/remote-renderer.test.ts | 135 +++++ .../codex-cdp/src/remote-renderer.ts | 513 ++++++++++++++++++ packages/host-adapter/src/index.ts | 6 + .../host-adapter/standalone/src/adapter.ts | 2 + .../standalone-host-adapter.contract.test.ts | 5 + tests/e2e/codex-runtime.e2e.test.ts | 54 ++ 22 files changed, 1983 insertions(+), 16 deletions(-) create mode 100644 apps/launcher/src/codex-runtime.ts create mode 100644 packages/host-adapter/codex-cdp/src/async-stream.ts create mode 100644 packages/host-adapter/codex-cdp/src/cdp-session.ts create mode 100644 packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/dedicated-adapter.ts create mode 100644 packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/dedicated-instance.ts create mode 100644 packages/host-adapter/codex-cdp/src/remote-renderer.test.ts create mode 100644 packages/host-adapter/codex-cdp/src/remote-renderer.ts create mode 100644 tests/e2e/codex-runtime.e2e.test.ts diff --git a/apps/launcher/package.json b/apps/launcher/package.json index 8e03268..1ecfc1f 100644 --- a/apps/launcher/package.json +++ b/apps/launcher/package.json @@ -7,6 +7,7 @@ "types": "./src/index.ts", "dependencies": { "@codex-git/host-adapter": "*", + "@codex-git/host-adapter-codex-cdp": "*", "@codex-git/host-adapter-standalone": "*", "@codex-git/server": "*" }, diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts new file mode 100644 index 0000000..ae46b87 --- /dev/null +++ b/apps/launcher/src/codex-runtime.ts @@ -0,0 +1,101 @@ +import type { HostConnection } from '@codex-git/host-adapter'; +import { + connectDedicatedCodexRenderer, + DedicatedCodexHostAdapter, + launchDedicatedCodexInstance, + type ConnectDedicatedRenderer, + type DedicatedCodexInstance, + type LaunchDedicatedCodexOptions, +} from '@codex-git/host-adapter-codex-cdp'; + +import { + startStandaloneRuntime, + type StandaloneRuntime, + type StandaloneRuntimeOptions, +} from './standalone-runtime.js'; + +export interface CodexRuntimeOptions extends StandaloneRuntimeOptions { + readonly connectRenderer?: ConnectDedicatedRenderer; + readonly dedicatedInstance?: LaunchDedicatedCodexOptions; + readonly launchInstance?: ( + options?: LaunchDedicatedCodexOptions, + ) => Promise; + readonly projectPath: string; +} + +export interface CodexRuntime extends StandaloneRuntime { + currentHost(): 'codex' | 'standalone'; +} + +export async function startCodexRuntime( + options: CodexRuntimeOptions, +): Promise { + const standalone = await startStandaloneRuntime(options); + let instance: DedicatedCodexInstance | null = null; + let connection: HostConnection | null = null; + let host: 'codex' | 'standalone' = 'standalone'; + let closing = false; + let monitor = Promise.resolve(); + + try { + instance = await (options.launchInstance ?? launchDedicatedCodexInstance)( + options.dedicatedInstance, + ); + const result = await new DedicatedCodexHostAdapter({ + connectRenderer: options.connectRenderer ?? connectDedicatedCodexRenderer, + instance, + projectPath: options.projectPath, + }).attach({ + title: 'Codex Git', + url: standalone.surfaceUrl, + }); + if (result.kind === 'standalone-required') { + await instance.close(); + instance = null; + } else { + connection = result.connection; + host = 'codex'; + monitor = monitorTransitions(connection, async () => { + if (closing) { + return; + } + host = 'standalone'; + await connection?.close(); + await instance?.close(); + connection = null; + instance = null; + }); + } + } catch { + await instance?.close().catch(() => undefined); + instance = null; + } + + return { + healthUrl: standalone.healthUrl, + surfaceUrl: standalone.surfaceUrl, + currentHost: () => host, + async close() { + if (closing) { + return; + } + closing = true; + await connection?.close(); + await instance?.close(); + await monitor; + await standalone.close(); + }, + }; +} + +async function monitorTransitions( + connection: HostConnection, + fallback: () => Promise, +): Promise { + for await (const transition of connection.transitions()) { + if (transition.kind === 'standalone-required') { + await fallback(); + return; + } + } +} diff --git a/apps/launcher/src/index.ts b/apps/launcher/src/index.ts index f3f8a36..1932889 100644 --- a/apps/launcher/src/index.ts +++ b/apps/launcher/src/index.ts @@ -1 +1,2 @@ +export * from './codex-runtime.js'; export * from './standalone-runtime.js'; diff --git a/apps/launcher/src/main.ts b/apps/launcher/src/main.ts index a7476db..3a535c7 100644 --- a/apps/launcher/src/main.ts +++ b/apps/launcher/src/main.ts @@ -1,12 +1,16 @@ -import { startStandaloneRuntime } from './index.js'; +import { resolve } from 'node:path'; -const runtime = await startStandaloneRuntime({ +import { startCodexRuntime } from './index.js'; + +const runtime = await startCodexRuntime({ healthPort: readPort('CODEX_GIT_PORT', 0), + projectPath: resolve(process.env.CODEX_GIT_PROJECT_PATH ?? process.cwd()), surfacePort: readPort('CODEX_GIT_SURFACE_PORT', 5173), }); console.log(`Codex Git placeholder surface: ${runtime.surfaceUrl.href}`); console.log(`Codex Git health endpoint: ${runtime.healthUrl.href}`); +console.log(`Codex Git host: ${runtime.currentHost()}`); let stopping = false; diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 5525fba..5eae4f9 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -5,11 +5,10 @@ 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). +The production integration launches a dedicated profile, binds its loopback CDP +endpoint and target, and connects only the tested public DOM anchors. It preserves +the launcher-owned project path across renderer generations, reference-counts CSP +bypass, and reports typed standalone transitions when safe attachment is lost. ## Trust and ownership requirements @@ -48,11 +47,10 @@ 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 +## 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. +This matrix passed on 2026-08-29 against a disposable dedicated profile using +Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. - 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/package-lock.json b/package-lock.json index db71da6..1d37778 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,8 @@ "name": "@codex-git/launcher", "version": "0.0.0", "dependencies": { + "@codex-git/host-adapter": "*", + "@codex-git/host-adapter-codex-cdp": "*", "@codex-git/host-adapter-standalone": "*", "@codex-git/server": "*" }, diff --git a/packages/host-adapter/codex-cdp/src/async-stream.ts b/packages/host-adapter/codex-cdp/src/async-stream.ts new file mode 100644 index 0000000..8b46ad2 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/async-stream.ts @@ -0,0 +1,51 @@ +interface Consumer { + readonly queue: T[]; + wake: (() => void) | null; +} + +export class AsyncStream { + private closed = false; + private readonly consumers = new Set>(); + + publish(value: T): void { + if (this.closed) return; + for (const consumer of this.consumers) { + consumer.queue.push(value); + consumer.wake?.(); + } + } + + close(): void { + this.closed = true; + for (const consumer of this.consumers) { + consumer.wake?.(); + } + this.consumers.clear(); + } + + async *read(initial?: T): AsyncIterable { + const consumer: Consumer = { + queue: initial === undefined ? [] : [initial], + wake: null, + }; + if (!this.closed) { + this.consumers.add(consumer); + } + + try { + while (!this.closed || consumer.queue.length > 0) { + const value = consumer.queue.shift(); + if (value !== undefined) { + yield value; + continue; + } + await new Promise((resolve) => { + consumer.wake = resolve; + }); + consumer.wake = null; + } + } finally { + this.consumers.delete(consumer); + } + } +} diff --git a/packages/host-adapter/codex-cdp/src/cdp-session.ts b/packages/host-adapter/codex-cdp/src/cdp-session.ts new file mode 100644 index 0000000..33a69b7 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/cdp-session.ts @@ -0,0 +1,115 @@ +export interface CdpEvent { + readonly method: string; + readonly params?: unknown; +} + +export interface CdpSession { + send(method: string, params?: unknown): Promise; + subscribe(listener: (event: CdpEvent) => void): () => void; + close(): Promise; +} + +export async function connectCdpSession(url: string): Promise { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => resolve(), { once: true }); + socket.addEventListener( + 'error', + () => reject(new Error('Dedicated Codex CDP websocket failed to open')), + { once: true }, + ); + }); + return new WebSocketCdpSession(socket); +} + +class WebSocketCdpSession implements CdpSession { + private nextId = 0; + private readonly listeners = new Set<(event: CdpEvent) => void>(); + private readonly pending = new Map< + number, + { reject(error: Error): void; resolve(value: unknown): void } + >(); + + constructor(private readonly socket: WebSocket) { + socket.addEventListener('message', this.handleMessage); + socket.addEventListener('close', this.handleClose); + } + + send(method: string, params?: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error('Dedicated Codex CDP session is closed')); + } + const id = ++this.nextId; + return new Promise((resolve, reject) => { + this.pending.set(id, { reject, resolve }); + this.socket.send( + JSON.stringify( + params === undefined ? { id, method } : { id, method, params }, + ), + ); + }); + } + + subscribe(listener: (event: CdpEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) { + return; + } + const closed = new Promise((resolve) => { + this.socket.addEventListener('close', () => resolve(), { once: true }); + }); + this.socket.close(); + await Promise.race([ + closed, + new Promise((resolve) => setTimeout(resolve, 1_000)), + ]); + } + + private readonly handleMessage = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + return; + } + let message: unknown; + try { + message = JSON.parse(event.data) as unknown; + } catch { + return; + } + if (!isRecord(message)) { + return; + } + if (typeof message.id === 'number') { + const pending = this.pending.get(message.id); + if (pending === undefined) { + return; + } + this.pending.delete(message.id); + if (isRecord(message.error)) { + pending.reject(new Error('Dedicated Codex CDP command failed')); + } else { + pending.resolve(message.result); + } + return; + } + if (typeof message.method === 'string') { + this.listeners.forEach((listener) => + listener({ method: message.method as string, params: message.params }), + ); + } + }; + + private readonly handleClose = () => { + const error = new Error('Dedicated Codex CDP session closed'); + this.pending.forEach(({ reject }) => reject(error)); + this.pending.clear(); + this.listeners.clear(); + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/packages/host-adapter/codex-cdp/src/connection.ts b/packages/host-adapter/codex-cdp/src/connection.ts index 511e75c..0951d8e 100644 --- a/packages/host-adapter/codex-cdp/src/connection.ts +++ b/packages/host-adapter/codex-cdp/src/connection.ts @@ -127,6 +127,8 @@ export class CodexHostConnection implements HostConnection { } } + async *transitions(): AsyncIterable {} + async perform(action: NativeHostAction): Promise { if (this.closed) { return { status: 'rejected' }; diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts index 338c4ff..7903c4c 100644 --- a/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts @@ -59,4 +59,26 @@ describe('dedicated renderer CSP bypass', () => { await expect(lease.release()).resolves.toBeUndefined(); expect(disableAttempts).toBe(2); }); + + it('reference-counts concurrent leases for the same renderer', async () => { + const enabled: boolean[] = []; + const transport: CodexCdpCommandTransport = { + async send(_rendererId, _method, params) { + enabled.push(params.enabled); + }, + }; + + const first = await acquireDedicatedRendererCspBypass( + transport, + 'renderer-target-42', + ); + const second = await acquireDedicatedRendererCspBypass( + transport, + 'renderer-target-42', + ); + await first.release(); + expect(enabled).toEqual([true]); + await second.release(); + expect(enabled).toEqual([true, false]); + }); }); diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.ts index d0c4862..0cc089a 100644 --- a/packages/host-adapter/codex-cdp/src/csp-bypass.ts +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.ts @@ -8,6 +8,16 @@ export interface CodexCdpCommandTransport { ): Promise; } +interface ActiveBypass { + count: number; + disabling: Promise | null; +} + +const activeByTransport = new WeakMap< + CodexCdpCommandTransport, + Map +>(); + export async function acquireDedicatedRendererCspBypass( transport: CodexCdpCommandTransport, rendererId: string, @@ -16,18 +26,58 @@ export async function acquireDedicatedRendererCspBypass( throw new Error('A stable renderer ID is required for CSP bypass'); } - await transport.send(rendererId, 'Page.setBypassCSP', { enabled: true }); + let renderers = activeByTransport.get(transport); + if (renderers === undefined) { + renderers = new Map(); + activeByTransport.set(transport, renderers); + } + const existing = renderers.get(rendererId); + if (existing?.disabling !== null && existing?.disabling !== undefined) { + await existing.disabling; + return acquireDedicatedRendererCspBypass(transport, rendererId); + } + const active = existing ?? { count: 0, disabling: null }; + if (existing === undefined) { + renderers.set(rendererId, active); + try { + await transport.send(rendererId, 'Page.setBypassCSP', { enabled: true }); + } catch (error) { + renderers.delete(rendererId); + throw error; + } + } + active.count++; let releaseAttempt: Promise | null = null; + let released = false; return { release() { + if (released) { + return Promise.resolve(); + } if (releaseAttempt === null) { - releaseAttempt = transport - .send(rendererId, 'Page.setBypassCSP', { enabled: false }) - .catch((error: unknown) => { + if (active.count > 1) { + active.count--; + released = true; + return Promise.resolve(); + } + releaseAttempt = transport.send(rendererId, 'Page.setBypassCSP', { + enabled: false, + }); + active.disabling = releaseAttempt; + releaseAttempt = releaseAttempt.then( + () => { + active.count = 0; + active.disabling = null; + released = true; + renderers.delete(rendererId); + }, + (error: unknown) => { + active.disabling = null; releaseAttempt = null; throw error; - }); + }, + ); } return releaseAttempt; }, diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts new file mode 100644 index 0000000..f235a2a --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import type { HostContext, NativeActionResult } from '@codex-git/host-adapter'; + +import { + DedicatedCodexHostAdapter, + type ConnectDedicatedRenderer, + type DedicatedCodexInstance, + type DedicatedCodexOwnership, + type DedicatedCodexTarget, + type DedicatedRendererConnection, +} from './index.js'; + +const surface = { + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), +}; + +describe('DedicatedCodexHostAdapter', () => { + it('rejects a target whose websocket is not bound to the launched endpoint', async () => { + const instance = new FixtureInstance({ + id: 'foreign-target', + webSocketUrl: 'ws://127.0.0.1:65530/devtools/page/foreign-target', + }); + let connections = 0; + const result = await new DedicatedCodexHostAdapter({ + connectRenderer: async () => { + connections++; + return new FixtureRendererConnection(defaultContext); + }, + instance, + projectPath: '/Users/example/codex-git', + }).attach(surface); + + expect(result).toMatchObject({ + kind: 'standalone-required', + reason: { code: 'host-unavailable' }, + }); + expect(connections).toBe(0); + }); + + it('remounts an open surface on an owned replacement target', async () => { + const instance = new FixtureInstance(ownedTarget('renderer-1')); + const sessions: FixtureRendererConnection[] = []; + const requests: Array<{ openSurface: boolean; targetId: string }> = []; + const connectRenderer: ConnectDedicatedRenderer = async (request) => { + requests.push({ + openSurface: request.openSurface, + targetId: request.target.id, + }); + const session = new FixtureRendererConnection( + sessions.length === 0 + ? defaultContext + : { ...defaultContext, theme: 'light' }, + true, + ); + sessions.push(session); + return session; + }; + const result = await new DedicatedCodexHostAdapter({ + connectRenderer, + instance, + projectPath: '/Users/example/codex-git', + }).attach(surface); + if (result.kind !== 'attached') { + throw new Error('Expected the owned renderer to attach'); + } + const contexts = result.connection.contexts()[Symbol.asyncIterator](); + await contexts.next(); + + instance.publish(ownedTarget('renderer-2')); + await expect(contexts.next()).resolves.toEqual({ + done: false, + value: { ...defaultContext, theme: 'light' }, + }); + expect(requests).toEqual([ + { openSurface: false, targetId: 'renderer-1' }, + { openSurface: true, targetId: 'renderer-2' }, + ]); + expect(sessions[0]?.closed).toBe(true); + + await result.connection.close(); + }); + + it('publishes one standalone transition when replacement cannot reacquire CSP', async () => { + const instance = new FixtureInstance(ownedTarget('renderer-1')); + let connectionCount = 0; + const result = await new DedicatedCodexHostAdapter({ + connectRenderer: async () => { + if (++connectionCount > 1) { + throw new Error('CSP reacquisition failed'); + } + return new FixtureRendererConnection(defaultContext, true); + }, + instance, + projectPath: '/Users/example/codex-git', + }).attach(surface); + if (result.kind !== 'attached') { + throw new Error('Expected the owned renderer to attach'); + } + const transitions = result.connection.transitions()[Symbol.asyncIterator](); + + instance.publish(ownedTarget('renderer-2')); + await expect(transitions.next()).resolves.toMatchObject({ + done: false, + value: { + kind: 'standalone-required', + reason: { code: 'attach-failed' }, + }, + }); + + await result.connection.close(); + await expect(transitions.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); +}); + +const defaultContext = { + projectPath: '/Users/example/codex-git', + task: { id: 'task-1', title: 'Implement dedicated renderer' }, + theme: 'dark', +} satisfies HostContext; + +const ownership = { + endpoint: 'http://127.0.0.1:43117/', + instanceId: 'instance-42', + processId: 4242, + profilePath: '/private/tmp/codex-git-profile-42', +} satisfies DedicatedCodexOwnership; + +function ownedTarget(id: string): DedicatedCodexTarget { + return { + id, + webSocketUrl: `ws://127.0.0.1:43117/devtools/page/${id}`, + }; +} + +// prettier-ignore +class FixtureInstance implements DedicatedCodexInstance { + readonly ownership = ownership; + readonly version = '26.820.60940'; + private readonly listeners = new Set<(target: DedicatedCodexTarget | null) => void>(); + constructor(private target: DedicatedCodexTarget | null) {} + async currentTarget(): Promise { return this.target; } + subscribe(listener: (target: DedicatedCodexTarget | null) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + publish(target: DedicatedCodexTarget | null): void { + this.target = target; + this.listeners.forEach((listener) => listener(target)); + } + async close(): Promise {} +} + +// prettier-ignore +class FixtureRendererConnection implements DedicatedRendererConnection { + closed = false; + + constructor(private context: HostContext, private readonly surfaceOpen = false) {} + currentContext(): HostContext { return this.context; } + isSurfaceOpen(): boolean { return this.surfaceOpen; } + projectIdentity(): { readonly id: string; readonly label: string } { return { id: 'project-42', label: 'codex-git' }; } + subscribe(): () => void { return () => undefined; } + async perform(): Promise { return { status: 'succeeded' }; } + async close(): Promise { this.closed = true; } +} diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts new file mode 100644 index 0000000..44b16a0 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts @@ -0,0 +1,242 @@ +import type { + HostAdapter, + HostAttachResult, + HostConnection, + HostContext, + HostTransition, + NativeActionResult, + NativeHostAction, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +import { AsyncStream } from './async-stream.js'; +import { + isDedicatedCodexTargetOwned, + type DedicatedCodexInstance, + type DedicatedCodexOwnership, + type DedicatedCodexTarget, +} from './dedicated-instance.js'; + +export type DedicatedRendererEvent = + | { readonly kind: 'context'; readonly context: HostContext } + | { readonly kind: 'standalone-required' }; + +export interface DedicatedProjectIdentity { + readonly id: string; + readonly label: string; +} + +export interface DedicatedRendererConnection { + currentContext(): HostContext; + isSurfaceOpen(): boolean; + projectIdentity(): DedicatedProjectIdentity; + subscribe(listener: (event: DedicatedRendererEvent) => void): () => void; + perform(action: NativeHostAction): Promise; + close(): Promise; +} + +export interface ConnectDedicatedRendererRequest { + readonly expectedProject: DedicatedProjectIdentity | null; + readonly openSurface: boolean; + readonly ownership: DedicatedCodexOwnership; + readonly projectPath: string; + readonly surface: SurfaceDescriptor; + readonly target: DedicatedCodexTarget; + readonly version: string; +} + +export type ConnectDedicatedRenderer = ( + request: ConnectDedicatedRendererRequest, +) => Promise; + +export interface DedicatedCodexHostAdapterOptions { + readonly connectRenderer: ConnectDedicatedRenderer; + readonly instance: DedicatedCodexInstance; + readonly projectPath: string; +} + +export class DedicatedCodexHostAdapter implements HostAdapter { + constructor(private readonly options: DedicatedCodexHostAdapterOptions) {} + + async attach(surface: SurfaceDescriptor): Promise { + const target = await this.options.instance.currentTarget(); + if ( + target === null || + !isDedicatedCodexTargetOwned(target, this.options.instance.ownership) + ) { + return standaloneRequired( + 'host-unavailable', + 'The dedicated Codex renderer was unavailable; use the standalone surface.', + ); + } + + try { + const renderer = await this.options.connectRenderer({ + expectedProject: null, + openSurface: false, + ownership: this.options.instance.ownership, + projectPath: this.options.projectPath, + surface, + target, + version: this.options.instance.version, + }); + return { + kind: 'attached', + connection: new ManagedDedicatedConnection( + renderer, + surface, + this.options, + ), + }; + } catch { + return standaloneRequired( + 'attach-failed', + 'The dedicated Codex renderer could not be attached; use the standalone surface.', + ); + } + } +} + +class ManagedDedicatedConnection implements HostConnection { + private closed = false; + private closeAttempt: Promise | null = null; + private context: HostContext; + private readonly contextStream = new AsyncStream(); + private degraded = false; + private readonly project: DedicatedProjectIdentity; + private renderer: DedicatedRendererConnection; + private rendererSubscription: () => void; + private replacement = Promise.resolve(); + private readonly sourceSubscription: () => void; + private readonly transitionStream = new AsyncStream(); + + constructor( + renderer: DedicatedRendererConnection, + private readonly surface: SurfaceDescriptor, + private readonly options: DedicatedCodexHostAdapterOptions, + ) { + this.renderer = renderer; + this.project = renderer.projectIdentity(); + this.context = renderer.currentContext(); + this.rendererSubscription = renderer.subscribe(this.handleRendererEvent); + this.sourceSubscription = options.instance.subscribe((target) => { + this.replacement = this.replacement.then(() => this.replace(target)); + }); + } + + currentContext(): HostContext { + return this.context; + } + + contexts(): AsyncIterable { + return this.contextStream.read(this.context); + } + + transitions(): AsyncIterable { + return this.transitionStream.read(); + } + + perform(action: NativeHostAction): Promise { + if (this.closed || this.degraded) { + return Promise.resolve({ status: 'rejected' }); + } + return this.renderer.perform(action); + } + + close(): Promise { + this.closeAttempt ??= this.closeOnce().catch((error: unknown) => { + this.closeAttempt = null; + throw error; + }); + return this.closeAttempt; + } + + private async replace(target: DedicatedCodexTarget | null): Promise { + if (this.closed || this.degraded) { + return; + } + if ( + target === null || + !isDedicatedCodexTargetOwned(target, this.options.instance.ownership) + ) { + await this.degrade('host-unavailable'); + return; + } + + const reopen = this.renderer.isSurfaceOpen(); + this.rendererSubscription(); + await this.renderer.close().catch(() => undefined); + try { + const renderer = await this.options.connectRenderer({ + expectedProject: this.project, + openSurface: reopen, + ownership: this.options.instance.ownership, + projectPath: this.options.projectPath, + surface: this.surface, + target, + version: this.options.instance.version, + }); + this.renderer = renderer; + this.context = renderer.currentContext(); + this.rendererSubscription = renderer.subscribe(this.handleRendererEvent); + this.contextStream.publish(this.context); + } catch { + await this.degrade('attach-failed'); + } + } + + private readonly handleRendererEvent = (event: DedicatedRendererEvent) => { + if (this.closed || this.degraded) { + return; + } + if (event.kind === 'context') { + this.context = event.context; + this.contextStream.publish(event.context); + return; + } + void this.degrade('incompatible-host'); + }; + + private async degrade( + code: 'attach-failed' | 'host-unavailable' | 'incompatible-host', + ): Promise { + if (this.degraded || this.closed) { + return; + } + this.degraded = true; + this.sourceSubscription(); + this.rendererSubscription(); + await this.renderer.close().catch(() => undefined); + this.transitionStream.publish({ + kind: 'standalone-required', + reason: { + code, + message: + 'The dedicated Codex renderer became unavailable; use the standalone surface.', + }, + }); + } + + private async closeOnce(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.sourceSubscription(); + this.rendererSubscription(); + await this.replacement; + await this.renderer.close(); + this.contextStream.close(); + this.transitionStream.close(); + } +} + +function standaloneRequired( + code: 'attach-failed' | 'host-unavailable', + message: string, +): HostAttachResult { + return { + kind: 'standalone-required', + reason: { code, message }, + }; +} diff --git a/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts b/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts new file mode 100644 index 0000000..a0c5e43 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + launchDedicatedCodexInstance, + type DedicatedCodexPlatform, + type DedicatedCodexProcess, +} from './index.js'; + +describe('dedicated Codex instance discovery', () => { + it('binds discovery to the launched profile, process, endpoint, and exact target', async () => { + const platform = new FixturePlatform( + [ + { + id: 'foreign-target', + type: 'page', + url: 'app://-/index.html', + webSocketDebuggerUrl: + 'ws://127.0.0.1:65530/devtools/page/foreign-target', + }, + { + id: 'owned-target', + type: 'page', + url: 'app://-/index.html', + webSocketDebuggerUrl: + 'ws://127.0.0.1:43117/devtools/page/owned-target', + }, + ], + 2, + ); + + const instance = await launchDedicatedCodexInstance({ + appPath: '/Applications/ChatGPT.app', + createInstanceId: () => 'instance-42', + platform, + }); + + expect(platform.launch).toEqual({ + args: [ + '--user-data-dir=/private/tmp/codex-git-profile-42', + '--remote-debugging-port=0', + '--no-first-run', + ], + executable: '/Applications/ChatGPT.app/Contents/MacOS/ChatGPT', + }); + expect(instance.ownership).toEqual({ + endpoint: 'http://127.0.0.1:43117/', + instanceId: 'instance-42', + processId: 4242, + profilePath: '/private/tmp/codex-git-profile-42', + }); + await expect(instance.currentTarget()).resolves.toEqual({ + id: 'owned-target', + webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/owned-target', + }); + + await instance.close(); + expect(platform.process.terminated).toBe(true); + expect(platform.removedProfiles).toEqual([ + '/private/tmp/codex-git-profile-42', + ]); + }); + + it('rejects discovery when no target is bound to the owned endpoint', async () => { + const platform = new FixturePlatform([ + { + id: 'foreign-target', + type: 'page', + url: 'app://-/index.html', + webSocketDebuggerUrl: + 'ws://127.0.0.1:65530/devtools/page/foreign-target', + }, + ]); + await expect( + launchDedicatedCodexInstance({ + appPath: '/Applications/ChatGPT.app', + createInstanceId: () => 'instance-42', + platform, + startupTimeoutMs: 1, + }), + ).rejects.toThrow('renderer target did not become available'); + }); +}); + +// prettier-ignore +class FixtureProcess implements DedicatedCodexProcess { + readonly exited = new Promise((resolve) => { + this.resolveExit = resolve; + }); + readonly pid = 4242; + terminated = false; + private resolveExit: () => void = () => undefined; + + terminate(): void { this.terminated = true; this.resolveExit(); } +} + +// prettier-ignore +class FixturePlatform implements DedicatedCodexPlatform { + readonly process = new FixtureProcess(); + launch: { readonly args: readonly string[]; readonly executable: string } | null = null; + readonly removedProfiles: string[] = []; + + constructor(private readonly targets: unknown, private emptyFetches = 0) {} + async createProfile(): Promise { return '/private/tmp/codex-git-profile-42'; } + async readAppVersion(): Promise { return '26.820.60940'; } + spawn(executable: string, args: readonly string[]): DedicatedCodexProcess { + this.launch = { args, executable }; + return this.process; + } + + async readFile(): Promise { return '43117\n/devtools/browser/browser-42\n'; } + async fetchJson(): Promise { return this.emptyFetches-- > 0 ? [] : this.targets; } + async removeProfile(profilePath: string): Promise { + this.removedProfiles.push(profilePath); + } + + async wait(): Promise {} +} diff --git a/packages/host-adapter/codex-cdp/src/dedicated-instance.ts b/packages/host-adapter/codex-cdp/src/dedicated-instance.ts new file mode 100644 index 0000000..d069b92 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/dedicated-instance.ts @@ -0,0 +1,352 @@ +import { execFile, spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const loopbackHost = '127.0.0.1'; + +export interface DedicatedCodexOwnership { + readonly endpoint: string; + readonly instanceId: string; + readonly processId: number; + readonly profilePath: string; +} + +export interface DedicatedCodexTarget { + readonly id: string; + readonly webSocketUrl: string; +} + +export interface DedicatedCodexProcess { + readonly exited: Promise; + readonly pid: number; + terminate(): void; +} + +export interface DedicatedCodexPlatform { + createProfile(): Promise; + readAppVersion(appPath: string): Promise; + spawn(executable: string, args: readonly string[]): DedicatedCodexProcess; + readFile(path: string): Promise; + fetchJson(url: URL): Promise; + removeProfile(profilePath: string): Promise; + wait(milliseconds: number): Promise; +} + +export interface DedicatedCodexInstance { + readonly ownership: DedicatedCodexOwnership; + readonly version: string; + currentTarget(): Promise; + subscribe( + listener: (target: DedicatedCodexTarget | null) => void, + ): () => void; + close(): Promise; +} + +export interface LaunchDedicatedCodexOptions { + readonly appPath?: string; + readonly createInstanceId?: () => string; + readonly platform?: DedicatedCodexPlatform; + readonly startupTimeoutMs?: number; +} + +export async function launchDedicatedCodexInstance( + options: LaunchDedicatedCodexOptions = {}, +): Promise { + const appPath = options.appPath ?? '/Applications/ChatGPT.app'; + const platform = options.platform ?? defaultPlatform; + const profilePath = await platform.createProfile(); + let process: DedicatedCodexProcess | null = null; + + try { + const version = await platform.readAppVersion(appPath); + const executable = join(appPath, 'Contents', 'MacOS', 'ChatGPT'); + process = platform.spawn(executable, [ + `--user-data-dir=${profilePath}`, + '--remote-debugging-port=0', + '--no-first-run', + ]); + const endpoint = await waitForEndpoint( + platform, + process, + profilePath, + options.startupTimeoutMs ?? 15_000, + ); + const ownership = { + endpoint: endpoint.href, + instanceId: (options.createInstanceId ?? randomUUID)(), + processId: process.pid, + profilePath, + } satisfies DedicatedCodexOwnership; + + const instance = new OwnedDedicatedCodexInstance( + ownership, + version, + process, + platform, + ); + const targetDeadline = Date.now() + (options.startupTimeoutMs ?? 15_000); + while (Date.now() < targetDeadline) { + if ((await instance.currentTarget().catch(() => null)) !== null) { + return instance; + } + await platform.wait(50); + } + throw new Error('Dedicated Codex renderer target did not become available'); + } catch (error) { + process?.terminate(); + await platform.removeProfile(profilePath); + throw error; + } +} + +class OwnedDedicatedCodexInstance implements DedicatedCodexInstance { + private closeAttempt: Promise | null = null; + private lastTargetId: string | null = null; + private missingPolls = 0; + private pollInFlight = false; + private pollTimer: ReturnType | null = null; + private readonly subscribers = new Set< + (target: DedicatedCodexTarget | null) => void + >(); + + constructor( + readonly ownership: DedicatedCodexOwnership, + readonly version: string, + private readonly process: DedicatedCodexProcess, + private readonly platform: DedicatedCodexPlatform, + ) {} + + async currentTarget(): Promise { + const target = await this.discoverTarget(); + this.lastTargetId = target?.id ?? null; + return target; + } + + subscribe( + listener: (target: DedicatedCodexTarget | null) => void, + ): () => void { + this.subscribers.add(listener); + if (this.pollTimer === null) { + this.pollTimer = setInterval(() => void this.poll(), 500); + this.pollTimer.unref(); + } + return () => { + this.subscribers.delete(listener); + if (this.subscribers.size === 0 && this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + }; + } + + private async discoverTarget(): Promise { + const endpoint = new URL(this.ownership.endpoint); + const targets = await this.platform.fetchJson( + new URL('/json/list', endpoint), + ); + if (!Array.isArray(targets)) { + return null; + } + + const ownedTargets = targets.flatMap((target) => { + const parsed = parseOwnedTarget(target, endpoint); + return parsed === null ? [] : [parsed]; + }); + return ownedTargets.length === 1 ? (ownedTargets[0] ?? null) : null; + } + + close(): Promise { + this.closeAttempt ??= this.closeOnce(); + return this.closeAttempt; + } + + private async closeOnce(): Promise { + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.subscribers.clear(); + this.process.terminate(); + await Promise.race([this.process.exited, this.platform.wait(5_000)]); + await this.platform.removeProfile(this.ownership.profilePath); + } + + private async poll(): Promise { + if (this.pollInFlight) return; + this.pollInFlight = true; + try { + const target = await this.discoverTarget(); + if (target === null && ++this.missingPolls < 10) return; + if (target !== null) this.missingPolls = 0; + const targetId = target?.id ?? null; + if (targetId !== this.lastTargetId) { + this.lastTargetId = targetId; + this.subscribers.forEach((subscriber) => subscriber(target)); + } + } catch { + return; + } finally { + this.pollInFlight = false; + } + } +} + +async function waitForEndpoint( + platform: DedicatedCodexPlatform, + process: DedicatedCodexProcess, + profilePath: string, + timeoutMs: number, +): Promise { + const startedAt = Date.now(); + let exited = false; + void process.exited.then(() => { + exited = true; + }); + + while (Date.now() - startedAt < timeoutMs) { + if (exited) { + throw new Error('Dedicated Codex exited before CDP became available'); + } + try { + const contents = await platform.readFile( + join(profilePath, 'DevToolsActivePort'), + ); + return parseDevToolsEndpoint(contents); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + } + await platform.wait(50); + } + + throw new Error('Dedicated Codex CDP endpoint did not become available'); +} + +function parseDevToolsEndpoint(contents: string): URL { + const [portText, browserPath] = contents.trim().split(/\r?\n/u); + const port = Number(portText); + if ( + !Number.isInteger(port) || + port < 1 || + port > 65_535 || + browserPath === undefined || + !/^\/devtools\/browser\/[A-Za-z0-9-]+$/u.test(browserPath) + ) { + throw new Error('Dedicated Codex returned an invalid CDP endpoint'); + } + return new URL(`http://${loopbackHost}:${port}/`); +} + +function parseOwnedTarget( + value: unknown, + endpoint: URL, +): DedicatedCodexTarget | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const target = value as Record; + if ( + target.type !== 'page' || + target.url !== 'app://-/index.html' || + typeof target.id !== 'string' || + target.id.length === 0 || + target.id.includes('/') || + typeof target.webSocketDebuggerUrl !== 'string' + ) { + return null; + } + + const parsed = { id: target.id, webSocketUrl: target.webSocketDebuggerUrl }; + return isDedicatedCodexTargetOwned(parsed, { + endpoint: endpoint.href, + instanceId: '', + processId: 0, + profilePath: '', + }) + ? parsed + : null; +} + +export function isDedicatedCodexTargetOwned( + target: DedicatedCodexTarget, + ownership: DedicatedCodexOwnership, +): boolean { + let endpoint: URL; + let webSocketUrl: URL; + try { + endpoint = new URL(ownership.endpoint); + webSocketUrl = new URL(target.webSocketUrl); + } catch { + return false; + } + return ( + endpoint.protocol === 'http:' && + endpoint.hostname === loopbackHost && + endpoint.username === '' && + endpoint.password === '' && + webSocketUrl.protocol === 'ws:' && + webSocketUrl.hostname === loopbackHost && + webSocketUrl.port === endpoint.port && + webSocketUrl.pathname === `/devtools/page/${target.id}` && + webSocketUrl.username === '' && + webSocketUrl.password === '' + ); +} + +const defaultPlatform: DedicatedCodexPlatform = { + createProfile: () => mkdtemp(join(tmpdir(), 'codex-git-')), + readAppVersion: (appPath) => + executeFile('/usr/bin/plutil', [ + '-extract', + 'CFBundleShortVersionString', + 'raw', + join(appPath, 'Contents', 'Info.plist'), + ]), + spawn(executable, args) { + const child = spawn(executable, [...args], { stdio: 'ignore' }); + if (child.pid === undefined) { + throw new Error('Dedicated Codex process did not start'); + } + const exited = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', () => resolve()); + }); + return { + exited, + pid: child.pid, + terminate: () => child.kill('SIGTERM'), + }; + }, + readFile: (path) => readFile(path, 'utf8'), + async fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Dedicated Codex CDP discovery returned ${response.status}`, + ); + } + return response.json() as Promise; + }, + removeProfile: (profilePath) => + rm(profilePath, { force: true, recursive: true }), + wait: (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)), +}; + +function executeFile( + executable: string, + args: readonly string[], +): Promise { + return new Promise((resolve, reject) => { + execFile(executable, [...args], (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout.trim()); + } + }); + }); +} diff --git a/packages/host-adapter/codex-cdp/src/index.ts b/packages/host-adapter/codex-cdp/src/index.ts index b031b95..430555c 100644 --- a/packages/host-adapter/codex-cdp/src/index.ts +++ b/packages/host-adapter/codex-cdp/src/index.ts @@ -1,6 +1,31 @@ export { CodexCdpHostAdapter } from './adapter.js'; export { acquireDedicatedRendererCspBypass } from './csp-bypass.js'; export type { CodexCdpCommandTransport } from './csp-bypass.js'; +export { connectCdpSession } from './cdp-session.js'; +export type { CdpEvent, CdpSession } from './cdp-session.js'; +export { DedicatedCodexHostAdapter } from './dedicated-adapter.js'; +export type { + ConnectDedicatedRenderer, + ConnectDedicatedRendererRequest, + DedicatedCodexHostAdapterOptions, + DedicatedProjectIdentity, + DedicatedRendererConnection, + DedicatedRendererEvent, +} from './dedicated-adapter.js'; +export { + isDedicatedCodexTargetOwned, + launchDedicatedCodexInstance, +} from './dedicated-instance.js'; +export { connectDedicatedCodexRenderer } from './remote-renderer.js'; +export type { ConnectDedicatedCodexRendererOptions } from './remote-renderer.js'; +export type { + DedicatedCodexInstance, + DedicatedCodexOwnership, + DedicatedCodexPlatform, + DedicatedCodexProcess, + DedicatedCodexTarget, + LaunchDedicatedCodexOptions, +} from './dedicated-instance.js'; export type { CodexRenderer, CodexRendererSource, diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts new file mode 100644 index 0000000..c2a9d7a --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; + +import type { HostContext } from '@codex-git/host-adapter'; + +import { + connectDedicatedCodexRenderer, + type CdpEvent, + type CdpSession, + type ConnectDedicatedRendererRequest, +} from './index.js'; + +describe('dedicated Codex remote renderer', () => { + it('binds the trusted path to the startup-observed project and scopes CSP', async () => { + const session = new FixtureCdpSession([ + { status: 'not-ready' }, + { + context: expectedContext, + project: { id: 'project-42', label: 'codex-git' }, + status: 'attached', + }, + { + context: expectedContext, + project: { id: 'project-42', label: 'codex-git' }, + status: 'attached', + }, + ]); + const connection = await connectDedicatedCodexRenderer(request, { + connect: async () => session, + createBindingName: () => '__codexGitNotify_fixture', + wait: async () => undefined, + }); + + expect(connection.currentContext()).toEqual(expectedContext); + expect(connection.projectIdentity()).toEqual({ + id: 'project-42', + label: 'codex-git', + }); + expect(session.commands.map(({ method }) => method)).toEqual([ + 'Runtime.enable', + 'Runtime.addBinding', + 'Page.setBypassCSP', + 'Runtime.evaluate', + 'Runtime.evaluate', + ]); + + const reinstalled = new Promise((resolve) => { + connection.subscribe((event) => { + if (event.kind === 'context') resolve(); + }); + }); + session.publish({ method: 'Runtime.executionContextsCleared' }); + await reinstalled; + + await connection.close(); + expect(session.commands.slice(-2).map(({ method }) => method)).toEqual([ + 'Runtime.evaluate', + 'Page.setBypassCSP', + ]); + expect(session.closed).toBe(true); + }); + + it('fails closed when the selected project differs from the bound identity', async () => { + const session = new FixtureCdpSession({ status: 'project-mismatch' }); + + await expect( + connectDedicatedCodexRenderer( + { + ...request, + expectedProject: { id: 'project-previous', label: 'codex-git' }, + }, + { connect: async () => session }, + ), + ).rejects.toThrow('selected project does not match'); + expect(session.closed).toBe(true); + }); +}); + +const expectedContext = { + projectPath: '/Users/example/codex-git', + task: { id: 'task-42', title: 'Implement CDP transport' }, + theme: 'dark', +} satisfies HostContext; + +const request = { + expectedProject: null, + openSurface: false, + ownership: { + endpoint: 'http://127.0.0.1:43117/', + instanceId: 'instance-42', + processId: 4242, + profilePath: '/private/tmp/codex-git-profile-42', + }, + projectPath: '/Users/example/codex-git', + surface: { + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }, + target: { + id: 'renderer-42', + webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/renderer-42', + }, + version: '26.820.60940', +} satisfies ConnectDedicatedRendererRequest; + +class FixtureCdpSession implements CdpSession { + readonly commands: Array<{ method: string; params?: unknown }> = []; + closed = false; + private listener: ((event: CdpEvent) => void) | null = null; + + constructor(private readonly installation: unknown | unknown[]) {} + + async send(method: string, params?: unknown): Promise { + this.commands.push(params === undefined ? { method } : { method, params }); + if (method === 'Runtime.evaluate') { + const value = Array.isArray(this.installation) + ? this.installation.shift() + : this.installation; + return { result: { value } }; + } + return {}; + } + + subscribe(listener: (event: CdpEvent) => void): () => void { + this.listener = listener; + return () => (this.listener = null); + } + + publish(event: CdpEvent): void { + this.listener?.(event); + } + + async close(): Promise { + this.closed = true; + } +} diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts new file mode 100644 index 0000000..cb73300 --- /dev/null +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -0,0 +1,513 @@ +import { randomUUID } from 'node:crypto'; + +import type { + HostContext, + NativeActionResult, + NativeHostAction, +} from '@codex-git/host-adapter'; + +import { + connectCdpSession, + type CdpEvent, + type CdpSession, +} from './cdp-session.js'; +import { acquireDedicatedRendererCspBypass } from './csp-bypass.js'; +import type { + ConnectDedicatedRendererRequest, + DedicatedProjectIdentity, + DedicatedRendererConnection, + DedicatedRendererEvent, +} from './dedicated-adapter.js'; +import type { CspBypassLease } from './renderer.js'; + +const supportedCodexVersion = '26.820.60940'; + +export interface ConnectDedicatedCodexRendererOptions { + readonly connect?: (url: string) => Promise; + readonly createBindingName?: () => string; + readonly wait?: (milliseconds: number) => Promise; +} + +export async function connectDedicatedCodexRenderer( + request: ConnectDedicatedRendererRequest, + options: ConnectDedicatedCodexRendererOptions = {}, +): Promise { + if (request.version !== supportedCodexVersion) { + throw new Error('Unsupported Codex Desktop version'); + } + const session = await (options.connect ?? connectCdpSession)( + request.target.webSocketUrl, + ); + let lease: CspBypassLease | null = null; + try { + await session.send('Runtime.enable'); + const bindingName = + options.createBindingName?.() ?? + `__codexGitNotify_${randomUUID().replaceAll('-', '')}`; + await session.send('Runtime.addBinding', { name: bindingName }); + lease = await acquireDedicatedRendererCspBypass( + { + send: (_rendererId, method, params) => + session.send(method, params).then(), + }, + request.target.id, + ); + let installation = await install(session, request, bindingName, 1); + for ( + let attempt = 0; + installation.status === 'not-ready' && attempt < 100; + attempt++ + ) { + await (options.wait ?? wait)(100); + installation = await install(session, request, bindingName, 1); + } + if (installation.status !== 'attached') { + throw new Error( + installation.status === 'project-mismatch' + ? 'The selected project does not match the launcher binding' + : 'The dedicated Codex renderer is incompatible', + ); + } + const connection = new RemoteDedicatedRendererConnection( + session, + lease, + request, + bindingName, + installation, + ); + lease = null; + return connection; + } catch (error) { + await lease?.release().catch(() => undefined); + await session.close().catch(() => undefined); + throw error; + } +} + +interface AttachedInstallation { + readonly context: HostContext; + readonly open: boolean; + readonly project: DedicatedProjectIdentity; + readonly status: 'attached'; +} + +type Installation = + | AttachedInstallation + | { readonly status: 'incompatible' | 'not-ready' | 'project-mismatch' }; + +class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { + private closed = false; + private closeAttempt: Promise | null = null; + private context: HostContext; + private generation = 1; + private readonly listeners = new Set< + (event: DedicatedRendererEvent) => void + >(); + private open: boolean; + private refresh = Promise.resolve(); + private readonly project: DedicatedProjectIdentity; + private readonly unsubscribe: () => void; + + constructor( + private readonly session: CdpSession, + private cspLease: CspBypassLease | null, + private readonly request: ConnectDedicatedRendererRequest, + private readonly bindingName: string, + installation: AttachedInstallation, + ) { + this.context = installation.context; + this.open = installation.open; + this.project = installation.project; + this.unsubscribe = session.subscribe(this.handleCdpEvent); + } + + currentContext(): HostContext { + return this.context; + } + + isSurfaceOpen(): boolean { + return this.open; + } + + projectIdentity(): DedicatedProjectIdentity { + return this.project; + } + + subscribe(listener: (event: DedicatedRendererEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async perform(action: NativeHostAction): Promise { + if (this.closed) { + return { status: 'rejected' }; + } + if (action.kind === 'restore-native-surface') { + await evaluate(this.session, 'globalThis.__codexGitBridge?.restore()'); + this.open = false; + return { status: 'succeeded' }; + } + return { status: 'unsupported' }; + } + + close(): Promise { + this.closeAttempt ??= this.closeOnce().catch((error: unknown) => { + this.closeAttempt = null; + throw error; + }); + return this.closeAttempt; + } + + private readonly handleCdpEvent = (event: CdpEvent) => { + if (this.closed) { + return; + } + if (event.method === 'Runtime.bindingCalled') { + const params = isRecord(event.params) ? event.params : null; + if ( + params?.name !== this.bindingName || + typeof params.payload !== 'string' + ) { + return; + } + const message = parseBridgeEvent(params.payload); + if (message?.kind === 'context') { + this.context = message.context; + this.listeners.forEach((listener) => listener(message)); + } else if (message?.kind === 'surface') { + this.open = message.open; + } else if (message?.kind === 'standalone-required') { + this.listeners.forEach((listener) => listener(message)); + } + return; + } + if (event.method === 'Runtime.executionContextsCleared') { + const reopen = this.open; + this.refresh = this.refresh.then(() => this.reinstall(reopen)); + } + }; + + private async reinstall(reopen: boolean): Promise { + if (this.closed) { + return; + } + const replacementRequest = { + ...this.request, + expectedProject: this.project, + openSurface: reopen, + }; + for (let attempt = 0; attempt < 20; attempt++) { + try { + await this.session.send('Page.setBypassCSP', { enabled: true }); + const installation = await install( + this.session, + replacementRequest, + this.bindingName, + ++this.generation, + ); + if (installation.status === 'attached') { + this.context = installation.context; + this.open = installation.open; + this.listeners.forEach((listener) => + listener({ kind: 'context', context: this.context }), + ); + return; + } + if (installation.status !== 'not-ready') break; + await wait(100); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + this.listeners.forEach((listener) => + listener({ kind: 'standalone-required' }), + ); + } + + private async closeOnce(): Promise { + if (!this.closed) { + this.closed = true; + this.unsubscribe(); + this.listeners.clear(); + await this.refresh; + } + await evaluate(this.session, 'globalThis.__codexGitBridge?.close()').catch( + () => undefined, + ); + if (this.cspLease !== null) { + await this.cspLease.release(); + this.cspLease = null; + } + await this.session.close(); + } +} + +async function install( + session: CdpSession, + request: ConnectDedicatedRendererRequest, + bindingName: string, + generation: number, +): Promise { + const input: BridgeInput = { + bindingName, + expectedProject: request.expectedProject, + generation, + openSurface: request.openSurface, + projectPath: request.projectPath, + surfaceTitle: request.surface.title, + surfaceUrl: request.surface.url.href, + }; + const response = await evaluate( + session, + `((__name)=>(${installDomBridge.toString()})(${JSON.stringify(input)}))((target)=>target)`, + ); + if (isRecord(response) && isRecord(response.exceptionDetails)) { + const exception = response.exceptionDetails.exception; + throw new Error( + isRecord(exception) && typeof exception.description === 'string' + ? exception.description + : typeof response.exceptionDetails.text === 'string' + ? response.exceptionDetails.text + : 'Dedicated Codex DOM bridge evaluation failed', + ); + } + const value = + isRecord(response) && isRecord(response.result) + ? response.result.value + : null; + return parseInstallation(value); +} + +function evaluate(session: CdpSession, expression: string): Promise { + return session.send('Runtime.evaluate', { + awaitPromise: true, + expression, + returnByValue: true, + }); +} + +function parseInstallation(value: unknown): Installation { + if (!isRecord(value) || typeof value.status !== 'string') { + return { status: 'incompatible' }; + } + if (value.status === 'project-mismatch') { + return { status: 'project-mismatch' }; + } + if (value.status === 'not-ready') { + return { status: 'not-ready' }; + } + const context = parseHostContext(value.context); + const project = parseProject(value.project); + return value.status === 'attached' && context !== null && project !== null + ? { context, open: value.open === true, project, status: 'attached' } + : { status: 'incompatible' }; +} + +function parseBridgeEvent( + payload: string, +): + | DedicatedRendererEvent + | { readonly kind: 'surface'; readonly open: boolean } + | null { + let value: unknown; + try { + value = JSON.parse(payload) as unknown; + } catch { + return null; + } + if (!isRecord(value) || typeof value.kind !== 'string') { + return null; + } + if (value.kind === 'context') { + const context = parseHostContext(value.context); + return context === null ? null : { kind: 'context', context }; + } + if (value.kind === 'surface') { + return typeof value.open === 'boolean' + ? { kind: 'surface', open: value.open } + : null; + } + return value.kind === 'standalone-required' + ? { kind: 'standalone-required' } + : null; +} + +function parseProject(value: unknown): DedicatedProjectIdentity | null { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + value.id.length === 0 + ) { + return null; + } + return typeof value.label === 'string' && value.label.length > 0 + ? { id: value.id, label: value.label } + : null; +} + +function parseHostContext(value: unknown): HostContext | null { + if (!isRecord(value) || typeof value.projectPath !== 'string') { + return null; + } + if ( + value.theme !== 'dark' && + value.theme !== 'light' && + value.theme !== 'system' + ) { + return null; + } + const task = value.task; + if (task === null) { + return { projectPath: value.projectPath, task: null, theme: value.theme }; + } + if ( + !isRecord(task) || + typeof task.id !== 'string' || + typeof task.title !== 'string' + ) { + return null; + } + return { + projectPath: value.projectPath, + task: { id: task.id, title: task.title }, + theme: value.theme, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +interface BridgeInput { + readonly bindingName: string; + readonly expectedProject: DedicatedProjectIdentity | null; + readonly generation: number; + readonly openSurface: boolean; + readonly projectPath: string; + readonly surfaceTitle: string; + readonly surfaceUrl: string; +} + +// Kept self-contained because CDP serializes this function into the renderer. +// prettier-ignore +function installDomBridge(input: BridgeInput): unknown { + const root = globalThis as typeof globalThis & { __codexGitBridge?: { close(): void; restore(): void } }; + root.__codexGitBridge?.close(); + const sidebar = document.querySelector('#app-shell-sidebar'); + const main = document.querySelector('[data-app-shell-main-surface="default"]'); + const selectedProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); + if (!(sidebar instanceof HTMLElement) || !(main instanceof HTMLElement) || + !(selectedProject instanceof HTMLElement)) { + return { status: 'not-ready' }; + } + const project = { id: selectedProject.dataset.appActionSidebarProjectId ?? '', + label: selectedProject.dataset.appActionSidebarProjectLabel ?? '' }; + if (project.id.length === 0 || project.label.length === 0) return { status: 'incompatible' }; + if (input.expectedProject !== null && (project.id !== input.expectedProject.id || + project.label !== input.expectedProject.label)) { + return { status: 'project-mismatch' }; + } + const notify = (value: unknown) => { + const binding = (root as Record)[input.bindingName]; + if (typeof binding === 'function') (binding as (payload: string) => void)(JSON.stringify(value)); + }; + const secret = () => crypto.randomUUID(); + const entry = document.createElement('button'); + entry.type = 'button'; entry.dataset.codexGitSidebarEntry = ''; entry.textContent = 'Git'; + entry.setAttribute('aria-label', 'Open Codex Git'); + const nativeEntry = sidebar.querySelector('button'); + if (nativeEntry instanceof HTMLButtonElement) entry.className = nativeEntry.className; + let host: HTMLElement | null = null; + let frame: HTMLIFrameElement | null = null; + let capability = '', challenge = '', lastContext = ''; + const context = () => { + const taskRow = document.querySelector('[data-app-action-sidebar-thread-row][data-app-action-sidebar-thread-selected="true"], [data-app-action-sidebar-thread-row][aria-current="page"]'); + const task = + taskRow instanceof HTMLElement && + typeof taskRow.dataset.appActionSidebarThreadId === 'string' && + typeof taskRow.dataset.appActionSidebarThreadTitle === 'string' + ? { id: taskRow.dataset.appActionSidebarThreadId, + title: taskRow.dataset.appActionSidebarThreadTitle } : null; + const classes = document.documentElement.classList; + const theme = classes.contains('electron-dark') ? 'dark' : + classes.contains('electron-light') ? 'light' : 'system'; + return { projectPath: input.projectPath, task, theme }; + }; + const publishContext = () => { + const next = context(); + const serialized = JSON.stringify(next); + if (serialized !== lastContext) { lastContext = serialized; + notify({ kind: 'context', context: next }); } + frame?.contentWindow?.postMessage({ + capability, challenge, context: next, + generation: input.generation, + type: 'codex-git:host-context', + }, '*'); + }; + const restore = () => { + frame = null; host?.remove(); host = null; main.hidden = false; + entry.removeAttribute('aria-current'); + notify({ kind: 'surface', open: false }); + }; + const open = () => { + restore(); + host = document.createElement('main'); + host.dataset.codexGitSurface = ''; + host.setAttribute('aria-label', input.surfaceTitle); + host.style.cssText = 'display:flex;flex:1 1 auto;min-height:0;min-width:0;overflow:hidden'; + frame = document.createElement('iframe'); + frame.src = input.surfaceUrl; frame.title = input.surfaceTitle; + frame.setAttribute('sandbox', 'allow-scripts'); + Object.assign(frame.style, { border: '0', height: '100%', width: '100%' }); + capability = secret(); challenge = secret(); + frame.addEventListener('load', publishContext); + host.append(frame); + main.after(host); + main.hidden = true; entry.setAttribute('aria-current', 'page'); + notify({ kind: 'surface', open: true }); + }; + const handleSidebar = (event: Event) => { + const target = event.target; + if (target instanceof Node && !entry.contains(target)) restore(); + }; + const handleMessage = (event: MessageEvent) => { + const value = event.data; + if (frame === null || event.source !== frame.contentWindow || + typeof value !== 'object' || value === null) return; + const message = value as Record; + const action = message.action; + if (message.type === 'codex-git:host-action' && message.capability === capability && + message.challenge === challenge && message.generation === input.generation && + typeof action === 'object' && action !== null && + (action as Record).kind === 'restore-native-surface') restore(); + }; + const observer = new MutationObserver(() => { + const currentProject = document.querySelector('[data-app-action-sidebar-project-row][aria-current="page"]'); + if (!sidebar.isConnected || !main.isConnected || !(currentProject instanceof HTMLElement) || + currentProject.dataset.appActionSidebarProjectId !== project.id || + currentProject.dataset.appActionSidebarProjectLabel !== project.label) { + notify({ kind: 'standalone-required' }); + return; + } + publishContext(); + }); + const close = () => { + observer.disconnect(); sidebar.removeEventListener('click', handleSidebar, true); + globalThis.removeEventListener('message', handleMessage); restore(); entry.remove(); + delete root.__codexGitBridge; + }; + root.__codexGitBridge = { close, restore }; + entry.addEventListener('click', open); + sidebar.addEventListener('click', handleSidebar, true); + globalThis.addEventListener('message', handleMessage); + sidebar.append(entry); + observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true }); + if (input.openSurface) open(); + const initialContext = context(); + lastContext = JSON.stringify(initialContext); + return { context: initialContext, open: input.openSurface, project, status: 'attached' }; +} diff --git a/packages/host-adapter/src/index.ts b/packages/host-adapter/src/index.ts index c20fd37..95c8a7e 100644 --- a/packages/host-adapter/src/index.ts +++ b/packages/host-adapter/src/index.ts @@ -39,6 +39,12 @@ export type HostAttachResult = export interface HostConnection { currentContext(): HostContext; contexts(): AsyncIterable; + transitions(): AsyncIterable; perform(action: NativeHostAction): Promise; close(): Promise; } + +export type HostTransition = { + readonly kind: 'standalone-required'; + readonly reason: SanitizedDiagnostic; +}; diff --git a/packages/host-adapter/standalone/src/adapter.ts b/packages/host-adapter/standalone/src/adapter.ts index e1e939a..8d19dd1 100644 --- a/packages/host-adapter/standalone/src/adapter.ts +++ b/packages/host-adapter/standalone/src/adapter.ts @@ -21,6 +21,8 @@ class StandaloneHostConnection implements HostConnection { yield standaloneContext; } + async *transitions(): AsyncIterable {} + async perform(): Promise { return { status: 'unsupported' }; } diff --git a/tests/contract/standalone-host-adapter.contract.test.ts b/tests/contract/standalone-host-adapter.contract.test.ts index 0b4136a..def32e4 100644 --- a/tests/contract/standalone-host-adapter.contract.test.ts +++ b/tests/contract/standalone-host-adapter.contract.test.ts @@ -15,6 +15,7 @@ describe('StandaloneHostAdapter contract', () => { const { connection } = result; const contexts = connection.contexts()[Symbol.asyncIterator](); + const transitions = connection.transitions()[Symbol.asyncIterator](); expect(await contexts.next()).toEqual({ done: false, @@ -24,6 +25,10 @@ describe('StandaloneHostAdapter contract', () => { theme: 'system', }, }); + await expect(transitions.next()).resolves.toEqual({ + done: true, + value: undefined, + }); await connection.close(); }); diff --git a/tests/e2e/codex-runtime.e2e.test.ts b/tests/e2e/codex-runtime.e2e.test.ts new file mode 100644 index 0000000..df3e015 --- /dev/null +++ b/tests/e2e/codex-runtime.e2e.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { + DedicatedCodexInstance, + DedicatedCodexTarget, +} from '@codex-git/host-adapter-codex-cdp'; +import { startCodexRuntime, type CodexRuntime } from '@codex-git/launcher'; + +const runtimes: CodexRuntime[] = []; + +afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); +}); + +describe('Codex runtime composition', () => { + it('closes the dedicated instance and remains standalone when ownership fails', async () => { + const instance = new FixtureInstance(null); + const runtime = await startCodexRuntime({ + healthPort: 0, + launchInstance: async () => instance, + projectPath: '/Users/example/codex-git', + surfacePort: 0, + }); + runtimes.push(runtime); + + expect(runtime.currentHost()).toBe('standalone'); + expect(instance.closed).toBe(true); + }); +}); + +class FixtureInstance implements DedicatedCodexInstance { + closed = false; + readonly ownership = { + endpoint: 'http://127.0.0.1:43117/', + instanceId: 'instance-42', + processId: 4242, + profilePath: '/private/tmp/codex-git-profile-42', + }; + readonly version = '26.820.60940'; + + constructor(private readonly target: DedicatedCodexTarget | null) {} + + async currentTarget(): Promise { + return this.target; + } + + subscribe(): () => void { + return () => undefined; + } + + async close(): Promise { + this.closed = true; + } +} From 22490e5a2cd2253c0cd73121bab161ac95a92aa0 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sat, 29 Aug 2026 16:11:55 +0800 Subject: [PATCH 2/3] fix: close dedicated renderer review gaps --- .../host-adapter/codex-cdp/src/cdp-session.ts | 3 ++ .../codex-cdp/src/csp-bypass.test.ts | 23 +++++++++++ .../host-adapter/codex-cdp/src/csp-bypass.ts | 27 +++++++++---- .../codex-cdp/src/dedicated-adapter.test.ts | 1 + .../codex-cdp/src/dedicated-adapter.ts | 26 +++++++----- .../codex-cdp/src/dedicated-instance.test.ts | 5 ++- .../codex-cdp/src/dedicated-instance.ts | 40 ++++++++++--------- packages/host-adapter/codex-cdp/src/index.ts | 2 +- .../codex-cdp/src/remote-renderer.test.ts | 34 +++++++++++++++- .../codex-cdp/src/remote-renderer.ts | 29 +++++++++++++- tests/e2e/codex-runtime.e2e.test.ts | 1 + 11 files changed, 152 insertions(+), 39 deletions(-) diff --git a/packages/host-adapter/codex-cdp/src/cdp-session.ts b/packages/host-adapter/codex-cdp/src/cdp-session.ts index 33a69b7..e122264 100644 --- a/packages/host-adapter/codex-cdp/src/cdp-session.ts +++ b/packages/host-adapter/codex-cdp/src/cdp-session.ts @@ -106,6 +106,9 @@ class WebSocketCdpSession implements CdpSession { const error = new Error('Dedicated Codex CDP session closed'); this.pending.forEach(({ reject }) => reject(error)); this.pending.clear(); + this.listeners.forEach((listener) => + listener({ method: 'CodexGit.sessionClosed' }), + ); this.listeners.clear(); }; } diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts index 7903c4c..cc71560 100644 --- a/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.test.ts @@ -81,4 +81,27 @@ describe('dedicated renderer CSP bypass', () => { await second.release(); expect(enabled).toEqual([true, false]); }); + + it('shares a stable ownership scope across production transports', async () => { + const commands: string[] = []; + const transport = (name: string): CodexCdpCommandTransport => ({ + async send(_rendererId, _method, params) { + commands.push(`${name}:${params.enabled}`); + }, + }); + const first = await acquireDedicatedRendererCspBypass( + transport('first'), + 'renderer-target-42', + 'instance-42:renderer-target-42', + ); + const second = await acquireDedicatedRendererCspBypass( + transport('second'), + 'renderer-target-42', + 'instance-42:renderer-target-42', + ); + + await first.release(); + await second.release(); + expect(commands).toEqual(['first:true', 'second:false']); + }); }); diff --git a/packages/host-adapter/codex-cdp/src/csp-bypass.ts b/packages/host-adapter/codex-cdp/src/csp-bypass.ts index 0cc089a..6b279ea 100644 --- a/packages/host-adapter/codex-cdp/src/csp-bypass.ts +++ b/packages/host-adapter/codex-cdp/src/csp-bypass.ts @@ -17,32 +17,43 @@ const activeByTransport = new WeakMap< CodexCdpCommandTransport, Map >(); +const activeByOwnershipScope = new Map(); export async function acquireDedicatedRendererCspBypass( transport: CodexCdpCommandTransport, rendererId: string, + ownershipScope?: string, ): Promise { if (rendererId.length === 0) { throw new Error('A stable renderer ID is required for CSP bypass'); } - let renderers = activeByTransport.get(transport); - if (renderers === undefined) { - renderers = new Map(); + let renderers: Map; + let key: string; + if (ownershipScope === undefined) { + renderers = activeByTransport.get(transport) ?? new Map(); activeByTransport.set(transport, renderers); + key = rendererId; + } else { + renderers = activeByOwnershipScope; + key = ownershipScope; } - const existing = renderers.get(rendererId); + const existing = renderers.get(key); if (existing?.disabling !== null && existing?.disabling !== undefined) { await existing.disabling; - return acquireDedicatedRendererCspBypass(transport, rendererId); + return acquireDedicatedRendererCspBypass( + transport, + rendererId, + ownershipScope, + ); } const active = existing ?? { count: 0, disabling: null }; if (existing === undefined) { - renderers.set(rendererId, active); + renderers.set(key, active); try { await transport.send(rendererId, 'Page.setBypassCSP', { enabled: true }); } catch (error) { - renderers.delete(rendererId); + renderers.delete(key); throw error; } } @@ -70,7 +81,7 @@ export async function acquireDedicatedRendererCspBypass( active.count = 0; active.disabling = null; released = true; - renderers.delete(rendererId); + renderers.delete(key); }, (error: unknown) => { active.disabling = null; diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts index f235a2a..1a759af 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.test.ts @@ -139,6 +139,7 @@ function ownedTarget(id: string): DedicatedCodexTarget { // prettier-ignore class FixtureInstance implements DedicatedCodexInstance { + readonly build = '7119'; readonly ownership = ownership; readonly version = '26.820.60940'; private readonly listeners = new Set<(target: DedicatedCodexTarget | null) => void>(); diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts index 44b16a0..3e0731a 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts @@ -11,7 +11,7 @@ import type { import { AsyncStream } from './async-stream.js'; import { - isDedicatedCodexTargetOwned, + isDedicatedCodexTargetBoundToEndpoint, type DedicatedCodexInstance, type DedicatedCodexOwnership, type DedicatedCodexTarget, @@ -36,6 +36,7 @@ export interface DedicatedRendererConnection { } export interface ConnectDedicatedRendererRequest { + readonly build: string; readonly expectedProject: DedicatedProjectIdentity | null; readonly openSurface: boolean; readonly ownership: DedicatedCodexOwnership; @@ -62,7 +63,10 @@ export class DedicatedCodexHostAdapter implements HostAdapter { const target = await this.options.instance.currentTarget(); if ( target === null || - !isDedicatedCodexTargetOwned(target, this.options.instance.ownership) + !isDedicatedCodexTargetBoundToEndpoint( + target, + this.options.instance.ownership.endpoint, + ) ) { return standaloneRequired( 'host-unavailable', @@ -72,6 +76,7 @@ export class DedicatedCodexHostAdapter implements HostAdapter { try { const renderer = await this.options.connectRenderer({ + build: this.options.instance.build, expectedProject: null, openSurface: false, ownership: this.options.instance.ownership, @@ -157,7 +162,10 @@ class ManagedDedicatedConnection implements HostConnection { } if ( target === null || - !isDedicatedCodexTargetOwned(target, this.options.instance.ownership) + !isDedicatedCodexTargetBoundToEndpoint( + target, + this.options.instance.ownership.endpoint, + ) ) { await this.degrade('host-unavailable'); return; @@ -168,6 +176,7 @@ class ManagedDedicatedConnection implements HostConnection { await this.renderer.close().catch(() => undefined); try { const renderer = await this.options.connectRenderer({ + build: this.options.instance.build, expectedProject: this.project, openSurface: reopen, ownership: this.options.instance.ownership, @@ -218,13 +227,12 @@ class ManagedDedicatedConnection implements HostConnection { } private async closeOnce(): Promise { - if (this.closed) { - return; + if (!this.closed) { + this.closed = true; + this.sourceSubscription(); + this.rendererSubscription(); + await this.replacement; } - this.closed = true; - this.sourceSubscription(); - this.rendererSubscription(); - await this.replacement; await this.renderer.close(); this.contextStream.close(); this.transitionStream.close(); diff --git a/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts b/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts index a0c5e43..b519d6f 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-instance.test.ts @@ -48,6 +48,7 @@ describe('dedicated Codex instance discovery', () => { processId: 4242, profilePath: '/private/tmp/codex-git-profile-42', }); + expect(instance.build).toBe('7119'); await expect(instance.currentTarget()).resolves.toEqual({ id: 'owned-target', webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/owned-target', @@ -101,7 +102,9 @@ class FixturePlatform implements DedicatedCodexPlatform { constructor(private readonly targets: unknown, private emptyFetches = 0) {} async createProfile(): Promise { return '/private/tmp/codex-git-profile-42'; } - async readAppVersion(): Promise { return '26.820.60940'; } + async readAppIdentity(): Promise<{ build: string; version: string }> { + return { build: '7119', version: '26.820.60940' }; + } spawn(executable: string, args: readonly string[]): DedicatedCodexProcess { this.launch = { args, executable }; return this.process; diff --git a/packages/host-adapter/codex-cdp/src/dedicated-instance.ts b/packages/host-adapter/codex-cdp/src/dedicated-instance.ts index d069b92..8e5f602 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-instance.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-instance.ts @@ -26,7 +26,10 @@ export interface DedicatedCodexProcess { export interface DedicatedCodexPlatform { createProfile(): Promise; - readAppVersion(appPath: string): Promise; + readAppIdentity(appPath: string): Promise<{ + readonly build: string; + readonly version: string; + }>; spawn(executable: string, args: readonly string[]): DedicatedCodexProcess; readFile(path: string): Promise; fetchJson(url: URL): Promise; @@ -35,6 +38,7 @@ export interface DedicatedCodexPlatform { } export interface DedicatedCodexInstance { + readonly build: string; readonly ownership: DedicatedCodexOwnership; readonly version: string; currentTarget(): Promise; @@ -60,7 +64,7 @@ export async function launchDedicatedCodexInstance( let process: DedicatedCodexProcess | null = null; try { - const version = await platform.readAppVersion(appPath); + const { build, version } = await platform.readAppIdentity(appPath); const executable = join(appPath, 'Contents', 'MacOS', 'ChatGPT'); process = platform.spawn(executable, [ `--user-data-dir=${profilePath}`, @@ -82,6 +86,7 @@ export async function launchDedicatedCodexInstance( const instance = new OwnedDedicatedCodexInstance( ownership, + build, version, process, platform, @@ -113,6 +118,7 @@ class OwnedDedicatedCodexInstance implements DedicatedCodexInstance { constructor( readonly ownership: DedicatedCodexOwnership, + readonly build: string, readonly version: string, private readonly process: DedicatedCodexProcess, private readonly platform: DedicatedCodexPlatform, @@ -260,24 +266,19 @@ function parseOwnedTarget( } const parsed = { id: target.id, webSocketUrl: target.webSocketDebuggerUrl }; - return isDedicatedCodexTargetOwned(parsed, { - endpoint: endpoint.href, - instanceId: '', - processId: 0, - profilePath: '', - }) + return isDedicatedCodexTargetBoundToEndpoint(parsed, endpoint.href) ? parsed : null; } -export function isDedicatedCodexTargetOwned( +export function isDedicatedCodexTargetBoundToEndpoint( target: DedicatedCodexTarget, - ownership: DedicatedCodexOwnership, + endpointUrl: string, ): boolean { let endpoint: URL; let webSocketUrl: URL; try { - endpoint = new URL(ownership.endpoint); + endpoint = new URL(endpointUrl); webSocketUrl = new URL(target.webSocketUrl); } catch { return false; @@ -298,13 +299,16 @@ export function isDedicatedCodexTargetOwned( const defaultPlatform: DedicatedCodexPlatform = { createProfile: () => mkdtemp(join(tmpdir(), 'codex-git-')), - readAppVersion: (appPath) => - executeFile('/usr/bin/plutil', [ - '-extract', - 'CFBundleShortVersionString', - 'raw', - join(appPath, 'Contents', 'Info.plist'), - ]), + async readAppIdentity(appPath) { + const plist = join(appPath, 'Contents', 'Info.plist'); + const read = (key: string) => + executeFile('/usr/bin/plutil', ['-extract', key, 'raw', plist]); + const [build, version] = await Promise.all([ + read('CFBundleVersion'), + read('CFBundleShortVersionString'), + ]); + return { build, version }; + }, spawn(executable, args) { const child = spawn(executable, [...args], { stdio: 'ignore' }); if (child.pid === undefined) { diff --git a/packages/host-adapter/codex-cdp/src/index.ts b/packages/host-adapter/codex-cdp/src/index.ts index 430555c..77921fd 100644 --- a/packages/host-adapter/codex-cdp/src/index.ts +++ b/packages/host-adapter/codex-cdp/src/index.ts @@ -13,7 +13,7 @@ export type { DedicatedRendererEvent, } from './dedicated-adapter.js'; export { - isDedicatedCodexTargetOwned, + isDedicatedCodexTargetBoundToEndpoint, launchDedicatedCodexInstance, } from './dedicated-instance.js'; export { connectDedicatedCodexRenderer } from './remote-renderer.js'; diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts index c2a9d7a..7bb229a 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.test.ts @@ -36,6 +36,7 @@ describe('dedicated Codex remote renderer', () => { label: 'codex-git', }); expect(session.commands.map(({ method }) => method)).toEqual([ + 'Browser.getVersion', 'Runtime.enable', 'Runtime.addBinding', 'Page.setBypassCSP', @@ -51,6 +52,14 @@ describe('dedicated Codex remote renderer', () => { session.publish({ method: 'Runtime.executionContextsCleared' }); await reinstalled; + const degraded = new Promise((resolve) => { + connection.subscribe((event) => { + if (event.kind === 'standalone-required') resolve(); + }); + }); + session.publish({ method: 'CodexGit.sessionClosed' }); + await degraded; + await connection.close(); expect(session.commands.slice(-2).map(({ method }) => method)).toEqual([ 'Runtime.evaluate', @@ -73,6 +82,22 @@ describe('dedicated Codex remote renderer', () => { ).rejects.toThrow('selected project does not match'); expect(session.closed).toBe(true); }); + + it('rejects an unverified Chromium build before changing CSP', async () => { + const session = new FixtureCdpSession( + { status: 'not-ready' }, + 'Chrome/151.0.7922.171', + ); + + await expect( + connectDedicatedCodexRenderer(request, { + connect: async () => session, + }), + ).rejects.toThrow('Unsupported Codex Desktop Chromium version'); + expect(session.commands.map(({ method }) => method)).toEqual([ + 'Browser.getVersion', + ]); + }); }); const expectedContext = { @@ -82,6 +107,7 @@ const expectedContext = { } satisfies HostContext; const request = { + build: '7119', expectedProject: null, openSurface: false, ownership: { @@ -107,10 +133,16 @@ class FixtureCdpSession implements CdpSession { closed = false; private listener: ((event: CdpEvent) => void) | null = null; - constructor(private readonly installation: unknown | unknown[]) {} + constructor( + private readonly installation: unknown | unknown[], + private readonly product = 'Chrome/151.0.7922.170', + ) {} async send(method: string, params?: unknown): Promise { this.commands.push(params === undefined ? { method } : { method, params }); + if (method === 'Browser.getVersion') { + return { product: this.product }; + } if (method === 'Runtime.evaluate') { const value = Array.isArray(this.installation) ? this.installation.shift() diff --git a/packages/host-adapter/codex-cdp/src/remote-renderer.ts b/packages/host-adapter/codex-cdp/src/remote-renderer.ts index cb73300..8e414f7 100644 --- a/packages/host-adapter/codex-cdp/src/remote-renderer.ts +++ b/packages/host-adapter/codex-cdp/src/remote-renderer.ts @@ -21,6 +21,8 @@ import type { import type { CspBypassLease } from './renderer.js'; const supportedCodexVersion = '26.820.60940'; +const supportedCodexBuild = '7119'; +const supportedChromiumProduct = 'Chrome/151.0.7922.170'; export interface ConnectDedicatedCodexRendererOptions { readonly connect?: (url: string) => Promise; @@ -32,7 +34,10 @@ export async function connectDedicatedCodexRenderer( request: ConnectDedicatedRendererRequest, options: ConnectDedicatedCodexRendererOptions = {}, ): Promise { - if (request.version !== supportedCodexVersion) { + if ( + request.version !== supportedCodexVersion || + request.build !== supportedCodexBuild + ) { throw new Error('Unsupported Codex Desktop version'); } const session = await (options.connect ?? connectCdpSession)( @@ -40,6 +45,10 @@ export async function connectDedicatedCodexRenderer( ); let lease: CspBypassLease | null = null; try { + const browser = await session.send('Browser.getVersion'); + if (!isRecord(browser) || browser.product !== supportedChromiumProduct) { + throw new Error('Unsupported Codex Desktop Chromium version'); + } await session.send('Runtime.enable'); const bindingName = options.createBindingName?.() ?? @@ -51,6 +60,7 @@ export async function connectDedicatedCodexRenderer( session.send(method, params).then(), }, request.target.id, + cspOwnershipScope(request), ); let installation = await install(session, request, bindingName, 1); for ( @@ -184,6 +194,12 @@ class RemoteDedicatedRendererConnection implements DedicatedRendererConnection { if (event.method === 'Runtime.executionContextsCleared') { const reopen = this.open; this.refresh = this.refresh.then(() => this.reinstall(reopen)); + return; + } + if (event.method === 'CodexGit.sessionClosed') { + this.listeners.forEach((listener) => + listener({ kind: 'standalone-required' }), + ); } }; @@ -378,6 +394,17 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function cspOwnershipScope(request: ConnectDedicatedRendererRequest): string { + const { endpoint, instanceId, processId, profilePath } = request.ownership; + return JSON.stringify([ + endpoint, + instanceId, + processId, + profilePath, + request.target.id, + ]); +} + function wait(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/tests/e2e/codex-runtime.e2e.test.ts b/tests/e2e/codex-runtime.e2e.test.ts index df3e015..ee7979a 100644 --- a/tests/e2e/codex-runtime.e2e.test.ts +++ b/tests/e2e/codex-runtime.e2e.test.ts @@ -29,6 +29,7 @@ describe('Codex runtime composition', () => { }); class FixtureInstance implements DedicatedCodexInstance { + readonly build = '7119'; closed = false; readonly ownership = { endpoint: 'http://127.0.0.1:43117/', From 53bbe358ea2a3aef53dedd9cf1d54550991ae576 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sat, 29 Aug 2026 16:24:11 +0800 Subject: [PATCH 3/3] fix: guarantee standalone fallback teardown --- apps/launcher/src/codex-runtime.ts | 25 ++++++-- docs/host-integration/codex-compatibility.md | 3 +- .../codex-cdp/src/dedicated-adapter.ts | 9 ++- tests/e2e/codex-runtime.e2e.test.ts | 62 ++++++++++++++++++- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/apps/launcher/src/codex-runtime.ts b/apps/launcher/src/codex-runtime.ts index ae46b87..bb575af 100644 --- a/apps/launcher/src/codex-runtime.ts +++ b/apps/launcher/src/codex-runtime.ts @@ -60,10 +60,14 @@ export async function startCodexRuntime( return; } host = 'standalone'; - await connection?.close(); - await instance?.close(); + const attachedConnection = connection; + const dedicatedInstance = instance; connection = null; instance = null; + await Promise.allSettled([ + attachedConnection?.close(), + dedicatedInstance?.close(), + ]); }); } } catch { @@ -80,10 +84,19 @@ export async function startCodexRuntime( return; } closing = true; - await connection?.close(); - await instance?.close(); - await monitor; - await standalone.close(); + const results = await Promise.allSettled([ + connection?.close(), + instance?.close(), + monitor, + standalone.close(), + ]); + const failure = results.find( + (result): result is PromiseRejectedResult => + result.status === 'rejected', + ); + if (failure !== undefined) { + throw failure.reason; + } }, }; } diff --git a/docs/host-integration/codex-compatibility.md b/docs/host-integration/codex-compatibility.md index 5eae4f9..0a9f59a 100644 --- a/docs/host-integration/codex-compatibility.md +++ b/docs/host-integration/codex-compatibility.md @@ -56,7 +56,8 @@ Codex Desktop `26.820.60940` (build `7119`) and Chromium `151.0.7922.170`. - 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. +- Change theme/task and confirm typed context updates; change Current Project + and confirm a typed standalone-required transition. - 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 diff --git a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts index 3e0731a..52576ab 100644 --- a/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts +++ b/packages/host-adapter/codex-cdp/src/dedicated-adapter.ts @@ -233,9 +233,12 @@ class ManagedDedicatedConnection implements HostConnection { this.rendererSubscription(); await this.replacement; } - await this.renderer.close(); - this.contextStream.close(); - this.transitionStream.close(); + try { + await this.renderer.close(); + } finally { + this.contextStream.close(); + this.transitionStream.close(); + } } } diff --git a/tests/e2e/codex-runtime.e2e.test.ts b/tests/e2e/codex-runtime.e2e.test.ts index ee7979a..044032d 100644 --- a/tests/e2e/codex-runtime.e2e.test.ts +++ b/tests/e2e/codex-runtime.e2e.test.ts @@ -1,15 +1,19 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { DedicatedCodexInstance, DedicatedCodexTarget, + DedicatedRendererConnection, } from '@codex-git/host-adapter-codex-cdp'; +import type { HostContext } from '@codex-git/host-adapter'; import { startCodexRuntime, type CodexRuntime } from '@codex-git/launcher'; const runtimes: CodexRuntime[] = []; afterEach(async () => { - await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); + await Promise.all( + runtimes.splice(0).map((runtime) => runtime.close().catch(() => undefined)), + ); }); describe('Codex runtime composition', () => { @@ -26,8 +30,30 @@ describe('Codex runtime composition', () => { expect(runtime.currentHost()).toBe('standalone'); expect(instance.closed).toBe(true); }); + + it('closes the dedicated instance when renderer teardown fails during fallback', async () => { + const instance = new FixtureInstance(ownedTarget); + const renderer = new FailingRenderer(); + const runtime = await startCodexRuntime({ + connectRenderer: async () => renderer, + healthPort: 0, + launchInstance: async () => instance, + projectPath: '/Users/example/codex-git', + surfacePort: 0, + }); + runtimes.push(runtime); + + renderer.publishStandalone(); + await vi.waitFor(() => expect(instance.closed).toBe(true)); + expect(runtime.currentHost()).toBe('standalone'); + }); }); +const ownedTarget = { + id: 'renderer-42', + webSocketUrl: 'ws://127.0.0.1:43117/devtools/page/renderer-42', +} satisfies DedicatedCodexTarget; + class FixtureInstance implements DedicatedCodexInstance { readonly build = '7119'; closed = false; @@ -53,3 +79,35 @@ class FixtureInstance implements DedicatedCodexInstance { this.closed = true; } } + +class FailingRenderer implements DedicatedRendererConnection { + private listener: Parameters[0] = + () => undefined; + + currentContext(): HostContext { + return { + projectPath: '/Users/example/codex-git', + task: null, + theme: 'dark', + }; + } + isSurfaceOpen(): boolean { + return false; + } + projectIdentity(): { readonly id: string; readonly label: string } { + return { id: 'project-42', label: 'codex-git' }; + } + subscribe(listener: typeof this.listener): () => void { + this.listener = listener; + return () => undefined; + } + publishStandalone(): void { + this.listener({ kind: 'standalone-required' }); + } + async perform(): Promise<{ readonly status: 'rejected' }> { + return { status: 'rejected' }; + } + async close(): Promise { + throw new Error('Closed CDP socket cannot restore CSP'); + } +}