import nodeFs from
"node:fs" ;
import fs from
"node:fs/promises" ;
import os from
"node:os" ;
import path from
"node:path" ;
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
"vitest" ;
import { createSandboxTestContext } from
"../../../src/agents/sandbox/test-fixtures.js" ;
import type { OpenShellSandboxBackend } from
"./backend.js" ;
import {
buildExecRemoteCommand,
buildOpenShellBaseArgv,
resolveOpenShellCommand,
setBundledOpenShellCommandResolverForTest,
shellEscape,
} from
"./cli.js" ;
import { resolveOpenShellPluginConfig } from
"./config.js" ;
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
}));
let createOpenShellSandboxBackendManager:
typeof import (
"./backend.js" ).createOpenS
hellSandboxBackendManager;
describe("openshell cli helpers" , () => {
afterEach(() => {
setBundledOpenShellCommandResolverForTest();
});
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("prefers the bundled openshell command when available" , () => {
setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell" );
const config = resolveOpenShellPluginConfig(undefined);
expect(resolveOpenShellCommand("openshell" )).toBe("/tmp/node_modules/.bin/openshell" );
expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell" ]);
});
it("falls back to the PATH command when no bundled openshell is present" , () => {
setBundledOpenShellCommandResolverForTest(() => null );
expect(resolveOpenShellCommand("openshell" )).toBe("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' `);
});
});
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,
};
});
({ createOpenShellSandboxBackendManager } = await import ("./backend.js" ));
});
afterAll(() => {
vi.doUnmock("./cli.js" );
});
beforeEach(() => {
vi.clearAllMocks();
});
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 ,
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: expect.objectContaining({
sandboxName: "openclaw-session-1234" ,
config: expect.objectContaining({
from: "custom-source" ,
}),
}),
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: {},
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: expect.objectContaining({
sandboxName: "openclaw-session-5678" ,
config: expect.objectContaining({
command: "/usr/local/bin/openshell" ,
gateway: "lab" ,
}),
}),
args: ["sandbox" , "delete" , "openclaw-session-5678" ],
});
});
});
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 cloneStatWithDev<T extends nodeFs.Stats | nodeFs.BigIntStats>(
stat: T,
dev: number | bigint,
): T {
return Object.defineProperty(
Object.create(Object.getPrototypeOf(stat), Object.getOwnPropertyDescriptors(stat)),
"dev" ,
{
value: dev,
configurable: true ,
enumerable: true ,
writable: true ,
},
) as T;
}
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 ,
}),
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
} as unknown as OpenShellSandboxBackend;
}
describe("openshell fs bridges" , () => {
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("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();
await expect(fs.stat(path.join(outsideDir, "escape.txt" ))).rejects.toThrow();
await expect(fs.readdir(outsideDir)).resolves.toEqual([]);
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();
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 swap that lands outside 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(workspaceDir, "subdir" , "secret.txt" ), "inside" , "utf8" );
await fs.writeFile(path.join(outsideDir, "secret.txt" ), "outside" , "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 });
const originalOpen = fs.open.bind(fs);
const targetPath = path.join(workspaceDir, "subdir" , "secret.txt" );
let swapped = false ;
const openSpy = vi.spyOn(fs, "open" ).mockImplementation((async (...args: unknown[]) => {
const filePath = args[0 ];
if (!swapped && filePath === targetPath) {
swapped = true ;
nodeFs.rmSync(path.join(workspaceDir, "subdir" ), { recursive: true , force: true });
nodeFs.symlinkSync(outsideDir, path.join(workspaceDir, "subdir" ));
}
return await (originalOpen as (...delegated: unknown[]) => Promise<unknown>)(...args);
}) as unknown as typeof fs.open);
try {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed" ,
);
expect(openSpy).toHaveBeenCalled();
} finally {
openSpy.mockRestore();
}
});
it("falls back to inode checks when fd path resolution is unavailable" , 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 });
const readlinkSpy = vi
.spyOn(fs, "readlink" )
.mockRejectedValue(new Error("fd path unavailable" ));
try {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).resolves.toEqual(
Buffer.from("inside" ),
);
expect(readlinkSpy).toHaveBeenCalled();
} finally {
readlinkSpy.mockRestore();
}
});
// The shared `sameFileIdentity` contract intentionally treats either-side
// `dev=0` as "unknown device" on win32 (path-based stat can legitimately
// report `dev=0` there) and only fails closed on other platforms. Skip the
// Linux/macOS rejection expectation on Windows runners.
it.skipIf(process.platform === "win32" )(
"rejects fallback reads when path stats report an unknown device id" ,
async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-" );
const targetPath = path.join(workspaceDir, "subdir" , "secret.txt" );
await fs.mkdir(path.join(workspaceDir, "subdir" ), { recursive: true });
await fs.writeFile(targetPath, "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 });
const readlinkSpy = vi
.spyOn(fs, "readlink" )
.mockRejectedValue(new Error("fd path unavailable" ));
const originalStat = fs.stat.bind(fs);
const statSpy = vi.spyOn(fs, "stat" ).mockImplementation(async (...args) => {
const stat = await originalStat(...args);
if (args[0 ] === targetPath) {
return cloneStatWithDev(stat, 0 );
}
return stat;
});
try {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed" ,
);
expect(readlinkSpy).toHaveBeenCalled();
expect(statSpy).toHaveBeenCalledWith(targetPath);
} finally {
statSpy.mockRestore();
readlinkSpy.mockRestore();
}
},
);
it("rejects fallback reads when an ancestor directory is swapped to a symlink" , 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(workspaceDir, "subdir" , "secret.txt" ), "inside" , "utf8" );
await fs.writeFile(path.join(outsideDir, "secret.txt" ), "outside" , "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 });
const originalOpen = fs.open.bind(fs);
const targetPath = path.join(workspaceDir, "subdir" , "secret.txt" );
let swapped = false ;
const openSpy = vi.spyOn(fs, "open" ).mockImplementation((async (...args: unknown[]) => {
const filePath = args[0 ];
if (!swapped && filePath === targetPath) {
swapped = true ;
nodeFs.rmSync(path.join(workspaceDir, "subdir" ), { recursive: true , force: true });
nodeFs.symlinkSync(outsideDir, path.join(workspaceDir, "subdir" ));
}
return await (originalOpen as (...delegated: unknown[]) => Promise<unknown>)(...args);
}) as unknown as typeof fs.open);
// Force the fallback verification path even on Linux so the ancestor-walk
// guard is exercised directly.
const readlinkSpy = vi
.spyOn(fs, "readlink" )
.mockRejectedValue(new Error("fd path unavailable" ));
try {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed" ,
);
expect(openSpy).toHaveBeenCalled();
expect(readlinkSpy).toHaveBeenCalled();
} finally {
readlinkSpy.mockRestore();
openSpy.mockRestore();
}
});
it("rejects fallback reads of a symlinked leaf when O_NOFOLLOW is unavailable" , 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" );
// The workspace contains a symlink as the FINAL path component pointing
// out-of-root. On Windows `O_NOFOLLOW` is `undefined`, so `open` would
// silently traverse the symlink to the outside file; the ancestor walk
// must lstat the leaf in that case to fail closed.
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, setReadOpenFlagsResolverForTest } =
await import ("./fs-bridge.js" );
const bridge = createOpenShellFsBridge({ sandbox, backend });
// Force the fallback path so the leaf-lstat guard is exercised.
const readlinkSpy = vi
.spyOn(fs, "readlink" )
.mockRejectedValue(new Error("fd path unavailable" ));
// Simulate a host that lacks `O_NOFOLLOW` (e.g. Windows) without touching
// the non-configurable native `fs.constants` data property. The bridge
// exposes a test-only seam for exactly this case.
setReadOpenFlagsResolverForTest(() => ({
flags: nodeFs.constants.O_RDONLY,
supportsNoFollow: false ,
}));
try {
await expect(bridge.readFile({ filePath: "subdir/secret.txt" })).rejects.toThrow(
"Sandbox boundary checks failed" ,
);
expect(readlinkSpy).toHaveBeenCalled();
} finally {
setReadOpenFlagsResolverForTest(undefined);
readlinkSpy.mockRestore();
}
});
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" ));
});
});
Messung V0.5 in Prozent C=98 H=100 G=98
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland