fix: tighten codex app-server lifecycle

This commit is contained in:
Peter Steinberger
2026-04-12 16:13:49 +01:00
parent 485f4167e1
commit 659bcc5e5b
8 changed files with 141 additions and 42 deletions

View File

@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js"; import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js"; import { CodexAppServerClient } from "./src/app-server/client.js";
import { resetSharedCodexAppServerClientForTests } from "./src/app-server/shared-client.js"; import {
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
afterEach(() => { afterEach(() => {
resetSharedCodexAppServerClientForTests(); resetSharedCodexAppServerClientForTests();
@@ -37,7 +40,7 @@ describe("codex provider", () => {
}); });
expect(listModels).toHaveBeenCalledWith( expect(listModels).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100, timeoutMs: 1234 }), expect.objectContaining({ limit: 100, timeoutMs: 1234, sharedClient: false }),
); );
expect(result.provider).toMatchObject({ expect(result.provider).toMatchObject({
auth: "token", auth: "token",
@@ -103,6 +106,32 @@ describe("codex provider", () => {
expect(client.close).toHaveBeenCalledTimes(1); expect(client.close).toHaveBeenCalledTimes(1);
}); });
it("does not close an active shared app-server client during live discovery", async () => {
const activeClient = {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
const discoveryClient = {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await getSharedCodexAppServerClient({ timeoutMs: 1000 });
await buildCodexProviderCatalog({
env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" },
});
expect(activeClient.close).not.toHaveBeenCalled();
expect(discoveryClient.close).toHaveBeenCalledTimes(1);
});
it("resolves arbitrary Codex app-server model ids through the codex provider", () => { it("resolves arbitrary Codex app-server model ids through the codex provider", () => {
const provider = buildCodexProvider(); const provider = buildCodexProvider();

View File

@@ -15,7 +15,6 @@ import {
readCodexPluginConfig, readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions, resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js"; } from "./src/app-server/config.js";
import { clearSharedCodexAppServerClient } from "./src/app-server/shared-client.js";
const PROVIDER_ID = "codex"; const PROVIDER_ID = "codex";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
@@ -28,6 +27,7 @@ type CodexModelLister = (options: {
timeoutMs: number; timeoutMs: number;
limit?: number; limit?: number;
startOptions?: CodexAppServerStartOptions; startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>; }) => Promise<CodexAppServerModelListResult>;
type BuildCodexProviderOptions = { type BuildCodexProviderOptions = {
@@ -102,15 +102,11 @@ export async function buildCodexProviderCatalog(
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs); const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
let discovered: CodexAppServerModel[] = []; let discovered: CodexAppServerModel[] = [];
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) { if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
try { discovered = await listModelsBestEffort({
discovered = await listModelsBestEffort({ listModels: options.listModels ?? listCodexAppServerModels,
listModels: options.listModels ?? listCodexAppServerModels, timeoutMs,
timeoutMs, startOptions: appServer.start,
startOptions: appServer.start, });
});
} finally {
clearSharedCodexAppServerClient();
}
} }
const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map( const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map(
codexModelToDefinition, codexModelToDefinition,
@@ -180,6 +176,7 @@ async function listModelsBestEffort(params: {
timeoutMs: params.timeoutMs, timeoutMs: params.timeoutMs,
limit: 100, limit: 100,
startOptions: params.startOptions, startOptions: params.startOptions,
sharedClient: false,
}); });
return result.models.filter((model) => !model.hidden); return result.models.filter((model) => !model.hidden);
} catch { } catch {

View File

@@ -1,7 +1,9 @@
import type { CodexAppServerStartOptions } from "./config.js"; import type { CodexAppServerStartOptions } from "./config.js";
import { type JsonObject, type JsonValue } from "./protocol.js"; import { type JsonObject, type JsonValue } from "./protocol.js";
import { getSharedCodexAppServerClient } from "./shared-client.js"; import {
import { withTimeout } from "./timeout.js"; createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
} from "./shared-client.js";
export type CodexAppServerModel = { export type CodexAppServerModel = {
id: string; id: string;
@@ -26,32 +28,39 @@ export type CodexAppServerListModelsOptions = {
includeHidden?: boolean; includeHidden?: boolean;
timeoutMs?: number; timeoutMs?: number;
startOptions?: CodexAppServerStartOptions; startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}; };
export async function listCodexAppServerModels( export async function listCodexAppServerModels(
options: CodexAppServerListModelsOptions = {}, options: CodexAppServerListModelsOptions = {},
): Promise<CodexAppServerModelListResult> { ): Promise<CodexAppServerModelListResult> {
const timeoutMs = options.timeoutMs ?? 2500; const timeoutMs = options.timeoutMs ?? 2500;
return await withTimeout( const useSharedClient = options.sharedClient !== false;
(async () => { const client = useSharedClient
const client = await getSharedCodexAppServerClient({ ? await getSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions, startOptions: options.startOptions,
timeoutMs, timeoutMs,
}); });
const response = await client.request<JsonObject>( try {
"model/list", const response = await client.request<JsonObject>(
{ "model/list",
limit: options.limit ?? null, {
cursor: options.cursor ?? null, limit: options.limit ?? null,
includeHidden: options.includeHidden ?? null, cursor: options.cursor ?? null,
}, includeHidden: options.includeHidden ?? null,
{ timeoutMs }, },
); { timeoutMs },
return readModelListResult(response); );
})(), return readModelListResult(response);
timeoutMs, } finally {
"codex app-server model/list timed out", if (!useSharedClient) {
); client.close();
}
}
} }
function readModelListResult(value: JsonValue | undefined): CodexAppServerModelListResult { function readModelListResult(value: JsonValue | undefined): CodexAppServerModelListResult {

View File

@@ -227,14 +227,10 @@ export async function runCodexAppServerAttempt(
); );
const abortListener = () => { const abortListener = () => {
void client interruptCodexTurnBestEffort(client, {
.request("turn/interrupt", { threadId: thread.threadId,
threadId: thread.threadId, turnId: activeTurnId,
turnId: activeTurnId, });
})
.catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server turn interrupt failed during abort", { error });
});
resolveCompletion?.(); resolveCompletion?.();
}; };
runAbortController.signal.addEventListener("abort", abortListener, { once: true }); runAbortController.signal.addEventListener("abort", abortListener, { once: true });
@@ -268,6 +264,20 @@ export async function runCodexAppServerAttempt(
} }
} }
function interruptCodexTurnBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
turnId: string;
},
): void {
void Promise.resolve()
.then(() => client.request("turn/interrupt", params))
.catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server turn interrupt failed during abort", { error });
});
}
type DynamicToolBuildParams = { type DynamicToolBuildParams = {
params: EmbeddedRunAttemptParams; params: EmbeddedRunAttemptParams;
resolvedWorkspace: string; resolvedWorkspace: string;

View File

@@ -59,6 +59,23 @@ export async function getSharedCodexAppServerClient(options?: {
} }
} }
export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
}): Promise<CodexAppServerClient> {
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const client = CodexAppServerClient.start(startOptions);
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
return client;
} catch (error) {
client.close();
await initialize.catch(() => undefined);
throw error;
}
}
export function resetSharedCodexAppServerClientForTests(): void { export function resetSharedCodexAppServerClientForTests(): void {
const state = getSharedCodexAppServerClientState(); const state = getSharedCodexAppServerClientState();
state.client = undefined; state.client = undefined;

View File

@@ -57,6 +57,9 @@ export function logModelFallbackDecision(params: {
const reasonText = params.reason ?? "unknown"; const reasonText = params.reason ?? "unknown";
const observedError = buildErrorObservationFields(params.error); const observedError = buildErrorObservationFields(params.error);
const detailText = observedError.providerErrorMessagePreview ?? observedError.errorPreview; const detailText = observedError.providerErrorMessagePreview ?? observedError.errorPreview;
const providerErrorTypeSuffix = observedError.providerErrorType
? ` providerErrorType=${sanitizeForLog(observedError.providerErrorType)}`
: "";
const detailSuffix = detailText ? ` detail=${sanitizeForLog(detailText)}` : ""; const detailSuffix = detailText ? ` detail=${sanitizeForLog(detailText)}` : "";
decisionLog.warn("model fallback decision", { decisionLog.warn("model fallback decision", {
event: "model_fallback_decision", event: "model_fallback_decision",
@@ -90,6 +93,6 @@ export function logModelFallbackDecision(params: {
})), })),
consoleMessage: consoleMessage:
`model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` + `model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` +
`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}${detailSuffix}`, `candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText}${providerErrorTypeSuffix} next=${nextText}${detailSuffix}`,
}); });
} }

View File

@@ -1,7 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { withPathResolutionEnv } from "../test-utils/env.js"; import { withPathResolutionEnv } from "../test-utils/env.js";
import { writeSkill } from "./skills.e2e-test-helpers.js"; import { writeSkill } from "./skills.e2e-test-helpers.js";
import { loadWorkspaceSkillEntries } from "./skills.js"; import { loadWorkspaceSkillEntries } from "./skills.js";
@@ -21,6 +23,9 @@ function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
} }
afterEach(async () => { afterEach(async () => {
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
await Promise.all( await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
); );
@@ -282,7 +287,16 @@ describe("loadWorkspaceSkillEntries", () => {
description: "Outside", description: "Outside",
}); });
await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true }); await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true });
await fs.symlink(escapedSkillDir, path.join(workspaceDir, "skills", "escaped-skill"), "dir"); const requestedPath = path.join(workspaceDir, "skills", "escaped-skill");
await fs.symlink(escapedSkillDir, requestedPath, "dir");
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
const entries = loadWorkspaceSkillEntries(workspaceDir, { const entries = loadWorkspaceSkillEntries(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"), managedSkillsDir: path.join(workspaceDir, ".managed"),
@@ -290,6 +304,15 @@ describe("loadWorkspaceSkillEntries", () => {
}); });
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill"); expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill");
const [line] = warn.mock.calls[0] ?? [];
const warningLine = String(line);
expect(warningLine).toContain(
"Skipping skill path that resolves outside its configured root:",
);
expect(warningLine).toContain("source=openclaw-workspace");
expect(warningLine).toContain(`root=${path.join(workspaceDir, "skills")}`);
expect(warningLine).toContain(`requested=${requestedPath}`);
expect(warningLine).toContain("resolved=");
}, },
); );

View File

@@ -165,14 +165,24 @@ function tryRealpath(filePath: string): string | null {
function warnEscapedSkillPath(params: { function warnEscapedSkillPath(params: {
source: string; source: string;
rootDir: string; rootDir: string;
rootRealPath: string;
candidatePath: string; candidatePath: string;
candidateRealPath: string; candidateRealPath: string;
}) { }) {
const rootResolved =
path.resolve(params.rootDir) === params.rootRealPath
? ""
: ` rootResolved=${params.rootRealPath}`;
skillsLogger.warn("Skipping skill path that resolves outside its configured root.", { skillsLogger.warn("Skipping skill path that resolves outside its configured root.", {
source: params.source, source: params.source,
rootDir: params.rootDir, rootDir: params.rootDir,
rootRealPath: params.rootRealPath,
path: params.candidatePath, path: params.candidatePath,
realPath: params.candidateRealPath, realPath: params.candidateRealPath,
consoleMessage:
`Skipping skill path that resolves outside its configured root: ` +
`source=${params.source} root=${params.rootDir}${rootResolved} ` +
`requested=${params.candidatePath} resolved=${params.candidateRealPath}`,
}); });
} }
@@ -192,6 +202,7 @@ function resolveContainedSkillPath(params: {
warnEscapedSkillPath({ warnEscapedSkillPath({
source: params.source, source: params.source,
rootDir: params.rootDir, rootDir: params.rootDir,
rootRealPath: params.rootRealPath,
candidatePath: path.resolve(params.candidatePath), candidatePath: path.resolve(params.candidatePath),
candidateRealPath, candidateRealPath,
}); });