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
122 changes: 121 additions & 1 deletion .github/workflows/portable-profile-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ on:
- "scripts/install.sh"
- "scripts/install-openshell.sh"
- "src/lib/onboard/**"
- "src/lib/actions/sandbox/**"
- "src/lib/domain/sandbox/image-tag.ts"
- "src/lib/sandbox/build-context.ts"
- "src/lib/sandbox/**"
- "test/e2e/fixtures/availability-env.ts"
- "test/e2e/live/full-e2e.test.ts"
- "test/e2e/live/launch-agent-turn.ts"
- "test/e2e/live/portable-profile-gateway-proof.ts"
- "test/e2e/live/portable-profile-rootless-linux.test.ts"
- "tools/e2e/check-semantic-phases.mts"
Expand Down Expand Up @@ -93,3 +97,119 @@ jobs:
include-hidden-files: false
if-no-files-found: ignore
retention-days: 14

portable-launch:
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 75
env:
E2E_JOB: "1"
E2E_TARGET_ID: portable-launch
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/portable-launch
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_RUN_LIVE_E2E: "1"
NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_EXPERIMENTAL_PROFILE: portable
NEMOCLAW_SANDBOX_NAME: portable-launch
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75

- name: Provision restricted rootless Linux runtime
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --yes fuse-overlayfs passt podman slirp4netns uidmap
runtime_dir="/run/user/$(id -u)"
sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir"
if ! grep -q "^${USER}:" /etc/subuid; then
sudo usermod --add-subuids 100000-165535 "$USER"
fi
if ! grep -q "^${USER}:" /etc/subgid; then
sudo usermod --add-subgids 100000-165535 "$USER"
fi

shim_dir="${RUNNER_TEMP}/nemoclaw-portable-bin"
install -d -m 700 "$shim_dir"
cat >"$shim_dir/systemctl" <<'SHIM'
#!/usr/bin/env bash
set -euo pipefail
runtime_dir="${XDG_RUNTIME_DIR:?}"
service_dir="${runtime_dir}/podman"
socket_path="${service_dir}/podman.sock"
pid_file="${runtime_dir}/nemoclaw-podman-service.pid"
log_file="${runtime_dir}/nemoclaw-podman-service.log"
case "$*" in
"--user set-environment "*) exit 0 ;;
"--user try-restart podman.service")
if [[ -f "$pid_file" ]]; then
kill "$(<"$pid_file")" 2>/dev/null || true
rm -f "$pid_file" "$socket_path"
fi
;;
"--user enable --now podman.socket")
install -d -m 755 "$service_dir"
nohup podman system service --time=0 "unix://$socket_path" >"$log_file" 2>&1 &
echo $! >"$pid_file"
for _ in $(seq 1 100); do
if [[ -S "$socket_path" ]]; then
chmod 660 "$socket_path"
exit 0
fi
sleep 0.1
done
cat "$log_file" >&2 || true
exit 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
;;
*)
echo "unexpected user-service command: $*" >&2
exit 64
;;
esac
SHIM
chmod 700 "$shim_dir/systemctl"

export PATH="$shim_dir:$PATH"
export XDG_RUNTIME_DIR="$runtime_dir"
systemctl --user enable --now podman.socket
printf '%s\n' "$shim_dir" >>"$GITHUB_PATH"
printf 'XDG_RUNTIME_DIR=%s\n' "$runtime_dir" >>"$GITHUB_ENV"
printf 'DOCKER_HOST=unix://%s/podman/podman.sock\n' "$runtime_dir" >>"$GITHUB_ENV"
podman --version
docker --version
docker --host "unix://$runtime_dir/podman/podman.sock" info

- name: Exercise a portable launch through chat and permission restoration
env:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
run: >-
npx tsx tools/e2e/live-vitest-invocation.mts run
--test-path test/e2e/live/full-e2e.test.ts

- name: Upload portable launch E2E artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portable-launch-e2e-artifacts
path: e2e-artifacts/portable-launch/
include-hidden-files: false
if-no-files-found: ignore
retention-days: 14

- name: Clean up portable runtime
if: always()
shell: bash
run: |
podman system reset --force || true
pid_file="${XDG_RUNTIME_DIR}/nemoclaw-podman-service.pid"
if [[ -f "$pid_file" ]]; then
kill "$(<"$pid_file")" 2>/dev/null || true
fi
5 changes: 5 additions & 0 deletions src/lib/actions/sandbox/destroy-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { R, YW } from "../../cli/terminal-style";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
import { removePortableDemoSandboxLifecycleReceipt } from "../../onboard/experimental/portable-demo-lifecycle";
import {
CURRENT_RUNTIME_PROVIDER_BUNDLES,
type RuntimeProviderBundle,
Expand Down Expand Up @@ -32,6 +33,10 @@ export function redactDestroyError(error: unknown): string {
return redactFull(error instanceof Error ? error.message : String(error));
}

export function retirePortableLifecycleAuthority(sandboxName: string): void {
removePortableDemoSandboxLifecycleReceipt(sandboxName);
}

type SandboxDestroyExecutionInput = {
cleanupShieldsArtifacts: (sandboxName: string) => void;
force: boolean;
Expand Down
5 changes: 5 additions & 0 deletions src/lib/actions/sandbox/destroy-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ describe("destroySandbox flow", () => {
).resolves.toBeUndefined();

expectSuccessfulLiveDestroy(harness, exitSpy);
expect(harness.retirePortableLifecycleReceiptSpy).toHaveBeenCalledWith("alpha");
expect(harness.removeSandboxSpy.mock.invocationCallOrder[0]).toBeLessThan(
harness.retirePortableLifecycleReceiptSpy.mock.invocationCallOrder[0],
);
});

it("revokes the prior HTTPS-pin route only after confirmed deletion and registry removal", async () => {
Expand Down Expand Up @@ -106,6 +110,7 @@ describe("destroySandbox flow", () => {
await expect(harness.destroySandbox("alpha", { yes: true })).rejects.toThrow("process.exit(7)");

expectFailedDeletePreservesHostState(harness, exitSpy);
expect(harness.retirePortableLifecycleReceiptSpy).not.toHaveBeenCalled();
});

it("preserves provider and registry ownership when runtime authority is unknown", async () => {
Expand Down
15 changes: 14 additions & 1 deletion src/lib/actions/sandbox/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import * as onboardSession from "../../state/onboard-session";
import { resolveNemoclawStateDir } from "../../state/paths";
import * as registry from "../../state/registry";
import { confirmSandboxDestroy } from "./destroy-confirmation";
import { executeSandboxDestroy, redactDestroyError } from "./destroy-execution";
import {
executeSandboxDestroy,
redactDestroyError,
retirePortableLifecycleAuthority,
} from "./destroy-execution";
import { cleanupGatewayAfterLastSandbox } from "./destroy-gateway";
import { shouldCleanupGatewayAfterConfirmedFinalDestroy } from "./destroy-gateway-cleanup";
import { prepareSandboxDestroy } from "./destroy-preflight";
Expand Down Expand Up @@ -612,6 +616,15 @@ async function destroySandboxUnlocked(
);
process.exit(1);
}
if (removed) {
try {
retirePortableLifecycleAuthority(sandboxName);
} catch (error) {
console.warn(
` ${YW}⚠${R} Failed to retire portable lifecycle authority for '${sandboxName}': ${redactDestroyError(error)}`,
);
}
}
if (deleteSucceededOrAlreadyGone && removed && priorHttpsPinRouteId) {
await revokeDestroyedSandboxHttpsPinRoute(cleanupGatewayName, priorHttpsPinRouteId);
}
Expand Down
15 changes: 12 additions & 3 deletions src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type PortableDemoLifecycleRecoveryResult,
recoverPortableDemoSandboxLifecycle,
} from "../../onboard/experimental/portable-demo-lifecycle";
import { compareAndSetLegacySandboxLifecycleGeneration } from "../../state/registry/lifecycle-generation";
import type { SandboxEntry } from "../../state/registry/types";
import { getSandboxDockerRuntime } from "./docker-health";
import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier";
Expand Down Expand Up @@ -91,14 +92,22 @@ function gatewayScopedArgs(args: string[], gatewayName?: string): string[] {
/** Recover a receipt-bound portable sandbox before the live lookup rejects a stopped container. */
export function recoverPortableDemoSandboxLifecycleForConnect(
sandboxName: string,
sandbox: Pick<SandboxEntry, "agent" | "provider"> | null,
sandbox: SandboxEntry | null,
gatewayName: string,
): PortableDemoLifecycleRecoveryResult {
if (!sandbox) return { kind: "not-installed" };
if (!sandbox || sandbox.openshellDriver !== "docker") return { kind: "not-installed" };
return recoverPortableDemoSandboxLifecycle(
sandboxName,
{ agent: sandbox.agent, gatewayName, provider: sandbox.provider },
{
agent: sandbox.agent,
gatewayName,
lifecycleGeneration: sandbox.lifecycleGeneration,
openshellDriver: sandbox.openshellDriver,
provider: sandbox.provider,
},
{
backfillRegistryGeneration: (generation) =>
compareAndSetLegacySandboxLifecycleGeneration(sandbox, generation),
openshellBinary: getOpenshellBinary(),
captureOpenshell: (args, timeoutMs) => {
const result = captureOpenshell([...args], {
Expand Down
16 changes: 14 additions & 2 deletions src/lib/adapters/podman/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export interface PodmanContainerEngineOptions {
) => void;
}

export function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const local = { ...env };
delete local.CONTAINER_CONNECTION;
delete local.CONTAINER_HOST;
delete local.CONTAINER_SSHKEY;
return local;
}

function podmanAuthorityId(authority: PodmanSocketAuthority): string {
const canonical = JSON.stringify({
socketPath: authority.socketPath,
Expand Down Expand Up @@ -65,5 +73,9 @@ export function createPodmanContainerEngine(
});
}

export type { PodmanSocketAuthority } from "./socket-authority";
export { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority";
export type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "./socket-authority";
export {
assertPodmanSocketAuthority,
capturePodmanSocketAuthority,
hardenPodmanSocketDirectory,
} from "./socket-authority";
83 changes: 80 additions & 3 deletions src/lib/adapters/podman/socket-authority.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import net from "node:net";
import path from "node:path";

import { describe, expect, it, vi } from "vitest";

import { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority";
import {
assertPodmanSocketAuthority,
capturePodmanSocketAuthority,
hardenPodmanSocketDirectory,
} from "./socket-authority";

const SOCKET_PATH = "/run/user/1000/podman/podman.sock";

Expand Down Expand Up @@ -85,10 +93,79 @@ describe("Podman socket authority", () => {
).toThrow("writable by another user or group");
});

it.each([0o660n, 0o666n])("rejects another-user-writable socket mode %s", (mode) => {
it("accepts the rootless systemd socket mode inside a private current-user directory", () => {
const authority = capturePodmanSocketAuthority(SOCKET_PATH, {
lstat: secureLstat({ mode: 0o660n }, { "/run/user/1000/podman": { mode: 0o700n } }),
uid: 1000,
});

expect(authority.mode).toBe(String(0o660));
});

it.runIf(process.platform !== "win32")(
"hardens the current-user socket directory without following unsafe parents (#8584)",
async () => {
const root = fs.mkdtempSync(path.join(fs.realpathSync(process.cwd()), ".nc-p-"));
const server = net.createServer();
try {
const socketDirectory = path.join(root, "p");
fs.mkdirSync(socketDirectory);
fs.chmodSync(socketDirectory, 0o755);
const socketPath = path.join(socketDirectory, "s");
const uid = process.getuid?.() ?? -1;
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
fs.chmodSync(socketPath, 0o660);

hardenPodmanSocketDirectory(socketPath, uid);
expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700);

fs.chmodSync(socketDirectory, 0o770);
expect(() => hardenPodmanSocketDirectory(socketPath, uid)).toThrow(
"writable by another user or group",
);

const targetDirectory = path.join(root, "target");
const linkedDirectory = path.join(root, "linked");
fs.mkdirSync(targetDirectory);
fs.symlinkSync(targetDirectory, linkedDirectory, "dir");
expect(() =>
hardenPodmanSocketDirectory(path.join(linkedDirectory, "podman.sock"), uid),
).toThrow();

const missingSocketDirectory = path.join(root, "missing");
fs.mkdirSync(missingSocketDirectory, { mode: 0o755 });
expect(() =>
hardenPodmanSocketDirectory(path.join(missingSocketDirectory, "missing.sock"), uid),
).toThrow();
expect(fs.statSync(missingSocketDirectory).mode & 0o777).toBe(0o755);

fs.chmodSync(socketDirectory, 0o755);
fs.chmodSync(socketPath, 0o666);
expect(() => hardenPodmanSocketDirectory(socketPath, uid)).toThrow(
"writable by another user or group",
);
expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o755);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve())).catch(() => undefined);
fs.rmSync(root, { force: true, recursive: true });
}
},
);

it("rejects socket modes reachable by another user", () => {
expect(() =>
capturePodmanSocketAuthority(SOCKET_PATH, {
lstat: secureLstat({ mode: 0o660n }),
uid: 1000,
}),
).toThrow("socket authority is writable by another user or group");

expect(() =>
capturePodmanSocketAuthority(SOCKET_PATH, {
lstat: secureLstat({ mode }),
lstat: secureLstat({ mode: 0o666n }, { "/run/user/1000/podman": { mode: 0o700n } }),
uid: 1000,
}),
).toThrow("socket authority is writable by another user or group");
Expand Down
Loading
Loading