Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/release/prepare-npm-meta.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`;
Expand Down
23 changes: 17 additions & 6 deletions scripts/release/prepare-npm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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;
Expand Down
107 changes: 106 additions & 1 deletion tests/release/npm-package.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,6 +32,7 @@ import {
validateNpmPackage,
} from "../../scripts/release/prepare-npm.mjs";
import {
createNpmMetaReadme,
createNpmMetaPackageManifest,
expectedNpmMetaPackagePaths,
validateNpmMetaPackage,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"');
Expand Down
23 changes: 23 additions & 0 deletions tests/release/production-renderer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"),
Expand Down
38 changes: 25 additions & 13 deletions tools/renderer-binding/run.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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) ||
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
Loading