Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
119 changes: 118 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,116 @@ 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")
mkdir -p "$service_dir"
nohup podman system service --time=0 "unix://$socket_path" >"$log_file" 2>&1 &
echo $! >"$pid_file"
for _ in $(seq 1 100); do
[[ -S "$socket_path" ]] && exit 0
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
2 changes: 1 addition & 1 deletion src/lib/adapters/podman/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,5 @@ export function createPodmanContainerEngine(
});
}

export type { PodmanSocketAuthority } from "./socket-authority";
export type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "./socket-authority";
export { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority";
213 changes: 209 additions & 4 deletions src/lib/onboard/experimental/portable-demo-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";
import type { PodmanSocketAuthorityDeps } from "../../adapters/podman";
import type { SandboxEntry } from "../../state/registry";
import { recordUserLocalOllamaOwnership } from "./ollama-user-local-runtime";
import {
installPortableDemoSandboxLifecycle,
type PortableDemoLifecycleDeps,
portableDemoLifecycleInternals,
recoverPortableDemoSandboxLifecycle,
resolvePortableDemoPrivilegedExecTarget,
} from "./portable-demo-lifecycle";

const CONTAINER_ID = "a".repeat(64);
const SANDBOX_ID = "sandbox-id-alpha";
const SOCKET_PATH = "/run/user/1001/podman/podman.sock";
const STARTUP_ARGV = [
"env",
"CHAT_UI_URL=http://127.0.0.1:18789",
Expand Down Expand Up @@ -63,16 +67,24 @@ function createPodman(
let sandboxId = options.sandboxId ?? SANDBOX_ID;
let managedLabel = "true";
let sandboxNameLabel = "alpha";
const podman = vi.fn((args: readonly string[]) => {
switch (args[0]) {
let containerId = CONTAINER_ID;
let containerName = "openshell-sandbox-alpha";
let matches = [CONTAINER_ID];
let socketPath = "/run/user/1001/podman/podman.sock";
const podman = vi.fn((args: readonly string[], _env?: NodeJS.ProcessEnv) => {
const command = args[0] === "--url" ? args.slice(2) : args;
switch (command[0]) {
case "info":
return { status: 0, stdout: `${socketPath}\n` };
case "ps":
return { status: 0, stdout: `${CONTAINER_ID}\n` };
return { status: 0, stdout: matches.length > 0 ? `${matches.join("\n")}\n` : "" };
case "inspect":
return {
status: 0,
stdout: JSON.stringify([
{
Id: CONTAINER_ID,
Id: containerId,
Name: containerName,
Config: {
Labels: {
"openshell.managed": managedLabel,
Expand Down Expand Up @@ -104,9 +116,74 @@ function createPodman(
setSandboxNameLabel(value: string) {
sandboxNameLabel = value;
},
setContainerId(value: string) {
containerId = value;
},
setContainerName(value: string) {
containerName = value;
},
setMatches(value: string[]) {
matches = value;
},
setRunning(value: boolean) {
running = value;
},
setSocketPath(value: string) {
socketPath = value;
},
};
}

function socketAuthorityDeps(
options: {
directory?: boolean;
directoryMode?: bigint;
socketInode?: () => bigint;
socketMode?: bigint;
socketUid?: bigint;
} = {},
): PodmanSocketAuthorityDeps {
const directoryInodes = new Map<string, bigint>();
return {
uid: 1001,
lstat: (filePath) => {
const socket = filePath === SOCKET_PATH;
const directoryInode = directoryInodes.get(filePath) ?? BigInt(7000 + directoryInodes.size);
directoryInodes.set(filePath, directoryInode);
return {
dev: 8n,
ino: socket ? (options.socketInode?.() ?? 9001n) : directoryInode,
mode: socket
? (options.socketMode ?? 0o600n)
: filePath === path.dirname(SOCKET_PATH)
? (options.directoryMode ?? 0o700n)
: 0o755n,
uid: socket
? (options.socketUid ?? 1001n)
: filePath.startsWith("/run/user/1001")
? 1001n
: 0n,
isDirectory: () => !socket && (options.directory ?? true),
isSocket: () => socket,
};
},
};
}

function resolveTarget(
stateDir: string,
runtime: ReturnType<typeof createPodman>,
overrides: Partial<PortableDemoLifecycleDeps> = {},
) {
return resolvePortableDemoPrivilegedExecTarget("alpha", {
platform: "linux",
stateDir,
podman: runtime.podman,
podmanSocketAuthorityDeps: socketAuthorityDeps(),
...overrides,
});
}

function installReceipt(stateDir: string, podman: ReturnType<typeof createPodman>["podman"]): void {
installPortableDemoSandboxLifecycle(
"alpha",
Expand Down Expand Up @@ -254,6 +331,134 @@ describe("portable demo sandbox lifecycle", () => {
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
});

it("resolves the receipt-owned container through the rootless Podman socket (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);
runtime.podman.mockClear();

expect(resolveTarget(stateDir, runtime)).toMatchObject({
containerId: CONTAINER_ID,
dockerHost: "unix:///run/user/1001/podman/podman.sock",
});
expect(runtime.podman.mock.calls.map(([args]) => args)).toEqual([
["info", "--format", "{{.Host.RemoteSocket.Path}}"],
[
"--url",
"unix:///run/user/1001/podman/podman.sock",
"ps",
"-a",
"--no-trunc",
"--filter",
"label=openshell.managed=true",
"--filter",
"label=openshell.sandbox-name=alpha",
"--format",
"{{.ID}}",
],
["--url", "unix:///run/user/1001/podman/podman.sock", "inspect", CONTAINER_ID],
]);
expect(runtime.podman.mock.calls.map(([, env]) => env)).toEqual([
expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }),
expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }),
expect.not.objectContaining({ CONTAINER_HOST: expect.anything() }),
]);
});

it("refuses missing or duplicate portable containers before privileged exec (#8584)", () => {
for (const matches of [[], [CONTAINER_ID, "b".repeat(64)]]) {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);
runtime.setMatches(matches);

expect(() => resolveTarget(stateDir, runtime)).toThrow(`found ${matches.length}`);
}
});

it("refuses renamed or relabeled portable containers before privileged exec (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);

runtime.setContainerName("renamed-alpha");
expect(() => resolveTarget(stateDir, runtime)).toThrow("OpenShell identity does not match");

runtime.setContainerName("openshell-sandbox-alpha");
runtime.setSandboxNameLabel("beta");
expect(() => resolveTarget(stateDir, runtime)).toThrow("OpenShell identity does not match");
});

it("refuses a replacement or stopped portable container before privileged exec (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);

const replacementId = "b".repeat(64);
runtime.setMatches([replacementId]);
runtime.setContainerId(replacementId);
expect(() => resolveTarget(stateDir, runtime)).toThrow("recorded container identity changed");

runtime.setMatches([CONTAINER_ID]);
runtime.setContainerId(CONTAINER_ID);
runtime.setRunning(false);
expect(() => resolveTarget(stateDir, runtime)).toThrow("is not running");
});

it("refuses a non-local portable Podman socket before privileged exec (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);
runtime.setSocketPath("tcp://example.test:1234");

expect(() => resolveTarget(stateDir, runtime)).toThrow("socket path is invalid");
});

it.each([
["foreign owner", socketAuthorityDeps({ socketUid: 2000n }), "owned by uid 2000"],
["writable socket", socketAuthorityDeps({ socketMode: 0o660n }), "writable by another"],
["writable parent", socketAuthorityDeps({ directoryMode: 0o770n }), "writable by another"],
["symlinked parent", socketAuthorityDeps({ directory: false }), "not a real directory"],
])("refuses a %s for portable privileged exec (#8584)", (_case, authority, message) => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);

expect(() =>
resolveTarget(stateDir, runtime, { podmanSocketAuthorityDeps: authority }),
).toThrow(message);
});

it("ignores ambient Podman remote selection for portable privileged exec (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);
runtime.podman.mockClear();

resolveTarget(stateDir, runtime, {
env: {
CONTAINER_CONNECTION: "attacker",
CONTAINER_HOST: "tcp://example.test:1234",
CONTAINER_SSHKEY: "/tmp/attacker-key",
},
});

expect(runtime.podman.mock.calls.map(([, env]) => env)).toEqual([{}, {}, {}]);
});

it("refuses socket replacement after portable workload inspection (#8584)", () => {
const stateDir = temporaryStateDir();
const runtime = createPodman();
installReceipt(stateDir, runtime.podman);
let inode = 9001n;
const target = resolveTarget(stateDir, runtime, {
podmanSocketAuthorityDeps: socketAuthorityDeps({ socketInode: () => inode }),
});
inode = 9002n;

expect(() => target?.assertRuntimeAuthority()).toThrow("changed after it was qualified");
});

it("does not persist proxy credentials from the create-time environment (#8441)", () => {
const stateDir = temporaryStateDir();
const { podman } = createPodman();
Expand Down
Loading
Loading