Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 6 additions & 2 deletions src/lib/adapters/podman/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,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";
62 changes: 59 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 os from "node:os";
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,58 @@ 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)",
() => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-podman-socket-"));
try {
const socketDirectory = path.join(root, "podman");
fs.mkdirSync(socketDirectory);
fs.chmodSync(socketDirectory, 0o755);
const socketPath = path.join(socketDirectory, "podman.sock");
const uid = process.getuid?.() ?? -1;

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();
} finally {
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
47 changes: 45 additions & 2 deletions src/lib/adapters/podman/socket-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ function normalizedSocketPath(socketPath: string): string {
return normalized;
}

export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: number): void {
const normalized = normalizedSocketPath(socketPath);
const uid = currentUid(configuredUid);
const directory = path.dirname(normalized);
const descriptor = fs.openSync(
directory,
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isDirectory()) {
throw new Error("Podman socket directory is not a real directory.");
}
if (before.uid !== BigInt(uid)) {
throw new Error(
`Podman socket directory is owned by uid ${before.uid.toString(10)}; expected current uid ${String(uid)}.`,
);
}
if ((before.mode & 0o022n) !== 0n) {
throw new Error("Podman socket directory is writable by another user or group.");
}

fs.fchmodSync(descriptor, 0o700);
const after = fs.fstatSync(descriptor, { bigint: true });
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.uid !== before.uid ||
(after.mode & 0o777n) !== 0o700n
) {
throw new Error("Podman socket directory changed while it was secured.");
}
} finally {
fs.closeSync(descriptor);
}
}

function captureDirectoryChain(
socketPath: string,
uid: number,
Expand Down Expand Up @@ -140,11 +177,17 @@ export function capturePodmanSocketAuthority(
);
}
const mode = integerValue(stat.mode, "mode");
if ((mode & 0o022n) !== 0n) {
const directoryChain = captureDirectoryChain(normalized, uid, lstat);
const socketParent = directoryChain[0];
const parentMode = socketParent ? BigInt(socketParent.mode) : 0o777n;
// The rootless Podman systemd socket defaults to 0660. Group write stays
// inside the current-UID trust boundary when its owner-only parent prevents
// every other non-root user from reaching the socket.
if ((mode & 0o002n) !== 0n || ((mode & 0o020n) !== 0n && (parentMode & 0o077n) !== 0n)) {
throw new Error("Podman socket authority is writable by another user or group.");
}
return Object.freeze({
directoryChain: captureDirectoryChain(normalized, uid, lstat),
directoryChain,
device: integerIdentity(stat.dev, "device"),
inode: integerIdentity(stat.ino, "inode"),
mode: mode.toString(10),
Expand Down
Loading
Loading