Skip to content
Draft
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
26 changes: 26 additions & 0 deletions apps/cli/src/__tests__/daemon-start-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ const coreMocks = vi.hoisted(() => ({
createApiNameResolver: vi.fn(),
createExecuteUpdate: vi.fn(),
createLoggerRuntimeOutput: vi.fn(),
createRuntimeInstallRunner: vi.fn(),
daemonRuntimeHomesEqual: vi.fn(),
declineUpdate: vi.fn(),
ensureActiveRootClientIdPersisted: vi.fn(),
ensureFreshAccessToken: vi.fn(),
getClientSwitchStartupBlock: vi.fn(),
getClientServiceStatus: vi.fn(),
handleClientOrgMismatch: vi.fn(),
installClaudeRuntime: vi.fn(),
installCodexRuntime: vi.fn(),
isDaemonRuntimeOwnershipError: vi.fn(),
isServiceSupported: vi.fn(),
listPinnedAgents: vi.fn(),
Expand Down Expand Up @@ -102,8 +105,10 @@ let runtimeInstance: {
watchAgentsDir: ReturnType<typeof vi.fn>;
onReconnect: ReturnType<typeof vi.fn>;
onRuntimeAuthStart: ReturnType<typeof vi.fn>;
onRuntimeInstallStart: ReturnType<typeof vi.fn>;
onProviderModelsList: ReturnType<typeof vi.fn>;
sendProviderModelsResult: ReturnType<typeof vi.fn>;
sendRuntimeInstallResult: ReturnType<typeof vi.fn>;
emitConnectionResilienceEvent: ReturnType<typeof vi.fn>;
};
let refresherInstance: {
Expand Down Expand Up @@ -183,6 +188,9 @@ beforeEach(() => {
coreMocks.registerClientRuntimeMarker.mockReturnValue(vi.fn());
coreMocks.createApiNameResolver.mockReturnValue(async () => "nova");
coreMocks.createExecuteUpdate.mockReturnValue(async () => undefined);
coreMocks.createRuntimeInstallRunner.mockReturnValue({ run: vi.fn(async () => undefined) });
coreMocks.installClaudeRuntime.mockResolvedValue({ ok: true, installedVersion: null });
coreMocks.installCodexRuntime.mockResolvedValue({ ok: true, installedVersion: null });
coreMocks.createLoggerRuntimeOutput.mockImplementation(
(logger: {
error: (message: string) => void;
Expand Down Expand Up @@ -219,8 +227,10 @@ beforeEach(() => {
}),
onReconnect: vi.fn(),
onRuntimeAuthStart: vi.fn(),
onRuntimeInstallStart: vi.fn(),
onProviderModelsList: vi.fn(),
sendProviderModelsResult: vi.fn(),
sendRuntimeInstallResult: vi.fn(),
emitConnectionResilienceEvent: vi.fn(),
};
coreMocks.ClientRuntime.mockImplementation(() => runtimeInstance);
Expand Down Expand Up @@ -585,6 +595,22 @@ describe("daemon start command", () => {
);
expect(coreMocks.CapabilityRefresher.mock.calls[0]?.[0]).not.toHaveProperty("initial");
expect(runtimeInstance.onReconnect).toHaveBeenCalledWith(expect.any(Function));
expect(coreMocks.createRuntimeInstallRunner).toHaveBeenCalledWith(
expect.objectContaining({
installClaude: expect.any(Function),
installCodex: expect.any(Function),
reprobe: expect.any(Function),
send: expect.any(Function),
}),
);
expect(runtimeInstance.onRuntimeInstallStart).toHaveBeenCalledWith(expect.any(Function));
const installRunnerDeps = coreMocks.createRuntimeInstallRunner.mock.calls[0]?.[0] as
| { installClaude: () => Promise<unknown>; installCodex: () => Promise<unknown> }
| undefined;
await installRunnerDeps?.installClaude();
await installRunnerDeps?.installCodex();
expect(coreMocks.installClaudeRuntime).toHaveBeenCalledWith("latest", expect.any(Function));
expect(coreMocks.installCodexRuntime).toHaveBeenCalledWith("latest", expect.any(Function));
expect(runtimeInstance.onProviderModelsList).toHaveBeenCalledWith(expect.any(Function));
expect(refresherInstance.start).toHaveBeenCalled();
expect(coreMocks.listPinnedAgents).toHaveBeenCalledWith({
Expand Down
22 changes: 22 additions & 0 deletions apps/cli/src/__tests__/runtime-install-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,28 @@ describe("native runtime installers", () => {
});
});

it("routes npm stderr to an injected sink without also writing through CLI Print", async () => {
const { installCodexRuntime } = await import("../core/install-codex-runtime.js");
const { installClaudeRuntime } = await import("../core/install-claude-runtime.js");
const stderrSink = vi.fn();

const codexChild = prepareSpawn().child;
const codexResult = installCodexRuntime("latest", stderrSink);
codexChild.stderr.emit("data", Buffer.from("codex registry diagnostic\n"));
codexChild.emit("exit", 1, null);
await codexResult;

const claudeChild = prepareSpawn().child;
const claudeResult = installClaudeRuntime("latest", stderrSink);
claudeChild.stderr.emit("data", Buffer.from("claude registry diagnostic\n"));
claudeChild.emit("exit", 1, null);
await claudeResult;

expect(stderrSink).toHaveBeenNthCalledWith(1, "codex registry diagnostic\n");
expect(stderrSink).toHaveBeenNthCalledWith(2, "claude registry diagnostic\n");
expect(outputMocks.line).not.toHaveBeenCalled();
});

it("maps synchronous registry spawn failures for both runtime installers", async () => {
const spawnError = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" });
clientMocks.getChildProcessRegistry.mockReturnValue({
Expand Down
108 changes: 108 additions & 0 deletions apps/cli/src/__tests__/runtime-install-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { RuntimeInstallResultFrame, RuntimeInstallStartCommand } from "@first-tree/shared";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeInstallRunner } from "../core/runtime-install.js";

const CODEX: RuntimeInstallStartCommand = {
type: "runtime-install:start",
provider: "codex",
ref: "123e4567-e89b-42d3-a456-426614174000",
};

function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}

describe("runtime install runner", () => {
it.each([
["codex", "installCodex"],
["claude-code", "installClaude"],
] as const)("invokes only the fixed %s installer and re-probes after success", async (provider, expectedInstaller) => {
const sent: RuntimeInstallResultFrame[] = [];
const installClaude = vi.fn().mockResolvedValue({ ok: true, installedVersion: "2.1.0" });
const installCodex = vi.fn().mockResolvedValue({ ok: true, installedVersion: "0.140.0" });
const reprobe = vi.fn().mockResolvedValue(undefined);
const runner = createRuntimeInstallRunner({
installClaude,
installCodex,
reprobe,
send: (result) => sent.push(result),
log: vi.fn(),
});

await runner.run({ ...CODEX, provider });

expect(installCodex).toHaveBeenCalledTimes(expectedInstaller === "installCodex" ? 1 : 0);
expect(installClaude).toHaveBeenCalledTimes(expectedInstaller === "installClaude" ? 1 : 0);
expect(reprobe).toHaveBeenCalledWith(provider);
expect(sent.map((result) => result.status)).toEqual(["accepted", "in-progress", "succeeded"]);
});

it("rejects a concurrent duplicate without spawning another install and allows retry after failure", async () => {
const first = deferred<{ ok: false; reason: string; retryable: boolean; reasonCode: string }>();
const sent: RuntimeInstallResultFrame[] = [];
const installCodex = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce({ ok: true, installedVersion: "0.140.0" });
const runner = createRuntimeInstallRunner({
installClaude: vi.fn(),
installCodex,
reprobe: vi.fn().mockResolvedValue(undefined),
send: (result) => sent.push(result),
log: vi.fn(),
});

const running = runner.run(CODEX);
await runner.run({ ...CODEX, ref: "223e4567-e89b-42d3-a456-426614174000" });
expect(installCodex).toHaveBeenCalledTimes(1);
expect(sent.at(-1)).toMatchObject({ status: "failed", reasonCode: "already_in_progress", retryable: true });

first.resolve({ ok: false, reason: "network down", retryable: true, reasonCode: "network_error" });
await running;
await runner.run({ ...CODEX, ref: "323e4567-e89b-42d3-a456-426614174000" });
expect(installCodex).toHaveBeenCalledTimes(2);
expect(sent.at(-1)).toMatchObject({ status: "succeeded" });
});

it("surfaces installer and capability re-probe failures as retryable terminal results", async () => {
const sent: RuntimeInstallResultFrame[] = [];
const runner = createRuntimeInstallRunner({
installClaude: vi.fn(),
installCodex: vi.fn().mockResolvedValue({ ok: true, installedVersion: null }),
reprobe: vi.fn().mockRejectedValue(new Error("probe unavailable")),
send: (result) => sent.push(result),
log: vi.fn(),
});

await runner.run(CODEX);
expect(sent.at(-1)).toMatchObject({
status: "failed",
reasonCode: "capability_reprobe_failed",
retryable: true,
});
});

it("redacts secrets before publishing installer failures", async () => {
const sent: RuntimeInstallResultFrame[] = [];
const runner = createRuntimeInstallRunner({
installClaude: vi.fn(),
installCodex: vi.fn().mockResolvedValue({
ok: false,
reason: "registry rejected token=ghp_AbCdEf0123456789abcdef0123456789abcd",
reasonCode: "npm_auth",
retryable: false,
}),
reprobe: vi.fn(),
send: (result) => sent.push(result),
log: vi.fn(),
});

await runner.run(CODEX);
expect(sent.at(-1)).toMatchObject({ status: "failed", reasonCode: "npm_auth" });
expect(sent.at(-1)).not.toEqual(expect.objectContaining({ reason: expect.stringContaining("ghp_") }));
});
});
22 changes: 22 additions & 0 deletions apps/cli/src/commands/daemon/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
discoverProviderModels,
flushClientSentry,
initClientSentry,
probeClaudeCodeCapability,
probeCodexCapability,
} from "@first-tree/client";
import {
agentConfigSchema,
Expand All @@ -35,13 +37,16 @@ import {
createApiNameResolver,
createExecuteUpdate,
createLoggerRuntimeOutput,
createRuntimeInstallRunner,
daemonRuntimeHomesEqual,
declineUpdate,
ensureActiveRootClientIdPersisted,
ensureFreshAccessToken,
getClientServiceStatus,
getClientSwitchStartupBlock,
handleClientOrgMismatch,
installClaudeRuntime,
installCodexRuntime,
isDaemonRuntimeOwnershipError,
isServiceSupported,
listPinnedAgents,
Expand Down Expand Up @@ -417,6 +422,23 @@ export function registerDaemonStartCommand(daemon: Command): void {
}).finally(() => capabilityRefresher.endInteractive(command.provider));
});

const runtimeInstallRunner = createRuntimeInstallRunner({
// npm stderr is retained in the installer result. Keep raw chunks
// off supervisor streams; the runner emits one bounded, redacted
// terminal diagnostic through the daemon logger instead.
installClaude: () => installClaudeRuntime("latest", () => undefined),
installCodex: () => installCodexRuntime("latest", () => undefined),
reprobe: async (provider) => {
const entry = provider === "codex" ? await probeCodexCapability() : await probeClaudeCodeCapability();
await capabilityRefresher.setProviderEntry(provider, entry);
},
send: (result) => runtime.sendRuntimeInstallResult(result),
log: (symbol, message) => writeStatus(symbol, message),
});
runtime.onRuntimeInstallStart((command) => {
void runtimeInstallRunner.run(command);
});

// Host-local model catalog: web opens Model settings → server asks this
// daemon → we discover from the real provider and reply on the WS.
runtime.onProviderModelsList((command) => {
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/src/core/client-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type HandlerFactory,
type ProviderModelsListCommand,
type RuntimeAuthCommand,
type RuntimeInstallCommand,
resolveAndLogClaudeExecutable,
type UpdateHooks,
UpdateManager,
Expand All @@ -20,6 +21,7 @@ import {
type AgentPinnedMessage,
type ClientPausedReason,
type ProviderModelCatalog,
type RuntimeInstallResultFrame,
type RuntimeProvider,
runtimeProviderSchema,
} from "@first-tree/shared";
Expand Down Expand Up @@ -305,6 +307,16 @@ export class ClientRuntime {
this.connection.on("runtime-auth:start", callback);
}

/** Register a handler for the controlled runtime-install command. */
onRuntimeInstallStart(callback: (command: RuntimeInstallCommand) => void): void {
this.connection.on("runtime-install:start", callback);
}

/** Publish runtime-install progress or its terminal outcome. */
sendRuntimeInstallResult(result: RuntimeInstallResultFrame): void {
this.connection.sendRuntimeInstallResult(result);
}

/**
* Register a handler for the server→client `provider-models:list` command.
* The daemon discovers models from the host-local provider and replies with
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ export { blank, status } from "./output.js";
export { isInteractive, promptAddAgent, promptMissingFields } from "./prompt.js";
// Runtime-auth login orchestrator (browser-OAuth provider login)
export { type RuntimeAuthLoginDeps, runRuntimeAuthLogin } from "./runtime-auth-login.js";
export {
createRuntimeInstallRunner,
type RuntimeInstallRunner,
type RuntimeInstallRunnerDeps,
} from "./runtime-install.js";
export type { PinnedAgentRuntimeRecord } from "./runtime-provider-reconcile.js";
// Pre-flight runtime-provider reconciliation (P2 — capabilities + YAML rewrite)
export {
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/core/install-claude-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ function parseInstalledVersion(stdout: string): string | null {
* the caller is expected to re-probe the claude-code capability so the new
* binary is picked up via PATH resolution. Does not exit the process.
*/
export async function installClaudeRuntime(spec = "latest"): Promise<InstallClaudeResult> {
export async function installClaudeRuntime(
spec = "latest",
stderrSink: (chunk: string) => void = (chunk) => print.line(chunk),
): Promise<InstallClaudeResult> {
if (!isSafeInstallSpec(spec)) {
return {
ok: false,
Expand Down Expand Up @@ -82,7 +85,7 @@ export async function installClaudeRuntime(spec = "latest"): Promise<InstallClau
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
print.line(chunk.toString("utf8"));
stderrSink(chunk.toString("utf8"));
});

child.on("error", (err) => {
Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/core/install-codex-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ function parseInstalledVersion(stdout: string): string | null {
* the caller is expected to re-probe the codex capability so the new binary is
* picked up via PATH resolution. Does not exit the process.
*/
export async function installCodexRuntime(spec = "latest"): Promise<InstallCodexResult> {
export async function installCodexRuntime(
spec = "latest",
stderrSink: (chunk: string) => void = (chunk) => print.line(chunk),
): Promise<InstallCodexResult> {
if (!isSafeInstallSpec(spec)) {
return {
ok: false,
Expand Down Expand Up @@ -82,7 +85,7 @@ export async function installCodexRuntime(spec = "latest"): Promise<InstallCodex
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
print.line(chunk.toString("utf8"));
stderrSink(chunk.toString("utf8"));
});

child.on("error", (err) => {
Expand Down
Loading
Loading