Skip to content

Commit a4c2e7f

Browse files
committed
refactor(codex): split app-server attempt seams
1 parent 1a34c48 commit a4c2e7f

40 files changed

Lines changed: 16188 additions & 14811 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
3+
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
4+
5+
describe("Codex app-server policy", () => {
6+
it("keeps implicit Codex yolo approval policy when untrusted approvals are disallowed", () => {
7+
const appServer = resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null });
8+
9+
const resolved = resolveCodexAppServerForOpenClawToolPolicy({
10+
appServer,
11+
pluginConfig: readCodexPluginConfig({}),
12+
env: {},
13+
shouldPromote: true,
14+
canUseUntrustedApprovalPolicy: false,
15+
});
16+
17+
expect(resolved.approvalPolicy).toBe("never");
18+
});
19+
20+
it("promotes implicit yolo approval policy when OpenClaw tool policy requires review", () => {
21+
const appServer = resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null });
22+
23+
const resolved = resolveCodexAppServerForOpenClawToolPolicy({
24+
appServer,
25+
pluginConfig: readCodexPluginConfig({}),
26+
env: {},
27+
shouldPromote: true,
28+
canUseUntrustedApprovalPolicy: true,
29+
});
30+
31+
expect(resolved.approvalPolicy).toBe("untrusted");
32+
});
33+
34+
it("preserves explicit operator app-server policy", () => {
35+
const appServer = resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null });
36+
const requirementsAppServer = resolveCodexAppServerRuntimeOptions({
37+
env: {},
38+
requirementsToml:
39+
'allowed_approval_policies = ["never"]\nallowed_sandbox_modes = ["workspace-write"]\n',
40+
});
41+
42+
const explicitConfig = resolveCodexAppServerForOpenClawToolPolicy({
43+
appServer,
44+
pluginConfig: readCodexPluginConfig({ appServer: { mode: "yolo" } }),
45+
env: {},
46+
shouldPromote: true,
47+
canUseUntrustedApprovalPolicy: true,
48+
});
49+
const explicitEnv = resolveCodexAppServerForOpenClawToolPolicy({
50+
appServer,
51+
pluginConfig: readCodexPluginConfig({}),
52+
env: { OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY: "never" },
53+
shouldPromote: true,
54+
canUseUntrustedApprovalPolicy: true,
55+
});
56+
const explicitRequirements = resolveCodexAppServerForOpenClawToolPolicy({
57+
appServer: requirementsAppServer,
58+
pluginConfig: readCodexPluginConfig({}),
59+
env: {},
60+
shouldPromote: true,
61+
canUseUntrustedApprovalPolicy: true,
62+
});
63+
64+
expect(explicitConfig.approvalPolicy).toBe("never");
65+
expect(explicitEnv.approvalPolicy).toBe("never");
66+
expect(explicitRequirements.approvalPolicy).toBe("never");
67+
});
68+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { CodexAppServerRuntimeOptions, CodexPluginConfig } from "./config.js";
2+
3+
export function resolveCodexAppServerForOpenClawToolPolicy(params: {
4+
appServer: CodexAppServerRuntimeOptions;
5+
pluginConfig: CodexPluginConfig;
6+
env: NodeJS.ProcessEnv;
7+
shouldPromote: boolean;
8+
canUseUntrustedApprovalPolicy: boolean;
9+
}): CodexAppServerRuntimeOptions {
10+
if (
11+
!params.shouldPromote ||
12+
!params.canUseUntrustedApprovalPolicy ||
13+
params.appServer.approvalPolicy !== "never"
14+
) {
15+
return params.appServer;
16+
}
17+
const explicitMode =
18+
params.pluginConfig.appServer?.mode !== undefined ||
19+
isCodexAppServerPolicyMode(params.env.OPENCLAW_CODEX_APP_SERVER_MODE);
20+
const explicitApprovalPolicy =
21+
params.pluginConfig.appServer?.approvalPolicy !== undefined ||
22+
isCodexAppServerApprovalPolicy(params.env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ||
23+
params.appServer.approvalPolicySource === "requirements";
24+
if (explicitMode || explicitApprovalPolicy) {
25+
return params.appServer;
26+
}
27+
return {
28+
...params.appServer,
29+
approvalPolicy: "untrusted",
30+
};
31+
}
32+
33+
function isCodexAppServerPolicyMode(value: unknown): boolean {
34+
return value === "guardian" || value === "yolo";
35+
}
36+
37+
function isCodexAppServerApprovalPolicy(value: unknown): boolean {
38+
return (
39+
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
40+
);
41+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
interruptCodexTurnBestEffort,
4+
unsubscribeCodexThreadBestEffort,
5+
} from "./attempt-client-cleanup.js";
6+
7+
describe("Codex app-server attempt client cleanup", () => {
8+
it("interrupts turns with optional request timeout", () => {
9+
const request = vi.fn(async () => ({}));
10+
11+
interruptCodexTurnBestEffort({ request } as never, {
12+
threadId: "thread-1",
13+
turnId: "turn-1",
14+
timeoutMs: 123,
15+
});
16+
17+
expect(request).toHaveBeenCalledWith(
18+
"turn/interrupt",
19+
{ threadId: "thread-1", turnId: "turn-1" },
20+
{ timeoutMs: 123 },
21+
);
22+
});
23+
24+
it("swallows unsubscribe cleanup failures", async () => {
25+
const request = vi.fn(async () => {
26+
throw new Error("already gone");
27+
});
28+
29+
await expect(
30+
unsubscribeCodexThreadBestEffort({ request } as never, {
31+
threadId: "thread-1",
32+
timeoutMs: 123,
33+
}),
34+
).resolves.toBeUndefined();
35+
36+
expect(request).toHaveBeenCalledWith(
37+
"thread/unsubscribe",
38+
{ threadId: "thread-1" },
39+
{ timeoutMs: 123 },
40+
);
41+
});
42+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
2+
import type { CodexAppServerClient } from "./client.js";
3+
import { retireSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
4+
5+
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
6+
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
7+
8+
export function interruptCodexTurnBestEffort(
9+
client: CodexAppServerClient,
10+
params: {
11+
threadId: string;
12+
turnId: string;
13+
timeoutMs?: number;
14+
},
15+
): void {
16+
const requestOptions =
17+
params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
18+
? { timeoutMs: params.timeoutMs }
19+
: undefined;
20+
const requestParams = { threadId: params.threadId, turnId: params.turnId };
21+
try {
22+
const interrupt = requestOptions
23+
? client.request("turn/interrupt", requestParams, requestOptions)
24+
: client.request("turn/interrupt", requestParams);
25+
void Promise.resolve(interrupt).catch((error: unknown) => {
26+
embeddedAgentLog.debug("codex app-server turn interrupt failed during abort", { error });
27+
});
28+
} catch (error) {
29+
embeddedAgentLog.debug("codex app-server turn interrupt failed during abort", { error });
30+
}
31+
}
32+
33+
export async function unsubscribeCodexThreadBestEffort(
34+
client: CodexAppServerClient,
35+
params: {
36+
threadId: string;
37+
timeoutMs: number;
38+
},
39+
): Promise<void> {
40+
try {
41+
await client.request(
42+
"thread/unsubscribe",
43+
{ threadId: params.threadId },
44+
{ timeoutMs: params.timeoutMs },
45+
);
46+
} catch (error) {
47+
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
48+
threadId: params.threadId,
49+
error,
50+
});
51+
}
52+
}
53+
54+
export async function retireCodexAppServerClientAfterTimedOutTurn(
55+
client: CodexAppServerClient,
56+
params: {
57+
threadId: string;
58+
turnId: string;
59+
reason: string;
60+
},
61+
): Promise<void> {
62+
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
63+
const detachedSharedClient = Boolean(retiredSharedClient);
64+
interruptCodexTurnBestEffort(client, {
65+
threadId: params.threadId,
66+
turnId: params.turnId,
67+
timeoutMs: CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS,
68+
});
69+
await unsubscribeCodexThreadBestEffort(client, {
70+
threadId: params.threadId,
71+
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
72+
});
73+
let closedClient = retiredSharedClient?.closed ?? false;
74+
if (!detachedSharedClient) {
75+
const close = (client as { close?: () => void }).close;
76+
if (typeof close === "function") {
77+
try {
78+
close.call(client);
79+
closedClient = true;
80+
} catch (error) {
81+
embeddedAgentLog.debug("codex app-server client close failed during timeout cleanup", {
82+
threadId: params.threadId,
83+
turnId: params.turnId,
84+
error,
85+
});
86+
}
87+
}
88+
}
89+
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
90+
threadId: params.threadId,
91+
turnId: params.turnId,
92+
reason: params.reason,
93+
detachedSharedClient,
94+
closedClient,
95+
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
96+
});
97+
}

0 commit comments

Comments
 (0)