mirror of https://github.com/openclaw/openclaw.git
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
|
|
import type { OpenShellSandboxBackend } from "./backend.js";
|
|
import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
async function makeTempDir(prefix: string) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
function translateRemotePath(value: string, roots: { workspace: string; agent: string }) {
|
|
if (value === "/sandbox" || value.startsWith("/sandbox/")) {
|
|
return path.join(roots.workspace, value.slice("/sandbox".length));
|
|
}
|
|
if (value === "/agent" || value.startsWith("/agent/")) {
|
|
return path.join(roots.agent, value.slice("/agent".length));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function runLocalShell(params: {
|
|
script: string;
|
|
args?: string[];
|
|
stdin?: Buffer | string;
|
|
allowFailure?: boolean;
|
|
roots: { workspace: string; agent: string };
|
|
}) {
|
|
const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots));
|
|
const script = normalizeScriptForLocalShell(params.script);
|
|
const result = await new Promise<{ stdout: Buffer; stderr: Buffer; code: number }>(
|
|
(resolve, reject) => {
|
|
const child = spawn("/bin/sh", ["-c", script, "openshell-test", ...translatedArgs], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
|
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
const result = {
|
|
stdout: Buffer.concat(stdoutChunks),
|
|
stderr: Buffer.concat(stderrChunks),
|
|
code: code ?? 0,
|
|
};
|
|
if (result.code !== 0 && !params.allowFailure) {
|
|
reject(
|
|
new Error(
|
|
result.stderr.toString("utf8").trim() || `script exited with code ${result.code}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve(result);
|
|
});
|
|
if (params.stdin !== undefined) {
|
|
child.stdin.end(params.stdin);
|
|
return;
|
|
}
|
|
child.stdin.end();
|
|
},
|
|
);
|
|
return {
|
|
...result,
|
|
stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"),
|
|
};
|
|
}
|
|
|
|
function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend {
|
|
return {
|
|
id: "openshell",
|
|
runtimeId: "openshell-test",
|
|
runtimeLabel: "openshell-test",
|
|
workdir: "/sandbox",
|
|
env: {},
|
|
mode: "remote",
|
|
remoteWorkspaceDir: "/sandbox",
|
|
remoteAgentWorkspaceDir: "/agent",
|
|
buildExecSpec: vi.fn(),
|
|
runShellCommand: vi.fn(),
|
|
runRemoteShellScript: vi.fn(
|
|
async (params) =>
|
|
await runLocalShell({
|
|
...params,
|
|
roots,
|
|
}),
|
|
),
|
|
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
|
|
} as unknown as OpenShellSandboxBackend;
|
|
}
|
|
|
|
function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) {
|
|
return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent");
|
|
}
|
|
|
|
function normalizeScriptForLocalShell(script: string) {
|
|
return script
|
|
.replace(
|
|
'stats=$(stat -c "%F|%h" -- "$1")',
|
|
`stats=$(python3 - "$1" <<'PY'
|
|
import os, stat, sys
|
|
st = os.stat(sys.argv[1])
|
|
kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other'
|
|
print(f"{kind}|{st.st_nlink}")
|
|
PY
|
|
)`,
|
|
)
|
|
.replace(
|
|
'stat -c "%F|%s|%Y" -- "$1"',
|
|
`python3 - "$1" <<'PY'
|
|
import os, stat, sys
|
|
st = os.stat(sys.argv[1])
|
|
kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other'
|
|
print(f"{kind}|{st.st_size}|{int(st.st_mtime)}")
|
|
PY`,
|
|
);
|
|
}
|
|
|
|
describe("openshell remote fs bridge", () => {
|
|
it("writes, reads, renames, and removes files without local host paths", async () => {
|
|
const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-");
|
|
const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-");
|
|
const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-");
|
|
const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir);
|
|
const remoteAgentRealDir = await fs.realpath(remoteAgentDir);
|
|
const backend = createBackendMock({
|
|
workspace: remoteWorkspaceRealDir,
|
|
agent: remoteAgentRealDir,
|
|
});
|
|
const sandbox = createSandboxTestContext({
|
|
overrides: {
|
|
backendId: "openshell",
|
|
workspaceDir,
|
|
agentWorkspaceDir: workspaceDir,
|
|
containerWorkdir: "/sandbox",
|
|
},
|
|
});
|
|
|
|
const bridge = createOpenShellRemoteFsBridge({ sandbox, backend });
|
|
await bridge.writeFile({
|
|
filePath: "nested/file.txt",
|
|
data: "hello",
|
|
mkdir: true,
|
|
});
|
|
|
|
expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe(
|
|
"hello",
|
|
);
|
|
expect(await fs.readdir(workspaceDir)).toEqual([]);
|
|
|
|
const resolved = bridge.resolvePath({ filePath: "nested/file.txt" });
|
|
expect(resolved.hostPath).toBeUndefined();
|
|
expect(resolved.containerPath).toBe("/sandbox/nested/file.txt");
|
|
expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello"));
|
|
expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual(
|
|
expect.objectContaining({
|
|
type: "file",
|
|
size: 5,
|
|
}),
|
|
);
|
|
|
|
await bridge.rename({
|
|
from: "nested/file.txt",
|
|
to: "nested/renamed.txt",
|
|
});
|
|
await expect(
|
|
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"),
|
|
).rejects.toBeDefined();
|
|
expect(
|
|
await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
|
).toBe("hello");
|
|
|
|
await bridge.remove({
|
|
filePath: "nested/renamed.txt",
|
|
});
|
|
await expect(
|
|
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
|
).rejects.toBeDefined();
|
|
});
|
|
});
|