Skip to content

Commit dd26e8c

Browse files
committed
feat: add Codex app-server harness extension
1 parent 44ec4d0 commit dd26e8c

27 files changed

Lines changed: 4639 additions & 0 deletions

.github/labeler.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@
297297
- changed-files:
298298
- any-glob-to-any-file:
299299
- "extensions/openai/**"
300+
"extensions: codex":
301+
- changed-files:
302+
- any-glob-to-any-file:
303+
- "extensions/codex/**"
300304
"extensions: kimi-coding":
301305
- changed-files:
302306
- any-glob-to-any-file:
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { callGatewayTool, type EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
4+
5+
vi.mock("openclaw/plugin-sdk/agent-harness", async (importOriginal) => ({
6+
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness")>()),
7+
callGatewayTool: vi.fn(),
8+
}));
9+
10+
const mockCallGatewayTool = vi.mocked(callGatewayTool);
11+
12+
function createParams(): EmbeddedRunAttemptParams {
13+
return {
14+
sessionKey: "agent:main:session-1",
15+
agentId: "main",
16+
messageChannel: "telegram",
17+
currentChannelId: "chat-1",
18+
agentAccountId: "default",
19+
currentThreadTs: "thread-ts",
20+
onAgentEvent: vi.fn(),
21+
} as unknown as EmbeddedRunAttemptParams;
22+
}
23+
24+
describe("Codex app-server approval bridge", () => {
25+
beforeEach(() => {
26+
mockCallGatewayTool.mockReset();
27+
});
28+
29+
it("routes command approvals through plugin approvals and accepts allowed commands", async () => {
30+
const params = createParams();
31+
mockCallGatewayTool
32+
.mockResolvedValueOnce({ id: "plugin:approval-1", status: "accepted" })
33+
.mockResolvedValueOnce({ id: "plugin:approval-1", decision: "allow-once" });
34+
35+
const result = await handleCodexAppServerApprovalRequest({
36+
method: "item/commandExecution/requestApproval",
37+
requestParams: {
38+
threadId: "thread-1",
39+
turnId: "turn-1",
40+
itemId: "cmd-1",
41+
command: "pnpm test extensions/codex/app-server",
42+
},
43+
paramsForRun: params,
44+
threadId: "thread-1",
45+
turnId: "turn-1",
46+
});
47+
48+
expect(result).toEqual({ decision: "accept" });
49+
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
50+
"plugin.approval.request",
51+
"plugin.approval.waitDecision",
52+
]);
53+
expect(mockCallGatewayTool).toHaveBeenCalledWith(
54+
"plugin.approval.request",
55+
expect.any(Object),
56+
expect.objectContaining({
57+
pluginId: "openclaw-codex-app-server",
58+
title: "Codex app-server command approval",
59+
twoPhase: true,
60+
turnSourceChannel: "telegram",
61+
turnSourceTo: "chat-1",
62+
}),
63+
{ expectFinal: false },
64+
);
65+
expect(params.onAgentEvent).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
stream: "approval",
68+
data: expect.objectContaining({ status: "pending", approvalId: "plugin:approval-1" }),
69+
}),
70+
);
71+
expect(params.onAgentEvent).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
stream: "approval",
74+
data: expect.objectContaining({ status: "approved", approvalId: "plugin:approval-1" }),
75+
}),
76+
);
77+
});
78+
79+
it("fails closed when no approval route is available", async () => {
80+
const params = createParams();
81+
mockCallGatewayTool.mockResolvedValueOnce({
82+
id: "plugin:approval-2",
83+
decision: null,
84+
});
85+
86+
const result = await handleCodexAppServerApprovalRequest({
87+
method: "item/fileChange/requestApproval",
88+
requestParams: {
89+
threadId: "thread-1",
90+
turnId: "turn-1",
91+
itemId: "patch-1",
92+
reason: "needs write access",
93+
},
94+
paramsForRun: params,
95+
threadId: "thread-1",
96+
turnId: "turn-1",
97+
});
98+
99+
expect(result).toEqual({ decision: "decline" });
100+
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
101+
expect(params.onAgentEvent).toHaveBeenCalledWith(
102+
expect.objectContaining({
103+
stream: "approval",
104+
data: expect.objectContaining({ status: "unavailable", reason: "needs write access" }),
105+
}),
106+
);
107+
});
108+
109+
it("maps app-server approval response families separately", () => {
110+
expect(buildApprovalResponse("execCommandApproval", undefined, "approved-session")).toEqual({
111+
decision: "approved_for_session",
112+
});
113+
expect(buildApprovalResponse("applyPatchApproval", undefined, "denied")).toEqual({
114+
decision: "denied",
115+
});
116+
expect(
117+
buildApprovalResponse(
118+
"item/permissions/requestApproval",
119+
{
120+
permissions: {
121+
network: { allowHosts: ["example.com"] },
122+
fileSystem: null,
123+
},
124+
},
125+
"approved-once",
126+
),
127+
).toEqual({
128+
permissions: { network: { allowHosts: ["example.com"] } },
129+
scope: "turn",
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)