mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 05:11:35 +00:00
* feat(openai): add provider-owned route facts * fix(openai): harden provider route facts * test(codex): update rebased auth fixtures * chore: leave release notes to release workflow * fix(openai): align route auth with current contracts * test(openai): align route and shard expectations * test(openai): satisfy route fixture contracts * fix(openai): preserve direct profile forwarding * test(models): complete route auth mocks * test(codex): type compaction factory mock * fix(openai): preserve provider-native model ids * test(agents): align route auth fixtures * style(agents): format route integrations * test(plugin-sdk): pin current surface counts
1235 lines
44 KiB
TypeScript
1235 lines
44 KiB
TypeScript
/**
|
|
* Owns shared and isolated Codex app-server client startup, auth application,
|
|
* lease tracking, and teardown.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import path from "node:path";
|
|
import type { AgentHarnessRuntimeArtifactBinding } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
|
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
|
import {
|
|
applyCodexAppServerAuthProfile,
|
|
bridgeCodexAppServerStartOptions,
|
|
resolveCodexAppServerAuthProfileIdForAgent,
|
|
resolveCodexAppServerAuthProfileStore,
|
|
resolveCodexAppServerFallbackApiKeyCacheKey,
|
|
resolveCodexAppServerHomeDir,
|
|
resolveCodexAppServerPreparedAuthProfileSnapshot,
|
|
resolveCodexAppServerPreparedApiKeyCacheKey,
|
|
type CodexAppServerPreparedAuth,
|
|
type CodexAppServerResolvedPreparedAuth,
|
|
} from "./auth-bridge.js";
|
|
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
|
|
import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js";
|
|
import {
|
|
codexAppServerStartOptionsKey,
|
|
resolveCodexAppServerRuntimeOptions,
|
|
resolveCodexAppServerStartOptionsForAgent,
|
|
resolveCodexAppServerUserHomeDir,
|
|
type CodexAppServerStartOptions,
|
|
} from "./config.js";
|
|
import {
|
|
resolveManagedCodexAppServerStartOptions,
|
|
resolveManagedCodexNativeCommand,
|
|
} from "./managed-binary.js";
|
|
import { acquireCodexNativeConfigFence } from "./native-config-fence.js";
|
|
import { withTimeout } from "./timeout.js";
|
|
|
|
export type { CodexAppServerPreparedAuth } from "./auth-bridge.js";
|
|
|
|
type SharedCodexAppServerClientEntry = {
|
|
client?: CodexAppServerClient;
|
|
startup?: SharedCodexAppServerClientStartup;
|
|
activeLeases: number;
|
|
pendingAcquires: number;
|
|
closeWhenIdle: boolean;
|
|
closeError?: Error;
|
|
runtimeArtifactStartupAbort?: AbortController;
|
|
onStartedClientCallbacks: Set<(client: CodexAppServerClient) => void>;
|
|
};
|
|
|
|
type SharedCodexAppServerClientStartup = {
|
|
initialized: Promise<void>;
|
|
ready: Promise<CodexAppServerClient>;
|
|
};
|
|
|
|
type SharedCodexAppServerClientState = {
|
|
clients: Map<string, SharedCodexAppServerClientEntry>;
|
|
leasedReleases: WeakMap<CodexAppServerClient, Array<() => void>>;
|
|
};
|
|
|
|
type CodexAppServerClientStartMetadata = {
|
|
requestedStartOptions: CodexAppServerStartOptions;
|
|
startOptions: CodexAppServerStartOptions;
|
|
agentDir: string;
|
|
nativeCommand?: string;
|
|
};
|
|
|
|
/** Successful physical process identity, excluding environment and credentials. */
|
|
export type CodexAppServerClientProcessIdentity = {
|
|
clientId: string;
|
|
command: string;
|
|
argsFingerprint: string;
|
|
commandSource?: CodexAppServerStartOptions["commandSource"];
|
|
managedCommandOrder?: CodexAppServerStartOptions["managedCommandOrder"];
|
|
nativeCommand?: string;
|
|
serverVersion?: string;
|
|
userAgent?: string;
|
|
};
|
|
|
|
export type CodexAppServerSpawnIdentity = Omit<
|
|
CodexAppServerClientProcessIdentity,
|
|
"clientId" | "serverVersion" | "userAgent"
|
|
>;
|
|
|
|
// Clients we already force-closed for suspect retirement; a repeat retire must
|
|
// report closed:false instead of pretending to close the corpse again.
|
|
const suspectClosedClients = new WeakSet<CodexAppServerClient>();
|
|
|
|
// Symbol.for shares one client table across duplicate module copies (dist +
|
|
// src bundles in one process). Plugin updates restart the gateway, so every
|
|
// copy writing this state runs the same code and the shape never migrates.
|
|
const SHARED_CODEX_APP_SERVER_CLIENT_STATE = Symbol.for("openclaw.codexAppServerClientState");
|
|
const CODEX_APP_SERVER_CLIENT_START_METADATA = Symbol.for(
|
|
"openclaw.codexAppServerClientStartMetadata",
|
|
);
|
|
|
|
function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
|
const globalState = globalThis as typeof globalThis & {
|
|
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: SharedCodexAppServerClientState;
|
|
};
|
|
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] ??= {
|
|
clients: new Map(),
|
|
leasedReleases: new WeakMap(),
|
|
};
|
|
return globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
|
|
}
|
|
|
|
function getCodexAppServerClientStartMetadata(): WeakMap<
|
|
CodexAppServerClient,
|
|
CodexAppServerClientStartMetadata
|
|
> {
|
|
const globalState = globalThis as typeof globalThis & {
|
|
[CODEX_APP_SERVER_CLIENT_START_METADATA]?: WeakMap<
|
|
CodexAppServerClient,
|
|
CodexAppServerClientStartMetadata
|
|
>;
|
|
};
|
|
globalState[CODEX_APP_SERVER_CLIENT_START_METADATA] ??= new WeakMap();
|
|
return globalState[CODEX_APP_SERVER_CLIENT_START_METADATA];
|
|
}
|
|
|
|
/** Reads the exact successful spawn selection plus its initialized runtime identity. */
|
|
export function readCodexAppServerClientProcessIdentity(
|
|
client: CodexAppServerClient,
|
|
): CodexAppServerClientProcessIdentity | undefined {
|
|
const metadata = getCodexAppServerClientStartMetadata().get(client);
|
|
if (!metadata) {
|
|
return undefined;
|
|
}
|
|
const runtimeIdentity = client.getRuntimeIdentity();
|
|
return {
|
|
clientId: client.getInstanceId(),
|
|
...resolveCodexAppServerSpawnIdentity(metadata.startOptions, metadata.nativeCommand),
|
|
...(runtimeIdentity?.serverVersion ? { serverVersion: runtimeIdentity.serverVersion } : {}),
|
|
...(runtimeIdentity?.userAgent ? { userAgent: runtimeIdentity.userAgent } : {}),
|
|
};
|
|
}
|
|
|
|
/** Resolves non-secret spawn identity before startup; argv is represented only by its hash. */
|
|
export function resolveCodexAppServerSpawnIdentity(
|
|
startOptions: CodexAppServerStartOptions,
|
|
resolvedNativeCommand?: string,
|
|
): CodexAppServerSpawnIdentity {
|
|
const nativeCommand =
|
|
resolvedNativeCommand ??
|
|
(startOptions.commandSource === "resolved-managed"
|
|
? resolveManagedCodexNativeCommand(startOptions.command)
|
|
: undefined);
|
|
return {
|
|
command: startOptions.command,
|
|
argsFingerprint: createHash("sha256").update(JSON.stringify(startOptions.args)).digest("hex"),
|
|
...(startOptions.commandSource ? { commandSource: startOptions.commandSource } : {}),
|
|
...(startOptions.managedCommandOrder
|
|
? { managedCommandOrder: startOptions.managedCommandOrder }
|
|
: {}),
|
|
...(nativeCommand ? { nativeCommand } : {}),
|
|
};
|
|
}
|
|
|
|
export class CodexAppServerStartSelectionChangedError extends Error {
|
|
readonly code = "CODEX_APP_SERVER_START_SELECTION_CHANGED";
|
|
|
|
constructor() {
|
|
super("Codex app-server managed executable selection changed during startup");
|
|
this.name = "CodexAppServerStartSelectionChangedError";
|
|
}
|
|
}
|
|
|
|
/** Cross-bundle-safe check for a managed executable selection retry. */
|
|
export function isCodexAppServerStartSelectionChangedError(
|
|
error: unknown,
|
|
): error is CodexAppServerStartSelectionChangedError {
|
|
return (
|
|
error instanceof Error &&
|
|
"code" in error &&
|
|
error.code === "CODEX_APP_SERVER_START_SELECTION_CHANGED"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rechecks mutable Codex-owned plugin state immediately before thread start/resume.
|
|
* The synchronous check prevents another gateway task from installing Computer
|
|
* Use between the check and the JSON-RPC write on the same event loop turn.
|
|
*/
|
|
export function assertCodexAppServerClientStartSelectionCurrent(params: {
|
|
client: CodexAppServerClient;
|
|
startOptions?: CodexAppServerStartOptions;
|
|
agentDir?: string;
|
|
}): void {
|
|
const metadata = getCodexAppServerClientStartMetadata().get(params.client);
|
|
if (!metadata) {
|
|
return;
|
|
}
|
|
const requestedStartOptions = params.startOptions ?? metadata.requestedStartOptions;
|
|
if (requestedStartOptions.commandSource !== "managed") {
|
|
return;
|
|
}
|
|
const current = resolveCodexAppServerStartOptionsForAgent({
|
|
startOptions: requestedStartOptions,
|
|
agentDir: params.agentDir ?? metadata.agentDir,
|
|
});
|
|
const actualOrder = metadata.startOptions.managedCommandOrder ?? "package-first";
|
|
const currentOrder = current.managedCommandOrder ?? "package-first";
|
|
if (actualOrder !== currentOrder) {
|
|
throw new CodexAppServerStartSelectionChangedError();
|
|
}
|
|
}
|
|
|
|
/** Resolves the per-CODEX_HOME key used to serialize native config loading. */
|
|
export function resolveCodexNativeConfigFenceKey(params: {
|
|
client?: CodexAppServerClient;
|
|
startOptions?: CodexAppServerStartOptions;
|
|
agentDir?: string;
|
|
config?: CodexAppServerClientOptions["config"];
|
|
}): string | undefined {
|
|
const metadata = params.client
|
|
? getCodexAppServerClientStartMetadata().get(params.client)
|
|
: undefined;
|
|
const startOptions = params.startOptions ?? metadata?.startOptions;
|
|
if (!startOptions || startOptions.transport !== "stdio") {
|
|
return undefined;
|
|
}
|
|
const configuredHome = startOptions.env?.CODEX_HOME?.trim();
|
|
const agentDir =
|
|
params.agentDir ?? metadata?.agentDir ?? resolveDefaultAgentDir(params.config ?? {});
|
|
const codexHome = configuredHome
|
|
? configuredHome
|
|
: startOptions.homeScope === "user"
|
|
? resolveCodexAppServerUserHomeDir()
|
|
: agentDir
|
|
? resolveCodexAppServerHomeDir(agentDir)
|
|
: undefined;
|
|
return codexHome ? `codex-home:${path.resolve(codexHome)}` : undefined;
|
|
}
|
|
|
|
export type CodexAppServerClientOptions = {
|
|
startOptions?: CodexAppServerStartOptions;
|
|
pluginConfig?: unknown;
|
|
timeoutMs?: number;
|
|
authProfileId?: string | null;
|
|
authProfileStore?: AuthProfileStore;
|
|
authBindingFingerprint?: string;
|
|
/** Setup-only generation whose exact local runtime bytes are captured. */
|
|
runtimeArtifactMode?: "capture";
|
|
/** Previously minted exact runtime required before the process may start. */
|
|
expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding;
|
|
preparedAuth?: CodexAppServerPreparedAuth;
|
|
agentDir?: string;
|
|
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
|
onStartedClient?: (client: CodexAppServerClient) => void;
|
|
abandonSignal?: AbortSignal;
|
|
};
|
|
|
|
/** Factory used by attempt startup and side turns to acquire a leased client. */
|
|
export type CodexAppServerClientFactory = (
|
|
options?: CodexAppServerClientOptions,
|
|
) => Promise<CodexAppServerClient>;
|
|
|
|
type ResolvedCodexAppServerClientStartContext = {
|
|
agentDir: string;
|
|
usesNativeAuth: boolean;
|
|
authProfileId: string | undefined;
|
|
authProfileStore: AuthProfileStore | undefined;
|
|
preparedAuth: CodexAppServerResolvedPreparedAuth | undefined;
|
|
requestedStartOptions: CodexAppServerStartOptions;
|
|
startOptions: CodexAppServerStartOptions;
|
|
};
|
|
|
|
async function resolveCodexAppServerClientStartContext(
|
|
options?: CodexAppServerClientOptions,
|
|
): Promise<ResolvedCodexAppServerClientStartContext> {
|
|
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
|
const requestedStartOptions =
|
|
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
|
const preparedAuth = options?.preparedAuth;
|
|
const preparedApiKey = preparedAuth?.kind === "api-key" ? preparedAuth.apiKey.trim() : undefined;
|
|
if (preparedAuth && options?.authProfileId !== undefined) {
|
|
throw new Error("Prepared Codex auth cannot also select a legacy auth profile.");
|
|
}
|
|
if (preparedAuth?.kind === "profile" && !preparedAuth.store.profiles[preparedAuth.profileId]) {
|
|
throw new Error(`Prepared Codex auth profile "${preparedAuth.profileId}" was not found.`);
|
|
}
|
|
if (preparedAuth?.kind === "api-key" && !preparedApiKey) {
|
|
throw new Error("Prepared Codex API-key auth is missing its resolved key.");
|
|
}
|
|
if (preparedAuth && requestedStartOptions.homeScope === "user") {
|
|
throw new Error("Prepared Codex auth requires an isolated app-server home.");
|
|
}
|
|
const usesNativeAuth =
|
|
!preparedAuth &&
|
|
(options?.authProfileId === null || requestedStartOptions.homeScope === "user");
|
|
const requestedAuthProfileId =
|
|
preparedAuth?.kind === "profile"
|
|
? preparedAuth.profileId
|
|
: (options?.authProfileId ?? undefined);
|
|
const authProfileStore =
|
|
preparedAuth?.kind === "profile"
|
|
? preparedAuth.store
|
|
: !usesNativeAuth && options?.authProfileStore
|
|
? resolveCodexAppServerAuthProfileStore({
|
|
agentDir,
|
|
authProfileId: requestedAuthProfileId,
|
|
authProfileStore: options.authProfileStore,
|
|
config: options.config,
|
|
})
|
|
: options?.authProfileStore;
|
|
const authProfileId =
|
|
preparedAuth?.kind === "profile"
|
|
? preparedAuth.profileId
|
|
: usesNativeAuth || preparedAuth?.kind === "api-key"
|
|
? undefined
|
|
: resolveCodexAppServerAuthProfileIdForAgent({
|
|
authProfileId: requestedAuthProfileId,
|
|
agentDir,
|
|
config: options?.config,
|
|
...(authProfileStore ? { authProfileStore } : {}),
|
|
});
|
|
const preparedAuthProfileSnapshot =
|
|
preparedAuth?.kind === "profile"
|
|
? (preparedAuth.snapshot ??
|
|
(await resolveCodexAppServerPreparedAuthProfileSnapshot({
|
|
authProfileId,
|
|
authProfileStore,
|
|
agentDir,
|
|
config: options?.config,
|
|
})))
|
|
: undefined;
|
|
if (preparedAuth?.kind === "profile" && !preparedAuthProfileSnapshot) {
|
|
throw new Error(`Prepared Codex auth profile "${preparedAuth.profileId}" is unusable.`);
|
|
}
|
|
const resolvedPreparedAuth: CodexAppServerResolvedPreparedAuth | undefined =
|
|
preparedAuth?.kind === "api-key"
|
|
? { kind: "api-key", apiKey: preparedApiKey as string }
|
|
: preparedAuth?.kind === "profile"
|
|
? {
|
|
...preparedAuth,
|
|
snapshot: preparedAuthProfileSnapshot as NonNullable<
|
|
typeof preparedAuthProfileSnapshot
|
|
>,
|
|
}
|
|
: undefined;
|
|
const agentStartOptions = resolveCodexAppServerStartOptionsForAgent({
|
|
startOptions: requestedStartOptions,
|
|
agentDir,
|
|
});
|
|
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(agentStartOptions);
|
|
const startOptions = await bridgeCodexAppServerStartOptions({
|
|
startOptions: managedStartOptions,
|
|
agentDir,
|
|
authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId,
|
|
...(resolvedPreparedAuth ? { preparedAuth: resolvedPreparedAuth } : {}),
|
|
config: options?.config,
|
|
pluginConfig: options?.pluginConfig,
|
|
...(authProfileStore ? { authProfileStore } : {}),
|
|
});
|
|
return {
|
|
agentDir,
|
|
usesNativeAuth,
|
|
authProfileId,
|
|
authProfileStore,
|
|
requestedStartOptions,
|
|
preparedAuth: resolvedPreparedAuth,
|
|
startOptions,
|
|
};
|
|
}
|
|
|
|
/** Gets or starts a shared Codex app-server client without retaining a lease. */
|
|
export async function getSharedCodexAppServerClient(
|
|
options?: CodexAppServerClientOptions,
|
|
): Promise<CodexAppServerClient> {
|
|
return (await acquireSharedCodexAppServerClient(options)).client;
|
|
}
|
|
|
|
/** Gets or starts a shared Codex app-server client and records a release lease. */
|
|
export async function getLeasedSharedCodexAppServerClient(
|
|
options?: CodexAppServerClientOptions,
|
|
): Promise<CodexAppServerClient> {
|
|
const acquired = await acquireSharedCodexAppServerClient(options, { leased: true });
|
|
const state = getSharedCodexAppServerClientState();
|
|
const releases = state.leasedReleases.get(acquired.client) ?? [];
|
|
releases.push(acquired.release);
|
|
state.leasedReleases.set(acquired.client, releases);
|
|
return acquired.client;
|
|
}
|
|
|
|
/** Releases one outstanding lease for a shared Codex app-server client. */
|
|
export function releaseLeasedSharedCodexAppServerClient(client: CodexAppServerClient): boolean {
|
|
const state = getSharedCodexAppServerClientState();
|
|
const releases = state.leasedReleases.get(client);
|
|
if (!releases) {
|
|
return false;
|
|
}
|
|
const release = releases.pop();
|
|
if (!release) {
|
|
return false;
|
|
}
|
|
if (releases.length === 0) {
|
|
state.leasedReleases.delete(client);
|
|
}
|
|
release();
|
|
return true;
|
|
}
|
|
|
|
/** Mutable ownership token for one shared-client lease across client replacement. */
|
|
export type CodexAppServerClientLease = { client?: CodexAppServerClient };
|
|
|
|
/** Releases the currently owned client exactly once. */
|
|
export function releaseCodexAppServerClientLease(lease: CodexAppServerClientLease): boolean {
|
|
const client = lease.client;
|
|
lease.client = undefined;
|
|
return client ? releaseLeasedSharedCodexAppServerClient(client) : false;
|
|
}
|
|
|
|
/** Retries one config-loading request after moving its lease to the current owner. */
|
|
export async function withLeasedCodexAppServerClientStartSelectionRetry<T>(params: {
|
|
lease: CodexAppServerClientLease;
|
|
options?: CodexAppServerClientOptions;
|
|
signal?: AbortSignal;
|
|
run: (
|
|
client: CodexAppServerClient,
|
|
requestOptions: { timeoutMs: number; signal?: AbortSignal },
|
|
) => Promise<T>;
|
|
onClientChange: (client: CodexAppServerClient) => void;
|
|
}): Promise<T> {
|
|
let client = params.lease.client;
|
|
if (!client) {
|
|
throw new Error("Codex app-server selection retry requires an active client lease");
|
|
}
|
|
const timeoutMs = params.options?.timeoutMs ?? 60_000;
|
|
const deadline = Date.now() + timeoutMs;
|
|
const signal = params.signal ?? params.options?.abandonSignal;
|
|
const requestOptions = () => {
|
|
if (signal?.aborted) {
|
|
throw new Error("Codex app-server selection retry aborted");
|
|
}
|
|
const remainingTimeoutMs = deadline - Date.now();
|
|
if (remainingTimeoutMs <= 0) {
|
|
throw new Error("Codex app-server selection retry timed out");
|
|
}
|
|
return {
|
|
timeoutMs: remainingTimeoutMs,
|
|
...(signal ? { signal } : {}),
|
|
};
|
|
};
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
try {
|
|
return await params.run(client, requestOptions());
|
|
} catch (error) {
|
|
if (!isCodexAppServerStartSelectionChangedError(error) || attempt > 0) {
|
|
throw error;
|
|
}
|
|
// Existing loaded threads can drain safely; only future acquisitions must
|
|
// move to the newly selected desktop-first owner.
|
|
retireSharedCodexAppServerClientIfCurrent(client);
|
|
params.lease.client = undefined;
|
|
if (!releaseLeasedSharedCodexAppServerClient(client)) {
|
|
client.close();
|
|
throw new Error("Codex app-server selection retry requires a leased shared client", {
|
|
cause: error,
|
|
});
|
|
}
|
|
const replacementOptions = requestOptions();
|
|
client = await getLeasedSharedCodexAppServerClient({
|
|
...params.options,
|
|
timeoutMs: replacementOptions.timeoutMs,
|
|
...(signal ? { abandonSignal: signal } : {}),
|
|
});
|
|
params.lease.client = client;
|
|
params.onClientChange(client);
|
|
}
|
|
}
|
|
throw new Error("Codex app-server selection retry loop exited unexpectedly");
|
|
}
|
|
|
|
async function acquireSharedCodexAppServerClient(
|
|
options?: CodexAppServerClientOptions,
|
|
): Promise<{ client: CodexAppServerClient }>;
|
|
async function acquireSharedCodexAppServerClient(
|
|
options: CodexAppServerClientOptions | undefined,
|
|
leaseOptions: { leased: true },
|
|
): Promise<{ client: CodexAppServerClient; release: () => void }>;
|
|
async function acquireSharedCodexAppServerClient(
|
|
options?: CodexAppServerClientOptions,
|
|
leaseOptions?: { leased: true },
|
|
): Promise<{ client: CodexAppServerClient; release?: () => void }> {
|
|
if (options?.abandonSignal?.aborted) {
|
|
throw new Error("codex app-server initialize aborted");
|
|
}
|
|
const acquireStartedAt = Date.now();
|
|
const timeoutMs = options?.timeoutMs ?? 0;
|
|
const context = await withCodexAppServerAcquireDeadline(
|
|
resolveCodexAppServerClientStartContext(options),
|
|
timeoutMs,
|
|
options?.abandonSignal,
|
|
);
|
|
const {
|
|
agentDir,
|
|
usesNativeAuth,
|
|
authProfileId,
|
|
authProfileStore,
|
|
preparedAuth,
|
|
requestedStartOptions,
|
|
startOptions,
|
|
} = context;
|
|
const remainingTimeoutMs = resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt);
|
|
const authIdentityCacheKey =
|
|
preparedAuth?.kind === "api-key"
|
|
? resolveCodexAppServerPreparedApiKeyCacheKey(preparedAuth.apiKey)
|
|
: (preparedAuth?.snapshot.secretFreeCacheKey ??
|
|
(authProfileId
|
|
? undefined
|
|
: resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions })));
|
|
const baseKey = codexAppServerStartOptionsKey(startOptions, {
|
|
authProfileId,
|
|
authBindingFingerprint: options?.authBindingFingerprint,
|
|
agentDir: usesNativeAuth ? undefined : agentDir,
|
|
fallbackApiKeyCacheKey: authIdentityCacheKey,
|
|
});
|
|
// Capture turns cannot inherit a normal client whose loaded bytes predate the
|
|
// filesystem snapshot. Keep their physical process generation separate.
|
|
const runtimeArtifactMode =
|
|
options?.runtimeArtifactMode ?? (options?.expectedRuntimeArtifact ? "capture" : undefined);
|
|
const expectedRuntimeArtifactKey = options?.expectedRuntimeArtifact
|
|
? createHash("sha256")
|
|
.update(options.expectedRuntimeArtifact.id)
|
|
.update("\0")
|
|
.update(options.expectedRuntimeArtifact.fingerprint)
|
|
.digest("hex")
|
|
: "mint";
|
|
const key = runtimeArtifactMode
|
|
? `${baseKey}\0runtime-artifact:capture-v1:${expectedRuntimeArtifactKey}`
|
|
: baseKey;
|
|
const state = getSharedCodexAppServerClientState();
|
|
const entry = getOrCreateSharedClientEntry(state, key);
|
|
if (runtimeArtifactMode) {
|
|
entry.runtimeArtifactStartupAbort ??= new AbortController();
|
|
}
|
|
entry.closeWhenIdle = false;
|
|
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
|
|
const startedCallback = options?.onStartedClient;
|
|
if (startedCallback) {
|
|
entry.onStartedClientCallbacks.add(startedCallback);
|
|
if (entry.client) {
|
|
startedCallback(entry.client);
|
|
}
|
|
}
|
|
const stopStartedClientNotifications = () => {
|
|
if (startedCallback) {
|
|
entry.onStartedClientCallbacks.delete(startedCallback);
|
|
}
|
|
};
|
|
let cleanupAbandonSignal: (() => void) | undefined;
|
|
if (options?.abandonSignal) {
|
|
const abandon = () => {
|
|
// Release this acquire before cleanup checks ownership; only other
|
|
// pending callers should keep the startup client alive.
|
|
stopStartedClientNotifications();
|
|
releasePendingAcquire();
|
|
retirePendingSharedClientEntryIfUnclaimed(key, entry);
|
|
};
|
|
options.abandonSignal.addEventListener("abort", abandon, { once: true });
|
|
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
|
|
if (options.abandonSignal.aborted) {
|
|
abandon();
|
|
}
|
|
}
|
|
const startup =
|
|
entry.startup ??
|
|
(entry.startup = createSharedCodexAppServerClientStartup({
|
|
entry,
|
|
key,
|
|
requestedStartOptions,
|
|
startOptions,
|
|
agentDir,
|
|
authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId,
|
|
authProfileStore,
|
|
preparedAuth,
|
|
runtimeArtifactMode,
|
|
...(options?.expectedRuntimeArtifact
|
|
? { expectedRuntimeArtifact: options.expectedRuntimeArtifact }
|
|
: {}),
|
|
runtimeArtifactSignal: entry.runtimeArtifactStartupAbort?.signal,
|
|
config: options?.config,
|
|
}));
|
|
try {
|
|
await withCodexAppServerAcquireDeadline(
|
|
startup.initialized,
|
|
remainingTimeoutMs,
|
|
options?.abandonSignal,
|
|
);
|
|
const client = await withCodexAppServerAcquireDeadline(
|
|
startup.ready,
|
|
timeoutMs,
|
|
options?.abandonSignal,
|
|
"codex app-server authentication timed out",
|
|
);
|
|
if (entry.closeError) {
|
|
throw entry.closeError;
|
|
}
|
|
// Later leases of the same keyed client may carry fresher config; the
|
|
// runtime install itself stays one-per-physical-client.
|
|
ensureCodexAppServerClientRuntime(client, {
|
|
agentDir,
|
|
authProfileId: usesNativeAuth ? undefined : authProfileId,
|
|
...(authProfileStore ? { authProfileStore } : {}),
|
|
authMode: preparedAuth?.kind === "api-key" ? "prepared-api-key" : "profile",
|
|
config: options?.config,
|
|
});
|
|
const release = leaseOptions?.leased ? retainSharedClientEntry(entry) : undefined;
|
|
return release ? { client, release } : { client };
|
|
} catch (error) {
|
|
// This deadline belongs to one waiter, not the shared physical client.
|
|
// Release first so only the final claimant can tear down stalled startup.
|
|
releasePendingAcquire();
|
|
retirePendingSharedClientEntryIfUnclaimed(key, entry);
|
|
throw error;
|
|
} finally {
|
|
cleanupAbandonSignal?.();
|
|
stopStartedClientNotifications();
|
|
releasePendingAcquire();
|
|
}
|
|
}
|
|
|
|
async function withCodexAppServerAcquireDeadline<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
timeoutMessage = "codex app-server initialize timed out",
|
|
): Promise<T> {
|
|
if (signal?.aborted) {
|
|
throw new Error("codex app-server initialize aborted");
|
|
}
|
|
const timed = withTimeout(promise, timeoutMs, timeoutMessage);
|
|
if (!signal) {
|
|
return await timed;
|
|
}
|
|
return await new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => reject(new Error("codex app-server initialize aborted"));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
timed.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
});
|
|
}
|
|
|
|
function resolveRemainingAcquireTimeout(timeoutMs: number, startedAt: number): number {
|
|
if (!(timeoutMs > 0)) {
|
|
return timeoutMs;
|
|
}
|
|
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
if (remaining <= 0) {
|
|
throw new Error("codex app-server initialize timed out");
|
|
}
|
|
return remaining;
|
|
}
|
|
|
|
function createSharedCodexAppServerClientStartup(params: {
|
|
entry: SharedCodexAppServerClientEntry;
|
|
key: string;
|
|
requestedStartOptions: CodexAppServerStartOptions;
|
|
startOptions: CodexAppServerStartOptions;
|
|
agentDir: string;
|
|
authProfileId: string | null | undefined;
|
|
authProfileStore?: AuthProfileStore;
|
|
runtimeArtifactMode?: "capture";
|
|
expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding;
|
|
runtimeArtifactSignal?: AbortSignal;
|
|
preparedAuth?: CodexAppServerResolvedPreparedAuth;
|
|
config?: CodexAppServerClientOptions["config"];
|
|
}): SharedCodexAppServerClientStartup {
|
|
const initialized = createDeferred<void>();
|
|
const ready = startInitializedCodexAppServerClient({
|
|
requestedStartOptions: params.requestedStartOptions,
|
|
startOptions: params.startOptions,
|
|
agentDir: params.agentDir,
|
|
authProfileId: params.authProfileId,
|
|
authProfileStore: params.authProfileStore,
|
|
preparedAuth: params.preparedAuth,
|
|
runtimeArtifactMode: params.runtimeArtifactMode,
|
|
...(params.expectedRuntimeArtifact
|
|
? { expectedRuntimeArtifact: params.expectedRuntimeArtifact }
|
|
: {}),
|
|
runtimeArtifactSignal: params.runtimeArtifactSignal,
|
|
config: params.config,
|
|
onStartedClient: (startedClient) => {
|
|
params.entry.client = startedClient;
|
|
for (const callback of params.entry.onStartedClientCallbacks) {
|
|
callback(startedClient);
|
|
}
|
|
retirePendingSharedClientEntryIfUnclaimed(params.key, params.entry);
|
|
},
|
|
onInitializedClient: () => initialized.resolve(),
|
|
}).then(
|
|
(client) => {
|
|
params.entry.client = client;
|
|
client.addCloseHandler((closedClient) =>
|
|
clearSharedClientEntryIfCurrent(params.key, closedClient),
|
|
);
|
|
return client;
|
|
},
|
|
(error: unknown) => {
|
|
initialized.reject(error);
|
|
throw error;
|
|
},
|
|
);
|
|
// Callers observe pre-initialize failures through the phase promise first.
|
|
void ready.catch(() => undefined);
|
|
return { initialized: initialized.promise, ready };
|
|
}
|
|
|
|
/** Starts a non-shared Codex app-server client owned entirely by the caller. */
|
|
export async function createIsolatedCodexAppServerClient(
|
|
options?: CodexAppServerClientOptions,
|
|
): Promise<CodexAppServerClient> {
|
|
if (options?.abandonSignal?.aborted) {
|
|
throw new Error("codex app-server initialize aborted");
|
|
}
|
|
const acquireStartedAt = Date.now();
|
|
const timeoutMs = options?.timeoutMs ?? 0;
|
|
const {
|
|
agentDir,
|
|
usesNativeAuth,
|
|
authProfileId,
|
|
authProfileStore,
|
|
preparedAuth,
|
|
requestedStartOptions,
|
|
startOptions,
|
|
} = await withCodexAppServerAcquireDeadline(
|
|
resolveCodexAppServerClientStartContext(options),
|
|
timeoutMs,
|
|
options?.abandonSignal,
|
|
);
|
|
return await startInitializedCodexAppServerClient({
|
|
requestedStartOptions,
|
|
startOptions,
|
|
agentDir,
|
|
authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId,
|
|
authProfileStore,
|
|
preparedAuth,
|
|
runtimeArtifactMode:
|
|
options?.runtimeArtifactMode ?? (options?.expectedRuntimeArtifact ? "capture" : undefined),
|
|
...(options?.expectedRuntimeArtifact
|
|
? { expectedRuntimeArtifact: options.expectedRuntimeArtifact }
|
|
: {}),
|
|
runtimeArtifactSignal: options?.abandonSignal,
|
|
config: options?.config,
|
|
timeoutMs: resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt),
|
|
abandonSignal: options?.abandonSignal,
|
|
onStartedClient: options?.onStartedClient,
|
|
});
|
|
}
|
|
|
|
async function startInitializedCodexAppServerClient(params: {
|
|
requestedStartOptions: CodexAppServerStartOptions;
|
|
startOptions: CodexAppServerStartOptions;
|
|
agentDir: string;
|
|
authProfileId: string | null | undefined;
|
|
authProfileStore?: AuthProfileStore;
|
|
runtimeArtifactMode?: "capture";
|
|
expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding;
|
|
runtimeArtifactSignal?: AbortSignal;
|
|
preparedAuth?: CodexAppServerResolvedPreparedAuth;
|
|
config?: CodexAppServerClientOptions["config"];
|
|
timeoutMs?: number;
|
|
abandonSignal?: AbortSignal;
|
|
onStartedClient?: (client: CodexAppServerClient) => void;
|
|
onInitializedClient?: () => void;
|
|
}): Promise<CodexAppServerClient> {
|
|
const acquireStartedAt = Date.now();
|
|
const timeoutMs = params.timeoutMs ?? 0;
|
|
const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions);
|
|
for (let index = 0; index < startOptionsCandidates.length; index += 1) {
|
|
const startOptions = startOptionsCandidates[index];
|
|
const runtimeArtifactModule = params.runtimeArtifactMode
|
|
? await import("./runtime-artifact.js")
|
|
: undefined;
|
|
const nativeCommandBeforeStart =
|
|
startOptions.commandSource === "resolved-managed"
|
|
? resolveManagedCodexNativeCommand(startOptions.command)
|
|
: undefined;
|
|
const runtimeArtifactBeforeStart = runtimeArtifactModule
|
|
? await runtimeArtifactModule.captureCodexAppServerRuntimeArtifactBeforeStart({
|
|
startOptions,
|
|
spawnIdentity: resolveCodexAppServerSpawnIdentity(startOptions, nativeCommandBeforeStart),
|
|
signal: params.runtimeArtifactSignal,
|
|
})
|
|
: undefined;
|
|
if (
|
|
runtimeArtifactModule &&
|
|
runtimeArtifactBeforeStart &&
|
|
params.expectedRuntimeArtifact &&
|
|
!runtimeArtifactModule.validateCodexAppServerRuntimeArtifactCapture(
|
|
params.expectedRuntimeArtifact,
|
|
runtimeArtifactBeforeStart,
|
|
)
|
|
) {
|
|
if (index + 1 < startOptionsCandidates.length) {
|
|
continue;
|
|
}
|
|
throw new Error("Codex app-server runtime artifact does not match verified inference");
|
|
}
|
|
const client = CodexAppServerClient.start(startOptions);
|
|
params.onStartedClient?.(client);
|
|
const initialize = client.initialize();
|
|
try {
|
|
await withCodexAppServerAcquireDeadline(
|
|
initialize,
|
|
resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt),
|
|
params.abandonSignal,
|
|
);
|
|
} catch (error) {
|
|
client.close();
|
|
void initialize.catch(() => undefined);
|
|
if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) {
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
params.onInitializedClient?.();
|
|
|
|
let runtimeArtifact: AgentHarnessRuntimeArtifactBinding | undefined;
|
|
try {
|
|
if (runtimeArtifactModule && runtimeArtifactBeforeStart) {
|
|
const nativeCommand =
|
|
startOptions.commandSource === "resolved-managed"
|
|
? resolveManagedCodexNativeCommand(startOptions.command)
|
|
: undefined;
|
|
runtimeArtifact = await runtimeArtifactModule.finalizeCodexAppServerRuntimeArtifact({
|
|
before: runtimeArtifactBeforeStart,
|
|
startOptions,
|
|
spawnIdentity: resolveCodexAppServerSpawnIdentity(startOptions, nativeCommand),
|
|
runtimeIdentity: client.getRuntimeIdentity(),
|
|
signal: params.runtimeArtifactSignal,
|
|
});
|
|
if (
|
|
params.expectedRuntimeArtifact &&
|
|
(runtimeArtifact.id !== params.expectedRuntimeArtifact.id ||
|
|
runtimeArtifact.fingerprint !== params.expectedRuntimeArtifact.fingerprint)
|
|
) {
|
|
throw new Error("Codex app-server runtime artifact does not match verified inference");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
client.close();
|
|
throw error;
|
|
}
|
|
ensureCodexAppServerClientRuntime(client, {
|
|
agentDir: params.agentDir,
|
|
authProfileId: params.authProfileId ?? undefined,
|
|
authMode: params.preparedAuth?.kind === "api-key" ? "prepared-api-key" : "profile",
|
|
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
|
|
config: params.config,
|
|
});
|
|
|
|
try {
|
|
await withCodexAppServerAcquireDeadline(
|
|
applyCodexAppServerAuthProfile({
|
|
client,
|
|
agentDir: params.agentDir,
|
|
authProfileId: params.authProfileId,
|
|
preparedAuth: params.preparedAuth,
|
|
startOptions,
|
|
config: params.config,
|
|
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
|
|
}),
|
|
resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt),
|
|
params.abandonSignal,
|
|
);
|
|
const nativeCommand =
|
|
startOptions.commandSource === "resolved-managed"
|
|
? resolveManagedCodexNativeCommand(startOptions.command)
|
|
: undefined;
|
|
if (runtimeArtifactModule && runtimeArtifact) {
|
|
runtimeArtifactModule.bindCodexAppServerRuntimeArtifact(client, runtimeArtifact);
|
|
}
|
|
getCodexAppServerClientStartMetadata().set(client, {
|
|
requestedStartOptions: params.requestedStartOptions,
|
|
startOptions,
|
|
agentDir: params.agentDir,
|
|
...(nativeCommand ? { nativeCommand } : {}),
|
|
});
|
|
const fenceKey = resolveCodexNativeConfigFenceKey({ client });
|
|
if (fenceKey) {
|
|
client.setThreadSessionRequestGuard(async (options) => {
|
|
const release = await acquireCodexNativeConfigFence(fenceKey, options);
|
|
try {
|
|
assertCodexAppServerClientStartSelectionCurrent({ client });
|
|
return release;
|
|
} catch (error) {
|
|
release();
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
return client;
|
|
} catch (error) {
|
|
client.close();
|
|
throw error;
|
|
}
|
|
}
|
|
throw new Error("Managed Codex app-server fallback candidates were exhausted.");
|
|
}
|
|
|
|
function resolveManagedFallbackStartOptions(
|
|
startOptions: CodexAppServerStartOptions,
|
|
): CodexAppServerStartOptions[] {
|
|
const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])];
|
|
const candidates: CodexAppServerStartOptions[] = [];
|
|
for (let index = 0; index < commands.length; index += 1) {
|
|
const command = commands[index];
|
|
const managedFallbackCommandPaths = commands.slice(index + 1);
|
|
const candidate = {
|
|
...startOptions,
|
|
command,
|
|
};
|
|
if (managedFallbackCommandPaths.length === 0) {
|
|
delete candidate.managedFallbackCommandPaths;
|
|
} else {
|
|
candidate.managedFallbackCommandPaths = managedFallbackCommandPaths;
|
|
}
|
|
candidates.push(candidate);
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function shouldTryManagedFallbackStartOption(
|
|
error: unknown,
|
|
startOptions: CodexAppServerStartOptions,
|
|
index: number,
|
|
startOptionsCandidates: readonly CodexAppServerStartOptions[],
|
|
): boolean {
|
|
return (
|
|
startOptions.commandSource === "resolved-managed" &&
|
|
index < startOptionsCandidates.length - 1 &&
|
|
isUnsupportedCodexAppServerVersionError(error)
|
|
);
|
|
}
|
|
|
|
/** Clears and closes all shared clients for deterministic tests. */
|
|
export function resetSharedCodexAppServerClientForTests(): void {
|
|
const state = getSharedCodexAppServerClientState();
|
|
const clients = collectSharedClients(state);
|
|
state.clients.clear();
|
|
state.leasedReleases = new WeakMap();
|
|
for (const client of clients) {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/** Clears and closes all shared clients. */
|
|
export function clearSharedCodexAppServerClient(): void {
|
|
const state = getSharedCodexAppServerClientState();
|
|
const clients = collectSharedClients(state);
|
|
state.clients.clear();
|
|
for (const client of clients) {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
/** Clears and closes the shared entry only if it still owns the supplied client. */
|
|
export function clearSharedCodexAppServerClientIfCurrent(
|
|
client: CodexAppServerClient | undefined,
|
|
): boolean {
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const [key, entry] of state.clients) {
|
|
if (entry.client === client) {
|
|
state.clients.delete(key);
|
|
client.close();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Detaches the shared entry without closing the client when it still matches. */
|
|
export function detachSharedCodexAppServerClientIfCurrent(
|
|
client: CodexAppServerClient | undefined,
|
|
): boolean {
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const [key, entry] of state.clients) {
|
|
if (entry.client === client) {
|
|
state.clients.delete(key);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Retains the matching shared client and returns a release callback. */
|
|
export function retainSharedCodexAppServerClientIfCurrent(
|
|
client: CodexAppServerClient | undefined,
|
|
): (() => void) | undefined {
|
|
if (!client) {
|
|
return undefined;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const entry of state.clients.values()) {
|
|
if (entry.client === client) {
|
|
return retainSharedClientEntry(entry);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Retires a matching shared client. Default is graceful: detach from the map
|
|
* (future acquisitions get a fresh client) and close once leases drain.
|
|
* `failActiveLeases` is for suspect clients only (timed-out turns): it closes
|
|
* the physical connection immediately so co-leased attempts hit the normal
|
|
* client-closed retry path, and pending acquires reject instead of leasing
|
|
* the poisoned process. Routine cleanup must NOT use it — it would abort
|
|
* healthy sibling turns on a working client.
|
|
*/
|
|
export function retireSharedCodexAppServerClientIfCurrent(
|
|
client: CodexAppServerClient | undefined,
|
|
opts?: { failActiveLeases?: boolean },
|
|
): { activeLeases: number; closed: boolean } | undefined {
|
|
if (!client) {
|
|
return undefined;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const [key, entry] of state.clients) {
|
|
if (entry.client === client) {
|
|
state.clients.delete(key);
|
|
entry.closeWhenIdle = true;
|
|
if (opts?.failActiveLeases) {
|
|
entry.closeError = new Error("codex app-server client is closed");
|
|
const closed = closeRetiredSharedClientEntry(entry);
|
|
if (closed) {
|
|
suspectClosedClients.add(client);
|
|
}
|
|
return { activeLeases: entry.activeLeases, closed };
|
|
}
|
|
const closed = closeRetiredSharedClientEntryIfIdle(entry);
|
|
return { activeLeases: entry.activeLeases, closed };
|
|
}
|
|
}
|
|
const activeLeases = state.leasedReleases.get(client)?.length ?? 0;
|
|
if (activeLeases > 0) {
|
|
// A gracefully detached client (e.g. one-shot cleanup) can still be leased
|
|
// when a later terminal-idle kill declares it suspect; the map miss must
|
|
// not let the poisoned process keep serving those co-leases.
|
|
if (opts?.failActiveLeases && !suspectClosedClients.has(client)) {
|
|
suspectClosedClients.add(client);
|
|
client.close();
|
|
return { activeLeases, closed: true };
|
|
}
|
|
return { activeLeases, closed: false };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Clears a matching shared client and waits for its process to exit. */
|
|
export async function clearSharedCodexAppServerClientIfCurrentAndWait(
|
|
client: CodexAppServerClient | undefined,
|
|
options?: {
|
|
exitTimeoutMs?: number;
|
|
forceKillDelayMs?: number;
|
|
},
|
|
): Promise<boolean> {
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const [key, entry] of state.clients) {
|
|
if (entry.client === client) {
|
|
state.clients.delete(key);
|
|
await client.closeAndWait(options);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Clears all shared clients and waits for their processes to exit. */
|
|
export async function clearSharedCodexAppServerClientAndWait(options?: {
|
|
exitTimeoutMs?: number;
|
|
forceKillDelayMs?: number;
|
|
}): Promise<void> {
|
|
const state = getSharedCodexAppServerClientState();
|
|
const clients = collectSharedClients(state);
|
|
state.clients.clear();
|
|
await Promise.all(clients.map((client) => client.closeAndWait(options)));
|
|
}
|
|
|
|
function getOrCreateSharedClientEntry(
|
|
state: SharedCodexAppServerClientState,
|
|
key: string,
|
|
): SharedCodexAppServerClientEntry {
|
|
let entry = state.clients.get(key);
|
|
if (!entry) {
|
|
entry = {
|
|
activeLeases: 0,
|
|
pendingAcquires: 0,
|
|
closeWhenIdle: false,
|
|
onStartedClientCallbacks: new Set(),
|
|
};
|
|
state.clients.set(key, entry);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClient): void {
|
|
const state = getSharedCodexAppServerClientState();
|
|
const entry = state.clients.get(key);
|
|
if (entry?.client === client) {
|
|
state.clients.delete(key);
|
|
}
|
|
}
|
|
|
|
/** Clears a matching shared client only when no lease or acquire currently claims it. */
|
|
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
|
|
client: CodexAppServerClient | undefined,
|
|
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
|
|
if (!client) {
|
|
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
for (const [key, entry] of state.clients) {
|
|
if (entry.client === client) {
|
|
return {
|
|
found: true,
|
|
closed: closeSharedClientEntryIfUnclaimed(key, entry),
|
|
activeLeases: entry.activeLeases,
|
|
pendingAcquires: entry.pendingAcquires,
|
|
};
|
|
}
|
|
}
|
|
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
|
}
|
|
|
|
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
|
|
let released = false;
|
|
entry.pendingAcquires += 1;
|
|
return () => {
|
|
if (released) {
|
|
return;
|
|
}
|
|
released = true;
|
|
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
|
|
closeRetiredSharedClientEntryIfIdle(entry);
|
|
};
|
|
}
|
|
|
|
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
|
|
let released = false;
|
|
entry.activeLeases += 1;
|
|
return () => {
|
|
if (released) {
|
|
return;
|
|
}
|
|
released = true;
|
|
entry.activeLeases = Math.max(0, entry.activeLeases - 1);
|
|
closeRetiredSharedClientEntryIfIdle(entry);
|
|
};
|
|
}
|
|
|
|
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
|
|
if (
|
|
!entry.closeWhenIdle ||
|
|
entry.activeLeases > 0 ||
|
|
entry.pendingAcquires > 0 ||
|
|
!entry.client
|
|
) {
|
|
return false;
|
|
}
|
|
const client = entry.client;
|
|
entry.closeWhenIdle = false;
|
|
entry.client = undefined;
|
|
client.close();
|
|
return true;
|
|
}
|
|
|
|
function closeRetiredSharedClientEntry(entry: SharedCodexAppServerClientEntry): boolean {
|
|
const client = entry.client;
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
entry.client = undefined;
|
|
client.close();
|
|
return true;
|
|
}
|
|
|
|
function closeSharedClientEntryIfUnclaimed(
|
|
key: string,
|
|
entry: SharedCodexAppServerClientEntry,
|
|
): boolean {
|
|
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
|
|
return false;
|
|
}
|
|
const state = getSharedCodexAppServerClientState();
|
|
if (state.clients.get(key) !== entry) {
|
|
return false;
|
|
}
|
|
state.clients.delete(key);
|
|
entry.client?.close();
|
|
return Boolean(entry.client);
|
|
}
|
|
|
|
function retirePendingSharedClientEntryIfUnclaimed(
|
|
key: string,
|
|
entry: SharedCodexAppServerClientEntry,
|
|
): void {
|
|
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
|
|
return;
|
|
}
|
|
entry.runtimeArtifactStartupAbort?.abort(
|
|
new Error("Codex runtime artifact startup was abandoned"),
|
|
);
|
|
entry.closeWhenIdle = true;
|
|
const state = getSharedCodexAppServerClientState();
|
|
if (state.clients.get(key) === entry) {
|
|
state.clients.delete(key);
|
|
}
|
|
if (!entry.client) {
|
|
return;
|
|
}
|
|
closeRetiredSharedClientEntry(entry);
|
|
}
|
|
|
|
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
|
return [
|
|
...new Set(
|
|
[...state.clients.values()]
|
|
.map((entry) => entry.client)
|
|
.filter((client): client is CodexAppServerClient => Boolean(client)),
|
|
),
|
|
];
|
|
}
|