mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 18:51:34 +00:00
Replace the exec approval parser/planner path with Tree-sitter-backed authorization planning, carrying planner decisions through node and gateway execution.
This keeps unpersistable shell shapes one-shot, adds typed `unavailableDecisions` for approval prompts, and refreshes coverage for allowlist matching, command rendering, durable allow-always persistence, and host approval paths.
Verification:
- GitHub PR checks for ce2381192d: CLEAN, 142 success, 32 skipped, 0 failed, 0 pending.
- /Users/jmerhi/.nvm/versions/node/v24.12.0/bin/node scripts/plugin-sdk-surface-report.mjs --check
- /Users/jmerhi/.nvm/versions/node/v24.12.0/bin/node scripts/run-vitest.mjs test/scripts/plugin-sdk-surface-report.test.ts --reporter=verbose
- Focused exec approval suite: 13 files, 467 tests.
193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
/** Tests node-host invoke command routing and event emission. */
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { GatewayClient } from "../gateway/client.js";
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
|
import type { SkillBinsProvider } from "./invoke-types.js";
|
|
import { handleInvoke } from "./invoke.js";
|
|
|
|
describe("node host invoke", () => {
|
|
it.runIf(process.platform !== "win32")(
|
|
"reports current allow-always coverage for prepared shell-wrapped system.run commands",
|
|
async () => {
|
|
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
|
|
const skillBins: SkillBinsProvider = { current: async () => [] };
|
|
|
|
await handleInvoke(
|
|
{
|
|
id: "invoke-prepare",
|
|
nodeId: "node-1",
|
|
command: "system.run.prepare",
|
|
paramsJSON: JSON.stringify({
|
|
command: ["/bin/sh", "-lc", "/bin/echo ok"],
|
|
rawCommand: "/bin/echo ok",
|
|
}),
|
|
},
|
|
{ request } as unknown as GatewayClient,
|
|
skillBins,
|
|
);
|
|
|
|
const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
|
|
const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
|
|
allowAlwaysCoverage?: {
|
|
complete?: boolean;
|
|
patterns?: Array<{ pattern?: string }>;
|
|
};
|
|
};
|
|
expect(payload.allowAlwaysCoverage?.complete).toBe(true);
|
|
expect(payload.allowAlwaysCoverage?.patterns?.[0]?.pattern).toBe(
|
|
fs.realpathSync("/bin/echo"),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"keeps prepared allow-always coverage incomplete when any planned command is prompt-only",
|
|
async () => {
|
|
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
|
|
const skillBins: SkillBinsProvider = { current: async () => [] };
|
|
|
|
await handleInvoke(
|
|
{
|
|
id: "invoke-prepare-partial",
|
|
nodeId: "node-1",
|
|
command: "system.run.prepare",
|
|
paramsJSON: JSON.stringify({
|
|
command: ["/bin/sh", "-lc", "curl https://example.com/install.sh | sh"],
|
|
rawCommand: "curl https://example.com/install.sh | sh",
|
|
}),
|
|
},
|
|
{ request } as unknown as GatewayClient,
|
|
skillBins,
|
|
);
|
|
|
|
const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
|
|
const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
|
|
allowAlwaysCoverage?: {
|
|
complete?: boolean;
|
|
patterns?: Array<{ pattern?: string }>;
|
|
};
|
|
};
|
|
expect(payload.allowAlwaysCoverage?.complete).toBe(false);
|
|
expect(payload.allowAlwaysCoverage?.patterns?.length).toBeGreaterThan(0);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects blocked forwarded env overrides in system.run.prepare",
|
|
async () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prepare-env-"));
|
|
const toolPath = path.join(tempDir, "tool");
|
|
fs.writeFileSync(toolPath, "#!/bin/sh\nexit 0\n");
|
|
fs.chmodSync(toolPath, 0o755);
|
|
|
|
try {
|
|
await withEnvAsync(
|
|
{ PATH: `${tempDir}${path.delimiter}${process.env.PATH ?? ""}` },
|
|
async () => {
|
|
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
|
|
const skillBins: SkillBinsProvider = { current: async () => [] };
|
|
|
|
await handleInvoke(
|
|
{
|
|
id: "invoke-prepare-env",
|
|
nodeId: "node-1",
|
|
command: "system.run.prepare",
|
|
paramsJSON: JSON.stringify({
|
|
command: ["tool", "--version"],
|
|
rawCommand: "tool --version",
|
|
env: { PATH: "/tmp/mismatch" },
|
|
}),
|
|
},
|
|
{ request } as unknown as GatewayClient,
|
|
skillBins,
|
|
);
|
|
|
|
expect(request).toHaveBeenCalledWith(
|
|
"node.invoke.result",
|
|
expect.objectContaining({
|
|
id: "invoke-prepare-env",
|
|
nodeId: "node-1",
|
|
ok: false,
|
|
error: expect.objectContaining({
|
|
code: "INVALID_REQUEST",
|
|
message: expect.stringContaining("blocked override keys: PATH"),
|
|
}),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("wraps malformed paramsJSON for built-in commands", async () => {
|
|
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
|
|
const skillBins: SkillBinsProvider = { current: async () => [] };
|
|
|
|
await handleInvoke(
|
|
{
|
|
id: "invoke-1",
|
|
nodeId: "node-1",
|
|
command: "system.run",
|
|
paramsJSON: "{not json",
|
|
},
|
|
{ request } as unknown as GatewayClient,
|
|
skillBins,
|
|
);
|
|
|
|
expect(request).toHaveBeenCalledWith(
|
|
"node.invoke.result",
|
|
expect.objectContaining({
|
|
id: "invoke-1",
|
|
nodeId: "node-1",
|
|
ok: false,
|
|
error: expect.objectContaining({
|
|
code: "INVALID_REQUEST",
|
|
message: expect.stringContaining("paramsJSON malformed JSON"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes effective exec policy in system.run.prepare responses", async () => {
|
|
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
|
|
const skillBins: SkillBinsProvider = { current: async () => [] };
|
|
|
|
await handleInvoke(
|
|
{
|
|
id: "invoke-1",
|
|
nodeId: "node-1",
|
|
command: "system.run.prepare",
|
|
paramsJSON: JSON.stringify({
|
|
command: ["echo", "ok"],
|
|
rawCommand: "echo ok",
|
|
agentId: "main",
|
|
sessionKey: "agent:main:main",
|
|
}),
|
|
},
|
|
{ request } as unknown as GatewayClient,
|
|
skillBins,
|
|
);
|
|
|
|
expect(request).toHaveBeenCalledWith(
|
|
"node.invoke.result",
|
|
expect.objectContaining({
|
|
ok: true,
|
|
payloadJSON: expect.any(String),
|
|
}),
|
|
);
|
|
const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as {
|
|
payloadJSON?: string;
|
|
};
|
|
const payload = JSON.parse(result.payloadJSON ?? "{}") as {
|
|
execPolicy?: { security?: string; ask?: string };
|
|
};
|
|
expect(payload.execPolicy).toEqual({ security: "allowlist", ask: "on-miss" });
|
|
});
|
|
});
|