From f98bd1752314546dcddf9b11bfd876a1633d6311 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 00:11:41 +0000 Subject: [PATCH 1/3] fix(dev): refresh Codex app-server components --- .changeset/refresh-codex-dev-components.md | 5 + docs/diagnostics.md | 2 +- .../agent-bundle/src/dev/codex-app-server.ts | 128 +++++++++++ .../src/dev/host-install-manager.ts | 56 ++++- .../tests/dev-host-install.test.ts | 205 +++++++++++++++++- .../tests/native-codex-app-server.test.ts | 46 ++++ .../tests/support/host-install.ts | 191 +++++++++++++++- rstest.integration-tests.ts | 1 + .../docs/en/guide/development/workbench.mdx | 23 +- .../docs/zh/guide/development/workbench.mdx | 15 +- 10 files changed, 638 insertions(+), 34 deletions(-) create mode 100644 .changeset/refresh-codex-dev-components.md create mode 100644 packages/agent-bundle/src/dev/codex-app-server.ts create mode 100644 packages/agent-bundle/tests/native-codex-app-server.test.ts diff --git a/.changeset/refresh-codex-dev-components.md b/.changeset/refresh-codex-dev-components.md new file mode 100644 index 000000000..b24603647 --- /dev/null +++ b/.changeset/refresh-codex-dev-components.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Refresh a running Codex app-server's MCP and hook components before `agent-bundle dev --install-host codex` reports the epoch attached, with failed refreshes reported as `AB7202`. (#716) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6adc1e698..a7a5d029b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1699,7 +1699,7 @@ host-facing build together with the failed checks. | --- | --- | --- | --- | | `AB7200` | error | A development rebuild could not be admitted: the coordinator is closed, closing, or not yet started. | Restart `agent-bundle dev`; no epoch changed. | | `AB7201` | error | The prepare, lint, or artifact phase of a development rebuild threw instead of reporting diagnostics. The message names the phase and the underlying error. | Fix the named failure and save again; the last-good epoch stays active. | -| `AB7202` | error | Publishing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed. Pointers were rolled back to the previous generation and the failure was published on `dev.host.sync`. | Repair the host cache path or permissions named in the message; the next successful epoch re-syncs. | +| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. | | `AB7210` | error | `dev.contracts` is malformed, its `fixtures` module escapes the project root, cannot be loaded, or default-exports something other than route-id keyed `ContractRouteFixture` objects. Reported on `dev.contract.status` for the affected epoch; compilation is unaffected. | Correct `dev.contracts` or the fixture module and rebuild; host surfaces keep the last passing epoch meanwhile. | | `AB7211` | error | The development contract matrix failed or could not complete for a published epoch. The message carries the aggregated `contract-violation` detail; `dev.contract.status` lists the failed check names grouped by route. That epoch is never adopted by live host connections or development installs. | Fix the failing route or fixture and rebuild; a passing epoch is adopted normally. | | `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. | diff --git a/packages/agent-bundle/src/dev/codex-app-server.ts b/packages/agent-bundle/src/dev/codex-app-server.ts new file mode 100644 index 000000000..52a47582c --- /dev/null +++ b/packages/agent-bundle/src/dev/codex-app-server.ts @@ -0,0 +1,128 @@ +import { stat } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { join } from 'node:path'; + +import WebSocket from 'ws'; + +import { isErrno } from '../core/errors.ts'; +import { isRecord } from '../core/strict-json.ts'; + +const requestTimeoutMs = 30_000; + +type CodexAppServerRequest = ( + method: string, + params: Readonly>, +) => Promise; + +/** Runs one initialized client exchange over Codex's documented local control socket, when present. */ +export const withCodexAppServer = async ( + codexRoot: string, + action: (request: CodexAppServerRequest) => Promise, +): Promise => { + const socketPath = join(codexRoot, 'app-server-control', 'app-server-control.sock'); + try { + await stat(socketPath); + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + } + + const socket = new WebSocket('ws://localhost/', { + createConnection: () => createConnection(socketPath), + handshakeTimeout: 5_000, + perMessageDeflate: false, + }); + try { + await new Promise((resolvePromise, rejectPromise) => { + const onError = (error: Error): void => rejectPromise(error); + socket.once('error', onError); + socket.once('open', () => { + socket.off('error', onError); + resolvePromise(); + }); + }); + } catch (error) { + socket.terminate(); + if (isErrno(error, 'ENOENT') || isErrno(error, 'ECONNREFUSED')) return undefined; + throw error; + } + + let nextId = 0; + const pending = new Map void; + readonly resolve: (result: unknown) => void; + }>(); + const rejectPending = (error: Error): void => { + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; + socket.on('error', rejectPending); + socket.on('close', () => rejectPending(new Error('Codex app-server connection closed before responding.'))); + socket.on('message', (data) => { + let message: Readonly> | undefined; + try { + const parsed = JSON.parse(data.toString()) as unknown; + message = isRecord(parsed) ? parsed : undefined; + } catch { + return; + } + if (message === undefined) return; + if (typeof message.method === 'string') return; + const id = message.id; + if (typeof id !== 'number') return; + const request = pending.get(id); + if (request === undefined) return; + pending.delete(id); + if (message.error !== undefined) { + request.reject(new Error(`Codex app-server request failed: ${JSON.stringify(message.error)}`)); + } else { + request.resolve(message.result); + } + }); + const request: CodexAppServerRequest = ( + method: string, + params: Readonly>, + ): Promise => new Promise((resolvePromise, rejectPromise) => { + const id = nextId++; + const timeout = setTimeout(() => { + pending.delete(id); + rejectPromise(new Error(`Codex app-server ${method} timed out.`)); + }, requestTimeoutMs); + pending.set(id, { + reject: (error) => { + clearTimeout(timeout); + rejectPromise(error); + }, + resolve: (result) => { + clearTimeout(timeout); + resolvePromise(result as Response); + }, + }); + socket.send(JSON.stringify({ id, method, params })); + }); + + try { + await request('initialize', { + capabilities: {}, + clientInfo: { + name: 'agent_bundle', + title: 'Agent Bundle', + version: '0.1.0', + }, + }); + socket.send('{"method":"initialized"}'); + return await action(request); + } finally { + await new Promise((resolvePromise) => { + const timeout = setTimeout(() => { + socket.terminate(); + resolvePromise(); + }, 1_000); + socket.once('close', () => { + clearTimeout(timeout); + resolvePromise(); + }); + socket.close(); + }); + } +}; diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 550dac1d3..78e90c1f1 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -23,6 +23,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { defaultCommandRunner, installBundle as defaultInstallBundle, + publicHostRoot, type InstallBundleOptions, type InstallCommandRunner, type InstallHost, @@ -32,6 +33,7 @@ import { uninstallBundle as defaultUninstallBundle, type UninstallBundleOptions, } from '../install/uninstall.ts'; +import { withCodexAppServer } from './codex-app-server.ts'; import { devProxyServerCommand } from './dev-proxy-command.ts'; import { subscribeToEpochAdoption, @@ -63,6 +65,7 @@ export interface DevHostInstallManagerOptions { interface InstalledDevHost { readonly destination: string; readonly host: InstallHost; + readonly plugin?: string; epochId: string; } @@ -130,7 +133,11 @@ const prepareDevBundle = async ( epochId: string, projectRoot: string, run: PlatformRun, -): Promise Promise; readonly root: string }>> => { +): Promise Promise; + readonly marketplaceDocument?: string; + readonly root: string; +}>> => { const parent = await mkdtemp(join(tmpdir(), `agent-bundle-dev-${host}-`)); const root = join(parent, 'bundle'); try { @@ -139,9 +146,10 @@ const prepareDevBundle = async ( if (manifestRead.status !== 'ok') { throw new Error(`Development install requires a valid artifact manifest at ${manifestRead.path}.`); } - const mcpDocument = manifestRead.manifest.projections.find( + const projection = manifestRead.manifest.projections.find( (projection) => projection.builtInHost === host, - )?.documents.mcp; + ); + const mcpDocument = projection?.documents.mcp; if (mcpDocument !== undefined) { await rewriteMcpDocument(root, mcpDocument, host, projectRoot, run); } @@ -155,6 +163,9 @@ const prepareDevBundle = async ( }); return Object.freeze({ cleanup: () => rm(parent, { force: true, recursive: true }), + ...(projection?.documents.marketplace === undefined + ? {} + : { marketplaceDocument: projection.documents.marketplace }), root, }); } catch (error) { @@ -474,8 +485,10 @@ export class DevHostInstallManager { try { const source = host === 'cursor' ? prepared.root : stableDevBundle(this.#projectRoot, host); let installed = this.#installed.get(host); + if (host !== 'cursor' && (installed === undefined || host === 'codex')) { + await ensureStableDevBundle(prepared.root, source); + } if (installed === undefined) { - if (host !== 'cursor') await ensureStableDevBundle(prepared.root, source); const result = await this.#installBundle({ commandRunner: this.#commandRunner, environment: this.#environment, @@ -489,12 +502,35 @@ export class DevHostInstallManager { destination: installedDestination(result, this.#home, this.#environment), epochId: '', host, + ...(host === 'codex' ? { plugin: result.plugin } : {}), }; this.#installed.set(host, installed); } const previousEpochId = installed.epochId; + let generationPublished = false; try { - await publishDevGeneration(installed.destination, prepared.root, epochId); + let refreshedByAppServer = false; + if (host === 'codex') { + refreshedByAppServer = await withCodexAppServer( + publicHostRoot('codex', this.#environment, this.#home ?? homedir()), + async (request) => { + const plugin = installed.plugin; + const marketplaceDocument = prepared.marketplaceDocument; + if (plugin === undefined || marketplaceDocument === undefined) { + throw new TypeError('Cannot refresh a Codex development install with no plugin marketplace identity.'); + } + await request('plugin/install', { + marketplacePath: join(source, marketplaceDocument), + pluginName: plugin, + }); + return true; + }, + ) === true; + } + if (!refreshedByAppServer) { + await publishDevGeneration(installed.destination, prepared.root, epochId); + generationPublished = true; + } } catch (error) { if (previousEpochId.length > 0 && await pathExists(generationRoot(installed.destination, previousEpochId))) { await publishInstalledGeneration(installed.destination, previousEpochId); @@ -503,10 +539,12 @@ export class DevHostInstallManager { throw error; } installed.epochId = epochId; - await pruneGenerations( - installed.destination, - previousEpochId.length === 0 ? [epochId] : [previousEpochId, epochId], - ); + if (generationPublished) { + await pruneGenerations( + installed.destination, + previousEpochId.length === 0 ? [epochId] : [previousEpochId, epochId], + ); + } } finally { await prepared.cleanup(); } diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 919d86a78..5cf928cc3 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -1,9 +1,12 @@ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterAll, afterEach, beforeAll, expect, it } from '@rstest/core'; +import { WebSocketServer } from 'ws'; import { devProxyServerCommand } from '../src/dev/dev-proxy-command.ts'; import { @@ -100,6 +103,61 @@ const writeEpoch = async ( return root; }; +const writeCodexEpoch = async ( + projectRoot: string, + id: string, + hookStatus: string, + mcpServer: string, +): Promise => { + const root = join(projectRoot, '.agent-bundle', 'epochs', id); + await Promise.all([ + mkdir(join(root, '.agents', 'plugins'), { recursive: true }), + mkdir(join(root, '.codex-plugin'), { recursive: true }), + mkdir(join(root, 'hooks'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'manifest.json'), '{}\n'), + writeFile( + join(root, '.agents', 'plugins', 'marketplace.json'), + '{"name":"dev-proof-marketplace","plugins":[]}\n', + ), + writeFile(join(root, '.codex-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), + writeFile( + join(root, '.codex-plugin', 'hooks.json'), + `${JSON.stringify({ + hooks: { + SessionStart: [{ + hooks: [{ + command: 'node ./hooks/session-start.codex.mjs', + statusMessage: hookStatus, + type: 'command', + }], + }], + }, + })}\n`, + ), + writeFile( + join(root, '.codex-plugin', 'mcp.json'), + `${JSON.stringify({ + mcpServers: { + [mcpServer]: { + args: ['./mcp/probe.mjs'], + command: 'node', + type: 'stdio', + }, + }, + })}\n`, + ), + writeFile(join(root, 'hooks', 'session-start.codex.mjs'), `export default ${JSON.stringify(hookStatus)};\n`), + ]); + await writeInstallFixtureManifest( + root, + { name: 'dev-proof', version: '1.0.0' }, + [{ host: 'codex', mcp: '.codex-plugin/mcp.json' }], + ); + return root; +}; + const activePayload = (value: ArtifactEpoch) => Object.freeze({ activeEpoch: value, currentSourceRevision: value.projectRevision, @@ -159,6 +217,7 @@ it('installs a marked public-host dev variant from a stable source and removes i bundleRoot: options.from, destination, host: 'codex', + marketplace: 'dev-proof-marketplace', plugin: 'dev-proof', state: 'installed', version: '1.0.0', @@ -310,6 +369,150 @@ it('installs a marked public-host dev variant from a stable source and removes i expect(uninstalls).toHaveLength(2); }); +it('refreshes a persistent Codex component snapshot before attaching each epoch', async () => { + const root = await createRoot(); + const codexRoot = await mkdtemp(join(tmpdir(), 'codex-')); + roots.push(codexRoot); + const projectRoot = join(root, 'project'); + const destination = join(codexRoot, 'plugins', 'cache', 'dev-proof-marketplace', 'dev-proof', '1.0.0'); + const firstEpochRoot = await writeCodexEpoch(projectRoot, 'epoch-1', 'epoch one', 'probe-v1'); + const secondEpochRoot = await writeCodexEpoch(projectRoot, 'epoch-2', 'epoch two', 'probe-v2'); + const rootsByEpoch = new Map([ + ['epoch-1', firstEpochRoot], + ['epoch-2', secondEpochRoot], + ]); + const socketPath = join(codexRoot, 'app-server-control', 'app-server-control.sock'); + const pluginId = 'dev-proof@dev-proof-marketplace'; + const appServer = new Map(); + const refreshSources: string[] = []; + await mkdir(dirname(socketPath), { recursive: true }); + const httpServer = createServer(); + const webSocketServer = new WebSocketServer({ server: httpServer }); + webSocketServer.on('connection', (socket) => { + socket.on('message', (data) => { + let request: { + readonly id?: number; + readonly method: string; + readonly params?: Readonly>; + }; + try { + request = JSON.parse(data.toString()) as typeof request; + } catch { + return; + } + void (async () => { + if (request.id === undefined) return; + if (request.method === 'initialize') { + socket.send(JSON.stringify({ id: request.id, result: { codexHome: codexRoot } })); + return; + } + if (request.method !== 'plugin/install') { + socket.send(JSON.stringify({ error: { code: -32601, message: 'unknown method' }, id: request.id })); + return; + } + const marketplacePath = request.params?.marketplacePath; + const pluginName = request.params?.pluginName; + if (typeof marketplacePath !== 'string' || typeof pluginName !== 'string') { + throw new TypeError('Fake Codex app-server received invalid plugin/install parameters.'); + } + socket.send(JSON.stringify({ id: request.id, method: 'fake/request', params: {} })); + await new Promise((resolvePromise) => { + setTimeout(resolvePromise, 50); + }); + const source = dirname(dirname(dirname(marketplacePath))); + const marketplaceDocument = JSON.parse(await readFile(marketplacePath, 'utf8')) as { readonly name: string }; + const hooks = JSON.parse(await readFile(join(source, '.codex-plugin', 'hooks.json'), 'utf8')) as { + readonly hooks: { readonly SessionStart: readonly [{ readonly hooks: readonly [{ readonly statusMessage: string }] }] }; + }; + const mcp = JSON.parse(await readFile(join(source, '.codex-plugin', 'mcp.json'), 'utf8')) as { + readonly mcpServers: Readonly>; + }; + await rm(destination, { force: true, recursive: true }); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { recursive: true }); + appServer.set(`${pluginName}@${marketplaceDocument.name}`, { + hookHash: createHash('sha256').update(JSON.stringify(hooks)).digest('hex'), + hookStatus: hooks.hooks.SessionStart[0].hooks[0].statusMessage, + mcpServers: Object.keys(mcp.mcpServers).sort(), + }); + refreshSources.push(source); + socket.send(JSON.stringify({ id: request.id, result: { appsNeedingAuth: [], authPolicy: 'ON_INSTALL' } })); + })().catch((error: unknown) => { + socket.send(JSON.stringify({ + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + id: request.id, + })); + }); + }); + }); + await new Promise((resolvePromise, rejectPromise) => { + httpServer.once('error', rejectPromise); + httpServer.listen(socketPath, () => { + httpServer.off('error', rejectPromise); + resolvePromise(); + }); + }); + const eventHub = new ProjectEventHub(); + const syncEvents: unknown[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.host.sync') syncEvents.push(event.payload); + }); + const manager = new DevHostInstallManager({ + environment: { ...process.env, CODEX_HOME: codexRoot }, + epochStore: { + acquireEpochReference: async (epochId) => ({ + close: async () => undefined, + epoch: epoch(projectRoot, epochId), + root: rootsByEpoch.get(epochId)!, + }), + }, + eventHub, + hosts: ['codex'], + installBundle: async (options) => ({ + bundleRoot: options.from, + destination, + host: 'codex', + marketplace: 'dev-proof-marketplace', + plugin: 'dev-proof', + state: 'already-installed', + version: '1.0.0', + }), + projectRoot, + uninstallBundle: async () => undefined, + }); + + try { + manager.sync('epoch-1'); + await manager.settled(); + expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-1', state: 'succeeded' }); + expect([...appServer.keys()]).toEqual([pluginId]); + const first = appServer.get(pluginId); + expect(first).toMatchObject({ hookStatus: 'epoch one', mcpServers: ['probe-v1'] }); + + manager.sync('epoch-2'); + await manager.settled(); + expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-2', state: 'succeeded' }); + const second = appServer.get(pluginId); + expect(second).toMatchObject({ hookStatus: 'epoch two', mcpServers: ['probe-v2'] }); + expect(second?.hookHash).not.toBe(first?.hookHash); + expect([...appServer.keys()]).toEqual([pluginId]); + expect(refreshSources).toEqual([ + join(projectRoot, '.agent-bundle', 'dev', 'codex'), + join(projectRoot, '.agent-bundle', 'dev', 'codex'), + ]); + expect(JSON.parse(await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'))) + .toMatchObject({ epochId: 'epoch-2' }); + } finally { + await manager.close(); + await new Promise((resolvePromise) => webSocketServer.close(() => resolvePromise())); + await new Promise((resolvePromise) => httpServer.close(() => resolvePromise())); + } +}); + it('publishes a diagnostic event and preserves the installed generation when re-sync fails', async () => { const root = await createRoot(); const home = join(root, 'home'); diff --git a/packages/agent-bundle/tests/native-codex-app-server.test.ts b/packages/agent-bundle/tests/native-codex-app-server.test.ts new file mode 100644 index 000000000..772a4fa16 --- /dev/null +++ b/packages/agent-bundle/tests/native-codex-app-server.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { + buildHostInstallFixture, + disposeHostInstallFixture, + runDevLiveHostProof, + type BuiltHostInstallFixture, +} from './support/host-install.ts'; + +const enabled = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' + && spawnSync('codex', ['--version'], { stdio: 'ignore', timeout: 5_000, windowsHide: true }).status === 0; +const nativeIt = enabled ? it : it.skip; +let fixture: BuiltHostInstallFixture | undefined; + +beforeAll(async () => { + if (!enabled) return; + fixture = await buildHostInstallFixture({ environment: process.env }); +}, 180_000); + +afterAll(async () => { + if (fixture !== undefined) await disposeHostInstallFixture(fixture); +}); + +nativeIt( + enabled + ? 'refreshes MCP and hook components in a Codex app-server started before the development install' + : 'refreshes a pre-existing Codex app-server [missing native Codex contract prerequisites]', + async () => { + if (fixture === undefined) throw new Error('The native Codex app-server fixture was not built.'); + const report = await runDevLiveHostProof(fixture, 'codex', { + environment: process.env, + persistentCodexAppServer: true, + }); + + expect(report.codexAppServer).toMatchObject({ + mcpServers: ['probe'], + registrationCount: 1, + startedBeforeInstall: true, + }); + expect(report.codexAppServer?.hookDocumentHashes[1]) + .not.toBe(report.codexAppServer?.hookDocumentHashes[0]); + }, + 240_000, +); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index e26fe4bf0..2dd2015b8 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1,9 +1,9 @@ -import { execFile as executeFile } from 'node:child_process'; +import { execFile as executeFile, spawn } from 'node:child_process'; import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; @@ -36,11 +36,12 @@ import { } from '../../src/test/contract.ts'; import { openInstalledHostMcpServer } from '../../src/test/installed.ts'; import { DEV_INSTALL_MARKER, DevHostInstallManager } from '../../src/dev/host-install-manager.ts'; +import { withCodexAppServer } from '../../src/dev/codex-app-server.ts'; import { ProjectEventHub } from '../../src/dev/events.ts'; import type { ArtifactEpoch } from '../../src/dev/types.ts'; import { startDevServer } from '../../src/dev/workbench-server.ts'; import { runDoctor, type DoctorCommandRunner } from '../../src/install/doctor.ts'; -import { installBundle, type InstallHost } from '../../src/install/install.ts'; +import { installBundle, publicHostRoot, type InstallHost } from '../../src/install/install.ts'; import { manifestInventory, readInstallReceipt } from '../../src/install/receipt.ts'; import { uninstallBundle } from '../../src/install/uninstall.ts'; import { @@ -687,7 +688,7 @@ export const disposeHostInstallFixture = async (fixture: BuiltFixtureProject): P await rm(fixture.root, { force: true, recursive: true }); }; -/** Proves initial host-owned installation followed by direct cache re-sync without another host CLI call. */ +/** Proves initial host-owned installation followed by the host-specific development re-sync. */ export const runDevHostInstallProof = async ( fixture: BuiltHostInstallFixture, host: InstallHost, @@ -1853,6 +1854,12 @@ export const runPortableHostInstallProof = async ( }; export interface DevLiveHostProofReport { + readonly codexAppServer?: { + readonly hookDocumentHashes: readonly [string, string]; + readonly mcpServers: readonly ['probe']; + readonly registrationCount: 1; + readonly startedBeforeInstall: true; + }; readonly connection: { readonly initialized: 1; readonly observations: readonly [string, string]; @@ -2060,13 +2067,104 @@ const withProcessEnvironment = async ( } }; +interface CodexAppServerClient { + close(): Promise; + request(method: string, params: Readonly>): Promise; +} + +const startCodexAppServer = async ( + cwd: string, + environment: NodeJS.ProcessEnv, +): Promise => { + const codexRoot = publicHostRoot('codex', environment, environment.HOME ?? homedir()); + const child = spawn(environment.AGENT_BUNDLE_REAL_HOST_BINARY ?? 'codex', ['app-server', '--listen', 'unix://'], { + cwd, + env: environment, + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr = `${stderr}${chunk}`.slice(-8_192); + }); + const exited = new Promise((resolvePromise) => { + child.once('exit', () => resolvePromise()); + }); + try { + await waitFor( + async () => access(join(codexRoot, 'app-server-control', 'app-server-control.sock')).then( + () => true, + () => false, + ), + 'Codex app-server control socket did not start.', + 10_000, + ); + } catch (error) { + if (child.exitCode === null) child.kill('SIGTERM'); + await exited; + throw new Error(`Codex app-server control socket did not start: ${stderr.trim()}`, { cause: error }); + } + return Object.freeze({ + close: async () => { + if (child.exitCode !== null) return; + child.kill('SIGTERM'); + await exited; + }, + request: async (method: string, params: Readonly>) => { + const result = await withCodexAppServer(codexRoot, (request) => request(method, params)); + if (result === undefined) throw new Error('Codex app-server control socket disappeared.'); + return result; + }, + }); +}; + +interface CodexAppServerComponents { + readonly hookDocumentHash: string; + readonly mcpServers: readonly ['probe']; +} + +const readCodexAppServerComponents = async ( + appServer: CodexAppServerClient, + cwd: string, + installedRoot: string, +): Promise => { + const stableRoot = join(cwd, '.agent-bundle', 'dev', 'codex'); + const marketplacePath = join(stableRoot, codexArtifactPaths.marketplace); + const pluginReadResult = await appServer.request('plugin/read', { marketplacePath, pluginName: plugin }); + const pluginDocument = record(record(pluginReadResult)?.plugin); + const hooks = pluginDocument?.hooks; + const servers = pluginDocument?.mcpServers; + assertProof( + Array.isArray(hooks) && hooks.length > 0, + `Codex app-server did not load the development hook: ${JSON.stringify(pluginReadResult)}`, + ); + assertProof( + Array.isArray(servers) && servers.length === 1 && servers[0] === 'probe', + `Codex app-server did not load the development MCP component: ${JSON.stringify(pluginReadResult)}`, + ); + return Object.freeze({ + hookDocumentHash: createHash('sha256') + .update(await readFile(join(installedRoot, codexArtifactPaths.hooksManifest))) + .digest('hex'), + mcpServers: Object.freeze(['probe'] as const), + }); +}; + const runLiveHostScenario = async ( fixture: BuiltHostInstallFixture, host: InstallHost, - options: { readonly environment: Readonly }, + options: { + readonly environment: Readonly; + readonly persistentCodexAppServer?: boolean; + }, observe?: (context: LiveHostObservationContext) => Promise, ): Promise => { - const scenarioRoot = await mkdtemp(join(tmpdir(), `agent-bundle-dev-live-${host}-`)); + const scenarioRoot = await mkdtemp( + host === 'codex' && options.persistentCodexAppServer === true + ? join(tmpdir(), 'codex-live-') + : join(tmpdir(), `agent-bundle-dev-live-${host}-`), + ); const projectRoot = dirname(fixture.artifactRoot); const roots = Object.freeze({ claudeConfig: join(scenarioRoot, 'claude'), @@ -2078,6 +2176,9 @@ const runLiveHostScenario = async ( mkdir(roots.codexHome, { recursive: true }), mkdir(join(roots.home, '.cursor'), { recursive: true }), ]); + if (host === 'codex' && options.persistentCodexAppServer === true) { + await writeFile(join(roots.codexHome, 'config.toml'), '[features]\nplugins = true\nhooks = true\n'); + } let environment = isolatedEnvironment(options.environment, { CLAUDE_CONFIG_DIR: roots.claudeConfig, CODEX_HOME: roots.codexHome, @@ -2091,6 +2192,16 @@ const runLiveHostScenario = async ( commandLog = recorded.log; hostBinaryVersion = recorded.version; } + const configSource = join(projectRoot, 'agent-bundle.config.ts'); + let rebuiltConfig: string | undefined; + if (host === 'codex' && options.persistentCodexAppServer === true) { + const initialConfig = (await readFile(configSource, 'utf8')).replace( + "sessionStart: { handler: './src/hooks/session-start.ts' },", + "sessionStart: { handler: './src/hooks/session-start.ts', timeout: 1 },", + ); + await writeFile(configSource, initialConfig); + rebuiltConfig = initialConfig.replace('timeout: 1', 'timeout: 2'); + } const mcpSource = join(projectRoot, 'src', 'mcp', 'probe.ts'); const skillSource = join(projectRoot, 'src', 'skills', 'probe', 'SKILL.md'); const hookSource = join(projectRoot, 'src', 'hooks', 'session-start.ts'); @@ -2100,10 +2211,18 @@ const runLiveHostScenario = async ( writeFile(hookSource, liveHookSource('v1')), ]); const destination = liveHostDestination(host, roots); + let appServer: CodexAppServerClient | undefined; + let firstAppServerComponents: CodexAppServerComponents | undefined; + let secondAppServerComponents: CodexAppServerComponents | undefined; + let codexRegistrationCount: number | undefined; let client: Client | undefined; let server: Awaited> | undefined; try { return await withProcessEnvironment(environment, async () => { + if (host === 'codex' && options.persistentCodexAppServer === true) { + appServer = await startCodexAppServer(projectRoot, environment); + await appServer.request('hooks/list', { cwds: [projectRoot] }); + } server = await startDevServer({ installHosts: [host], open: false, port: 0, root: projectRoot }); const markerBefore = parseJson<{ readonly epochId: string }>( await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'), @@ -2138,12 +2257,18 @@ const runLiveHostScenario = async ( assertProof(listed.tools.some((tool) => tool.name === 'echo'), `${host} live proxy did not list echo.`); const first = textToolResult(await client.callTool({ arguments: { message: host }, name: 'echo' })); assertProof(first === `v1:${host}`, `${host} live proxy did not observe v1.`); + if (appServer !== undefined) { + firstAppServerComponents = await readCodexAppServerComponents(appServer, projectRoot, destination); + } await observe?.({ environment, installedRoot: destination, version: 'v1' }); const installCommandsBeforeRebuild = await hostCliInstallCommandCount(commandLog); await Promise.all([ replaceWatchedSource(projectRoot, mcpSource, liveMcpSource('v2')), replaceWatchedSource(projectRoot, skillSource, liveSkillSource('v2')), replaceWatchedSource(projectRoot, hookSource, liveHookSource('v2')), + ...(rebuiltConfig === undefined + ? [] + : [replaceWatchedSource(projectRoot, configSource, rebuiltConfig)]), ]); await Promise.race([ changed.promise, @@ -2164,12 +2289,39 @@ const runLiveHostScenario = async ( async () => (await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).includes('proof v2'), `${host} installed skill did not re-sync to v2.`, ); - const hookName = (await readdir(join(destination, 'hooks'))).find((name) => name.endsWith('.mjs')); - assertProof(hookName !== undefined, `${host} installed hooks contained no executable module.`); + let hookName: string | undefined; + await waitFor(async () => { + try { + hookName = (await readdir(join(destination, 'hooks'))).find((name) => name.endsWith('.mjs')); + return hookName !== undefined; + } catch { + return false; + } + }, `${host} installed hooks contained no executable module.`); + const installedHookPath = hookName === undefined + ? fail(`${host} installed hooks contained no executable module.`) + : join(destination, 'hooks', hookName); await waitFor( - async () => (await readFile(join(destination, 'hooks', hookName), 'utf8')).includes('proof v2'), + async () => (await readFile(installedHookPath, 'utf8')).includes('proof v2'), `${host} installed hook did not re-sync to v2.`, ); + if (appServer !== undefined) { + secondAppServerComponents = await readCodexAppServerComponents(appServer, projectRoot, destination); + if (secondAppServerComponents.hookDocumentHash === firstAppServerComponents?.hookDocumentHash) { + throw new Error('Codex hook document digest did not change after rebuild.'); + } + const listed = await run( + environment.AGENT_BUNDLE_REAL_HOST_BINARY ?? 'codex', + ['plugin', 'list', '--json'], + { cwd: projectRoot, environment }, + ); + assertProof(listed.exitCode === 0, `Codex plugin listing failed after rebuild: ${commandDetail(listed)}`); + const installed = record(parseJson(listed.stdout, 'Codex plugin listing'))?.installed; + codexRegistrationCount = Array.isArray(installed) + ? installed.filter((row) => record(row)?.pluginId === `${plugin}@${marketplace}`).length + : 0; + assertProof(codexRegistrationCount === 1, `Codex retained ${String(codexRegistrationCount)} development registrations.`); + } const installCommandsAfterRebuild = await hostCliInstallCommandCount(commandLog); assertProof( installCommandsAfterRebuild === installCommandsBeforeRebuild, @@ -2182,6 +2334,21 @@ const runLiveHostScenario = async ( ? 'unavailable: Codex exec authenticates non-interactively but exposes no inline plugin loader for the isolated dev install; exact installed proxy observed v1→v2 on one connection' : 'host-owned installation and exact installed proxy observed v1→v2 on one connection'; const report: DevLiveHostProofReport = Object.freeze({ + ...(firstAppServerComponents === undefined || secondAppServerComponents === undefined + ? {} + : { + codexAppServer: Object.freeze({ + hookDocumentHashes: Object.freeze([ + firstAppServerComponents.hookDocumentHash, + secondAppServerComponents.hookDocumentHash, + ] as const), + mcpServers: secondAppServerComponents.mcpServers, + registrationCount: codexRegistrationCount === 1 + ? 1 + : fail('Codex app-server proof did not retain one registration.'), + startedBeforeInstall: true, + }), + }), connection: Object.freeze({ initialized: 1, observations: Object.freeze([first, second] as const), @@ -2209,6 +2376,7 @@ const runLiveHostScenario = async ( } finally { await client?.close().catch(() => undefined); await server?.close().catch(() => undefined); + await appServer?.close().catch(() => undefined); await rm(scenarioRoot, { force: true, recursive: true }); } }; @@ -2216,7 +2384,10 @@ const runLiveHostScenario = async ( export const runDevLiveHostProof = async ( fixture: BuiltHostInstallFixture, host: InstallHost, - options: { readonly environment: Readonly }, + options: { + readonly environment: Readonly; + readonly persistentCodexAppServer?: boolean; + }, ): Promise => (await runLiveHostScenario(fixture, host, options)).report; export const runClaudeLiveDevSessionProof = async ( diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index ee34df486..2b22d2756 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -134,6 +134,7 @@ export const integrationTestFiles: readonly string[] = [ */ export const nativeHostTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-adapters.native.test.ts', + 'packages/agent-bundle/tests/native-codex-app-server.test.ts', 'packages/agent-bundle/tests/native-host-sessions.test.ts', ]; diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index b0e830582..0c57e4dba 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -470,14 +470,21 @@ agent-bundle dev proxy --root --server --target --server --target Date: Mon, 7 Sep 2026 00:12:36 +0000 Subject: [PATCH 2/3] docs: reference PR in changeset --- .changeset/refresh-codex-dev-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/refresh-codex-dev-components.md b/.changeset/refresh-codex-dev-components.md index b24603647..cca3f7201 100644 --- a/.changeset/refresh-codex-dev-components.md +++ b/.changeset/refresh-codex-dev-components.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Refresh a running Codex app-server's MCP and hook components before `agent-bundle dev --install-host codex` reports the epoch attached, with failed refreshes reported as `AB7202`. (#716) +Refresh a running Codex app-server's MCP and hook components before `agent-bundle dev --install-host codex` reports the epoch attached, with failed refreshes reported as `AB7202`. (#722) From ec419a69c8fa7c2e896ad28392197fc2f4e64f33 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 00:20:37 +0000 Subject: [PATCH 3/3] refactor(dev): reuse path existence helper --- packages/agent-bundle/src/dev/codex-app-server.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/dev/codex-app-server.ts b/packages/agent-bundle/src/dev/codex-app-server.ts index 52a47582c..4dee951aa 100644 --- a/packages/agent-bundle/src/dev/codex-app-server.ts +++ b/packages/agent-bundle/src/dev/codex-app-server.ts @@ -1,10 +1,10 @@ -import { stat } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { join } from 'node:path'; import WebSocket from 'ws'; import { isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; const requestTimeoutMs = 30_000; @@ -20,12 +20,7 @@ export const withCodexAppServer = async ( action: (request: CodexAppServerRequest) => Promise, ): Promise => { const socketPath = join(codexRoot, 'app-server-control', 'app-server-control.sock'); - try { - await stat(socketPath); - } catch (error) { - if (isErrno(error, 'ENOENT')) return undefined; - throw error; - } + if (!await exists(socketPath)) return undefined; const socket = new WebSocket('ws://localhost/', { createConnection: () => createConnection(socketPath),