mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
fix: tighten codex app-server lifecycle
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user