File size: 6,915 Bytes
253e783 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import { once } from "node:events";
import fs from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { testing as approvalsTesting } from "../infra/exec-approvals-store.test-support.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { requestExecHostViaSocket, type ExecHostRequest } from "../infra/exec-host.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withTestDir } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import { handleSystemRunInvoke } from "./invoke-system-run.js";
describe.runIf(process.platform !== "win32")("enforced exec host transport boundary", () => {
it("does not replay locally when a completed execution loses its socket response", async () => {
await withTestDir({ prefix: "oc-run-", parentDir: "/tmp" }, async (dir) => {
await withEnvAsync(
{ OPENCLAW_HOME: dir, OPENCLAW_STATE_DIR: path.join(dir, "state") },
async () => {
closeOpenClawStateDatabaseForTest();
approvalsTesting.reset();
const socketPath = path.join(dir, "host.sock");
const token = "enforced-exec-host-test-token";
const marker = path.join(dir, "executions");
const command = [
"/bin/sh",
"-c",
'printf "START\\nCOMPLETE\\n" >> "$1"',
"exec-proof",
marker,
];
const order: string[] = [];
const sockets: net.Socket[] = [];
const closes: Promise<void>[] = [];
const failures: unknown[] = [];
let handler = Promise.resolve();
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
sockets.push(socket);
closes.push(
new Promise<void>((resolve) => {
socket.once("close", resolve);
}),
);
socket.on("error", (error) => failures.push(error));
let wire = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
wire += chunk;
});
handler = (async () => {
await once(socket, "end");
const envelope = JSON.parse(wire) as {
nonce: string;
ts: number;
hmac: string;
requestJson: string;
};
expect(envelope.hmac).toBe(
crypto
.createHmac("sha256", token)
.update(`${envelope.nonce}:${envelope.ts}:${envelope.requestJson}`)
.digest("hex"),
);
const request = JSON.parse(envelope.requestJson) as ExecHostRequest;
expect(request.command).toEqual(command);
const [executable, ...args] = request.command;
assert.ok(executable, "Exec peer received an empty command");
const child = spawn(executable, args, {
cwd: dir,
env: { HOME: dir, PATH: "/usr/bin:/bin" },
stdio: "ignore",
});
expect(await once(child, "close")).toEqual([0, null]);
expect(await fs.readFile(marker, "utf8")).toBe("START\nCOMPLETE\n");
order.push("child-completed");
socket.end();
order.push("response-dropped");
})().catch((error: unknown) => {
failures.push(error);
socket.destroy();
});
});
try {
const listening = once(server, "listening");
server.listen(socketPath);
await listening;
saveExecApprovals({
version: 1,
socket: { path: socketPath, token },
defaults: { security: "full", ask: "off", autoAllowSkills: false },
agents: {},
});
const runCommand = vi.fn<Parameters<typeof handleSystemRunInvoke>[0]["runCommand"]>();
const sendInvokeResult = vi.fn();
const sendNodeEvent = vi.fn();
const sendExecFinishedEvent = vi.fn();
await handleSystemRunInvoke({
client: {
request: async () => {
throw new Error("Unexpected Gateway request");
},
},
params: { command, cwd: dir, sessionKey: "agent:main:proof" },
skillBins: { current: async () => [] },
execHostEnforced: true,
// Production defaults this preference to true; enforcement still forbids replay.
execHostFallbackAllowed: true,
preferMacAppExecHost: true,
resolveExecSecurity: () => "full",
resolveExecAsk: () => "off",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
getRuntimeConfig: () => ({}),
runCommand,
runViaMacAppExecHost: async ({ request }) => {
const response = await requestExecHostViaSocket({
socketPath,
token,
request,
timeoutMs: 2_000,
});
expect(response).toBeNull();
order.push("client-null");
return response;
},
sendInvokeResult,
sendNodeEvent,
sendExecFinishedEvent,
buildExecEventPayload: (payload) => payload,
});
await handler;
expect(failures).toEqual([]);
expect(order).toEqual(["child-completed", "response-dropped", "client-null"]);
expect(runCommand).not.toHaveBeenCalled();
expect(sendExecFinishedEvent).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ ok: false }),
);
expect(sendNodeEvent).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
"exec.denied",
expect.objectContaining({ host: "node" }),
);
expect(await fs.readFile(marker, "utf8")).toBe("START\nCOMPLETE\n");
} finally {
for (const socket of sockets) {
socket.destroy();
}
await handler;
await Promise.all(closes);
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
approvalsTesting.reset();
closeOpenClawStateDatabaseForTest();
}
},
);
});
});
});
|