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
11 changes: 11 additions & 0 deletions .github/workflows/portable-profile-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,27 @@ on:
- ".github/workflows/portable-profile-e2e.yaml"
- "install.sh"
- "scripts/install.sh"
- "scripts/install-openshell.sh"
- "src/lib/onboard/**"
- "src/lib/domain/sandbox/image-tag.ts"
- "src/lib/sandbox/build-context.ts"
- "test/e2e/live/portable-profile-gateway-proof.ts"
- "test/e2e/live/portable-profile-rootless-linux.test.ts"
- "tools/e2e/check-semantic-phases.mts"
push:
branches:
- main
paths:
- ".github/workflows/portable-profile-e2e.yaml"
- "install.sh"
- "scripts/install.sh"
- "scripts/install-openshell.sh"
- "src/lib/onboard/**"
- "src/lib/domain/sandbox/image-tag.ts"
- "src/lib/sandbox/build-context.ts"
- "test/e2e/live/portable-profile-gateway-proof.ts"
- "test/e2e/live/portable-profile-rootless-linux.test.ts"
- "tools/e2e/check-semantic-phases.mts"

permissions:
contents: read
Expand Down Expand Up @@ -58,6 +64,11 @@ jobs:
- name: Build shared policy boundary
run: npm run build:policy-boundary

- name: Install pinned OpenShell
run: |
env -u GH_TOKEN -u GITHUB_TOKEN NEMOCLAW_NON_INTERACTIVE=1 bash scripts/install-openshell.sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Provision restricted rootless Linux runtime
shell: bash
run: |
Expand Down
5 changes: 4 additions & 1 deletion src/lib/onboard/docker-driver-gateway-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ export function buildDockerDriverGatewayConfigToml(
["host_gateway_ip", driver === "podman" ? PORTABLE_HOST_GATEWAY_IP : undefined],
["network_name", gatewayEnv.OPENSHELL_DOCKER_NETWORK_NAME],
["supervisor_image", gatewayEnv.OPENSHELL_DOCKER_SUPERVISOR_IMAGE],
["supervisor_bin", sandboxBin ?? undefined],
// OpenShell 0.0.85 accepts supervisor_bin only for the Docker driver.
// The Podman schema rejects the entire driver table when this Docker-only
// field is present, so portable onboarding must rely on supervisor_image.
["supervisor_bin", driver === "docker" ? (sandboxBin ?? undefined) : undefined],
["guest_tls_ca", localTlsDir ? path.join(localTlsDir, "ca.crt") : undefined],
["guest_tls_cert", localTlsDir ? path.join(localTlsDir, "client", "tls.crt") : undefined],
["guest_tls_key", localTlsDir ? path.join(localTlsDir, "client", "tls.key") : undefined],
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/docker-driver-gateway-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("buildDockerDriverGatewayEnv", () => {
expect(toml).toContain('compute_drivers = ["podman"]');
expect(toml).toContain("[openshell.drivers.podman]");
expect(toml).toContain('host_gateway_ip = "169.254.1.2"');
expect(toml).not.toContain("supervisor_bin");
} finally {
vi.unstubAllEnvs();
fs.rmSync(stateDir, { recursive: true, force: true });
Expand Down
56 changes: 56 additions & 0 deletions test/e2e/live/portable-profile-gateway-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";

import { spawnObservedChild } from "../fixtures/observed-child-process.ts";
import type { TestProgress } from "../fixtures/progress.ts";
import { stripAnsi } from "./json-envelope.ts";

/** Verify that the pinned gateway accepts the generated rootless Podman configuration. */
export async function verifyPinnedPodmanGatewayStarts(
gatewayBin: string,
gatewayEnv: Record<string, string>,
progress: TestProgress,
): Promise<void> {
const child = spawnObservedChild(gatewayBin, [], {
activityLabel: "command: pinned OpenShell Podman gateway",
progress,
spawn: {
env: { ...process.env, ...gatewayEnv },
stdio: ["ignore", "pipe", "pipe"],
},
});
let output = "";
child.stdout?.on("data", (chunk) => {
output += String(chunk);
});
child.stderr?.on("data", (chunk) => {
output += String(chunk);
});
try {
const deadline = Date.now() + 15_000;
let driverSeenAt = 0;
while (Date.now() < deadline) {
const plainOutput = stripAnsi(output);
if (/configuration error|invalid \[openshell\.drivers\.podman\] table/i.test(plainOutput)) {
assert.fail(`Pinned OpenShell rejected the generated Podman configuration:\n${output}`);
}
if (child.exitCode !== null) {
assert.fail(
`Pinned OpenShell Podman gateway exited with ${String(child.exitCode)}:\n${output}`,
);
}
if (/Using compute driver\s+driver=podman/.test(plainOutput)) {
if (driverSeenAt === 0) driverSeenAt = Date.now();
if (Date.now() - driverSeenAt >= 2_000) return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.fail(
`Pinned OpenShell did not report driver=podman and remain running for two seconds:\n${output}`,
);
} finally {
child.kill("SIGTERM");
}
}
31 changes: 26 additions & 5 deletions test/e2e/live/portable-profile-rootless-linux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,24 @@ import os from "node:os";
import path from "node:path";

import * as importedGatewayEnv from "../../../src/lib/onboard/docker-driver-gateway-env.ts";
import * as importedGatewayLocalTls from "../../../src/lib/onboard/docker-driver-gateway-local-tls.ts";
import * as importedPortableHostPreparation from "../../../src/lib/onboard/experimental/portable-host-preparation.ts";
import * as importedSandboxPrebuild from "../../../src/lib/onboard/sandbox-prebuild.ts";
import * as importedBuildContext from "../../../src/lib/sandbox/build-context.ts";
import { test } from "../fixtures/e2e-test.ts";
import type { TestProgress } from "../fixtures/progress.ts";
import { verifyPinnedPodmanGatewayStarts } from "./portable-profile-gateway-proof.ts";

const gatewayEnvModule = (
"default" in importedGatewayEnv && importedGatewayEnv.default
? importedGatewayEnv.default
: importedGatewayEnv
) as typeof import("../../../src/lib/onboard/docker-driver-gateway-env.ts");
const gatewayLocalTlsModule = (
"default" in importedGatewayLocalTls && importedGatewayLocalTls.default
? importedGatewayLocalTls.default
: importedGatewayLocalTls
) as typeof import("../../../src/lib/onboard/docker-driver-gateway-local-tls.ts");
const portableHostPreparationModule = (
"default" in importedPortableHostPreparation && importedPortableHostPreparation.default
? importedPortableHostPreparation.default
Expand All @@ -36,6 +44,7 @@ const buildContextModule = (
) as typeof import("../../../src/lib/sandbox/build-context.ts");

const { buildDockerDriverGatewayEnv } = gatewayEnvModule;
const { ensureDockerDriverGatewayLocalTlsBundle } = gatewayLocalTlsModule;
const { preparePortableExperimentalHost } = portableHostPreparationModule;
const { prebuildSandboxImageIfEligible } = sandboxPrebuildModule;
const { SANDBOX_BUILD_CONTEXT_PREFIX } = buildContextModule;
Expand All @@ -46,6 +55,7 @@ const PORTABLE_PROFILE_E2E_PHASES = [
"select the Podman-reported runtime socket",
"prepare the rootless container runtime",
"build and publish the sandbox image",
"start the pinned Podman gateway",
"verify the fixed host route",
"record portable environment completion",
] as const;
Expand Down Expand Up @@ -141,7 +151,7 @@ function selectInstallerPodmanRuntime(repoRoot: string): string {
return run("bash", ["-c", script]);
}

async function main(progress: { phase: (phase: string) => void }): Promise<void> {
async function main(progress: TestProgress): Promise<void> {
assert.equal(process.platform, "linux", "portable profile E2E requires Linux");
assert.notEqual(process.getuid?.(), 0, "portable profile E2E must run without root privileges");

Expand Down Expand Up @@ -222,21 +232,32 @@ async function main(progress: { phase: (phase: string) => void }): Promise<void>
"nemoclaw-portable-registry",
]);

progress.phase("verify the fixed host route");
const gatewayBin = run("bash", ["-lc", "command -v openshell-gateway"]);
const sandboxBin = run("bash", ["-lc", "command -v openshell-sandbox"]);
const gatewayEnv = buildDockerDriverGatewayEnv({
platform: "linux",
gatewayPort: 5000,
gatewayPort: 8080,
stateDir,
getDockerSupervisorImage: () => "supervisor:e2e-not-launched",
resolveSandboxBin: () => null,
resolveSandboxBin: () => sandboxBin,
});
assert.equal(gatewayEnv.OPENSHELL_DRIVERS, "podman");
assert.equal(gatewayEnv.OPENSHELL_BIND_ADDRESS, "0.0.0.0");
assert.equal(gatewayEnv.OPENSHELL_GRPC_ENDPOINT, "https://169.254.1.2:5000");
assert.equal(gatewayEnv.OPENSHELL_GRPC_ENDPOINT, "https://169.254.1.2:8080");
assert.match(
fs.readFileSync(gatewayEnv.OPENSHELL_GATEWAY_CONFIG, "utf-8"),
/host_gateway_ip = "169\.254\.1\.2"/,
);
assert.doesNotMatch(
fs.readFileSync(gatewayEnv.OPENSHELL_GATEWAY_CONFIG, "utf-8"),
/supervisor_bin/,
);

progress.phase("start the pinned Podman gateway");
ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin, stateDir });
await verifyPinnedPodmanGatewayStarts(gatewayBin, gatewayEnv, progress);

progress.phase("verify the fixed host route");

const routeProof = [
"exec 3<>/dev/tcp/169.254.1.2/5000",
Expand Down
18 changes: 18 additions & 0 deletions test/e2e/support/e2e-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,26 @@ describe("e2e workflow boundary", () => {
jobs: Record<string, { env?: Record<string, string>; if?: string }>;
};
const workflowJobs = new Set(Object.keys(workflow.jobs));
const portableWorkflow = YAML.parse(
fs.readFileSync(
path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"),
"utf8",
),
) as {
on?: { pull_request?: { paths?: string[] }; push?: { paths?: string[] } };
};
const portableProofInputs = [
"scripts/install-openshell.sh",
"test/e2e/live/portable-profile-gateway-proof.ts",
"test/e2e/live/portable-profile-rootless-linux.test.ts",
"tools/e2e/check-semantic-phases.mts",
];

expect(validateFreeStandingWorkflowInventory()).toEqual([]);
expect(portableWorkflow.on?.pull_request?.paths).toEqual(
expect.arrayContaining(portableProofInputs),
);
expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs));
expect(inventory.allowedJobs).not.toHaveLength(0);
expect(inventory.targetToJob.size).toBeGreaterThan(0);
expect(inventory.workflowJobs.every((job) => workflowJobs.has(job))).toBe(true);
Expand Down
4 changes: 4 additions & 0 deletions tools/e2e/check-semantic-phases.mts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,10 @@ const OBSERVED_CHILD_PROGRESS_POLICIES = new Map<string, ObservedChildProgressPo
{ kind: "path", path: "options.progress" },
],
["test/e2e/live/ollama-auth-proxy.test.ts#spawnLogged", { kind: "path", path: "progress" }],
[
"test/e2e/live/portable-profile-gateway-proof.ts#verifyPinnedPodmanGatewayStarts",
{ kind: "path", path: "progress" },
],
[
"test/e2e/live/windows-mxc-openclaw-process-container-helpers.ts#runCommand",
{ kind: "path", path: "progress" },
Expand Down
Loading