docs: document exec tool entry

This commit is contained in:
Peter Steinberger
2026-06-04 06:16:34 -04:00
parent 045145c700
commit 2feb81249f
10 changed files with 160 additions and 103 deletions

View File

@@ -1,3 +1,8 @@
/**
* Foreground exec failure tests.
* Verifies failed process outcomes surface useful text/details for shell
* errors, timeouts, signals, and runtime failures.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SpawnInput } from "../process/supervisor/index.js";
import { captureEnv } from "../test-utils/env.js";

View File

@@ -1,3 +1,8 @@
/**
* Gateway exec approval E2E tests.
* Exercises a real gateway server approval flow, approval follow-up text, and
* approval timeout behavior in an isolated temp config.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -62,117 +67,121 @@ describe("gateway-hosted exec approvals", () => {
clearSessionStoreCacheForTest();
});
it("lets OpenClaw-style gateway tool calls request and wait for approval over separate connections", async () => {
const envSnapshot = captureEnv(TEST_ENV_KEYS);
cleanup.push(() => envSnapshot.restore());
it(
"lets OpenClaw-style gateway tool calls request and wait for approval over separate connections",
async () => {
const envSnapshot = captureEnv(TEST_ENV_KEYS);
cleanup.push(() => envSnapshot.restore());
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
const stateDir = path.join(tempHome, ".openclaw");
const workspaceDir = path.join(tempHome, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const stateDir = path.join(tempHome, ".openclaw");
const workspaceDir = path.join(tempHome, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const port = await getFreeGatewayPort();
const token = "exec-approval-e2e-token";
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
port,
auth: { mode: "token", token },
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
const port = await getFreeGatewayPort();
const token = "exec-approval-e2e-token";
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
port,
auth: { mode: "token", token },
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
},
},
},
null,
2,
)}\n`,
"utf8",
);
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_GATEWAY_PORT = String(port);
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
deferStartupSidecars: true,
});
cleanup.push(() => server.close());
const operator = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "approval operator",
mode: GATEWAY_CLIENT_MODES.TEST,
scopes: [ADMIN_SCOPE],
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
});
cleanup.push(() => disconnectGatewayClient(operator));
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
resolveOutcome = resolve;
});
const tool = createExecTool({
host: "gateway",
security: "allowlist",
ask: "always",
cwd: workspaceDir,
approvalRunningNoticeMs: 0,
approvalFollowupMode: "direct",
approvalFollowup: ({ outcome }) => {
resolveOutcome(outcome);
return undefined;
},
null,
2,
)}\n`,
"utf8",
);
});
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_GATEWAY_PORT = String(port);
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
const pending = await tool.execute("exec-approval-e2e", {
command: "printf 'smoke\\n'",
workdir: workspaceDir,
timeout: 5,
});
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
deferStartupSidecars: true,
});
cleanup.push(() => server.close());
expect(pending.details.status).toBe("approval-pending");
if (pending.details.status !== "approval-pending") {
throw new Error("expected approval-pending exec result");
}
const operator = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "approval operator",
mode: GATEWAY_CLIENT_MODES.TEST,
scopes: [ADMIN_SCOPE],
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
});
cleanup.push(() => disconnectGatewayClient(operator));
await operator.request(
"exec.approval.resolve",
{ id: pending.details.approvalId, decision: "allow-once" },
{ timeoutMs: 10_000 },
);
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
resolveOutcome = resolve;
});
const tool = createExecTool({
host: "gateway",
security: "allowlist",
ask: "always",
cwd: workspaceDir,
approvalRunningNoticeMs: 0,
approvalFollowupMode: "direct",
approvalFollowup: ({ outcome }) => {
resolveOutcome(outcome);
return undefined;
},
});
const pending = await tool.execute("exec-approval-e2e", {
command: "printf 'smoke\\n'",
workdir: workspaceDir,
timeout: 5,
});
expect(pending.details.status).toBe("approval-pending");
if (pending.details.status !== "approval-pending") {
throw new Error("expected approval-pending exec result");
}
await operator.request(
"exec.approval.resolve",
{ id: pending.details.approvalId, decision: "allow-once" },
{ timeoutMs: 10_000 },
);
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
expect(outcome.status).toBe("completed");
expect(outcome.exitCode).toBe(0);
expect(outcome.aggregated).toBe("smoke");
}, EXEC_APPROVAL_E2E_TIMEOUT_MS);
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
expect(outcome.status).toBe("completed");
expect(outcome.exitCode).toBe(0);
expect(outcome.aggregated).toBe("smoke");
},
EXEC_APPROVAL_E2E_TIMEOUT_MS,
);
});

View File

@@ -1,3 +1,8 @@
/**
* Exec approval id routing tests.
* Covers approval registration ids, follow-up idempotency, and approved
* node/gateway invocation behavior.
*/
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";

View File

@@ -1,3 +1,8 @@
/**
* Exec background abort tests.
* Ensures agent-turn aborts stop foreground execs but do not kill already
* backgrounded sessions.
*/
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
import { killProcessTree } from "../process/kill-tree.js";

View File

@@ -1,3 +1,8 @@
/**
* Exec PATH handling tests.
* Covers shell snapshot PATH merging, pathPrepend behavior, and host env
* sanitization for gateway/sandbox execution.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

View File

@@ -1,3 +1,8 @@
/**
* Exec PTY integration tests.
* Starts PTY sessions, polls them through the process tool, and verifies
* terminal input/output handling.
*/
import { afterEach, expect, test } from "vitest";
import { markBackgrounded, resetProcessRegistryForTests } from "./bash-process-registry.js";
import { runExecProcess } from "./bash-tools.exec-runtime.js";

View File

@@ -1,3 +1,8 @@
/**
* Resolve-exec-env hook tests.
* Verifies plugin-provided env values are filtered and forwarded to the chosen
* exec host without leaking unsafe overrides.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import type { ExtensionContext } from "./sessions/index.js";

View File

@@ -1,3 +1,8 @@
/**
* Exec script preflight tests.
* Covers Python/Node script file validation, shell-bleed detection, and
* symlink/path race handling before execution.
*/
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";

View File

@@ -1,3 +1,8 @@
/**
* Exec security floor tests.
* Verifies tool config and exec-approvals policy combine by tightening
* security/ask rather than silently broadening execution.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

View File

@@ -1,3 +1,8 @@
/**
* Exec tool factory and request pipeline.
* Resolves host/sandbox/node target, policy, approval, env, script preflight,
* process launch, foreground result, and background session handoff.
*/
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -1312,6 +1317,7 @@ function resolveExecReviewerDefaults(params: { defaults?: ExecToolDefaults; agen
return agentExec?.reviewer ?? cfg?.tools?.exec?.reviewer;
}
/** Creates an exec tool instance with runtime defaults and approval policy wiring. */
export function createExecTool(
defaults?: ExecToolDefaults,
): AgentToolWithMeta<typeof execSchema, ExecToolDetails> {
@@ -1974,8 +1980,10 @@ export function createExecTool(
};
}
/** Default exec tool instance used by agent tool registries. */
export const execTool = createExecTool();
/** Test-only seams for parser/preflight helpers. */
export const testing = {
parseOpenClawChannelsLoginShellCommand,
validateScriptFileForShellBleed,