Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates=20250419 \
iproute2=6.15.0-1 \
iptables=1.8.11-2 \
nftables=1.1.3-1 \
libcap2-bin=1:2.75-10+deb13u1+b1 \
procps=2:4.0.4-9 \
e2fsprogs=1.47.2-3+b11 \
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates=20250419 \
iproute2=6.15.0-1 \
iptables=1.8.11-2 \
nftables=1.1.3-1 \
libcap2-bin=1:2.75-10+deb13u1+b1 \
procps=2:4.0.4-9 \
e2fsprogs=1.47.2-3+b11 \
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates=20250419 \
iproute2=6.15.0-1 \
iptables=1.8.11-2 \
nftables=1.1.3-1 \
libcap2-bin=1:2.75-10+deb13u1+b1 \
procps=2:4.0.4-9 \
e2fsprogs=1.47.2-3+b11 \
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard/docker-gpu-patch-clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe("Docker GPU clone envelope", () => {
"openshell.ai/sandbox-name=alpha",
"--volume",
"/host:/container:rw",
"--tmpfs",
"/tmp/nemoclaw-exact-main-driver-config:noexec,size=16777216,mode=1777",
"--mount",
"type=tmpfs,dst=/tmp/nemoclaw-exact-main-driver-config,tmpfs-size=16777216,tmpfs-mode=1777",
"--network",
"openshell-docker",
"--network-alias",
Expand Down
29 changes: 18 additions & 11 deletions src/lib/onboard/docker-gpu-patch-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const GPU_ENV_KEYS = new Set([
"NVIDIA_REQUIRE_CUDA",
"NVIDIA_DISABLE_REQUIRE",
]);
const DOCKER_DEFAULT_TMPFS_OPTIONS = new Set(["noexec", "nosuid", "nodev"]);
type DockerStructuredMount = NonNullable<
NonNullable<DockerContainerInspect["HostConfig"]>["Mounts"]
>[number];
Expand Down Expand Up @@ -156,30 +157,35 @@ function dockerTmpfsMountValue(mount: DockerStructuredMount): string {
if (!target.startsWith("/")) {
throw new Error("Docker structured mount target must be an absolute container path.");
}
const options: string[] = [];
if (optionalMountBoolean(mount.ReadOnly, "ReadOnly")) options.push("ro");
const values = [`type=tmpfs`, `dst=${target}`];
if (optionalMountBoolean(mount.ReadOnly, "ReadOnly")) values.push("readonly");
for (const parts of mount.TmpfsOptions?.Options ?? []) {
if (!Array.isArray(parts) || parts.length < 1 || parts.length > 2) {
throw new Error("Docker tmpfs mount options must contain one or two values.");
if (!Array.isArray(parts) || parts.length !== 1) {
throw new Error("Docker structured tmpfs options must contain exactly one value.");
}
const option = mountValue(parts[0], "tmpfs option");
if (!DOCKER_DEFAULT_TMPFS_OPTIONS.has(option)) {
throw new Error(
`Docker structured tmpfs option '${option}' cannot be preserved during recreation.`,
);
}
options.push(parts.map((part) => mountValue(part, "tmpfs option")).join("="));
}

const sizeBytes = mount.TmpfsOptions?.SizeBytes;
if (sizeBytes !== undefined && sizeBytes !== null) {
if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
throw new Error("Docker tmpfs mount size must be a positive safe integer.");
}
options.push(`size=${sizeBytes}`);
values.push(`tmpfs-size=${sizeBytes}`);
}
const mode = mount.TmpfsOptions?.Mode;
if (mode !== undefined && mode !== null) {
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) {
throw new Error("Docker tmpfs mount mode must be a valid non-negative file mode.");
}
options.push(`mode=${mode.toString(8)}`);
values.push(`tmpfs-mode=${mode.toString(8)}`);
}
return options.length > 0 ? `${target}:${options.join(",")}` : target;
return values.join(",");
}

function dockerVolumeMountValue(mount: DockerStructuredMount): string {
Expand Down Expand Up @@ -215,9 +221,10 @@ function dockerStructuredMountArgs(inspect: DockerContainerInspect): string[] {
for (const mount of inspect.HostConfig?.Mounts ?? []) {
switch (mount.Type) {
case "tmpfs":
// The --tmpfs form preserves OpenShell's arbitrary TmpfsOptions.Options,
// such as noexec, in addition to the structured size and mode fields.
args.push("--tmpfs", dockerTmpfsMountValue(mount));
// Docker applies noexec, nosuid, and nodev to tmpfs mounts by default.
// Keep the structured representation so Docker inspect and later
// recreation retain size and mode alongside that security posture.
args.push("--mount", dockerTmpfsMountValue(mount));
break;
case "volume":
args.push("--mount", dockerVolumeMountValue(mount));
Expand Down
44 changes: 44 additions & 0 deletions src/lib/onboard/docker-gpu-patch-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,50 @@ describe("Docker GPU startup command validation (#6110)", () => {
expect(dockerRunDetached).not.toHaveBeenCalled();
});

it("rejects non-default structured tmpfs options before touching the original container", () => {
const inspect = inspectFixture();
inspect.HostConfig!.Mounts = [
{
Type: "tmpfs",
Target: "/tmp/sandbox",
TmpfsOptions: { Options: [["exec"]] },
},
];
const dockerStop = vi.fn(() => ({ status: 0 }));
const dockerRename = vi.fn(() => ({ status: 0 }));
const dockerRunDetached = vi.fn(() => ({ status: 0, stdout: "new-container-id\n" }));

expect(() =>
recreateOpenShellDockerSandboxWithGpu(
{
sandboxName: "alpha",
timeoutSecs: 1,
openshellSandboxCommand: ["env", "nemoclaw-start"],
},
{
dockerCapture: vi.fn((args: readonly string[]) =>
args[0] === "ps"
? "old-container-id\n"
: args[0] === "inspect"
? JSON.stringify([inspect])
: "",
),
detectSandboxFallbackDns: vi.fn(() => null),
dockerRun: vi.fn(() => ({ status: 0, stdout: "probe-id\n" })),
dockerRunDetached,
dockerRename,
dockerRm: vi.fn(() => ({ status: 0 })),
dockerStop,
readDir: vi.fn(() => null),
readFile: vi.fn(() => null),
},
),
).toThrow("Docker structured tmpfs option 'exec' cannot be preserved during recreation");
expect(dockerStop).not.toHaveBeenCalled();
expect(dockerRename).not.toHaveBeenCalled();
expect(dockerRunDetached).not.toHaveBeenCalled();
});

it.each([
";",
"&&",
Expand Down
25 changes: 17 additions & 8 deletions test/e2e/live/mcp-bridge-onboard-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,34 @@ const EXACT_MAIN_OVERLAY_KEYS = new Set([
"NEMOCLAW_OPENSHELL_SANDBOX_BIN",
]);

export function buildMcpBridgeOnboardEnv(options: {
agent: "openclaw" | "hermes" | "langchain-deepagents-code";
export function buildMcpBridgeExactMainEnv(options: {
baseEnv?: NodeJS.ProcessEnv;
compatibleKey: string;
compatibleModel: string;
endpointUrl: string;
envOverlay?: NodeJS.ProcessEnv;
sandboxName: string;
}): NodeJS.ProcessEnv {
const envOverlay = options.envOverlay ?? {};
for (const key of Object.keys(envOverlay)) {
if (!EXACT_MAIN_OVERLAY_KEYS.has(key)) {
throw new Error(`MCP exact-main onboarding does not allow env overlay key '${key}'`);
throw new Error(`MCP exact-main command does not allow env overlay key '${key}'`);
}
}

return {
...buildAvailabilityProbeEnv(options.baseEnv),
...envOverlay,
};
}

export function buildMcpBridgeOnboardEnv(options: {
agent: "openclaw" | "hermes" | "langchain-deepagents-code";
baseEnv?: NodeJS.ProcessEnv;
compatibleKey: string;
compatibleModel: string;
endpointUrl: string;
envOverlay?: NodeJS.ProcessEnv;
sandboxName: string;
}): NodeJS.ProcessEnv {
return {
...buildMcpBridgeExactMainEnv(options),
COMPATIBLE_API_KEY: options.compatibleKey,
NVIDIA_INFERENCE_API_KEY: options.compatibleKey,
NEMOCLAW_AGENT: options.agent,
Expand All @@ -38,6 +48,5 @@ export function buildMcpBridgeOnboardEnv(options: {
NEMOCLAW_PROVIDER: "custom",
NEMOCLAW_SANDBOX_NAME: options.sandboxName,
NEMOCLAW_RECREATE_SANDBOX: "1",
...envOverlay,
};
}
12 changes: 9 additions & 3 deletions test/e2e/live/mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
assertHermesInspectionRejectsUnmanagedFields,
assertHermesRemovalSurvivesGatewayRestart,
} from "./mcp-bridge-hermes-lifecycle.ts";
import { buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts";
import { buildMcpBridgeExactMainEnv, buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts";
import { retryAfterHermesRestartTransportFailure } from "./mcp-bridge-reliability.ts";
import {
buildMcpDnsRebindingProbeScript,
Expand Down Expand Up @@ -827,11 +827,12 @@ async function rebuildWithoutMcpHostSecret(
host: HostCliClient,
sandboxName: string,
artifactPrefix: string,
envOverlay: NodeJS.ProcessEnv = {},
): Promise<void> {
const rebuild = await host.nemoclaw([sandboxName, "rebuild", "--yes"], {
artifactName: `${artifactPrefix}-rebuild-with-provider-backed-mcp`,
env: {
...buildAvailabilityProbeEnv(),
...buildMcpBridgeExactMainEnv({ envOverlay }),
COMPATIBLE_API_KEY: COMPATIBLE_KEY,
NVIDIA_INFERENCE_API_KEY: COMPATIBLE_KEY,
},
Expand Down Expand Up @@ -1439,7 +1440,12 @@ mcpBridgeShardTest("deepagents")(
[HOST_SECRET, ROTATED_HOST_SECRET],
"deepagents-assert-secrets-absent-after-rotation",
);
await rebuildWithoutMcpHostSecret(host, DEEPAGENTS_SANDBOX_NAME, "deepagents");
await rebuildWithoutMcpHostSecret(
host,
DEEPAGENTS_SANDBOX_NAME,
"deepagents",
exactMainProof.envOverlay,
);
await exactMainProof.afterRebuild();
await assertDeepAgentsConfig(sandbox, DEEPAGENTS_SANDBOX_NAME, mcpUrl);
await assertSecretAbsentFromSandbox(
Expand Down
34 changes: 34 additions & 0 deletions test/e2e/live/openshell-exact-main-driver-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ type DockerMount = {
Type?: unknown;
};

type ConfiguredDockerMount = {
Target?: unknown;
TmpfsOptions?: unknown;
Type?: unknown;
};

type RuntimeSnapshot = {
bridgeAddress: string;
config: ParsedGatewayConfig;
Expand Down Expand Up @@ -345,6 +351,34 @@ async function assertRuntimeMounts(
phase: string,
): Promise<void> {
const components = proof.components!;
const configuredMounts = await inspectJson<ConfiguredDockerMount[] | null>(
host,
containerId,
"{{json .HostConfig.Mounts}}",
`exact-main-driver-configured-mounts-${phase}`,
);
expect(Array.isArray(configuredMounts), "Docker HostConfig.Mounts must be structured").toBe(true);
expect(configuredMounts?.find((mount) => mount.Target === EXACT_MAIN_TMPFS_TARGET)).toMatchObject(
{
Type: "tmpfs",
Target: EXACT_MAIN_TMPFS_TARGET,
TmpfsOptions: {
Mode: EXACT_MAIN_TMPFS_MOUNT.mode,
SizeBytes: EXACT_MAIN_TMPFS_MOUNT.size_bytes,
},
},
);
const configuredTmpfs = await inspectJson<JsonRecord | null>(
host,
containerId,
"{{json .HostConfig.Tmpfs}}",
`exact-main-driver-configured-tmpfs-${phase}`,
);
expect(
configuredTmpfs === null ||
(isRecord(configuredTmpfs) && !Object.hasOwn(configuredTmpfs, EXACT_MAIN_TMPFS_TARGET)),
"the reviewed tmpfs must not use Docker's legacy HostConfig.Tmpfs path",
).toBe(true);
const mounts = await inspectJson<DockerMount[]>(
host,
containerId,
Expand Down
10 changes: 6 additions & 4 deletions test/e2e/live/openshell-exact-main-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ proc = matches[0]
status = {}
for line in (proc / "status").read_text(encoding="utf-8").splitlines():
name, separator, value = line.partition(":")
if separator:
status[name] = value.strip().split()[0]
fields = value.strip().split()
if separator and fields:
status[name] = fields[0]
cap_bnd = status.get("CapBnd", "")
if not cap_bnd or set(cap_bnd) != {"0"}:
raise SystemExit(f"entrypoint retained capability bounding set {cap_bnd or '<missing>'}")
Expand All @@ -69,8 +70,9 @@ tls_names = {${SUPERVISOR_TLS_ENV_NAMES.map((name) => JSON.stringify(name)).join
status = {}
for line in Path("/proc/self/status").read_text(encoding="utf-8").splitlines():
name, separator, value = line.partition(":")
if separator:
status[name] = value.strip().split()[0]
fields = value.strip().split()
if separator and fields:
status[name] = fields[0]
cap_bnd = status.get("CapBnd", "")
if not cap_bnd or set(cap_bnd) != {"0"}:
raise SystemExit(f"exec child retained capability bounding set {cap_bnd or '<missing>'}")
Expand Down
Loading
Loading