diff --git a/scripts/release/prepare-npm-meta.mjs b/scripts/release/prepare-npm-meta.mjs index 3bb4cda8..fae26d01 100644 --- a/scripts/release/prepare-npm-meta.mjs +++ b/scripts/release/prepare-npm-meta.mjs @@ -60,7 +60,7 @@ codexhost --version codexhost \`\`\` -The \`codexhost\` command starts Codex Desktop and returns immediately: the packaged Launcher keeps supervising in the background and exits after you quit the Desktop. Re-running \`codexhost\` attaches to the same controlled instance. +The \`codexhost\` command starts Codex Desktop. On macOS and Linux it returns immediately while the packaged Launcher keeps supervising in the background. On Windows, the command remains attached until Codex Desktop exits so shells that clean up process trees of completed commands cannot discard the supervisor. Re-running \`codexhost\` attaches to the same controlled instance. If installation used \`--omit=optional\`, reinstall without that option so npm can select the native package for the current architecture. `; diff --git a/scripts/release/prepare-npm.mjs b/scripts/release/prepare-npm.mjs index 26c18818..78cd005a 100644 --- a/scripts/release/prepare-npm.mjs +++ b/scripts/release/prepare-npm.mjs @@ -507,9 +507,12 @@ if (remoteArguments !== null) { }); } else if (launchArguments?.[0] === "launch") { // The Launcher prints "ready" once the Desktop, Controller, and Host chain - // are up, then detaches from the terminal to keep supervising. Return - // success immediately so the terminal is not held open by this command. + // are up, then detaches from the terminal to keep supervising. Windows + // command hosts may clean up a completed command's process tree, so keep the + // npm parent alive there until the managed Desktop exits. Other platforms + // return immediately after startup as before. startupTrace("spawning Launcher"); + const keepLauncherForeground = process.platform === "win32"; const child = spawn(launcher, launchArguments, { env: updateEnvironment, stdio: ["ignore", "pipe", "inherit"], @@ -523,20 +526,28 @@ if (remoteArguments !== null) { process.exit(code); }; child.stdout.setEncoding("utf8"); + let ready = false; let launcherOutput = ""; + const readyMarker = "ready\\n"; child.stdout.on("data", (chunk) => { - launcherOutput += chunk; - if (launcherOutput.includes("ready\\n")) { + if (ready) return; + const output = launcherOutput + chunk; + if (output.includes(readyMarker)) { + ready = true; + launcherOutput = ""; startupTrace("received Launcher ready"); - finish(0); + if (!keepLauncherForeground) finish(0); + return; } + // Only retain the tail needed to recognize a marker split across chunks. + launcherOutput = output.slice(1 - readyMarker.length); }); child.on("error", (error) => { startupTrace("Launcher spawn failed: " + error.message); fail(error.message); }); child.on("exit", (code, signal) => { - startupTrace("Launcher exited before ready"); + startupTrace(ready ? "Launcher exited after ready" : "Launcher exited before ready"); if (signal) { process.kill(process.pid, signal); return; diff --git a/tests/release/npm-package.test.mjs b/tests/release/npm-package.test.mjs index efaaa1e6..d2779ffa 100644 --- a/tests/release/npm-package.test.mjs +++ b/tests/release/npm-package.test.mjs @@ -12,6 +12,7 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; @@ -31,6 +32,7 @@ import { validateNpmPackage, } from "../../scripts/release/prepare-npm.mjs"; import { + createNpmMetaReadme, createNpmMetaPackageManifest, expectedNpmMetaPackagePaths, validateNpmMetaPackage, @@ -165,6 +167,88 @@ async function createNpmMetaPackageFixture(root) { } } +async function createLauncherLifecycleFixture(root, platform) { + const launcherPath = path.join(root, "node_modules", "@codexhost", "cli", "bin", "codexhost.js"); + const platformPackage = `@codexhost/cli-${platform}-x64`; + const platformRoot = path.join(root, "node_modules", ...platformPackage.split("/")); + const executableSuffix = platform === "win32" ? ".exe" : ""; + const npmCliPath = path.join(root, "npm-cli.js"); + const preloadPath = path.join(root, "launcher-child-preload.mjs"); + + await writeExecutable(launcherPath, createNpmBinLauncherSource({ version: "0.1.0" })); + await mkdir(platformRoot, { recursive: true }); + await writeFile( + path.join(platformRoot, "package.json"), + `${JSON.stringify({ name: platformPackage, version: "0.1.0" })}\n`, + ); + for (const relative of [ + path.join("bin", `codexhost${executableSuffix}`), + path.join("libexec", `codexhost-shim${executableSuffix}`), + path.join("app", "host-runtime.mjs"), + path.join("app", "desktop-controller.mjs"), + path.join("app", "renderer-extension.js"), + ]) { + await writeExecutable(path.join(platformRoot, relative), `fixture:${relative}\n`); + } + await writeFile(npmCliPath, "// fixture npm CLI\n"); + await writeFile( + preloadPath, + `import childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { syncBuiltinESMExports } from "node:module"; +import { PassThrough } from "node:stream"; + +Object.defineProperty(process, "platform", { + configurable: true, + value: process.env.CODEXHOST_TEST_PLATFORM, +}); +Object.defineProperty(process, "arch", { + configurable: true, + value: "x64", +}); + +childProcess.spawn = () => { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + setTimeout(() => child.stdout.write("startup:" + "x".repeat(1024 * 1024) + "rea"), 10); + setTimeout(() => child.stdout.write("dy\\n"), 20); + setTimeout(() => child.emit("exit", 7, null), 80); + return child; +}; +syncBuiltinESMExports(); +`, + ); + + return { launcherPath, npmCliPath, preloadPath }; +} + +async function runLauncherLifecycle(platform) { + const root = await temporaryDirectory(); + try { + const { launcherPath, npmCliPath, preloadPath } = await createLauncherLifecycleFixture( + root, + platform, + ); + return spawnSync( + process.execPath, + ["--import", pathToFileURL(preloadPath).href, launcherPath], + { + encoding: "utf8", + env: { + ...process.env, + CODEXHOST_STARTUP_TRACE: "1", + CODEXHOST_TEST_PLATFORM: platform, + npm_execpath: npmCliPath, + }, + timeout: 2_000, + windowsHide: true, + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + describe("npm package release", () => { it("maps the current host to a release target id", () => { expect(hostReleaseTargetId("darwin", "arm64")).toBe("macos-arm64"); @@ -282,11 +366,32 @@ describe("npm package release", () => { expect(source).toContain('"--codexhost-remote"'); expect(source).toContain('"--host-runtime", hostRuntime'); expect(source).toContain('stdio: ["ignore", "pipe", "inherit"]'); - expect(source).toContain('launcherOutput.includes("ready\\n")'); + expect(source).toContain('const readyMarker = "ready\\n"'); expect(source).toContain("path.dirname(path.dirname(path.resolve(process.argv[1])))"); expect(source).not.toContain("runtime/node"); }); + it("keeps Windows launcher supervision alive after the ready handshake", async () => { + const result = await runLauncherLifecycle("win32"); + const readme = createNpmMetaReadme({ version: "0.1.0" }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(7); + expect(result.stderr).toContain("received Launcher ready"); + expect(result.stderr).toContain("Launcher exited after ready"); + expect(readme).toContain("On Windows, the command remains attached until Codex Desktop exits"); + expect(readme).toContain("process trees of completed commands"); + }); + + it.each(["darwin", "linux"])("returns after the ready handshake on %s", async (platform) => { + const result = await runLauncherLifecycle(platform); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("received Launcher ready"); + expect(result.stderr).not.toContain("Launcher exited after ready"); + }); + it("does not forward remote SSH bootstrap variables into a local Desktop launch", () => { const source = createNpmBinLauncherSource({ version: "0.1.0" }); expect(source).toContain('import { homedir } from "node:os"'); diff --git a/tests/release/production-renderer.test.mjs b/tests/release/production-renderer.test.mjs index 1d9de230..e115525e 100644 --- a/tests/release/production-renderer.test.mjs +++ b/tests/release/production-renderer.test.mjs @@ -3,6 +3,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { RENDERER_PROBE_AGENTS, validateProbeStatus } from "../../tools/renderer-binding/run.mjs"; + const root = path.resolve(import.meta.dirname, "../.."); async function source(relative) { @@ -29,6 +31,27 @@ describe("production Renderer release chain", () => { expect(installer).toContain("installCurrentRendererAdapter"); }); + it("accepts Grok in renderer probe capabilities and selections", () => { + const status = validateProbeStatus({ + version: 2, + mountedComposers: 1, + enabledAgents: [...RENDERER_PROBE_AGENTS], + selections: [{ composerId: "composer-grok", agent: "grok", phase: "draft" }], + adapter: { state: "ready", reason: "ready", modelUpdates: 0 }, + }); + + expect(RENDERER_PROBE_AGENTS).toContain("grok"); + expect(status.selections).toEqual([ + { composerId: "composer-grok", agent: "grok", phase: "draft" }, + ]); + expect(() => + validateProbeStatus({ + ...status, + selections: [{ composerId: "composer-unknown", agent: "unknown", phase: "draft" }], + }), + ).toThrow("invalid selection"); + }); + it("builds and packages executable production entries", async () => { const [rendererManifest, releaseBuilder] = await Promise.all([ source("packages/renderer-extension/package.json"), diff --git a/tools/renderer-binding/run.mjs b/tools/renderer-binding/run.mjs index f960e8d7..54f77178 100644 --- a/tools/renderer-binding/run.mjs +++ b/tools/renderer-binding/run.mjs @@ -1,6 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { CdpClient, @@ -13,6 +14,13 @@ import { installRendererObserver, readRendererObserver } from "./renderer-observ const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const defaultOutputDirectory = path.join(repositoryRoot, ".codexhost", "renderer-binding"); +export const RENDERER_PROBE_AGENTS = Object.freeze([ + "codex", + "pi", + "claude-code", + "deepseek-harness", + "grok", +]); function usage() { console.error(`usage: @@ -91,16 +99,14 @@ function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function validateProbeStatus(value) { +export function validateProbeStatus(value) { if ( !isRecord(value) || value.version !== 2 || !Number.isInteger(value.mountedComposers) || !Array.isArray(value.enabledAgents) || value.enabledAgents.length < 2 || - value.enabledAgents.some( - (agent) => !["codex", "pi", "claude-code", "deepseek-harness"].includes(agent), - ) || + value.enabledAgents.some((agent) => !RENDERER_PROBE_AGENTS.includes(agent)) || !value.enabledAgents.includes("codex") || !value.enabledAgents.includes("pi") || !Array.isArray(value.selections) || @@ -114,7 +120,7 @@ function validateProbeStatus(value) { if ( !isRecord(selection) || typeof selection.composerId !== "string" || - !["codex", "pi", "claude-code", "deepseek-harness"].includes(selection.agent) || + !RENDERER_PROBE_AGENTS.includes(selection.agent) || !["draft", "locked"].includes(selection.phase) ) { throw new Error("Renderer binding probe returned an invalid selection"); @@ -296,7 +302,7 @@ async function run() { await pageClient.command("Runtime.enable"); const cdpDom = await inspectRendererDom(pageClient); const source = fs.readFileSync(probeBundlePath, "utf8"); - const enabledAgents = ["codex", "pi", "claude-code", "deepseek-harness"]; + const enabledAgents = [...RENDERER_PROBE_AGENTS]; rendererControl = await installRendererControlSession({ inspectorEndpoint: options.inspectorEndpoint, rendererSource: source, @@ -391,11 +397,17 @@ async function run() { } } -try { - await run(); -} catch (error) { - console.error( - `renderer binding probe: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; +const invokedAsScript = + typeof process.argv[1] === "string" && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (invokedAsScript) { + try { + await run(); + } catch (error) { + console.error( + `renderer binding probe: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } }