Skip to content

Commit b4fdd14

Browse files
committed
fix(codex): expose sandbox shell tools for ssh backends
1 parent 1c3ff34 commit b4fdd14

3 files changed

Lines changed: 268 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai
9292
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.
9393
- Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.
9494
- Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.
95+
- Codex app-server: expose OpenClaw's sandbox-routed shell as `sandbox_exec`/`sandbox_process` for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.
9596
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
9697
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
9798
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 186 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ import * as authBridge from "./auth-bridge.js";
3939
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
4040
import type { CodexAppServerClientFactory } from "./client-factory.js";
4141
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
42-
import { CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE } from "./dynamic-tools.js";
42+
import {
43+
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
44+
createCodexDynamicToolBridge,
45+
} from "./dynamic-tools.js";
4346
import * as elicitationBridge from "./elicitation-bridge.js";
4447
import {
4548
buildCodexPluginAppCacheKey,
@@ -463,9 +466,14 @@ function createNamedDynamicTool(
463466
};
464467
}
465468

466-
function createRuntimeDynamicTool(name: string) {
469+
type RuntimeDynamicToolForTest = Parameters<
470+
typeof createCodexDynamicToolBridge
471+
>[0]["tools"][number];
472+
473+
function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
467474
return {
468475
name,
476+
label: name,
469477
description: `${name} test tool`,
470478
parameters: {
471479
type: "object",
@@ -476,7 +484,7 @@ function createRuntimeDynamicTool(name: string) {
476484
content: [{ type: "text" as const, text: `${name} done` }],
477485
details: {},
478486
})),
479-
} as never;
487+
};
480488
}
481489

482490
function createPluginAppConfigPatch() {
@@ -696,6 +704,177 @@ describe("runCodexAppServerAttempt", () => {
696704
expect(testing.resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
697705
});
698706

707+
it("exposes OpenClaw sandbox shell tools under distinct names for non-Docker sandbox backends", async () => {
708+
testing.setOpenClawCodingToolsFactoryForTests(() => [
709+
createRuntimeDynamicTool("read"),
710+
createRuntimeDynamicTool("write"),
711+
createRuntimeDynamicTool("edit"),
712+
createRuntimeDynamicTool("apply_patch"),
713+
createRuntimeDynamicTool("exec"),
714+
createRuntimeDynamicTool("process"),
715+
createRuntimeDynamicTool("message"),
716+
]);
717+
const sessionFile = path.join(tempDir, "session.jsonl");
718+
const workspaceDir = path.join(tempDir, "workspace");
719+
const params = createParams(sessionFile, workspaceDir);
720+
params.disableTools = false;
721+
const sandboxSessionKey = params.sessionKey;
722+
if (!sandboxSessionKey) {
723+
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
724+
}
725+
726+
const tools = await testing.buildDynamicTools({
727+
params,
728+
resolvedWorkspace: workspaceDir,
729+
effectiveWorkspace: workspaceDir,
730+
sandboxSessionKey,
731+
sandbox: { enabled: true, backendId: "ssh" } as never,
732+
runAbortController: new AbortController(),
733+
sessionAgentId: "main",
734+
pluginConfig: {},
735+
onYieldDetected: () => undefined,
736+
});
737+
738+
expect(tools.map((tool) => tool.name)).toEqual(["message", "sandbox_exec", "sandbox_process"]);
739+
expect(tools.find((tool) => tool.name === "sandbox_exec")?.description).toContain(
740+
"configured sandbox backend",
741+
);
742+
expect(tools.find((tool) => tool.name === "sandbox_process")?.description).toContain(
743+
"sandbox_exec sessions",
744+
);
745+
});
746+
747+
it("does not expose sandbox shell tools unless non-Docker sandbox routing is available", async () => {
748+
testing.setOpenClawCodingToolsFactoryForTests(() => [
749+
createRuntimeDynamicTool("exec"),
750+
createRuntimeDynamicTool("process"),
751+
createRuntimeDynamicTool("message"),
752+
]);
753+
const sessionFile = path.join(tempDir, "session.jsonl");
754+
const workspaceDir = path.join(tempDir, "workspace");
755+
const params = createParams(sessionFile, workspaceDir);
756+
params.disableTools = false;
757+
const sandboxSessionKey = params.sessionKey;
758+
if (!sandboxSessionKey) {
759+
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
760+
}
761+
762+
const dockerTools = await testing.buildDynamicTools({
763+
params,
764+
resolvedWorkspace: workspaceDir,
765+
effectiveWorkspace: workspaceDir,
766+
sandboxSessionKey,
767+
sandbox: { enabled: true, backendId: "docker" } as never,
768+
runAbortController: new AbortController(),
769+
sessionAgentId: "main",
770+
pluginConfig: {},
771+
onYieldDetected: () => undefined,
772+
});
773+
const disabledSandboxTools = await testing.buildDynamicTools({
774+
params,
775+
resolvedWorkspace: workspaceDir,
776+
effectiveWorkspace: workspaceDir,
777+
sandboxSessionKey,
778+
sandbox: { enabled: false, backendId: "ssh" } as never,
779+
runAbortController: new AbortController(),
780+
sessionAgentId: "main",
781+
pluginConfig: {},
782+
onYieldDetected: () => undefined,
783+
});
784+
785+
expect(dockerTools.map((tool) => tool.name)).toEqual(["message"]);
786+
expect(disabledSandboxTools.map((tool) => tool.name)).toEqual(["message"]);
787+
});
788+
789+
it("does not expose sandbox_exec without a matching process follow-up tool", async () => {
790+
testing.setOpenClawCodingToolsFactoryForTests(() => [
791+
createRuntimeDynamicTool("exec"),
792+
createRuntimeDynamicTool("message"),
793+
]);
794+
const sessionFile = path.join(tempDir, "session.jsonl");
795+
const workspaceDir = path.join(tempDir, "workspace");
796+
const params = createParams(sessionFile, workspaceDir);
797+
params.disableTools = false;
798+
const sandboxSessionKey = params.sessionKey;
799+
if (!sandboxSessionKey) {
800+
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
801+
}
802+
803+
const tools = await testing.buildDynamicTools({
804+
params,
805+
resolvedWorkspace: workspaceDir,
806+
effectiveWorkspace: workspaceDir,
807+
sandboxSessionKey,
808+
sandbox: { enabled: true, backendId: "ssh" } as never,
809+
runAbortController: new AbortController(),
810+
sessionAgentId: "main",
811+
pluginConfig: {},
812+
onYieldDetected: () => undefined,
813+
});
814+
815+
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
816+
});
817+
818+
it("honors Codex dynamic tool excludes for sandbox shell exposure", async () => {
819+
testing.setOpenClawCodingToolsFactoryForTests(() => [
820+
createRuntimeDynamicTool("exec"),
821+
createRuntimeDynamicTool("process"),
822+
createRuntimeDynamicTool("message"),
823+
]);
824+
const sessionFile = path.join(tempDir, "session.jsonl");
825+
const workspaceDir = path.join(tempDir, "workspace");
826+
const params = createParams(sessionFile, workspaceDir);
827+
params.disableTools = false;
828+
const sandboxSessionKey = params.sessionKey;
829+
if (!sandboxSessionKey) {
830+
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
831+
}
832+
833+
for (const excludedToolName of ["sandbox_exec", "process"]) {
834+
const tools = await testing.buildDynamicTools({
835+
params,
836+
resolvedWorkspace: workspaceDir,
837+
effectiveWorkspace: workspaceDir,
838+
sandboxSessionKey,
839+
sandbox: { enabled: true, backendId: "ssh" } as never,
840+
runAbortController: new AbortController(),
841+
sessionAgentId: "main",
842+
pluginConfig: { codexDynamicToolsExclude: [excludedToolName] },
843+
onYieldDetected: () => undefined,
844+
});
845+
846+
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
847+
}
848+
});
849+
850+
it("points yielded sandbox_exec follow-up guidance at sandbox_process", async () => {
851+
const execTool = createRuntimeDynamicTool("exec");
852+
vi.mocked(execTool.execute).mockResolvedValueOnce({
853+
content: [
854+
{
855+
type: "text",
856+
text: "Command still running (session exec-1, pid 123). Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
857+
},
858+
],
859+
details: { status: "running" },
860+
});
861+
const processTool = createRuntimeDynamicTool("process");
862+
const tools = testing.addSandboxShellDynamicToolsIfAvailable([], [execTool, processTool], {
863+
sandbox: { enabled: true, backendId: "ssh" },
864+
pluginConfig: {},
865+
} as never);
866+
867+
const sandboxExec = tools.find((tool) => tool.name === "sandbox_exec");
868+
const result = await sandboxExec?.execute("call-1", {}, undefined);
869+
870+
expect(result?.content).toEqual([
871+
{
872+
type: "text",
873+
text: "Command still running (session exec-1, pid 123). Use sandbox_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
874+
},
875+
]);
876+
});
877+
699878
it("starts Codex threads without duplicate OpenClaw workspace tools by default", async () => {
700879
const sessionFile = path.join(tempDir, "session.jsonl");
701880
const workspaceDir = path.join(tempDir, "workspace");
@@ -980,13 +1159,15 @@ describe("runCodexAppServerAttempt", () => {
9801159
});
9811160

9821161
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
983-
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));
1162+
const tools = ["exec", "sandbox_exec", "sandbox_process", "apply_patch", "read", "message"].map(
1163+
(name) => ({ name }),
1164+
);
9841165

9851166
expect(
9861167
testing
9871168
.filterCodexDynamicToolsForAllowlist(tools, [" BASH ", "apply-patch", "READ"])
9881169
.map((tool) => tool.name),
989-
).toEqual(["exec", "apply_patch", "read"]);
1170+
).toEqual(["exec", "sandbox_exec", "sandbox_process", "apply_patch", "read"]);
9901171
});
9911172

9921173
it("treats an explicit empty Codex dynamic toolsAllow as no tools", () => {

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

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ type OpenClawCodingToolsOptions = NonNullable<
207207
>;
208208
type OpenClawCodingToolsFactory =
209209
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
210+
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
210211
type CodexBootstrapContext = Awaited<ReturnType<typeof resolveBootstrapContextForRun>>;
211212
type CodexBootstrapFile = CodexBootstrapContext["bootstrapFiles"][number];
212213
type CodexSystemPromptReport = NonNullable<EmbeddedRunAttemptResult["systemPromptReport"]>;
@@ -3257,7 +3258,11 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
32573258
input.runAbortController.abort("sessions_yield");
32583259
},
32593260
});
3260-
const codexFilteredTools = filterCodexDynamicTools(allTools, input.pluginConfig);
3261+
const codexFilteredTools = addSandboxShellDynamicToolsIfAvailable(
3262+
filterCodexDynamicTools(allTools, input.pluginConfig),
3263+
allTools,
3264+
input,
3265+
);
32613266
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
32623267
modelHasVision,
32633268
hasInboundImages: (params.images?.length ?? 0) > 0,
@@ -3317,6 +3322,72 @@ function disableCodexPluginThreadConfig(pluginConfig?: unknown): CodexPluginConf
33173322
};
33183323
}
33193324

3325+
function addSandboxShellDynamicToolsIfAvailable(
3326+
filteredTools: OpenClawDynamicTool[],
3327+
allTools: OpenClawDynamicTool[],
3328+
input: DynamicToolBuildParams,
3329+
): OpenClawDynamicTool[] {
3330+
if (
3331+
!shouldExposeSandboxExecDynamicTool(input) ||
3332+
isSandboxShellDynamicToolExcluded(input.pluginConfig)
3333+
) {
3334+
return filteredTools;
3335+
}
3336+
const execTool = allTools.find((tool) => normalizeCodexDynamicToolName(tool.name) === "exec");
3337+
const processTool = allTools.find(
3338+
(tool) => normalizeCodexDynamicToolName(tool.name) === "process",
3339+
);
3340+
if (!execTool || !processTool) {
3341+
return filteredTools;
3342+
}
3343+
const sandboxExecTool: OpenClawDynamicTool = {
3344+
...execTool,
3345+
name: "sandbox_exec",
3346+
description:
3347+
"Run a shell command through OpenClaw's configured sandbox backend for this session. Use only when the command must execute in the OpenClaw sandbox backend, such as an SSH-backed sandbox. Use Codex's native shell for normal local workspace commands.",
3348+
execute: async (toolCallId, args, signal, onUpdate) => {
3349+
const result = await execTool.execute(toolCallId, args, signal, onUpdate);
3350+
return {
3351+
...result,
3352+
content: result.content.map((item) =>
3353+
item.type === "text"
3354+
? Object.assign({}, item, {
3355+
text: item.text.replace(
3356+
"Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
3357+
"Use sandbox_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
3358+
),
3359+
})
3360+
: item,
3361+
),
3362+
};
3363+
},
3364+
};
3365+
const sandboxProcessTool: OpenClawDynamicTool = {
3366+
...processTool,
3367+
name: "sandbox_process",
3368+
description:
3369+
"Manage sandbox_exec sessions that were started through OpenClaw's configured sandbox backend for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for sandbox_exec follow-up; use Codex's native shell session handling for normal native shell commands.",
3370+
};
3371+
return [...filteredTools, sandboxExecTool, sandboxProcessTool];
3372+
}
3373+
3374+
function shouldExposeSandboxExecDynamicTool(input: DynamicToolBuildParams): boolean {
3375+
const backendId = input.sandbox?.enabled ? input.sandbox.backendId.trim().toLowerCase() : "";
3376+
return Boolean(backendId && backendId !== "docker");
3377+
}
3378+
3379+
function isSandboxShellDynamicToolExcluded(config: CodexPluginConfig): boolean {
3380+
return (config.codexDynamicToolsExclude ?? []).some((name) => {
3381+
const normalized = normalizeCodexDynamicToolName(name);
3382+
return (
3383+
normalized === "exec" ||
3384+
normalized === "sandbox_exec" ||
3385+
normalized === "process" ||
3386+
normalized === "sandbox_process"
3387+
);
3388+
});
3389+
}
3390+
33203391
function filterCodexDynamicToolsForAllowlist<T extends { name: string }>(
33213392
tools: T[],
33223393
toolsAllow?: string[],
@@ -3333,7 +3404,14 @@ function filterCodexDynamicToolsForAllowlist<T extends { name: string }>(
33333404
const allowSet = new Set(
33343405
toolsAllow.map((name) => normalizeCodexDynamicToolName(name)).filter(Boolean),
33353406
);
3336-
return tools.filter((tool) => allowSet.has(normalizeCodexDynamicToolName(tool.name)));
3407+
return tools.filter((tool) => {
3408+
const normalized = normalizeCodexDynamicToolName(tool.name);
3409+
return (
3410+
allowSet.has(normalized) ||
3411+
(normalized === "sandbox_exec" && allowSet.has("exec")) ||
3412+
(normalized === "sandbox_process" && (allowSet.has("exec") || allowSet.has("process")))
3413+
);
3414+
});
33373415
}
33383416

33393417
function hasWildcardCodexToolsAllow(toolsAllow: string[]): boolean {
@@ -4496,6 +4574,7 @@ export const testing = {
44964574
buildDeveloperInstructions,
44974575
filterCodexDynamicTools,
44984576
buildDynamicTools,
4577+
addSandboxShellDynamicToolsIfAvailable,
44994578
filterCodexDynamicToolsForAllowlist,
45004579
filterToolsForVisionInputs,
45014580
hasWildcardCodexToolsAllow,

0 commit comments

Comments
 (0)