Files
openclaw/extensions/openshell/src/openshell-core.test.ts
Daniel Morandini 6163425a2d Fix SSH sandbox remote directory args (#93367)
Merged via squash.

Prepared head SHA: 02e3d7eb9f
Co-authored-by: dmorn <10097445+dmorn@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:45:18 +08:00

1047 lines
36 KiB
TypeScript

// Openshell tests cover openshell core plugin behavior.
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
createSandboxTestContext,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenShellSandboxBackend } from "./backend.js";
import {
applyGatewayEndpointToSshConfig,
buildExecRemoteCommand,
buildValidatedExecRemoteCommand,
buildOpenShellBaseArgv,
resolveOpenShellCommand,
runOpenShellCli,
shellEscape,
} from "./cli.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
}));
let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager;
let createOpenShellSandboxBackendFactory: typeof import("./backend.js").createOpenShellSandboxBackendFactory;
let ensureOpenShellRemoteRealDirectoryScript: typeof import("./backend.js").ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT;
describe("openshell cli helpers", () => {
const originalEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
});
it("builds base argv with gateway overrides", () => {
const config = resolveOpenShellPluginConfig({
command: "/usr/local/bin/openshell",
gateway: "lab",
gatewayEndpoint: "https://lab.example",
});
expect(buildOpenShellBaseArgv(config)).toEqual([
"/usr/local/bin/openshell",
"--gateway",
"lab",
"--gateway-endpoint",
"https://lab.example",
]);
});
it("uses the configured NVIDIA OpenShell CLI command directly", () => {
const config = resolveOpenShellPluginConfig(undefined);
expect(resolveOpenShellCommand("openshell")).toBe("openshell");
expect(buildOpenShellBaseArgv(config)).toEqual(["openshell"]);
});
it("preserves an explicit NVIDIA OpenShell CLI path", () => {
expect(resolveOpenShellCommand("/opt/openshell/bin/openshell")).toBe(
"/opt/openshell/bin/openshell",
);
});
it("shell escapes single quotes", () => {
expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`);
});
it("wraps exec commands with env and workdir", () => {
const command = buildExecRemoteCommand({
command: "pwd && printenv TOKEN",
workdir: "/sandbox/project",
env: {
TOKEN: "abc 123",
},
});
expect(command).toContain(`'env'`);
expect(command).toContain(`'TOKEN=abc 123'`);
expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`);
});
it("uses the shared SSH exec command preflight", () => {
expect(() =>
buildValidatedExecRemoteCommand({
command: 'workflow run <workflow-id> "<task>"',
env: {},
}),
).toThrow(/unresolved placeholder token <workflow-id>/);
});
it("passes direct gateway endpoints to openshell commands without registration", async () => {
const calls: string[][] = [];
const openshellCommand = await makeExecutable({
name: "openshell",
script: ["#!/bin/sh", `printf '%s\\n' "$*" >> "__LOG__"`, "exit 0"].join("\n"),
});
await runOpenShellCli({
context: {
sandboxName: "demo",
config: resolveOpenShellPluginConfig({
command: openshellCommand,
gateway: "alice",
gatewayEndpoint: "http://openshell.openshell-alice.svc.cluster.local:8080",
}),
},
args: ["sandbox", "get", "demo"],
});
const log = await fs.readFile(process.env.OPEN_SHELL_CLI_TEST_LOG as string, "utf8");
for (const line of log.trim().split("\n")) {
calls.push(line.split(" "));
}
expect(calls[0]).toEqual([
"--gateway",
"alice",
"--gateway-endpoint",
"http://openshell.openshell-alice.svc.cluster.local:8080",
"sandbox",
"get",
"demo",
]);
});
it("adds direct gateway endpoints to generated ssh proxy configs", () => {
const configText = [
"Host openshell-demo",
" User sandbox",
" ProxyCommand /usr/local/bin/openshell ssh-proxy --gateway-name alice --name demo",
"",
].join("\n");
expect(
applyGatewayEndpointToSshConfig({
configText,
gatewayEndpoint: "http://openshell.openshell-alice.svc.cluster.local:8080",
}),
).toContain(
"ProxyCommand /usr/local/bin/openshell ssh-proxy --gateway-name alice --name demo --server 'http://openshell.openshell-alice.svc.cluster.local:8080'",
);
});
it("leaves ssh proxy configs with an explicit endpoint unchanged", () => {
const configText =
"Host openshell-demo\n ProxyCommand openshell ssh-proxy --gateway-name alice --name demo --server 'http://existing'\n";
expect(
applyGatewayEndpointToSshConfig({
configText,
gatewayEndpoint: "http://replacement",
}),
).toBe(configText);
});
});
describe("openshell backend manager", () => {
beforeAll(async () => {
vi.doMock("./cli.js", async () => {
const actual = await vi.importActual<typeof import("./cli.js")>("./cli.js");
return {
...actual,
runOpenShellCli: cliMocks.runOpenShellCli,
};
});
({
ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT: ensureOpenShellRemoteRealDirectoryScript,
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,
} = await import("./backend.js"));
});
afterAll(() => {
vi.doUnmock("./cli.js");
vi.resetModules();
});
beforeEach(() => {
vi.clearAllMocks();
});
it.runIf(process.platform !== "win32")(
"preserves caller positional args after OpenShell remote directory validation",
async () => {
const realParent = await makeTempDir("openclaw-openshell-real-");
const root = path.join(realParent, "sandbox");
const target = path.join(root, ".openclaw", "sandbox-skills");
const result = spawnSync(
"/bin/sh",
[
"-c",
[
ensureOpenShellRemoteRealDirectoryScript,
'printf "%s\\n%s\\n" "$1" "$2"',
'touch "$1/proof"',
'find "$1" -mindepth 1 -maxdepth 1 -name proof -print',
].join("\n"),
"openclaw-openshell-dir",
target,
root,
],
{ encoding: "utf8" },
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim().split("\n")).toEqual([target, root, path.join(target, "proof")]);
},
);
it("checks runtime status with config override from OpenClaw config", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "{}",
stderr: "",
});
const manager = createOpenShellSandboxBackendManager({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
from: "openclaw",
}),
});
const result = await manager.describeRuntime({
entry: {
containerName: "openclaw-session-1234",
backendId: "openshell",
runtimeLabel: "openclaw-session-1234",
sessionKey: "agent:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "custom-source",
configLabelKind: "Source",
},
config: {
plugins: {
entries: {
openshell: {
enabled: true,
config: {
command: "openshell",
from: "custom-source",
},
},
},
},
},
});
expect(result).toEqual({
running: true,
actualConfigLabel: "custom-source",
configLabelMatch: true,
});
const expectedConfig = resolveOpenShellPluginConfig({
command: "openshell",
from: "custom-source",
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: {
sandboxName: "openclaw-session-1234",
config: expectedConfig,
},
args: ["sandbox", "get", "openclaw-session-1234"],
});
});
it("removes runtimes via openshell sandbox delete", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
});
const manager = createOpenShellSandboxBackendManager({
pluginConfig: resolveOpenShellPluginConfig({
command: "/usr/local/bin/openshell",
gateway: "lab",
}),
});
await manager.removeRuntime({
entry: {
containerName: "openclaw-session-5678",
backendId: "openshell",
runtimeLabel: "openclaw-session-5678",
sessionKey: "agent:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "openclaw",
configLabelKind: "Source",
},
config: {},
});
const expectedConfig = resolveOpenShellPluginConfig({
command: "/usr/local/bin/openshell",
gateway: "lab",
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: {
sandboxName: "openclaw-session-5678",
config: expectedConfig,
},
args: ["sandbox", "delete", "openclaw-session-5678"],
});
});
it("rejects malformed exec commands before opening an OpenShell SSH session", async () => {
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
}),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(
backend.buildExecSpec({
command: "workflow install <name>",
env: {},
usePty: false,
}),
).rejects.toThrow(/unresolved placeholder token <name>/);
expect(cliMocks.runOpenShellCli).not.toHaveBeenCalled();
});
it("preserves a local sandbox skills shadow when mirror sync crosses filesystems", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
const shadowFile = path.join(workspaceDir, ".openclaw", "sandbox-skills", "user-note.txt");
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
await fs.writeFile(shadowFile, "local shadow", "utf8");
const originalRename = fs.rename.bind(fs);
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => {
const source = String(from);
const target = String(to);
const shadowDir = path.dirname(shadowFile);
const isFallbackStagedMove = path.basename(source).startsWith(".fs-safe-move-");
if (source === shadowDir || (target === shadowDir && !isFallbackStagedMove)) {
throw Object.assign(new Error("cross-device link not permitted"), { code: "EXDEV" });
}
return await originalRename(from, to);
});
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
if (args[0] === "sandbox" && args[1] === "download") {
const tmpDir = args[4];
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
await fs.mkdir(path.join(tmpDir, ".openclaw", "sandbox-skills", "skills"), {
recursive: true,
});
await fs.writeFile(
path.join(tmpDir, ".openclaw", "sandbox-skills", "skills", "generated.txt"),
"generated",
"utf8",
);
}
return { code: 0, stdout: "", stderr: "" };
});
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
try {
await backend.finalizeExec?.({
status: "completed",
exitCode: 0,
timedOut: false,
token: undefined,
});
expect(renameSpy).toHaveBeenCalled();
await expect(fs.readFile(shadowFile, "utf8")).resolves.toBe("local shadow");
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
"remote",
);
await expectPathMissing(
path.join(workspaceDir, ".openclaw", "sandbox-skills", "skills", "generated.txt"),
);
} finally {
renameSpy.mockRestore();
}
});
it("drops non-directory materialized sandbox skills from mirror downloads", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
if (args[0] === "sandbox" && args[1] === "download") {
const tmpDir = args[4];
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
await fs.mkdir(path.join(tmpDir, ".openclaw"), { recursive: true });
await fs.writeFile(path.join(tmpDir, ".openclaw", "sandbox-skills"), "poison", "utf8");
}
return { code: 0, stdout: "", stderr: "" };
});
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await backend.finalizeExec?.({
status: "completed",
exitCode: 0,
timedOut: false,
token: undefined,
});
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
"remote",
);
await expectPathMissing(path.join(workspaceDir, ".openclaw", "sandbox-skills"));
});
it("restores a local sandbox skills shadow when mirror download has a file parent", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
const shadowFile = path.join(workspaceDir, ".openclaw", "sandbox-skills", "user-note.txt");
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
await fs.writeFile(shadowFile, "local shadow", "utf8");
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
if (args[0] === "sandbox" && args[1] === "download") {
const tmpDir = args[4];
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
await fs.writeFile(path.join(tmpDir, ".openclaw"), "poison", "utf8");
}
return { code: 0, stdout: "", stderr: "" };
});
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await factory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await backend.finalizeExec?.({
status: "completed",
exitCode: 0,
timedOut: false,
token: undefined,
});
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
"remote",
);
await expect(fs.readFile(shadowFile, "utf8")).resolves.toBe("local shadow");
expect((await fs.stat(path.join(workspaceDir, ".openclaw"))).isDirectory()).toBe(true);
});
});
const tempDirs: string[] = [];
function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] {
return {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
network: "none",
capDrop: [],
binds: [],
env: {},
},
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
browser: createSandboxBrowserConfig(),
tools: { allow: ["*"], deny: [] },
prune: createSandboxPruneConfig(),
};
}
async function makeTempDir(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function makeExecutable(params: { name: string; script: string }): Promise<string> {
const dir = await makeTempDir("openclaw-openshell-bin-");
const file = path.join(dir, params.name);
const logPath = path.join(dir, "openshell.log");
await fs.writeFile(file, params.script.replaceAll("__LOG__", logPath), { mode: 0o755 });
await fs.chmod(file, 0o755);
process.env.OPEN_SHELL_CLI_TEST_LOG = logPath;
return file;
}
async function expectPathMissing(targetPath: string): Promise<void> {
let error: unknown;
try {
await fs.stat(targetPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
function createMirrorBackendMock(): OpenShellSandboxBackend {
return {
id: "openshell",
runtimeId: "openshell-test",
runtimeLabel: "openshell-test",
workdir: "/sandbox",
env: {},
remoteWorkspaceDir: "/sandbox",
remoteAgentWorkspaceDir: "/agent",
buildExecSpec: vi.fn(),
runShellCommand: vi.fn(),
runRemoteShellScript: vi.fn().mockResolvedValue({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
mkdirpRemotePath: vi.fn().mockResolvedValue(undefined),
renameRemotePath: vi.fn().mockResolvedValue(undefined),
removeRemotePath: vi.fn().mockResolvedValue(undefined),
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
} as unknown as OpenShellSandboxBackend;
}
describe("openshell fs bridges", () => {
it.runIf(process.platform !== "win32")(
"rejects remote-only symlink parents in pinned mirror mutations",
async () => {
const stateDir = await makeTempDir("openclaw-openshell-remote-pin-");
const remoteRoot = path.join(stateDir, "sandbox");
const outsideDir = path.join(stateDir, "outside");
await fs.mkdir(remoteRoot, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(path.join(remoteRoot, "source.txt"), "payload", "utf8");
await fs.symlink(outsideDir, path.join(remoteRoot, "alias"));
const { PINNED_REMOTE_PATH_MUTATION_SCRIPT } = await import("./backend.js");
const runPinnedMutation = (args: string[]) =>
spawnSync("sh", ["-c", PINNED_REMOTE_PATH_MUTATION_SCRIPT, "openshell-test", ...args], {
encoding: "utf8",
});
expect(runPinnedMutation(["mkdirp", remoteRoot, "safe/nested"]).status).toBe(0);
await expect(fs.stat(path.join(remoteRoot, "safe", "nested"))).resolves.toBeDefined();
expect(runPinnedMutation(["mkdirp", remoteRoot, "..cache/file"]).status).toBe(0);
await expect(fs.stat(path.join(remoteRoot, "..cache", "file"))).resolves.toBeDefined();
expect(runPinnedMutation(["mkdirp", remoteRoot, "alias/escaped"]).status).not.toBe(0);
await expectPathMissing(path.join(outsideDir, "escaped"));
expect(
runPinnedMutation([
"rename",
remoteRoot,
"",
"source.txt",
remoteRoot,
"alias",
"escaped.txt",
]).status,
).not.toBe(0);
await expect(fs.readFile(path.join(remoteRoot, "source.txt"), "utf8")).resolves.toBe(
"payload",
);
await expectPathMissing(path.join(outsideDir, "escaped.txt"));
await fs.writeFile(path.join(remoteRoot, "victim.txt"), "delete me", "utf8");
expect(runPinnedMutation(["removefile", remoteRoot, "alias", "victim.txt"]).status).not.toBe(
0,
);
expect(
runPinnedMutation(["removefile", remoteRoot, "missing-parent", "victim.txt", "1"]).status,
).toBe(0);
expect(
runPinnedMutation(["removefile", remoteRoot, "alias", "victim.txt", "1"]).status,
).not.toBe(0);
await expect(fs.readFile(path.join(remoteRoot, "victim.txt"), "utf8")).resolves.toBe(
"delete me",
);
await expectPathMissing(path.join(outsideDir, "victim.txt"));
},
);
it("writes locally and syncs the file to the remote workspace", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await bridge.writeFile({
filePath: "nested/file.txt",
data: "hello",
mkdir: true,
});
expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello");
expect(backend["syncLocalPathToRemote"]).toHaveBeenCalledWith(
path.join(workspaceDir, "nested", "file.txt"),
"/sandbox/nested/file.txt",
);
});
it("creates remote mirror directories through the pinned backend operation", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await bridge.mkdirp({ filePath: "nested/dir" });
await expect(fs.stat(path.join(workspaceDir, "nested", "dir"))).resolves.toBeDefined();
expect(backend["mkdirpRemotePath"]).toHaveBeenCalledWith("/sandbox/nested/dir", undefined);
expect(backend["runRemoteShellScript"]).not.toHaveBeenCalled();
});
it("renames remote mirror paths through the pinned backend operation", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
await fs.writeFile(path.join(workspaceDir, "source.txt"), "payload", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await bridge.rename({ from: "source.txt", to: "nested/target.txt" });
await expect(
fs.readFile(path.join(workspaceDir, "nested", "target.txt"), "utf8"),
).resolves.toBe("payload");
expect(backend["renameRemotePath"]).toHaveBeenCalledWith(
"/sandbox/source.txt",
"/sandbox/nested/target.txt",
undefined,
);
expect(backend["runRemoteShellScript"]).not.toHaveBeenCalled();
});
it("removes remote mirror paths through the pinned backend operation", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
await fs.writeFile(path.join(workspaceDir, "target.txt"), "payload", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await bridge.remove({ filePath: "target.txt", force: true });
await expectPathMissing(path.join(workspaceDir, "target.txt"));
expect(backend["removeRemotePath"]).toHaveBeenCalledWith("/sandbox/target.txt", {
recursive: false,
signal: undefined,
ignoreMissing: true,
});
expect(backend["runRemoteShellScript"]).not.toHaveBeenCalled();
});
it("keeps local mirror state unchanged when remote pinned mkdir is rejected", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const backend = createMirrorBackendMock();
backend["mkdirpRemotePath"] = vi.fn().mockRejectedValue(new Error("remote rejected"));
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.mkdirp({ filePath: "alias/escaped" })).rejects.toThrow("remote rejected");
await expectPathMissing(path.join(workspaceDir, "alias"));
});
it("keeps local mirror state unchanged when remote pinned remove is rejected", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const targetPath = path.join(workspaceDir, "target.txt");
await fs.writeFile(targetPath, "payload", "utf8");
const backend = createMirrorBackendMock();
backend["removeRemotePath"] = vi.fn().mockRejectedValue(new Error("remote rejected"));
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.remove({ filePath: "target.txt", force: true })).rejects.toThrow(
"remote rejected",
);
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("payload");
});
it("keeps local mirror state unchanged when remote pinned rename is rejected", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const sourcePath = path.join(workspaceDir, "source.txt");
const targetPath = path.join(workspaceDir, "nested", "target.txt");
await fs.writeFile(sourcePath, "payload", "utf8");
const backend = createMirrorBackendMock();
backend["renameRemotePath"] = vi.fn().mockRejectedValue(new Error("remote rejected"));
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.rename({ from: "source.txt", to: "nested/target.txt" })).rejects.toThrow(
"remote rejected",
);
await expect(fs.readFile(sourcePath, "utf8")).resolves.toBe("payload");
await expectPathMissing(targetPath);
});
it("rejects symlink-parent writes instead of escaping the local mount root", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
await fs.symlink(outsideDir, path.join(workspaceDir, "alias"));
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(
bridge.writeFile({
filePath: "alias/escape.txt",
data: "owned",
mkdir: true,
}),
).rejects.toThrow("Sandbox path escapes allowed mounts");
await expectPathMissing(path.join(outsideDir, "escape.txt"));
await expect(fs.readdir(outsideDir)).resolves.toStrictEqual([]);
expect(backend["syncLocalPathToRemote"]).not.toHaveBeenCalled();
});
it("rejects writes whose final target is a symlink inside the local mount root", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const linkedTarget = path.join(workspaceDir, "existing.txt");
await fs.writeFile(linkedTarget, "keep", "utf8");
await fs.symlink("existing.txt", path.join(workspaceDir, "link.txt"));
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(
bridge.writeFile({
filePath: "link.txt",
data: "owned",
mkdir: true,
}),
).rejects.toThrow("Sandbox boundary checks failed");
await expect(fs.readlink(path.join(workspaceDir, "link.txt"))).resolves.toBe("existing.txt");
await expect(fs.readFile(linkedTarget, "utf8")).resolves.toBe("keep");
expect(backend["syncLocalPathToRemote"]).not.toHaveBeenCalled();
});
it("rejects a parent symlink that lands outside the sandbox root", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
await fs.symlink(outsideDir, path.join(workspaceDir, "subdir"));
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed",
);
});
it("reads regular files through the shared safe fs root", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "subdir", "secret.txt"), "inside", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).resolves.toEqual(
Buffer.from("inside"),
);
});
it("reads materialized sandbox skills from the protected skills workspace", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const skillsWorkspaceDir = await makeTempDir("openclaw-openshell-skills-");
const skillFile = path.join(skillsWorkspaceDir, "skills", "demo", "SKILL.md");
const shadowFile = path.join(
workspaceDir,
".openclaw",
"sandbox-skills",
"skills",
"demo",
"SKILL.md",
);
await fs.mkdir(path.dirname(skillFile), { recursive: true });
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
await fs.writeFile(skillFile, "# Demo\nmaterialized\n", "utf8");
await fs.writeFile(shadowFile, "# Demo\nworkspace shadow\n", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
skillsWorkspaceDir,
workspaceAccess: "rw",
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(
bridge.readFile({
filePath: "/sandbox/.openclaw/sandbox-skills/skills/demo/SKILL.md",
}),
).resolves.toEqual(Buffer.from("# Demo\nmaterialized\n"));
await expect(
bridge.readFile({
filePath: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
}),
).resolves.toEqual(Buffer.from("# Demo\nmaterialized\n"));
await expect(
bridge.writeFile({
filePath: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
data: "owned",
}),
).rejects.toThrow(/read-only/);
await expect(
bridge.writeFile({
filePath: shadowFile,
data: "owned",
}),
).rejects.toThrow(/read-only/);
expect(await fs.readFile(shadowFile, "utf8")).toContain("workspace shadow");
expect(backend["syncLocalPathToRemote"]).not.toHaveBeenCalled();
});
it("rejects reads of a symlinked leaf", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
await fs.symlink(
path.join(outsideDir, "secret.txt"),
path.join(workspaceDir, "subdir", "secret.txt"),
);
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed",
);
});
it("rejects hardlinked files inside the sandbox root", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
await fs.mkdir(path.join(workspaceDir, "subdir"), { recursive: true });
await fs.writeFile(path.join(outsideDir, "secret.txt"), "outside", "utf8");
await fs.link(
path.join(outsideDir, "secret.txt"),
path.join(workspaceDir, "subdir", "secret.txt"),
);
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed",
);
});
it("maps agent mount paths when the sandbox workspace is read-only", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-");
await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir,
workspaceAccess: "ro",
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" });
expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt"));
expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent"));
});
});