Download src/agents/sandbox/docker-mount-source.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sandbox/docker-mount-source.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/sandbox/docker-mount-source.test.ts
-
curl -L -o docker-mount-source.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sandbox/docker-mount-source.test.ts
10.7 kB
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { DOCKER_SANDBOX_ENGINE, execContainer, PODMAN_SANDBOX_ENGINE } from "./container-engine.js"; | |
| import { | |
| parseInspectedSandboxMounts, | |
| resolveDockerSourceNamespace, | |
| translateSandboxMountSources, | |
| } from "./docker-mount-source.js"; | |
| vi.mock("node:fs", () => ({ | |
| default: { | |
| readFileSync: vi.fn(), | |
| readlinkSync: vi.fn(), | |
| existsSync: vi.fn(), | |
| realpathSync: vi.fn(), | |
| }, | |
| })); | |
| vi.mock("node:os", () => ({ default: { hostname: vi.fn() } })); | |
| vi.mock("./container-engine.js", () => ({ | |
| DOCKER_SANDBOX_ENGINE: { id: "docker", command: "docker", displayName: "Docker" }, | |
| PODMAN_SANDBOX_ENGINE: { id: "podman", command: "podman", displayName: "Podman" }, | |
| execContainer: vi.fn(), | |
| })); | |
| const id = "a".repeat(64); | |
| const identity = JSON.stringify(["test-boot", "mnt:[123]"]); | |
| const wireMount = { | |
| Type: "bind", | |
| Source: "/host/project", | |
| Destination: "/gateway/workspace", | |
| RW: true, | |
| }; | |
| let target = 0; | |
| function translateSandboxMountSource( | |
| params: Omit< | |
| Parameters<typeof translateSandboxMountSources>[0], | |
| "containerPath" | "shadowedTargets" | |
| >, | |
| ): string { | |
| return translateSandboxMountSources({ | |
| ...params, | |
| containerPath: "/workspace", | |
| shadowedTargets: [], | |
| })[0]!.hostPath; | |
| } | |
| beforeEach(() => { | |
| vi.resetAllMocks(); | |
| vi.stubGlobal("process", { ...process, platform: "linux" }); | |
| vi.stubEnv("DOCKER_CONTEXT", `test-${++target}`); | |
| vi.mocked(os.hostname).mockReturnValue("gateway-hostname"); | |
| vi.mocked(fs.existsSync).mockImplementation((file) => file === "/.dockerenv"); | |
| vi.mocked(fs.readlinkSync).mockReturnValue("mnt:[123]"); | |
| vi.mocked(fs.readFileSync).mockImplementation((file) => | |
| file === "/proc/sys/kernel/random/boot_id" ? "test-boot\n" : "0::/\n", | |
| ); | |
| vi.mocked(fs.realpathSync).mockImplementation((file) => String(file)); | |
| vi.mocked(execContainer).mockImplementation(async (_engine, args) => ({ | |
| stdout: args[0] === "inspect" ? JSON.stringify({ Id: id, Mounts: [wireMount] }) : identity, | |
| stderr: "", | |
| code: 0, | |
| })); | |
| }); | |
| afterEach(() => { | |
| vi.unstubAllGlobals(); | |
| vi.unstubAllEnvs(); | |
| }); | |
| describe("Docker source namespace", () => { | |
| it("proves a custom hostname on private cgroup v2 before accepting mounts, and shares the snapshot", async () => { | |
| const [first, second] = await Promise.all([ | |
| resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE), | |
| resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE), | |
| ]); | |
| expect(first).toEqual(parseInspectedSandboxMounts([wireMount])); | |
| expect(second).toBe(first); | |
| expect(execContainer).toHaveBeenCalledTimes(2); | |
| expect(vi.mocked(execContainer).mock.calls[0]?.[1]).toEqual([ | |
| "inspect", | |
| "--type", | |
| "container", | |
| "--format", | |
| '{"Id":{{json .ID}},"Mounts":{{json .Mounts}},"Tmpfs":{{json .HostConfig.Tmpfs}}}', | |
| "gateway-hostname", | |
| ]); | |
| expect(vi.mocked(execContainer).mock.calls[1]?.[1].slice(0, 4)).toEqual([ | |
| "exec", | |
| id, | |
| process.execPath, | |
| "-e", | |
| ]); | |
| expect(vi.mocked(execContainer).mock.calls[0]?.[2]?.signal).toBeInstanceOf(AbortSignal); | |
| }); | |
| it("uses a mountinfo container ID when the hostname is not a Docker name", async () => { | |
| vi.mocked(fs.readFileSync).mockImplementation((file) => | |
| file === "/proc/self/mountinfo" | |
| ? `1 2 0:1 /var/lib/docker/containers/${id}/hostname /etc/hostname rw - ext4 /dev/test rw` | |
| : file === "/proc/sys/kernel/random/boot_id" | |
| ? "test-boot" | |
| : "0::/\n", | |
| ); | |
| vi.mocked(execContainer).mockRejectedValueOnce(new Error("No such container")); | |
| await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toHaveLength(1); | |
| expect(vi.mocked(execContainer).mock.calls[1]?.[1].at(-1)).toBe(id); | |
| }); | |
| it.each(["/gateway/workspace", "/gateway/workspace/data"])( | |
| "rejects Docker --tmpfs at %s even when .Mounts only reports its bind ancestor", | |
| async (destination) => { | |
| vi.mocked(execContainer).mockResolvedValueOnce({ | |
| stdout: JSON.stringify({ | |
| Id: id, | |
| Mounts: [wireMount], | |
| Tmpfs: { [destination]: "rw,size=1m" }, | |
| }), | |
| stderr: "", | |
| code: 0, | |
| }); | |
| const mounts = await resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE); | |
| expect(() => | |
| translateSandboxMountSource({ | |
| source: "/gateway/workspace", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts: mounts!, | |
| readOnly: false, | |
| }), | |
| ).toThrow(/unsupported (?:nested )?tmpfs mount/); | |
| }, | |
| ); | |
| it.each(["wrong-boot", "wrong-namespace", "invalid-id", "missing-mounts", "unavailable"])( | |
| "rejects %s without a wrong-path fallback and retries after recovery", | |
| async (failure) => { | |
| if (failure === "unavailable") { | |
| vi.mocked(execContainer).mockRejectedValueOnce(new Error("daemon unavailable")); | |
| } else if (failure === "invalid-id" || failure === "missing-mounts") { | |
| vi.mocked(execContainer).mockResolvedValueOnce({ | |
| stdout: JSON.stringify({ | |
| Id: failure === "invalid-id" ? "short-id" : id, | |
| Mounts: failure === "missing-mounts" ? null : [wireMount], | |
| }), | |
| stderr: "", | |
| code: 0, | |
| }); | |
| } else { | |
| vi.mocked(execContainer) | |
| .mockImplementationOnce(async () => ({ | |
| stdout: JSON.stringify({ Id: id, Mounts: [wireMount] }), | |
| stderr: "", | |
| code: 0, | |
| })) | |
| .mockResolvedValueOnce({ | |
| stdout: JSON.stringify( | |
| failure === "wrong-boot" ? ["other-boot", "mnt:[123]"] : ["test-boot", "mnt:[456]"], | |
| ), | |
| stderr: "", | |
| code: 0, | |
| }); | |
| } | |
| await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).rejects.toThrow( | |
| "Connect Docker to the daemon that runs the Gateway", | |
| ); | |
| await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toHaveLength(1); | |
| }, | |
| ); | |
| it("leaves native Docker and Podman sources unchanged without self-inspection", async () => { | |
| vi.mocked(fs.existsSync).mockReturnValue(false); | |
| vi.mocked(fs.readFileSync).mockReturnValue( | |
| `1 2 0:1 / /var/lib/docker/overlay2/${id}/merged rw - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/${id}`, | |
| ); | |
| await expect(resolveDockerSourceNamespace(DOCKER_SANDBOX_ENGINE)).resolves.toBeUndefined(); | |
| await expect(resolveDockerSourceNamespace(PODMAN_SANDBOX_ENGINE)).resolves.toBeUndefined(); | |
| expect(execContainer).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("managed source translation", () => { | |
| it("keeps literal backslashes in inspected sources and Gateway mount selection", () => { | |
| const mounts = parseInspectedSandboxMounts([ | |
| { ...wireMount, Source: "/host/a\\b", Destination: "/gateway/a\\b" }, | |
| { ...wireMount, Source: "/host/a/b", Destination: "/gateway/a/b" }, | |
| ]); | |
| for (const suffix of ["a\\b", "a/b"]) { | |
| expect( | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: `/gateway/${suffix}/leaf`, | |
| allowedRoots: [`/gateway/${suffix}`], | |
| mounts, | |
| }), | |
| ).toBe(`/host/${suffix}/leaf`); | |
| } | |
| }); | |
| it("uses the longest segment prefix and preserves spaces", () => { | |
| const mounts = parseInspectedSandboxMounts([ | |
| wireMount, | |
| { ...wireMount, Source: "/host/special skills", Destination: "/gateway/workspace/skills" }, | |
| ]); | |
| expect( | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/gateway/workspace/skills/demo", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts, | |
| }), | |
| ).toBe("/host/special skills/demo"); | |
| expect(() => | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/gateway/workspace-other", | |
| allowedRoots: ["/gateway/workspace-other"], | |
| mounts, | |
| }), | |
| ).toThrow("not backed by a Gateway bind mount"); | |
| }); | |
| it.each(["volume", "tmpfs"])("does not reinterpret %s storage as a host bind", (type) => { | |
| const mounts = parseInspectedSandboxMounts([ | |
| { ...wireMount, Type: type, Source: "/var/lib/docker/private" }, | |
| ]); | |
| expect(() => | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/gateway/workspace", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts, | |
| }), | |
| ).toThrow(`unsupported ${type} mount`); | |
| }); | |
| it("rejects relative daemon sources", () => { | |
| expect(() => parseInspectedSandboxMounts([{ ...wireMount, Source: "relative" }])).toThrow( | |
| "invalid mount entry", | |
| ); | |
| expect(() => parseInspectedSandboxMounts([wireMount], { relative: "rw" })).toThrow( | |
| "invalid tmpfs destination", | |
| ); | |
| }); | |
| it("maps a root destination without losing the separator", () => { | |
| expect( | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/workspace", | |
| allowedRoots: ["/workspace"], | |
| mounts: parseInspectedSandboxMounts([{ ...wireMount, Destination: "/" }]), | |
| }), | |
| ).toBe("/host/project/workspace"); | |
| }); | |
| it("preserves Windows drive sources in container inspection", () => { | |
| expect( | |
| parseInspectedSandboxMounts([{ ...wireMount, Source: "c:\\Users\\Example\\project" }])[0] | |
| ?.source, | |
| ).toBe("C:/Users/Example/project"); | |
| }); | |
| it("does not grant writes through a read-only Gateway bind", () => { | |
| const params = { | |
| source: "/gateway/workspace", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts: parseInspectedSandboxMounts([{ ...wireMount, RW: false }]), | |
| }; | |
| expect(() => translateSandboxMountSource({ ...params, readOnly: false })).toThrow( | |
| "Use workspaceAccess=ro", | |
| ); | |
| expect(translateSandboxMountSource({ ...params, readOnly: true })).toBe("/host/project"); | |
| }); | |
| it("checks symlink containment locally and never resolves a daemon source locally", () => { | |
| vi.mocked(fs.realpathSync).mockImplementation((file) => | |
| file === "/gateway/workspace/escape" ? "/etc" : String(file), | |
| ); | |
| expect(() => | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/gateway/workspace/escape", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts: parseInspectedSandboxMounts([wireMount]), | |
| }), | |
| ).toThrow("escapes its Gateway workspace roots"); | |
| translateSandboxMountSource({ | |
| readOnly: false, | |
| source: "/gateway/workspace", | |
| allowedRoots: ["/gateway/workspace"], | |
| mounts: parseInspectedSandboxMounts([wireMount]), | |
| }); | |
| expect( | |
| vi.mocked(fs.realpathSync).mock.calls.some(([file]) => String(file).startsWith("/host/")), | |
| ).toBe(false); | |
| }); | |
| }); | |