Skip to content

Commit dd3a00a

Browse files
authored
fix(irc): sanitize internal tool-trace lines from outbound text
1 parent 1fd8820 commit dd3a00a

37 files changed

Lines changed: 1468 additions & 66 deletions

docs/gateway/configuration-reference.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
316316
plugin/app support for the Codex harness. Default: `false`.
317317
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
318318
default destructive-action policy for migrated plugin app elicitations.
319+
Use `true` to accept safe Codex approval schemas without prompting, `false`
320+
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
321+
plugin approvals, or `"always"` to ask for every plugin write/destructive
322+
action without durable approval. The `"always"` mode clears durable Codex
323+
per-tool approval overrides for the affected app before starting the thread.
319324
Default: `true`.
320325
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
321326
migrated plugin entry when global `codexPlugins.enabled` is also true.
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
326331
Codex plugin identity from migration, for example `"google-calendar"`.
327332
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
328333
per-plugin destructive-action override. When omitted, the global
329-
`allow_destructive_actions` value is used.
334+
`allow_destructive_actions` value is used. The per-plugin value accepts the
335+
same `true`, `false`, `"auto"`, or `"always"` policies.
330336

331337
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
332338
entries written by migration are the durable install and repair eligibility set.

docs/plugins/codex-native-plugins.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,11 @@ enabled.
200200

201201
OpenClaw sets app-level `destructive_enabled` from the effective global or
202202
per-plugin `allow_destructive_actions` policy and lets Codex enforce
203-
destructive tool metadata from its native app tool annotations. `true` and
204-
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
205-
`_default` app config is disabled with `open_world_enabled: false`. Enabled
206-
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
207-
expose a separate plugin open-world policy knob and does not maintain
203+
destructive tool metadata from its native app tool annotations. `true`,
204+
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
205+
false. The `_default` app config is disabled with `open_world_enabled: false`.
206+
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
207+
not expose a separate plugin open-world policy knob and does not maintain
208208
per-plugin destructive tool-name deny lists.
209209

210210
Tool approval mode is automatic by default for plugin apps so non-destructive
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
225225
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
226226
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
227227
plugin approvals before returning the Codex approval response.
228+
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
229+
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
230+
app before the thread starts, and only offers one-shot approval or denial so
231+
durable approvals cannot suppress later write-action prompts.
228232
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
229233
id, or an unsafe elicitation schema declines instead of prompting.
230234

@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
272276
establishes a new harness session or replaces a stale binding.
273277

274278
**Destructive action is declined:** check the global and per-plugin
275-
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
276-
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
279+
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
280+
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
281+
closed.
277282

278283
## Related
279284

extensions/clickclack/src/http-client.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,81 @@
1+
import { createServer, type Server } from "node:http";
12
import { describe, expect, it, vi } from "vitest";
23
import { createClickClackClient } from "./http-client.js";
34

5+
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
6+
7+
async function listenLoopbackServer(server: Server): Promise<number> {
8+
return await new Promise((resolve, reject) => {
9+
server.once("error", reject);
10+
server.listen(0, "127.0.0.1", () => {
11+
server.off("error", reject);
12+
const address = server.address();
13+
if (!address || typeof address === "string") {
14+
reject(new Error("expected loopback TCP address"));
15+
return;
16+
}
17+
resolve(address.port);
18+
});
19+
});
20+
}
21+
22+
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
23+
let resolveClosed: (sentBytes: number) => void = () => {};
24+
const closed = new Promise<number>((resolve) => {
25+
resolveClosed = resolve;
26+
});
27+
const server = createServer((req, res) => {
28+
let sentBytes = 0;
29+
let stopped = false;
30+
let prefixSent = false;
31+
const prefixChunk = Buffer.from('{"user":{"id":"');
32+
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
33+
const suffixChunk = Buffer.from('"}}');
34+
const writeBuffer = (buffer: Buffer) => {
35+
sentBytes += buffer.length;
36+
if (!res.write(buffer)) {
37+
res.once("drain", writeChunks);
38+
return false;
39+
}
40+
return true;
41+
};
42+
const writeChunks = () => {
43+
if (!prefixSent) {
44+
prefixSent = true;
45+
if (!writeBuffer(prefixChunk)) {
46+
return;
47+
}
48+
}
49+
while (true) {
50+
if (stopped) {
51+
return;
52+
}
53+
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
54+
break;
55+
}
56+
if (!writeBuffer(bodyChunk)) {
57+
return;
58+
}
59+
}
60+
if (!stopped) {
61+
sentBytes += suffixChunk.length;
62+
res.end(suffixChunk);
63+
}
64+
};
65+
res.writeHead(200, { connection: "close", "content-type": "application/json" });
66+
res.on("close", () => {
67+
stopped = true;
68+
resolveClosed(sentBytes);
69+
});
70+
req.on("aborted", () => {
71+
stopped = true;
72+
res.destroy();
73+
});
74+
writeChunks();
75+
});
76+
return { server, closed };
77+
}
78+
479
function streamedErrorResponse(body: string, limit: number) {
580
const encoded = new TextEncoder().encode(body);
681
let readCount = 0;
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) {
39114
}
40115

41116
describe("ClickClack HTTP client", () => {
117+
it("bounds oversized success JSON responses and closes the stream early", async () => {
118+
const { server, closed } = createOversizedJsonServer();
119+
const port = await listenLoopbackServer(server);
120+
const client = createClickClackClient({
121+
baseUrl: `http://127.0.0.1:${port}`,
122+
token: "test-token",
123+
});
124+
125+
try {
126+
await expect(client.me()).rejects.toThrow(
127+
"ClickClack response: JSON response exceeds 16777216 bytes",
128+
);
129+
const sentBytes = await closed;
130+
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
131+
} finally {
132+
server.close();
133+
}
134+
});
135+
42136
it("bounds error response bodies without using raw response.text()", async () => {
43137
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
44138
const fetchMock = vi.fn(async () => streamed.response);

extensions/clickclack/src/http-client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
33
* delivery code.
44
*/
5-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
5+
import {
6+
readProviderJsonResponse,
7+
readResponseTextLimited,
8+
} from "openclaw/plugin-sdk/provider-http";
69
import { WebSocket } from "ws";
710
import type {
811
ClickClackChannel,
@@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) {
4447
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
4548
throw new Error(`ClickClack ${response.status}: ${detail}`);
4649
}
47-
return (await response.json()) as T;
50+
return await readProviderJsonResponse<T>(response, "ClickClack response");
4851
}
4952

5053
return {

extensions/codex/doctor-contract-api.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
3636
},
3737
}),
3838
).toBe(false);
39+
expect(
40+
legacyConfigRules[1]?.match({
41+
allow_destructive_actions: "always",
42+
plugins: {
43+
"google-calendar": { allow_destructive_actions: "always" },
44+
},
45+
}),
46+
).toBe(false);
3947
});
4048

4149
it("removes the retired dynamic tools profile without dropping other Codex config", () => {

extensions/codex/openclaw.plugin.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"default": false
102102
},
103103
"allow_destructive_actions": {
104-
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
104+
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
105105
"default": true
106106
},
107107
"plugins": {
@@ -121,7 +121,7 @@
121121
"type": "string"
122122
},
123123
"allow_destructive_actions": {
124-
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
124+
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
125125
}
126126
}
127127
}
@@ -343,7 +343,7 @@
343343
},
344344
"codexPlugins.allow_destructive_actions": {
345345
"label": "Allow Destructive Plugin Actions",
346-
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
346+
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
347347
"advanced": true
348348
},
349349
"codexPlugins.plugins": {

extensions/codex/src/app-server/attempt-startup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
346346
timeoutMs: params.appServer.requestTimeoutMs,
347347
signal,
348348
}),
349+
configCwd: startupExecutionCwd,
349350
appCache: defaultCodexAppInventoryCache,
350351
appCacheKey: pluginAppCacheKey,
351352
}),

extensions/codex/src/app-server/config.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
11921192
});
11931193
});
11941194

1195+
it("parses always native Codex plugin destructive policy", () => {
1196+
const config = readCodexPluginConfig({
1197+
codexPlugins: {
1198+
enabled: true,
1199+
allow_destructive_actions: "always",
1200+
plugins: {
1201+
"google-calendar": {
1202+
marketplaceName: "openai-curated",
1203+
pluginName: "google-calendar",
1204+
},
1205+
slack: {
1206+
marketplaceName: "openai-curated",
1207+
pluginName: "slack",
1208+
allow_destructive_actions: "auto",
1209+
},
1210+
},
1211+
},
1212+
});
1213+
1214+
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
1215+
expect(resolveCodexPluginsPolicy(config)).toEqual({
1216+
configured: true,
1217+
enabled: true,
1218+
allowDestructiveActions: true,
1219+
destructiveApprovalMode: "always",
1220+
pluginPolicies: [
1221+
{
1222+
configKey: "google-calendar",
1223+
marketplaceName: "openai-curated",
1224+
pluginName: "google-calendar",
1225+
enabled: true,
1226+
allowDestructiveActions: true,
1227+
destructiveApprovalMode: "always",
1228+
},
1229+
{
1230+
configKey: "slack",
1231+
marketplaceName: "openai-curated",
1232+
pluginName: "slack",
1233+
enabled: true,
1234+
allowDestructiveActions: true,
1235+
destructiveApprovalMode: "auto",
1236+
},
1237+
],
1238+
});
1239+
});
1240+
11951241
it("rejects unsupported native Codex plugin destructive policy strings", () => {
11961242
const config = readCodexPluginConfig({
11971243
codexPlugins: {

extensions/codex/src/app-server/config.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
7474
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
7575
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
7676
export type CodexDynamicToolsLoading = "searchable" | "direct";
77-
export type CodexPluginDestructivePolicy = boolean | "auto";
78-
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
77+
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
78+
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
7979

8080
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
8181

@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
311311
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
312312
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
313313
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
314-
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
314+
const codexPluginDestructivePolicySchema = z.union([
315+
z.boolean(),
316+
z.literal("auto"),
317+
z.literal("always"),
318+
]);
315319
const codexAppServerServiceTierSchema = z
316320
.preprocess(
317321
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
495499
allowDestructiveActions: boolean;
496500
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
497501
} {
498-
if (policy === "auto") {
499-
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
502+
if (policy === "auto" || policy === "always") {
503+
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
500504
}
501505
return {
502506
allowDestructiveActions: policy,

0 commit comments

Comments
 (0)