Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0b297d1
feat(cli): add read-only host mounts
ericksoa Aug 5, 2026
2307c24
docs(cli): document read-only host mounts
ericksoa Aug 5, 2026
aad2498
docs(cli): align host mount reference wording
ericksoa Aug 5, 2026
6a8b0fd
test(cli): keep host mount fixtures compatible
ericksoa Aug 5, 2026
e01efa7
refactor(onboard): keep host mount flow modular
ericksoa Aug 5, 2026
1668693
fix(cli): harden host mount lifecycle
ericksoa Aug 5, 2026
5ca4cc3
fix(onboard): always restore host mount scope
ericksoa Aug 5, 2026
dfadc4e
fix(cli): canonicalize host mounts deterministically
ericksoa Aug 5, 2026
19c269a
fix(cli): revalidate host mounts at create boundary
ericksoa Aug 5, 2026
6cbe6e7
test(cli): expect rebuilt mount identity
ericksoa Aug 5, 2026
0930ee5
merge: resolve conflicts with main
github-actions[bot] Aug 5, 2026
de8bee4
merge: resolve conflicts with main
github-actions[bot] Aug 5, 2026
7d0e659
fix(cli): use shared gateway health wait config
ericksoa Aug 5, 2026
974066d
Merge remote-tracking branch 'origin/feat/read-only-host-mounts-8274'…
ericksoa Aug 5, 2026
3e33e40
feat(onboard): declare runtime host mount support
ericksoa Aug 5, 2026
23d5c6e
test(onboard): keep capability assertions linear
ericksoa Aug 5, 2026
90784b8
merge: resolve conflicts with main
github-actions[bot] Aug 5, 2026
f57f843
merge: resolve conflicts with main
github-actions[bot] Aug 6, 2026
a016737
merge: resolve conflicts with main
github-actions[bot] Aug 6, 2026
837389e
Merge branch 'main' into feat/read-only-host-mounts-8274
ericksoa Aug 6, 2026
905b4e6
merge: resolve runtime host-mount capability conflicts
prekshivyas Aug 6, 2026
f832717
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
e3e3a35
merge: resolve conflicts with main
github-actions[bot] Aug 13, 2026
3541c65
merge: resolve conflicts with main
github-actions[bot] Aug 13, 2026
d12df2d
merge: resolve conflicts with main
github-actions[bot] Aug 13, 2026
8415b24
merge: resolve conflicts with main
github-actions[bot] Aug 14, 2026
7cc7546
merge: resolve conflicts with main
github-actions[bot] Aug 14, 2026
29d0ab3
merge: resolve conflicts with main
github-actions[bot] Aug 14, 2026
793aa8a
merge: rebuild runtime host mount support on main
cv Aug 15, 2026
1cd2810
fix(onboard): reject host mounts before runtime preparation
cv Aug 15, 2026
ff20cc5
test(onboard): narrow runtime authority fixture
cv Aug 15, 2026
fa0020a
merge(main): incorporate current required checks
cv Aug 15, 2026
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
28 changes: 26 additions & 2 deletions docs/manage-sandboxes/workspace-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,34 @@ You can also expose selected host directories for live, read-only access when co

## Mount a Host Directory for Read-Only Access

On Linux and Windows Subsystem for Linux 2 (WSL2), onboarding can expose an existing host directory inside a sandbox.
This operation is supported only with a NemoClaw-managed Docker-driver gateway.
Onboarding can expose an existing host directory inside a sandbox when the selected runtime provider and host platform support read-only host mounts.
The same command works with OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes.

### Runtime Support

The runtime provider and host platform determine whether NemoClaw can create the mount:

| Runtime Provider | Host Platform | Status |
|---|---|---|
| Docker | Linux or Windows Subsystem for Linux 2 (WSL2) | Supported with a NemoClaw-managed Docker-driver gateway. |
| Docker | macOS or native Windows | Unsupported. |
| Kubernetes | Any host | Unsupported because host directories are node-local and require separately qualified scheduling, policy, and security rules. |
| Podman | Any host | Unsupported because read-only host mounts have not passed runtime-provider qualification. |
| OpenShell MXC | Any host | Unsupported because OpenShell MXC does not expose a qualified native host-sharing contract. |

When you request `--host-mount`, NemoClaw checks the selected runtime provider and host platform before it records onboarding state or changes runtime resources.
NemoClaw reports the reason that the unsupported provider declares.
For a supported provider on an unqualified host, NemoClaw reports that the host platform is not qualified.
NemoClaw does not fall back to Docker bind-mount configuration.

A runtime-provider implementation must meet these requirements before it can declare support:

- Declare qualified host platforms when supported, or declare an explicit reason when unsupported.
- Preserve the source, target, symbolic-link, duplication, and read-only validation described on this page.
- Revalidate the source path identity immediately before sandbox creation.
- Implement provider-specific create configuration and host-side activation without a Docker fallback.
- Test requested and persisted mounts across onboarding, resume, rebuild, and failure paths.

<Warning>
The mount crosses the sandbox boundary and gives every sandbox process read access to the complete host directory tree.
Read-only access prevents sandbox writes, but it does not protect confidential host files from being read.
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe("sandbox status host mounts", () => {

expect(report.hostMounts).toEqual(hostMounts);
expect(report.hostMounts).not.toBe(hostMounts);
expect(report.hostMounts?.[0]).not.toBe(hostMounts[0]);
} finally {
fs.rmSync(source, { recursive: true, force: true });
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/checkpoint-resume-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function makeDeps(overrides: Partial<OnboardSessionBootstrapDeps>): OnboardSessi
exitProcess: (code) => {
throw new ExitError(code);
},
requireHostMountRuntimeSupport: () => {},
resolveResumeCheckpoint: (): CheckpointLoadResult => ({ status: "none" }),
...overrides,
};
Expand Down
51 changes: 38 additions & 13 deletions src/lib/onboard/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ describe("onboard command options", () => {
});
});

it("maps repeated Linux host mounts and rejects unsupported host platforms", () => {
it("maps repeated host mounts when the selected runtime provider supports them", () => {
const first = fs.mkdtempSync(path.join(process.cwd(), ".onboard-host-mount-test-"));
const second = fs.mkdtempSync(path.join(process.cwd(), ".onboard-host-mount-test-"));
const values = [`${first}:/sandbox/project`, `${second}:/sandbox/reference`];
Expand All @@ -309,28 +309,53 @@ describe("onboard command options", () => {
sourceIdentity: { device: expect.any(String), inode: expect.any(String) },
},
]);
expect(() => resolve({ "host-mount": values }, { platform: "darwin" })).toThrow("exit:1");
} finally {
fs.rmSync(first, { recursive: true, force: true });
fs.rmSync(second, { recursive: true, force: true });
}
});

it("rejects host mounts for the portable Podman profile before path or runtime effects", () => {
it("reports the Podman capability reason for portable host mounts", () => {
const errors: string[] = [];
const source = fs.mkdtempSync(path.join(process.cwd(), ".onboard-host-mount-test-"));

expect(() =>
resolve(
{
"experimental-profile": "portable",
"host-mount": ["/path/that/need/not/exist:/sandbox/project"],
},
{ platform: "linux", error: (message = "") => errors.push(message) },
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("requires the OpenShell Docker driver");
try {
expect(() =>
resolve(
{
"experimental-profile": "portable",
"host-mount": [`${source}:/sandbox/project`],
},
{ platform: "linux", error: (message = "") => errors.push(message) },
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("Runtime provider 'podman'");
expect(errors.join("\n")).toContain("not qualified for the Podman runtime provider");
} finally {
fs.rmSync(source, { recursive: true, force: true });
}
});

it.each([
["darwin", "arm64", "docker", "has not qualified read-only host mounts"],
["win32", "x64", "kubernetes", "Kubernetes hostPath semantics"],
] as const)(
"reports why the runtime provider selected for %s rejects host mounts",
(platform, arch, provider, reason) => {
const source = fs.mkdtempSync(path.join(process.cwd(), ".onboard-host-mount-test-"));
const error = vi.fn();
try {
expect(() =>
resolve({ "host-mount": [`${source}:/sandbox/project`] }, { platform, arch, error }),
).toThrow("exit:1");
expect(error.mock.calls.flat().join("\n")).toContain(`Runtime provider '${provider}'`);
expect(error.mock.calls.flat().join("\n")).toContain(reason);
} finally {
fs.rmSync(source, { recursive: true, force: true });
}
},
);

it("resolves the portable profile to deterministic unattended defaults", () => {
expect(resolve({ "experimental-profile": "portable" })).toMatchObject({
experimentalProfile: "portable",
Expand Down
16 changes: 7 additions & 9 deletions src/lib/onboard/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
resolveLocalModelProfilePlan,
} from "./local-model-profile/plan";
import { managedSandboxFeatureIssue } from "./managed-sandbox-feature";
import { parseReadOnlyHostMounts } from "./host-mount";
import { parseReadOnlyHostMounts, requireReadOnlyHostMountRuntimeSupport } from "./host-mount";
import { DCODE_OBSERVABILITY_FEATURE } from "./observability-policy-presets";
import { isOpenclawAgent } from "./openclaw-otel-policy-presets";
import { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice";
Expand Down Expand Up @@ -91,6 +91,8 @@ export interface OnboardCommandOptions {
export interface ResolveOnboardOptionsDeps {
env: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
arch?: NodeJS.Architecture;
runtimeProviders?: import("./runtime-provider/access").RuntimeProviderBundleRegistry;
listAgents?: () => string[];
listServingProfiles?: () => ServingProfileListEntry[];
loadServingCatalog?: () => CompiledServingCatalog;
Expand Down Expand Up @@ -193,20 +195,16 @@ function resolveHostMounts(
experimentalProfile: ExperimentalOnboardProfile | null,
deps: ResolveOnboardOptionsDeps,
): import("../state/registry/types").SandboxHostMount[] {
if ((values?.length ?? 0) > 0 && experimentalProfile === PORTABLE_EXPERIMENTAL_PROFILE) {
fail(
deps,
" --host-mount requires the OpenShell Docker driver and cannot be used with --experimental-profile portable.",
);
}
let mounts: import("../state/registry/types").SandboxHostMount[];
try {
mounts = parseReadOnlyHostMounts(values ?? []);
} catch (error) {
return fail(deps, ` ${error instanceof Error ? error.message : String(error)}`);
}
if (mounts.length > 0 && (deps.platform ?? process.platform) !== "linux") {
fail(deps, " --host-mount is currently supported only on Linux and WSL2 hosts.");
try {
requireReadOnlyHostMountRuntimeSupport(mounts, { ...deps, experimentalProfile });
} catch (error) {
fail(deps, ` ${error instanceof Error ? error.message : String(error)}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return mounts;
}
Expand Down
43 changes: 43 additions & 0 deletions src/lib/onboard/host-mount/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ import {
verifyReadOnlyHostMountSources,
} from "../../state/registry/host-mount";
import type { SandboxHostMount } from "../../state/registry/types";
import {
type ExperimentalOnboardProfile,
isPortableExperimentalProfile,
PORTABLE_EXPERIMENTAL_PROFILE,
} from "../docker-driver-platform";
import {
CURRENT_RUNTIME_PROVIDER_BUNDLES,
type RuntimeProviderBundleRegistry,
RuntimeProviderSelectionError,
requireRuntimeProviderReadOnlyHostMounts,
resolveCurrentRuntimeProviderBundle,
} from "../runtime-provider/access";
import { PODMAN_READ_ONLY_HOST_MOUNT_UNSUPPORTED_REASON } from "../runtime-provider/podman";

export {
hasUnsafeHostMountTerminalText,
Expand All @@ -18,6 +31,36 @@ export {
verifyReadOnlyHostMountSources,
};

export interface ReadOnlyHostMountRuntimeSupportDeps {
readonly platform?: NodeJS.Platform;
readonly arch?: NodeJS.Architecture;
readonly env?: NodeJS.ProcessEnv;
readonly experimentalProfile?: ExperimentalOnboardProfile | null;
readonly runtimeProviders?: RuntimeProviderBundleRegistry;
}

export function requireReadOnlyHostMountRuntimeSupport(
mounts: readonly SandboxHostMount[] | undefined,
deps: ReadOnlyHostMountRuntimeSupportDeps = {},
): void {
if (!mounts || mounts.length === 0) return;
const portable =
deps.experimentalProfile === PORTABLE_EXPERIMENTAL_PROFILE ||
isPortableExperimentalProfile(deps.env);
if (portable) {
throw new RuntimeProviderSelectionError(
`Runtime provider 'podman' does not support read-only host mounts: ${PODMAN_READ_ONLY_HOST_MOUNT_UNSUPPORTED_REASON}`,
);
}
const platform = deps.platform ?? process.platform;
const provider = resolveCurrentRuntimeProviderBundle(
platform,
deps.arch ?? process.arch,
deps.runtimeProviders ?? CURRENT_RUNTIME_PROVIDER_BUNDLES,
);
requireRuntimeProviderReadOnlyHostMounts(provider, platform);
}

let dockerBindMountsEnabled = false;

export function isDockerBindMountsEnabled(): boolean {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/onboard/managed-workload-rebuild-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ function bundle(providerId: string): RuntimeProviderBundle {
directLifecycle: false,
legacyGatewayContainerInspection: false,
workloadImageCleanup: false,
readOnlyHostMounts: {
supported: false,
reason: "not used by the rebuild transaction contract test",
},
},
preflightDoctor: {
providerId,
Expand Down
52 changes: 52 additions & 0 deletions src/lib/onboard/resume/locked-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ import {
} from "../../state/onboard-checkpoint-types";
import { prepare } from "./locked-runtime";

vi.mock("../session-bootstrap", async (importOriginal) => {
const original = await importOriginal<typeof import("../session-bootstrap")>();
return { ...original, assertLockedResumeIntentSnapshot: vi.fn() };
});

const portableAuthority = {
schemaVersion: 1 as const,
kind: "podman" as const,
ownership: "current-user" as const,
uid: 1000,
homeDir: "/home/alice",
configHome: "/home/alice/.config",
runtimeDir: "/run/user/1000",
socketPath: "/run/user/1000/podman/podman.sock",
};

const portableCheckpointWithoutAuthority: OnboardCheckpoint = {
schemaVersion: CHECKPOINT_SCHEMA_VERSION,
profile: { kind: "selected", value: "portable" },
Expand Down Expand Up @@ -45,4 +61,40 @@ describe("locked onboarding runtime preparation", () => {
).rejects.toThrow(/requires recorded runtime authority.*--fresh/su);
expect(preparePortableHost).not.toHaveBeenCalled();
});

it("rejects persisted portable host mounts before host preparation (#8343)", async () => {
const preparePortableHost = vi.fn();
const checkpoint: OnboardCheckpoint = {
...portableCheckpointWithoutAuthority,
runtimeAuthority: { kind: "selected", value: portableAuthority },
};

await expect(
prepare(
{
resume: true,
experimentalProfile: "portable",
preparePortableHost,
resumeIntentSnapshot: {
fingerprint: "a".repeat(64),
sessionId: checkpoint.sessionId,
checkpointUpdatedAt: checkpoint.updatedAt,
machineRevision: 1,
profile: "portable",
},
},
true,
true,
() => ({
checkpoint,
metadata: {
hostMounts: [
{ source: "/srv/project", target: "/sandbox/project", readOnly: true },
],
},
}),
),
).rejects.toThrow(/provider 'podman'.*does not support read-only host mounts/su);
expect(preparePortableHost).not.toHaveBeenCalled();
});
});
13 changes: 11 additions & 2 deletions src/lib/onboard/resume/locked-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
CheckpointPortableRuntimeAuthority,
OnboardCheckpoint,
} from "../../state/onboard-checkpoint-types";
import { requireReadOnlyHostMountRuntimeSupport } from "../host-mount";
import {
assertLockedResumeIntentSnapshot,
createDefaultResumeProfileEnvironmentScope,
Expand Down Expand Up @@ -121,12 +122,20 @@ export async function prepare(
options: OnboardOptions,
resume: boolean,
nonInteractive: boolean,
loadSession: () => { readonly checkpoint?: OnboardCheckpoint | null } | null,
loadSession: () => {
readonly checkpoint?: OnboardCheckpoint | null;
readonly metadata?: { readonly hostMounts?: OnboardOptions["hostMounts"] };
} | null,
): Promise<LockedOnboardRuntimePreparation> {
const storedSession = resume ? loadSession() : null;
const { checkpointProfile, expectedPortableAuthority } = resolveCheckpointProfile(
options,
resume,
loadSession,
() => storedSession,
);
requireReadOnlyHostMountRuntimeSupport(
options.hostMounts?.length ? options.hostMounts : storedSession?.metadata?.hostMounts,
{ experimentalProfile: checkpointProfile === "portable" ? "portable" : null },
);
let environmentScope: PortableOnboardEnvironmentScope | null = null;
try {
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/runtime-provider/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
requireRuntimeProviderDestructiveCleanupAuthority,
requireRuntimeProviderHostLocalInferenceOperation,
requireRuntimeProviderMutationAuthority,
requireRuntimeProviderReadOnlyHostMounts,
requireRuntimeProviderStateMutationSurface,
resolveRuntimeProviderBundle,
runtimeProviderContainerEngineIdentity,
Expand Down
11 changes: 11 additions & 0 deletions src/lib/onboard/runtime-provider/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,22 @@ export interface RuntimeProviderPlanDefinition {
readonly gatewayLauncher: RuntimeProviderGatewayLauncher;
}

export type RuntimeProviderReadOnlyHostMountCapability =
| {
readonly supported: true;
readonly hostPlatforms: readonly NodeJS.Platform[];
}
| {
readonly supported: false;
readonly reason: string;
};

export interface RuntimeProviderNormalizedCapabilities {
readonly hostLocalInference: boolean;
readonly directLifecycle: boolean;
readonly legacyGatewayContainerInspection: boolean;
readonly workloadImageCleanup: boolean;
readonly readOnlyHostMounts: RuntimeProviderReadOnlyHostMountCapability;
}

export type RuntimeProviderManagedImageSupport = {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/onboard/runtime-provider/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export function createDockerRuntimeProviderBundle(
directLifecycle: true,
legacyGatewayContainerInspection: false,
workloadImageCleanup: true,
readOnlyHostMounts: { supported: true, hostPlatforms: ["linux"] },
},
preflightDoctor: {
providerId,
Expand Down Expand Up @@ -478,6 +479,11 @@ export function createKubernetesRuntimeProviderBundle(
directLifecycle: false,
legacyGatewayContainerInspection: true,
workloadImageCleanup: true,
readOnlyHostMounts: {
supported: false,
reason:
"Kubernetes hostPath semantics have not passed NemoClaw security and lifecycle qualification.",
},
},
preflightDoctor: {
providerId,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/onboard/runtime-provider/mxc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ describe("inactive OpenShell MXC runtime provider", () => {
hostLocalInference: false,
directLifecycle: false,
workloadImageCleanup: false,
readOnlyHostMounts: {
supported: false,
reason: expect.stringMatching(/host-directory sharing contract/u),
},
});
for (const surface of [
provider.lifecycle,
Expand Down
Loading
Loading