From 7a7daf5c48b69394f17ea34a272e8a7df7c11690 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 10:24:40 -0700 Subject: [PATCH 01/42] feat(onboard): add hosted inference to portable profile Signed-off-by: Aaron Erickson --- src/lib/onboard/command.test.ts | 89 +++++++ src/lib/onboard/command.ts | 51 +++- .../portable-inference-source.test.ts | 117 +++++++++ .../experimental/portable-inference-source.ts | 224 ++++++++++++++++++ 4 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 src/lib/onboard/experimental/portable-inference-source.test.ts create mode 100644 src/lib/onboard/experimental/portable-inference-source.ts diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index fd03af62888..71d8e9990d4 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -11,6 +11,7 @@ import { loadServingCatalog } from "../inference/serving/catalog-loader"; import { servingProfileProvenance } from "../inference/serving/profile-provenance"; import { resolveOnboardOptions, runOnboardCommand } from "./command"; import type { OnboardFlags } from "./command-support"; +import { PortableInferenceSourceError } from "./experimental/portable-inference-source"; import { invalidGatewayManagementDeclarationError } from "./gateway-management"; import { GatewayAuthorityError } from "./gateway-teardown-authority"; @@ -484,6 +485,94 @@ describe("onboard command options", () => { }); }); + it("uses the portable hosted inference descriptor without starting local inference", async () => { + const env: NodeJS.ProcessEnv = { + S3_BUCKET: "portable-inference", + S3_KEY: "path/credential.b64", + COMPATIBLE_API_KEY: "previous-compatible-key", + NEMOCLAW_ENDPOINT_URL: "https://previous.example.test/v1", + NEMOCLAW_PREFERRED_API: "openai-responses", + NEMOCLAW_PROVIDER: "previous-provider", + NEMOCLAW_MODEL: "previous-model", + }; + const observed: Record = {}; + const resolvePortableInferenceSource = vi.fn(() => ({ + apiKey: "test-credential-value-1234", + baseUrl: "https://inference.example.test/v1", + model: "example/model-1", + })); + + await runOnboardCommand({ + flags: { "experimental-profile": "portable" }, + env, + resolvePortableInferenceSource, + runOnboard: async () => { + for (const key of [ + "COMPATIBLE_API_KEY", + "NEMOCLAW_ENDPOINT_URL", + "NEMOCLAW_PREFERRED_API", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_MODEL", + "NEMOCLAW_OLLAMA_NO_AUTOSTART", + ]) { + observed[key] = env[key]; + } + }, + }); + + expect(resolvePortableInferenceSource).toHaveBeenCalledWith(env); + expect(observed).toEqual({ + COMPATIBLE_API_KEY: "test-credential-value-1234", + NEMOCLAW_ENDPOINT_URL: "https://inference.example.test/v1", + NEMOCLAW_PREFERRED_API: "openai-completions", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_MODEL: "example/model-1", + NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", + }); + expect(env).toMatchObject({ + S3_BUCKET: "portable-inference", + S3_KEY: "path/credential.b64", + COMPATIBLE_API_KEY: "previous-compatible-key", + NEMOCLAW_ENDPOINT_URL: "https://previous.example.test/v1", + NEMOCLAW_PREFERRED_API: "openai-responses", + NEMOCLAW_PROVIDER: "previous-provider", + NEMOCLAW_MODEL: "previous-model", + }); + }); + + it("stops before onboarding when the portable inference descriptor cannot be resolved", async () => { + const env: NodeJS.ProcessEnv = { + S3_BUCKET: "portable-inference", + S3_KEY: "path/credential.b64", + NEMOCLAW_PROVIDER: "previous-provider", + }; + const runOnboard = vi.fn(); + const errors: string[] = []; + + await expect( + runOnboardCommand({ + flags: { "experimental-profile": "portable" }, + env, + resolvePortableInferenceSource: () => { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid descriptor.", + ); + }, + runOnboard, + error: (message = "") => errors.push(message), + exit: exitWithCode, + }), + ).rejects.toThrow("exit:1"); + + expect(runOnboard).not.toHaveBeenCalled(); + expect(errors).toEqual([" Portable hosted inference received an invalid descriptor."]); + expect(env).toEqual({ + S3_BUCKET: "portable-inference", + S3_KEY: "path/credential.b64", + NEMOCLAW_PROVIDER: "previous-provider", + }); + }); + it("scopes an agents manifest to one onboarding run", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-agents-manifest-")); const manifestPath = path.join(tmpDir, "agents.yaml"); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 1c66ec43d1c..6adf373479d 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -30,6 +30,11 @@ import { type ExperimentalOnboardProfile, PORTABLE_EXPERIMENTAL_PROFILE, } from "./docker-driver-platform"; +import { + type PortableInferenceSource, + PortableInferenceSourceError, + resolvePortableInferenceSource, +} from "./experimental/portable-inference-source"; import { GatewayManagementDeclarationError } from "./gateway-management"; import { GatewayAuthorityError, gatewayAuthorityFailureLines } from "./gateway-teardown-authority"; import { managedSandboxFeatureIssue } from "./managed-sandbox-feature"; @@ -76,6 +81,7 @@ export interface ResolveOnboardOptionsDeps { export interface RunOnboardCommandDeps extends ResolveOnboardOptionsDeps { flags: OnboardFlags; runOnboard: (options: OnboardCommandOptions) => Promise; + resolvePortableInferenceSource?: (env: NodeJS.ProcessEnv) => PortableInferenceSource | null; } function fail(deps: ResolveOnboardOptionsDeps, message: string): never { @@ -340,6 +346,13 @@ function promptCancellationCode(error: unknown): "EOF" | "SIGINT" | null { return code === "EOF" || code === "SIGINT" ? code : null; } +function isOnboardInputError(error: unknown): error is Error { + return ( + error instanceof GatewayManagementDeclarationError || + error instanceof PortableInferenceSourceError + ); +} + function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps): void { const cancellationCode = promptCancellationCode(error); if (cancellationCode === "SIGINT") { @@ -349,11 +362,10 @@ function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps): // oclif as a raw stack trace (#7439). return; } - // A rejected NEMOCLAW_GATEWAY_MANAGEMENT contract is operator input error, - // not a crash: print the validation reason as a clean single-line CLI error - // and exit nonzero instead of re-throwing it into a Node.js stack trace - // (#7627). `fail` sets exit code 1. - if (error instanceof GatewayManagementDeclarationError) { + // Operator-owned gateway declarations and portable inference descriptors are + // input errors, not crashes. Print one bounded validation reason instead of + // re-throwing either error into an oclif stack trace (#7627). + if (isOnboardInputError(error)) { fail(deps, ` ${error.message}`); } // Gateway-authority refusals are reported, never rethrown. Recreation is not @@ -376,17 +388,24 @@ function handleOnboardCommandError(error: unknown, deps: RunOnboardCommandDeps): function applyPortableEnvironment( options: OnboardCommandOptions, env: NodeJS.ProcessEnv, + resolveInferenceSource: (env: NodeJS.ProcessEnv) => PortableInferenceSource | null, ): () => void { if (!options.experimentalProfile) return () => {}; - const portableEnvDefaults = { - [EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile ?? undefined, + const hostedInference = resolveInferenceSource(env); + const portableEnvDefaults: Record = { + [EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile, [TOOL_DISCLOSURE_ENV]: "direct", - NEMOCLAW_PROVIDER: "ollama", - NEMOCLAW_MODEL: "qwen3-vl:4b", + NEMOCLAW_PROVIDER: hostedInference ? "custom" : "ollama", + NEMOCLAW_MODEL: hostedInference?.model ?? "qwen3-vl:4b", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: "personal", - } as const; + }; + if (hostedInference) { + portableEnvDefaults.COMPATIBLE_API_KEY = hostedInference.apiKey; + portableEnvDefaults.NEMOCLAW_ENDPOINT_URL = hostedInference.baseUrl; + portableEnvDefaults.NEMOCLAW_PREFERRED_API = "openai-completions"; + } const previousPortableEnv = new Map(); const restore = () => { for (const [key, value] of previousPortableEnv) { @@ -428,6 +447,12 @@ function toolDisclosureEnvironmentOverride( return flags["tool-disclosure"] !== undefined ? options.toolDisclosure : null; } +function portableInferenceSourceResolver( + deps: RunOnboardCommandDeps, +): (env: NodeJS.ProcessEnv) => PortableInferenceSource | null { + return deps.resolvePortableInferenceSource ?? resolvePortableInferenceSource; +} + export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { const options = resolveOnboardOptions(deps.flags, deps); const env = deps.env ?? process.env; @@ -435,7 +460,11 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise {}; const previousAgentsManifest = env.NEMOCLAW_EXTRA_AGENTS_JSON; try { - restorePortableEnvironment = applyPortableEnvironment(options, env); + restorePortableEnvironment = applyPortableEnvironment( + options, + env, + portableInferenceSourceResolver(deps), + ); restoreServingProfileEnvironment = applyServingProfileEnvironment(options, env); if (options.noOllamaAutostart) env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1"; // Keep direct callers and the legacy monolithic onboard path on the same diff --git a/src/lib/onboard/experimental/portable-inference-source.test.ts b/src/lib/onboard/experimental/portable-inference-source.test.ts new file mode 100644 index 00000000000..ef27bc79db6 --- /dev/null +++ b/src/lib/onboard/experimental/portable-inference-source.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + parsePortableInferenceDescriptor, + resolvePortableInferenceSource, +} from "./portable-inference-source"; + +function descriptor(fields: Record): Buffer { + return Buffer.from(Buffer.from(JSON.stringify(fields)).toString("base64")); +} + +const VALID_FIELDS = { + apiKey: "test-credential-value-1234", + url: "https://inference.example.test/v1", + model: "example/model-1", +}; + +describe("portable hosted inference source", () => { + it("does not read object storage when the portable source is not configured", () => { + const readObject = vi.fn(); + + expect(resolvePortableInferenceSource({}, readObject)).toBeNull(); + expect(readObject).not.toHaveBeenCalled(); + }); + + it("reads and validates the configured descriptor without writing credential state", () => { + const readObject = vi.fn(() => descriptor(VALID_FIELDS)); + const env = { S3_BUCKET: "portable-inference", S3_KEY: "/path/credential.b64" }; + + expect(resolvePortableInferenceSource(env, readObject)).toEqual({ + apiKey: VALID_FIELDS.apiKey, + baseUrl: VALID_FIELDS.url, + model: VALID_FIELDS.model, + }); + expect(readObject).toHaveBeenCalledWith("s3://portable-inference/path/credential.b64", env); + expect(env).toEqual({ + S3_BUCKET: "portable-inference", + S3_KEY: "/path/credential.b64", + }); + }); + + it("resolves the prefix form to the portable credential object", () => { + const readObject = vi.fn(() => + descriptor({ + token: VALID_FIELDS.apiKey, + base_url: `${VALID_FIELDS.url}/`, + default_model: VALID_FIELDS.model, + }), + ); + + expect( + resolvePortableInferenceSource( + { S3_BUCKET: "portable-inference", S3_PREFIX: "/tenant/session/" }, + readObject, + ), + ).toEqual({ + apiKey: VALID_FIELDS.apiKey, + baseUrl: VALID_FIELDS.url, + model: VALID_FIELDS.model, + }); + expect(readObject).toHaveBeenCalledWith( + "s3://portable-inference/tenant/session/secrets/nvcf-llm.b64", + expect.any(Object), + ); + }); + + it("does not expose object-reader errors that can contain credential material", () => { + const readObject = vi.fn(() => { + throw new Error("upstream output contained test-credential-value-1234"); + }); + let caught: unknown; + try { + resolvePortableInferenceSource( + { S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64" }, + readObject, + ); + } catch (error) { + caught = error; + } + expect(String(caught)).toContain( + "Portable hosted inference could not read its credential descriptor.", + ); + expect(String(caught)).not.toContain("test-credential-value-1234"); + }); + + it.each([ + [{ S3_BUCKET: "portable-inference" }, "requires S3_BUCKET"], + [{ S3_KEY: "credential.b64" }, "requires S3_BUCKET"], + [ + { S3_BUCKET: "portable-inference", S3_KEY: "one", S3_PREFIX: "two" }, + "accepts S3_KEY or S3_PREFIX", + ], + [{ S3_BUCKET: "bad/bucket", S3_KEY: "credential.b64" }, "invalid S3_BUCKET"], + [{ S3_BUCKET: "portable-inference", S3_PREFIX: "/" }, "invalid S3_PREFIX"], + ])("rejects incomplete or ambiguous source configuration", (env, message) => { + expect(() => resolvePortableInferenceSource(env, vi.fn())).toThrow(message); + }); + + it.each([ + [Buffer.from("not base64"), "invalid base64"], + [Buffer.from(Buffer.from("not JSON").toString("base64")), "not valid JSON"], + [descriptor({ ...VALID_FIELDS, apiKey: "short" }), "no usable credential"], + [descriptor({ ...VALID_FIELDS, url: "http://127.0.0.1/v1" }), "HTTPS URL"], + [descriptor({ ...VALID_FIELDS, url: "https://name:secret@example.test/v1" }), "HTTPS URL"], + [descriptor({ ...VALID_FIELDS, url: "https://example.test/v1?key=secret" }), "HTTPS URL"], + [descriptor({ ...VALID_FIELDS, model: "model with spaces" }), "no usable model ID"], + [ + descriptor({ ...VALID_FIELDS, api_key: "different-test-credential-5678" }), + "conflicting credential fields", + ], + ])("rejects an unsafe descriptor before onboarding", (raw, message) => { + expect(() => parsePortableInferenceDescriptor(raw)).toThrow(message); + }); +}); diff --git a/src/lib/onboard/experimental/portable-inference-source.ts b/src/lib/onboard/experimental/portable-inference-source.ts new file mode 100644 index 00000000000..57b1e9000c2 --- /dev/null +++ b/src/lib/onboard/experimental/portable-inference-source.ts @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { TextDecoder } from "node:util"; + +const DEFAULT_RELATIVE_OBJECT_KEY = "secrets/nvcf-llm.b64"; +const MAX_DESCRIPTOR_BYTES = 64 * 1024; +const MAX_ENDPOINT_LENGTH = 2048; +const MAX_MODEL_ID_LENGTH = 512; +const MIN_CREDENTIAL_LENGTH = 16; +const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._:/-]+$/; +const OPENAI_ENDPOINT_SUFFIXES = ["/responses", "/chat/completions", "/completions", "/models"]; + +export interface PortableInferenceSource { + apiKey: string; + baseUrl: string; + model: string; +} + +export class PortableInferenceSourceError extends Error { + override readonly name = "PortableInferenceSourceError"; +} + +export type PortableInferenceObjectReader = (uri: string, env: NodeJS.ProcessEnv) => Buffer; + +function configurationValue(env: NodeJS.ProcessEnv, name: string): string { + return String(env[name] ?? "").trim(); +} + +function resolveObjectUri(env: NodeJS.ProcessEnv): string | null { + const bucket = configurationValue(env, "S3_BUCKET"); + const configuredKey = configurationValue(env, "S3_KEY"); + const configuredPrefix = configurationValue(env, "S3_PREFIX"); + if (!bucket && !configuredKey && !configuredPrefix) return null; + if (!bucket || (!configuredKey && !configuredPrefix)) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires S3_BUCKET and exactly one of S3_KEY or S3_PREFIX.", + ); + } + if (configuredKey && configuredPrefix) { + throw new PortableInferenceSourceError( + "Portable hosted inference accepts S3_KEY or S3_PREFIX, not both.", + ); + } + if ( + bucket.length > 255 || + /[\s/\\\u0000-\u001f\u007f]/.test(bucket) || + !/^[A-Za-z0-9]/.test(bucket) + ) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid S3_BUCKET.", + ); + } + const normalizedPrefix = configuredPrefix.replace(/^\/+|\/+$/g, ""); + if (configuredPrefix && !normalizedPrefix) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid S3_PREFIX.", + ); + } + const key = configuredKey + ? configuredKey.replace(/^\/+/, "") + : `${normalizedPrefix}/${DEFAULT_RELATIVE_OBJECT_KEY}`; + if (!key || key.length > 1024 || /[\u0000-\u001f\u007f]/.test(key)) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid object key.", + ); + } + return `s3://${bucket}/${key}`; +} + +function readObjectWithAwsCli(uri: string, env: NodeJS.ProcessEnv): Buffer { + const result = spawnSync("aws", ["s3", "cp", uri, "-", "--only-show-errors"], { + env, + maxBuffer: MAX_DESCRIPTOR_BYTES + 1, + timeout: 10_000, + killSignal: "SIGKILL", + }); + if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + throw new PortableInferenceSourceError( + result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT" + ? "Portable hosted inference requires the AWS CLI on PATH." + : "Portable hosted inference could not read its credential descriptor.", + ); + } + if (result.stdout.length === 0 || result.stdout.length > MAX_DESCRIPTOR_BYTES) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an empty or oversized descriptor.", + ); + } + return result.stdout; +} + +function decodeBase64Json(raw: Buffer): Record { + let encoded: string; + try { + encoded = new TextDecoder("utf-8", { fatal: true }).decode(raw).replace(/\s/g, ""); + } catch { + throw new PortableInferenceSourceError( + "Portable hosted inference received a descriptor that is not UTF-8.", + ); + } + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid base64 descriptor.", + ); + } + const decoded = Buffer.from(encoded, "base64"); + if ( + decoded.length === 0 || + decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "") + ) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an invalid base64 descriptor.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decoded)); + } catch { + throw new PortableInferenceSourceError( + "Portable hosted inference received a descriptor that is not valid JSON.", + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires a JSON object descriptor.", + ); + } + return parsed as Record; +} + +function aliasedString( + payload: Record, + fields: readonly string[], + label: string, +): string { + const values = fields.flatMap((field) => { + const value = payload[field]; + return typeof value === "string" && value.length > 0 ? [value] : []; + }); + if (new Set(values).size > 1) { + throw new PortableInferenceSourceError( + `Portable hosted inference descriptor has conflicting ${label} fields.`, + ); + } + return values[0] ?? ""; +} + +function canonicalHostedEndpoint(value: string): string | null { + const raw = value.trim(); + if (!raw || raw.length > MAX_ENDPOINT_LENGTH) return null; + try { + const parsed = new URL(raw); + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) { + return null; + } + let pathname = parsed.pathname.replace(/\/+$/, ""); + for (const suffix of OPENAI_ENDPOINT_SUFFIXES) { + if (pathname === suffix || pathname.endsWith(suffix)) { + pathname = pathname.slice(0, -suffix.length).replace(/\/+$/, ""); + break; + } + } + parsed.pathname = pathname || "/"; + return parsed.pathname === "/" ? parsed.origin : `${parsed.origin}${parsed.pathname}`; + } catch { + return null; + } +} + +export function parsePortableInferenceDescriptor(raw: Buffer): PortableInferenceSource { + const payload = decodeBase64Json(raw); + const apiKey = aliasedString(payload, ["apiKey", "api_key", "key", "token"], "credential"); + if ( + apiKey.length < MIN_CREDENTIAL_LENGTH || + apiKey.length > 8192 || + !/^[\u0021-\u007e]+$/.test(apiKey) + ) { + throw new PortableInferenceSourceError( + "Portable hosted inference descriptor has no usable credential.", + ); + } + + const rawBaseUrl = aliasedString(payload, ["url", "baseUrl", "base_url"], "URL"); + const baseUrl = canonicalHostedEndpoint(rawBaseUrl); + if (!baseUrl) { + throw new PortableInferenceSourceError( + "Portable hosted inference descriptor requires a credential-free HTTPS URL.", + ); + } + + const model = aliasedString(payload, ["model", "defaultModel", "default_model"], "model ID"); + if (!model || model.length > MAX_MODEL_ID_LENGTH || !SAFE_MODEL_ID_PATTERN.test(model)) { + throw new PortableInferenceSourceError( + "Portable hosted inference descriptor has no usable model ID.", + ); + } + return { apiKey, baseUrl, model }; +} + +export function resolvePortableInferenceSource( + env: NodeJS.ProcessEnv, + readObject: PortableInferenceObjectReader = readObjectWithAwsCli, +): PortableInferenceSource | null { + const uri = resolveObjectUri(env); + if (!uri) return null; + let raw: Buffer; + try { + raw = readObject(uri, env); + } catch (error) { + if (error instanceof PortableInferenceSourceError) throw error; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its credential descriptor.", + ); + } + return parsePortableInferenceDescriptor(raw); +} From 27e493fa08472e1462446c89dbcf987f264bb829 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 10:44:26 -0700 Subject: [PATCH 02/42] feat(onboard): support activated portable credentials Signed-off-by: Aaron Erickson --- .../portable-inference-source.test.ts | 86 ++++++++++++++++++- .../experimental/portable-inference-source.ts | 68 +++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/experimental/portable-inference-source.test.ts b/src/lib/onboard/experimental/portable-inference-source.test.ts index ef27bc79db6..1976ab3f6f5 100644 --- a/src/lib/onboard/experimental/portable-inference-source.test.ts +++ b/src/lib/onboard/experimental/portable-inference-source.test.ts @@ -1,10 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; import { parsePortableInferenceDescriptor, + readPortableInferenceActivationFile, resolvePortableInferenceSource, } from "./portable-inference-source"; @@ -18,6 +23,14 @@ const VALID_FIELDS = { model: "example/model-1", }; +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + describe("portable hosted inference source", () => { it("does not read object storage when the portable source is not configured", () => { const readObject = vi.fn(); @@ -26,6 +39,77 @@ describe("portable hosted inference source", () => { expect(readObject).not.toHaveBeenCalled(); }); + it("prefers an activated descriptor over configured object storage", () => { + const readObject = vi.fn(); + const readActivatedDescriptor = vi.fn(() => descriptor(VALID_FIELDS)); + + expect( + resolvePortableInferenceSource( + { S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64" }, + readObject, + readActivatedDescriptor, + ), + ).toEqual({ + apiKey: VALID_FIELDS.apiKey, + baseUrl: VALID_FIELDS.url, + model: VALID_FIELDS.model, + }); + expect(readActivatedDescriptor).toHaveBeenCalledOnce(); + expect(readObject).not.toHaveBeenCalled(); + }); + + it("redacts unexpected activated-descriptor reader errors", () => { + const readActivatedDescriptor = vi.fn(() => { + throw new Error("reader output contained test-credential-value-1234"); + }); + + expect(() => resolvePortableInferenceSource({}, vi.fn(), readActivatedDescriptor)).toThrow( + "could not read its activated credential descriptor", + ); + try { + resolvePortableInferenceSource({}, vi.fn(), readActivatedDescriptor); + } catch (error) { + expect(String(error)).not.toContain("test-credential-value-1234"); + } + }); + + it("reads an owner-only activated descriptor file", () => { + const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); + tempDirectories.push(directory); + const filePath = path.join(directory, "descriptor.b64"); + const raw = descriptor(VALID_FIELDS); + writeFileSync(filePath, raw, { mode: 0o600 }); + chmodSync(filePath, 0o600); + + expect(readPortableInferenceActivationFile(filePath)).toEqual(raw); + }); + + it("rejects a group-readable activated descriptor file", () => { + const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); + tempDirectories.push(directory); + const filePath = path.join(directory, "descriptor.b64"); + writeFileSync(filePath, descriptor(VALID_FIELDS), { mode: 0o640 }); + chmodSync(filePath, 0o640); + + expect(() => readPortableInferenceActivationFile(filePath)).toThrow( + "owned by root or the current user", + ); + }); + + it("rejects a symlinked activated descriptor file", () => { + const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); + tempDirectories.push(directory); + const targetPath = path.join(directory, "target.b64"); + const linkPath = path.join(directory, "descriptor.b64"); + writeFileSync(targetPath, descriptor(VALID_FIELDS), { mode: 0o600 }); + chmodSync(targetPath, 0o600); + symlinkSync(targetPath, linkPath); + + expect(() => readPortableInferenceActivationFile(linkPath)).toThrow( + "could not read its activated credential descriptor", + ); + }); + it("reads and validates the configured descriptor without writing credential state", () => { const readObject = vi.fn(() => descriptor(VALID_FIELDS)); const env = { S3_BUCKET: "portable-inference", S3_KEY: "/path/credential.b64" }; diff --git a/src/lib/onboard/experimental/portable-inference-source.ts b/src/lib/onboard/experimental/portable-inference-source.ts index 57b1e9000c2..efc1eac1833 100644 --- a/src/lib/onboard/experimental/portable-inference-source.ts +++ b/src/lib/onboard/experimental/portable-inference-source.ts @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; import { TextDecoder } from "node:util"; +export const PORTABLE_INFERENCE_ACTIVATION_PATH = "/run/nemoclaw/portable-inference.b64"; + const DEFAULT_RELATIVE_OBJECT_KEY = "secrets/nvcf-llm.b64"; const MAX_DESCRIPTOR_BYTES = 64 * 1024; const MAX_ENDPOINT_LENGTH = 2048; @@ -23,6 +26,57 @@ export class PortableInferenceSourceError extends Error { } export type PortableInferenceObjectReader = (uri: string, env: NodeJS.ProcessEnv) => Buffer; +export type PortableInferenceActivationReader = () => Buffer | null; + +export function readPortableInferenceActivationFile( + filePath: string = PORTABLE_INFERENCE_ACTIVATION_PATH, +): Buffer | null { + let descriptorFd: number; + try { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + descriptorFd = openSync(filePath, constants.O_RDONLY | noFollow); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its activated credential descriptor.", + ); + } + + try { + const stats = fstatSync(descriptorFd); + const effectiveUid = typeof process.geteuid === "function" ? process.geteuid() : null; + if ( + !stats.isFile() || + (stats.mode & 0o077) !== 0 || + (effectiveUid !== null && stats.uid !== 0 && stats.uid !== effectiveUid) + ) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires its activated descriptor to be a regular file owned by root or the current user, with no group or other permissions.", + ); + } + + const buffer = Buffer.allocUnsafe(MAX_DESCRIPTOR_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(descriptorFd, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset === 0 || offset > MAX_DESCRIPTOR_BYTES) { + throw new PortableInferenceSourceError( + "Portable hosted inference received an empty or oversized descriptor.", + ); + } + return buffer.subarray(0, offset); + } catch (error) { + if (error instanceof PortableInferenceSourceError) throw error; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its activated credential descriptor.", + ); + } finally { + closeSync(descriptorFd); + } +} function configurationValue(env: NodeJS.ProcessEnv, name: string): string { return String(env[name] ?? "").trim(); @@ -208,7 +262,21 @@ export function parsePortableInferenceDescriptor(raw: Buffer): PortableInference export function resolvePortableInferenceSource( env: NodeJS.ProcessEnv, readObject: PortableInferenceObjectReader = readObjectWithAwsCli, + readActivatedDescriptor: PortableInferenceActivationReader = readPortableInferenceActivationFile, ): PortableInferenceSource | null { + let activatedDescriptor: Buffer | null; + try { + activatedDescriptor = readActivatedDescriptor(); + } catch (error) { + if (error instanceof PortableInferenceSourceError) throw error; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its activated credential descriptor.", + ); + } + if (activatedDescriptor !== null) { + return parsePortableInferenceDescriptor(activatedDescriptor); + } + const uri = resolveObjectUri(env); if (!uri) return null; let raw: Buffer; From 442e8c7549f9a527b0cbfd7388327c9d9b066e31 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 10:52:24 -0700 Subject: [PATCH 03/42] test(onboard): tighten portable inference isolation Signed-off-by: Aaron Erickson --- src/lib/onboard/command.test.ts | 2 +- .../experimental/portable-inference-source.test.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 71d8e9990d4..491b590c4a1 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -529,7 +529,7 @@ describe("onboard command options", () => { NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", }); - expect(env).toMatchObject({ + expect(env).toEqual({ S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64", COMPATIBLE_API_KEY: "previous-compatible-key", diff --git a/src/lib/onboard/experimental/portable-inference-source.test.ts b/src/lib/onboard/experimental/portable-inference-source.test.ts index 1976ab3f6f5..1e4b775e3ef 100644 --- a/src/lib/onboard/experimental/portable-inference-source.test.ts +++ b/src/lib/onboard/experimental/portable-inference-source.test.ts @@ -24,6 +24,7 @@ const VALID_FIELDS = { }; const tempDirectories: string[] = []; +const noActivatedDescriptor = (): null => null; afterEach(() => { for (const directory of tempDirectories.splice(0)) { @@ -35,7 +36,7 @@ describe("portable hosted inference source", () => { it("does not read object storage when the portable source is not configured", () => { const readObject = vi.fn(); - expect(resolvePortableInferenceSource({}, readObject)).toBeNull(); + expect(resolvePortableInferenceSource({}, readObject, noActivatedDescriptor)).toBeNull(); expect(readObject).not.toHaveBeenCalled(); }); @@ -114,7 +115,7 @@ describe("portable hosted inference source", () => { const readObject = vi.fn(() => descriptor(VALID_FIELDS)); const env = { S3_BUCKET: "portable-inference", S3_KEY: "/path/credential.b64" }; - expect(resolvePortableInferenceSource(env, readObject)).toEqual({ + expect(resolvePortableInferenceSource(env, readObject, noActivatedDescriptor)).toEqual({ apiKey: VALID_FIELDS.apiKey, baseUrl: VALID_FIELDS.url, model: VALID_FIELDS.model, @@ -139,6 +140,7 @@ describe("portable hosted inference source", () => { resolvePortableInferenceSource( { S3_BUCKET: "portable-inference", S3_PREFIX: "/tenant/session/" }, readObject, + noActivatedDescriptor, ), ).toEqual({ apiKey: VALID_FIELDS.apiKey, @@ -160,6 +162,7 @@ describe("portable hosted inference source", () => { resolvePortableInferenceSource( { S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64" }, readObject, + noActivatedDescriptor, ); } catch (error) { caught = error; @@ -180,7 +183,9 @@ describe("portable hosted inference source", () => { [{ S3_BUCKET: "bad/bucket", S3_KEY: "credential.b64" }, "invalid S3_BUCKET"], [{ S3_BUCKET: "portable-inference", S3_PREFIX: "/" }, "invalid S3_PREFIX"], ])("rejects incomplete or ambiguous source configuration", (env, message) => { - expect(() => resolvePortableInferenceSource(env, vi.fn())).toThrow(message); + expect(() => resolvePortableInferenceSource(env, vi.fn(), noActivatedDescriptor)).toThrow( + message, + ); }); it.each([ From 75766ca10b760b1274749c013855329b17b22ccb Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 11:21:11 -0700 Subject: [PATCH 04/42] fix(onboard): skip portable dashboard forwards Signed-off-by: Aaron Erickson --- src/lib/actions/sandbox/doctor.ts | 4 +- src/lib/actions/sandbox/forward-recovery.ts | 2 +- src/lib/actions/sandbox/gateway-restart.ts | 11 +++-- src/lib/actions/sandbox/process-recovery.ts | 39 ++++++++++------- src/lib/onboard.ts | 8 ++-- .../onboard/agent-dashboard-forward.test.ts | 15 +++++++ src/lib/onboard/agent-dashboard-forward.ts | 4 +- src/lib/onboard/dashboard-runtime.ts | 12 ++++++ .../machine/handlers/finalization.test.ts | 19 +++++++++ .../onboard/machine/handlers/finalization.ts | 42 ++++++++++++++++--- src/lib/onboard/sandbox-registration.ts | 2 + src/lib/onboard/sandbox-reuse.test.ts | 3 ++ src/lib/onboard/sandbox-reuse.ts | 1 + src/lib/state/registry/types.ts | 2 + src/lib/verify-deployment.test.ts | 16 +++++++ src/lib/verify-deployment.ts | 39 +++++++++++------ test/recover-port-forward.test.ts | 22 ++++++++++ 17 files changed, 199 insertions(+), 42 deletions(-) diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index b8ffe74351f..8624622891c 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -468,7 +468,9 @@ function collectRegisteredSandboxChecks( const checks = [agentVersionDoctorCheck(sandboxName), shieldsDoctorCheck(sandboxName)]; let dashboardPortRequired = true; try { - dashboardPortRequired = shouldManageDashboardForAgent(loadAgent(sb.agent || "openclaw")); + dashboardPortRequired = + sb.dashboardForwardEnabled !== false && + shouldManageDashboardForAgent(loadAgent(sb.agent || "openclaw")); } catch { // Require dashboard metadata when the agent definition cannot be loaded. } diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index bcf4adcc366..00cdb6ebe7b 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -137,7 +137,7 @@ export function teardownSandboxDashboardForward( try { const getSandbox = deps.getSandbox ?? registry.getSandbox; const sandbox = getSandbox(sandboxName); - if (!sandbox) return; + if (!sandbox || sandbox.dashboardForwardEnabled === false) return; const gatewayName = (deps.resolveSandboxGatewayName ?? resolveSandboxGatewayName)(sandbox); const resolvePort = deps.resolveSandboxDashboardPort ?? resolveSandboxDashboardPort; const port = resolvePort(sandboxName, { getSandbox: () => sandbox }); diff --git a/src/lib/actions/sandbox/gateway-restart.ts b/src/lib/actions/sandbox/gateway-restart.ts index aa0b61cf524..ea6ebcc0670 100644 --- a/src/lib/actions/sandbox/gateway-restart.ts +++ b/src/lib/actions/sandbox/gateway-restart.ts @@ -71,7 +71,9 @@ export type GatewayRestartResult = detail: string; }; -type SandboxAgentLookup = (sandboxName: string) => { agent?: string | null } | null | undefined; +type SandboxAgentLookup = ( + sandboxName: string, +) => { agent?: string | null; dashboardForwardEnabled?: boolean } | null | undefined; type SupervisorAction = ( sandboxName: string, @@ -360,6 +362,7 @@ export function restartSandboxGatewayWithDeps( } const agentName = agent?.name ?? persistedAgent ?? "openclaw"; const dashboardPort = deps.resolveSandboxDashboardPort(sandboxName); + const dashboardForwardEnabled = deps.getSandbox(sandboxName)?.dashboardForwardEnabled !== false; if (!agent && persistedAgent && persistedAgent !== "openclaw") { const detail = unsupportedGatewayRestartAgentDetail( @@ -434,7 +437,9 @@ export function restartSandboxGatewayWithDeps( } } - const forwardRecovered = deps.ensureSandboxPortForward(sandboxName); + const forwardRecovered = dashboardForwardEnabled + ? deps.ensureSandboxPortForward(sandboxName) + : false; const dashboardForwardRecovered = deps.ensureHermesDashboardPortForwardIfEnabled(sandboxName); const messagingForwardRecovered = deps.recoverMessagingHostForward(sandboxName, { quiet }); const declaredForwardsRecovered = deps.recoverDeclaredAgentForwardPorts( @@ -448,7 +453,7 @@ export function restartSandboxGatewayWithDeps( { label: "one or more agent-declared host forwards", recovered: declaredForwardsRecovered }, ]); - if (!forwardRecovered) { + if (dashboardForwardEnabled && !forwardRecovered) { const detail = "gateway health passed but the primary dashboard/API host forward could not be re-established"; printGatewayRestartFailure(sandboxName, "forward recovery failure", detail); diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 3adc1954473..281907b12bb 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -1097,6 +1097,8 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( return { checked: false, wasRunning: null, recovered: false, forwardRecovered: false }; } const recoveryPort = resolveSandboxDashboardPort(sandboxName); + const dashboardForwardEnabled = + registry.getSandbox(sandboxName)?.dashboardForwardEnabled !== false; if (running) { const enforcement = enforceHermesSecretBoundaryOnRunningGateway( sandboxName, @@ -1120,7 +1122,9 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( // Gateway is alive but the host-side forward can still be dead or // owned by another sandbox. Probe and re-establish only when // necessary so the live-and-healthy path stays a no-op. - const forwardHealthy = isSandboxForwardHealthy(sandboxName, { isWsl: isWslOverride }); + const forwardHealthy = dashboardForwardEnabled + ? isSandboxForwardHealthy(sandboxName, { isWsl: isWslOverride }) + : true; if (forwardHealthy === false) { if (!quiet) { console.log(""); @@ -1456,12 +1460,15 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( } const mcpRefusal = processRecoveryMcpReconciliationRefusal(sandboxName, false); if (mcpRefusal) return mcpRefusal; - const forwardRecovered = ensureSandboxPortForward(sandboxName, { - afterSuccess: confirmRelaunchedManagedHealthForForward ?? undefined, - beforeStart: confirmRelaunchedManagedHealthForForward ?? undefined, - isWsl: isWslOverride, - }); - if (!forwardRecovered && relaunchedIdentityRejected) { + const forwardRecovered = dashboardForwardEnabled + ? ensureSandboxPortForward(sandboxName, { + afterSuccess: confirmRelaunchedManagedHealthForForward ?? undefined, + beforeStart: confirmRelaunchedManagedHealthForForward ?? undefined, + isWsl: isWslOverride, + }) + : false; + const primaryForwardReady = !dashboardForwardEnabled || forwardRecovered; + if (!primaryForwardReady && relaunchedIdentityRejected) { return withManagedControlCompletion({ checked: true, wasRunning: false, @@ -1485,16 +1492,18 @@ function checkAndRecoverSandboxProcessesWithoutHostLock( const auxiliaryFailureDetail = auxiliaryRecoveryFailureDetail(auxiliaryResults); if (!quiet) { console.log(` ${G}✓${R} ${recoveryDisplayName} gateway restarted inside sandbox.`); - if (forwardRecovered) { - console.log(` ${G}✓${R} Dashboard port forward re-established.`); - } else { - console.error(" Failed to re-establish the dashboard port forward."); - console.error( - ` Run \`openshell forward start --background ${recoveryPort} ${sandboxName}\` manually.`, - ); + if (dashboardForwardEnabled) { + if (forwardRecovered) { + console.log(` ${G}✓${R} Dashboard port forward re-established.`); + } else { + console.error(" Failed to re-establish the dashboard port forward."); + console.error( + ` Run \`openshell forward start --background ${recoveryPort} ${sandboxName}\` manually.`, + ); + } } } - if (!forwardRecovered) { + if (!primaryForwardReady) { return withManagedControlCompletion({ checked: true, wasRunning: false, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 83370b936d9..c3c50267929 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2254,6 +2254,7 @@ async function createSandboxWithBaseImageResolution( resolvedCreateIntent, ); const manageDashboard = dashboardRuntime.shouldManageDashboardForAgent(agent); + const manageDashboardForward = dashboardRuntime.shouldManageDashboardForwardForAgent(agent); const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile); let effectivePort = 0, chatUiUrl = ""; @@ -2296,7 +2297,7 @@ async function createSandboxWithBaseImageResolution( sandboxGpuConfig: effectiveSandboxGpuConfig, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, - manageDashboard, + manageDashboard: manageDashboardForward, ensureDashboardForward, hermesDashboardForwarding, updateReusedSandboxMetadata, @@ -2708,7 +2709,7 @@ async function createSandboxWithBaseImageResolution( let actualDashboardPort = 0; let finalHermesDashboardState = hermesDashboardState; - if (manageDashboard) { + if (manageDashboardForward) { actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { rollbackSandboxOnFailure: true, }); @@ -2781,6 +2782,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, + dashboardForwardEnabled: manageDashboardForward, ...recreateRuntime.registrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, @@ -4503,7 +4505,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), - }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name) }); + }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name), verifyDashboardForward: dashboardRuntime.shouldManageDashboardForwardForAgent(agent) }); }, formatVerificationDiagnostics: (result) => { const verifyDeploymentModule: typeof import("./verify-deployment") = diff --git a/src/lib/onboard/agent-dashboard-forward.test.ts b/src/lib/onboard/agent-dashboard-forward.test.ts index 45a08dec750..28b241ab06f 100644 --- a/src/lib/onboard/agent-dashboard-forward.test.ts +++ b/src/lib/onboard/agent-dashboard-forward.test.ts @@ -8,6 +8,21 @@ import { ensureAgentDashboardForward } from "./agent-dashboard-forward"; describe("ensureAgentDashboardForward", () => { afterEach(() => { delete process.env.CHAT_UI_URL; + vi.unstubAllEnvs(); + }); + + it("does not create a host dashboard forward for the portable profile", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const ensureDashboardForward = vi.fn(() => 18789); + + expect( + ensureAgentDashboardForward({ + sandboxName: "portable-box", + agent: { forwardPort: 18789 }, + ensureDashboardForward, + }), + ).toBe(0); + expect(ensureDashboardForward).not.toHaveBeenCalled(); }); it("preserves additional host-forward ports during dashboard refresh", () => { diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index 786bf79d0c8..39234b806a5 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -7,7 +7,7 @@ import { getAgentDeclaredForwardPorts, getAgentPrimaryForwardPort, isValidForwardPort, - shouldManageDashboardForAgent, + shouldManageDashboardForwardForAgent, } from "./dashboard-runtime"; export type EnsureDashboardForward = ( @@ -42,7 +42,7 @@ export function ensureAgentDashboardForward(options: { preserveForwardPorts = [], warn = (message: string) => console.warn(message), } = options; - if (!shouldManageDashboardForAgent(agent)) { + if (!shouldManageDashboardForwardForAgent(agent)) { return 0; } diff --git a/src/lib/onboard/dashboard-runtime.ts b/src/lib/onboard/dashboard-runtime.ts index b6622fe7b9c..eaaca91c862 100644 --- a/src/lib/onboard/dashboard-runtime.ts +++ b/src/lib/onboard/dashboard-runtime.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isTerminalAgent } from "../agent/runtime-manifest"; +import { isPortableExperimentalProfile } from "./docker-driver-platform"; export type DashboardRuntimeAgent = { forwardPort?: number | null; @@ -31,3 +32,14 @@ export function shouldManageDashboardForAgent(agent: DashboardRuntimeAgent): boo if (!agent || !isTerminalAgent(agent)) return true; return getAgentDeclaredForwardPorts(agent).length > 0; } + +/** + * The hidden portable profile keeps the gateway dashboard available inside the + * sandbox, but its host does not need or retain a browser-facing tunnel. + */ +export function shouldManageDashboardForwardForAgent( + agent: DashboardRuntimeAgent, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return shouldManageDashboardForAgent(agent) && !isPortableExperimentalProfile(env); +} diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index 530fd6c1fc8..5ce34f1734c 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -216,6 +216,25 @@ describe("finalization handlers", () => { expect(result.stateResult.type).toBe("complete"); }); + it("keeps portable gateway verification without recreating or printing a host forward", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + try { + const { deps, calls } = createDeps(); + + const result = await runFinalizationHandlers(baseOptions(deps)); + + expect(calls.recoverProcesses).toHaveBeenCalledTimes(2); + expect(calls.ensureAgentDashboard).not.toHaveBeenCalled(); + expect(calls.verify).toHaveBeenCalledOnce(); + expect(calls.dashboard).not.toHaveBeenCalled(); + expect(calls.log).toHaveBeenCalledWith(" ✓ OpenClaw gateway runtime is ready"); + expect(calls.log).toHaveBeenCalledWith(" Connect: nemoclaw my-assistant connect"); + expect(result.stateResult.type).toBe("complete"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("persists the dashboard port selected after final recovery (#8214)", async () => { const persistDashboardPort = vi.fn(); const { deps } = createDeps({ diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index 12219a0414e..776b800b579 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { CLI_NAME } from "../../../cli/branding"; -import { type DashboardRuntimeAgent, shouldManageDashboardForAgent } from "../../dashboard-runtime"; +import { + type DashboardRuntimeAgent, + shouldManageDashboardForAgent, + shouldManageDashboardForwardForAgent, +} from "../../dashboard-runtime"; import type { WebSearchVerifyProvider } from "../../web-search-verify"; import { advanceTo, @@ -136,6 +140,22 @@ function logTerminalReadyBlock( } } +function logGatewayReadyBlock( + sandboxName: string, + agent: unknown, + log: (message?: string) => void, +): void { + const runtimeAgent = agent as TerminalReadyAgent; + const displayName = + typeof runtimeAgent?.displayName === "string" + ? runtimeAgent.displayName + : typeof runtimeAgent?.name === "string" + ? runtimeAgent.name + : "OpenClaw"; + log(` ✓ ${displayName} gateway runtime is ready`); + log(` Connect: ${CLI_NAME} ${sandboxName} connect`); +} + export async function handleFinalizationState({ sandboxName, agent, @@ -148,6 +168,9 @@ export async function handleFinalizationState): Promise { const manageDashboard = shouldManageDashboardForAgent(agent as DashboardRuntimeAgent); + const manageDashboardForward = shouldManageDashboardForwardForAgent( + agent as DashboardRuntimeAgent, + ); // Reaching finalization means the policy-preset step was confirmed, so it is // now safe to register this sandbox as the default (#4614). @@ -189,9 +212,11 @@ export async function handleFinalizationState 0) { - deps.persistDashboardPort(sandboxName, dashboardPort); + if (manageDashboardForward) { + const dashboardPort = deps.ensureAgentDashboardForward(sandboxName, agent); + if (dashboardPort > 0) { + deps.persistDashboardPort(sandboxName, dashboardPort); + } } } @@ -218,6 +243,9 @@ export async function handlePostVerifyState): Promise { const manageDashboard = shouldManageDashboardForAgent(agent as DashboardRuntimeAgent); + const manageDashboardForward = shouldManageDashboardForwardForAgent( + agent as DashboardRuntimeAgent, + ); let verificationDiagnostics: string[] = []; let deploymentHealthy = true; @@ -237,7 +265,11 @@ export async function handlePostVerifyState 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), dashboardPort: input.dashboardPort, + ...(input.dashboardForwardEnabled === false ? { dashboardForwardEnabled: false } : {}), dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, lifecycleGeneration: input.lifecycleGeneration, lifecycleLiveIdentityFingerprint: input.lifecycleLiveIdentityFingerprint, diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts index 72c4dfc16e2..ef465fe7a6c 100644 --- a/src/lib/onboard/sandbox-reuse.test.ts +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -73,6 +73,7 @@ describe("applyReusedSandboxDashboardState", () => { sandboxGpuConfig, ); expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + dashboardForwardEnabled: true, hermesDashboardEnabled: true, hermesDashboardPort: 9123, hermesDashboardInternalPort: 19123, @@ -119,6 +120,7 @@ describe("applyReusedSandboxDashboardState", () => { }); expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + dashboardForwardEnabled: true, hermesDashboardEnabled: undefined, hermesDashboardPort: undefined, hermesDashboardInternalPort: undefined, @@ -181,6 +183,7 @@ describe("applyReusedSandboxDashboardState", () => { sandboxGpuConfig, ); expect(updateSandbox).toHaveBeenCalledWith("terminal-box", { + dashboardForwardEnabled: false, hermesDashboardEnabled: undefined, hermesDashboardPort: undefined, hermesDashboardInternalPort: undefined, diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index dc95852c038..f9628b83ded 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -105,6 +105,7 @@ export function applyReusedSandboxDashboardState( ); (input.updateSandbox ?? registry.updateSandbox)(input.sandboxName, { ...getHermesDashboardRegistryFields(hermesDashboardState), + dashboardForwardEnabled: manageDashboard, gatewayName: input.gatewayName, gatewayPort: input.gatewayPort, }); diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index c85c1daf997..2b1597c7617 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -142,6 +142,8 @@ export interface SandboxEntry extends Partial { hermesDashboardInternalPort?: number | null; hermesDashboardTui?: boolean; dashboardPort?: number | null; + /** False when the in-sandbox dashboard intentionally has no host-side forward. */ + dashboardForwardEnabled?: boolean; /** Remote dashboard exposure was included in the sandbox's generated config. */ dashboardRemoteBindPrepared?: boolean; /** Generation proving which durable same-name recreate registered this row. */ diff --git a/src/lib/verify-deployment.test.ts b/src/lib/verify-deployment.test.ts index 1c19925d668..5bd67f9ba6e 100644 --- a/src/lib/verify-deployment.test.ts +++ b/src/lib/verify-deployment.test.ts @@ -179,6 +179,22 @@ describe("verifyDeployment", () => { expect(dashDiag?.hint).toContain("forward"); }); + it("stays healthy without probing a deliberately disabled host forward", async () => { + const probeHostPort = vi.fn(() => 0); + const result = await verifyDeployment("my-sandbox", chain, makeDeps({ probeHostPort }), { + ...NO_RETRY, + verifyDashboardForward: false, + }); + + expect(result.healthy).toBe(true); + expect(result.verification.dashboardReachable).toBe(false); + expect(result.diagnostics.some((diagnostic) => diagnostic.link === "dashboard")).toBe(false); + expect(probeHostPort).not.toHaveBeenCalled(); + expect(formatVerificationDiagnostics(result)[0]).toContain( + "gateway and inference route are healthy", + ); + }); + it("reports unhealthy when the inference route is unreachable (#6849)", async () => { const deps = makeDeps({ executeSandboxCommand: (_name: string, script: string) => { diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index f271069d5fa..7e5631c67e9 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -129,6 +129,8 @@ export interface VerifyDeploymentOptions { * startup paths intentionally differ. */ diagnoseCustomOpenClawRuntime?: boolean; + /** Skip host dashboard-forward verification when the sandbox intentionally has none. */ + verifyDashboardForward?: boolean; } const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [1000, 2000, 5000, 7000, 10000]; @@ -524,17 +526,22 @@ export async function verifyDeployment( // 3. Dashboard reachable from host (port forward) // A port forward cannot repair an image that has no managed gateway runtime, // so avoid spending a second retry budget on the dependent dashboard probe. + const verifyDashboardForward = options.verifyDashboardForward !== false; const dashboardRetryDelays = customRuntimeHints ? [] : retryDelaysMs; - const dashboard = await verifyDashboardFromHost(chain, deps, dashboardRetryDelays, sleep); - diagnostics.push({ - link: "dashboard", - status: dashboard.reachable ? "ok" : "fail", - detail: dashboard.detail, - hint: dashboard.reachable - ? "" - : (customRuntimeHints?.dashboard ?? - `Port forward on ${chain.port} is not working. Run: openshell forward start ${chain.forwardTarget} ${sandboxName}`), - }); + const dashboard = verifyDashboardForward + ? await verifyDashboardFromHost(chain, deps, dashboardRetryDelays, sleep) + : { reachable: false, detail: "host dashboard forward intentionally disabled" }; + if (verifyDashboardForward) { + diagnostics.push({ + link: "dashboard", + status: dashboard.reachable ? "ok" : "fail", + detail: dashboard.detail, + hint: dashboard.reachable + ? "" + : (customRuntimeHints?.dashboard ?? + `Port forward on ${chain.port} is not working. Run: openshell forward start ${chain.forwardTarget} ${sandboxName}`), + }); + } // 4. Inference route const inference = await verifyInferenceRoute( @@ -581,7 +588,10 @@ export async function verifyDeployment( accessMethod, }; - const healthy = gateway.reachable && dashboard.reachable && inference.status === "ok"; + const healthy = + gateway.reachable && + (!verifyDashboardForward || dashboard.reachable) && + inference.status === "ok"; return { healthy, verification, diagnostics }; } @@ -601,8 +611,13 @@ export function formatVerificationDiagnostics(result: VerifyDeploymentResult): s const RESET = "\x1b[0m"; if (result.healthy) { + const dashboardVerified = result.diagnostics.some( + (diagnostic) => diagnostic.link === "dashboard", + ); lines.push( - ` ${G}✓${RESET} Deployment verified — gateway, dashboard, and inference route are healthy.`, + dashboardVerified + ? ` ${G}✓${RESET} Deployment verified — gateway, dashboard, and inference route are healthy.` + : ` ${G}✓${RESET} Deployment verified — gateway and inference route are healthy.`, ); if (result.verification.gatewayVersion) { lines.push(` OpenClaw version: ${result.verification.gatewayVersion}`); diff --git a/test/recover-port-forward.test.ts b/test/recover-port-forward.test.ts index 2c4a84fbce0..df456e43f67 100644 --- a/test/recover-port-forward.test.ts +++ b/test/recover-port-forward.test.ts @@ -106,6 +106,7 @@ function setupFixture(opts: { forwardStartDelayPolls?: number; recoveryWaitMs?: string; port?: string; + dashboardForwardEnabled?: boolean; }): Fixture { const sandboxName = opts.sandboxName; const port = opts.port ?? String(nextFixturePort++); @@ -131,6 +132,7 @@ function setupFixture(opts: { gpuEnabled: false, policies: [], dashboardPort: Number(port), + ...(opts.dashboardForwardEnabled === false ? { dashboardForwardEnabled: false } : {}), }, }, }), @@ -406,4 +408,24 @@ describe("nemoclaw recover", () => { expect(calls.some((l) => l.startsWith("forward stop "))).toBe(false); expect(calls.some((l) => l.startsWith("forward start "))).toBe(false); }); + + it("does not probe or recreate an intentionally disabled dashboard forward", () => { + const fixture = setupFixture({ + sandboxName: "no-forward-sandbox", + gatewayProbe: "RUNNING", + forwardListStatus: "missing", + forwardStartHeals: false, + dashboardForwardEnabled: false, + }); + const result = runRecover(fixture); + expect(result.status).toBe(0); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).not.toContain("Dashboard port forward"); + expect(combined).not.toContain("Re-establishing"); + + const calls = fs.readFileSync(fixture.invocationLog, "utf-8").split("\n"); + expect(calls.some((line) => line === "forward list")).toBe(false); + expect(calls.some((line) => line.startsWith("forward start "))).toBe(false); + }); }); From e2a45b848b1f55409324e892c4177d28f8555b00 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 11:34:08 -0700 Subject: [PATCH 05/42] fix(onboard): require portable hosted inference Signed-off-by: Aaron Erickson --- src/lib/onboard.ts | 4 +- src/lib/onboard/command.test.ts | 41 +++++++++++++++++-- src/lib/onboard/command.ts | 19 +++++---- .../experimental/portable-inference-source.ts | 16 ++++++-- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c3c50267929..547162570ee 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2706,16 +2706,14 @@ async function createSandboxWithBaseImageResolution( runtimePatch, ); } - let actualDashboardPort = 0; let finalHermesDashboardState = hermesDashboardState; if (manageDashboardForward) { actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { rollbackSandboxOnFailure: true, }); - if (actualDashboardPort !== Number(getDashboardForwardPort(chatUiUrl))) { + if (actualDashboardPort !== Number(getDashboardForwardPort(chatUiUrl))) chatUiUrl = `http://127.0.0.1:${actualDashboardPort}`; - } process.env.CHAT_UI_URL = chatUiUrl; finalHermesDashboardState = hermesDashboardForwarding.resolveStateForPort(actualDashboardPort); hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 491b590c4a1..31b026cae02 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -436,7 +436,7 @@ describe("onboard command options", () => { expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); }); - it("prepares and scopes portable profile defaults around onboarding", async () => { + it("prepares and scopes hosted-only portable profile defaults around onboarding", async () => { const env: NodeJS.ProcessEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile", NEMOCLAW_PROVIDER: "previous-provider", @@ -450,6 +450,11 @@ describe("onboard command options", () => { await runOnboardCommand({ flags: { "experimental-profile": "portable" }, env, + resolvePortableInferenceSource: () => ({ + apiKey: "test-credential-value-1234", + baseUrl: "https://inference.example.test/v1", + model: "example/model-1", + }), runOnboard: async () => { for (const key of [ "NEMOCLAW_EXPERIMENTAL_PROFILE", @@ -467,10 +472,10 @@ describe("onboard command options", () => { expect(observed).toEqual({ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", - NEMOCLAW_PROVIDER: "ollama", - NEMOCLAW_MODEL: "qwen3-vl:4b", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "skip", NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); @@ -485,6 +490,27 @@ describe("onboard command options", () => { }); }); + it("never falls back to local inference when portable activation is missing", async () => { + const runOnboard = vi.fn(); + const errors: string[] = []; + + await expect( + runOnboardCommand({ + flags: { "experimental-profile": "portable" }, + env: {}, + resolvePortableInferenceSource: () => null, + runOnboard, + error: (message = "") => errors.push(message), + exit: exitWithCode, + }), + ).rejects.toThrow("exit:1"); + + expect(runOnboard).not.toHaveBeenCalled(); + expect(errors).toEqual([ + " Portable hosted inference requires an activated credential descriptor or configured object source.", + ]); + }); + it("uses the portable hosted inference descriptor without starting local inference", async () => { const env: NodeJS.ProcessEnv = { S3_BUCKET: "portable-inference", @@ -608,6 +634,11 @@ describe("onboard command options", () => { name: "portable profile", flags: { "experimental-profile": "portable" } as OnboardFlags, listServingProfiles: undefined, + resolvePortableInferenceSource: () => ({ + apiKey: "test-credential-value-1234", + baseUrl: "https://inference.example.test/v1", + model: "example/model-1", + }), keys: [ "NEMOCLAW_EXPERIMENTAL_PROFILE", "NEMOCLAW_PROVIDER", @@ -619,6 +650,7 @@ describe("onboard command options", () => { name: "serving profile", flags: { profile: COMPATIBLE_NANO_PROFILE.id } as OnboardFlags, listServingProfiles: () => [COMPATIBLE_NANO_PROFILE], + resolvePortableInferenceSource: undefined, keys: ["NEMOCLAW_SERVING_PRESET"], }, ])("restores the $name environment when an agents manifest is invalid", async (testCase) => { @@ -633,6 +665,7 @@ describe("onboard command options", () => { flags: { ...testCase.flags, agents: manifestPath }, env, listServingProfiles: testCase.listServingProfiles, + resolvePortableInferenceSource: testCase.resolvePortableInferenceSource, runOnboard: vi.fn(), }), ).rejects.toThrow("--agents YAML parse error"); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 6adf373479d..3a95c7b55cc 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -392,20 +392,23 @@ function applyPortableEnvironment( ): () => void { if (!options.experimentalProfile) return () => {}; const hostedInference = resolveInferenceSource(env); + if (!hostedInference) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires an activated credential descriptor or configured object source.", + ); + } const portableEnvDefaults: Record = { [EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile, [TOOL_DISCLOSURE_ENV]: "direct", - NEMOCLAW_PROVIDER: hostedInference ? "custom" : "ollama", - NEMOCLAW_MODEL: hostedInference?.model ?? "qwen3-vl:4b", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "skip", NEMOCLAW_POLICY_TIER: "personal", + COMPATIBLE_API_KEY: hostedInference.apiKey, + NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, + NEMOCLAW_PREFERRED_API: "openai-completions", }; - if (hostedInference) { - portableEnvDefaults.COMPATIBLE_API_KEY = hostedInference.apiKey; - portableEnvDefaults.NEMOCLAW_ENDPOINT_URL = hostedInference.baseUrl; - portableEnvDefaults.NEMOCLAW_PREFERRED_API = "openai-completions"; - } const previousPortableEnv = new Map(); const restore = () => { for (const [key, value] of previousPortableEnv) { diff --git a/src/lib/onboard/experimental/portable-inference-source.ts b/src/lib/onboard/experimental/portable-inference-source.ts index efc1eac1833..01c4e3cd15f 100644 --- a/src/lib/onboard/experimental/portable-inference-source.ts +++ b/src/lib/onboard/experimental/portable-inference-source.ts @@ -6,6 +6,7 @@ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; import { TextDecoder } from "node:util"; export const PORTABLE_INFERENCE_ACTIVATION_PATH = "/run/nemoclaw/portable-inference.b64"; +export const PORTABLE_INFERENCE_IMAGE_PATH = "/var/lib/nemoclaw/portable-inference.b64"; const DEFAULT_RELATIVE_OBJECT_KEY = "secrets/nvcf-llm.b64"; const MAX_DESCRIPTOR_BYTES = 64 * 1024; @@ -28,9 +29,18 @@ export class PortableInferenceSourceError extends Error { export type PortableInferenceObjectReader = (uri: string, env: NodeJS.ProcessEnv) => Buffer; export type PortableInferenceActivationReader = () => Buffer | null; -export function readPortableInferenceActivationFile( - filePath: string = PORTABLE_INFERENCE_ACTIVATION_PATH, -): Buffer | null { +export function readPortableInferenceActivationFile(filePath?: string): Buffer | null { + const candidates = filePath + ? [filePath] + : [PORTABLE_INFERENCE_ACTIVATION_PATH, PORTABLE_INFERENCE_IMAGE_PATH]; + for (const candidate of candidates) { + const descriptor = readPortableInferenceActivationCandidate(candidate); + if (descriptor !== null) return descriptor; + } + return null; +} + +function readPortableInferenceActivationCandidate(filePath: string): Buffer | null { let descriptorFd: number; try { const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; From ab85412aecf81c77ab79ff240bcc02f478ea4442 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 12:25:20 -0700 Subject: [PATCH 06/42] fix(onboard): secure portable inference bootstrap Signed-off-by: Aaron Erickson --- src/lib/onboard.ts | 8 +- src/lib/onboard/command.test.ts | 66 ++++- src/lib/onboard/command.ts | 2 +- .../portable-inference-source.test.ts | 229 ++++++++++-------- .../experimental/portable-inference-source.ts | 224 ++++++++++------- src/lib/onboard/forward-cleanup.test.ts | 32 ++- src/lib/onboard/forward-cleanup.ts | 28 +++ .../onboard/inference-selection-validation.ts | 4 + src/lib/onboard/sandbox-reuse.test.ts | 54 +++++ src/lib/onboard/sandbox-reuse.ts | 17 ++ src/lib/security/trusted-private-endpoint.ts | 21 +- 11 files changed, 477 insertions(+), 208 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 547162570ee..67b08ff5e0d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -56,7 +56,10 @@ const channelState: typeof import("./onboard/channel-state") = require("./onboar const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); -const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); +const { + bestEffortForwardStop, + stopForwardForSandboxOrThrow, +} = require("./onboard/forward-cleanup"); const { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, @@ -2298,6 +2301,9 @@ async function createSandboxWithBaseImageResolution( gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, manageDashboard: manageDashboardForward, + getSandbox: registry.getSandbox, + stopDashboardForward: (name, port) => + stopForwardForSandboxOrThrow(runOpenshell, runCaptureOpenshell, port, name), ensureDashboardForward, hermesDashboardForwarding, updateReusedSandboxMetadata, diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 31b026cae02..afe8c75e2fa 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -14,6 +14,7 @@ import type { OnboardFlags } from "./command-support"; import { PortableInferenceSourceError } from "./experimental/portable-inference-source"; import { invalidGatewayManagementDeclarationError } from "./gateway-management"; import { GatewayAuthorityError } from "./gateway-teardown-authority"; +import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation"; afterEach(() => { vi.unstubAllEnvs(); @@ -479,7 +480,7 @@ describe("onboard command options", () => { NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); - expect(env).toMatchObject({ + expect(env).toEqual({ NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile", NEMOCLAW_PROVIDER: "previous-provider", NEMOCLAW_MODEL: "previous-model", @@ -490,7 +491,7 @@ describe("onboard command options", () => { }); }); - it("never falls back to local inference when portable activation is missing", async () => { + it("never falls back to local inference when the portable bootstrap is missing", async () => { const runOnboard = vi.fn(); const errors: string[] = []; @@ -507,14 +508,12 @@ describe("onboard command options", () => { expect(runOnboard).not.toHaveBeenCalled(); expect(errors).toEqual([ - " Portable hosted inference requires an activated credential descriptor or configured object source.", + " Portable hosted inference requires infrakey.txt on the desktop or an owner-only bootstrap credential at /run/nemoclaw/portable-bootstrap.", ]); }); it("uses the portable hosted inference descriptor without starting local inference", async () => { const env: NodeJS.ProcessEnv = { - S3_BUCKET: "portable-inference", - S3_KEY: "path/credential.b64", COMPATIBLE_API_KEY: "previous-compatible-key", NEMOCLAW_ENDPOINT_URL: "https://previous.example.test/v1", NEMOCLAW_PREFERRED_API: "openai-responses", @@ -556,8 +555,6 @@ describe("onboard command options", () => { NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", }); expect(env).toEqual({ - S3_BUCKET: "portable-inference", - S3_KEY: "path/credential.b64", COMPATIBLE_API_KEY: "previous-compatible-key", NEMOCLAW_ENDPOINT_URL: "https://previous.example.test/v1", NEMOCLAW_PREFERRED_API: "openai-responses", @@ -566,10 +563,59 @@ describe("onboard command options", () => { }); }); + it.each([ + { + endpointUrl: "https://127.0.0.1/v1", + resolveEndpointHost: vi.fn(async () => [{ address: "127.0.0.1", family: 4 }]), + }, + { + endpointUrl: "https://public-name.example.test/v1", + resolveEndpointHost: vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]), + }, + ])("blocks the portable endpoint $endpointUrl before probing", async ({ + endpointUrl, + resolveEndpointHost, + }) => { + const probeOpenAiLikeEndpoint = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + let validationResult: unknown = null; + + try { + await runOnboardCommand({ + flags: { "experimental-profile": "portable" }, + env: process.env, + resolvePortableInferenceSource: () => ({ + apiKey: "test-credential-value-1234", + baseUrl: endpointUrl, + model: "example/model-1", + }), + runOnboard: async () => { + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => process.env.COMPATIBLE_API_KEY ?? null, + probeOpenAiLikeEndpoint, + resolveEndpointHost, + promptValidationRecovery: vi.fn(async () => "selection" as const), + }); + validationResult = await helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + process.env.NEMOCLAW_ENDPOINT_URL ?? "", + process.env.NEMOCLAW_MODEL ?? "", + "COMPATIBLE_API_KEY", + ); + }, + }); + } finally { + error.mockRestore(); + } + + expect(validationResult).toEqual({ ok: false, retry: "selection" }); + expect(probeOpenAiLikeEndpoint).not.toHaveBeenCalled(); + }); + it("stops before onboarding when the portable inference descriptor cannot be resolved", async () => { const env: NodeJS.ProcessEnv = { - S3_BUCKET: "portable-inference", - S3_KEY: "path/credential.b64", NEMOCLAW_PROVIDER: "previous-provider", }; const runOnboard = vi.fn(); @@ -593,8 +639,6 @@ describe("onboard command options", () => { expect(runOnboard).not.toHaveBeenCalled(); expect(errors).toEqual([" Portable hosted inference received an invalid descriptor."]); expect(env).toEqual({ - S3_BUCKET: "portable-inference", - S3_KEY: "path/credential.b64", NEMOCLAW_PROVIDER: "previous-provider", }); }); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 3a95c7b55cc..c8d87f75cb8 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -394,7 +394,7 @@ function applyPortableEnvironment( const hostedInference = resolveInferenceSource(env); if (!hostedInference) { throw new PortableInferenceSourceError( - "Portable hosted inference requires an activated credential descriptor or configured object source.", + "Portable hosted inference requires infrakey.txt on the desktop or an owner-only bootstrap credential at /run/nemoclaw/portable-bootstrap.", ); } const portableEnvDefaults: Record = { diff --git a/src/lib/onboard/experimental/portable-inference-source.test.ts b/src/lib/onboard/experimental/portable-inference-source.test.ts index 1e4b775e3ef..b71ecba9064 100644 --- a/src/lib/onboard/experimental/portable-inference-source.test.ts +++ b/src/lib/onboard/experimental/portable-inference-source.test.ts @@ -1,7 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,7 +18,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { parsePortableInferenceDescriptor, - readPortableInferenceActivationFile, + readPortableInferenceBootstrapFile, + readPortableInferenceDescriptorFromS3, resolvePortableInferenceSource, } from "./portable-inference-source"; @@ -22,170 +32,185 @@ const VALID_FIELDS = { url: "https://inference.example.test/v1", model: "example/model-1", }; +const TEST_ACCESS_KEY_ID = "AKIAABCDEFGHIJKLMNOP"; +const TEST_SECRET_ACCESS_KEY = "s".repeat(40); +const TEST_BOOTSTRAP = `${TEST_ACCESS_KEY_ID}:${TEST_SECRET_ACCESS_KEY}`; const tempDirectories: string[] = []; -const noActivatedDescriptor = (): null => null; afterEach(() => { + vi.unstubAllEnvs(); for (const directory of tempDirectories.splice(0)) { rmSync(directory, { force: true, recursive: true }); } }); describe("portable hosted inference source", () => { - it("does not read object storage when the portable source is not configured", () => { - const readObject = vi.fn(); - - expect(resolvePortableInferenceSource({}, readObject, noActivatedDescriptor)).toBeNull(); - expect(readObject).not.toHaveBeenCalled(); - }); - - it("prefers an activated descriptor over configured object storage", () => { + it("does not read object storage when the bootstrap credential is missing", () => { const readObject = vi.fn(); - const readActivatedDescriptor = vi.fn(() => descriptor(VALID_FIELDS)); - expect( - resolvePortableInferenceSource( - { S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64" }, - readObject, - readActivatedDescriptor, - ), - ).toEqual({ - apiKey: VALID_FIELDS.apiKey, - baseUrl: VALID_FIELDS.url, - model: VALID_FIELDS.model, - }); - expect(readActivatedDescriptor).toHaveBeenCalledOnce(); + expect(resolvePortableInferenceSource({}, () => null, readObject)).toBeNull(); expect(readObject).not.toHaveBeenCalled(); }); - it("redacts unexpected activated-descriptor reader errors", () => { - const readActivatedDescriptor = vi.fn(() => { - throw new Error("reader output contained test-credential-value-1234"); - }); + it("reads an owner-only bootstrap credential without removing it", () => { + const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); + tempDirectories.push(directory); + const filePath = path.join(directory, "portable-bootstrap"); + writeFileSync(filePath, TEST_BOOTSTRAP, { mode: 0o600 }); + chmodSync(filePath, 0o600); - expect(() => resolvePortableInferenceSource({}, vi.fn(), readActivatedDescriptor)).toThrow( - "could not read its activated credential descriptor", - ); - try { - resolvePortableInferenceSource({}, vi.fn(), readActivatedDescriptor); - } catch (error) { - expect(String(error)).not.toContain("test-credential-value-1234"); - } + expect(readPortableInferenceBootstrapFile(filePath)).toEqual(Buffer.from(TEST_BOOTSTRAP)); + expect(existsSync(filePath)).toBe(true); }); - it("reads an owner-only activated descriptor file", () => { + it("finds infrakey.txt on the desktop and immediately restricts its permissions", () => { const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); tempDirectories.push(directory); - const filePath = path.join(directory, "descriptor.b64"); - const raw = descriptor(VALID_FIELDS); - writeFileSync(filePath, raw, { mode: 0o600 }); - chmodSync(filePath, 0o600); - - expect(readPortableInferenceActivationFile(filePath)).toEqual(raw); + const desktop = path.join(directory, "Desktop"); + const filePath = path.join(desktop, "infrakey.txt"); + mkdirSync(desktop); + writeFileSync(filePath, `${TEST_ACCESS_KEY_ID}\n${TEST_SECRET_ACCESS_KEY}\n`, { mode: 0o644 }); + chmodSync(filePath, 0o644); + vi.stubEnv("HOME", directory); + + const raw = readPortableInferenceBootstrapFile(); + + expect(raw).toEqual(Buffer.from(`${TEST_ACCESS_KEY_ID}\n${TEST_SECRET_ACCESS_KEY}\n`)); + expect(statSync(filePath).mode & 0o777).toBe(0o600); + expect(existsSync(filePath)).toBe(true); }); - it("rejects a group-readable activated descriptor file", () => { + it("rejects a group-readable bootstrap credential without consuming it", () => { const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); tempDirectories.push(directory); - const filePath = path.join(directory, "descriptor.b64"); - writeFileSync(filePath, descriptor(VALID_FIELDS), { mode: 0o640 }); + const filePath = path.join(directory, "portable-bootstrap"); + writeFileSync(filePath, TEST_BOOTSTRAP, { mode: 0o640 }); chmodSync(filePath, 0o640); - expect(() => readPortableInferenceActivationFile(filePath)).toThrow( - "owned by root or the current user", - ); + expect(() => readPortableInferenceBootstrapFile(filePath)).toThrow("mode 0600"); + expect(existsSync(filePath)).toBe(true); }); - it("rejects a symlinked activated descriptor file", () => { + it("rejects a symlinked bootstrap credential", () => { const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); tempDirectories.push(directory); - const targetPath = path.join(directory, "target.b64"); - const linkPath = path.join(directory, "descriptor.b64"); - writeFileSync(targetPath, descriptor(VALID_FIELDS), { mode: 0o600 }); + const targetPath = path.join(directory, "target"); + const linkPath = path.join(directory, "portable-bootstrap"); + writeFileSync(targetPath, TEST_BOOTSTRAP, { mode: 0o600 }); chmodSync(targetPath, 0o600); symlinkSync(targetPath, linkPath); - expect(() => readPortableInferenceActivationFile(linkPath)).toThrow( - "could not read its activated credential descriptor", + expect(() => readPortableInferenceBootstrapFile(linkPath)).toThrow( + "could not read its bootstrap credential", ); }); - it("reads and validates the configured descriptor without writing credential state", () => { + it("fetches and validates the descriptor from the fixed object source", () => { + const rawBootstrap = Buffer.from(TEST_BOOTSTRAP); const readObject = vi.fn(() => descriptor(VALID_FIELDS)); - const env = { S3_BUCKET: "portable-inference", S3_KEY: "/path/credential.b64" }; - expect(resolvePortableInferenceSource(env, readObject, noActivatedDescriptor)).toEqual({ + expect(resolvePortableInferenceSource({}, () => rawBootstrap, readObject)).toEqual({ apiKey: VALID_FIELDS.apiKey, baseUrl: VALID_FIELDS.url, model: VALID_FIELDS.model, }); - expect(readObject).toHaveBeenCalledWith("s3://portable-inference/path/credential.b64", env); - expect(env).toEqual({ - S3_BUCKET: "portable-inference", - S3_KEY: "/path/credential.b64", + expect(readObject).toHaveBeenCalledWith({ + accessKeyId: TEST_ACCESS_KEY_ID, + secretAccessKey: TEST_SECRET_ACCESS_KEY, }); + expect(rawBootstrap.every((byte) => byte === 0)).toBe(true); }); - it("resolves the prefix form to the portable credential object", () => { - const readObject = vi.fn(() => - descriptor({ - token: VALID_FIELDS.apiKey, - base_url: `${VALID_FIELDS.url}/`, - default_model: VALID_FIELDS.model, - }), - ); - + it("accepts desktop-friendly credentials on two separate lines", () => { + const rawBootstrap = Buffer.from(`${TEST_ACCESS_KEY_ID}\n${TEST_SECRET_ACCESS_KEY}\n`); expect( resolvePortableInferenceSource( - { S3_BUCKET: "portable-inference", S3_PREFIX: "/tenant/session/" }, - readObject, - noActivatedDescriptor, + {}, + () => rawBootstrap, + () => descriptor(VALID_FIELDS), ), ).toEqual({ apiKey: VALID_FIELDS.apiKey, baseUrl: VALID_FIELDS.url, model: VALID_FIELDS.model, }); - expect(readObject).toHaveBeenCalledWith( - "s3://portable-inference/tenant/session/secrets/nvcf-llm.b64", - expect.any(Object), - ); + expect(rawBootstrap.every((byte) => byte === 0)).toBe(true); }); - it("does not expose object-reader errors that can contain credential material", () => { - const readObject = vi.fn(() => { - throw new Error("upstream output contained test-credential-value-1234"); + it("keeps the long-term bootstrap secret out of the curl process boundary", () => { + const rawDescriptor = descriptor(VALID_FIELDS); + const runCurl = vi.fn(() => ({ + pid: 1, + output: [null, rawDescriptor, Buffer.alloc(0)], + stdout: rawDescriptor, + stderr: Buffer.alloc(0), + status: 0, + signal: null, + })) as unknown as typeof import("node:child_process").spawnSync; + + expect( + readPortableInferenceDescriptorFromS3( + { + accessKeyId: TEST_ACCESS_KEY_ID, + secretAccessKey: TEST_SECRET_ACCESS_KEY, + }, + runCurl, + new Date("2026-08-07T18:00:00.000Z"), + ), + ).toEqual(rawDescriptor); + + expect(runCurl).toHaveBeenCalledOnce(); + const [command, args, options] = (runCurl as unknown as ReturnType).mock.calls[0]; + expect(command).toBe("curl"); + expect(args).toEqual(["--config", "-"]); + expect(JSON.stringify(args)).not.toContain(TEST_ACCESS_KEY_ID); + expect(JSON.stringify(args)).not.toContain(TEST_SECRET_ACCESS_KEY); + expect(JSON.stringify(options.env ?? {})).not.toContain(TEST_ACCESS_KEY_ID); + expect(JSON.stringify(options.env ?? {})).not.toContain(TEST_SECRET_ACCESS_KEY); + expect(String(options.input)).toContain("Authorization: AWS4-HMAC-SHA256"); + expect(String(options.input)).not.toContain(TEST_SECRET_ACCESS_KEY); + expect(options).toMatchObject({ + maxBuffer: 65_537, + timeout: 10_000, + killSignal: "SIGKILL", }); - let caught: unknown; + }); + + it("redacts bootstrap and object-reader errors that can contain credentials", () => { + const bootstrapSecret = "bootstrap-secret-value"; + const descriptorSecret = "descriptor-secret-value"; + + expect(() => + resolvePortableInferenceSource( + {}, + () => { + throw new Error(bootstrapSecret); + }, + vi.fn(), + ), + ).toThrow("could not read its bootstrap credential"); try { resolvePortableInferenceSource( - { S3_BUCKET: "portable-inference", S3_KEY: "path/credential.b64" }, - readObject, - noActivatedDescriptor, + {}, + () => Buffer.from(TEST_BOOTSTRAP), + () => { + throw new Error(descriptorSecret); + }, ); } catch (error) { - caught = error; + expect(String(error)).toContain("could not read its credential descriptor"); + expect(String(error)).not.toContain(descriptorSecret); } - expect(String(caught)).toContain( - "Portable hosted inference could not read its credential descriptor.", - ); - expect(String(caught)).not.toContain("test-credential-value-1234"); }); it.each([ - [{ S3_BUCKET: "portable-inference" }, "requires S3_BUCKET"], - [{ S3_KEY: "credential.b64" }, "requires S3_BUCKET"], - [ - { S3_BUCKET: "portable-inference", S3_KEY: "one", S3_PREFIX: "two" }, - "accepts S3_KEY or S3_PREFIX", - ], - [{ S3_BUCKET: "bad/bucket", S3_KEY: "credential.b64" }, "invalid S3_BUCKET"], - [{ S3_BUCKET: "portable-inference", S3_PREFIX: "/" }, "invalid S3_PREFIX"], - ])("rejects incomplete or ambiguous source configuration", (env, message) => { - expect(() => resolvePortableInferenceSource(env, vi.fn(), noActivatedDescriptor)).toThrow( - message, - ); + [Buffer.from("missing-separator"), "invalid bootstrap credential"], + [Buffer.from(`short:${TEST_SECRET_ACCESS_KEY}`), "invalid bootstrap credential"], + [Buffer.from(`${TEST_ACCESS_KEY_ID}:short`), "invalid bootstrap credential"], + ])("rejects an invalid bootstrap credential", (raw, message) => { + expect(() => resolvePortableInferenceSource({}, () => raw, vi.fn())).toThrow(message); + expect(raw.every((byte) => byte === 0)).toBe(true); }); it.each([ diff --git a/src/lib/onboard/experimental/portable-inference-source.ts b/src/lib/onboard/experimental/portable-inference-source.ts index 01c4e3cd15f..51c3c0c04ab 100644 --- a/src/lib/onboard/experimental/portable-inference-source.ts +++ b/src/lib/onboard/experimental/portable-inference-source.ts @@ -2,13 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { createHash, createHmac } from "node:crypto"; +import { closeSync, constants, fchmodSync, fstatSync, openSync, readSync } from "node:fs"; +import path from "node:path"; import { TextDecoder } from "node:util"; -export const PORTABLE_INFERENCE_ACTIVATION_PATH = "/run/nemoclaw/portable-inference.b64"; -export const PORTABLE_INFERENCE_IMAGE_PATH = "/var/lib/nemoclaw/portable-inference.b64"; +export const PORTABLE_INFERENCE_BOOTSTRAP_PATH = "/run/nemoclaw/portable-bootstrap"; +export const PORTABLE_INFERENCE_DESKTOP_FILENAME = "infrakey.txt"; -const DEFAULT_RELATIVE_OBJECT_KEY = "secrets/nvcf-llm.b64"; +const PORTABLE_INFERENCE_S3_REGION = "us-east-2"; +const PORTABLE_INFERENCE_S3_BUCKET = "gfn-ld-ai-poc-355178295565-us-east-2-an"; +const PORTABLE_INFERENCE_S3_KEY = "GFNClawV2/secrets/nvcf-llm.b64"; +const MAX_BOOTSTRAP_BYTES = 256; const MAX_DESCRIPTOR_BYTES = 64 * 1024; const MAX_ENDPOINT_LENGTH = 2048; const MAX_MODEL_ID_LENGTH = 512; @@ -26,125 +31,164 @@ export class PortableInferenceSourceError extends Error { override readonly name = "PortableInferenceSourceError"; } -export type PortableInferenceObjectReader = (uri: string, env: NodeJS.ProcessEnv) => Buffer; -export type PortableInferenceActivationReader = () => Buffer | null; - -export function readPortableInferenceActivationFile(filePath?: string): Buffer | null { - const candidates = filePath - ? [filePath] - : [PORTABLE_INFERENCE_ACTIVATION_PATH, PORTABLE_INFERENCE_IMAGE_PATH]; - for (const candidate of candidates) { - const descriptor = readPortableInferenceActivationCandidate(candidate); - if (descriptor !== null) return descriptor; - } - return null; +export interface PortableBootstrapCredential { + accessKeyId: string; + secretAccessKey: string; } -function readPortableInferenceActivationCandidate(filePath: string): Buffer | null { - let descriptorFd: number; +export type PortableInferenceBootstrapReader = () => Buffer | null; +export type PortableInferenceObjectReader = (credential: PortableBootstrapCredential) => Buffer; + +function readBootstrapCandidate(filePath: string, repairOwnerPermissions: boolean): Buffer | null { + let bootstrapFd: number; try { const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; - descriptorFd = openSync(filePath, constants.O_RDONLY | noFollow); + bootstrapFd = openSync(filePath, constants.O_RDONLY | noFollow); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new PortableInferenceSourceError( - "Portable hosted inference could not read its activated credential descriptor.", + "Portable hosted inference could not read its bootstrap credential.", ); } try { - const stats = fstatSync(descriptorFd); + let stats = fstatSync(bootstrapFd); const effectiveUid = typeof process.geteuid === "function" ? process.geteuid() : null; - if ( - !stats.isFile() || - (stats.mode & 0o077) !== 0 || - (effectiveUid !== null && stats.uid !== 0 && stats.uid !== effectiveUid) - ) { + if (!stats.isFile() || (effectiveUid !== null && stats.uid !== effectiveUid)) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires its bootstrap credential to be a regular file owned by the current user.", + ); + } + if (repairOwnerPermissions) { + fchmodSync(bootstrapFd, 0o600); + stats = fstatSync(bootstrapFd); + } + if ((stats.mode & 0o777) !== 0o600) { throw new PortableInferenceSourceError( - "Portable hosted inference requires its activated descriptor to be a regular file owned by root or the current user, with no group or other permissions.", + "Portable hosted inference requires its bootstrap credential to have mode 0600.", ); } - const buffer = Buffer.allocUnsafe(MAX_DESCRIPTOR_BYTES + 1); + const buffer = Buffer.allocUnsafe(MAX_BOOTSTRAP_BYTES + 1); let offset = 0; while (offset < buffer.length) { - const bytesRead = readSync(descriptorFd, buffer, offset, buffer.length - offset, null); + const bytesRead = readSync(bootstrapFd, buffer, offset, buffer.length - offset, null); if (bytesRead === 0) break; offset += bytesRead; } - if (offset === 0 || offset > MAX_DESCRIPTOR_BYTES) { + if (offset === 0 || offset > MAX_BOOTSTRAP_BYTES) { throw new PortableInferenceSourceError( - "Portable hosted inference received an empty or oversized descriptor.", + "Portable hosted inference received an empty or oversized bootstrap credential.", ); } return buffer.subarray(0, offset); } catch (error) { if (error instanceof PortableInferenceSourceError) throw error; throw new PortableInferenceSourceError( - "Portable hosted inference could not read its activated credential descriptor.", + "Portable hosted inference could not read its bootstrap credential.", ); } finally { - closeSync(descriptorFd); + closeSync(bootstrapFd); } } -function configurationValue(env: NodeJS.ProcessEnv, name: string): string { - return String(env[name] ?? "").trim(); +export function readPortableInferenceBootstrapFile(filePath?: string): Buffer | null { + if (filePath) return readBootstrapCandidate(filePath, false); + + const runtimeBootstrap = readBootstrapCandidate(PORTABLE_INFERENCE_BOOTSTRAP_PATH, false); + if (runtimeBootstrap) return runtimeBootstrap; + + const homeDirectory = process.env.HOME; + if (!homeDirectory || !path.isAbsolute(homeDirectory)) return null; + return readBootstrapCandidate( + path.join(homeDirectory, "Desktop", PORTABLE_INFERENCE_DESKTOP_FILENAME), + true, + ); } -function resolveObjectUri(env: NodeJS.ProcessEnv): string | null { - const bucket = configurationValue(env, "S3_BUCKET"); - const configuredKey = configurationValue(env, "S3_KEY"); - const configuredPrefix = configurationValue(env, "S3_PREFIX"); - if (!bucket && !configuredKey && !configuredPrefix) return null; - if (!bucket || (!configuredKey && !configuredPrefix)) { - throw new PortableInferenceSourceError( - "Portable hosted inference requires S3_BUCKET and exactly one of S3_KEY or S3_PREFIX.", - ); - } - if (configuredKey && configuredPrefix) { +function parsePortableBootstrapCredential(raw: Buffer): PortableBootstrapCredential { + let value: string; + try { + value = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim(); + } catch { throw new PortableInferenceSourceError( - "Portable hosted inference accepts S3_KEY or S3_PREFIX, not both.", + "Portable hosted inference received an invalid bootstrap credential.", ); } + const tokens = value.split(/\s+/); + const colonSeparated = tokens.length === 1 ? tokens[0].split(":") : []; + const [accessKeyId = "", secretAccessKey = ""] = tokens.length === 2 ? tokens : colonSeparated; if ( - bucket.length > 255 || - /[\s/\\\u0000-\u001f\u007f]/.test(bucket) || - !/^[A-Za-z0-9]/.test(bucket) + (tokens.length !== 2 && colonSeparated.length !== 2) || + !/^[A-Z0-9]{16,128}$/.test(accessKeyId) || + !/^[A-Za-z0-9/+=]{32,128}$/.test(secretAccessKey) ) { throw new PortableInferenceSourceError( - "Portable hosted inference received an invalid S3_BUCKET.", - ); - } - const normalizedPrefix = configuredPrefix.replace(/^\/+|\/+$/g, ""); - if (configuredPrefix && !normalizedPrefix) { - throw new PortableInferenceSourceError( - "Portable hosted inference received an invalid S3_PREFIX.", - ); - } - const key = configuredKey - ? configuredKey.replace(/^\/+/, "") - : `${normalizedPrefix}/${DEFAULT_RELATIVE_OBJECT_KEY}`; - if (!key || key.length > 1024 || /[\u0000-\u001f\u007f]/.test(key)) { - throw new PortableInferenceSourceError( - "Portable hosted inference received an invalid object key.", + "Portable hosted inference received an invalid bootstrap credential.", ); } - return `s3://${bucket}/${key}`; + return { accessKeyId, secretAccessKey }; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function hmac(key: Buffer | string, value: string, encoding?: "hex"): Buffer | string { + const digest = createHmac("sha256", key).update(value); + return encoding === "hex" ? digest.digest("hex") : digest.digest(); } -function readObjectWithAwsCli(uri: string, env: NodeJS.ProcessEnv): Buffer { - const result = spawnSync("aws", ["s3", "cp", uri, "-", "--only-show-errors"], { - env, +export function readPortableInferenceDescriptorFromS3( + credential: PortableBootstrapCredential, + runCurl: typeof spawnSync = spawnSync, + now = new Date(), +): Buffer { + const host = `${PORTABLE_INFERENCE_S3_BUCKET}.s3.${PORTABLE_INFERENCE_S3_REGION}.amazonaws.com`; + const canonicalPath = `/${PORTABLE_INFERENCE_S3_KEY.split("/") + .map((part) => encodeURIComponent(part)) + .join("/")}`; + const requestUrl = `https://${host}${canonicalPath}`; + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); + const dateStamp = amzDate.slice(0, 8); + const payloadHash = sha256(""); + const canonicalHeaders = + `host:${host}\n` + `x-amz-content-sha256:${payloadHash}\n` + `x-amz-date:${amzDate}\n`; + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonicalRequest = `GET\n${canonicalPath}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`; + const credentialScope = `${dateStamp}/${PORTABLE_INFERENCE_S3_REGION}/s3/aws4_request`; + const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256(canonicalRequest)}`; + const dateKey = hmac(`AWS4${credential.secretAccessKey}`, dateStamp) as Buffer; + const regionKey = hmac(dateKey, PORTABLE_INFERENCE_S3_REGION) as Buffer; + const serviceKey = hmac(regionKey, "s3") as Buffer; + const signingKey = hmac(serviceKey, "aws4_request") as Buffer; + const signature = hmac(signingKey, stringToSign, "hex") as string; + const authorization = + `AWS4-HMAC-SHA256 Credential=${credential.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`; + const curlConfig = [ + "silent", + "show-error", + "fail", + 'proto = "=https"', + 'request = "GET"', + 'max-time = "10"', + `max-filesize = "${MAX_DESCRIPTOR_BYTES}"`, + `url = "${requestUrl}"`, + `header = "Authorization: ${authorization}"`, + `header = "x-amz-content-sha256: ${payloadHash}"`, + `header = "x-amz-date: ${amzDate}"`, + "", + ].join("\n"); + const result = runCurl("curl", ["--config", "-"], { + input: curlConfig, maxBuffer: MAX_DESCRIPTOR_BYTES + 1, timeout: 10_000, killSignal: "SIGKILL", }); if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) { throw new PortableInferenceSourceError( - result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT" - ? "Portable hosted inference requires the AWS CLI on PATH." - : "Portable hosted inference could not read its credential descriptor.", + "Portable hosted inference could not read its credential descriptor.", ); } if (result.stdout.length === 0 || result.stdout.length > MAX_DESCRIPTOR_BYTES) { @@ -270,33 +314,41 @@ export function parsePortableInferenceDescriptor(raw: Buffer): PortableInference } export function resolvePortableInferenceSource( - env: NodeJS.ProcessEnv, - readObject: PortableInferenceObjectReader = readObjectWithAwsCli, - readActivatedDescriptor: PortableInferenceActivationReader = readPortableInferenceActivationFile, + _env: NodeJS.ProcessEnv, + readBootstrapCredential: PortableInferenceBootstrapReader = readPortableInferenceBootstrapFile, + readObject: PortableInferenceObjectReader = readPortableInferenceDescriptorFromS3, ): PortableInferenceSource | null { - let activatedDescriptor: Buffer | null; + let rawBootstrap: Buffer | null; try { - activatedDescriptor = readActivatedDescriptor(); + rawBootstrap = readBootstrapCredential(); } catch (error) { if (error instanceof PortableInferenceSourceError) throw error; throw new PortableInferenceSourceError( - "Portable hosted inference could not read its activated credential descriptor.", + "Portable hosted inference could not read its bootstrap credential.", ); } - if (activatedDescriptor !== null) { - return parsePortableInferenceDescriptor(activatedDescriptor); - } + if (rawBootstrap === null) return null; - const uri = resolveObjectUri(env); - if (!uri) return null; - let raw: Buffer; + let credential: PortableBootstrapCredential; + try { + credential = parsePortableBootstrapCredential(rawBootstrap); + } finally { + rawBootstrap.fill(0); + } + let descriptor: Buffer; try { - raw = readObject(uri, env); + descriptor = readObject(credential); } catch (error) { if (error instanceof PortableInferenceSourceError) throw error; throw new PortableInferenceSourceError( "Portable hosted inference could not read its credential descriptor.", ); } - return parsePortableInferenceDescriptor(raw); + let source: PortableInferenceSource; + try { + source = parsePortableInferenceDescriptor(descriptor); + } finally { + descriptor.fill(0); + } + return source; } diff --git a/src/lib/onboard/forward-cleanup.test.ts b/src/lib/onboard/forward-cleanup.test.ts index c3ffc220080..e79cdbdce9a 100644 --- a/src/lib/onboard/forward-cleanup.test.ts +++ b/src/lib/onboard/forward-cleanup.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi } from "vitest"; -import { bestEffortForwardStop, bestEffortForwardStopForSandbox } from "./forward-cleanup"; +import { + bestEffortForwardStop, + bestEffortForwardStopForSandbox, + stopForwardForSandboxOrThrow, +} from "./forward-cleanup"; function forwardListWith( entries: Array<{ sandbox: string; port: number; status?: string }>, @@ -112,3 +116,29 @@ describe("bestEffortForwardStopForSandbox", () => { expect(run).toHaveBeenCalledTimes(1); }); }); + +describe("stopForwardForSandboxOrThrow", () => { + it("returns only after the selected sandbox forward is absent", () => { + const run = vi.fn(); + const fetch = vi + .fn() + .mockReturnValueOnce(forwardListWith([{ sandbox: "my-sandbox", port: 18789 }])) + .mockReturnValueOnce(forwardListWith([])); + + expect(() => stopForwardForSandboxOrThrow(run, fetch, 18789, "my-sandbox")).not.toThrow(); + expect(run).toHaveBeenCalledWith(["forward", "stop", "18789", "my-sandbox"], { + ignoreError: true, + suppressOutput: true, + }); + }); + + it("throws when the selected sandbox forward remains active", () => { + const run = vi.fn(); + const active = forwardListWith([{ sandbox: "my-sandbox", port: 18789 }]); + const fetch = vi.fn().mockReturnValue(active); + + expect(() => stopForwardForSandboxOrThrow(run, fetch, 18789, "my-sandbox")).toThrow( + "Could not stop dashboard forward", + ); + }); +}); diff --git a/src/lib/onboard/forward-cleanup.ts b/src/lib/onboard/forward-cleanup.ts index 5ccffe71f5d..a0b5220d79c 100644 --- a/src/lib/onboard/forward-cleanup.ts +++ b/src/lib/onboard/forward-cleanup.ts @@ -87,3 +87,31 @@ export function bestEffortForwardStopForSandbox( }); return owner === sandboxName ? "stopped" : "no-entry"; } + +export function stopForwardForSandboxOrThrow( + runOpenshell: ForwardStopRunner, + runCaptureOpenshell: ForwardListRunner, + port: string | number, + sandboxName: string, +): void { + const readOwner = (): string | null => { + const output = runCaptureOpenshell(["forward", "list"], { + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + return getOccupiedPorts(output).get(String(port)) ?? null; + }; + const owner = readOwner(); + if (owner === null) return; + if (owner !== sandboxName) { + throw new Error( + `Cannot stop dashboard forward ${String(port)} for '${sandboxName}': it belongs to '${owner}'.`, + ); + } + runOpenshell(["forward", "stop", String(port), sandboxName], { + ignoreError: true, + suppressOutput: true, + }); + if (readOwner() === sandboxName) { + throw new Error(`Could not stop dashboard forward ${String(port)} for '${sandboxName}'.`); + } +} diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index bad8dc2d81e..1bc1fd82803 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -40,6 +40,7 @@ import { } from "../inference/endpoint-ssrf-preflight"; import { shouldForceCompletionsApi } from "../validation"; import { getProbeRecovery } from "../validation-recovery"; +import { isPortableExperimentalProfile } from "./docker-driver-platform"; import { summarizeProbeForDisplay } from "./probe-diagnostics"; import { normalizeReasoningFlag } from "./reasoning-mode"; @@ -65,6 +66,8 @@ export interface InferenceSelectionValidationDeps { resolveEndpointHost?: EndpointDnsLookupFn; /** Exact private endpoint hosts trusted by the operator (tests may inject this). */ trustedPrivateEndpointHosts?: readonly string[]; + /** True when the selected flow can intentionally use a host loopback endpoint. */ + allowExplicitLoopback?: () => boolean; promptValidationRecovery( label: string, recovery: ReturnType, @@ -183,6 +186,7 @@ export function createInferenceSelectionValidationHelpers( // pinning, or fail-closed resolver handling (#6861). const preflight = await assertEndpointResolvesPublic(endpointUrl, deps.resolveEndpointHost, { trustedPrivateHosts: trustedPrivateEndpointHosts, + allowExplicitLoopback: deps.allowExplicitLoopback?.() ?? !isPortableExperimentalProfile(), }); // On success, carry the validated address set forward so the probe pins its // connection (curl --resolve) to a checked address; a second DNS lookup at diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts index ef465fe7a6c..d51cc747eeb 100644 --- a/src/lib/onboard/sandbox-reuse.test.ts +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SandboxEntry } from "../state/registry"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; import { fingerprintSandboxRecreateValue } from "./sandbox-recreate-transaction"; import { applyReusedSandboxDashboardState, createSandboxReuseHelpers } from "./sandbox-reuse"; @@ -149,6 +150,7 @@ describe("applyReusedSandboxDashboardState", () => { resolveStateForPort: vi.fn(), ensureForState: vi.fn(), }; + const stopDashboardForward = vi.fn(); const updateReusedSandboxMetadata = vi.fn(); const result = applyReusedSandboxDashboardState({ @@ -163,12 +165,17 @@ describe("applyReusedSandboxDashboardState", () => { gatewayName: "nemoclaw", gatewayPort: 8080, manageDashboard: false, + getSandbox: vi.fn( + () => ({ dashboardForwardEnabled: true, dashboardPort: 18789 }) as SandboxEntry, + ), + stopDashboardForward, ensureDashboardForward, hermesDashboardForwarding, updateSandbox, updateReusedSandboxMetadata, }); + expect(stopDashboardForward).toHaveBeenCalledWith("terminal-box", 18789); expect(ensureDashboardForward).not.toHaveBeenCalled(); expect(hermesDashboardForwarding.resolveStateForPort).not.toHaveBeenCalled(); expect(hermesDashboardForwarding.ensureForState).not.toHaveBeenCalled(); @@ -191,12 +198,59 @@ describe("applyReusedSandboxDashboardState", () => { gatewayName: "nemoclaw", gatewayPort: 8080, }); + expect(stopDashboardForward.mock.invocationCallOrder[0]).toBeLessThan( + updateSandbox.mock.invocationCallOrder[0], + ); expect(result).toEqual({ chatUiUrl: "", dashboardPort: 0, hermesDashboardState: { enabled: false, config: null }, }); }); + + it("keeps forwarding enabled when a reused sandbox forward cannot stop", () => { + const updateSandbox = vi.fn(); + const updateReusedSandboxMetadata = vi.fn(); + const sandboxGpuConfig: SandboxGpuConfig = { + hostGpuDetected: false, + hostGpuPlatform: null, + sandboxGpuEnabled: false, + mode: "auto", + sandboxGpuDevice: null, + errors: [], + }; + + expect(() => + applyReusedSandboxDashboardState({ + sandboxName: "terminal-box", + chatUiUrl: "", + env: {}, + agent: { name: "langchain-deepagents-code" } as any, + model: "test-model", + provider: "nvidia-prod", + selectionVerified: true, + sandboxGpuConfig, + gatewayName: "nemoclaw", + gatewayPort: 8080, + manageDashboard: false, + getSandbox: vi.fn( + () => ({ dashboardForwardEnabled: true, dashboardPort: 18789 }) as SandboxEntry, + ), + stopDashboardForward: vi.fn(() => { + throw new Error("forward remained active"); + }), + ensureDashboardForward: vi.fn(), + hermesDashboardForwarding: { + resolveStateForPort: vi.fn(), + ensureForState: vi.fn(), + }, + updateSandbox, + updateReusedSandboxMetadata, + }), + ).toThrow("forward remained active"); + expect(updateSandbox).not.toHaveBeenCalled(); + expect(updateReusedSandboxMetadata).not.toHaveBeenCalled(); + }); }); describe("createSandboxReuseHelpers", () => { diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index f9628b83ded..79c4c048b44 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -47,6 +47,7 @@ export interface ReusedSandboxDashboardStateInput { gatewayPort: number; manageDashboard?: boolean; getSandbox?(sandboxName: string): SandboxEntry | null; + stopDashboardForward?(sandboxName: string, dashboardPort: number): void; ensureDashboardForward(sandboxName: string, chatUiUrl: string): number; hermesDashboardForwarding: ReusedSandboxDashboardForwarding; updateSandbox?(sandboxName: string, updates: Partial): unknown; @@ -81,6 +82,22 @@ export function applyReusedSandboxDashboardState( `Sandbox '${input.sandboxName}' was created without remote dashboard exposure. Re-run onboarding with NEMOCLAW_DASHBOARD_BIND=0.0.0.0 and --recreate-sandbox before opening a remote bind.`, ); } + if (!manageDashboard) { + const previous = (input.getSandbox ?? registry.getSandbox)(input.sandboxName); + if (previous?.dashboardForwardEnabled === true) { + if (!Number.isInteger(previous.dashboardPort) || Number(previous.dashboardPort) <= 0) { + throw new Error( + `Cannot disable dashboard forwarding for '${input.sandboxName}': the recorded port is invalid.`, + ); + } + if (!input.stopDashboardForward) { + throw new Error( + `Cannot disable dashboard forwarding for '${input.sandboxName}': no forward stop handler is available.`, + ); + } + input.stopDashboardForward(input.sandboxName, Number(previous.dashboardPort)); + } + } const dashboardPort = manageDashboard ? input.ensureDashboardForward(input.sandboxName, input.chatUiUrl) : 0; diff --git a/src/lib/security/trusted-private-endpoint.ts b/src/lib/security/trusted-private-endpoint.ts index 2db7b63c970..543166248ed 100644 --- a/src/lib/security/trusted-private-endpoint.ts +++ b/src/lib/security/trusted-private-endpoint.ts @@ -117,6 +117,8 @@ export interface EndpointSsrfPreflightResult { export interface EndpointSsrfPreflightOptions { /** Exact hostnames or IP literals the operator explicitly trusts on a private network. */ trustedPrivateHosts?: readonly string[]; + /** Keep false for hosted-only flows that must never connect to a host loopback service. */ + allowExplicitLoopback?: boolean; } function normalizeIpLiteral(address: string): string { @@ -231,10 +233,9 @@ export function replayTrustedPrivateEndpoint( * which runs later, before the URL is persisted. * * Loopback (`127.0.0.0/8`, `::1`, and `localhost`) remains exempt only when the - * endpoint hostname is itself loopback. This preserves local inference - * behavior. A public name that resolves to loopback is treated as a rebinding - * attempt and rejected. The check fails closed on a resolver error or empty - * result. + * endpoint hostname is itself loopback and the caller permits local inference. + * A public name that resolves to loopback is treated as a rebinding attempt and + * rejected. The check fails closed on a resolver error or empty result. * * See PR #6293 PRA-4 (GPT-5.5 advisor). */ @@ -271,8 +272,16 @@ export async function assertEndpointResolvesPublic( } const trustedPrivateHost = trustedPrivateHosts.includes(normalizedHostname); - // An explicit loopback host is a legitimate local inference server. - if (isLoopbackHostname(hostname)) return { ok: true, addresses: [] }; + // An explicit loopback host is valid only for flows that can select local inference. + if (isLoopbackHostname(hostname)) { + return options.allowExplicitLoopback !== false + ? { ok: true, addresses: [] } + : { + ok: false, + reason: `endpoint host "${hostname}" is a private/internal address`, + reasonCode: "rejected", + }; + } // NemoClaw's own OpenShell-managed aliases (inference.local, host.*.internal) // resolve to the managed proxy/loopback by design and are trusted, not From a2deec55bb0ee18eb4e478cad71bc296ccad9178 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 12:42:00 -0700 Subject: [PATCH 07/42] fix(onboard): apply personal policy for portable profile Signed-off-by: Aaron Erickson --- src/lib/onboard/command.test.ts | 2 +- src/lib/onboard/command.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index afe8c75e2fa..7c18c8042b6 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -476,7 +476,7 @@ describe("onboard command options", () => { NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "skip", + NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index c8d87f75cb8..86d031aca03 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,7 +403,7 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "skip", + NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, From b62b3be593542733103a6dfed642eb934464f326 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 12:56:55 -0700 Subject: [PATCH 08/42] fix(onboard): bound portable personal policy Signed-off-by: Aaron Erickson --- src/lib/onboard/command.test.ts | 6 +++++- src/lib/onboard/command.ts | 3 ++- test/policy-tiers-onboard.test.ts | 13 +++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 7c18c8042b6..db9f99bce64 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -444,6 +444,7 @@ describe("onboard command options", () => { NEMOCLAW_MODEL: "previous-model", NEMOCLAW_OLLAMA_NO_AUTOSTART: "0", NEMOCLAW_POLICY_MODE: "previous-mode", + NEMOCLAW_POLICY_PRESETS: "previous-preset", NEMOCLAW_POLICY_TIER: "previous-tier", NEMOCLAW_TOOL_DISCLOSURE: "progressive", }; @@ -463,6 +464,7 @@ describe("onboard command options", () => { "NEMOCLAW_MODEL", "NEMOCLAW_OLLAMA_NO_AUTOSTART", "NEMOCLAW_POLICY_MODE", + "NEMOCLAW_POLICY_PRESETS", "NEMOCLAW_POLICY_TIER", "NEMOCLAW_TOOL_DISCLOSURE", ]) { @@ -476,7 +478,8 @@ describe("onboard command options", () => { NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "custom", + NEMOCLAW_POLICY_PRESETS: "personal-open-internet", NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); @@ -486,6 +489,7 @@ describe("onboard command options", () => { NEMOCLAW_MODEL: "previous-model", NEMOCLAW_OLLAMA_NO_AUTOSTART: "0", NEMOCLAW_POLICY_MODE: "previous-mode", + NEMOCLAW_POLICY_PRESETS: "previous-preset", NEMOCLAW_POLICY_TIER: "previous-tier", NEMOCLAW_TOOL_DISCLOSURE: "progressive", }); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 86d031aca03..ba12013309e 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,7 +403,8 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "custom", + NEMOCLAW_POLICY_PRESETS: "personal-open-internet", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index eeec02aff90..3c5d8ac79ce 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -446,6 +446,19 @@ describe("policy tier setup", () => { assert.deepEqual(result.applied, []); }); + it("limits an explicit Personal selection to the portable open-internet preset", async () => { + const result = await runPolicySetup( + { + tierName: "personal", + policyMode: "custom", + policyPresets: "personal-open-internet", + }, + { agent: "openclaw", webSearchConfig: null, webSearchSupported: true }, + ); + + assert.deepEqual(result.applied, ["personal-open-internet"]); + }); + it("keeps OpenClaw web search and OpenClaw-only presets in Personal", async () => { const result = await runPolicySetup( { tierName: "personal" }, From 87038567ce350f8c22a7531b3a5199b092b0ae8b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 12:59:08 -0700 Subject: [PATCH 09/42] Revert "fix(onboard): bound portable personal policy" This reverts commit b62b3be593542733103a6dfed642eb934464f326. --- src/lib/onboard/command.test.ts | 6 +----- src/lib/onboard/command.ts | 3 +-- test/policy-tiers-onboard.test.ts | 13 ------------- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index db9f99bce64..7c18c8042b6 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -444,7 +444,6 @@ describe("onboard command options", () => { NEMOCLAW_MODEL: "previous-model", NEMOCLAW_OLLAMA_NO_AUTOSTART: "0", NEMOCLAW_POLICY_MODE: "previous-mode", - NEMOCLAW_POLICY_PRESETS: "previous-preset", NEMOCLAW_POLICY_TIER: "previous-tier", NEMOCLAW_TOOL_DISCLOSURE: "progressive", }; @@ -464,7 +463,6 @@ describe("onboard command options", () => { "NEMOCLAW_MODEL", "NEMOCLAW_OLLAMA_NO_AUTOSTART", "NEMOCLAW_POLICY_MODE", - "NEMOCLAW_POLICY_PRESETS", "NEMOCLAW_POLICY_TIER", "NEMOCLAW_TOOL_DISCLOSURE", ]) { @@ -478,8 +476,7 @@ describe("onboard command options", () => { NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "custom", - NEMOCLAW_POLICY_PRESETS: "personal-open-internet", + NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); @@ -489,7 +486,6 @@ describe("onboard command options", () => { NEMOCLAW_MODEL: "previous-model", NEMOCLAW_OLLAMA_NO_AUTOSTART: "0", NEMOCLAW_POLICY_MODE: "previous-mode", - NEMOCLAW_POLICY_PRESETS: "previous-preset", NEMOCLAW_POLICY_TIER: "previous-tier", NEMOCLAW_TOOL_DISCLOSURE: "progressive", }); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index ba12013309e..86d031aca03 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,8 +403,7 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "custom", - NEMOCLAW_POLICY_PRESETS: "personal-open-internet", + NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 3c5d8ac79ce..eeec02aff90 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -446,19 +446,6 @@ describe("policy tier setup", () => { assert.deepEqual(result.applied, []); }); - it("limits an explicit Personal selection to the portable open-internet preset", async () => { - const result = await runPolicySetup( - { - tierName: "personal", - policyMode: "custom", - policyPresets: "personal-open-internet", - }, - { agent: "openclaw", webSearchConfig: null, webSearchSupported: true }, - ); - - assert.deepEqual(result.applied, ["personal-open-internet"]); - }); - it("keeps OpenClaw web search and OpenClaw-only presets in Personal", async () => { const result = await runPolicySetup( { tierName: "personal" }, From 8fcfb17c51ae31037bd3231a66a77f8ddb49818f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 13:09:38 -0700 Subject: [PATCH 10/42] fix(policy): recover from policy-set transport resets Signed-off-by: Aaron Erickson --- src/lib/policy/index.ts | 58 +++++++++++++- test/policies.test.ts | 164 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 8f238b1d389..44fcfa338d9 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -27,7 +27,7 @@ import { import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; -import { ROOT, run, runCapture } from "../runner"; +import { ROOT, redact, run, runCapture, runCaptureEx } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import * as registry from "../state/registry"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; @@ -641,6 +641,60 @@ function policyDocumentsMatch(left: string, right: string): boolean { } } +function isPolicySetH2TransportReset(stdout: string, stderr: string): boolean { + const diagnostic = `${stdout}\n${stderr}`; + return /h2 protocol error/i.test(diagnostic) && /PROTOCOL_ERROR/i.test(diagnostic); +} + +function activePolicyMatches(sandboxName: string, expectedPolicy: string): boolean { + const result = runCaptureEx(buildPolicyGetCommand(sandboxName)); + if (result.exitCode !== 0) return false; + const activePolicy = parseCurrentPolicyOrEmpty(result.stdout); + return Boolean(activePolicy) && policyDocumentsMatch(activePolicy, expectedPolicy); +} + +/** + * OpenShell can commit a policy update and then lose the HTTP/2 response while + * `policy set --wait` is polling the sandbox. Reconcile against a fresh + * effective-policy read before retrying the same idempotent update. This keeps + * the requested policy intact; it does not fall back to a smaller policy. + */ +function setPolicyFileWithH2Recovery( + policyFile: string, + sandboxName: string, + expectedPolicy: string, +): boolean { + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = runCaptureEx(buildPolicySetCommand(policyFile, sandboxName)); + if (result.exitCode === 0) return true; + + if (!isPolicySetH2TransportReset(result.stdout, result.stderr || "")) { + const detail = redact([result.stdout, result.stderr].filter(Boolean).join("\n")).trim(); + console.error(` Failed to update policy for sandbox '${sandboxName}'.`); + if (detail) console.error(` ${detail}`); + return false; + } + + if (activePolicyMatches(sandboxName, expectedPolicy)) { + console.warn( + " OpenShell lost the policy-set response, but a fresh read verified the complete policy is active.", + ); + return true; + } + + if (attempt === 0) { + console.warn( + " OpenShell reset the policy-set HTTP/2 stream before the policy became active; retrying once.", + ); + } + } + + console.error( + ` OpenShell reset the policy-set stream twice and the complete policy is not active for sandbox '${sandboxName}'.`, + ); + return false; +} + function logPresetNoNewEgress( presetName: string, logger: (line: string) => void = console.log, @@ -2250,7 +2304,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); try { - run(buildPolicySetCommand(tmpFile, sandboxName)); + if (!setPolicyFileWithH2Recovery(tmpFile, sandboxName, merged)) return false; for (const preset of presetContents.filter((entry) => entry.state !== "match")) { console.log(` Applied preset: ${preset.name}`); diff --git a/test/policies.test.ts b/test/policies.test.ts index cea7bd9b7d3..55f747eca1c 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -212,6 +212,170 @@ exit 1 } }); + it("continues when an h2 reset loses the response after the full policy becomes active", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-h2-committed-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const callsPath = path.join(tmpDir, "calls.log"); + const activePolicyPath = path.join(tmpDir, "active-policy.yaml"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "test-sandbox", policies: [] }); +const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), + registry: registry.getSandbox("test-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\nStatus: Effective\n---\n' + if [ -f ${JSON.stringify(activePolicyPath)} ]; then + cat ${JSON.stringify(activePolicyPath)} + else + printf 'version: 1\n\nnetwork_policies: {}\n' + fi + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + cp "$policy_file" ${JSON.stringify(activePolicyPath)} + printf '%s\n' 'Error: code: Internal error, message: h2 protocol error: http2 error; Reset(StreamId(3), PROTOCOL_ERROR, Library)' >&2 + exit 1 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + CALLS_PATH: callsPath, + }, + }); + + expect(result.status).toBe(0); + const payload = parseResultPayload(result.stdout); + expect(payload.result).toBe(true); + expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength( + 1, + ); + expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength( + 2, + ); + expect(payload.registry.policies).toEqual(["npm", "pypi"]); + expect(result.stderr).toContain("fresh read verified the complete policy is active"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("retries the unchanged full policy once after an h2 reset before it becomes active", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-h2-retry-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const callsPath = path.join(tmpDir, "calls.log"); + const activePolicyPath = path.join(tmpDir, "active-policy.yaml"); + const setCountPath = path.join(tmpDir, "set-count"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "test-sandbox", policies: [] }); +const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), + registry: registry.getSandbox("test-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\nStatus: Effective\n---\n' + if [ -f ${JSON.stringify(activePolicyPath)} ]; then + cat ${JSON.stringify(activePolicyPath)} + else + printf 'version: 1\n\nnetwork_policies: {}\n' + fi + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + count=0 + if [ -f ${JSON.stringify(setCountPath)} ]; then + count=$(cat ${JSON.stringify(setCountPath)}) + fi + count=$((count + 1)) + printf '%s' "$count" > ${JSON.stringify(setCountPath)} + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + if [ "$count" -eq 2 ]; then + cp "$policy_file" ${JSON.stringify(activePolicyPath)} + fi + printf '%s\n' 'Error: code: Internal error, message: h2 protocol error: http2 error; Reset(StreamId(3), PROTOCOL_ERROR, Library)' >&2 + exit 1 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + CALLS_PATH: callsPath, + }, + }); + + expect(result.status).toBe(0); + const payload = parseResultPayload(result.stdout); + expect(payload.result).toBe(true); + expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength( + 2, + ); + expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength( + 3, + ); + expect(payload.registry.policies).toEqual(["npm", "pypi"]); + expect(result.stderr).toContain("retrying once"); + expect(result.stderr).toContain("fresh read verified the complete policy is active"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("uses agent-specific preset content for Hermes Discord", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-hermes-")); const fakeOpenshell = path.join(tmpDir, "openshell"); From bb2f814df2862d79b1e6357e6147c467fe3b5526 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 13:10:18 -0700 Subject: [PATCH 11/42] chore: keep transport recovery production-only Signed-off-by: Aaron Erickson --- test/policies.test.ts | 164 ------------------------------------------ 1 file changed, 164 deletions(-) diff --git a/test/policies.test.ts b/test/policies.test.ts index 55f747eca1c..cea7bd9b7d3 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -212,170 +212,6 @@ exit 1 } }); - it("continues when an h2 reset loses the response after the full policy becomes active", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-h2-committed-")); - const fakeOpenshell = path.join(tmpDir, "openshell"); - const callsPath = path.join(tmpDir, "calls.log"); - const activePolicyPath = path.join(tmpDir, "active-policy.yaml"); - const script = String.raw` -const fs = require("node:fs"); -const registry = require(${REGISTRY_PATH}); -const policies = require(${POLICIES_PATH}); -registry.registerSandbox({ name: "test-sandbox", policies: [] }); -const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); -process.stdout.write("\n__RESULT__" + JSON.stringify({ - result, - calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), - registry: registry.getSandbox("test-sandbox"), -})); -`; - fs.writeFileSync( - fakeOpenshell, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} -if [ "$1 $2" = "policy get" ]; then - printf 'Version: 1\nHash: test\nStatus: Effective\n---\n' - if [ -f ${JSON.stringify(activePolicyPath)} ]; then - cat ${JSON.stringify(activePolicyPath)} - else - printf 'version: 1\n\nnetwork_policies: {}\n' - fi - exit 0 -fi -if [ "$1 $2" = "policy set" ]; then - policy_file="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--policy" ]; then - policy_file="$2" - break - fi - shift - done - cp "$policy_file" ${JSON.stringify(activePolicyPath)} - printf '%s\n' 'Error: code: Internal error, message: h2 protocol error: http2 error; Reset(StreamId(3), PROTOCOL_ERROR, Library)' >&2 - exit 1 -fi -exit 1 -`, - { mode: 0o755 }, - ); - - try { - const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { - cwd: REPO_ROOT, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, - CALLS_PATH: callsPath, - }, - }); - - expect(result.status).toBe(0); - const payload = parseResultPayload(result.stdout); - expect(payload.result).toBe(true); - expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength( - 1, - ); - expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength( - 2, - ); - expect(payload.registry.policies).toEqual(["npm", "pypi"]); - expect(result.stderr).toContain("fresh read verified the complete policy is active"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("retries the unchanged full policy once after an h2 reset before it becomes active", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-h2-retry-")); - const fakeOpenshell = path.join(tmpDir, "openshell"); - const callsPath = path.join(tmpDir, "calls.log"); - const activePolicyPath = path.join(tmpDir, "active-policy.yaml"); - const setCountPath = path.join(tmpDir, "set-count"); - const script = String.raw` -const fs = require("node:fs"); -const registry = require(${REGISTRY_PATH}); -const policies = require(${POLICIES_PATH}); -registry.registerSandbox({ name: "test-sandbox", policies: [] }); -const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); -process.stdout.write("\n__RESULT__" + JSON.stringify({ - result, - calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), - registry: registry.getSandbox("test-sandbox"), -})); -`; - fs.writeFileSync( - fakeOpenshell, - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} -if [ "$1 $2" = "policy get" ]; then - printf 'Version: 1\nHash: test\nStatus: Effective\n---\n' - if [ -f ${JSON.stringify(activePolicyPath)} ]; then - cat ${JSON.stringify(activePolicyPath)} - else - printf 'version: 1\n\nnetwork_policies: {}\n' - fi - exit 0 -fi -if [ "$1 $2" = "policy set" ]; then - count=0 - if [ -f ${JSON.stringify(setCountPath)} ]; then - count=$(cat ${JSON.stringify(setCountPath)}) - fi - count=$((count + 1)) - printf '%s' "$count" > ${JSON.stringify(setCountPath)} - policy_file="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--policy" ]; then - policy_file="$2" - break - fi - shift - done - if [ "$count" -eq 2 ]; then - cp "$policy_file" ${JSON.stringify(activePolicyPath)} - fi - printf '%s\n' 'Error: code: Internal error, message: h2 protocol error: http2 error; Reset(StreamId(3), PROTOCOL_ERROR, Library)' >&2 - exit 1 -fi -exit 1 -`, - { mode: 0o755 }, - ); - - try { - const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { - cwd: REPO_ROOT, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, - CALLS_PATH: callsPath, - }, - }); - - expect(result.status).toBe(0); - const payload = parseResultPayload(result.stdout); - expect(payload.result).toBe(true); - expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength( - 2, - ); - expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength( - 3, - ); - expect(payload.registry.policies).toEqual(["npm", "pypi"]); - expect(result.stderr).toContain("retrying once"); - expect(result.stderr).toContain("fresh read verified the complete policy is active"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("uses agent-specific preset content for Hermes Discord", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-hermes-")); const fakeOpenshell = path.join(tmpDir, "openshell"); From 9d95523305267f2a3181c2f807bd242e461a8481 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 13:15:36 -0700 Subject: [PATCH 12/42] fix(onboard): use basic portable network policy Signed-off-by: Aaron Erickson --- src/lib/onboard/command.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 86d031aca03..ba12013309e 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,7 +403,8 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "custom", + NEMOCLAW_POLICY_PRESETS: "personal-open-internet", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, From 268d391e860aea72e1da60512a9c984f50808a93 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 13:18:38 -0700 Subject: [PATCH 13/42] fix(onboard): install compact portable policy set Signed-off-by: Aaron Erickson --- src/lib/onboard/command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index ba12013309e..f9c33864718 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -404,7 +404,7 @@ function applyPortableEnvironment( NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", NEMOCLAW_POLICY_MODE: "custom", - NEMOCLAW_POLICY_PRESETS: "personal-open-internet", + NEMOCLAW_POLICY_PRESETS: "personal-open-internet,weather,public-reference,github", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, From beb7b2bea4ee46df057c76e9970ff3cb2218e6b0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 13:22:58 -0700 Subject: [PATCH 14/42] fix(onboard): use balanced portable policy tier Signed-off-by: Aaron Erickson --- src/lib/onboard/command.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index f9c33864718..19f4779508a 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,9 +403,8 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "custom", - NEMOCLAW_POLICY_PRESETS: "personal-open-internet,weather,public-reference,github", - NEMOCLAW_POLICY_TIER: "personal", + NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_TIER: "balanced", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, NEMOCLAW_PREFERRED_API: "openai-completions", From cc6b7c16313125c01a9e5acedf5c41187c911bb9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 15:43:37 -0700 Subject: [PATCH 15/42] fix(policy): compose personal tier for openshell 0.0.99 --- src/lib/onboard/command.ts | 2 +- src/lib/policy/index.ts | 100 ++++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 19f4779508a..86d031aca03 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -404,7 +404,7 @@ function applyPortableEnvironment( NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", NEMOCLAW_POLICY_MODE: "suggested", - NEMOCLAW_POLICY_TIER: "balanced", + NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, NEMOCLAW_PREFERRED_API: "openai-completions", diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 44fcfa338d9..f818496af86 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -27,7 +27,7 @@ import { import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; -import { ROOT, redact, run, runCapture, runCaptureEx } from "../runner"; +import { ROOT, run, runCapture } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import * as registry from "../state/registry"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; @@ -641,58 +641,62 @@ function policyDocumentsMatch(left: string, right: string): boolean { } } -function isPolicySetH2TransportReset(stdout: string, stderr: string): boolean { - const diagnostic = `${stdout}\n${stderr}`; - return /h2 protocol error/i.test(diagnostic) && /PROTOCOL_ERROR/i.test(diagnostic); -} +const PERSONAL_OPEN_INTERNET_POLICY_KEY = "personal_open_internet"; +const PERSONAL_OPEN_INTERNET_PORTS = new Set([80, 443]); -function activePolicyMatches(sandboxName: string, expectedPolicy: string): boolean { - const result = runCaptureEx(buildPolicyGetCommand(sandboxName)); - if (result.exitCode !== 0) return false; - const activePolicy = parseCurrentPolicyOrEmpty(result.stdout); - return Boolean(activePolicy) && policyDocumentsMatch(activePolicy, expectedPolicy); +function endpointOverlapsPersonalOpenInternet(endpoint: PolicyValue): boolean { + if (!isPolicyObject(endpoint)) return false; + const ports = Array.isArray(endpoint.ports) ? endpoint.ports : [endpoint.port]; + return ports.some((port) => { + const numericPort = typeof port === "number" ? port : Number(port); + return Number.isInteger(numericPort) && PERSONAL_OPEN_INTERNET_PORTS.has(numericPort); + }); } /** - * OpenShell can commit a policy update and then lose the HTTP/2 response while - * `policy set --wait` is polling the sandbox. Reconcile against a fresh - * effective-policy read before retrying the same idempotent update. This keeps - * the requested policy intact; it does not fall back to a smaller policy. + * OpenShell 0.0.99 rejects a hostless endpoint when a named endpoint selects + * the same port with different L4/L7 metadata. Personal's hostless 80/443 + * route is the authoritative selector for those ports, so retain it and only + * the non-overlapping endpoints from the baseline and other selected presets. */ -function setPolicyFileWithH2Recovery( - policyFile: string, - sandboxName: string, - expectedPolicy: string, -): boolean { - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = runCaptureEx(buildPolicySetCommand(policyFile, sandboxName)); - if (result.exitCode === 0) return true; - - if (!isPolicySetH2TransportReset(result.stdout, result.stderr || "")) { - const detail = redact([result.stdout, result.stderr].filter(Boolean).join("\n")).trim(); - console.error(` Failed to update policy for sandbox '${sandboxName}'.`); - if (detail) console.error(` ${detail}`); - return false; - } +function makePersonalOpenInternetAuthoritative(policy: string): string { + let document: PolicyDocument | null = null; + try { + const parsed = YAML.parse(policy); + document = isPolicyDocument(parsed) ? parsed : null; + } catch { + document = null; + } + if (!document || !isPresetPolicyMap(document.network_policies)) { + throw new Error( + "Cannot compose Personal policy: the merged policy is not a valid network policy mapping.", + ); + } - if (activePolicyMatches(sandboxName, expectedPolicy)) { - console.warn( - " OpenShell lost the policy-set response, but a fresh read verified the complete policy is active.", - ); - return true; - } + const networkPolicies = document.network_policies; + if (!Object.hasOwn(networkPolicies, PERSONAL_OPEN_INTERNET_POLICY_KEY)) return policy; + if (!isPolicyObject(networkPolicies[PERSONAL_OPEN_INTERNET_POLICY_KEY])) { + throw new Error("Cannot compose Personal policy: its open-internet entry is malformed."); + } - if (attempt === 0) { - console.warn( - " OpenShell reset the policy-set HTTP/2 stream before the policy became active; retrying once.", - ); + const compatiblePolicies: PolicyObject = {}; + for (const [key, value] of Object.entries(networkPolicies)) { + if (key === PERSONAL_OPEN_INTERNET_POLICY_KEY) { + compatiblePolicies[key] = value; + continue; } + if (!isPolicyObject(value) || !Array.isArray(value.endpoints)) { + compatiblePolicies[key] = value; + continue; + } + const endpoints = value.endpoints.filter( + (endpoint) => !endpointOverlapsPersonalOpenInternet(endpoint), + ); + if (endpoints.length > 0) compatiblePolicies[key] = { ...value, endpoints }; } - console.error( - ` OpenShell reset the policy-set stream twice and the complete policy is not active for sandbox '${sandboxName}'.`, - ); - return false; + document.network_policies = compatiblePolicies; + return YAML.stringify(document); } function logPresetNoNewEgress( @@ -1010,7 +1014,11 @@ function mergePresetNamesIntoPolicy( policyHasNetworkPolicy(currentPolicy, OPENCLAW_NPM_PRESET_KEY), ).policy; } - return { policy, appliedPresets, missingPresets }; + return { + policy: makePersonalOpenInternetAuthoritative(policy), + appliedPresets, + missingPresets, + }; } /** @@ -2077,6 +2085,7 @@ function applyPresetContent( return false; } } + merged = makePersonalOpenInternetAuthoritative(merged); const presetState = classifyPresetEntries(currentPolicy, presetEntries); const disclosedPresetState = @@ -2280,6 +2289,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { return false; } } + merged = makePersonalOpenInternetAuthoritative(merged); for (const preset of presetContents) { const disclosedPresetState = @@ -2304,7 +2314,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); try { - if (!setPolicyFileWithH2Recovery(tmpFile, sandboxName, merged)) return false; + run(buildPolicySetCommand(tmpFile, sandboxName)); for (const preset of presetContents.filter((entry) => entry.state !== "match")) { console.log(` Applied preset: ${preset.name}`); From 4c3fac446c0ba1ccb39d53ebda4d1c81c23f5eb4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 16:10:07 -0700 Subject: [PATCH 16/42] chore(deps): upgrade openshell to 0.0.101 --- .github/workflows/e2e.yaml | 26 +++++----- agents/hermes/Dockerfile | 6 +-- agents/hermes/mcp-config-transaction.py | 10 ++-- nemoclaw-blueprint/blueprint.yaml | 4 +- nemoclaw-blueprint/policies/presets/brew.yaml | 2 +- .../src/shared/openshell-policy-boundary.cts | 2 +- nemoclaw/src/shared/sandbox-name.cts | 4 +- scripts/brev-launchable-ci-cpu.sh | 10 ++-- scripts/check-installer-hash.sh | 3 ++ ...anaged-image-protected-runtime-contract.ts | 2 +- scripts/install-openshell.sh | 47 ++++++++++--------- scripts/install.sh | 8 ++-- scripts/update-hermes-agent.sh | 2 +- .../sandbox/mcp-bridge-url-validation.ts | 10 ++-- .../actions/sandbox/mcp-bridge-validation.ts | 2 +- ...l-child-visible-credentials.v0.0.101.json} | 4 +- src/lib/deploy/index.ts | 2 +- .../onboard/docker-driver-gateway-runtime.ts | 1 + src/lib/onboard/forward-start.ts | 4 +- src/lib/onboard/openshell-feature-gate.ts | 3 ++ src/lib/onboard/openshell-install.ts | 2 +- src/lib/onboard/openshell-version.ts | 2 +- src/lib/policy/index.ts | 4 +- tools/e2e/mcp-workflow-boundary.mts | 12 ++--- tools/pr-review-advisor/workflow-boundary.mts | 2 +- 25 files changed, 93 insertions(+), 81 deletions(-) rename src/lib/actions/sandbox/{openshell-child-visible-credentials.v0.0.99.json => openshell-child-visible-credentials.v0.0.101.json} (95%) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 7c6e2007bcc..17f858b4c02 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1002,7 +1002,7 @@ jobs: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openshell-gateway-auth-contract NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.99" + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.101" DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1087,7 +1087,7 @@ jobs: NEMOCLAW_OPENSHELL_CHANNEL: stable NEMOCLAW_OPENSHELL_EXACT_MAIN_PROOF: "1" NEMOCLAW_RUN_LIVE_E2E: "1" - OPENSHELL_DOCKER_SUPERVISOR_IMAGE: ghcr.io/nvidia/openshell/supervisor@sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6 + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: ghcr.io/nvidia/openshell/supervisor@sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb steps: - id: trusted_hermes_swap name: Provision trusted Hermes E2E swap @@ -1340,12 +1340,12 @@ jobs: jq -n '{ schemaVersion: 1, sourceRepository: "NVIDIA/OpenShell", - releaseTag: "v0.0.99", - sourceSha: "8c7dd148a9e6360c9d5b2830e339a0dc4b3f3032", + releaseTag: "v0.0.101", + sourceSha: "8ddd98c3dff62619a3963f99ba1e055b67650e72", artifacts: { - cli: {binarySha256: "5c0dabb90152a3cfae9005731771da99f00a22403080c81952c7be8ba4b5728f"}, - gateway: {binarySha256: "05bd6c982dd72b73364b91ab694487c026bc56d0cd869f4289b44cc392a5c2ba"}, - standaloneSandbox: {binarySha256: "a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214"} + cli: {binarySha256: "1ad48efd5e1de8f3f017a81b3a7177872f350343a1a8d8074c7e844bca4801e9"}, + gateway: {binarySha256: "a6a5d754605a2144b148637b85a09291d2eeb77e08a4ee34b83685c6920448f5"}, + standaloneSandbox: {binarySha256: "a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920"} } }' > "$E2E_ARTIFACT_DIR/mcp-bridge-deepagents/openshell-exact-main-provenance.json" fi @@ -1409,7 +1409,7 @@ jobs: NEMOCLAW_OPENSHELL_CHANNEL: stable NEMOCLAW_OPENSHELL_EXACT_MAIN_PROOF: "1" NEMOCLAW_RUN_LIVE_E2E: "1" - OPENSHELL_DOCKER_SUPERVISOR_IMAGE: ghcr.io/nvidia/openshell/supervisor@sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6 + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: ghcr.io/nvidia/openshell/supervisor@sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1461,12 +1461,12 @@ jobs: jq -n '{ schemaVersion: 1, sourceRepository: "NVIDIA/OpenShell", - releaseTag: "v0.0.99", - sourceSha: "8c7dd148a9e6360c9d5b2830e339a0dc4b3f3032", + releaseTag: "v0.0.101", + sourceSha: "8ddd98c3dff62619a3963f99ba1e055b67650e72", artifacts: { - cli: {binarySha256: "5c0dabb90152a3cfae9005731771da99f00a22403080c81952c7be8ba4b5728f"}, - gateway: {binarySha256: "05bd6c982dd72b73364b91ab694487c026bc56d0cd869f4289b44cc392a5c2ba"}, - standaloneSandbox: {binarySha256: "a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214"} + cli: {binarySha256: "1ad48efd5e1de8f3f017a81b3a7177872f350343a1a8d8074c7e844bca4801e9"}, + gateway: {binarySha256: "a6a5d754605a2144b148637b85a09291d2eeb77e08a4ee34b83685c6920448f5"}, + standaloneSandbox: {binarySha256: "a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920"} } }' > "$E2E_ARTIFACT_DIR/openshell-credential-generation-window/openshell-exact-main-provenance.json" diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1c9ab27753c..173ed379a75 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -134,7 +134,7 @@ COPY agents/hermes/finalize-tirith-marker.py /usr/local/lib/nemoclaw/finalize-ti COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py COPY agents/hermes/cron-restore-control.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.py -COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.99.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.99.json +COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.101.json COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py COPY agents/hermes/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ @@ -363,12 +363,12 @@ RUN chmod -R a+rX /opt/nemoclaw-blueprint/ # minimum supported Hermes sandbox base tag guarantees those artifacts and # test/sandbox-rlimit-hooks.test.ts covers that base. RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ - && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.99.json \ + && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.101.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/hermes-cron-restore-control.py \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/managed_policy.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ - && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.99.json \ + && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.101.json \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ chown -R 0:0 /usr/local/lib/nemoclaw/preloads \ && find /usr/local/lib/nemoclaw/preloads -type f -exec chmod 444 {} + \ diff --git a/agents/hermes/mcp-config-transaction.py b/agents/hermes/mcp-config-transaction.py index 450a37fc360..8385edd18f9 100755 --- a/agents/hermes/mcp-config-transaction.py +++ b/agents/hermes/mcp-config-transaction.py @@ -71,7 +71,7 @@ r"^Bearer openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]{0,127})$" ) OPENSHELL_REVISIONED_CREDENTIAL_NAME_RE = re.compile(r"^v[0-9]+_[A-Za-z0-9_]+$") -BOUNDARY_MANIFEST_NAME = "openshell-child-visible-credentials.v0.0.99.json" +BOUNDARY_MANIFEST_NAME = "openshell-child-visible-credentials.v0.0.101.json" ANSI_ESCAPE_RE = re.compile( r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-_])" ) @@ -107,7 +107,7 @@ def _load_credential_boundary_manifest() -> dict[str, object]: # corrupt, or wrong-version OpenShell boundary manifest. # sourceBoundary: NemoClaw owns one reviewed manifest installed beside this # helper in images; the second path is the deterministic source-checkout layout. - # whyNotSourceFix: OpenShell v0.0.99 has no machine-readable child-env contract. + # whyNotSourceFix: OpenShell v0.0.101 has no machine-readable child-env contract. # It also deliberately hides the supervisor identity mount from workload # children and the Hermes image contains no OpenShell CLI. Executing # ``openshell --version`` here would therefore either fail every real @@ -135,7 +135,7 @@ def _load_credential_boundary_manifest() -> dict[str, object]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if ( not isinstance(manifest, dict) - or manifest.get("openshellVersion") != "0.0.99" + or manifest.get("openshellVersion") != "0.0.101" ): raise RuntimeError("Hermes MCP credential boundary manifest is invalid") return manifest @@ -319,7 +319,7 @@ def _validate_payload(action: str, payload: dict[str, object]) -> None: } if action == "add" and hostname in host_aliases: raise ValueError( - "Authenticated MCP OpenShell host aliases are unavailable with OpenShell v0.0.99" + "Authenticated MCP OpenShell host aliases are unavailable with OpenShell v0.0.101" ) # Host preflight owns destination trust and binds every accepted endpoint to # exact OpenShell address pins. This in-sandbox check revalidates canonical @@ -1091,7 +1091,7 @@ def _assert_non_root_lifecycle_identity() -> None: # topology. # sourceBoundary: OpenShell owns workload topology; NemoClaw owns the # immutable root-lifecycle marker and validates it before mutation. - # whyNotSourceFix: OpenShell 0.0.99 supports both topologies but exposes no + # whyNotSourceFix: OpenShell 0.0.101 supports both topologies but exposes no # attested same-UID capability that this packaged helper can query. # regressionTest: hermes-mcp-config-transaction.test.ts rejects both probe # and add when the root-lifecycle marker identifies the legacy topology. diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 99908a4105b..1fbbfcc2e77 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -3,8 +3,8 @@ version: "0.1.0" # Requires OpenShell MCP/JSON-RPC L7 policy support from NVIDIA/OpenShell#1865. -min_openshell_version: "0.0.99" -max_openshell_version: "0.0.99" +min_openshell_version: "0.0.101" +max_openshell_version: "0.0.101" min_openclaw_version: "2026.3.11" # Mirrors the components.sandbox.image manifest digest below. Lets a # downstream consumer (or release tooling) verify the blueprint declares diff --git a/nemoclaw-blueprint/policies/presets/brew.yaml b/nemoclaw-blueprint/policies/presets/brew.yaml index d6cda96f539..d2fc81c4f59 100644 --- a/nemoclaw-blueprint/policies/presets/brew.yaml +++ b/nemoclaw-blueprint/policies/presets/brew.yaml @@ -21,7 +21,7 @@ network_policies: access: full # Keep GitHub and raw-content routes on automatic TLS handling so this # preset composes with the agent baselines and narrower inspected - # routes under OpenShell 0.0.99. System git is excluded below; the + # routes under OpenShell 0.0.101. System git is excluded below; the # remaining curl/Homebrew clients trust the sandbox CA. - host: ghcr.io port: 443 diff --git a/nemoclaw/src/shared/openshell-policy-boundary.cts b/nemoclaw/src/shared/openshell-policy-boundary.cts index 5b12d93f165..830a393854f 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.cts +++ b/nemoclaw/src/shared/openshell-policy-boundary.cts @@ -100,7 +100,7 @@ export function parseOpenShellPolicy(raw: string): ParsedOpenShellPolicy { // regressionTest: the root policy round-trip and plugin runner policy tests. // removalCondition: OpenShell's supported base-policy contract guarantees that // provider-composed entries are absent from every mutation read. -// tracking: revalidated for stable OpenShell 0.0.99; revalidate after 0.0.99. +// tracking: revalidated for stable OpenShell 0.0.101; revalidate after 0.0.101. export function withoutProviderComposedPolicies(policies: Record): Record { return Object.fromEntries( Object.entries(policies).filter(([name]) => !name.startsWith("_provider_")), diff --git a/nemoclaw/src/shared/sandbox-name.cts b/nemoclaw/src/shared/sandbox-name.cts index 142fbefc658..dc26ce2da57 100644 --- a/nemoclaw/src/shared/sandbox-name.cts +++ b/nemoclaw/src/shared/sandbox-name.cts @@ -23,7 +23,7 @@ // or provider identifier, or both grammars are enforced by shared upstream // contracts. -// OpenShell v0.0.99 routes sandbox and workspace identities through labels +// OpenShell v0.0.101 routes sandbox and workspace identities through labels // capped at 19 characters. Keep NemoClaw's canonical sandbox-name boundary at // that upstream limit so invalid creates fail before any gateway mutation. export const NAME_MAX_LENGTH = 19; @@ -31,7 +31,7 @@ export const PROVIDER_NAME_MAX_LENGTH = 128; // NemoClaw label: starts with a lowercase letter, then lowercase // letters/digits/single internal hyphens, and ends with a letter or digit. -// OpenShell v0.0.99 reserves `--` as a routed-name segment delimiter. +// OpenShell v0.0.101 reserves `--` as a routed-name segment delimiter. export const NAME_VALID_PATTERN = /^(?!.*--)[a-z]([a-z0-9-]*[a-z0-9])?$/; export const PROVIDER_NAME_VALID_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/; diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index dff8bc45081..e4766cceb53 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -73,7 +73,7 @@ assert_openshell_version() { if [ -z "$OPENSHELL_VERSION" ]; then case "${NEMOCLAW_OPENSHELL_CHANNEL:-stable}" in dev) OPENSHELL_VERSION="dev" ;; - stable | auto) OPENSHELL_VERSION="v0.0.99" ;; + stable | auto) OPENSHELL_VERSION="v0.0.101" ;; *) fail "NEMOCLAW_OPENSHELL_CHANNEL must be one of: stable, dev, auto" ;; esac fi @@ -148,11 +148,11 @@ openshell_cli_asset_for_arch() { openshell_cli_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.99:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "35725a358e42ef7f0f0393035536da317706b0febcc459a2011e0555f6c2b71c" + v0.0.101:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "7d49ab2a5ff0b826bd2bdca5e0244010f832dfc6901c808ea8c8467004c26913" ;; - v0.0.99:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "d00cbf0d8779c01ddea6453ead2ad4db3d89a1f14eb6f0785f7919f42813a279" + v0.0.101:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "b553d3bfc08e9354b990a10fb8abd976e039afeec2d3947f8a112018be40d296" ;; *) return 1 diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh index 75c58426e3e..847bb3751b4 100755 --- a/scripts/check-installer-hash.sh +++ b/scripts/check-installer-hash.sh @@ -42,6 +42,9 @@ readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=( "0.0.99|openshell-checksums-sha256.txt|ea3e2c1a583e5ea00332c3b65a18068bd1f9b090f7ff0f5e24b29762cfc3b4c7" "0.0.99|openshell-gateway-checksums-sha256.txt|7f84f728412548720c8ef51993c58414c4f04598451c282b26ead233185e40c5" "0.0.99|openshell-sandbox-checksums-sha256.txt|9e67af6bab9f975432a1045fcfea5ab182ab585b17886c8c290c1eb77232b87a" + "0.0.101|openshell-checksums-sha256.txt|9c90869d00b109b5ac1062b1a9808a592c2311d3c0c4926bae44d136b979d8a9" + "0.0.101|openshell-gateway-checksums-sha256.txt|dcb3f1917713bf2a8e8e1803ac42c5e39d9dd41e644136b05def32b077082777" + "0.0.101|openshell-sandbox-checksums-sha256.txt|d16f7d369c54d74d36c7df036565267a960e7ce6fb143012fe9d77f257d6e8b3" ) case "${1:-}" in diff --git a/scripts/checks/managed-image-protected-runtime-contract.ts b/scripts/checks/managed-image-protected-runtime-contract.ts index ff9efb24e43..898da3ee055 100644 --- a/scripts/checks/managed-image-protected-runtime-contract.ts +++ b/scripts/checks/managed-image-protected-runtime-contract.ts @@ -18,7 +18,7 @@ export type ManagedImageLocalInferenceKind = (typeof MANAGED_IMAGE_LOCAL_INFEREN export type ManagedImageProtectedRouteKind = ManagedImageLocalInferenceKind | "rollback"; -// OpenShell 0.0.99 caps routable sandbox names at 19 characters. Keep the +// OpenShell 0.0.101 caps routable sandbox names at 19 characters. Keep the // protected-runtime ownership prefix and every agent/route discriminator // explicit so the qualification matrix remains deterministic and collision // free without relying on truncation. diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 88fc536d47b..a3b05e08109 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -37,16 +37,16 @@ info "Detected $OS_LABEL ($ARCH_LABEL)" # round-trippable base policies: WebSocket text frames, provider-shaped # aliases, REST request bodies, MCP/JSON-RPC L7 enforcement, and # `policy get --base` for MCP/JSON-RPC-safe read-modify-write operations. -MIN_VERSION="0.0.99" +MIN_VERSION="0.0.101" # Maximum version validated for this NemoClaw release. Newer OpenShell builds # may change sandbox semantics; upgrade NemoClaw before upgrading past this. -MAX_VERSION="0.0.99" +MAX_VERSION="0.0.101" # Pin fresh installs to this version. The TS installer normally overrides this # via NEMOCLAW_OPENSHELL_PIN_VERSION after resolving the highest published # OpenShell release that satisfies the blueprint's max_openshell_version # (see #3404). The hardcoded value is the fallback for offline runs. PIN_VERSION="$MAX_VERSION" -DEV_MIN_VERSION="0.0.99" +DEV_MIN_VERSION="0.0.101" CHANNEL="${NEMOCLAW_OPENSHELL_CHANNEL:-auto}" case "$CHANNEL" in @@ -143,32 +143,32 @@ fi openshell_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.99:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "35725a358e42ef7f0f0393035536da317706b0febcc459a2011e0555f6c2b71c" + v0.0.101:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "7d49ab2a5ff0b826bd2bdca5e0244010f832dfc6901c808ea8c8467004c26913" ;; - v0.0.99:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "d00cbf0d8779c01ddea6453ead2ad4db3d89a1f14eb6f0785f7919f42813a279" + v0.0.101:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "b553d3bfc08e9354b990a10fb8abd976e039afeec2d3947f8a112018be40d296" ;; - v0.0.99:openshell-aarch64-apple-darwin.tar.gz) - printf '%s\n' "e31cac5360e2adf3c971d5742a516626c58acf2fd3db4dcb0e45804def3dc844" + v0.0.101:openshell-aarch64-apple-darwin.tar.gz) + printf '%s\n' "9daaccdb9e30e220d56dd6d6bf4bd00ccca8ae4ad2845f5f0d9b9da3eb8ee881" ;; - v0.0.99:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "640d204dc3c6bc28bffa1f3d870897fc23bbc5ec0151a6c642083e958455cb49" + v0.0.101:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "eaeb094ccf7dcb1fe00c7e926e6aa9aaaefb89ecbef8343720628b0fd2d84654" ;; - v0.0.99:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "3a5d3092ae34356beb0ff2a920f9a87af4233c7a1086a53cd9429d48358f5c09" + v0.0.101:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "ac842ccc2ab8b5682f7479d71532cc650839250a8a41dbfae2b871cbbdfd3279" ;; - v0.0.99:openshell-gateway-aarch64-apple-darwin.tar.gz) - printf '%s\n' "4340619292ecb565f90eb2250db504baa37dd410361b366b42e174d34512cb6c" + v0.0.101:openshell-gateway-aarch64-apple-darwin.tar.gz) + printf '%s\n' "0f9e195b7cde57f4c2080df95159c5e7e72b0248306abc242ae00a3bb6f07f14" ;; - v0.0.99:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "84caed3dec4390e0938e89b38b1256d31e8970b4bfd85437bf92ed79f5b1ff05" + v0.0.101:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "953b90eaa7d2fc1bb7bdf38eb0ada6fad7902b13f9f895ca20b89caeac483a9e" ;; - v0.0.99:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "c758e7dc2b8c904baa01e2ccce0f08daf96ede0c648478b23346d8c4dd16f432" + v0.0.101:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "c39b7ba3cf212b88712a00d2a0e3d28e2c1e0e9f47a9a6ca818a8f06ed2140aa" ;; - v0.0.99:openshell.rb) - printf '%s\n' "8dd34fc17ee9a30327664a18c9509c8a765cb010de38cda8e22841bddbe92713" + v0.0.101:openshell.rb) + printf '%s\n' "87fadc7b0c854aa44f71d5b3a206865070117cd27825d59c61da252a99f402a2" ;; *) return 1 @@ -296,6 +296,11 @@ pinned_sandbox_build_version() { f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e) printf '%s\n' "0.0.99" ;; + # OpenShell v0.0.101 standalone sandbox binaries. + a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920 | \ + 88300e35f153123e4dc3021c537834dd6c0a09665a4a6d3974cd285d512345c4) + printf '%s\n' "0.0.101" + ;; *) return 1 ;; diff --git a/scripts/install.sh b/scripts/install.sh index 98cba197162..5b68c58e7a4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1139,7 +1139,7 @@ _PREEXISTING_SANDBOX_RECOVERY_RAN=false # preserved). The final summary must not claim those sandboxes were recovered. _PREEXISTING_SANDBOX_ORPHANED=false _LEGACY_MANAGED_RECOVERY_NAMES_JSON="[]" -# OpenShell v0.0.99 routes sandbox and workspace identities through labels +# OpenShell v0.0.101 routes sandbox and workspace identities through labels # capped at 19 characters. Keep this installer-only raw-registry preflight in # sync with NAME_MAX_LENGTH in nemoclaw/src/shared/sandbox-name.cts. The # current CLI cannot be prepared safely until legacy names are checked. @@ -2538,7 +2538,7 @@ require_openshell_compatible_sandbox_names() { cat <> = { "0.0.72": "sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", "0.0.99": "sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", + "0.0.101": "sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb", }; export type DockerDriverGatewayRuntimeDrift = { reason: string }; diff --git a/src/lib/onboard/forward-start.ts b/src/lib/onboard/forward-start.ts index 64ec4787f9b..deb07405148 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -110,8 +110,8 @@ export function looksLikeUntrackedForward(diagnostic: string): boolean { * (#6099). * * Compatibility boundary: these exact diagnostics are emitted by the pinned - * OpenShell 0.0.99 forward-start path tracked in #7266. Reassess this matcher - * when NemoClaw's supported OpenShell range moves beyond 0.0.99, and remove it + * OpenShell 0.0.101 forward-start path tracked in #7266. Reassess this matcher + * when NemoClaw's supported OpenShell range moves beyond 0.0.101, and remove it * once OpenShell either keeps the attempt alive until the listener is ready or * exposes a structured retryable outcome. Keep the fragments narrow so an * unrelated SSH or gateway failure cannot enter the listener-retry path. diff --git a/src/lib/onboard/openshell-feature-gate.ts b/src/lib/onboard/openshell-feature-gate.ts index 7de53598967..68773eee604 100644 --- a/src/lib/onboard/openshell-feature-gate.ts +++ b/src/lib/onboard/openshell-feature-gate.ts @@ -59,6 +59,9 @@ const PINNED_SANDBOX_BUILD_VERSIONS = new Map([ // OpenShell v0.0.99 standalone sandbox binaries. ["a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214", "0.0.99"], ["f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e", "0.0.99"], + // OpenShell v0.0.101 standalone sandbox binaries. + ["a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920", "0.0.101"], + ["88300e35f153123e4dc3021c537834dd6c0a09665a4a6d3974cd285d512345c4", "0.0.101"], ]); export function pinnedOpenShellSandboxBuildVersion(sha256: string): string | null { diff --git a/src/lib/onboard/openshell-install.ts b/src/lib/onboard/openshell-install.ts index e777388ab1c..da4ea9ca8e2 100644 --- a/src/lib/onboard/openshell-install.ts +++ b/src/lib/onboard/openshell-install.ts @@ -157,7 +157,7 @@ export function ensureOpenshellForOnboard(deps: OpenShellInstallDeps): OpenShell localBin: null, futureShellPathHint: null, }; - const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.99"; + const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.101"; if (!deps.isOpenshellInstalled()) { deps.log(" openshell CLI not found. Installing..."); diff --git a/src/lib/onboard/openshell-version.ts b/src/lib/onboard/openshell-version.ts index eb6afc970c9..fe03a5cb8dc 100644 --- a/src/lib/onboard/openshell-version.ts +++ b/src/lib/onboard/openshell-version.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { resolveOpenshell } from "../adapters/openshell/resolve"; import { ROOT, runCapture } from "../runner"; -export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.99"; +export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.101"; export function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { const openshellBin = resolveOpenshell(); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index f818496af86..ec7fce8b9bf 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -654,7 +654,7 @@ function endpointOverlapsPersonalOpenInternet(endpoint: PolicyValue): boolean { } /** - * OpenShell 0.0.99 rejects a hostless endpoint when a named endpoint selects + * OpenShell 0.0.101 rejects a hostless endpoint when a named endpoint selects * the same port with different L4/L7 metadata. Personal's hostless 80/443 * route is the authoritative selector for those ports, so retain it and only * the non-overlapping endpoints from the baseline and other selected presets. @@ -796,7 +796,7 @@ function openClawNpmReviewedEntries(baselinePolicyContent: string): { } /** - * OpenShell 0.0.99 rejects overlapping endpoint selectors whose TLS or L7 + * OpenShell 0.0.101 rejects overlapping endpoint selectors whose TLS or L7 * metadata differs, even when their binary lists are disjoint. Keep the * restricted OpenClaw baseline GET-only. While the broader npm preset is * active, its reviewed full-access L4 endpoint temporarily replaces the diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index 259bbcda9d4..fac4274e427 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -23,15 +23,15 @@ const CREDENTIAL_WINDOW_ARTIFACT_DIR = "e2e-artifacts/live/openshell-credential- const CREDENTIAL_WINDOW_RUN_STEP = "Run OpenShell credential generation-window live test"; const CREDENTIAL_WINDOW_JOB_CONDITION = "${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',mcp-bridge,') || contains(format(',{0},', inputs.targets), ',mcp-bridge,') || contains(format(',{0},', inputs.jobs), ',openshell-credential-generation-window,') || contains(format(',{0},', inputs.targets), ',openshell-credential-generation-window,') }}"; -const STABLE_RELEASE_SOURCE_SHA = "8c7dd148a9e6360c9d5b2830e339a0dc4b3f3032"; +const STABLE_RELEASE_SOURCE_SHA = "8ddd98c3dff62619a3963f99ba1e055b67650e72"; const STABLE_RELEASE_SUPERVISOR_INDEX = - "ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6"; + "b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb"; const STABLE_RELEASE_IDENTITY_TOKENS = [ - 'releaseTag: "v0.0.99"', + 'releaseTag: "v0.0.101"', STABLE_RELEASE_SOURCE_SHA, - "5c0dabb90152a3cfae9005731771da99f00a22403080c81952c7be8ba4b5728f", - "05bd6c982dd72b73364b91ab694487c026bc56d0cd869f4289b44cc392a5c2ba", - "a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214", + "1ad48efd5e1de8f3f017a81b3a7177872f350343a1a8d8074c7e844bca4801e9", + "a6a5d754605a2144b148637b85a09291d2eeb77e08a4ee34b83685c6920448f5", + "a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920", ] as const; const STABLE_RELEASE_PROVENANCE_TOKENS = [ ...STABLE_RELEASE_IDENTITY_TOKENS, diff --git a/tools/pr-review-advisor/workflow-boundary.mts b/tools/pr-review-advisor/workflow-boundary.mts index b03f8f5ed0e..740392dd1b2 100644 --- a/tools/pr-review-advisor/workflow-boundary.mts +++ b/tools/pr-review-advisor/workflow-boundary.mts @@ -466,7 +466,7 @@ function checkAnalysisJob(errors: string[], reviewJob: WorkflowRecord): void { !OPENSHELL_SANDBOX_NAME_PATTERN.test(sandboxName) ) { errors.push( - `advisor matrix entry ${index + 1} sandbox_name must satisfy the OpenShell 0.0.99 sandbox-name contract`, + `advisor matrix entry ${index + 1} sandbox_name must satisfy the OpenShell 0.0.101 sandbox-name contract`, ); } } From 4de5086719498c71815e0f4e8396e51cbeaa6cb2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 16:10:17 -0700 Subject: [PATCH 17/42] fix(onboard): recover portable resume lifecycle --- scripts/lib/openshell-gateway.service.in | 6 ++--- src/lib/actions/sandbox/gateway-state.ts | 20 ++++++++++++++++ src/lib/onboard.ts | 3 ++- .../experimental/portable-demo-lifecycle.ts | 22 +++++++---------- .../experimental/portable-host-preparation.ts | 24 +++++++++++++++++++ src/lib/onboard/sandbox-gpu-create-flow.ts | 12 ++++++---- 6 files changed, 66 insertions(+), 21 deletions(-) diff --git a/scripts/lib/openshell-gateway.service.in b/scripts/lib/openshell-gateway.service.in index 647f1c202df..e9373c87502 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -5,12 +5,12 @@ [Unit] Description=OpenShell Gateway Documentation=https://github.com/NVIDIA/OpenShell -After=default.target [Service] Type=simple -StateDirectory=openshell/gateway -Environment=OPENSHELL_LOCAL_TLS_DIR=%S/openshell/tls +StateDirectory=nemoclaw/openshell-docker-gateway +Environment=OPENSHELL_DB_URL=sqlite:%h/.local/state/nemoclaw/openshell-docker-gateway/openshell.db +Environment=OPENSHELL_LOCAL_TLS_DIR=%h/.local/state/nemoclaw/openshell-docker-gateway/tls EnvironmentFile=-%E/openshell/gateway.env ExecStartPre=@OPENSHELL_GATEWAY_BIN@ generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal ExecStart=@OPENSHELL_GATEWAY_BIN@ diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 5f46646d015..21b8cc2695f 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -100,6 +101,25 @@ export function recoverPortableDemoSandboxLifecycleForConnect( { agent: sandbox.agent, gatewayName, provider: sandbox.provider }, { openshellBinary: getOpenshellBinary(), + ensureGateway: () => { + const result = spawnSync( + "systemctl", + ["--user", "start", "nemoclaw-openshell-gateway.service"], + { + encoding: "utf-8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }, + ); + if (result.status === 0 && !result.error) return; + const detail = + result.error?.message || + String(result.stderr ?? "").trim() || + String(result.stdout ?? "").trim() || + `exit ${String(result.status)}`; + throw new Error(`Starting the portable OpenShell gateway failed: ${detail}`); + }, captureOpenshell: (args, timeoutMs) => { const result = captureOpenshell([...args], { ignoreError: true, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 863f603d0da..d21d12b7ceb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2629,6 +2629,7 @@ async function createSandboxWithBaseImageResolution( route: selectedGpuRoute, firstCreateOutput, registryImageRef, + portableDashboardPort, } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( { sandboxName, @@ -2697,7 +2698,7 @@ async function createSandboxWithBaseImageResolution( runtimePatch, ); } - let actualDashboardPort = 0; + let actualDashboardPort = portableDashboardPort ?? 0; let finalHermesDashboardState = hermesDashboardState; if (manageDashboardForward) { actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index d9b565fce40..157c41eb1e4 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -76,6 +76,7 @@ export interface PortableDemoLifecycleDeps { executableFd: number, ) => void; loadManagedOllama?: () => string | null; + ensureGateway?: () => void; sleep?: (milliseconds: number) => void; now?: () => number; log?: (message: string) => void; @@ -250,14 +251,6 @@ function loadReceipt(sandboxName: string, stateDir: string): PortableDemoLifecyc } } -function removeReceipt(sandboxName: string, stateDir: string): void { - try { - fs.unlinkSync(receiptPath(sandboxName, stateDir)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } -} - function startupEnvValue(startupArgv: readonly string[], name: string): string | null { const prefix = `${name}=`; for (let index = startupArgv.length - 2; index >= 1; index -= 1) { @@ -604,13 +597,13 @@ export function installPortableDemoSandboxLifecycle( createdStartupArgv: readonly string[], env: NodeJS.ProcessEnv = process.env, deps: PortableDemoLifecycleDeps = {}, -): void { - if (!isPortableExperimentalProfile(env)) return; +): number | null { + if (!isPortableExperimentalProfile(env)) return null; if ( createdStartupArgv[createdStartupArgv.length - 1] !== "/usr/local/bin/nemoclaw-start" || startupEnvValue(createdStartupArgv, "OPENCLAW_HOME") === null ) { - return; + return null; } if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle requires Linux"); @@ -630,6 +623,7 @@ export function installPortableDemoSandboxLifecycle( `Setting the portable restart policy for sandbox '${sandboxName}'`, ); writeReceipt(receipt, deps.stateDir ?? defaultStateDir(env)); + return receipt.dashboardPort; } /** @@ -649,12 +643,14 @@ export function recoverPortableDemoSandboxLifecycle( throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } + deps.ensureGateway?.(); const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); const initialInspection = podman(["inspect", receipt.containerId]); if (isMissingPodmanContainer(initialInspection)) { - removeReceipt(sandboxName, stateDir); - return { kind: "not-installed" }; + throw new Error( + `Portable sandbox '${sandboxName}' container '${receipt.containerId}' is temporarily unavailable; its lifecycle receipt was preserved`, + ); } let inspection = inspectPodmanContainer( receipt.containerId, diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index aa7ef066030..c477f0afcdb 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -27,6 +27,11 @@ firewall_driver = "iptables" [engine] env = ["NETAVARK_FW=iptables"] `; +const PORTABLE_GATEWAY_SYSTEMD_DROP_IN = `[Unit] +Requires=podman.socket +After=podman.socket +Before=podman-restart.service +`; type SpawnResult = ReturnType; @@ -119,6 +124,16 @@ function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): strin // The OpenShell gateway service and sandbox prebuild read this file through CONTAINERS_CONF. const containersConf = path.join(configHome, "nemoclaw", "portable", "containers.conf"); writePrivateConfig(containersConf, PORTABLE_CONTAINERS_CONF); + writePrivateConfig( + path.join( + configHome, + "systemd", + "user", + "nemoclaw-openshell-gateway.service.d", + "portable.conf", + ), + PORTABLE_GATEWAY_SYSTEMD_DROP_IN, + ); return containersConf; } @@ -195,6 +210,10 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: HOST_COMMAND_TIMEOUT_MS, })); + requireCommand( + systemctl(["--user", "daemon-reload"], env), + "Reloading the portable user services", + ); requireCommand( systemctl( [ @@ -215,6 +234,10 @@ export function preparePortableExperimentalHost( systemctl(["--user", "enable", "--now", "podman.socket"], env), "Starting the rootless container socket", ); + requireCommand( + systemctl(["--user", "enable", "podman-restart.service"], env), + "Enabling rootless container restart after login", + ); const podman = deps.podman ?? @@ -245,5 +268,6 @@ export const portableHostPreparationInternals = { REGISTRY_IMAGE, REGISTRY_FRAGMENT, PORTABLE_CONTAINERS_CONF, + PORTABLE_GATEWAY_SYSTEMD_DROP_IN, resolvePodmanDockerHost, }; diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index d9cec4789f4..4cd8dc9bd1b 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -119,6 +119,7 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; + portableDashboardPort: number | null; } /** @@ -250,11 +251,13 @@ export async function runSandboxGpuCreateFlow( process.exit(1); } + let portableDashboardPort: number | null = null; try { - (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( - input.sandboxName, - input.sandboxStartupCommand, - ); + portableDashboardPort = + (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( + input.sandboxName, + input.sandboxStartupCommand, + ) ?? null; } catch (error) { const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); @@ -265,5 +268,6 @@ export async function runSandboxGpuCreateFlow( route: gpuCreateOutcome.route, firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, + portableDashboardPort, }; } From 5718a067f93f6b9892bad44b7cff5ab0c3a0aa07 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 16:30:09 -0700 Subject: [PATCH 18/42] fix(build): handle rootless perl icmp tests --- .../security/build-perl-security-packages.sh | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index fbc4b4ea9cb..30074a25c7f 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -54,6 +54,29 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 -Dman3dir=none make -j"$(nproc)" make test_prep + readonly extutils_constant_test='../cpan/ExtUtils-Constant/t/Constant.t' + readonly -a raw_icmp_tests=( + '../dist/Net-Ping/t/001_new.t' + '../dist/Net-Ping/t/110_icmp_inst.t' + '../dist/Net-Ping/t/500_ping_icmp.t' + '../dist/Net-Ping/t/520_icmp_ttl.t' + ) + parallel_test_exclusion='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' + : >"${build_root}/perl-tests-capability-skipped" + # Rootless Podman maps the build user to effective UID 0 inside its user + # namespace without granting CAP_NET_RAW. Net::Ping treats UID 0 as proof + # that raw ICMP sockets are available, so these four upstream cases attempt + # privileged socket creation and fail with EPERM. Probe the actual capability: + # full-capability builders still run every test, while rootless builders omit + # only the cases that cannot execute in their namespace. + if ! env -C t ./perl -MNet::Ping -e 'Net::Ping->new("icmp")' >/dev/null 2>&1; then + parallel_test_exclusion='--nre=^(?:[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t|[.][.]/dist/Net-Ping/t/(?:001_new|110_icmp_inst|500_ping_icmp|520_icmp_ttl)[.]t)$' + env -C t ./perl harness -dumptests "${raw_icmp_tests[@]}" \ + >"${build_root}/perl-tests-capability-skipped" + printf '%s\n' \ + 'Skipping four Net::Ping raw-ICMP tests because this build namespace lacks CAP_NET_RAW.' \ + >&2 + fi # ExtUtils::Constant's test recursively invokes make and produced an incomplete # TAP plan when it overlapped another test locally, so run it alone first and # exclude exactly that already-passed file from the parallel pass. @@ -63,16 +86,17 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ >"${build_root}/perl-tests-full" env -C t ./perl harness -dumptests \ - ../cpan/ExtUtils-Constant/t/Constant.t \ + "${extutils_constant_test}" \ >"${build_root}/perl-tests-serial" env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + "${parallel_test_exclusion}" \ >"${build_root}/perl-tests-parallel" sort "${build_root}/perl-tests-full" \ >"${build_root}/perl-tests-full.sorted" sort \ "${build_root}/perl-tests-serial" \ "${build_root}/perl-tests-parallel" \ + "${build_root}/perl-tests-capability-skipped" \ >"${build_root}/perl-tests-combined.sorted" cmp \ "${build_root}/perl-tests-full.sorted" \ @@ -87,11 +111,11 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 # scheduler use each native runner efficiently instead of serializing every # script in QEMU. TEST_JOBS=1 \ - TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ + TEST_ARGS="${extutils_constant_test}" \ make test_harness TEST_JOBS="$(nproc)" \ PERL_TEST_HARNESS_ASAP=1 \ - TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + TEST_ARGS="${parallel_test_exclusion}" \ make -j"$(nproc)" test_harness make install DESTDIR="${perl_root}" ) From 7f5d758c24488fb83c8539d9cd49f2dfb7287671 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 16:44:36 -0700 Subject: [PATCH 19/42] fix(build): avoid intentional perl crash popup --- .../security/build-perl-security-packages.sh | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index 30074a25c7f..6a384ec6993 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -38,6 +38,27 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 ( cd "${source_dir}" + # Test::Harness validates signal-result parsing by deliberately sending + # SIGSEGV to a child Perl process. Desktop crash reporters surface that + # expected child signal as a scary "process crash" notification during the + # otherwise healthy package build. Skip only that self-crash subtest; the + # rest of cpan/Test-Harness/t/harness.t still runs normally. + readonly test_harness_test='cpan/Test-Harness/t/harness.t' + readonly test_harness_tmp="${test_harness_test}.nemoclaw" + test "$(grep -Fc 'skip "No SIGSEGV on $^O", 1 if' "${test_harness_test}")" -eq 1 + awk ' + index($0, "skip \"No SIGSEGV on $^O\", 1 if") { + print " skip \"NemoClaw package builds do not intentionally raise SIGSEGV\", 1;" + next + } + { print } + ' "${test_harness_test}" >"${test_harness_tmp}" + mv "${test_harness_tmp}" "${test_harness_test}" + test "$( + grep -Fxc \ + ' skip "NemoClaw package builds do not intentionally raise SIGSEGV", 1;' \ + "${test_harness_test}" + )" -eq 1 # Pin the reviewed d_syscallproto result for trixie's libc so both native # architectures use the same known declaration instead of relying on a # Configure probe that previously returned a false negative under QEMU. From 9e402848e3cf4472dc6018d17fa2a605272fc813 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 16:53:41 -0700 Subject: [PATCH 20/42] fix(portable): preserve gateway state on resume --- scripts/lib/openshell-gateway.service.in | 6 +++--- src/lib/onboard.ts | 1 + src/lib/onboard/sandbox-gpu-create-flow.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/lib/openshell-gateway.service.in b/scripts/lib/openshell-gateway.service.in index e9373c87502..33d050314d4 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -8,9 +8,9 @@ Documentation=https://github.com/NVIDIA/OpenShell [Service] Type=simple -StateDirectory=nemoclaw/openshell-docker-gateway -Environment=OPENSHELL_DB_URL=sqlite:%h/.local/state/nemoclaw/openshell-docker-gateway/openshell.db -Environment=OPENSHELL_LOCAL_TLS_DIR=%h/.local/state/nemoclaw/openshell-docker-gateway/tls +StateDirectory=openshell/gateway +Environment=OPENSHELL_DB_URL=sqlite:%S/openshell/gateway/openshell.db +Environment=OPENSHELL_LOCAL_TLS_DIR=%S/openshell/tls EnvironmentFile=-%E/openshell/gateway.env ExecStartPre=@OPENSHELL_GATEWAY_BIN@ generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal ExecStart=@OPENSHELL_GATEWAY_BIN@ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d21d12b7ceb..b3499738d98 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2644,6 +2644,7 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, + lifecycleStartupCommand: intendedSandboxStartupCommand, prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 4cd8dc9bd1b..8ba0e07725c 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -80,6 +80,7 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; + lifecycleStartupCommand: string[]; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -256,7 +257,7 @@ export async function runSandboxGpuCreateFlow( portableDashboardPort = (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( input.sandboxName, - input.sandboxStartupCommand, + input.lifecycleStartupCommand, ) ?? null; } catch (error) { const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); From 413df3f3299f902d18b9db653f71fbe6a91bc085 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 17:00:50 -0700 Subject: [PATCH 21/42] fix(installer): restore portable build input --- src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts b/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts index eebacbacd3d..884f2e0a1fc 100644 --- a/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts @@ -39,6 +39,7 @@ export function createGpuFlowInput(): SandboxGpuCreateFlowInput { createArgv: ["openshell", "sandbox", "create", "--gpu"], sandboxEnv: {}, sandboxStartupCommand: ["nemoclaw-start"], + lifecycleStartupCommand: ["nemoclaw-start"], prebuild: { createArgs: ["--from", "openshell/sandbox-from:test", "--name", "alpha", "--gpu"], imageRef: "openshell/sandbox-from:test", From ce4c02d2e25adb8c3ec023e6ff12bef2b9de3382 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 17:24:19 -0700 Subject: [PATCH 22/42] fix(portable): reopen authoritative gateway state --- scripts/lib/openshell-gateway.service.in | 6 +++--- src/lib/onboard.ts | 4 +++- src/lib/onboard/sandbox-gpu-create-flow.ts | 7 +++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/lib/openshell-gateway.service.in b/scripts/lib/openshell-gateway.service.in index 33d050314d4..4b7796c7524 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -8,9 +8,9 @@ Documentation=https://github.com/NVIDIA/OpenShell [Service] Type=simple -StateDirectory=openshell/gateway -Environment=OPENSHELL_DB_URL=sqlite:%S/openshell/gateway/openshell.db -Environment=OPENSHELL_LOCAL_TLS_DIR=%S/openshell/tls +StateDirectory=nemoclaw/openshell-docker-gateway +Environment=OPENSHELL_DB_URL=sqlite:%S/nemoclaw/openshell-docker-gateway/openshell.db +Environment=OPENSHELL_LOCAL_TLS_DIR=%S/nemoclaw/openshell-docker-gateway/tls EnvironmentFile=-%E/openshell/gateway.env ExecStartPre=@OPENSHELL_GATEWAY_BIN@ generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal ExecStart=@OPENSHELL_GATEWAY_BIN@ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b3499738d98..c79ed88b4ac 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2699,7 +2699,9 @@ async function createSandboxWithBaseImageResolution( runtimePatch, ); } - let actualDashboardPort = portableDashboardPort ?? 0; + let actualDashboardPort = + portableDashboardPort ?? + (dockerDriverPlatform.isPortableExperimentalProfile() ? Number(effectiveDashboardPort) : 0); let finalHermesDashboardState = hermesDashboardState; if (manageDashboardForward) { actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 8ba0e07725c..51fd2a36638 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -4,6 +4,7 @@ import type { StreamSandboxCreateResult } from "../sandbox/create-stream"; import { redactFull } from "../security/redact"; import type { SandboxGpuProofResult } from "../state/registry"; +import { isPortableExperimentalProfile } from "./docker-driver-platform"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import { collectDockerGpuPatchDiagnostics } from "./docker-gpu-patch"; import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types"; @@ -261,8 +262,14 @@ export async function runSandboxGpuCreateFlow( ) ?? null; } catch (error) { const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); + if (isPortableExperimentalProfile()) { + throw new Error(`Portable demo lifecycle setup did not complete: ${detail}`); + } console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); } + if (isPortableExperimentalProfile() && portableDashboardPort === null) { + throw new Error("Portable demo lifecycle setup did not return a dashboard port"); + } return { ...gpuCreateOutcome.value, From f4f093707a6740bdabee1c21e64d37c2fa2e0e7c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 17:42:02 -0700 Subject: [PATCH 23/42] fix(portable): enroll committed sandbox container --- src/lib/onboard.ts | 32 +++++++++++++++++-- .../sandbox-gpu-create-flow.ts | 1 - src/lib/onboard/sandbox-gpu-create-flow.ts | 25 --------------- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c79ed88b4ac..3e271a3d2b7 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -127,6 +127,8 @@ const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = requir const compatibleEndpointGatewayRoute: typeof import("./onboard/inference-providers/compatible-endpoint-gateway-route") = require("./onboard/inference-providers/compatible-endpoint-gateway-route"); const dockerDriverPlatform: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); const { isLinuxDockerDriverGatewayEnabled } = dockerDriverPlatform; +const portableDemoLifecycle: typeof import("./onboard/experimental/portable-demo-lifecycle") = + require("./onboard/experimental/portable-demo-lifecycle"); const { reconcileGatewayGpuReuseForGpuIntent, }: typeof import("./onboard/gateway-gpu-passthrough") = require("./onboard/gateway-gpu-passthrough"); @@ -2186,6 +2188,30 @@ const { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandbox runCaptureOpenshell, }); +function installPortableLifecycleAfterCommittedCreate( + sandboxName: string, + intendedSandboxStartupCommand: readonly string[], +): number | null { + let dashboardPort: number | null = null; + try { + dashboardPort = + portableDemoLifecycle.installPortableDemoSandboxLifecycle( + sandboxName, + intendedSandboxStartupCommand, + ) ?? null; + } catch (error) { + const detail = redact(error instanceof Error ? error.message : String(error)).slice(0, 500); + if (dockerDriverPlatform.isPortableExperimentalProfile()) { + throw new Error(`Portable demo lifecycle setup did not complete: ${detail}`); + } + console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); + } + if (dockerDriverPlatform.isPortableExperimentalProfile() && dashboardPort === null) { + throw new Error("Portable demo lifecycle setup did not return a dashboard port"); + } + return dashboardPort; +} + // ── Step 5: Sandbox ────────────────────────────────────────────── async function createSandboxWithBaseImageResolution( @@ -2629,7 +2655,6 @@ async function createSandboxWithBaseImageResolution( route: selectedGpuRoute, firstCreateOutput, registryImageRef, - portableDashboardPort, } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( { sandboxName, @@ -2644,7 +2669,6 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, - lifecycleStartupCommand: intendedSandboxStartupCommand, prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), @@ -2699,6 +2723,10 @@ async function createSandboxWithBaseImageResolution( runtimePatch, ); } + const portableDashboardPort = installPortableLifecycleAfterCommittedCreate( + sandboxName, + intendedSandboxStartupCommand, + ); let actualDashboardPort = portableDashboardPort ?? (dockerDriverPlatform.isPortableExperimentalProfile() ? Number(effectiveDashboardPort) : 0); diff --git a/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts b/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts index 884f2e0a1fc..eebacbacd3d 100644 --- a/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts @@ -39,7 +39,6 @@ export function createGpuFlowInput(): SandboxGpuCreateFlowInput { createArgv: ["openshell", "sandbox", "create", "--gpu"], sandboxEnv: {}, sandboxStartupCommand: ["nemoclaw-start"], - lifecycleStartupCommand: ["nemoclaw-start"], prebuild: { createArgs: ["--from", "openshell/sandbox-from:test", "--name", "alpha", "--gpu"], imageRef: "openshell/sandbox-from:test", diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 51fd2a36638..a46fce580ee 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -4,14 +4,12 @@ import type { StreamSandboxCreateResult } from "../sandbox/create-stream"; import { redactFull } from "../security/redact"; import type { SandboxGpuProofResult } from "../state/registry"; -import { isPortableExperimentalProfile } from "./docker-driver-platform"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import { collectDockerGpuPatchDiagnostics } from "./docker-gpu-patch"; import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types"; import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { renderCompatibilityFallbackCreateArgs } from "./docker-gpu-route"; import { adaptDockerGpuRouteForPatch } from "./docker-gpu-route-patch-adapter"; -import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; import { type ManagedBootstrapAdapter, type ManagedBootstrapAgentIdentity, @@ -81,7 +79,6 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; - lifecycleStartupCommand: string[]; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -108,8 +105,6 @@ export interface SandboxGpuCreateFlowDeps { sleep: Sleep; openshellArgv(args: string[]): string[]; verifyDirectSandboxGpu(sandboxName: string): SandboxGpuProofResult; - /** Production callers configure the hidden portable lifecycle through the default implementation. */ - installPortableDemoLifecycle?: typeof installPortableDemoSandboxLifecycle; /** Production callers omit this factory and use the runtime provider's adapter. */ createManagedBootstrapAdapter?: () => ManagedBootstrapAdapter; } @@ -121,7 +116,6 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; - portableDashboardPort: number | null; } /** @@ -253,29 +247,10 @@ export async function runSandboxGpuCreateFlow( process.exit(1); } - let portableDashboardPort: number | null = null; - try { - portableDashboardPort = - (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( - input.sandboxName, - input.lifecycleStartupCommand, - ) ?? null; - } catch (error) { - const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); - if (isPortableExperimentalProfile()) { - throw new Error(`Portable demo lifecycle setup did not complete: ${detail}`); - } - console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); - } - if (isPortableExperimentalProfile() && portableDashboardPort === null) { - throw new Error("Portable demo lifecycle setup did not return a dashboard port"); - } - return { ...gpuCreateOutcome.value, route: gpuCreateOutcome.route, firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, - portableDashboardPort, }; } From 1bfb3c7bccc7cd3c60b8907348d5cf0e9c8104d7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 17:51:54 -0700 Subject: [PATCH 24/42] fix(portable): discover exact OpenShell container --- .../experimental/portable-demo-lifecycle.ts | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 157c41eb1e4..37285e28b25 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -32,6 +32,11 @@ const SANDBOX_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u; const PODMAN_MANAGED_LABEL = "openshell.managed"; const PODMAN_SANDBOX_ID_LABEL = "openshell.sandbox-id"; const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; +const DOCKER_MANAGED_BY_LABEL = "openshell.ai/managed-by"; +const DOCKER_MANAGED_BY_VALUE = "openshell"; +const DOCKER_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; +const DOCKER_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; +const SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; const CURRENT_RECEIPT_SCHEMA_VERSION = 2; @@ -298,17 +303,41 @@ function inspectPodmanContainer( const config = isRecord(inspection.Config) ? inspection.Config : null; const labels = config && isRecord(config.Labels) ? config.Labels : null; const state = isRecord(inspection.State) ? inspection.State : null; - const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; + const containerName = + typeof inspection.Name === "string" ? inspection.Name.replace(/^\/+/, "") : null; + const expectedContainerName = `${SANDBOX_CONTAINER_PREFIX}${sandboxName}`; + const podmanSandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; + const dockerSandboxId = labels?.[DOCKER_SANDBOX_ID_LABEL]; + const podmanIdentityMatches = + labels?.[PODMAN_MANAGED_LABEL] === "true" && + labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName && + typeof podmanSandboxId === "string" && + SANDBOX_ID_PATTERN.test(podmanSandboxId); + const dockerIdentityMatches = + labels?.[DOCKER_MANAGED_BY_LABEL] === DOCKER_MANAGED_BY_VALUE && + labels?.[DOCKER_SANDBOX_NAME_LABEL] === sandboxName && + typeof dockerSandboxId === "string" && + SANDBOX_ID_PATTERN.test(dockerSandboxId); + const sandboxId = podmanIdentityMatches + ? podmanSandboxId + : dockerIdentityMatches + ? dockerSandboxId + : null; if ( inspection.Id !== containerId || - labels?.[PODMAN_MANAGED_LABEL] !== "true" || - labels?.[PODMAN_SANDBOX_NAME_LABEL] !== sandboxName || - typeof sandboxId !== "string" || - !SANDBOX_ID_PATTERN.test(sandboxId) || + containerName !== expectedContainerName || + sandboxId === null || typeof state?.Running !== "boolean" ) { + const checks = [ + `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, + `container-name=${containerName === expectedContainerName ? "match" : "mismatch"}`, + `podman-ownership=${podmanIdentityMatches ? "match" : "mismatch"}`, + `docker-ownership=${dockerIdentityMatches ? "match" : "mismatch"}`, + `running-state=${typeof state?.Running === "boolean" ? "present" : "missing"}`, + ].join(", "); throw new Error( - `Portable demo lifecycle refused container '${containerId}' because its OpenShell identity does not match sandbox '${sandboxName}'`, + `Portable demo lifecycle refused container '${containerId}' because its OpenShell identity does not match sandbox '${sandboxName}' (${checks})`, ); } return { containerId, sandboxId, running: state.Running }; @@ -326,14 +355,13 @@ function discoverPodmanContainer( sandboxName: string, podman: NonNullable, ): PodmanContainerInspection { + const expectedContainerName = `${SANDBOX_CONTAINER_PREFIX}${sandboxName}`; const result = podman([ "ps", "-a", "--no-trunc", "--filter", - `label=${PODMAN_MANAGED_LABEL}=true`, - "--filter", - `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, + `name=^${expectedContainerName}$`, "--format", "{{.ID}}", ]); @@ -343,8 +371,32 @@ function discoverPodmanContainer( .map((line) => line.trim()) .filter(Boolean); if (matches.length !== 1 || !CONTAINER_ID_PATTERN.test(matches[0] ?? "")) { + const diagnosticMatchCount = (filter: string): string => { + const diagnostic = podman([ + "ps", + "-a", + "--no-trunc", + "--filter", + filter, + "--format", + "{{.ID}}", + ]); + if (diagnostic.status !== 0 || diagnostic.error) return "query-error"; + return String( + String(diagnostic.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean).length, + ); + }; + const podmanLabelMatches = diagnosticMatchCount( + `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, + ); + const dockerLabelMatches = diagnosticMatchCount( + `label=${DOCKER_SANDBOX_NAME_LABEL}=${sandboxName}`, + ); throw new Error( - `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; found ${matches.length}`, + `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; exact-name matches=${String(matches.length)}, podman-label matches=${podmanLabelMatches}, docker-label matches=${dockerLabelMatches}`, ); } return inspectPodmanContainer(matches[0]!, sandboxName, podman); From 3fd5d1d1b888f43526f745b98591464134756275 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 18:00:44 -0700 Subject: [PATCH 25/42] fix(portable): resolve Docker-owned sandbox container --- .../experimental/portable-demo-lifecycle.ts | 68 ++++++------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 37285e28b25..f53b0d3a57a 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -323,12 +323,7 @@ function inspectPodmanContainer( : dockerIdentityMatches ? dockerSandboxId : null; - if ( - inspection.Id !== containerId || - containerName !== expectedContainerName || - sandboxId === null || - typeof state?.Running !== "boolean" - ) { + if (inspection.Id !== containerId || sandboxId === null || typeof state?.Running !== "boolean") { const checks = [ `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, `container-name=${containerName === expectedContainerName ? "match" : "mismatch"}`, @@ -356,50 +351,31 @@ function discoverPodmanContainer( podman: NonNullable, ): PodmanContainerInspection { const expectedContainerName = `${SANDBOX_CONTAINER_PREFIX}${sandboxName}`; - const result = podman([ - "ps", - "-a", - "--no-trunc", - "--filter", - `name=^${expectedContainerName}$`, - "--format", - "{{.ID}}", - ]); - requireCommand(result, `Finding portable sandbox '${sandboxName}'`); - const matches = String(result.stdout ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean); - if (matches.length !== 1 || !CONTAINER_ID_PATTERN.test(matches[0] ?? "")) { - const diagnosticMatchCount = (filter: string): string => { - const diagnostic = podman([ - "ps", - "-a", - "--no-trunc", - "--filter", - filter, - "--format", - "{{.ID}}", - ]); - if (diagnostic.status !== 0 || diagnostic.error) return "query-error"; - return String( - String(diagnostic.stdout ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean).length, - ); + const query = (filter: string): { ids: string[]; ok: boolean } => { + const result = podman(["ps", "-a", "--no-trunc", "--filter", filter, "--format", "{{.ID}}"]); + if (result.status !== 0 || result.error) return { ids: [], ok: false }; + return { + ids: String(result.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean), + ok: true, }; - const podmanLabelMatches = diagnosticMatchCount( - `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, - ); - const dockerLabelMatches = diagnosticMatchCount( - `label=${DOCKER_SANDBOX_NAME_LABEL}=${sandboxName}`, - ); + }; + const exactName = query(`name=^${expectedContainerName}$`); + const podmanLabel = query(`label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`); + const dockerLabel = query(`label=${DOCKER_SANDBOX_NAME_LABEL}=${sandboxName}`); + const candidates = [...new Set([...exactName.ids, ...podmanLabel.ids, ...dockerLabel.ids])]; + const queriesSucceeded = exactName.ok && podmanLabel.ok && dockerLabel.ok; + const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); + if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { + const count = (result: { ids: string[]; ok: boolean }): string => + result.ok ? String(result.ids.length) : "query-error"; throw new Error( - `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; exact-name matches=${String(matches.length)}, podman-label matches=${podmanLabelMatches}, docker-label matches=${dockerLabelMatches}`, + `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; exact-name matches=${count(exactName)}, podman-label matches=${count(podmanLabel)}, docker-label matches=${count(dockerLabel)}, unique candidates=${String(candidates.length)}`, ); } - return inspectPodmanContainer(matches[0]!, sandboxName, podman); + return inspectPodmanContainer(candidates[0]!, sandboxName, podman); } function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { From e969c9f1256327968604a84ea289a293a8c7f5c4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 18:15:07 -0700 Subject: [PATCH 26/42] fix(portable): match OpenShell Podman labels --- .../experimental/portable-demo-lifecycle.ts | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index f53b0d3a57a..dd23a93716a 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -30,13 +30,10 @@ const POLL_INTERVAL_MS = 1_000; const CONTAINER_ID_PATTERN = /^[a-f0-9]{64}$/u; const SANDBOX_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u; const PODMAN_MANAGED_LABEL = "openshell.managed"; -const PODMAN_SANDBOX_ID_LABEL = "openshell.sandbox-id"; -const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; const DOCKER_MANAGED_BY_LABEL = "openshell.ai/managed-by"; const DOCKER_MANAGED_BY_VALUE = "openshell"; -const DOCKER_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; -const DOCKER_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; -const SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; +const OPENSHELL_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; +const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; const CURRENT_RECEIPT_SCHEMA_VERSION = 2; @@ -303,30 +300,28 @@ function inspectPodmanContainer( const config = isRecord(inspection.Config) ? inspection.Config : null; const labels = config && isRecord(config.Labels) ? config.Labels : null; const state = isRecord(inspection.State) ? inspection.State : null; - const containerName = - typeof inspection.Name === "string" ? inspection.Name.replace(/^\/+/, "") : null; - const expectedContainerName = `${SANDBOX_CONTAINER_PREFIX}${sandboxName}`; - const podmanSandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; - const dockerSandboxId = labels?.[DOCKER_SANDBOX_ID_LABEL]; + const sandboxId = labels?.[OPENSHELL_SANDBOX_ID_LABEL]; + const sandboxNameMatches = labels?.[OPENSHELL_SANDBOX_NAME_LABEL] === sandboxName; const podmanIdentityMatches = labels?.[PODMAN_MANAGED_LABEL] === "true" && - labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName && - typeof podmanSandboxId === "string" && - SANDBOX_ID_PATTERN.test(podmanSandboxId); + sandboxNameMatches && + typeof sandboxId === "string" && + SANDBOX_ID_PATTERN.test(sandboxId); const dockerIdentityMatches = labels?.[DOCKER_MANAGED_BY_LABEL] === DOCKER_MANAGED_BY_VALUE && - labels?.[DOCKER_SANDBOX_NAME_LABEL] === sandboxName && - typeof dockerSandboxId === "string" && - SANDBOX_ID_PATTERN.test(dockerSandboxId); - const sandboxId = podmanIdentityMatches - ? podmanSandboxId - : dockerIdentityMatches - ? dockerSandboxId - : null; - if (inspection.Id !== containerId || sandboxId === null || typeof state?.Running !== "boolean") { + sandboxNameMatches && + typeof sandboxId === "string" && + SANDBOX_ID_PATTERN.test(sandboxId); + if ( + inspection.Id !== containerId || + (!podmanIdentityMatches && !dockerIdentityMatches) || + typeof sandboxId !== "string" || + typeof state?.Running !== "boolean" + ) { const checks = [ `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, - `container-name=${containerName === expectedContainerName ? "match" : "mismatch"}`, + `sandbox-name=${sandboxNameMatches ? "match" : "mismatch"}`, + `sandbox-id=${typeof sandboxId === "string" && SANDBOX_ID_PATTERN.test(sandboxId) ? "valid" : "invalid"}`, `podman-ownership=${podmanIdentityMatches ? "match" : "mismatch"}`, `docker-ownership=${dockerIdentityMatches ? "match" : "mismatch"}`, `running-state=${typeof state?.Running === "boolean" ? "present" : "missing"}`, @@ -350,9 +345,15 @@ function discoverPodmanContainer( sandboxName: string, podman: NonNullable, ): PodmanContainerInspection { - const expectedContainerName = `${SANDBOX_CONTAINER_PREFIX}${sandboxName}`; - const query = (filter: string): { ids: string[]; ok: boolean } => { - const result = podman(["ps", "-a", "--no-trunc", "--filter", filter, "--format", "{{.ID}}"]); + const query = (filters: readonly string[]): { ids: string[]; ok: boolean } => { + const result = podman([ + "ps", + "-a", + "--no-trunc", + ...filters.flatMap((filter) => ["--filter", filter]), + "--format", + "{{.ID}}", + ]); if (result.status !== 0 || result.error) return { ids: [], ok: false }; return { ids: String(result.stdout ?? "") @@ -362,17 +363,22 @@ function discoverPodmanContainer( ok: true, }; }; - const exactName = query(`name=^${expectedContainerName}$`); - const podmanLabel = query(`label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`); - const dockerLabel = query(`label=${DOCKER_SANDBOX_NAME_LABEL}=${sandboxName}`); - const candidates = [...new Set([...exactName.ids, ...podmanLabel.ids, ...dockerLabel.ids])]; - const queriesSucceeded = exactName.ok && podmanLabel.ok && dockerLabel.ok; + const podmanLabels = query([ + `label=${PODMAN_MANAGED_LABEL}=true`, + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]); + const dockerLabels = query([ + `label=${DOCKER_MANAGED_BY_LABEL}=${DOCKER_MANAGED_BY_VALUE}`, + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]); + const candidates = [...new Set([...podmanLabels.ids, ...dockerLabels.ids])]; + const queriesSucceeded = podmanLabels.ok && dockerLabels.ok; const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { const count = (result: { ids: string[]; ok: boolean }): string => result.ok ? String(result.ids.length) : "query-error"; throw new Error( - `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; exact-name matches=${count(exactName)}, podman-label matches=${count(podmanLabel)}, docker-label matches=${count(dockerLabel)}, unique candidates=${String(candidates.length)}`, + `Portable demo lifecycle requires one exact OpenShell-owned Podman container for sandbox '${sandboxName}'; podman-ownership matches=${count(podmanLabels)}, docker-ownership matches=${count(dockerLabels)}, unique candidates=${String(candidates.length)}`, ); } return inspectPodmanContainer(candidates[0]!, sandboxName, podman); From 76a8d5dfa532c5a4fd205df3a833e74b104f2d36 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 18:47:01 -0700 Subject: [PATCH 27/42] fix(portable): recover replaced sandbox containers --- .../experimental/portable-demo-lifecycle.ts | 131 +++++++++++++----- 1 file changed, 96 insertions(+), 35 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index dd23a93716a..ae0ff7aec91 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -62,6 +62,11 @@ interface PodmanContainerInspection { running: boolean; } +interface PodmanContainerQuery { + ids: string[]; + ok: boolean; +} + export interface PortableDemoLifecycleDeps { platform?: NodeJS.Platform; stateDir?: string; @@ -341,47 +346,85 @@ function isMissingPodmanContainer(result: CommandResult): boolean { ); } +function queryPodmanContainerIds( + podman: NonNullable, + filters: readonly string[], +): PodmanContainerQuery { + const result = podman([ + "ps", + "-a", + "--no-trunc", + ...filters.flatMap((filter) => ["--filter", filter]), + "--format", + "{{.ID}}", + ]); + if (result.status !== 0 || result.error) return { ids: [], ok: false }; + return { + ids: String(result.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean), + ok: true, + }; +} + +function podmanQueryCount(result: PodmanContainerQuery): string { + return result.ok ? String(result.ids.length) : "query-error"; +} + +function podmanStorageEvidence(podman: NonNullable): string { + const result = podman(["info", "--format", "{{.Store.GraphRoot}}"]); + if (result.status !== 0 || result.error) return `graph-root=${commandDetail(result)}`; + const graphRoot = String(result.stdout ?? "") + .trim() + .replace(/\s+/gu, " "); + return `graph-root=${graphRoot || "empty"}`; +} + function discoverPodmanContainer( sandboxName: string, podman: NonNullable, + expectedSandboxId?: string, ): PodmanContainerInspection { - const query = (filters: readonly string[]): { ids: string[]; ok: boolean } => { - const result = podman([ - "ps", - "-a", - "--no-trunc", - ...filters.flatMap((filter) => ["--filter", filter]), - "--format", - "{{.ID}}", - ]); - if (result.status !== 0 || result.error) return { ids: [], ok: false }; - return { - ids: String(result.stdout ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean), - ok: true, - }; - }; - const podmanLabels = query([ - `label=${PODMAN_MANAGED_LABEL}=true`, + const identityFilters = [ `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ...(expectedSandboxId ? [`label=${OPENSHELL_SANDBOX_ID_LABEL}=${expectedSandboxId}`] : []), + ]; + const podmanLabels = queryPodmanContainerIds(podman, [ + `label=${PODMAN_MANAGED_LABEL}=true`, + ...identityFilters, ]); - const dockerLabels = query([ + const dockerLabels = queryPodmanContainerIds(podman, [ `label=${DOCKER_MANAGED_BY_LABEL}=${DOCKER_MANAGED_BY_VALUE}`, - `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ...identityFilters, ]); const candidates = [...new Set([...podmanLabels.ids, ...dockerLabels.ids])]; const queriesSucceeded = podmanLabels.ok && dockerLabels.ok; const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { - const count = (result: { ids: string[]; ok: boolean }): string => - result.ok ? String(result.ids.length) : "query-error"; + let recoveryEvidence = ""; + if (expectedSandboxId) { + const nameOnlyPodman = queryPodmanContainerIds(podman, [ + `label=${PODMAN_MANAGED_LABEL}=true`, + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]); + const nameOnlyDocker = queryPodmanContainerIds(podman, [ + `label=${DOCKER_MANAGED_BY_LABEL}=${DOCKER_MANAGED_BY_VALUE}`, + `label=${OPENSHELL_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]); + recoveryEvidence = `; name-only podman-ownership matches=${podmanQueryCount(nameOnlyPodman)}, name-only docker-ownership matches=${podmanQueryCount(nameOnlyDocker)}, ${podmanStorageEvidence(podman)}`; + } + throw new Error( + `Portable demo lifecycle requires one exact OpenShell-owned Podman container for sandbox '${sandboxName}'${expectedSandboxId ? ` with sandbox ID '${expectedSandboxId}'` : ""}; podman-ownership matches=${podmanQueryCount(podmanLabels)}, docker-ownership matches=${podmanQueryCount(dockerLabels)}, unique candidates=${String(candidates.length)}${recoveryEvidence}`, + ); + } + const inspection = inspectPodmanContainer(candidates[0]!, sandboxName, podman); + if (expectedSandboxId && inspection.sandboxId !== expectedSandboxId) { throw new Error( - `Portable demo lifecycle requires one exact OpenShell-owned Podman container for sandbox '${sandboxName}'; podman-ownership matches=${count(podmanLabels)}, docker-ownership matches=${count(dockerLabels)}, unique candidates=${String(candidates.length)}`, + `Portable demo lifecycle refused container '${inspection.containerId}' because its OpenShell sandbox ID changed`, ); } - return inspectPodmanContainer(candidates[0]!, sandboxName, podman); + return inspection; } function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { @@ -671,7 +714,7 @@ export function recoverPortableDemoSandboxLifecycle( ): PortableDemoLifecycleRecoveryResult { if ((context.agent ?? "openclaw") !== "openclaw") return { kind: "not-installed" }; const commandEnv = deps.env ?? process.env; - const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); + let receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); if (!receipt) return { kind: "not-installed" }; if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); @@ -681,17 +724,35 @@ export function recoverPortableDemoSandboxLifecycle( const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); const initialInspection = podman(["inspect", receipt.containerId]); + let inspection: PodmanContainerInspection; if (isMissingPodmanContainer(initialInspection)) { - throw new Error( - `Portable sandbox '${sandboxName}' container '${receipt.containerId}' is temporarily unavailable; its lifecycle receipt was preserved`, + const previousContainerId = receipt.containerId; + let replacement: PodmanContainerInspection; + try { + replacement = discoverPodmanContainer(sandboxName, podman, receipt.sandboxId); + } catch (error) { + throw new Error( + `Portable sandbox '${sandboxName}' recorded container '${previousContainerId}' is absent and no identity-preserving replacement was found; its lifecycle receipt was preserved. ${error instanceof Error ? error.message : String(error)}`, + ); + } + requireCommand( + podman(["update", "--restart=unless-stopped", replacement.containerId]), + `Restoring the portable restart policy for sandbox '${sandboxName}'`, + ); + receipt = { ...receipt, containerId: replacement.containerId }; + writeReceipt(receipt, stateDir); + (deps.log ?? console.log)( + ` Portable demo lifecycle rebound sandbox '${sandboxName}' from missing Podman container '${previousContainerId}' to '${replacement.containerId}' using OpenShell sandbox ID '${receipt.sandboxId}'.`, + ); + inspection = replacement; + } else { + inspection = inspectPodmanContainer( + receipt.containerId, + sandboxName, + podman, + initialInspection, ); } - let inspection = inspectPodmanContainer( - receipt.containerId, - sandboxName, - podman, - initialInspection, - ); if (inspection.sandboxId !== receipt.sandboxId) { throw new Error( `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, From 6f08ad751c2a93c6c0f92f5d3518aa72bd4d8d48 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 18:53:58 -0700 Subject: [PATCH 28/42] fix(portable): always restart GFN sandbox --- src/lib/onboard/experimental/portable-demo-lifecycle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index ae0ff7aec91..164b196fd15 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -696,7 +696,7 @@ export function installPortableDemoSandboxLifecycle( dashboardPort: parseDashboardPort(createdStartupArgv, sandboxName), }; requireCommand( - podman(["update", "--restart=unless-stopped", inspection.containerId]), + podman(["update", "--restart=always", inspection.containerId]), `Setting the portable restart policy for sandbox '${sandboxName}'`, ); writeReceipt(receipt, deps.stateDir ?? defaultStateDir(env)); @@ -736,7 +736,7 @@ export function recoverPortableDemoSandboxLifecycle( ); } requireCommand( - podman(["update", "--restart=unless-stopped", replacement.containerId]), + podman(["update", "--restart=always", replacement.containerId]), `Restoring the portable restart policy for sandbox '${sandboxName}'`, ); receipt = { ...receipt, containerId: replacement.containerId }; From 75e43414a612f6170baa5c24f53f061dbb297605 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 21:01:42 -0700 Subject: [PATCH 29/42] fix(portable): persist Podman container metadata --- src/lib/onboard.ts | 2 +- .../experimental/portable-demo-lifecycle.ts | 58 +++++- .../experimental/portable-host-preparation.ts | 165 +++++++++++++++++- 3 files changed, 207 insertions(+), 18 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b1d12cf97db..0a6ea757f0e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2724,7 +2724,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardForwardEnabled: manageDashboardForward, + dashboardForwardEnabled: manageDashboard, ...lifecycleRegistrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index ba72019b7e6..6ca99014eb3 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -101,6 +101,7 @@ export interface PortableDemoLifecycleDeps { executableFd: number, ) => void; loadManagedOllama?: () => string | null; + ensureGateway?: () => void; sleep?: (milliseconds: number) => void; now?: () => number; log?: (message: string) => void; @@ -432,13 +433,49 @@ function podmanQueryCount(result: PodmanContainerQuery): string { return result.ok ? String(result.ids.length) : "query-error"; } -function podmanStorageEvidence(podman: NonNullable): string { - const result = podman(["info", "--format", "{{.Store.GraphRoot}}"]); - if (result.status !== 0 || result.error) return `graph-root=${commandDetail(result)}`; - const graphRoot = String(result.stdout ?? "") - .trim() - .replace(/\s+/gu, " "); - return `graph-root=${graphRoot || "empty"}`; +function podmanIdentityResourceEvidence( + podman: NonNullable, + resource: "volume" | "secret", + sandboxId: string, +): string { + const result = podman([resource, "ls", "--format", "{{.Name}}"]); + if (result.status !== 0 || result.error) { + return `${resource}-sandbox-id-matches=${commandDetail(result)}`; + } + const matches = String(result.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.includes(sandboxId)).length; + return `${resource}-sandbox-id-matches=${String(matches)}`; +} + +function podmanStorageEvidence( + podman: NonNullable, + sandboxId?: string, +): string { + const result = podman([ + "info", + "--format", + "{{.Store.TransientStore}}|{{.Store.GraphRoot}}|{{.Store.RunRoot}}", + ]); + let storage: string; + if (result.status !== 0 || result.error) { + storage = `storage-info=${commandDetail(result)}`; + } else { + const [transientStore = "empty", graphRoot = "empty", runRoot = "empty"] = String( + result.stdout ?? "", + ) + .trim() + .split("|") + .map((value) => value.trim().replace(/\s+/gu, " ")); + storage = `transient-store=${transientStore || "empty"}, graph-root=${graphRoot || "empty"}, run-root=${runRoot || "empty"}`; + } + if (!sandboxId) return storage; + return [ + storage, + podmanIdentityResourceEvidence(podman, "volume", sandboxId), + podmanIdentityResourceEvidence(podman, "secret", sandboxId), + ].join(", "); } function discoverPodmanContainer( @@ -475,7 +512,9 @@ function discoverPodmanContainer( const queriesSucceeded = queries.every((query) => query.ok); const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { - const recoveryEvidence = expectedSandboxId ? `; ${podmanStorageEvidence(podman)}` : ""; + const recoveryEvidence = expectedSandboxId + ? `; ${podmanStorageEvidence(podman, expectedSandboxId)}` + : ""; throw new Error( `Portable demo lifecycle requires one exact OpenShell-owned Podman container for sandbox '${sandboxName}'${expectedSandboxId ? ` with sandbox ID '${expectedSandboxId}'` : ""}; found ${String(candidates.length)} (matches=${queries.map(podmanQueryCount).join(",")})${recoveryEvidence}`, ); @@ -891,6 +930,9 @@ export function installPortableDemoSandboxLifecycle( `Setting the portable restart policy for sandbox '${sandboxName}'`, ); writeReceipt(receipt, stateDir); + (deps.log ?? console.log)( + ` Portable demo lifecycle baseline: sandbox-id=${receipt.sandboxId}, container-id=${receipt.containerId}, ${podmanStorageEvidence(podman, receipt.sandboxId)}`, + ); return registryGeneration; } diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 9d01e8799a8..47a775f73ad 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -17,6 +17,7 @@ const REGISTRY_IMAGE = "docker.io/library/registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"; const HOST_COMMAND_TIMEOUT_MS = 30_000; const REGISTRY_COMMAND_TIMEOUT_MS = 300_000; +const MAX_STORAGE_CONFIG_BYTES = 128 * 1024; const REGISTRY_FRAGMENT = `[[registry]] location = "${PORTABLE_LOCAL_REGISTRY}" insecure = true @@ -36,6 +37,13 @@ Before=podman-restart.service type SpawnResult = ReturnType; +interface PodmanStorageInfo { + transientStore: boolean; + driver: string; + graphRoot: string; + runRoot: string; +} + export interface PortableHostPreparationDeps { platform?: NodeJS.Platform; home?: string; @@ -110,6 +118,137 @@ function writePrivateConfig(filePath: string, value: string): void { } } +function readStorageConfig(filePath: string): string | null { + let file; + try { + file = openRegularFileNoFollow(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + try { + return file.readUtf8(MAX_STORAGE_CONFIG_BYTES); + } finally { + file.close(); + } +} + +function readPodmanStorageInfo( + podman: NonNullable, + env: NodeJS.ProcessEnv, +): PodmanStorageInfo { + const result = podman( + [ + "info", + "--format", + "{{.Store.TransientStore}}|{{.Store.GraphDriverName}}|{{.Store.GraphRoot}}|{{.Store.RunRoot}}", + ], + env, + ); + requireCommand(result, "Reading the rootless Podman storage mode"); + const [transientStore, driver, graphRoot, runRoot, ...extra] = String(result.stdout ?? "") + .trim() + .split("|") + .map((value) => value.trim()); + if ( + extra.length > 0 || + (transientStore !== "true" && transientStore !== "false") || + !driver || + !path.isAbsolute(graphRoot ?? "") || + !path.isAbsolute(runRoot ?? "") + ) { + throw new Error("Reading the rootless Podman storage mode returned invalid data"); + } + return { + transientStore: transientStore === "true", + driver, + graphRoot: graphRoot!, + runRoot: runRoot!, + }; +} + +function persistentStorageConfig(source: string, info: PodmanStorageInfo): string { + const lines = source.replace(/\r\n/gu, "\n").split("\n"); + const storageStart = lines.findIndex((line) => /^\s*\[storage\]\s*(?:#.*)?$/u.test(line)); + if (storageStart < 0) { + throw new Error("The active Podman storage configuration has no [storage] table"); + } + const nextTable = lines.findIndex( + (line, index) => index > storageStart && /^\s*\[[^\]]+\]\s*(?:#.*)?$/u.test(line), + ); + const storageEnd = nextTable < 0 ? lines.length : nextTable; + const values: Readonly> = { + driver: JSON.stringify(info.driver), + graphroot: JSON.stringify(info.graphRoot), + runroot: JSON.stringify(info.runRoot), + transient_store: "false", + }; + const seen = new Set(); + for (let index = storageStart + 1; index < storageEnd; index += 1) { + const match = /^\s*(driver|graphroot|runroot|transient_store)\s*=/u.exec(lines[index] ?? ""); + if (!match) continue; + const key = match[1]!; + lines[index] = `${key} = ${values[key]}`; + seen.add(key); + } + const missing = Object.keys(values) + .filter((key) => !seen.has(key)) + .map((key) => `${key} = ${values[key]}`); + lines.splice(storageStart + 1, 0, ...missing); + return `${lines.join("\n").replace(/\n*$/u, "")}\n`; +} + +function ensurePersistentPodmanStore( + home: string, + env: NodeJS.ProcessEnv, + podman: NonNullable, +): string | null { + const before = readPodmanStorageInfo(podman, env); + if (!before.transientStore) return null; + + const existingContainers = podman(["ps", "-a", "--format", "{{.Names}}"], env); + requireCommand(existingContainers, "Checking the transient Podman container store"); + const names = String(existingContainers.stdout ?? "") + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean); + if (names.length > 0) { + throw new Error( + `The portable profile cannot migrate a non-empty transient Podman store safely (containers: ${names.join(", ")}). Uninstall those transient containers, then rerun the portable installer.`, + ); + } + + const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"); + const target = path.join(configHome, "containers", "storage.conf"); + const sourceCandidates = [ + env.CONTAINERS_STORAGE_CONF?.trim(), + target, + "/etc/containers/storage.conf", + "/usr/share/containers/storage.conf", + ].filter((candidate): candidate is string => Boolean(candidate)); + let source: string | null = null; + for (const candidate of new Set(sourceCandidates)) { + source = readStorageConfig(candidate); + if (source !== null) break; + } + source ??= "[storage]\n"; + writePrivateConfig(target, persistentStorageConfig(source, before)); + env.CONTAINERS_STORAGE_CONF = target; + + const after = readPodmanStorageInfo(podman, env); + if ( + after.transientStore || + after.driver !== before.driver || + after.graphRoot !== before.graphRoot || + after.runRoot !== before.runRoot + ) { + throw new Error( + "The portable profile could not switch Podman to persistent container metadata without changing its existing storage paths", + ); + } + return target; +} + function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): string { const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"); writePrivateConfig( @@ -203,6 +342,20 @@ export function preparePortableExperimentalHost( const home = deps.home ?? env.HOME ?? os.homedir(); env.NETAVARK_FW = "iptables"; env.CONTAINERS_CONF = writePortableRuntimeConfig(home, env); + const podman = + deps.podman ?? + ((args, childEnv) => + spawnSync("podman", [...args], { + encoding: "utf-8", + env: childEnv, + timeout: HOST_COMMAND_TIMEOUT_MS, + })); + const podmanEnv = localPodmanEnvironment(env); + const storageConf = ensurePersistentPodmanStore(home, podmanEnv, podman); + if (storageConf) { + env.CONTAINERS_STORAGE_CONF = storageConf; + podmanEnv.CONTAINERS_STORAGE_CONF = storageConf; + } const systemctl = deps.systemctl ?? @@ -223,6 +376,9 @@ export function preparePortableExperimentalHost( "set-environment", "NETAVARK_FW=iptables", `CONTAINERS_CONF=${env.CONTAINERS_CONF}`, + ...(env.CONTAINERS_STORAGE_CONF + ? [`CONTAINERS_STORAGE_CONF=${env.CONTAINERS_STORAGE_CONF}`] + : []), ], env, ), @@ -241,15 +397,6 @@ export function preparePortableExperimentalHost( "Enabling rootless container restart after login", ); - const podman = - deps.podman ?? - ((args, childEnv) => - spawnSync("podman", [...args], { - encoding: "utf-8", - env: childEnv, - timeout: HOST_COMMAND_TIMEOUT_MS, - })); - const podmanEnv = localPodmanEnvironment(env); const dockerHost = resolvePodmanDockerHost( podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], podmanEnv), ); From e014b1ec25ea3f043ad30db3ef0e61a73201a2a1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 21:33:02 -0700 Subject: [PATCH 30/42] fix(portable): keep Podman state in durable home --- .../experimental/portable-host-preparation.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 47a775f73ad..d7fec6f5789 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -167,7 +167,11 @@ function readPodmanStorageInfo( }; } -function persistentStorageConfig(source: string, info: PodmanStorageInfo): string { +function persistentStorageConfig( + source: string, + info: PodmanStorageInfo, + durableGraphRoot: string, +): string { const lines = source.replace(/\r\n/gu, "\n").split("\n"); const storageStart = lines.findIndex((line) => /^\s*\[storage\]\s*(?:#.*)?$/u.test(line)); if (storageStart < 0) { @@ -179,15 +183,21 @@ function persistentStorageConfig(source: string, info: PodmanStorageInfo): strin const storageEnd = nextTable < 0 ? lines.length : nextTable; const values: Readonly> = { driver: JSON.stringify(info.driver), - graphroot: JSON.stringify(info.graphRoot), + graphroot: JSON.stringify(durableGraphRoot), + ...(info.graphRoot === durableGraphRoot + ? {} + : { imagestore: JSON.stringify(info.graphRoot) }), runroot: JSON.stringify(info.runRoot), transient_store: "false", }; const seen = new Set(); for (let index = storageStart + 1; index < storageEnd; index += 1) { - const match = /^\s*(driver|graphroot|runroot|transient_store)\s*=/u.exec(lines[index] ?? ""); + const match = /^\s*(driver|graphroot|imagestore|runroot|transient_store)\s*=/u.exec( + lines[index] ?? "", + ); if (!match) continue; const key = match[1]!; + if (!Object.hasOwn(values, key)) continue; lines[index] = `${key} = ${values[key]}`; seen.add(key); } @@ -204,17 +214,18 @@ function ensurePersistentPodmanStore( podman: NonNullable, ): string | null { const before = readPodmanStorageInfo(podman, env); - if (!before.transientStore) return null; + const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman"); + if (!before.transientStore && before.graphRoot === durableGraphRoot) return null; const existingContainers = podman(["ps", "-a", "--format", "{{.Names}}"], env); - requireCommand(existingContainers, "Checking the transient Podman container store"); + requireCommand(existingContainers, "Checking the current Podman container store"); const names = String(existingContainers.stdout ?? "") .split(/\r?\n/u) .map((name) => name.trim()) .filter(Boolean); if (names.length > 0) { throw new Error( - `The portable profile cannot migrate a non-empty transient Podman store safely (containers: ${names.join(", ")}). Uninstall those transient containers, then rerun the portable installer.`, + `The portable profile cannot migrate a non-empty Podman store safely (containers: ${names.join(", ")}). Uninstall those containers, then rerun the portable installer.`, ); } @@ -232,18 +243,18 @@ function ensurePersistentPodmanStore( if (source !== null) break; } source ??= "[storage]\n"; - writePrivateConfig(target, persistentStorageConfig(source, before)); + writePrivateConfig(target, persistentStorageConfig(source, before, durableGraphRoot)); env.CONTAINERS_STORAGE_CONF = target; const after = readPodmanStorageInfo(podman, env); if ( after.transientStore || after.driver !== before.driver || - after.graphRoot !== before.graphRoot || + after.graphRoot !== durableGraphRoot || after.runRoot !== before.runRoot ) { throw new Error( - "The portable profile could not switch Podman to persistent container metadata without changing its existing storage paths", + "The portable profile could not move Podman metadata into durable user storage while preserving its image store", ); } return target; From 84dde512e57d479c7e75312b44cf2c5b6e4a8ebb Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 21:48:23 -0700 Subject: [PATCH 31/42] fix(portable): isolate Podman image cache --- .../experimental/portable-host-preparation.ts | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index d7fec6f5789..59efb758acc 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -171,6 +171,7 @@ function persistentStorageConfig( source: string, info: PodmanStorageInfo, durableGraphRoot: string, + durableImageStore: string, ): string { const lines = source.replace(/\r\n/gu, "\n").split("\n"); const storageStart = lines.findIndex((line) => /^\s*\[storage\]\s*(?:#.*)?$/u.test(line)); @@ -184,9 +185,7 @@ function persistentStorageConfig( const values: Readonly> = { driver: JSON.stringify(info.driver), graphroot: JSON.stringify(durableGraphRoot), - ...(info.graphRoot === durableGraphRoot - ? {} - : { imagestore: JSON.stringify(info.graphRoot) }), + imagestore: JSON.stringify(durableImageStore), runroot: JSON.stringify(info.runRoot), transient_store: "false", }; @@ -208,27 +207,28 @@ function persistentStorageConfig( return `${lines.join("\n").replace(/\n*$/u, "")}\n`; } +function configuredImageStore(source: string): string | null { + const storageStart = source.search(/^\s*\[storage\]\s*(?:#.*)?$/mu); + if (storageStart < 0) return null; + const storage = source.slice(storageStart); + const nextTable = storage.slice(1).search(/^\s*\[[^\]]+\]\s*(?:#.*)?$/mu); + const table = nextTable < 0 ? storage : storage.slice(0, nextTable + 1); + const match = /^\s*imagestore\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/mu.exec(table); + if (!match) return null; + try { + const value: unknown = JSON.parse(match[1]!); + return typeof value === "string" && path.isAbsolute(value) ? value : null; + } catch { + return null; + } +} + function ensurePersistentPodmanStore( home: string, env: NodeJS.ProcessEnv, podman: NonNullable, ): string | null { const before = readPodmanStorageInfo(podman, env); - const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman"); - if (!before.transientStore && before.graphRoot === durableGraphRoot) return null; - - const existingContainers = podman(["ps", "-a", "--format", "{{.Names}}"], env); - requireCommand(existingContainers, "Checking the current Podman container store"); - const names = String(existingContainers.stdout ?? "") - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean); - if (names.length > 0) { - throw new Error( - `The portable profile cannot migrate a non-empty Podman store safely (containers: ${names.join(", ")}). Uninstall those containers, then rerun the portable installer.`, - ); - } - const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"); const target = path.join(configHome, "containers", "storage.conf"); const sourceCandidates = [ @@ -243,7 +243,37 @@ function ensurePersistentPodmanStore( if (source !== null) break; } source ??= "[storage]\n"; - writePrivateConfig(target, persistentStorageConfig(source, before, durableGraphRoot)); + const currentImageStore = configuredImageStore(source) ?? before.graphRoot; + const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman-v2"); + const durableImageStore = + currentImageStore === "/kiosk-persistent" || + currentImageStore.startsWith("/kiosk-persistent/") + ? "/kiosk-persistent/nemoclaw-images-v2" + : path.join(home, ".nemoclaw", "portable-images-v2"); + if ( + !before.transientStore && + before.graphRoot === durableGraphRoot && + currentImageStore === durableImageStore + ) { + return null; + } + + const existingContainers = podman(["ps", "-a", "--format", "{{.Names}}"], env); + requireCommand(existingContainers, "Checking the current Podman container store"); + const names = String(existingContainers.stdout ?? "") + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean); + if (names.length > 0) { + throw new Error( + `The portable profile cannot migrate a non-empty Podman store safely (containers: ${names.join(", ")}). Uninstall those containers, then rerun the portable installer.`, + ); + } + + writePrivateConfig( + target, + persistentStorageConfig(source, before, durableGraphRoot, durableImageStore), + ); env.CONTAINERS_STORAGE_CONF = target; const after = readPodmanStorageInfo(podman, env); @@ -254,7 +284,7 @@ function ensurePersistentPodmanStore( after.runRoot !== before.runRoot ) { throw new Error( - "The portable profile could not move Podman metadata into durable user storage while preserving its image store", + "The portable profile could not configure durable Podman metadata and isolated image storage", ); } return target; From bad313458cc0fd8434d9bb25cc564e3032e822a3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 22:09:18 -0700 Subject: [PATCH 32/42] fix(portable): persist complete Podman store --- .../experimental/portable-demo-lifecycle.ts | 11 ++++++--- .../experimental/portable-host-preparation.ts | 23 +++++++------------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 6ca99014eb3..608994f43ac 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -194,9 +194,14 @@ function defaultSleep(milliseconds: number): void { } function commandDetail(result: CommandResult): string { - if (result.error) - return (result.error as NodeJS.ErrnoException).code ?? "command execution error"; - return `exit ${String(result.status)}`; + const output = String(result.stderr ?? result.stdout ?? "") + .trim() + .replace(/\s+/gu, " ") + .slice(0, 2_048); + const status = result.error + ? ((result.error as NodeJS.ErrnoException).code ?? "command execution error") + : `exit ${String(result.status)}`; + return output ? `${status}: ${output}` : status; } function requireCommand(result: CommandResult, action: string): void { diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 59efb758acc..3e8e5b046e7 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -171,7 +171,6 @@ function persistentStorageConfig( source: string, info: PodmanStorageInfo, durableGraphRoot: string, - durableImageStore: string, ): string { const lines = source.replace(/\r\n/gu, "\n").split("\n"); const storageStart = lines.findIndex((line) => /^\s*\[storage\]\s*(?:#.*)?$/u.test(line)); @@ -185,7 +184,6 @@ function persistentStorageConfig( const values: Readonly> = { driver: JSON.stringify(info.driver), graphroot: JSON.stringify(durableGraphRoot), - imagestore: JSON.stringify(durableImageStore), runroot: JSON.stringify(info.runRoot), transient_store: "false", }; @@ -196,6 +194,10 @@ function persistentStorageConfig( ); if (!match) continue; const key = match[1]!; + if (key === "imagestore") { + lines[index] = ""; + continue; + } if (!Object.hasOwn(values, key)) continue; lines[index] = `${key} = ${values[key]}`; seen.add(key); @@ -243,17 +245,11 @@ function ensurePersistentPodmanStore( if (source !== null) break; } source ??= "[storage]\n"; - const currentImageStore = configuredImageStore(source) ?? before.graphRoot; - const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman-v2"); - const durableImageStore = - currentImageStore === "/kiosk-persistent" || - currentImageStore.startsWith("/kiosk-persistent/") - ? "/kiosk-persistent/nemoclaw-images-v2" - : path.join(home, ".nemoclaw", "portable-images-v2"); + const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman-v3"); if ( !before.transientStore && before.graphRoot === durableGraphRoot && - currentImageStore === durableImageStore + configuredImageStore(source) === null ) { return null; } @@ -270,10 +266,7 @@ function ensurePersistentPodmanStore( ); } - writePrivateConfig( - target, - persistentStorageConfig(source, before, durableGraphRoot, durableImageStore), - ); + writePrivateConfig(target, persistentStorageConfig(source, before, durableGraphRoot)); env.CONTAINERS_STORAGE_CONF = target; const after = readPodmanStorageInfo(podman, env); @@ -284,7 +277,7 @@ function ensurePersistentPodmanStore( after.runRoot !== before.runRoot ) { throw new Error( - "The portable profile could not configure durable Podman metadata and isolated image storage", + "The portable profile could not configure the complete Podman store in durable user storage", ); } return target; From 64779f038562cc57c993d3e014d8290f0cf03ea3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 22:25:23 -0700 Subject: [PATCH 33/42] fix(portable): remove registry on uninstall --- src/lib/actions/uninstall/run-plan.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index c026b878c3b..9665735a4e5 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1957,8 +1957,24 @@ function resolvePreserveSet( ); return []; } - const preservable = detectPreservableEntries(paths, runtime); - if (preservable.length === 0) return PRESERVED_USER_DATA_ENTRIES; + const portableLifecycleInstalled = runtime.existsSync( + path.join(paths.nemoclawStateDir, "portable-demo-lifecycle"), + ); + const defaultPreserveEntries = portableLifecycleInstalled + ? PRESERVED_USER_DATA_ENTRIES.filter((name) => name !== "sandboxes.json") + : PRESERVED_USER_DATA_ENTRIES; + if ( + portableLifecycleInstalled && + runtime.existsSync(path.join(paths.nemoclawStateDir, "sandboxes.json")) + ) { + runtime.log( + "Portable lifecycle state detected; removing sandboxes.json so uninstall cannot leave stranded sandbox registrations.", + ); + } + const preservable = detectPreservableEntries(paths, runtime).filter((name) => + defaultPreserveEntries.includes(name), + ); + if (preservable.length === 0) return defaultPreserveEntries; const nonInteractive = !runtime.isTty || options.assumeYes || runtime.env.NEMOCLAW_NON_INTERACTIVE === "1"; if (nonInteractive) { @@ -1967,7 +1983,7 @@ function resolvePreserveSet( " Pass --destroy-user-data (or set NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1) to purge user data on uninstall.", ); warnPreservedRegistryUnrecoverable(preservable, runtime); - return PRESERVED_USER_DATA_ENTRIES; + return defaultPreserveEntries; } runtime.log(`The following user data under ${paths.nemoclawStateDir} is preserved by default:`); for (const name of preservable) runtime.log(` · ${name}`); @@ -1979,7 +1995,7 @@ function resolvePreserveSet( } runtime.log("Keeping user data."); warnPreservedRegistryUnrecoverable(preservable, runtime); - return PRESERVED_USER_DATA_ENTRIES; + return defaultPreserveEntries; } function executePlan( From 434f35be29afc8a510e1f813866486171d3bbb00 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 22:30:24 -0700 Subject: [PATCH 34/42] fix(portable): remove UID-mapped stores --- src/lib/actions/uninstall/run-plan.ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 9665735a4e5..e83b75d8848 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -269,6 +269,7 @@ const PRESERVED_USER_DATA_ENTRIES: readonly string[] = [ "backups", "sandboxes.json", ]; +const PORTABLE_PODMAN_STORE_PATTERN = /^portable-podman(?:-v[1-9][0-9]*)?$/u; const HTTPS_PIN_RUNTIME_ADAPTER_STATE_ENTRIES: readonly string[] = [ "https-pin-runtime-adapter.pid", @@ -383,6 +384,39 @@ function removePathExcept( return true; } +function removePortablePodmanStores(paths: UninstallPaths, runtime: UninstallRuntime): boolean { + if (!runtime.existsSync(paths.nemoclawStateDir)) return true; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(paths.nemoclawStateDir, { withFileTypes: true }); + } catch (error) { + runtime.warn( + `Failed to inspect portable Podman stores under ${paths.nemoclawStateDir}: ${formatError(error)}`, + ); + return false; + } + const stores = entries + .filter((entry) => PORTABLE_PODMAN_STORE_PATTERN.test(entry.name)) + .map((entry) => path.join(paths.nemoclawStateDir, entry.name)); + if (stores.length === 0) return true; + if (!runtime.commandExists("podman")) { + runtime.warn("Podman is required to remove portable UID-mapped container storage."); + return false; + } + for (const store of stores) { + const result = runtime.run("podman", ["unshare", "rm", "-rf", "--", store], { + env: runtime.env, + stdio: "ignore", + }); + if (result.status !== 0 || runtime.existsSync(store)) { + runtime.warn(`Failed to remove portable Podman store ${store}.`); + return false; + } + runtime.log(`Removed portable Podman store ${store}`); + } + return true; +} + function removeFileWithOptionalSudo(target: string, deps: UninstallRuntime): void { if (!deps.existsSync(target)) return; const parent = path.dirname(target); @@ -2163,6 +2197,9 @@ function executePlan( } const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const selectedIsDefault = path.resolve(paths.nemoclawStateDir) === path.resolve(sharedRoot); + if (!scopedToSelectedGateway && !removePortablePodmanStores(paths, runtime)) { + return { ok: false }; + } if (scopedToSelectedGateway && selectedIsDefault && sharedRegistryMustBePreserved) { if ( !preserveUnderStateDir.includes("sandboxes.json") && From 1f94fded8ea469a97e093b00e999501444d99325 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 22:48:12 -0700 Subject: [PATCH 35/42] fix(portable): recover stale Podman namespace --- .../experimental/portable-demo-lifecycle.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 608994f43ac..1fcd8b600bc 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -407,6 +407,15 @@ function isMissingPodmanContainer(result: CommandResult): boolean { ); } +function isStaleRootlessPauseProcess(result: CommandResult): boolean { + if (result.status === 0 && !result.error) return false; + const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; + return ( + /invalid internal status.*(?:podman )?system migrate/isu.test(detail) && + /(?:common\.pid|pause process)/iu.test(detail) + ); +} + interface PodmanContainerQuery { ids: string[]; ok: boolean; @@ -978,7 +987,17 @@ export function recoverPortableDemoSandboxLifecycle( deps.ensureGateway?.(); const podmanEnv = localPodmanEnvironment(commandEnv); const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); - const initialInspection = podman(["inspect", receipt.containerId]); + let initialInspection = podman(["inspect", receipt.containerId]); + if (isStaleRootlessPauseProcess(initialInspection)) { + requireCommand( + podman(["system", "migrate"]), + "Resetting the stale rootless Podman pause process", + ); + (deps.log ?? console.log)( + " Reset stale rootless Podman namespace state after the portable host resumed.", + ); + initialInspection = podman(["inspect", receipt.containerId]); + } let inspection: PodmanContainerInspection; if (isMissingPodmanContainer(initialInspection)) { const previousContainerId = receipt.containerId; From 793a339cae16e83e68249e1cca12f5a623ec5d05 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 22:57:43 -0700 Subject: [PATCH 36/42] fix(portable): retry Podman 5.4 migration panic --- .../experimental/portable-demo-lifecycle.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 1fcd8b600bc..4956cb3b533 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -416,6 +416,16 @@ function isStaleRootlessPauseProcess(result: CommandResult): boolean { ); } +function isPodmanMigrationCleanupPanic(result: CommandResult): boolean { + if (result.status === 0 && !result.error) return false; + const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; + return ( + /panic: runtime error: invalid memory address or nil pointer dereference/iu.test(detail) && + /storageService\)\.UnmountContainerImage/iu.test(detail) && + /Runtime\)\.Migrate/iu.test(detail) + ); +} + interface PodmanContainerQuery { ids: string[]; ok: boolean; @@ -989,10 +999,14 @@ export function recoverPortableDemoSandboxLifecycle( const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); let initialInspection = podman(["inspect", receipt.containerId]); if (isStaleRootlessPauseProcess(initialInspection)) { - requireCommand( - podman(["system", "migrate"]), - "Resetting the stale rootless Podman pause process", - ); + let migration = podman(["system", "migrate"]); + if (isPodmanMigrationCleanupPanic(migration)) { + (deps.log ?? console.log)( + " Podman stopped stale portable workloads but crashed during 5.4.x storage cleanup; completing its rootless namespace migration.", + ); + migration = podman(["system", "migrate"]); + } + requireCommand(migration, "Resetting the stale rootless Podman pause process"); (deps.log ?? console.log)( " Reset stale rootless Podman namespace state after the portable host resumed.", ); From 9cf097fefab361df8e25590ef898d591f2a92b42 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 7 Aug 2026 23:15:52 -0700 Subject: [PATCH 37/42] fix(portable): pin OpenShell 0.0.85 --- nemoclaw-blueprint/blueprint.yaml | 4 +- scripts/brev-launchable-ci-cpu.sh | 10 ++-- scripts/install-openshell.sh | 50 +++++++++---------- .../actions/sandbox/mcp-bridge-validation.ts | 2 +- .../onboard/docker-driver-gateway-runtime.ts | 1 + .../experimental/portable-demo-lifecycle.ts | 35 +------------ src/lib/onboard/openshell-feature-gate.ts | 3 ++ src/lib/onboard/openshell-install.ts | 2 +- src/lib/onboard/openshell-version.ts | 2 +- 9 files changed, 40 insertions(+), 69 deletions(-) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 1fbbfcc2e77..ea06893b29e 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -3,8 +3,8 @@ version: "0.1.0" # Requires OpenShell MCP/JSON-RPC L7 policy support from NVIDIA/OpenShell#1865. -min_openshell_version: "0.0.101" -max_openshell_version: "0.0.101" +min_openshell_version: "0.0.85" +max_openshell_version: "0.0.85" min_openclaw_version: "2026.3.11" # Mirrors the components.sandbox.image manifest digest below. Lets a # downstream consumer (or release tooling) verify the blueprint declares diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index e4766cceb53..6a1b65a57af 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -73,7 +73,7 @@ assert_openshell_version() { if [ -z "$OPENSHELL_VERSION" ]; then case "${NEMOCLAW_OPENSHELL_CHANNEL:-stable}" in dev) OPENSHELL_VERSION="dev" ;; - stable | auto) OPENSHELL_VERSION="v0.0.101" ;; + stable | auto) OPENSHELL_VERSION="v0.0.85" ;; *) fail "NEMOCLAW_OPENSHELL_CHANNEL must be one of: stable, dev, auto" ;; esac fi @@ -148,11 +148,11 @@ openshell_cli_asset_for_arch() { openshell_cli_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.101:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "7d49ab2a5ff0b826bd2bdca5e0244010f832dfc6901c808ea8c8467004c26913" + v0.0.85:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "078fa086f506832c3d47d992e6109f26074bdd55916ce268e47c3971423459eb" ;; - v0.0.101:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "b553d3bfc08e9354b990a10fb8abd976e039afeec2d3947f8a112018be40d296" + v0.0.85:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "3cf353e7994d5835a233fe0641f9a860779190b054d0f90a04c897be782734b8" ;; *) return 1 diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index a3b05e08109..c2338ab7fa9 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -37,16 +37,16 @@ info "Detected $OS_LABEL ($ARCH_LABEL)" # round-trippable base policies: WebSocket text frames, provider-shaped # aliases, REST request bodies, MCP/JSON-RPC L7 enforcement, and # `policy get --base` for MCP/JSON-RPC-safe read-modify-write operations. -MIN_VERSION="0.0.101" +MIN_VERSION="0.0.85" # Maximum version validated for this NemoClaw release. Newer OpenShell builds # may change sandbox semantics; upgrade NemoClaw before upgrading past this. -MAX_VERSION="0.0.101" +MAX_VERSION="0.0.85" # Pin fresh installs to this version. The TS installer normally overrides this # via NEMOCLAW_OPENSHELL_PIN_VERSION after resolving the highest published # OpenShell release that satisfies the blueprint's max_openshell_version # (see #3404). The hardcoded value is the fallback for offline runs. PIN_VERSION="$MAX_VERSION" -DEV_MIN_VERSION="0.0.101" +DEV_MIN_VERSION="0.0.85" CHANNEL="${NEMOCLAW_OPENSHELL_CHANNEL:-auto}" case "$CHANNEL" in @@ -143,32 +143,32 @@ fi openshell_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.101:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "7d49ab2a5ff0b826bd2bdca5e0244010f832dfc6901c808ea8c8467004c26913" + v0.0.85:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "078fa086f506832c3d47d992e6109f26074bdd55916ce268e47c3971423459eb" ;; - v0.0.101:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "b553d3bfc08e9354b990a10fb8abd976e039afeec2d3947f8a112018be40d296" + v0.0.85:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "3cf353e7994d5835a233fe0641f9a860779190b054d0f90a04c897be782734b8" ;; - v0.0.101:openshell-aarch64-apple-darwin.tar.gz) - printf '%s\n' "9daaccdb9e30e220d56dd6d6bf4bd00ccca8ae4ad2845f5f0d9b9da3eb8ee881" + v0.0.85:openshell-aarch64-apple-darwin.tar.gz) + printf '%s\n' "522c963f9515c7325b978e89022de76227ac245eefe1371292af1424434e2067" ;; - v0.0.101:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "eaeb094ccf7dcb1fe00c7e926e6aa9aaaefb89ecbef8343720628b0fd2d84654" + v0.0.85:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "718cc9f942f88565cacb13c39717b128d6acc8d336212d42d26243f36ab19ece" ;; - v0.0.101:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "ac842ccc2ab8b5682f7479d71532cc650839250a8a41dbfae2b871cbbdfd3279" + v0.0.85:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "09f2823f6e9c5f70f4482b200206eac455d789618da4ebe4acff042d794e7162" ;; - v0.0.101:openshell-gateway-aarch64-apple-darwin.tar.gz) - printf '%s\n' "0f9e195b7cde57f4c2080df95159c5e7e72b0248306abc242ae00a3bb6f07f14" + v0.0.85:openshell-gateway-aarch64-apple-darwin.tar.gz) + printf '%s\n' "5de3e08ad1bdb0cdd01373999f537edca3d8aca22ae1c29bc9926969fe401e45" ;; - v0.0.101:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "953b90eaa7d2fc1bb7bdf38eb0ada6fad7902b13f9f895ca20b89caeac483a9e" + v0.0.85:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "94306f057d862cd5c34a0daa7692491733bc5ca528a7b92f9f62f717fb70a9be" ;; - v0.0.101:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "c39b7ba3cf212b88712a00d2a0e3d28e2c1e0e9f47a9a6ca818a8f06ed2140aa" + v0.0.85:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "2c52b2971aecf125e41ed160d8d2f2addf04031906ca88f120ae3d436dd6b8f7" ;; - v0.0.101:openshell.rb) - printf '%s\n' "87fadc7b0c854aa44f71d5b3a206865070117cd27825d59c61da252a99f402a2" + v0.0.85:openshell.rb) + printf '%s\n' "f53c62777fed23b42427822d231670451ee4358efeb2660c41a7a38919211b23" ;; *) return 1 @@ -296,10 +296,10 @@ pinned_sandbox_build_version() { f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e) printf '%s\n' "0.0.99" ;; - # OpenShell v0.0.101 standalone sandbox binaries. - a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920 | \ - 88300e35f153123e4dc3021c537834dd6c0a09665a4a6d3974cd285d512345c4) - printf '%s\n' "0.0.101" + # OpenShell v0.0.85 standalone sandbox binaries. + 863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2 | \ + 680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d) + printf '%s\n' "0.0.85" ;; *) return 1 diff --git a/src/lib/actions/sandbox/mcp-bridge-validation.ts b/src/lib/actions/sandbox/mcp-bridge-validation.ts index 2254d8e6236..b5d8cac9d3c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-validation.ts @@ -22,7 +22,7 @@ import { normalizeMcpServerUrl } from "./mcp-bridge-url-validation"; // must reject a missing or malformed security manifest instead of letting the // CLI start with a weakened credential-name denylist. Input, package, image, // and workflow contracts pin its structure, installed path, and version. -import childVisibleCredentialManifest from "./openshell-child-visible-credentials.v0.0.101.json"; +import childVisibleCredentialManifest from "./openshell-child-visible-credentials.v0.0.85.json"; export { MCP_SERVER_URL_MAX_LENGTH, diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index e898681b3f3..a4f293360b2 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -35,6 +35,7 @@ import * as vmDriverProcess from "./vm-driver-process"; const OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS: Readonly> = { "0.0.72": "sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", + "0.0.85": "sha256:f4226253a3525c3832adac5b38b419a0f27d1e915effe565b5885e20f93cd5e9", "0.0.99": "sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", "0.0.101": "sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb", }; diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 4956cb3b533..608994f43ac 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -407,25 +407,6 @@ function isMissingPodmanContainer(result: CommandResult): boolean { ); } -function isStaleRootlessPauseProcess(result: CommandResult): boolean { - if (result.status === 0 && !result.error) return false; - const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; - return ( - /invalid internal status.*(?:podman )?system migrate/isu.test(detail) && - /(?:common\.pid|pause process)/iu.test(detail) - ); -} - -function isPodmanMigrationCleanupPanic(result: CommandResult): boolean { - if (result.status === 0 && !result.error) return false; - const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; - return ( - /panic: runtime error: invalid memory address or nil pointer dereference/iu.test(detail) && - /storageService\)\.UnmountContainerImage/iu.test(detail) && - /Runtime\)\.Migrate/iu.test(detail) - ); -} - interface PodmanContainerQuery { ids: string[]; ok: boolean; @@ -997,21 +978,7 @@ export function recoverPortableDemoSandboxLifecycle( deps.ensureGateway?.(); const podmanEnv = localPodmanEnvironment(commandEnv); const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); - let initialInspection = podman(["inspect", receipt.containerId]); - if (isStaleRootlessPauseProcess(initialInspection)) { - let migration = podman(["system", "migrate"]); - if (isPodmanMigrationCleanupPanic(migration)) { - (deps.log ?? console.log)( - " Podman stopped stale portable workloads but crashed during 5.4.x storage cleanup; completing its rootless namespace migration.", - ); - migration = podman(["system", "migrate"]); - } - requireCommand(migration, "Resetting the stale rootless Podman pause process"); - (deps.log ?? console.log)( - " Reset stale rootless Podman namespace state after the portable host resumed.", - ); - initialInspection = podman(["inspect", receipt.containerId]); - } + const initialInspection = podman(["inspect", receipt.containerId]); let inspection: PodmanContainerInspection; if (isMissingPodmanContainer(initialInspection)) { const previousContainerId = receipt.containerId; diff --git a/src/lib/onboard/openshell-feature-gate.ts b/src/lib/onboard/openshell-feature-gate.ts index 68773eee604..552d65a1321 100644 --- a/src/lib/onboard/openshell-feature-gate.ts +++ b/src/lib/onboard/openshell-feature-gate.ts @@ -56,6 +56,9 @@ const PINNED_SANDBOX_BUILD_VERSIONS = new Map([ // OpenShell v0.0.82 standalone sandbox binaries. ["145246049bd73c60452ac3c2b4b1801663196c8e2f80575af820289c78c1cf09", "0.0.82"], ["76bc19b70d9f1e1e9871307045796cd39cc7b8fc4c08ffc90593cc934f36d500", "0.0.82"], + // OpenShell v0.0.85 standalone sandbox binaries. + ["863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2", "0.0.85"], + ["680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d", "0.0.85"], // OpenShell v0.0.99 standalone sandbox binaries. ["a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214", "0.0.99"], ["f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e", "0.0.99"], diff --git a/src/lib/onboard/openshell-install.ts b/src/lib/onboard/openshell-install.ts index da4ea9ca8e2..105de2c36e6 100644 --- a/src/lib/onboard/openshell-install.ts +++ b/src/lib/onboard/openshell-install.ts @@ -157,7 +157,7 @@ export function ensureOpenshellForOnboard(deps: OpenShellInstallDeps): OpenShell localBin: null, futureShellPathHint: null, }; - const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.101"; + const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.85"; if (!deps.isOpenshellInstalled()) { deps.log(" openshell CLI not found. Installing..."); diff --git a/src/lib/onboard/openshell-version.ts b/src/lib/onboard/openshell-version.ts index fe03a5cb8dc..894de51627e 100644 --- a/src/lib/onboard/openshell-version.ts +++ b/src/lib/onboard/openshell-version.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { resolveOpenshell } from "../adapters/openshell/resolve"; import { ROOT, runCapture } from "../runner"; -export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.101"; +export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.85"; export function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { const openshellBin = resolveOpenshell(); From 4e3f478baedbaaf200e77b0601cdf8bb52b15d7b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 8 Aug 2026 00:23:02 -0700 Subject: [PATCH 38/42] fix(portable): restore podman run state on resume --- scripts/install.sh | 4 +- scripts/lib/openshell-gateway.service.in | 6 +- src/lib/actions/sandbox/vm-dns-monkeypatch.ts | 3 +- src/lib/actions/uninstall/run-plan.ts | 19 +++++-- .../experimental/portable-demo-lifecycle.ts | 55 +++++++++++++++++++ src/lib/onboard/gateway-binding.ts | 5 +- src/lib/onboard/host-gateway-process.ts | 3 +- src/lib/onboard/sandbox-create-failure.ts | 9 +++ src/lib/verify-deployment.ts | 2 +- 9 files changed, 93 insertions(+), 13 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 5b68c58e7a4..ae3f59e38fc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2755,9 +2755,9 @@ stop_legacy_openshell_gateway_process() { if [ -n "${NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR:-}" ]; then runtime_dir="${NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR}" elif [ "$gateway_port" -eq 8080 ]; then - runtime_dir="${HOME}/.local/state/nemoclaw/openshell-docker-gateway" + runtime_dir="${HOME}/.local/state/nemoclaw/openshell-docker-gateway-v0.0.85" else - runtime_dir="${HOME}/.local/state/nemoclaw/openshell-docker-gateway-${gateway_port}" + runtime_dir="${HOME}/.local/state/nemoclaw/openshell-docker-gateway-v0.0.85-${gateway_port}" fi pid_file="${runtime_dir}/openshell-gateway.pid" [ -f "$pid_file" ] || return 1 diff --git a/scripts/lib/openshell-gateway.service.in b/scripts/lib/openshell-gateway.service.in index 4b7796c7524..8b9be98d261 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -8,9 +8,9 @@ Documentation=https://github.com/NVIDIA/OpenShell [Service] Type=simple -StateDirectory=nemoclaw/openshell-docker-gateway -Environment=OPENSHELL_DB_URL=sqlite:%S/nemoclaw/openshell-docker-gateway/openshell.db -Environment=OPENSHELL_LOCAL_TLS_DIR=%S/nemoclaw/openshell-docker-gateway/tls +StateDirectory=nemoclaw/openshell-docker-gateway-v0.0.85 +Environment=OPENSHELL_DB_URL=sqlite:%S/nemoclaw/openshell-docker-gateway-v0.0.85/openshell.db +Environment=OPENSHELL_LOCAL_TLS_DIR=%S/nemoclaw/openshell-docker-gateway-v0.0.85/tls EnvironmentFile=-%E/openshell/gateway.env ExecStartPre=@OPENSHELL_GATEWAY_BIN@ generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal ExecStart=@OPENSHELL_GATEWAY_BIN@ diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts index b5e07858ae8..da0edc48879 100644 --- a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { type CaptureOpenshellResult, stripAnsi } from "../../adapters/openshell/client"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import type { SandboxEntry } from "../../state/registry"; +import { BASE_GATEWAY_STATE_DIR_NAME } from "../../onboard/gateway-binding"; const GVPROXY_DNS = "192.168.127.1"; const INIT_SCRIPT_RELATIVE_PATH = ["srv", "openshell-vm-sandbox-init.sh"] as const; @@ -48,7 +49,7 @@ export function shouldApplyVmDnsMonkeypatch( function dockerDriverGatewayStateDir(env: NodeJS.ProcessEnv, homeDir: string): string { const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; if (configured && configured.trim()) return path.resolve(configured.trim()); - return path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway"); + return path.join(homeDir, ".local", "state", "nemoclaw", BASE_GATEWAY_STATE_DIR_NAME); } export function parseSandboxIdFromGetOutput(output: string): string | null { diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index e83b75d8848..b35e715965a 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -404,10 +404,21 @@ function removePortablePodmanStores(paths: UninstallPaths, runtime: UninstallRun return false; } for (const store of stores) { - const result = runtime.run("podman", ["unshare", "rm", "-rf", "--", store], { - env: runtime.env, - stdio: "ignore", - }); + const result = runtime.run( + "podman", + [ + "unshare", + "sh", + "-c", + 'umount -l "$1/overlay" 2>/dev/null || true\nrm -rf -- "$1"', + "nemoclaw-portable-store-cleanup", + store, + ], + { + env: runtime.env, + stdio: "ignore", + }, + ); if (result.status !== 0 || runtime.existsSync(store)) { runtime.warn(`Failed to remove portable Podman store ${store}.`); return false; diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 608994f43ac..0c69683ca4a 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -399,6 +399,55 @@ function inspectPodmanContainer( return { containerId, sandboxId, running: state.Running }; } +function ensurePodmanContainerRunDirectory( + containerId: string, + sandboxName: string, + podman: NonNullable, + log: (message: string) => void, +): void { + const result = podman([ + "info", + "--format", + "{{.Store.GraphDriverName}}|{{.Store.RunRoot}}", + ]); + requireCommand(result, "Reading the portable Podman runtime location"); + const [driver, runRoot, ...extra] = String(result.stdout ?? "") + .trim() + .split("|") + .map((value) => value.trim()); + if ( + extra.length > 0 || + !driver || + !/^[a-z0-9][a-z0-9._+-]{0,63}$/u.test(driver) || + !path.isAbsolute(runRoot ?? "") || + /[\u0000-\u001f\u007f-\u009f]/u.test(runRoot ?? "") + ) { + throw new Error("Reading the portable Podman runtime location returned invalid data"); + } + const runDirectory = path.join( + runRoot!, + `${driver}-containers`, + containerId, + "userdata", + ); + if (fs.existsSync(runDirectory)) return; + + // containers/storage normally creates this per-session directory from its + // durable container record. GFN can discard the rootless RunRoot while the + // user manager (and Podman's alive marker) survives, so Podman skips its + // reboot refresh and later fails creating resolv.conf. Recreate the exact + // ContainerRunDirectory path before starting the already-validated container. + fs.mkdirSync(runDirectory, { mode: 0o700, recursive: true }); + const state = fs.lstatSync(runDirectory); + if (!state.isDirectory() || state.isSymbolicLink()) { + throw new Error( + `Portable Podman runtime path for sandbox '${sandboxName}' is not a directory`, + ); + } + fs.chmodSync(runDirectory, 0o700); + log(` Restored portable Podman runtime directory for sandbox '${sandboxName}'.`); +} + function isMissingPodmanContainer(result: CommandResult): boolean { if (result.status === 0 && !result.error) return false; const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; @@ -1014,6 +1063,12 @@ export function recoverPortableDemoSandboxLifecycle( ); } if (!inspection.running) { + ensurePodmanContainerRunDirectory( + receipt.containerId, + sandboxName, + podman, + deps.log ?? console.log, + ); requireCommand( podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, diff --git a/src/lib/onboard/gateway-binding.ts b/src/lib/onboard/gateway-binding.ts index de3a43ace7d..413b0d5de31 100644 --- a/src/lib/onboard/gateway-binding.ts +++ b/src/lib/onboard/gateway-binding.ts @@ -25,7 +25,10 @@ import type { GatewayReuseState } from "../state/gateway"; /** Gateway registration name used for the default gateway port. */ export const BASE_GATEWAY_NAME = "nemoclaw"; /** Docker-driver gateway state directory leaf name for the default port. */ -export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway"; +// OpenShell gateway databases are not downgrade-compatible. Keep the pinned +// 0.0.85 runtime in its own durable namespace so it cannot open a database +// already migrated by 0.0.99/0.0.101 during a portable/GFN reinstall. +export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway-v0.0.85"; /** Docker-driver gateway compatibility container name for the default port. */ export const BASE_GATEWAY_COMPAT_CONTAINER_NAME = "nemoclaw-openshell-gateway"; diff --git a/src/lib/onboard/host-gateway-process.ts b/src/lib/onboard/host-gateway-process.ts index 11c3e217702..78baf2bf7b1 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { waitUntil } from "../core/wait"; import { clearDockerDriverGatewayRuntimeMarker } from "./docker-driver-gateway-runtime-marker"; +import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; import { type OpenShellGatewayProcessTarget, hostGatewayCmdlineMatches as sharedHostGatewayCmdlineMatches, @@ -108,7 +109,7 @@ export function resolveDockerDriverGatewayStateDir( ): string { const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; if (configured && configured.trim()) return path.resolve(configured.trim()); - return path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway"); + return path.join(homeDir, ".local", "state", "nemoclaw", BASE_GATEWAY_STATE_DIR_NAME); } export function resolveDockerDriverGatewayPidFile( diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index 87ed446fe49..2028f0a2f06 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { GATEWAY_PORT } from "../core/ports"; import { rejectSymlinksOnPath } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; +import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; @@ -47,6 +48,14 @@ function timestampForPath(now: Date): string { function gatewayLogCandidates(homeDir: string): string[] { return [ + path.join( + homeDir, + ".local", + "state", + "nemoclaw", + BASE_GATEWAY_STATE_DIR_NAME, + "openshell-gateway.log", + ), path.join( homeDir, ".local", diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index 7e5631c67e9..17148690b6f 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -159,7 +159,7 @@ function buildGatewayLogHint(sandboxName: string, customRuntimeHint: string | nu `The gateway probe failed after retrying. Inspect the in-sandbox gateway log with ` + `\`nemoclaw ${sandboxName} logs\` (the gateway writes to /tmp/gateway.log inside the sandbox when it starts). ` + `If the sandbox itself never came up, also check the host-side OpenShell gateway log at ` + - `~/.local/state/nemoclaw/openshell-docker-gateway/openshell-gateway.log ` + + `~/.local/state/nemoclaw/openshell-docker-gateway-v0.0.85/openshell-gateway.log ` + `(or ~/.local/state/openshell/openshell-gateway.log on older installs).` ); } From 82ccbc9d171ea4f182e7caab2e963b0316ffde6f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 8 Aug 2026 01:07:50 -0700 Subject: [PATCH 39/42] fix(portable): isolate OpenShell 0.0.85 fallback --- scripts/install-openshell.sh | 20 + scripts/install.sh | 11 + src/lib/actions/sandbox/vm-dns-monkeypatch.ts | 3 +- src/lib/actions/uninstall/run-plan.ts | 72 +-- src/lib/onboard.ts | 2 +- .../experimental/portable-demo-lifecycle.ts | 449 +++++++++--------- .../experimental/portable-host-preparation.ts | 199 +------- src/lib/onboard/gateway-binding.ts | 5 +- src/lib/onboard/host-gateway-process.ts | 3 +- src/lib/onboard/sandbox-gpu-create-flow.ts | 6 +- 10 files changed, 285 insertions(+), 485 deletions(-) diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index c2338ab7fa9..6a042a1a3f5 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -361,6 +361,20 @@ component_matches_cli_build() { && component_build_versions_match "$openshell_version" "$component_version" } +report_installed_component_versions() { + local openshell_bin="$1" + local gateway_bin sandbox_bin cli_version gateway_version sandbox_version + gateway_bin="$(installed_component_path "$openshell_bin" openshell-gateway "${NEMOCLAW_OPENSHELL_GATEWAY_BIN:-}")" + sandbox_bin="$(selected_sandbox_component_path "$openshell_bin")" + cli_version="$(component_build_version "$openshell_bin" cli)" \ + || fail "Could not verify the installed OpenShell CLI version." + gateway_version="$(component_build_version "$gateway_bin" gateway)" \ + || fail "Could not verify the installed OpenShell gateway version." + sandbox_version="$(component_build_version "$sandbox_bin" sandbox)" \ + || fail "Could not verify the installed OpenShell sandbox version." + info "OpenShell components: CLI ${cli_version}; gateway ${gateway_version}; sandbox ${sandbox_version}" +} + required_driver_bins_present() { local openshell_bin="${1:-$(command -v openshell 2>/dev/null || true)}" local gateway_bin sandbox_bin @@ -806,6 +820,9 @@ if command -v openshell >/dev/null 2>&1; then if [ "$OS" = "Darwin" ] && ! command -v brew >/dev/null 2>&1; then warn "Homebrew is not installed; reusing the standalone OpenShell gateway without reboot persistence." fi + if [ "$OS" = "Linux" ]; then + report_installed_component_versions "$ACTIVE_OPENSHELL_BIN" + fi info "openshell already installed: $INSTALLED_VERSION (>= $MIN_VERSION, <= $MAX_VERSION, messaging rewrite, MCP L7, and policy --base capable)" exit 0 fi @@ -1002,4 +1019,7 @@ required_driver_bins_installed_in_dir "$target_dir" \ || fail "OpenShell release '$RELEASE_TAG' did not install the required Docker-driver binaries." require_openshell_messaging_features "$target_dir/openshell" +if [ "$OS" = "Linux" ]; then + report_installed_component_versions "$target_dir/openshell" +fi info "$("$target_dir/openshell" --version 2>&1 || echo openshell) installed" diff --git a/scripts/install.sh b/scripts/install.sh index ae3f59e38fc..fdb117a0bc9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2811,6 +2811,13 @@ preinstall_backup_and_retire_legacy_gateway() { fi _PREEXISTING_SANDBOX_COUNT="$sandbox_count" [ "$sandbox_count" -gt 0 ] 2>/dev/null || return 0 + if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" ]]; then + info "Portable OpenShell 0.0.85 install is isolated from existing OpenShell state; skipping gateway migration and sandbox backup." + if command_exists systemctl; then + systemctl --user stop nemoclaw-openshell-gateway.service >/dev/null 2>&1 || true + fi + return 0 + fi require_openshell_compatible_sandbox_names "$reg_file" if ! command_exists openshell; then # NemoClaw v0.0.55's OpenShell 0.0.44 layout could install this binary @@ -3107,6 +3114,10 @@ recover_preexisting_sandboxes_before_onboard() { if [ "${_PREEXISTING_SANDBOX_COUNT:-0}" -le 0 ] 2>/dev/null; then return 0 fi + if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" ]]; then + info "OpenShell 0.0.85 portable state is isolated; not migrating sandboxes from prior OpenShell stacks." + return 0 + fi info "Recovering and upgrading pre-existing sandboxes before onboarding…" # `--auto` is the existing non-interactive maintenance path. When the diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts index da0edc48879..4146b7ef754 100644 --- a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts @@ -5,10 +5,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { BASE_GATEWAY_STATE_DIR_NAME } from "../../onboard/gateway-binding"; + import { type CaptureOpenshellResult, stripAnsi } from "../../adapters/openshell/client"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import type { SandboxEntry } from "../../state/registry"; -import { BASE_GATEWAY_STATE_DIR_NAME } from "../../onboard/gateway-binding"; const GVPROXY_DNS = "192.168.127.1"; const INIT_SCRIPT_RELATIVE_PATH = ["srv", "openshell-vm-sandbox-init.sh"] as const; diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index b35e715965a..c026b878c3b 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -269,7 +269,6 @@ const PRESERVED_USER_DATA_ENTRIES: readonly string[] = [ "backups", "sandboxes.json", ]; -const PORTABLE_PODMAN_STORE_PATTERN = /^portable-podman(?:-v[1-9][0-9]*)?$/u; const HTTPS_PIN_RUNTIME_ADAPTER_STATE_ENTRIES: readonly string[] = [ "https-pin-runtime-adapter.pid", @@ -384,50 +383,6 @@ function removePathExcept( return true; } -function removePortablePodmanStores(paths: UninstallPaths, runtime: UninstallRuntime): boolean { - if (!runtime.existsSync(paths.nemoclawStateDir)) return true; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(paths.nemoclawStateDir, { withFileTypes: true }); - } catch (error) { - runtime.warn( - `Failed to inspect portable Podman stores under ${paths.nemoclawStateDir}: ${formatError(error)}`, - ); - return false; - } - const stores = entries - .filter((entry) => PORTABLE_PODMAN_STORE_PATTERN.test(entry.name)) - .map((entry) => path.join(paths.nemoclawStateDir, entry.name)); - if (stores.length === 0) return true; - if (!runtime.commandExists("podman")) { - runtime.warn("Podman is required to remove portable UID-mapped container storage."); - return false; - } - for (const store of stores) { - const result = runtime.run( - "podman", - [ - "unshare", - "sh", - "-c", - 'umount -l "$1/overlay" 2>/dev/null || true\nrm -rf -- "$1"', - "nemoclaw-portable-store-cleanup", - store, - ], - { - env: runtime.env, - stdio: "ignore", - }, - ); - if (result.status !== 0 || runtime.existsSync(store)) { - runtime.warn(`Failed to remove portable Podman store ${store}.`); - return false; - } - runtime.log(`Removed portable Podman store ${store}`); - } - return true; -} - function removeFileWithOptionalSudo(target: string, deps: UninstallRuntime): void { if (!deps.existsSync(target)) return; const parent = path.dirname(target); @@ -2002,24 +1957,8 @@ function resolvePreserveSet( ); return []; } - const portableLifecycleInstalled = runtime.existsSync( - path.join(paths.nemoclawStateDir, "portable-demo-lifecycle"), - ); - const defaultPreserveEntries = portableLifecycleInstalled - ? PRESERVED_USER_DATA_ENTRIES.filter((name) => name !== "sandboxes.json") - : PRESERVED_USER_DATA_ENTRIES; - if ( - portableLifecycleInstalled && - runtime.existsSync(path.join(paths.nemoclawStateDir, "sandboxes.json")) - ) { - runtime.log( - "Portable lifecycle state detected; removing sandboxes.json so uninstall cannot leave stranded sandbox registrations.", - ); - } - const preservable = detectPreservableEntries(paths, runtime).filter((name) => - defaultPreserveEntries.includes(name), - ); - if (preservable.length === 0) return defaultPreserveEntries; + const preservable = detectPreservableEntries(paths, runtime); + if (preservable.length === 0) return PRESERVED_USER_DATA_ENTRIES; const nonInteractive = !runtime.isTty || options.assumeYes || runtime.env.NEMOCLAW_NON_INTERACTIVE === "1"; if (nonInteractive) { @@ -2028,7 +1967,7 @@ function resolvePreserveSet( " Pass --destroy-user-data (or set NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1) to purge user data on uninstall.", ); warnPreservedRegistryUnrecoverable(preservable, runtime); - return defaultPreserveEntries; + return PRESERVED_USER_DATA_ENTRIES; } runtime.log(`The following user data under ${paths.nemoclawStateDir} is preserved by default:`); for (const name of preservable) runtime.log(` · ${name}`); @@ -2040,7 +1979,7 @@ function resolvePreserveSet( } runtime.log("Keeping user data."); warnPreservedRegistryUnrecoverable(preservable, runtime); - return defaultPreserveEntries; + return PRESERVED_USER_DATA_ENTRIES; } function executePlan( @@ -2208,9 +2147,6 @@ function executePlan( } const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const selectedIsDefault = path.resolve(paths.nemoclawStateDir) === path.resolve(sharedRoot); - if (!scopedToSelectedGateway && !removePortablePodmanStores(paths, runtime)) { - return { ok: false }; - } if (scopedToSelectedGateway && selectedIsDefault && sharedRegistryMustBePreserved) { if ( !preserveUnderStateDir.includes("sandboxes.json") && diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0a6ea757f0e..b1d12cf97db 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2724,7 +2724,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardForwardEnabled: manageDashboard, + dashboardForwardEnabled: manageDashboardForward, ...lifecycleRegistrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 0c69683ca4a..371096f7b5d 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -42,13 +42,14 @@ const PODMAN_MANAGED_LABEL = "openshell.managed"; const PODMAN_SANDBOX_ID_LABEL = "openshell.sandbox-id"; const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; const PODMAN_SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; -const DOCKER_MANAGED_BY_LABEL = "openshell.ai/managed-by"; -const DOCKER_MANAGED_BY_VALUE = "openshell"; -const OPENSHELL_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; -const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; +const STALE_NEWER_MANAGED_BY_LABEL = "openshell.ai/managed-by"; +const STALE_NEWER_MANAGED_BY_VALUE = "openshell"; +const STALE_NEWER_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; +const STALE_NEWER_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; -const CURRENT_RECEIPT_SCHEMA_VERSION = 3; +const PINNED_OPENSHELL_VERSION = "0.0.85"; +const CURRENT_RECEIPT_SCHEMA_VERSION = 4; const STARTUP_PROCESS_PATTERN = "^(/usr/local/bin/nemoclaw-start|(bash|/bin/bash|/usr/bin/bash) /usr/local/bin/nemoclaw-start)( |$)"; const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4)); @@ -61,12 +62,13 @@ type CommandResult = { }; interface PortableDemoLifecycleReceipt { - schemaVersion: 1 | 2 | 3; + schemaVersion: 1 | 2 | 3 | 4; sandboxName: string; sandboxId: string; containerId: string; dashboardPort: number; registryGeneration?: string; + openshellVersion?: typeof PINNED_OPENSHELL_VERSION; } interface PodmanContainerInspection { @@ -194,14 +196,9 @@ function defaultSleep(milliseconds: number): void { } function commandDetail(result: CommandResult): string { - const output = String(result.stderr ?? result.stdout ?? "") - .trim() - .replace(/\s+/gu, " ") - .slice(0, 2_048); - const status = result.error - ? ((result.error as NodeJS.ErrnoException).code ?? "command execution error") - : `exit ${String(result.status)}`; - return output ? `${status}: ${output}` : status; + if (result.error) + return (result.error as NodeJS.ErrnoException).code ?? "command execution error"; + return `exit ${String(result.status)}`; } function requireCommand(result: CommandResult, action: string): void { @@ -247,14 +244,17 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl const keys = Object.keys(receipt).sort(); const expectedKeys = receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION - ? "containerId,dashboardPort,registryGeneration,sandboxId,sandboxName,schemaVersion" - : "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion"; + ? "containerId,dashboardPort,openshellVersion,registryGeneration,sandboxId,sandboxName,schemaVersion" + : receipt.schemaVersion === 3 + ? "containerId,dashboardPort,registryGeneration,sandboxId,sandboxName,schemaVersion" + : "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion"; if (keys.join(",") !== expectedKeys) { throw new Error("Portable demo lifecycle receipt fields are invalid"); } if ( (receipt.schemaVersion !== 1 && receipt.schemaVersion !== 2 && + receipt.schemaVersion !== 3 && receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION) || receipt.sandboxName !== sandboxName || typeof receipt.containerId !== "string" || @@ -264,9 +264,11 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl !Number.isInteger(receipt.dashboardPort) || Number(receipt.dashboardPort) < 1024 || Number(receipt.dashboardPort) > 65535 || - (receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && + ((receipt.schemaVersion === 3 || receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION) && (typeof receipt.registryGeneration !== "string" || - !SANDBOX_ID_PATTERN.test(receipt.registryGeneration))) + !SANDBOX_ID_PATTERN.test(receipt.registryGeneration))) || + (receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && + receipt.openshellVersion !== PINNED_OPENSHELL_VERSION) ) { throw new Error("Portable demo lifecycle receipt values are invalid"); } @@ -281,8 +283,15 @@ function requireCurrentRegistryGeneration( // container ID may claim a missing registry generation only after exact // local runtime validation; an existing generation must already match. const receiptGeneration = - receipt.schemaVersion === 3 ? receipt.registryGeneration : receipt.containerId; - if (registryGeneration === undefined && receipt.schemaVersion !== 3) return true; + receipt.schemaVersion === 3 || receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION + ? receipt.registryGeneration + : receipt.containerId; + if ( + registryGeneration === undefined && + receipt.schemaVersion !== 3 && + receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION + ) + return true; if (receiptGeneration !== registryGeneration) { throw new Error( `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' does not belong to the current registry generation`, @@ -365,31 +374,28 @@ function inspectPodmanContainer( const config = isRecord(inspection.Config) ? inspection.Config : null; const labels = config && isRecord(config.Labels) ? config.Labels : null; const state = isRecord(inspection.State) ? inspection.State : null; - const sandboxId = labels?.[OPENSHELL_SANDBOX_ID_LABEL] ?? labels?.[PODMAN_SANDBOX_ID_LABEL]; - const sandboxNameMatches = - labels?.[OPENSHELL_SANDBOX_NAME_LABEL] === sandboxName || - labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName; - const podmanIdentityMatches = + const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; + const sandboxNameMatches = labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName; + const containerNameMatches = + String(inspection.Name ?? "").replace(/^\//u, "") === + `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}`; + const legacyIdentityMatches = labels?.[PODMAN_MANAGED_LABEL] === "true" && sandboxNameMatches && - typeof sandboxId === "string" && - SANDBOX_ID_PATTERN.test(sandboxId); - const dockerIdentityMatches = - labels?.[DOCKER_MANAGED_BY_LABEL] === DOCKER_MANAGED_BY_VALUE && - sandboxNameMatches && + containerNameMatches && typeof sandboxId === "string" && SANDBOX_ID_PATTERN.test(sandboxId); if ( inspection.Id !== containerId || - (!podmanIdentityMatches && !dockerIdentityMatches) || + !legacyIdentityMatches || typeof state?.Running !== "boolean" ) { const checks = [ `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, + `container-name=${containerNameMatches ? "match" : "mismatch"}`, `sandbox-name=${sandboxNameMatches ? "match" : "mismatch"}`, `sandbox-id=${typeof sandboxId === "string" && SANDBOX_ID_PATTERN.test(sandboxId) ? "valid" : "invalid"}`, - `podman-ownership=${podmanIdentityMatches ? "match" : "mismatch"}`, - `docker-ownership=${dockerIdentityMatches ? "match" : "mismatch"}`, + `legacy-ownership=${legacyIdentityMatches ? "match" : "mismatch"}`, `running-state=${typeof state?.Running === "boolean" ? "present" : "missing"}`, ].join(", "); throw new Error( @@ -399,55 +405,6 @@ function inspectPodmanContainer( return { containerId, sandboxId, running: state.Running }; } -function ensurePodmanContainerRunDirectory( - containerId: string, - sandboxName: string, - podman: NonNullable, - log: (message: string) => void, -): void { - const result = podman([ - "info", - "--format", - "{{.Store.GraphDriverName}}|{{.Store.RunRoot}}", - ]); - requireCommand(result, "Reading the portable Podman runtime location"); - const [driver, runRoot, ...extra] = String(result.stdout ?? "") - .trim() - .split("|") - .map((value) => value.trim()); - if ( - extra.length > 0 || - !driver || - !/^[a-z0-9][a-z0-9._+-]{0,63}$/u.test(driver) || - !path.isAbsolute(runRoot ?? "") || - /[\u0000-\u001f\u007f-\u009f]/u.test(runRoot ?? "") - ) { - throw new Error("Reading the portable Podman runtime location returned invalid data"); - } - const runDirectory = path.join( - runRoot!, - `${driver}-containers`, - containerId, - "userdata", - ); - if (fs.existsSync(runDirectory)) return; - - // containers/storage normally creates this per-session directory from its - // durable container record. GFN can discard the rootless RunRoot while the - // user manager (and Podman's alive marker) survives, so Podman skips its - // reboot refresh and later fails creating resolv.conf. Recreate the exact - // ContainerRunDirectory path before starting the already-validated container. - fs.mkdirSync(runDirectory, { mode: 0o700, recursive: true }); - const state = fs.lstatSync(runDirectory); - if (!state.isDirectory() || state.isSymbolicLink()) { - throw new Error( - `Portable Podman runtime path for sandbox '${sandboxName}' is not a directory`, - ); - } - fs.chmodSync(runDirectory, 0o700); - log(` Restored portable Podman runtime directory for sandbox '${sandboxName}'.`); -} - function isMissingPodmanContainer(result: CommandResult): boolean { if (result.status === 0 && !result.error) return false; const detail = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; @@ -487,90 +444,26 @@ function podmanQueryCount(result: PodmanContainerQuery): string { return result.ok ? String(result.ids.length) : "query-error"; } -function podmanIdentityResourceEvidence( - podman: NonNullable, - resource: "volume" | "secret", - sandboxId: string, -): string { - const result = podman([resource, "ls", "--format", "{{.Name}}"]); - if (result.status !== 0 || result.error) { - return `${resource}-sandbox-id-matches=${commandDetail(result)}`; - } - const matches = String(result.stdout ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.includes(sandboxId)).length; - return `${resource}-sandbox-id-matches=${String(matches)}`; -} - -function podmanStorageEvidence( - podman: NonNullable, - sandboxId?: string, -): string { - const result = podman([ - "info", - "--format", - "{{.Store.TransientStore}}|{{.Store.GraphRoot}}|{{.Store.RunRoot}}", - ]); - let storage: string; - if (result.status !== 0 || result.error) { - storage = `storage-info=${commandDetail(result)}`; - } else { - const [transientStore = "empty", graphRoot = "empty", runRoot = "empty"] = String( - result.stdout ?? "", - ) - .trim() - .split("|") - .map((value) => value.trim().replace(/\s+/gu, " ")); - storage = `transient-store=${transientStore || "empty"}, graph-root=${graphRoot || "empty"}, run-root=${runRoot || "empty"}`; - } - if (!sandboxId) return storage; - return [ - storage, - podmanIdentityResourceEvidence(podman, "volume", sandboxId), - podmanIdentityResourceEvidence(podman, "secret", sandboxId), - ].join(", "); -} - function discoverPodmanContainer( sandboxName: string, podman: NonNullable, expectedSandboxId?: string, ): PodmanContainerInspection { - const ownershipVariants = [ - { managed: PODMAN_MANAGED_LABEL, value: "true" }, - { managed: DOCKER_MANAGED_BY_LABEL, value: DOCKER_MANAGED_BY_VALUE }, - ]; - const identityVariants = [ - { name: PODMAN_SANDBOX_NAME_LABEL, id: PODMAN_SANDBOX_ID_LABEL }, - { name: OPENSHELL_SANDBOX_NAME_LABEL, id: OPENSHELL_SANDBOX_ID_LABEL }, + const queries = [ + queryPodmanContainerIds(podman, [ + `label=${PODMAN_MANAGED_LABEL}=true`, + `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, + ...(expectedSandboxId + ? [`label=${PODMAN_SANDBOX_ID_LABEL}=${expectedSandboxId}`] + : []), + ]), ]; - let queries: PodmanContainerQuery[] = []; - for (const { name, id } of identityVariants) { - const variantQueries: PodmanContainerQuery[] = []; - for (const { managed, value } of ownershipVariants) { - const query = queryPodmanContainerIds(podman, [ - `label=${managed}=${value}`, - `label=${name}=${sandboxName}`, - ...(expectedSandboxId ? [`label=${id}=${expectedSandboxId}`] : []), - ]); - variantQueries.push(query); - if (!query.ok || query.ids.length > 0) break; - } - queries = variantQueries; - if (!queries.every((query) => query.ok) || queries.some((query) => query.ids.length > 0)) { - break; - } - } const candidates = [...new Set(queries.flatMap((query) => query.ids))]; const queriesSucceeded = queries.every((query) => query.ok); const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { - const recoveryEvidence = expectedSandboxId - ? `; ${podmanStorageEvidence(podman, expectedSandboxId)}` - : ""; throw new Error( - `Portable demo lifecycle requires one exact OpenShell-owned Podman container for sandbox '${sandboxName}'${expectedSandboxId ? ` with sandbox ID '${expectedSandboxId}'` : ""}; found ${String(candidates.length)} (matches=${queries.map(podmanQueryCount).join(",")})${recoveryEvidence}`, + `Portable demo lifecycle requires one exact OpenShell 0.0.85 Podman container for sandbox '${sandboxName}'${expectedSandboxId ? ` with sandbox ID '${expectedSandboxId}'` : ""}; found ${String(candidates.length)} (matches=${queries.map(podmanQueryCount).join(",")})`, ); } const inspection = inspectPodmanContainer(candidates[0]!, sandboxName, podman); @@ -582,6 +475,168 @@ function discoverPodmanContainer( return inspection; } +interface OwnedStaleContainer { + containerId: string; + sandboxId: string; +} + +function inspectOwnedStaleContainer( + containerId: string, + sandboxName: string, + podman: NonNullable, + result: CommandResult = podman(["inspect", containerId]), +): OwnedStaleContainer | null { + if (isMissingPodmanContainer(result)) return null; + requireCommand(result, `Inspecting stale portable sandbox '${sandboxName}'`); + let parsed: unknown; + try { + parsed = JSON.parse(String(result.stdout ?? "")); + } catch { + throw new Error(`Inspecting stale portable sandbox '${sandboxName}' returned invalid JSON`); + } + if (!Array.isArray(parsed) || parsed.length !== 1 || !isRecord(parsed[0])) { + throw new Error(`Inspecting stale portable sandbox '${sandboxName}' returned an invalid record`); + } + const inspection = parsed[0]; + const config = isRecord(inspection.Config) ? inspection.Config : null; + const labels = config && isRecord(config.Labels) ? config.Labels : null; + const containerNameMatches = + String(inspection.Name ?? "").replace(/^\//u, "") === + `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}`; + const legacySandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; + const newerSandboxId = labels?.[STALE_NEWER_SANDBOX_ID_LABEL]; + const legacyOwned = + labels?.[PODMAN_MANAGED_LABEL] === "true" && + labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName && + typeof legacySandboxId === "string" && + SANDBOX_ID_PATTERN.test(legacySandboxId); + const newerOwned = + labels?.[STALE_NEWER_MANAGED_BY_LABEL] === STALE_NEWER_MANAGED_BY_VALUE && + labels?.[STALE_NEWER_SANDBOX_NAME_LABEL] === sandboxName && + typeof newerSandboxId === "string" && + SANDBOX_ID_PATTERN.test(newerSandboxId); + if ( + inspection.Id !== containerId || + !containerNameMatches || + (!legacyOwned && !newerOwned) + ) { + return null; + } + return { + containerId, + sandboxId: (legacyOwned ? legacySandboxId : newerSandboxId) as string, + }; +} + +function removeExactOwnedStaleContainer( + owned: OwnedStaleContainer, + sandboxName: string, + podman: NonNullable, +): void { + requireCommand( + podman(["rm", "-f", owned.containerId]), + `Removing stale OpenShell-owned sandbox '${sandboxName}'`, + ); + podman(["volume", "rm", `${PODMAN_SANDBOX_CONTAINER_PREFIX}${owned.sandboxId}-workspace`]); + podman(["secret", "rm", `openshell-token-${owned.sandboxId}`]); +} + +/** + * Clear only exact OpenShell-owned name conflicts before 0.0.85 creates a sandbox. + * Receipts written by newer installer attempts are deliberately retired instead + * of being migrated into the isolated 0.0.85 gateway database. + */ +export function preparePortableDemoSandboxCreation( + sandboxName: string, + env: NodeJS.ProcessEnv = process.env, + deps: PortableDemoLifecycleDeps = {}, +): void { + if (!isPortableExperimentalProfile(env)) return; + if ((deps.platform ?? process.platform) !== "linux") { + throw new Error("Portable demo lifecycle requires Linux"); + } + const commandEnv = deps.env ?? env; + const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); + const podmanEnv = localPodmanEnvironment(commandEnv); + const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); + const receipt = loadReceipt(sandboxName, stateDir); + + if ( + receipt?.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && + receipt.openshellVersion === PINNED_OPENSHELL_VERSION + ) { + const result = podman(["inspect", receipt.containerId]); + if (!isMissingPodmanContainer(result)) { + const inspection = inspectPodmanContainer( + receipt.containerId, + sandboxName, + podman, + result, + ); + if (inspection.sandboxId !== receipt.sandboxId) { + throw new Error( + `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, + ); + } + requireCommand( + podman(["update", "--restart=always", receipt.containerId]), + `Restoring the portable restart policy for sandbox '${sandboxName}'`, + ); + if (!inspection.running) { + requireCommand( + podman(["start", receipt.containerId]), + `Starting portable sandbox '${sandboxName}'`, + ); + } + return; + } + removeReceipt(sandboxName, stateDir); + } else if (receipt) { + const result = podman(["inspect", receipt.containerId]); + const owned = inspectOwnedStaleContainer( + receipt.containerId, + sandboxName, + podman, + result, + ); + if (owned) { + removeExactOwnedStaleContainer(owned, sandboxName, podman); + } else if (!isMissingPodmanContainer(result)) { + throw new Error( + `Portable demo lifecycle refused to remove receipt-bound container '${receipt.containerId}' because it is not an exact OpenShell-owned '${sandboxName}' sandbox`, + ); + } + removeReceipt(sandboxName, stateDir); + } + + const queries = [ + queryPodmanContainerIds(podman, [ + `label=${PODMAN_MANAGED_LABEL}=true`, + `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]), + queryPodmanContainerIds(podman, [ + `label=${STALE_NEWER_MANAGED_BY_LABEL}=${STALE_NEWER_MANAGED_BY_VALUE}`, + `label=${STALE_NEWER_SANDBOX_NAME_LABEL}=${sandboxName}`, + ]), + ]; + if (!queries.every((query) => query.ok)) { + throw new Error(`Could not inspect stale OpenShell-owned sandbox '${sandboxName}'`); + } + const candidates = [...new Set(queries.flatMap((query) => query.ids))]; + for (const containerId of candidates) { + if (!CONTAINER_ID_PATTERN.test(containerId)) { + throw new Error(`OpenShell returned an invalid container ID for sandbox '${sandboxName}'`); + } + const owned = inspectOwnedStaleContainer(containerId, sandboxName, podman); + if (!owned) { + throw new Error( + `Portable demo lifecycle refused to remove container '${containerId}' because its ownership could not be proven`, + ); + } + removeExactOwnedStaleContainer(owned, sandboxName, podman); + } +} + function podmanSocketPath( podman: NonNullable, env: NodeJS.ProcessEnv, @@ -650,29 +705,15 @@ function requireReceiptOwnedInspection( } } -function backfillLegacyReceiptGeneration( - receipt: PortableDemoLifecycleReceipt, - stateDir: string, - backfillRequired: boolean, - deps: PortableDemoLifecycleDeps, -): PortableDemoLifecycleReceipt { - if (receipt.schemaVersion === 3) return receipt; +function requirePinnedReceipt(receipt: PortableDemoLifecycleReceipt): void { if ( - backfillRequired && - (!deps.backfillRegistryGeneration || !deps.backfillRegistryGeneration(receipt.containerId)) + receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION || + receipt.openshellVersion !== PINNED_OPENSHELL_VERSION ) { throw new Error( - `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' could not claim the current registry generation`, + `Portable sandbox '${receipt.sandboxName}' has lifecycle state from a different OpenShell stack; rerun the pinned 0.0.85 installer`, ); } - if (receipt.schemaVersion === 1) return receipt; - const migrated: PortableDemoLifecycleReceipt = { - ...receipt, - schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, - registryGeneration: receipt.containerId, - }; - writeReceipt(migrated, stateDir); - return migrated; } /** Resolve the receipt-owned portable container for a host-side privileged exec. */ @@ -687,15 +728,15 @@ export function resolvePortableDemoPrivilegedExecTarget( if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - const backfillRequired = requireCurrentRegistryGeneration(receipt, deps.registryGeneration); + requirePinnedReceipt(receipt); + requireCurrentRegistryGeneration(receipt, deps.registryGeneration); const authority = qualifiedPodmanAuthority(commandEnv, deps); - const inspection = discoverPodmanContainer(sandboxName, authority.podman); + const inspection = inspectPodmanContainer(receipt.containerId, sandboxName, authority.podman); requireReceiptOwnedInspection(receipt, inspection); if (!inspection.running) { throw new Error(`Portable sandbox '${sandboxName}' is not running`); } authority.assertRuntimeAuthority(); - backfillLegacyReceiptGeneration(receipt, stateDir, backfillRequired, deps); return { assertRuntimeAuthority: authority.assertRuntimeAuthority, containerId: inspection.containerId, @@ -973,6 +1014,7 @@ export function installPortableDemoSandboxLifecycle( } const receipt: PortableDemoLifecycleReceipt = { schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, + openshellVersion: PINNED_OPENSHELL_VERSION, sandboxName, sandboxId: inspection.sandboxId, containerId: inspection.containerId, @@ -984,9 +1026,6 @@ export function installPortableDemoSandboxLifecycle( `Setting the portable restart policy for sandbox '${sandboxName}'`, ); writeReceipt(receipt, stateDir); - (deps.log ?? console.log)( - ` Portable demo lifecycle baseline: sandbox-id=${receipt.sandboxId}, container-id=${receipt.containerId}, ${podmanStorageEvidence(podman, receipt.sandboxId)}`, - ); return registryGeneration; } @@ -1011,64 +1050,34 @@ export function recoverPortableDemoSandboxLifecycle( if (context.openshellDriver !== "docker") return { kind: "not-installed" }; const commandEnv = deps.env ?? process.env; const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); - let receipt = loadReceipt(sandboxName, stateDir); + const receipt = loadReceipt(sandboxName, stateDir); if (!receipt) return { kind: "not-installed" }; if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - const backfillRequired = requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); - if (backfillRequired || receipt.schemaVersion === 2) { - const authority = qualifiedPodmanAuthority(commandEnv, deps); - const migrationInspection = discoverPodmanContainer(sandboxName, authority.podman); - requireReceiptOwnedInspection(receipt, migrationInspection); - authority.assertRuntimeAuthority(); - receipt = backfillLegacyReceiptGeneration(receipt, stateDir, backfillRequired, deps); - } + requirePinnedReceipt(receipt); + requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); deps.ensureGateway?.(); const podmanEnv = localPodmanEnvironment(commandEnv); const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); const initialInspection = podman(["inspect", receipt.containerId]); - let inspection: PodmanContainerInspection; if (isMissingPodmanContainer(initialInspection)) { - const previousContainerId = receipt.containerId; - let replacement: PodmanContainerInspection; - try { - replacement = discoverPodmanContainer(sandboxName, podman, receipt.sandboxId); - } catch (error) { - throw new Error( - `Portable sandbox '${sandboxName}' recorded container '${previousContainerId}' is absent and no identity-preserving replacement was found; its lifecycle receipt was preserved. ${error instanceof Error ? error.message : String(error)}`, - ); - } - requireCommand( - podman(["update", "--restart=always", replacement.containerId]), - `Restoring the portable restart policy for sandbox '${sandboxName}'`, - ); - receipt = { ...receipt, containerId: replacement.containerId }; - writeReceipt(receipt, stateDir); - (deps.log ?? console.log)( - ` Portable demo lifecycle rebound sandbox '${sandboxName}' from missing Podman container '${previousContainerId}' to '${replacement.containerId}' using OpenShell sandbox ID '${receipt.sandboxId}'.`, - ); - inspection = replacement; - } else { - inspection = inspectPodmanContainer( - receipt.containerId, - sandboxName, - podman, - initialInspection, + throw new Error( + `Portable sandbox '${sandboxName}' recorded OpenShell 0.0.85 container '${receipt.containerId}' is absent; its lifecycle receipt was preserved`, ); } + let inspection = inspectPodmanContainer( + receipt.containerId, + sandboxName, + podman, + initialInspection, + ); if (inspection.sandboxId !== receipt.sandboxId) { throw new Error( `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, ); } if (!inspection.running) { - ensurePodmanContainerRunDirectory( - receipt.containerId, - sandboxName, - podman, - deps.log ?? console.log, - ); requireCommand( podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 3e8e5b046e7..9d01e8799a8 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -17,7 +17,6 @@ const REGISTRY_IMAGE = "docker.io/library/registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"; const HOST_COMMAND_TIMEOUT_MS = 30_000; const REGISTRY_COMMAND_TIMEOUT_MS = 300_000; -const MAX_STORAGE_CONFIG_BYTES = 128 * 1024; const REGISTRY_FRAGMENT = `[[registry]] location = "${PORTABLE_LOCAL_REGISTRY}" insecure = true @@ -37,13 +36,6 @@ Before=podman-restart.service type SpawnResult = ReturnType; -interface PodmanStorageInfo { - transientStore: boolean; - driver: string; - graphRoot: string; - runRoot: string; -} - export interface PortableHostPreparationDeps { platform?: NodeJS.Platform; home?: string; @@ -118,171 +110,6 @@ function writePrivateConfig(filePath: string, value: string): void { } } -function readStorageConfig(filePath: string): string | null { - let file; - try { - file = openRegularFileNoFollow(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } - try { - return file.readUtf8(MAX_STORAGE_CONFIG_BYTES); - } finally { - file.close(); - } -} - -function readPodmanStorageInfo( - podman: NonNullable, - env: NodeJS.ProcessEnv, -): PodmanStorageInfo { - const result = podman( - [ - "info", - "--format", - "{{.Store.TransientStore}}|{{.Store.GraphDriverName}}|{{.Store.GraphRoot}}|{{.Store.RunRoot}}", - ], - env, - ); - requireCommand(result, "Reading the rootless Podman storage mode"); - const [transientStore, driver, graphRoot, runRoot, ...extra] = String(result.stdout ?? "") - .trim() - .split("|") - .map((value) => value.trim()); - if ( - extra.length > 0 || - (transientStore !== "true" && transientStore !== "false") || - !driver || - !path.isAbsolute(graphRoot ?? "") || - !path.isAbsolute(runRoot ?? "") - ) { - throw new Error("Reading the rootless Podman storage mode returned invalid data"); - } - return { - transientStore: transientStore === "true", - driver, - graphRoot: graphRoot!, - runRoot: runRoot!, - }; -} - -function persistentStorageConfig( - source: string, - info: PodmanStorageInfo, - durableGraphRoot: string, -): string { - const lines = source.replace(/\r\n/gu, "\n").split("\n"); - const storageStart = lines.findIndex((line) => /^\s*\[storage\]\s*(?:#.*)?$/u.test(line)); - if (storageStart < 0) { - throw new Error("The active Podman storage configuration has no [storage] table"); - } - const nextTable = lines.findIndex( - (line, index) => index > storageStart && /^\s*\[[^\]]+\]\s*(?:#.*)?$/u.test(line), - ); - const storageEnd = nextTable < 0 ? lines.length : nextTable; - const values: Readonly> = { - driver: JSON.stringify(info.driver), - graphroot: JSON.stringify(durableGraphRoot), - runroot: JSON.stringify(info.runRoot), - transient_store: "false", - }; - const seen = new Set(); - for (let index = storageStart + 1; index < storageEnd; index += 1) { - const match = /^\s*(driver|graphroot|imagestore|runroot|transient_store)\s*=/u.exec( - lines[index] ?? "", - ); - if (!match) continue; - const key = match[1]!; - if (key === "imagestore") { - lines[index] = ""; - continue; - } - if (!Object.hasOwn(values, key)) continue; - lines[index] = `${key} = ${values[key]}`; - seen.add(key); - } - const missing = Object.keys(values) - .filter((key) => !seen.has(key)) - .map((key) => `${key} = ${values[key]}`); - lines.splice(storageStart + 1, 0, ...missing); - return `${lines.join("\n").replace(/\n*$/u, "")}\n`; -} - -function configuredImageStore(source: string): string | null { - const storageStart = source.search(/^\s*\[storage\]\s*(?:#.*)?$/mu); - if (storageStart < 0) return null; - const storage = source.slice(storageStart); - const nextTable = storage.slice(1).search(/^\s*\[[^\]]+\]\s*(?:#.*)?$/mu); - const table = nextTable < 0 ? storage : storage.slice(0, nextTable + 1); - const match = /^\s*imagestore\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/mu.exec(table); - if (!match) return null; - try { - const value: unknown = JSON.parse(match[1]!); - return typeof value === "string" && path.isAbsolute(value) ? value : null; - } catch { - return null; - } -} - -function ensurePersistentPodmanStore( - home: string, - env: NodeJS.ProcessEnv, - podman: NonNullable, -): string | null { - const before = readPodmanStorageInfo(podman, env); - const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"); - const target = path.join(configHome, "containers", "storage.conf"); - const sourceCandidates = [ - env.CONTAINERS_STORAGE_CONF?.trim(), - target, - "/etc/containers/storage.conf", - "/usr/share/containers/storage.conf", - ].filter((candidate): candidate is string => Boolean(candidate)); - let source: string | null = null; - for (const candidate of new Set(sourceCandidates)) { - source = readStorageConfig(candidate); - if (source !== null) break; - } - source ??= "[storage]\n"; - const durableGraphRoot = path.join(home, ".nemoclaw", "portable-podman-v3"); - if ( - !before.transientStore && - before.graphRoot === durableGraphRoot && - configuredImageStore(source) === null - ) { - return null; - } - - const existingContainers = podman(["ps", "-a", "--format", "{{.Names}}"], env); - requireCommand(existingContainers, "Checking the current Podman container store"); - const names = String(existingContainers.stdout ?? "") - .split(/\r?\n/u) - .map((name) => name.trim()) - .filter(Boolean); - if (names.length > 0) { - throw new Error( - `The portable profile cannot migrate a non-empty Podman store safely (containers: ${names.join(", ")}). Uninstall those containers, then rerun the portable installer.`, - ); - } - - writePrivateConfig(target, persistentStorageConfig(source, before, durableGraphRoot)); - env.CONTAINERS_STORAGE_CONF = target; - - const after = readPodmanStorageInfo(podman, env); - if ( - after.transientStore || - after.driver !== before.driver || - after.graphRoot !== durableGraphRoot || - after.runRoot !== before.runRoot - ) { - throw new Error( - "The portable profile could not configure the complete Podman store in durable user storage", - ); - } - return target; -} - function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): string { const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config"); writePrivateConfig( @@ -376,20 +203,6 @@ export function preparePortableExperimentalHost( const home = deps.home ?? env.HOME ?? os.homedir(); env.NETAVARK_FW = "iptables"; env.CONTAINERS_CONF = writePortableRuntimeConfig(home, env); - const podman = - deps.podman ?? - ((args, childEnv) => - spawnSync("podman", [...args], { - encoding: "utf-8", - env: childEnv, - timeout: HOST_COMMAND_TIMEOUT_MS, - })); - const podmanEnv = localPodmanEnvironment(env); - const storageConf = ensurePersistentPodmanStore(home, podmanEnv, podman); - if (storageConf) { - env.CONTAINERS_STORAGE_CONF = storageConf; - podmanEnv.CONTAINERS_STORAGE_CONF = storageConf; - } const systemctl = deps.systemctl ?? @@ -410,9 +223,6 @@ export function preparePortableExperimentalHost( "set-environment", "NETAVARK_FW=iptables", `CONTAINERS_CONF=${env.CONTAINERS_CONF}`, - ...(env.CONTAINERS_STORAGE_CONF - ? [`CONTAINERS_STORAGE_CONF=${env.CONTAINERS_STORAGE_CONF}`] - : []), ], env, ), @@ -431,6 +241,15 @@ export function preparePortableExperimentalHost( "Enabling rootless container restart after login", ); + const podman = + deps.podman ?? + ((args, childEnv) => + spawnSync("podman", [...args], { + encoding: "utf-8", + env: childEnv, + timeout: HOST_COMMAND_TIMEOUT_MS, + })); + const podmanEnv = localPodmanEnvironment(env); const dockerHost = resolvePodmanDockerHost( podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], podmanEnv), ); diff --git a/src/lib/onboard/gateway-binding.ts b/src/lib/onboard/gateway-binding.ts index 413b0d5de31..f7846949056 100644 --- a/src/lib/onboard/gateway-binding.ts +++ b/src/lib/onboard/gateway-binding.ts @@ -25,9 +25,8 @@ import type { GatewayReuseState } from "../state/gateway"; /** Gateway registration name used for the default gateway port. */ export const BASE_GATEWAY_NAME = "nemoclaw"; /** Docker-driver gateway state directory leaf name for the default port. */ -// OpenShell gateway databases are not downgrade-compatible. Keep the pinned -// 0.0.85 runtime in its own durable namespace so it cannot open a database -// already migrated by 0.0.99/0.0.101 during a portable/GFN reinstall. +// OpenShell gateway schemas are not downgrade-compatible. The pinned 0.0.85 +// stack must never open state written by 0.0.99/0.0.101 installer attempts. export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway-v0.0.85"; /** Docker-driver gateway compatibility container name for the default port. */ export const BASE_GATEWAY_COMPAT_CONTAINER_NAME = "nemoclaw-openshell-gateway"; diff --git a/src/lib/onboard/host-gateway-process.ts b/src/lib/onboard/host-gateway-process.ts index 78baf2bf7b1..80b60653148 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -6,9 +6,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; + import { waitUntil } from "../core/wait"; import { clearDockerDriverGatewayRuntimeMarker } from "./docker-driver-gateway-runtime-marker"; -import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; import { type OpenShellGatewayProcessTarget, hostGatewayCmdlineMatches as sharedHostGatewayCmdlineMatches, diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index d63cda96d82..994defe2747 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -10,7 +10,10 @@ import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types" import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { renderCompatibilityFallbackCreateArgs } from "./docker-gpu-route"; import { adaptDockerGpuRouteForPatch } from "./docker-gpu-route-patch-adapter"; -import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; +import { + installPortableDemoSandboxLifecycle, + preparePortableDemoSandboxCreation, +} from "./experimental/portable-demo-lifecycle"; import { type ManagedBootstrapAdapter, type ManagedBootstrapAgentIdentity, @@ -144,6 +147,7 @@ export async function runSandboxGpuCreateFlow( deps: SandboxGpuCreateFlowDeps, ): Promise { let registryImageRef: string | null = input.prebuild.imageRef; + preparePortableDemoSandboxCreation(input.sandboxName); const attemptRunner = createSandboxGpuCreateAttemptRunner(input, deps); const gpuCreateOutcome = await sandboxGpuCreateAttempt .executeSandboxGpuCreatePlan(input.gpuRoutePlan, { From 1d089096e88edb800473073cf98b4bd2d683fb87 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 8 Aug 2026 01:20:37 -0700 Subject: [PATCH 40/42] fix(onboard): restore dashboard registration variable --- src/lib/onboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b1d12cf97db..0a6ea757f0e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2724,7 +2724,7 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardForwardEnabled: manageDashboardForward, + dashboardForwardEnabled: manageDashboard, ...lifecycleRegistrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, From 270df3803da01736432688fcc566cc192c9e87ec Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 8 Aug 2026 01:57:59 -0700 Subject: [PATCH 41/42] fix(portable): restore receipt container run directory --- .../experimental/portable-demo-lifecycle.ts | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index 371096f7b5d..ecdb48984f8 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -75,6 +75,8 @@ interface PodmanContainerInspection { containerId: string; sandboxId: string; running: boolean; + driver: string; + resolvConfPath: string; } export interface PortableDemoPrivilegedExecTarget { @@ -198,6 +200,13 @@ function defaultSleep(milliseconds: number): void { function commandDetail(result: CommandResult): string { if (result.error) return (result.error as NodeJS.ErrnoException).code ?? "command execution error"; + const output = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; + if (/creating resolv\.conf for container[\s\S]*no such file or directory/iu.test(output)) { + return `exit ${String(result.status)}: Podman's ephemeral container run directory is missing`; + } + if (/common\.pid[\s\S]*no such file or directory/iu.test(output)) { + return `exit ${String(result.status)}: Podman's rootless pause-process state is missing`; + } return `exit ${String(result.status)}`; } @@ -375,6 +384,8 @@ function inspectPodmanContainer( const labels = config && isRecord(config.Labels) ? config.Labels : null; const state = isRecord(inspection.State) ? inspection.State : null; const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; + const driver = inspection.Driver; + const resolvConfPath = inspection.ResolvConfPath; const sandboxNameMatches = labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName; const containerNameMatches = String(inspection.Name ?? "").replace(/^\//u, "") === @@ -388,7 +399,11 @@ function inspectPodmanContainer( if ( inspection.Id !== containerId || !legacyIdentityMatches || - typeof state?.Running !== "boolean" + typeof state?.Running !== "boolean" || + typeof driver !== "string" || + !/^[a-z0-9_-]+$/u.test(driver) || + typeof resolvConfPath !== "string" || + !path.isAbsolute(resolvConfPath) ) { const checks = [ `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, @@ -397,12 +412,56 @@ function inspectPodmanContainer( `sandbox-id=${typeof sandboxId === "string" && SANDBOX_ID_PATTERN.test(sandboxId) ? "valid" : "invalid"}`, `legacy-ownership=${legacyIdentityMatches ? "match" : "mismatch"}`, `running-state=${typeof state?.Running === "boolean" ? "present" : "missing"}`, + `runtime-path=${typeof resolvConfPath === "string" && path.isAbsolute(resolvConfPath) ? "absolute" : "invalid"}`, ].join(", "); throw new Error( `Portable demo lifecycle refused container '${containerId}' because its OpenShell identity does not match sandbox '${sandboxName}' (${checks})`, ); } - return { containerId, sandboxId, running: state.Running }; + return { containerId, sandboxId, running: state.Running, driver, resolvConfPath }; +} + +function restoreReceiptBoundPodmanRunDirectory( + inspection: PodmanContainerInspection, + env: NodeJS.ProcessEnv, +): void { + if (inspection.running) return; + const uid = process.getuid?.(); + if (!Number.isInteger(uid) || Number(uid) < 0) { + throw new Error("Portable demo lifecycle could not resolve the current user ID"); + } + const configuredRuntimeDir = env.XDG_RUNTIME_DIR?.trim(); + const runtimeDir = configuredRuntimeDir || `/run/user/${String(uid)}`; + if (!path.isAbsolute(runtimeDir) || path.normalize(runtimeDir) !== runtimeDir) { + throw new Error("Portable demo lifecycle refused an invalid Podman runtime directory"); + } + const expectedRunDirectory = path.join( + runtimeDir, + "containers", + `${inspection.driver}-containers`, + inspection.containerId, + "userdata", + ); + if (inspection.resolvConfPath !== path.join(expectedRunDirectory, "resolv.conf")) { + throw new Error( + `Portable demo lifecycle refused an unexpected runtime path for sandbox container '${inspection.containerId}'`, + ); + } + try { + const existing = fs.lstatSync(expectedRunDirectory); + if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw new Error("runtime path is not a directory"); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new Error( + `Portable demo lifecycle refused the existing runtime path for sandbox container '${inspection.containerId}'`, + ); + } + } + fs.mkdirSync(expectedRunDirectory, { mode: 0o700, recursive: true }); + fs.chmodSync(expectedRunDirectory, 0o700); } function isMissingPodmanContainer(result: CommandResult): boolean { @@ -583,6 +642,7 @@ export function preparePortableDemoSandboxCreation( `Restoring the portable restart policy for sandbox '${sandboxName}'`, ); if (!inspection.running) { + restoreReceiptBoundPodmanRunDirectory(inspection, commandEnv); requireCommand( podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, @@ -1078,6 +1138,7 @@ export function recoverPortableDemoSandboxLifecycle( ); } if (!inspection.running) { + restoreReceiptBoundPodmanRunDirectory(inspection, commandEnv); requireCommand( podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, From f4a0e76032b66051fe132ebb88b907a335bfdcb0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 8 Aug 2026 07:16:41 -0700 Subject: [PATCH 42/42] fix(portable): restore video-era OpenShell 0.0.85 runtime Signed-off-by: Aaron Erickson --- nemoclaw-blueprint/policies/presets/brew.yaml | 7 +- .../policies/presets/outlook.yaml | 2 - .../src/blueprint/runner-identity.test.ts | 6 +- .../blueprint/runner-name-validation.test.ts | 2 +- .../src/shared/openshell-policy-boundary.cts | 2 +- nemoclaw/src/shared/sandbox-name.cts | 25 +- nemoclaw/src/shared/sandbox-name.test.ts | 6 +- schemas/blueprint.schema.json | 6 +- scripts/check-installer-hash.sh | 6 - scripts/install-openshell.sh | 5 - scripts/install.sh | 103 +--- scripts/lib/openshell-gateway.service.in | 7 +- scripts/nemoclaw-start.sh | 21 +- .../security/build-perl-security-packages.sh | 53 +- scripts/smoke-macos-install.sh | 8 +- scripts/update-hermes-agent.sh | 2 +- src/lib/actions/sandbox/destroy-execution.ts | 5 - src/lib/actions/sandbox/destroy-flow.test.ts | 5 - src/lib/actions/sandbox/destroy.ts | 20 +- .../actions/sandbox/doctor-inference.test.ts | 40 +- src/lib/actions/sandbox/doctor-inference.ts | 14 +- .../exec-policy-hint-rendering.test.ts | 6 +- src/lib/actions/sandbox/gateway-state.ts | 35 +- .../actions/sandbox/mcp-bridge-add-restart.ts | 8 +- .../sandbox/mcp-bridge-input-targets.test.ts | 68 +-- .../mcp-bridge-input-validation.test.ts | 2 +- .../mcp-bridge-name-diagnostics.test.ts | 15 +- .../sandbox/mcp-bridge-policy-render.ts | 24 +- .../actions/sandbox/mcp-bridge-policy.test.ts | 148 +---- src/lib/actions/sandbox/mcp-bridge-policy.ts | 79 +-- .../mcp-bridge-private-lifecycle.test.ts | 35 +- .../sandbox/mcp-bridge-provider-inspection.ts | 4 +- .../sandbox/mcp-bridge-url-validation.ts | 125 +--- .../actions/sandbox/mcp-bridge-validation.ts | 7 +- ...ll-child-visible-credentials.v0.0.101.json | 109 ---- .../sandbox/policy-channel-add-drift.test.ts | 41 -- src/lib/actions/sandbox/policy-channel.ts | 64 +- src/lib/actions/sandbox/vm-dns-monkeypatch.ts | 4 +- .../upgrade-sandboxes-preflight.test.ts | 69 --- .../upgrade-sandboxes-recovery.test.ts | 22 +- src/lib/actions/upgrade-sandboxes.ts | 28 - src/lib/adapters/http/curl-args.test.ts | 77 +-- src/lib/adapters/http/curl-args.ts | 54 +- src/lib/adapters/http/probe.ts | 2 +- src/lib/adapters/podman/index.ts | 16 +- .../adapters/podman/socket-authority.test.ts | 83 +-- src/lib/adapters/podman/socket-authority.ts | 105 +--- src/lib/deploy/index.test.ts | 17 +- src/lib/deploy/index.ts | 36 +- .../compatible-endpoint-context.test.ts | 16 +- .../inference/compatible-endpoint-context.ts | 2 +- src/lib/inference/config.test.ts | 19 - src/lib/inference/config.ts | 2 +- .../inference/endpoint-ssrf-preflight.test.ts | 21 +- src/lib/inference/endpoint-ssrf-preflight.ts | 33 +- .../llama-cpp/host-local-runtime.test.ts | 10 - .../llama-cpp/managed-status.test.ts | 84 +-- src/lib/inference/probe-anthropic.ts | 2 +- src/lib/name-validation.ts | 2 +- src/lib/onboard.ts | 249 +++++--- src/lib/onboard/command.test.ts | 2 +- src/lib/onboard/command.ts | 2 +- .../docker-driver-gateway-runtime.test.ts | 12 +- .../onboard/docker-driver-gateway-runtime.ts | 2 - src/lib/onboard/docker-gpu-patch-types.ts | 1 - .../portable-demo-lifecycle-migration.test.ts | 244 -------- .../portable-demo-lifecycle.test.ts | 389 +----------- .../experimental/portable-demo-lifecycle.ts | 559 ++---------------- .../portable-host-preparation.test.ts | 35 +- .../experimental/portable-host-preparation.ts | 39 +- src/lib/onboard/forward-start.ts | 4 +- src/lib/onboard/gateway-binding.ts | 4 +- src/lib/onboard/gateway-reuse.test.ts | 131 +--- src/lib/onboard/gateway-reuse.ts | 128 +--- src/lib/onboard/host-gateway-process.ts | 10 +- .../inference-selection-validation.test.ts | 5 +- .../onboard/inference-selection-validation.ts | 2 +- .../initial-policy-real-policy.test.ts | 72 --- .../machine/handlers/provider-inference.ts | 2 +- .../managed-bootstrap/docker-test-fixture.ts | 2 +- .../onboard/managed-bootstrap/docker.test.ts | 35 -- src/lib/onboard/managed-bootstrap/docker.ts | 8 +- .../managed-workload-clone-handoff.test.ts | 2 +- .../managed-workload/onboard-orchestration.ts | 2 +- .../onboard/messaging-channel-setup.test.ts | 4 +- .../onboard/openshell-feature-gate.test.ts | 12 +- src/lib/onboard/openshell-feature-gate.ts | 6 - src/lib/onboard/openshell-install.test.ts | 4 +- .../policy-selection-application.test.ts | 79 --- src/lib/onboard/policy-selection.ts | 118 +--- ...lama-cpp-managed-lifecycle.test-support.ts | 106 ---- ...docker-llama-cpp-managed-lifecycle.test.ts | 235 ++++---- .../docker-llama-cpp-managed-lifecycle.ts | 136 +++-- .../runtime-provider/host-local-inference.ts | 2 +- .../onboard/runtime-provider/podman.test.ts | 2 +- .../runtime-provider-contract.test.ts | 3 +- src/lib/onboard/sandbox-create-failure.ts | 9 - .../onboard/sandbox-gpu-create-flow.test.ts | 18 +- src/lib/onboard/sandbox-gpu-create-flow.ts | 34 +- src/lib/onboard/setup-nim-selection.ts | 2 +- src/lib/policy/index.ts | 440 +------------- .../policy/trusted-private-endpoints.test.ts | 23 +- src/lib/policy/trusted-private-endpoints.ts | 67 ++- src/lib/runner.ts | 7 +- src/lib/sandbox-name-contract.ts | 12 - src/lib/sandbox/privileged-exec.test.ts | 178 +----- src/lib/sandbox/privileged-exec.ts | 41 +- .../security/trusted-private-endpoint.test.ts | 92 +-- src/lib/security/trusted-private-endpoint.ts | 210 +++---- src/lib/shields/flow.test.ts | 4 +- src/lib/shields/mcp-policy-transition.test.ts | 19 +- src/lib/shields/timer-process.test.ts | 2 +- src/lib/shields/timer-recovery-budget.test.ts | 10 +- src/lib/shields/timer.test.ts | 9 +- src/lib/state/registry-mcp.ts | 28 +- src/lib/state/registry-normalization.test.ts | 18 - .../state/registry/lifecycle-generation.ts | 31 - src/lib/verify-deployment.ts | 2 +- 118 files changed, 942 insertions(+), 4781 deletions(-) delete mode 100644 src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json delete mode 100644 src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts delete mode 100644 src/lib/onboard/policy-selection-application.test.ts delete mode 100644 src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test-support.ts delete mode 100644 src/lib/sandbox-name-contract.ts delete mode 100644 src/lib/state/registry/lifecycle-generation.ts diff --git a/nemoclaw-blueprint/policies/presets/brew.yaml b/nemoclaw-blueprint/policies/presets/brew.yaml index d2fc81c4f59..cf1c9d5a342 100644 --- a/nemoclaw-blueprint/policies/presets/brew.yaml +++ b/nemoclaw-blueprint/policies/presets/brew.yaml @@ -19,10 +19,7 @@ network_policies: - host: github.com port: 443 access: full - # Keep GitHub and raw-content routes on automatic TLS handling so this - # preset composes with the agent baselines and narrower inspected - # routes under OpenShell 0.0.101. System git is excluded below; the - # remaining curl/Homebrew clients trust the sandbox CA. + tls: skip - host: ghcr.io port: 443 access: full @@ -38,7 +35,7 @@ network_policies: - host: raw.githubusercontent.com port: 443 access: full - # See the automatic-TLS compatibility rationale on github.com above. + tls: skip # System git is intentionally excluded; git-based Homebrew operations require the github preset. binaries: - { path: /usr/bin/curl } diff --git a/nemoclaw-blueprint/policies/presets/outlook.yaml b/nemoclaw-blueprint/policies/presets/outlook.yaml index 9d449f5ef4f..ccfb648f257 100644 --- a/nemoclaw-blueprint/policies/presets/outlook.yaml +++ b/nemoclaw-blueprint/policies/presets/outlook.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - request_body_credential_rewrite: true rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -22,7 +21,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - request_body_credential_rewrite: true rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw/src/blueprint/runner-identity.test.ts b/nemoclaw/src/blueprint/runner-identity.test.ts index 260dd2f516b..af060841e1c 100644 --- a/nemoclaw/src/blueprint/runner-identity.test.ts +++ b/nemoclaw/src/blueprint/runner-identity.test.ts @@ -853,14 +853,14 @@ describe("blueprint identity wrapper", () => { store.set(stateDir, { type: "dir" }); store.set(`${stateDir}/plan.json`, { type: "file", - content: JSON.stringify({ sandbox_name: "existing-sandbox" }), + content: JSON.stringify({ sandbox_name: "pre-existing-sandbox" }), }); await actionRollback("legacy-run"); const rollbackCommands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - expect(rollbackCommands).not.toContain("sandbox stop existing-sandbox"); - expect(rollbackCommands).not.toContain("sandbox remove existing-sandbox"); + expect(rollbackCommands).not.toContain("sandbox stop pre-existing-sandbox"); + expect(rollbackCommands).not.toContain("sandbox remove pre-existing-sandbox"); expect(store.get(`${stateDir}/rolled_back`)?.content).toBeDefined(); }); diff --git a/nemoclaw/src/blueprint/runner-name-validation.test.ts b/nemoclaw/src/blueprint/runner-name-validation.test.ts index 82159fb78d4..20b186070d3 100644 --- a/nemoclaw/src/blueprint/runner-name-validation.test.ts +++ b/nemoclaw/src/blueprint/runner-name-validation.test.ts @@ -149,7 +149,7 @@ describe("blueprint name validation (fail-closed integration)", () => { ); }); - it("rollback rejects a plan whose sandbox_name is not OpenShell-compatible", async () => { + it("rollback rejects a plan whose sandbox_name is not an RFC 1035 label", async () => { const runDir = `${RUNS_DIR}/nc-run-1`; addDir(runDir); // "--rm" would be consumed as a flag by `openshell sandbox stop/remove`. diff --git a/nemoclaw/src/shared/openshell-policy-boundary.cts b/nemoclaw/src/shared/openshell-policy-boundary.cts index 830a393854f..5f1c0c107fc 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.cts +++ b/nemoclaw/src/shared/openshell-policy-boundary.cts @@ -100,7 +100,7 @@ export function parseOpenShellPolicy(raw: string): ParsedOpenShellPolicy { // regressionTest: the root policy round-trip and plugin runner policy tests. // removalCondition: OpenShell's supported base-policy contract guarantees that // provider-composed entries are absent from every mutation read. -// tracking: revalidated for stable OpenShell 0.0.101; revalidate after 0.0.101. +// tracking: revalidated for stable OpenShell 0.0.85; revalidate after 0.0.85. export function withoutProviderComposedPolicies(policies: Record): Record { return Object.fromEntries( Object.entries(policies).filter(([name]) => !name.startsWith("_provider_")), diff --git a/nemoclaw/src/shared/sandbox-name.cts b/nemoclaw/src/shared/sandbox-name.cts index dc26ce2da57..a4a0a117dc0 100644 --- a/nemoclaw/src/shared/sandbox-name.cts +++ b/nemoclaw/src/shared/sandbox-name.cts @@ -2,10 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // sourceOfTruth: These are the canonical blueprint sandbox and provider name -// grammars. Sandbox names use NemoClaw's OpenShell-compatible label subset; -// provider names mirror the existing NemoClaw provider contract. This module -// is compiled to generated .cjs/.d.cts files by build:cli before both the -// plugin and root CLI are built. +// grammars. Sandbox names are RFC 1035 labels; provider names mirror the +// existing NemoClaw provider contract. This module is compiled to generated +// .cjs/.d.cts files by build:cli before both the plugin and root CLI are built. // consumers: The ESM plugin runner (nemoclaw/src/blueprint/runner.ts) and // migration snapshot (nemoclaw/src/blueprint/snapshot.ts) import the generated // .cjs directly; the root CLI re-exports the sandbox constants through @@ -23,21 +22,17 @@ // or provider identifier, or both grammars are enforced by shared upstream // contracts. -// OpenShell v0.0.101 routes sandbox and workspace identities through labels -// capped at 19 characters. Keep NemoClaw's canonical sandbox-name boundary at -// that upstream limit so invalid creates fail before any gateway mutation. -export const NAME_MAX_LENGTH = 19; +export const NAME_MAX_LENGTH = 63; export const PROVIDER_NAME_MAX_LENGTH = 128; -// NemoClaw label: starts with a lowercase letter, then lowercase -// letters/digits/single internal hyphens, and ends with a letter or digit. -// OpenShell v0.0.101 reserves `--` as a routed-name segment delimiter. -export const NAME_VALID_PATTERN = /^(?!.*--)[a-z]([a-z0-9-]*[a-z0-9])?$/; +// RFC 1035 label: starts with a lowercase letter, then lowercase +// letters/digits/internal hyphens, ends with a letter or digit. +export const NAME_VALID_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; export const PROVIDER_NAME_VALID_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/; export const NAME_ALLOWED_FORMAT = `1-${NAME_MAX_LENGTH} characters, lowercase, starts with a letter, ` + - "letters/numbers/single internal hyphens only, ends with letter/number"; + "letters/numbers/internal hyphens only, ends with letter/number"; export const PROVIDER_NAME_ALLOWED_FORMAT = `1-${PROVIDER_NAME_MAX_LENGTH} characters, starts with a letter, ` + "letters/numbers/dots/underscores/hyphens only"; @@ -70,8 +65,8 @@ export function diagnosticPreview(value: unknown): string { } /** - * True when `value` is a well-formed sandbox name under NemoClaw's - * OpenShell-compatible label contract. + * True when `value` is a well-formed sandbox name: a non-empty RFC 1035 label + * no longer than NAME_MAX_LENGTH characters. */ export function isValidName(value: unknown): value is string { return ( diff --git a/nemoclaw/src/shared/sandbox-name.test.ts b/nemoclaw/src/shared/sandbox-name.test.ts index 7b482b970c8..53279d16a09 100644 --- a/nemoclaw/src/shared/sandbox-name.test.ts +++ b/nemoclaw/src/shared/sandbox-name.test.ts @@ -48,7 +48,7 @@ describe("sandbox and provider name canonical validators", () => { "a1", "my-sandbox-1", "a".repeat(NAME_MAX_LENGTH), - ])("accepts the OpenShell-compatible sandbox name '%s'", (name) => { + ])("accepts the RFC 1035 label '%s'", (name) => { expect(isValidName(name)).toBe(true); }); @@ -57,7 +57,6 @@ describe("sandbox and provider name canonical validators", () => { ["leading dash (flag injection)", "-x"], ["flag-like", "--help"], ["trailing dash", "foo-"], - ["consecutive hyphens", "foo--bar"], ["leading digit", "1box"], ["uppercase", "Foo"], ["underscore", "my_box"], @@ -157,11 +156,10 @@ describe("sandbox and provider name canonical validators", () => { expect(isValidName(candidate)).toBe(false); return; } - // If it returned, the accepted value must satisfy the canonical contract. + // If it returned, the accepted value must be a safe RFC 1035 label. expect(returned).toBe(candidate); expect(NAME_VALID_PATTERN.test(returned)).toBe(true); expect(returned.startsWith("-")).toBe(false); - expect(returned.includes("--")).toBe(false); expect(/[^a-z0-9-]/.test(returned)).toBe(false); }), ); diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json index a69a129436c..0c69f925597 100644 --- a/schemas/blueprint.schema.json +++ b/schemas/blueprint.schema.json @@ -57,9 +57,9 @@ }, "name": { "type": "string", - "pattern": "^(?!.*--)[a-z]([a-z0-9-]*[a-z0-9])?$", - "maxLength": 19, - "description": "Default sandbox name. Lowercase, starts with a letter, contains letters/numbers/single internal hyphens only, ends with a letter or number, max 19 characters. Flows into 'openshell sandbox create --name' and Kubernetes pod names." + "pattern": "^[a-z]([a-z0-9-]*[a-z0-9])?$", + "maxLength": 63, + "description": "Default sandbox name. RFC 1035 label: lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with a letter or number, max 63 characters. Flows into 'openshell sandbox create --name' and Kubernetes pod names." }, "forward_ports": { "type": "array", diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh index 847bb3751b4..64cc2ae0d1f 100755 --- a/scripts/check-installer-hash.sh +++ b/scripts/check-installer-hash.sh @@ -39,12 +39,6 @@ readonly -a OPENSHELL_RELEASE_MANIFEST_ALLOWLIST=( "0.0.85|openshell-checksums-sha256.txt|6554b3f96c04006d661519786d40d17e34c7860b7aac8fd35259ef2aea01567f" "0.0.85|openshell-gateway-checksums-sha256.txt|cc4f32afed376ebe9b43cccdb4d2a77b2524b57132a6b56bb88d705e02420f86" "0.0.85|openshell-sandbox-checksums-sha256.txt|b6ac353c933fa4cf9a3ef11d66cce6635f39ecc2e928d9c8ff1783ca797308b3" - "0.0.99|openshell-checksums-sha256.txt|ea3e2c1a583e5ea00332c3b65a18068bd1f9b090f7ff0f5e24b29762cfc3b4c7" - "0.0.99|openshell-gateway-checksums-sha256.txt|7f84f728412548720c8ef51993c58414c4f04598451c282b26ead233185e40c5" - "0.0.99|openshell-sandbox-checksums-sha256.txt|9e67af6bab9f975432a1045fcfea5ab182ab585b17886c8c290c1eb77232b87a" - "0.0.101|openshell-checksums-sha256.txt|9c90869d00b109b5ac1062b1a9808a592c2311d3c0c4926bae44d136b979d8a9" - "0.0.101|openshell-gateway-checksums-sha256.txt|dcb3f1917713bf2a8e8e1803ac42c5e39d9dd41e644136b05def32b077082777" - "0.0.101|openshell-sandbox-checksums-sha256.txt|d16f7d369c54d74d36c7df036565267a960e7ce6fb143012fe9d77f257d6e8b3" ) case "${1:-}" in diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 6a042a1a3f5..77fc85f16e9 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -291,11 +291,6 @@ pinned_sandbox_build_version() { 76bc19b70d9f1e1e9871307045796cd39cc7b8fc4c08ffc90593cc934f36d500) printf '%s\n' "0.0.82" ;; - # OpenShell v0.0.99 standalone sandbox binaries. - a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214 | \ - f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e) - printf '%s\n' "0.0.99" - ;; # OpenShell v0.0.85 standalone sandbox binaries. 863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2 | \ 680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d) diff --git a/scripts/install.sh b/scripts/install.sh index fdb117a0bc9..ca38d49be5a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1139,11 +1139,6 @@ _PREEXISTING_SANDBOX_RECOVERY_RAN=false # preserved). The final summary must not claim those sandboxes were recovered. _PREEXISTING_SANDBOX_ORPHANED=false _LEGACY_MANAGED_RECOVERY_NAMES_JSON="[]" -# OpenShell v0.0.101 routes sandbox and workspace identities through labels -# capped at 19 characters. Keep this installer-only raw-registry preflight in -# sync with NAME_MAX_LENGTH in nemoclaw/src/shared/sandbox-name.cts. The -# current CLI cannot be prepared safely until legacy names are checked. -_OPENSHELL_SANDBOX_NAME_MAX_LENGTH=19 # #5735: set when automatic recovery/upgrade of pre-existing sandboxes # reported a failure. A failed/destructive rebuild must not be reported as a # clean install, so print_done downgrades the final banner when this is true. @@ -2295,7 +2290,7 @@ verify_nemoclaw() { inspect_sandbox_registry_for_upgrade() { local reg_file="$1" field="$2" scope="${3:-legacy}" gateway_port gateway_port="$(resolve_nemoclaw_gateway_port)" - node - "$reg_file" "$field" "$gateway_port" "$scope" "$_OPENSHELL_SANDBOX_NAME_MAX_LENGTH" <<'NODE' + node - "$reg_file" "$field" "$gateway_port" "$scope" <<'NODE' const fs = require("node:fs"); function isObjectRecord(value) { @@ -2347,8 +2342,6 @@ try { process.exit(1); } if (process.argv[5] === "selected" && entries.length !== allEntries.length) process.exit(1); -const maxNameLength = Number(process.argv[6]); -if (!Number.isInteger(maxNameLength) || maxNameLength < 1) process.exit(1); // Keep this raw-registry predicate in sync with isRouteOnlySandboxReservation() // in src/lib/state/registry.ts. const sandboxes = entries.filter( @@ -2359,18 +2352,6 @@ if (process.argv[3] === "count") { process.stdout.write(String(sandboxes.length)); process.exit(0); } -if (process.argv[3] === "incompatible-names") { - const compatiblePattern = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; - const incompatible = sandboxes - .map(([name]) => name) - .filter( - (name) => - name.length > maxNameLength || !compatiblePattern.test(name) || name.includes("--"), - ) - .sort(); - process.stdout.write(JSON.stringify(incompatible)); - process.exit(0); -} if (process.argv[3] !== "ambiguous-names") process.exit(1); const ambiguous = sandboxes @@ -2516,69 +2497,6 @@ legacy_ambiguous_sandbox_names_json() { inspect_sandbox_registry_for_upgrade "$reg_file" ambiguous-names "$scope" } -openshell_incompatible_sandbox_names_json() { - local reg_file="$1" - local scope="legacy" - if [ "$(resolve_nemoclaw_gateway_port)" -ne 8080 ] \ - && [ "$reg_file" = "$(nemoclaw_state_dir)/sandboxes.json" ]; then - scope="selected" - fi - inspect_sandbox_registry_for_upgrade "$reg_file" incompatible-names "$scope" -} - -require_openshell_compatible_sandbox_names() { - local reg_file="$1" incompatible_json="" incompatible_count="0" - if ! incompatible_json="$(openshell_incompatible_sandbox_names_json "$reg_file")"; then - error "Could not validate existing sandbox names for the OpenShell upgrade. Existing gateway and sandboxes were left unchanged." - fi - incompatible_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).length))' "$incompatible_json")" - if [ "$incompatible_count" -eq 0 ] 2>/dev/null; then - return 0 - fi - - cat < { - const raw = String(value); - const prefix = raw.slice(0, 80); - let escaped = "\""; - for (let index = 0; index < prefix.length; index += 1) { - const codeUnit = prefix.charCodeAt(index); - if (codeUnit === 0x22) escaped += "\\\""; - else if (codeUnit === 0x5c) escaped += "\\\\"; - else if (codeUnit >= 0x20 && codeUnit <= 0x7e) escaped += prefix[index]; - else escaped += "\\u" + codeUnit.toString(16).padStart(4, "0"); - } - return escaped + (raw.length > 80 ? "...\"" : "\""); - }; - for (const name of JSON.parse(process.argv[1])) console.log(preview(name)); - ' "$incompatible_json") - cat </dev/null || return 0 - if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" ]]; then - info "Portable OpenShell 0.0.85 install is isolated from existing OpenShell state; skipping gateway migration and sandbox backup." - if command_exists systemctl; then - systemctl --user stop nemoclaw-openshell-gateway.service >/dev/null 2>&1 || true - fi - return 0 - fi - require_openshell_compatible_sandbox_names "$reg_file" if ! command_exists openshell; then # NemoClaw v0.0.55's OpenShell 0.0.44 layout could install this binary # without persisting ~/.local/bin on PATH. Retain this fallback while direct @@ -3114,10 +3024,6 @@ recover_preexisting_sandboxes_before_onboard() { if [ "${_PREEXISTING_SANDBOX_COUNT:-0}" -le 0 ] 2>/dev/null; then return 0 fi - if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" ]]; then - info "OpenShell 0.0.85 portable state is isolated; not migrating sandboxes from prior OpenShell stacks." - return 0 - fi info "Recovering and upgrading pre-existing sandboxes before onboarding…" # `--auto` is the existing non-interactive maintenance path. When the @@ -3882,8 +3788,7 @@ validate_station_express_resume_agent() { validate_station_express_resume_sandbox() { local sandbox="${1:-}" - [[ ${#sandbox} -le $_OPENSHELL_SANDBOX_NAME_MAX_LENGTH ]] \ - && [[ "$sandbox" != *--* ]] \ + [[ ${#sandbox} -le 63 ]] \ && { [[ "$sandbox" =~ ^[a-z]$ ]] || [[ "$sandbox" =~ ^[a-z][a-z0-9-]*[a-z0-9]$ ]]; } } diff --git a/scripts/lib/openshell-gateway.service.in b/scripts/lib/openshell-gateway.service.in index 8b9be98d261..5cd39b5cf9a 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -5,12 +5,13 @@ [Unit] Description=OpenShell Gateway Documentation=https://github.com/NVIDIA/OpenShell +After=default.target [Service] Type=simple -StateDirectory=nemoclaw/openshell-docker-gateway-v0.0.85 -Environment=OPENSHELL_DB_URL=sqlite:%S/nemoclaw/openshell-docker-gateway-v0.0.85/openshell.db -Environment=OPENSHELL_LOCAL_TLS_DIR=%S/nemoclaw/openshell-docker-gateway-v0.0.85/tls +StateDirectory=openshell/gateway-v0.0.85-pr8578 +Environment=OPENSHELL_DB_URL=sqlite:%S/openshell/gateway-v0.0.85-pr8578/openshell.db +Environment=OPENSHELL_LOCAL_TLS_DIR=%S/openshell/gateway-v0.0.85-pr8578/tls EnvironmentFile=-%E/openshell/gateway.env ExecStartPre=@OPENSHELL_GATEWAY_BIN@ generate-certs --output-dir ${OPENSHELL_LOCAL_TLS_DIR} --server-san host.openshell.internal ExecStart=@OPENSHELL_GATEWAY_BIN@ diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 3f0207515b8..efbaadbd968 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3454,7 +3454,7 @@ GATEWAYURLENVEOF # src/lib/onboard/sandbox-create-launch.ts) and is the only in-container # source of the name; capture it here for the renderer below. # - # Apply the same canonical sandbox-name allowlist the renderer uses (mirrors + # Apply the same RFC-1123 allowlist the renderer uses (mirrors # NAME_VALID_PATTERN in src/lib/name-validation.ts). Missing or invalid # values cannot reach a copyable command. An accepted value is limited to # [a-z0-9-] and needs no further escaping. @@ -3467,9 +3467,9 @@ GATEWAYURLENVEOF _sandbox_label="" case "$_sandbox_label_src" in "" | 0 | 1 | true | TRUE | false | FALSE) ;; - [!a-z]* | *- | *--* | *[!a-z0-9-]*) ;; + [!a-z]* | *- | *[!a-z0-9-]*) ;; *) - if [ "${#_sandbox_label_src}" -le 19 ]; then + if [ "${#_sandbox_label_src}" -le 63 ]; then _sandbox_label="$_sandbox_label_src" fi ;; @@ -3942,12 +3942,11 @@ _nemoclaw_valid_sandbox_label() { # The candidates are untrusted input interpolated into a copyable `nemoclaw …` # command, so allowlist rather than merely strip: only render a value that is # a valid sandbox name. This mirrors NAME_VALID_PATTERN in - # src/lib/name-validation.ts (lowercase, max 19, no consecutive hyphens): - # starts with a lowercase letter, then lowercase alphanumerics/single internal - # hyphens, with no trailing hyphen. Anything else (digit-leading labels, - # control characters, ANSI escapes, shell metacharacters, whitespace) is - # rejected, and the caller falls back to a placeholder the user resolves with - # `nemoclaw list`. Shell `case` + # src/lib/name-validation.ts (/^[a-z]([a-z0-9-]*[a-z0-9])?$/, max 63): starts + # with a lowercase letter, then lowercase alphanumerics/hyphens, no trailing + # hyphen. Anything else (digit-leading labels, control characters, ANSI + # escapes, shell metacharacters, whitespace) is rejected, and the caller falls + # back to a placeholder the user resolves with `nemoclaw list`. Shell `case` # globs match newlines as ordinary characters, so an embedded newline is # rejected by the metacharacter class. The boolean forms are OpenShell's older # "this is a sandbox" marker rather than a name. @@ -3960,9 +3959,9 @@ _nemoclaw_valid_sandbox_label() { LC_ALL=C case "${1:-}" in "" | 0 | 1 | true | TRUE | false | FALSE) ;; - [!a-z]* | *- | *--* | *[!a-z0-9-]*) ;; + [!a-z]* | *- | *[!a-z0-9-]*) ;; *) - if [ "${#1}" -le 19 ]; then + if [ "${#1}" -le 63 ]; then printf '%s' "$1" fi ;; diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index 6a384ec6993..fbc4b4ea9cb 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -38,27 +38,6 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 ( cd "${source_dir}" - # Test::Harness validates signal-result parsing by deliberately sending - # SIGSEGV to a child Perl process. Desktop crash reporters surface that - # expected child signal as a scary "process crash" notification during the - # otherwise healthy package build. Skip only that self-crash subtest; the - # rest of cpan/Test-Harness/t/harness.t still runs normally. - readonly test_harness_test='cpan/Test-Harness/t/harness.t' - readonly test_harness_tmp="${test_harness_test}.nemoclaw" - test "$(grep -Fc 'skip "No SIGSEGV on $^O", 1 if' "${test_harness_test}")" -eq 1 - awk ' - index($0, "skip \"No SIGSEGV on $^O\", 1 if") { - print " skip \"NemoClaw package builds do not intentionally raise SIGSEGV\", 1;" - next - } - { print } - ' "${test_harness_test}" >"${test_harness_tmp}" - mv "${test_harness_tmp}" "${test_harness_test}" - test "$( - grep -Fxc \ - ' skip "NemoClaw package builds do not intentionally raise SIGSEGV", 1;' \ - "${test_harness_test}" - )" -eq 1 # Pin the reviewed d_syscallproto result for trixie's libc so both native # architectures use the same known declaration instead of relying on a # Configure probe that previously returned a false negative under QEMU. @@ -75,29 +54,6 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 -Dman3dir=none make -j"$(nproc)" make test_prep - readonly extutils_constant_test='../cpan/ExtUtils-Constant/t/Constant.t' - readonly -a raw_icmp_tests=( - '../dist/Net-Ping/t/001_new.t' - '../dist/Net-Ping/t/110_icmp_inst.t' - '../dist/Net-Ping/t/500_ping_icmp.t' - '../dist/Net-Ping/t/520_icmp_ttl.t' - ) - parallel_test_exclusion='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' - : >"${build_root}/perl-tests-capability-skipped" - # Rootless Podman maps the build user to effective UID 0 inside its user - # namespace without granting CAP_NET_RAW. Net::Ping treats UID 0 as proof - # that raw ICMP sockets are available, so these four upstream cases attempt - # privileged socket creation and fail with EPERM. Probe the actual capability: - # full-capability builders still run every test, while rootless builders omit - # only the cases that cannot execute in their namespace. - if ! env -C t ./perl -MNet::Ping -e 'Net::Ping->new("icmp")' >/dev/null 2>&1; then - parallel_test_exclusion='--nre=^(?:[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t|[.][.]/dist/Net-Ping/t/(?:001_new|110_icmp_inst|500_ping_icmp|520_icmp_ttl)[.]t)$' - env -C t ./perl harness -dumptests "${raw_icmp_tests[@]}" \ - >"${build_root}/perl-tests-capability-skipped" - printf '%s\n' \ - 'Skipping four Net::Ping raw-ICMP tests because this build namespace lacks CAP_NET_RAW.' \ - >&2 - fi # ExtUtils::Constant's test recursively invokes make and produced an incomplete # TAP plan when it overlapped another test locally, so run it alone first and # exclude exactly that already-passed file from the parallel pass. @@ -107,17 +63,16 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ >"${build_root}/perl-tests-full" env -C t ./perl harness -dumptests \ - "${extutils_constant_test}" \ + ../cpan/ExtUtils-Constant/t/Constant.t \ >"${build_root}/perl-tests-serial" env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - "${parallel_test_exclusion}" \ + '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ >"${build_root}/perl-tests-parallel" sort "${build_root}/perl-tests-full" \ >"${build_root}/perl-tests-full.sorted" sort \ "${build_root}/perl-tests-serial" \ "${build_root}/perl-tests-parallel" \ - "${build_root}/perl-tests-capability-skipped" \ >"${build_root}/perl-tests-combined.sorted" cmp \ "${build_root}/perl-tests-full.sorted" \ @@ -132,11 +87,11 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 # scheduler use each native runner efficiently instead of serializing every # script in QEMU. TEST_JOBS=1 \ - TEST_ARGS="${extutils_constant_test}" \ + TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ make test_harness TEST_JOBS="$(nproc)" \ PERL_TEST_HARNESS_ASAP=1 \ - TEST_ARGS="${parallel_test_exclusion}" \ + TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ make -j"$(nproc)" test_harness make install DESTDIR="${perl_root}" ) diff --git a/scripts/smoke-macos-install.sh b/scripts/smoke-macos-install.sh index a85217269f5..28cea4a6714 100755 --- a/scripts/smoke-macos-install.sh +++ b/scripts/smoke-macos-install.sh @@ -24,7 +24,7 @@ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # shellcheck source=./lib/runtime.sh . "$SCRIPT_DIR/lib/runtime.sh" -SANDBOX_NAME="smoke-$(date +%m%d%H%M%S)" +SANDBOX_NAME="smoke-$(date +%Y%m%d%H%M%S)" LOG_DIR="${TMPDIR:-/tmp}/nemoclaw-smoke" RUNTIME="" ALLOW_EXISTING_STATE=false @@ -115,10 +115,8 @@ done [ -x "$REPO_DIR/uninstall.sh" ] || fail "uninstall.sh not found at repo root." validate_sandbox_name() { - if [ "${#SANDBOX_NAME}" -gt 19 ] \ - || [[ "$SANDBOX_NAME" == *--* ]] \ - || ! [[ "$SANDBOX_NAME" =~ ^[a-z]([a-z0-9-]*[a-z0-9])?$ ]]; then - fail "Invalid sandbox name '$SANDBOX_NAME'. Use 1-19 lowercase letters, numbers, and single internal hyphens; start with a letter and end with a letter or number." + if ! [[ "$SANDBOX_NAME" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + fail "Invalid sandbox name '$SANDBOX_NAME'. Use lowercase letters, numbers, and hyphens." fi } diff --git a/scripts/update-hermes-agent.sh b/scripts/update-hermes-agent.sh index 0bf2ffab7b6..75b7c8c3a1c 100755 --- a/scripts/update-hermes-agent.sh +++ b/scripts/update-hermes-agent.sh @@ -201,7 +201,7 @@ installed_copy_schema_error() { "COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py" \ "/opt/hermes/.venv/bin/python -I /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py --guard /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py" \ "hermes-mcp-config-transaction.py" \ - "openshell-child-visible-credentials.v0.0.101.json" \ + "openshell-child-visible-credentials.v0.0.85.json" \ "HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix" \ "node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts" \ "/sandbox/.hermes/profiles/dashboard-home"; do diff --git a/src/lib/actions/sandbox/destroy-execution.ts b/src/lib/actions/sandbox/destroy-execution.ts index 0629dc97ef7..865fae0773a 100644 --- a/src/lib/actions/sandbox/destroy-execution.ts +++ b/src/lib/actions/sandbox/destroy-execution.ts @@ -3,7 +3,6 @@ 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, @@ -33,10 +32,6 @@ 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; diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 97986044577..74eb373b7a9 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -57,10 +57,6 @@ 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 () => { @@ -110,7 +106,6 @@ 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 () => { diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 0f4c55dea67..6712b7e362d 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -41,11 +41,7 @@ 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, - retirePortableLifecycleAuthority, -} from "./destroy-execution"; +import { executeSandboxDestroy, redactDestroyError } from "./destroy-execution"; import { cleanupGatewayAfterLastSandbox } from "./destroy-gateway"; import { shouldCleanupGatewayAfterConfirmedFinalDestroy } from "./destroy-gateway-cleanup"; import { prepareSandboxDestroy } from "./destroy-preflight"; @@ -149,9 +145,8 @@ export function cleanupSandboxServices( // Source boundary: this exported helper can be called independently of CLI // dispatch, including from forced local recovery. Validate once before every // host and provider cleanup side effect, then derive the PID path from that - // same canonical OpenShell-compatible name. Remove only when the helper - // accepts a validated-name type that cannot be constructed from unchecked - // input. + // same RFC 1123 name. Remove only when the helper accepts a validated-name + // type that cannot be constructed from unchecked input. const validatedSandboxName = validateName(sandboxName, "sandbox name"); const servicesPidDir = path.resolve("/tmp", `nemoclaw-services-${validatedSandboxName}`); const getSandbox = deps.getSandbox ?? registry.getSandbox; @@ -616,15 +611,6 @@ 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); } diff --git a/src/lib/actions/sandbox/doctor-inference.test.ts b/src/lib/actions/sandbox/doctor-inference.test.ts index c42279fe39d..bf641d7bc9e 100644 --- a/src/lib/actions/sandbox/doctor-inference.test.ts +++ b/src/lib/actions/sandbox/doctor-inference.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ProviderHealthStatus } from "../../inference/health"; -import { collectInferenceChecks, collectManagedLlamaCppDoctorChecks } from "./doctor-inference"; +import { collectInferenceChecks } from "./doctor-inference"; const endpoint = "https://inference.local/v1/models"; @@ -32,44 +32,6 @@ function upstream(overrides: Partial = {}): ProviderHealth } describe("doctor inference checks", () => { - it.each([ - ["running", "ok", false], - ["preparing", "warn", true], - ["stopped", "warn", true], - ["absent", "fail", true], - ["conflict", "fail", true], - ["unknown", "fail", true], - ] as const)("maps managed llama.cpp %s to an actionable %s diagnostic", (state, status, hinted) => { - const checks = collectManagedLlamaCppDoctorChecks("spark-agent", 7443, { - inspectManagedLlamaCppStatusImpl: vi.fn(() => ({ - recipeId: "llama-cpp.nemotron.spark.v1", - modelDigest: state === "preparing" ? null : `sha256:${"a".repeat(64)}`, - imageReference: - state === "preparing" - ? null - : `ghcr.io/nvidia/nemoclaw/llama-cpp-server@sha256:${"b".repeat(64)}`, - endpoint: "https://inference.local/v1" as const, - state, - detail: `${state} managed runtime`, - })), - }); - - expect(checks).toHaveLength(2); - expect(checks[1]).toMatchObject({ - label: "Managed llama.cpp runtime", - status, - detail: `${state}: ${state} managed runtime; endpoint https://inference.local/v1`, - ...(hinted - ? { hint: "re-run `nemoclaw onboard` for 'spark-agent' to recover the exact runtime" } - : {}), - }); - expect(checks[1]?.hint).toBe( - hinted - ? "re-run `nemoclaw onboard` for 'spark-agent' to recover the exact runtime" - : undefined, - ); - }); - it("makes a broken inference.local route authoritative over a healthy upstream (#6192)", async () => { const checks = await collectInferenceChecks( "alpha", diff --git a/src/lib/actions/sandbox/doctor-inference.ts b/src/lib/actions/sandbox/doctor-inference.ts index f6c9a4fad12..55c252ad18d 100644 --- a/src/lib/actions/sandbox/doctor-inference.ts +++ b/src/lib/actions/sandbox/doctor-inference.ts @@ -18,21 +18,13 @@ export type DoctorInferenceRoute = { effectiveReasoningEffort?: EffectiveReasoningEffort | null; }; -type ManagedLlamaCppDoctorDeps = { - inspectManagedLlamaCppStatusImpl?: typeof inspectManagedLlamaCppStatus; -}; - export function collectManagedLlamaCppDoctorChecks( sandboxName: string, gatewayPort?: number | null, - deps: ManagedLlamaCppDoctorDeps = {}, ): DoctorCheck[] { - const managed = (deps.inspectManagedLlamaCppStatusImpl ?? inspectManagedLlamaCppStatus)( - sandboxName, - { - ...(typeof gatewayPort === "number" ? { gatewayPort } : {}), - }, - ); + const managed = inspectManagedLlamaCppStatus(sandboxName, { + ...(typeof gatewayPort === "number" ? { gatewayPort } : {}), + }); if (!managed) return []; const runtimeStatus = managed.state === "running" diff --git a/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts b/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts index 0f6ae443ea7..a5ff1a3b62d 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-rendering.test.ts @@ -36,8 +36,8 @@ describe("buildPolicyDenialExecHint (#5978)", () => { "a-b-c", "valid-lowercase", "valid-with-hyphens", - "a".repeat(19), - `${"a".repeat(17)}-b`, + "a".repeat(63), + `${"a".repeat(61)}-b`, ])("renders a valid RFC-1123 sandbox name unchanged: %s", (valid) => { const hint = buildPolicyDenialExecHint("nemoclaw", valid, "example.com:443"); expect(hint).toContain(`inside sandbox '${valid}'`); @@ -48,7 +48,7 @@ describe("buildPolicyDenialExecHint (#5978)", () => { ["control characters / TTY escapes", "oc\ninjected"], ["shell metacharacters", "oc; rm -rf /"], ["uppercase (not an RFC-1123 label)", "OC-Fresh"], - ["over-length label", "a".repeat(20)], + ["over-length label", "a".repeat(64)], ])("renders the placeholder for an unsafe sandbox name: %s", (_label, unsafe) => { const hint = buildPolicyDenialExecHint("nemoclaw", unsafe, "example.com:443"); expect(hint).toContain("nemoclaw logs --tail 50"); diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 41bd08a1f7b..5f46646d015 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -52,7 +51,6 @@ 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"; @@ -93,42 +91,15 @@ 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: SandboxEntry | null, + sandbox: Pick | null, gatewayName: string, ): PortableDemoLifecycleRecoveryResult { - if (!sandbox || sandbox.openshellDriver !== "docker") return { kind: "not-installed" }; + if (!sandbox) 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(), - ensureGateway: () => { - const result = spawnSync( - "systemctl", - ["--user", "start", "nemoclaw-openshell-gateway.service"], - { - encoding: "utf-8", - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }, - ); - if (result.status === 0 && !result.error) return; - const detail = - result.error?.message || - String(result.stderr ?? "").trim() || - String(result.stdout ?? "").trim() || - `exit ${String(result.status)}`; - throw new Error(`Starting the portable OpenShell gateway failed: ${detail}`); - }, captureOpenshell: (args, timeoutMs) => { const result = captureOpenshell([...args], { ignoreError: true, diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index 6f3a7711f48..77b4a0e5cdc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -123,7 +123,12 @@ function assertPreparedMcpAddResourcesAbsent( `MCP add preflight for '${entry.server}' found an existing policy ownership record '${entry.policyName}'. The durable add manifest was preserved without claiming it.`, ); } - const policyContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); + const policyContent = buildMcpBridgePolicyYaml( + entry.server, + entry.url, + adapter, + target.addresses, + ); const policyState = policies.getPresetContentGatewayState(sandboxName, policyContent); if (policyState !== "absent") { throw new McpBridgeError( @@ -204,7 +209,6 @@ async function addMcpBridgeUnlocked( const replay = replayTrustedPrivateEndpoint( existingEntry.trustedPrivateHost, existingEntry.allowedIps ?? [], - { requireAllPrivate: true }, ); target = { addresses: [...replay.addresses], diff --git a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts index cf78ccabede..195da493110 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts @@ -9,7 +9,6 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { isTrustedPrivateEndpointCapability } from "../../security/trusted-private-endpoint"; import { addMcpBridge, normalizeMcpServerUrl } from "./mcp-bridge"; import { inspectMcpRecordedTargetPins, @@ -91,63 +90,8 @@ describe("MCP URL target validation", () => { } }); - it("admits a direct private IPv4 target with exact host-bound authority (#8267)", async () => { - const lookup = vi.spyOn(dns, "lookup"); - try { - const url = normalizeMcpServerUrl("https://10.20.30.40/mcp", { - trustedPrivateHosts: ["10.20.30.40"], - }); - const target = await preflightMcpServerUrlResolvedTarget(new URL(url), { - trustedPrivateHosts: ["10.20.30.40"], - requireTrustedPrivateEndpoint: true, - }); - - expect(lookup).not.toHaveBeenCalled(); - expect(target).toMatchObject({ - addresses: ["10.20.30.40"], - trustedPrivateHost: "10.20.30.40", - }); - expect(isTrustedPrivateEndpointCapability(target.trustedPrivateCapability)).toBe(true); - expect(target.trustedPrivateCapability).toMatchObject({ - host: "10.20.30.40", - addresses: ["10.20.30.40"], - }); - } finally { - lookup.mockRestore(); - } - }); - - it("admits a trusted reserved-suffix DNS target with exact private pins (#8267)", async () => { - const lookup = vi - .spyOn(dns, "lookup") - .mockResolvedValue([{ address: "10.20.30.40", family: 4 }] as never); - try { - expect(() => normalizeMcpServerUrl("https://mcp.corp.internal/mcp")).toThrow( - /private, local, or special-use/, - ); - const url = normalizeMcpServerUrl("https://mcp.corp.internal/mcp", { - trustedPrivateHosts: ["mcp.corp.internal"], - }); - await expect( - preflightMcpServerUrlResolvedTarget(new URL(url), { - trustedPrivateHosts: ["mcp.corp.internal"], - requireTrustedPrivateEndpoint: true, - }), - ).resolves.toMatchObject({ - addresses: ["10.20.30.40"], - trustedPrivateHost: "mcp.corp.internal", - trustedPrivateCapability: { - host: "mcp.corp.internal", - addresses: ["10.20.30.40"], - }, - }); - } finally { - lookup.mockRestore(); - } - }); - it("persists exact normalized pins after successful trusted-private admission (#8267)", { - timeout: 40_000, + timeout: 15_000, }, () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-private-mcp-add-success-")); const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); @@ -210,10 +154,8 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { capabilityAddresses: admittedTarget.trustedPrivateCapability.addresses, trustedPrivateHost: admittedTarget.trustedPrivateHost, }, - }), () => process.exit(0)); -}, (error) => { - process.stderr.write(error.stack || error.message, () => process.exit(1)); -}); + })); +}, (error) => { process.stderr.write(error.stack || error.message); process.exitCode = 1; }); `; try { const result = spawnSync(process.execPath, ["-e", script], { @@ -226,7 +168,7 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { .filter(Boolean) .join(" "), }, - timeout: 30_000, + timeout: 12_000, }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); const admission = JSON.parse(result.stdout) as { @@ -260,7 +202,7 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { trustedPrivateHosts: ["mcp.corp.example"], requireTrustedPrivateEndpoint: true, }), - ).rejects.toThrow(/must resolve only to supported routed private addresses/); + ).rejects.toThrow(/mixed public and private addresses/); lookup.mockResolvedValueOnce([{ address: "8.8.8.8", family: 4 }] as never); await expect( diff --git a/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts index fe0924052f6..b0c9b4f4d29 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts @@ -14,7 +14,7 @@ import { parseMcpAddArgs, resolveCredentialEnv, } from "./mcp-bridge"; -import childVisibleCredentialManifest from "./openshell-child-visible-credentials.v0.0.99.json"; +import childVisibleCredentialManifest from "./openshell-child-visible-credentials.v0.0.85.json"; describe("MCP CLI input validation", () => { it("parses server, URL, and env references", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-name-diagnostics.test.ts b/src/lib/actions/sandbox/mcp-bridge-name-diagnostics.test.ts index fde3aa29bc0..b737154fb28 100644 --- a/src/lib/actions/sandbox/mcp-bridge-name-diagnostics.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-name-diagnostics.test.ts @@ -21,17 +21,6 @@ function messageFrom(reject: () => void): string { } describe("MCP bridge name diagnostics", () => { - it("uses the canonical OpenShell 0.0.99 sandbox-name boundary (#8497)", () => { - expect(() => validateSandboxName("a".repeat(19))).not.toThrow(); - - for (const name of ["a".repeat(20), "legacy--box"]) { - const message = messageFrom(() => validateSandboxName(name)); - - expect(message).toContain(`Invalid sandbox name "${name}"`); - expect(message).toContain("Allowed format: 1-19 characters"); - } - }); - it("escapes control characters in a rejected sandbox name (#7796)", () => { const message = messageFrom(() => validateSandboxName(`bad${ESC}[31mX`)); @@ -55,10 +44,8 @@ describe("MCP bridge name diagnostics", () => { it("bounds an over-length rejected name to a truncated preview (#7796)", () => { const message = messageFrom(() => validateSandboxName(`Bad${"x".repeat(200)}`)); - const muchLongerMessage = messageFrom(() => validateSandboxName(`Bad${"x".repeat(2_000)}`)); expect(message).toContain(`"Bad${"x".repeat(77)}..."`); - expect(muchLongerMessage.length).toBe(message.length); - expect(message.length).toBeLessThan(260); + expect(message.length).toBeLessThan(200); }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy-render.ts b/src/lib/actions/sandbox/mcp-bridge-policy-render.ts index 4a505bbc03a..94f2463bf80 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy-render.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy-render.ts @@ -4,11 +4,7 @@ import YAML from "yaml"; import type { AgentMcpAdapter } from "../../agent/defs"; -import { - type McpBridgeTargetValidation, - parseMcpUrlWithValidatedTarget, -} from "./mcp-bridge-url-validation"; -import { validateMcpServerName } from "./mcp-bridge-validation"; +import { parseMcpUrl, validateMcpServerName } from "./mcp-bridge-validation"; export const MCP_BRIDGE_POLICY_MAX_BODY_BYTES = 131_072; export const MCP_BRIDGE_ALLOWED_METHODS = [ @@ -82,17 +78,23 @@ function binariesForAdapter(adapter: AgentMcpAdapter): Array<{ path: string }> { } } +function allowedIpsForEndpoint( + resolvedAddresses: readonly string[] | undefined, +): string[] | undefined { + // OpenShell resolves this hostname for every new connection, validates every + // current answer against allowed_ips, and connects to that validated list. + return resolvedAddresses && resolvedAddresses.length > 0 ? [...resolvedAddresses] : undefined; +} + export function buildMcpBridgePolicyYaml( server: string, url: string, adapter: AgentMcpAdapter, - target: McpBridgeTargetValidation, + resolvedAddresses?: readonly string[], ): string { - const parsed = parseMcpUrlWithValidatedTarget(url, target); + const parsed = parseMcpUrl(url); const key = buildMcpBridgePolicyKey(server); - // OpenShell resolves this hostname for every new connection, validates every - // current answer against allowed_ips, and connects to that validated list. - const allowedIps = [...target.addresses]; + const allowedIps = allowedIpsForEndpoint(resolvedAddresses); return YAML.stringify({ preset: { name: buildMcpBridgePolicyName(server), @@ -108,7 +110,7 @@ export function buildMcpBridgePolicyYaml( path: endpointPath(parsed), protocol: "mcp", enforcement: "enforce", - allowed_ips: allowedIps, + ...(allowedIps ? { allowed_ips: allowedIps } : {}), mcp: { max_body_bytes: MCP_BRIDGE_POLICY_MAX_BODY_BYTES, strict_tool_names: true, diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index d43faa14b48..a02e45ea005 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import * as policies from "../../policy"; -import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { @@ -16,12 +15,7 @@ import { MCP_BRIDGE_POLICY_MAX_BODY_BYTES, MCP_BRIDGE_POLICY_SOURCE, } from "./mcp-bridge"; -import { - applyGeneratedPolicy, - assertGeneratedPolicyExactReadOnly, - assertGeneratedPolicyMutationSafe, - removeGeneratedPolicy, -} from "./mcp-bridge-policy"; +import { applyGeneratedPolicy, assertGeneratedPolicyExactReadOnly } from "./mcp-bridge-policy"; function githubBridgeEntry(overrides: Partial = {}): McpBridgeEntry { return { @@ -61,32 +55,13 @@ describe("MCP OpenShell policy", () => { ).toThrow(/without exact public address pins/); }); - it("inspects an unowned direct-private policy key without targetless rendering (#8267)", () => { - const entry = githubBridgeEntry({ - server: "local", - url: "https://10.20.30.40/mcp", - trustedPrivateHost: "10.20.30.40", - allowedIps: ["10.20.30.40"], - policyName: "mcp-bridge-local", - }); - vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); - const inspectKey = vi.spyOn(policies, "getLiveSandboxPolicyEntryDigest").mockReturnValue(null); - const inspectContent = vi.spyOn(policies, "getPresetContentGatewayState"); - const removePreset = vi.spyOn(policies, "removePreset"); - - expect(() => assertGeneratedPolicyMutationSafe("alpha", entry)).not.toThrow(); - expect(() => removeGeneratedPolicy("alpha", entry)).not.toThrow(); - expect(inspectKey).toHaveBeenCalledWith("alpha", "mcp_bridge_local"); - expect(inspectContent).not.toHaveBeenCalled(); - expect(removePreset).not.toHaveBeenCalled(); - }); - it("pins DNS answers while constraining the generic mcporter Node grant", () => { const policyName = buildMcpBridgePolicyName("GitHub_Server"); const policy = YAML.parse( - buildMcpBridgePolicyYaml("GitHub_Server", "https://api.githubcopilot.com/mcp", "mcporter", { - addresses: ["2606:4700:4700::1111", "8.8.8.8"], - }), + buildMcpBridgePolicyYaml("GitHub_Server", "https://api.githubcopilot.com/mcp", "mcporter", [ + "8.8.8.8", + "2606:4700:4700::1111", + ]), ) as { preset: { name: string }; network_policies: Record< @@ -130,7 +105,7 @@ describe("MCP OpenShell policy", () => { allow: { method }, })), ); - expect(entry.endpoints[0].allowed_ips).toEqual(["2606:4700:4700::1111", "8.8.8.8"]); + expect(entry.endpoints[0].allowed_ips).toEqual(["8.8.8.8", "2606:4700:4700::1111"]); expect(entry.binaries.map((binary) => binary.path)).toEqual([ "/usr/local/bin/mcporter", "/usr/bin/mcporter", @@ -145,71 +120,6 @@ describe("MCP OpenShell policy", () => { }); }); - it.each([ - "mcporter", - "hermes-config", - "deepagents-config", - ] as const)("renders an exactly authorized private IPv4 target for %s (#8267)", (adapter) => { - const replay = replayTrustedPrivateEndpoint("10.20.30.40", ["10.20.30.40"]); - const policy = YAML.parse( - buildMcpBridgePolicyYaml("local", "https://10.20.30.40/mcp", adapter, { - addresses: [...replay.addresses], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }), - ) as { - network_policies: Record< - string, - { endpoints: Array<{ allowed_ips: string[]; host: string }> } - >; - }; - - expect(policy.network_policies.mcp_bridge_local.endpoints[0]).toMatchObject({ - host: "10.20.30.40", - allowed_ips: ["10.20.30.40"], - }); - }); - - it("requires host-bound capability authority for a trusted private DNS policy (#8267)", () => { - const replay = replayTrustedPrivateEndpoint("mcp.corp.internal", ["10.20.30.40"]); - const target = { - addresses: [...replay.addresses], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }; - - expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", target), - ).not.toThrow(); - expect(() => - buildMcpBridgePolicyYaml("local", "https://other.corp.internal/mcp", "mcporter", target), - ).toThrow(/does not match URL host/); - expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { - addresses: ["10.20.30.40"], - trustedPrivateHost: "mcp.corp.internal", - }), - ).toThrow(/no provenance-checked endpoint capability/); - }); - - it("rejects empty and structurally forged render targets (#8267)", () => { - expect(() => - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { - addresses: [], - }), - ).toThrow(/non-empty canonical set/); - expect(() => - buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { - addresses: ["10.20.30.40"], - trustedPrivateHost: "mcp.corp.internal", - trustedPrivateCapability: { - host: "mcp.corp.internal", - addresses: ["10.20.30.40"], - }, - } as never), - ).toThrow(/does not match its host-bound endpoint capability/); - }); - it("applies internally generated DNS pins outside the user-supplied preset path", () => { vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); vi.spyOn(registry, "addCustomPolicy").mockReturnValue(true); @@ -244,10 +154,8 @@ describe("MCP OpenShell policy", () => { it("accepts only the canonical generated policy for the exact bridge and DNS pins", () => { const entry = githubBridgeEntry(); - const pins = ["2606:4700:4700::1111", "8.8.8.8"]; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: pins, - }); + const pins = ["8.8.8.8", "2606:4700:4700::1111"]; + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); const registration = { name: entry.policyName, content, @@ -270,9 +178,7 @@ describe("MCP OpenShell policy", () => { ])("rejects duplicate same-name ownership records regardless of order (%s)", (order) => { const entry = githubBridgeEntry(); const pins = ["8.8.8.8"]; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: pins, - }); + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); const owned = { name: entry.policyName, content, @@ -297,9 +203,7 @@ describe("MCP OpenShell policy", () => { it("rejects individually valid policy records that disagree with their bridge definition", () => { const entry = githubBridgeEntry(); const pins = ["8.8.8.8"]; - const canonical = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: pins, - }); + const canonical = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); const wrongKeyDocument = YAML.parse(canonical) as { network_policies: Record; }; @@ -321,7 +225,7 @@ describe("MCP OpenShell policy", () => { entry.server, "https://mcp.example.test/mcp", "mcporter", - { addresses: pins }, + pins, ), }, { @@ -330,21 +234,17 @@ describe("MCP OpenShell policy", () => { entry.server, "https://api.githubcopilot.com/other", "mcporter", - { addresses: pins }, + pins, ), }, { label: "adapter", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "hermes-config", { - addresses: pins, - }), + content: buildMcpBridgePolicyYaml(entry.server, entry.url, "hermes-config", pins), }, { label: "network policy key", content: YAML.stringify(wrongKeyDocument) }, { label: "resolved address pins", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { - addresses: ["1.1.1.1"], - }), + content: buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", ["1.1.1.1"]), }, { label: "policy name", @@ -426,9 +326,7 @@ describe("MCP OpenShell policy", () => { it("emits only fields supported by OpenShell current main", () => { const policy = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { - addresses: ["8.8.8.8"], - }), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter"), ) as { network_policies: Record> }> }; const endpoint = policy.network_policies.mcp_bridge_srv.endpoints[0]; expect(endpoint).not.toHaveProperty("credential_keys"); @@ -443,25 +341,19 @@ describe("MCP OpenShell policy", () => { "host.containers.internal", ]) { expect(() => - buildMcpBridgePolicyYaml("local", `https://${host}:31337/mcp`, "mcporter", { - addresses: ["8.8.8.8"], - }), + buildMcpBridgePolicyYaml("local", `https://${host}:31337/mcp`, "mcporter"), ).toThrow(/does not expose an attested driver gateway address/); } }); it("scopes binaries to the selected agent adapter", () => { const hermes = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "hermes-config", { - addresses: ["8.8.8.8"], - }), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "hermes-config"), ) as { network_policies: Record }>; }; const deepAgents = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "deepagents-config", { - addresses: ["8.8.8.8"], - }), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "deepagents-config"), ) as { network_policies: Record }>; }; @@ -485,11 +377,11 @@ describe("MCP OpenShell policy", () => { expect(underscoreNormalized).toMatch(/^alpha-mcp-github-server-[a-f0-9]{16}$/); expect(new Set([caseNormalized, underscoreNormalized, "alpha-mcp-github-server"]).size).toBe(3); const long = buildMcpBridgeProviderName( - "sandbox-name-prefix", + "sandbox-name-with-a-long-prefix", "ServerNameThatWouldOtherwiseExceedTheProviderNameLimit", ); expect(long.length).toBeLessThanOrEqual(63); - expect(long).toMatch(/^sandbox-name-prefix-mcp-servernamethatwouldoth-[a-f0-9]{16}$/); + expect(long).toMatch(/^sandbox-name-with-a-long-prefix-mcp-servername-[a-f0-9]{16}$/); expect(buildMcpBridgeProviderName("alpha", "github-server", "0123456789abcdef")).toBe( "alpha-mcp-github-server-0123456789abcdef", ); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 1762d6058c6..a17aaab5844 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -10,7 +10,7 @@ import { diagnosticPreview } from "../../name-validation"; import * as policies from "../../policy"; import { isBlockedMcpUrlTargetHost } from "../../security/mcp-url-target"; import { - assertTrustedPrivateEndpointCapability, + isTrustedPrivateEndpointCapability, replayTrustedPrivateEndpoint, } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; @@ -92,7 +92,7 @@ function requireCanonicalAllowedIps( networkPolicy: unknown, policyName: string, bridge: McpBridgeEntry, -): McpBridgeTargetValidation { +): readonly string[] { const addressKind = bridge.trustedPrivateHost ? "trusted-private" : "public"; if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); @@ -129,9 +129,7 @@ function requireCanonicalAllowedIps( if (bridge.trustedPrivateHost) { let replay; try { - replay = replayTrustedPrivateEndpoint(bridge.trustedPrivateHost, bridge.allowedIps ?? [], { - requireAllPrivate: true, - }); + replay = replayTrustedPrivateEndpoint(bridge.trustedPrivateHost, bridge.allowedIps ?? []); } catch { throw new Error( `Managed MCP policy '${policyName}' has invalid trusted-private address pins`, @@ -146,15 +144,10 @@ function requireCanonicalAllowedIps( `Managed MCP policy '${policyName}' does not match its recorded trusted-private address pins`, ); } - return { - addresses: [...pins], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }; } else if (pins.some((address) => isBlockedMcpUrlTargetHost(address))) { throw new Error(`Managed MCP policy '${policyName}' has invalid public address pins`); } - return { addresses: [...pins] }; + return pins; } function resolveCanonicalManagedMcpAdapter( @@ -231,7 +224,7 @@ function requireCanonicalManagedPolicy( } const registeredNetworkPolicy = registeredPolicies[policyKey]; - const target = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName, bridge); + const allowedIps = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName, bridge); let expectedDocument: Record; try { expectedDocument = parseManagedPolicyDocument( @@ -239,7 +232,7 @@ function requireCanonicalManagedPolicy( bridge.server, bridge.url, resolveCanonicalManagedMcpAdapter(sandbox, bridge), - target, + allowedIps, ), `Canonical managed MCP policy '${policyName}'`, ); @@ -610,7 +603,7 @@ export function applyGeneratedPolicy( ); } const adapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter"; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, resolvedAddresses); const policyKey = buildMcpBridgePolicyKey(entry.server); const sameNamePolicy = registry .getCustomPolicies(sandboxName) @@ -722,23 +715,22 @@ export function assertMcpBridgePolicyTarget( } return target.addresses; } - let authority; - try { - authority = assertTrustedPrivateEndpointCapability( - entry.trustedPrivateHost, - target.addresses, - target.trustedPrivateCapability, - { requireAllPrivate: true }, - ); - } catch { + if ( + target.trustedPrivateHost !== entry.trustedPrivateHost || + !isTrustedPrivateEndpointCapability(target.trustedPrivateCapability) + ) { throw new McpBridgeError( `MCP server '${entry.server}' has no provenance-checked capability for trusted private host '${entry.trustedPrivateHost}'.`, ); } const recordedPins = entry.allowedIps ?? []; + const capabilityPins = [...target.trustedPrivateCapability.addresses].sort(); if ( - target.trustedPrivateHost !== authority.host || - !isDeepStrictEqual(authority.addresses, recordedPins) + recordedPins.length === 0 || + target.addresses.length !== recordedPins.length || + target.addresses.some((address, index) => address !== recordedPins[index]) || + capabilityPins.length !== recordedPins.length || + capabilityPins.some((address, index) => address !== recordedPins[index]) ) { throw new McpBridgeError( `MCP server '${entry.server}' no longer resolves to its recorded trusted-private address pins. Remove and re-add the server to approve changed pins.`, @@ -748,20 +740,9 @@ export function assertMcpBridgePolicyTarget( return recordedPins; } -function getUnownedGeneratedPolicyState( - sandboxName: string, - entry: McpBridgeEntry, -): "absent" | "present" | null { - try { - return policies.getLiveSandboxPolicyEntryDigest( - sandboxName, - buildMcpBridgePolicyKey(entry.server), - ) === null - ? "absent" - : "present"; - } catch { - return null; - } +function generatedPolicyContent(entry: McpBridgeEntry): string { + const adapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter"; + return buildMcpBridgePolicyYaml(entry.server, entry.url, adapter); } export function assertGeneratedPolicyMutationSafe( @@ -773,11 +754,12 @@ export function assertGeneratedPolicyMutationSafe( const reconciled = registeredPolicy ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) : undefined; - const state = reconciled?.state ?? getUnownedGeneratedPolicyState(sandboxName, entry); + const content = reconciled?.policy.content ?? generatedPolicyContent(entry); + const state = reconciled?.state ?? policies.getPresetContentGatewayState(sandboxName, content); if (state === "absent") return; if (!owned || state !== "match") { throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' is unowned, unreachable, or drifted. Refusing to mutate the adapter, provider, or same-key live policy until ownership is resolved. The registry entry was preserved so cleanup can be retried.`, + `Generated MCP policy '${entry.policyName}' is unowned, unreachable, or drifted. Refusing to mutate the adapter, provider, or same-key live policy until ownership is resolved.`, ); } } @@ -833,7 +815,7 @@ export function assertGeneratedPolicyExactReadOnly( } let expectedContent: string; try { - expectedContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); + expectedContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, resolvedAddresses); } catch { // Registry entries are untrusted local state. Keep malformed URLs and any // credential-shaped material out of the recovery diagnostic. @@ -885,12 +867,9 @@ export function removeGeneratedPolicy( ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) : undefined; const effectiveRegistration = reconciled?.policy ?? registeredPolicy; - const content = effectiveRegistration?.content; + const content = effectiveRegistration?.content ?? generatedPolicyContent(entry); const gatewayState = - reconciled?.state ?? - (content - ? policies.getPresetContentGatewayState(sandboxName, content) - : getUnownedGeneratedPolicyState(sandboxName, entry)); + reconciled?.state ?? policies.getPresetContentGatewayState(sandboxName, content); if (gatewayState === "absent") { if (ownsRegistration) { registry.removeCustomPolicyByName(sandboxName, policyName); @@ -911,12 +890,6 @@ export function removeGeneratedPolicy( }); // OpenShell can acknowledge a superseded policy revision as success. Confirm // the exact generated key is absent before discarding its ownership record. - if (!content) { - if (options.bestEffort) return; - throw new McpBridgeError( - `Generated MCP policy '${policyName}' has no exact ownership content. Refusing to delete same-key policy state.`, - ); - } const activeState = policies.getPresetContentGatewayState(sandboxName, content); if (activeState === "absent") { registry.removeCustomPolicyByName(sandboxName, policyName); diff --git a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts index 5feea6740e6..f977f206e15 100644 --- a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts @@ -26,9 +26,9 @@ function privateEntry(adapter: AgentMcpAdapter, agent: string): McpBridgeEntry { server: "local", agent, adapter, - url: "https://mcp.corp.internal/mcp", + url: "https://mcp.corp.example/mcp", env: ["LOCAL_MCP_TOKEN"], - trustedPrivateHost: "mcp.corp.internal", + trustedPrivateHost: "mcp.corp.example", allowedIps: ["10.20.30.40", "fd00::40"], providerName: "alpha-mcp-local", providerId: "11111111-2222-4333-8444-555555555555", @@ -55,26 +55,6 @@ describe("trusted-private MCP lifecycle replay", () => { expect(target && assertMcpBridgePolicyTarget(entry, target)).toEqual(entry.allowedIps); }); - it.each(adapters)("replays a direct private IPv4 target for $agent (#8267)", async ({ - adapter, - agent, - }) => { - const lookup = vi.spyOn(dns, "lookup").mockRejectedValue(new Error("ambient DNS used")); - const entry = privateEntry(adapter, agent); - entry.url = "https://10.20.30.40/mcp"; - entry.trustedPrivateHost = "10.20.30.40"; - entry.allowedIps = ["10.20.30.40"]; - - const target = (await preflightMcpEntryTargets([entry])).get(entry.server); - - expect(lookup).not.toHaveBeenCalled(); - expect(target).toMatchObject({ - addresses: ["10.20.30.40"], - trustedPrivateHost: "10.20.30.40", - }); - expect(target && assertMcpBridgePolicyTarget(entry, target)).toEqual(["10.20.30.40"]); - }); - it("rejects invalid durable private pins without consulting DNS (#8267)", async () => { const lookup = vi.spyOn(dns, "lookup").mockRejectedValue(new Error("ambient DNS used")); const entry = privateEntry("mcporter", "openclaw"); @@ -97,9 +77,7 @@ describe("trusted-private MCP lifecycle replay", () => { expect(lookup).not.toHaveBeenCalled(); }); - it("resumes an incomplete private add from recorded pins without ambient DNS (#8267)", { - timeout: 40_000, - }, () => { + it("resumes an incomplete private add from recorded pins without ambient DNS (#8267)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-private-mcp-add-replay-")); const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const script = ` @@ -134,10 +112,7 @@ bridge.addMcpBridge("alpha", { trustedPrivateHosts: ["mcp.corp.example"], }).then( () => process.exit(9), - (error) => process.stdout.write( - JSON.stringify({ message: error.message, dnsCalls }), - () => process.exit(0), - ), + (error) => process.stdout.write(JSON.stringify({ message: error.message, dnsCalls })), ); `; const result = spawnSync(process.execPath, ["-e", script], { @@ -150,7 +125,7 @@ bridge.addMcpBridge("alpha", { .filter(Boolean) .join(" "), }, - timeout: 30_000, + timeout: 15_000, }); fs.rmSync(home, { recursive: true, force: true }); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index a6568d07b8c..b9465602927 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -284,9 +284,7 @@ export async function preflightMcpEntryTargets( const recordedPins = entry.allowedIps ?? []; let replay; try { - replay = replayTrustedPrivateEndpoint(entry.trustedPrivateHost, recordedPins, { - requireAllPrivate: true, - }); + replay = replayTrustedPrivateEndpoint(entry.trustedPrivateHost, recordedPins); } catch (error) { throw new McpBridgeError( `MCP server '${entry.server}' has invalid durable trusted-private intent: ${error instanceof Error ? error.message : String(error)}. Remove it with --force and add it again.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts index e0f71eb8cb1..783c02b9723 100644 --- a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isIP } from "node:net"; - import { resolveHostAddresses } from "../../adapters/dns/resolve"; import { isLoopbackHostname } from "../../private-networks"; import { @@ -13,7 +11,7 @@ import { import { TOKEN_PREFIX_PATTERNS } from "../../security/secret-patterns"; import { assertEndpointResolvesPublic, - assertTrustedPrivateEndpointCapability, + isTrustedPrivateEndpointCapability, normalizeTrustedPrivateHost, type TrustedPrivateEndpointCapability, } from "../../security/trusted-private-endpoint"; @@ -51,12 +49,12 @@ function rejectUnsupportedOpenShellMcpHostAlias(hostname: string): void { // invalidState: a host alias is accepted without an attested gateway address, // forcing broad private-range policy instead of an exact destination pin. // sourceBoundary: the pinned OpenShell release owns gateway-address discovery. - // whyNotSourceFix: v0.0.101 exposes no attested driver gateway address. + // whyNotSourceFix: v0.0.85 exposes no attested driver gateway address. // regressionTest: URL validation and all three live adapters reject aliases. // removalCondition: remove only after a reviewed OpenShell capability exposes // an attested address; a future version number alone is not that capability. throw new McpBridgeError( - `Authenticated MCP OpenShell host alias '${hostname}' is unavailable with OpenShell v0.0.101 because that release does not expose an attested driver gateway address for exact policy pinning. Use a normal HTTPS DNS endpoint with public address records.`, + `Authenticated MCP OpenShell host alias '${hostname}' is unavailable with OpenShell v0.0.85 because that release does not expose an attested driver gateway address for exact policy pinning. Use a normal HTTPS DNS endpoint with public address records.`, 2, ); } @@ -66,7 +64,7 @@ function rejectUnsupportedOpenShellMcpIpv6Literal(hostname: string): void { // invalidState: an IPv6 literal reaches an OpenShell parser that cannot // represent and enforce its exact proxy target safely. // sourceBoundary: the pinned OpenShell proxy parser owns literal support. - // whyNotSourceFix: v0.0.101 does not support this target form. + // whyNotSourceFix: v0.0.85 does not support this target form. // regressionTest: URL normalization and resolved-target preflight both reject // private and public IPv6 literals with this capability-specific result. // removalCondition: remove only with reviewed parser support and parity proof; @@ -223,14 +221,10 @@ export async function preflightMcpServerUrlResolvedTarget( ): Promise { // invalidState: a hostname is public at add time but later rebinds to an // unpinned address. sourceBoundary: NemoClaw pins the add-time public answers; - // With its operator-controlled proxy_connect_by_hostname option disabled, - // OpenShell v0.0.101 resolves, validates every answer against allowed_ips, and - // connects with that same SocketAddr list. This URL validator cannot observe - // gateway configuration; the documented guarantee and residual risk are - // therefore explicitly conditional on that option remaining disabled. - // whyNotSourceFix: duplicating DNS resolution here before each remote - // connection would create a second, non-authoritative TOCTOU boundary outside - // OpenShell's data plane. + // OpenShell v0.0.85 resolves, validates every answer against allowed_ips, and + // connects with that same SocketAddr list. whyNotSourceFix: duplicating DNS + // resolution here before each remote connection would create a second, + // non-authoritative TOCTOU boundary outside OpenShell's data plane. // regressionTest: e2e/support/mcp-bridge-sandbox.test.ts pins the exact // upstream source contract, and live/mcp-bridge.test.ts remaps DNS and proves // a 403 plus zero upstream requests for all three adapters. @@ -283,24 +277,29 @@ export async function preflightMcpServerUrlResolvedTarget( const normalizedHostname = normalizeTrustedPrivateHost(parsed.hostname); const explicitTrust = normalizedTrustedHosts.includes(normalizedHostname); if (result.trustedPrivateEndpoint) { - try { - const authority = assertTrustedPrivateEndpointCapability( - normalizedHostname, - addresses, - result.trustedPrivateCapability, - { requireAllPrivate: true }, + if (!isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)) { + throw new McpBridgeError( + `MCP server URL host '${normalizedHostname}' did not return a provenance-checked trusted-private capability.`, + 2, ); - return { - addresses: [...authority.addresses], - trustedPrivateCapability: authority.trustedPrivateCapability, - trustedPrivateHost: authority.host, - }; - } catch { + } + const capabilityAddresses = [...result.trustedPrivateCapability.addresses] + .map((address) => address.toLowerCase()) + .sort(); + if ( + capabilityAddresses.length !== addresses.length || + capabilityAddresses.some((address, index) => address !== addresses[index]) + ) { throw new McpBridgeError( - `MCP server URL host '${normalizedHostname}' did not return exact routed-private authority. Trusted-private MCP endpoints must resolve only to supported routed private addresses, and every pin must match the host-bound capability.`, + `MCP server URL host '${normalizedHostname}' returned mixed public and private addresses. Trusted-private MCP endpoints must resolve only to supported routed private addresses.`, 2, ); } + return { + addresses, + trustedPrivateCapability: result.trustedPrivateCapability, + trustedPrivateHost: normalizedHostname, + }; } if (explicitTrust && options.requireTrustedPrivateEndpoint) { throw new McpBridgeError( @@ -341,75 +340,3 @@ export async function inspectMcpRecordedTargetPins( export function parseMcpUrl(rawUrl: string): URL { return new URL(normalizeMcpServerUrl(rawUrl)); } - -/** - * Revalidate URL syntax while preserving the exact destination authority - * issued by MCP target preflight or durable trusted-private replay. - */ -export function parseMcpUrlWithValidatedTarget( - rawUrl: string, - target: McpBridgeTargetValidation, -): URL { - const addresses = [...target.addresses]; - const sortedAddresses = [...addresses].sort(); - if ( - addresses.length === 0 || - addresses.some( - (address) => - typeof address !== "string" || - isIP(address) === 0 || - address !== address.toLowerCase() || - address.includes("%"), - ) || - new Set(addresses).size !== addresses.length || - addresses.some((address, index) => address !== sortedAddresses[index]) - ) { - throw new McpBridgeError( - "Validated MCP target must contain a non-empty canonical set of exact address pins.", - 2, - ); - } - - const hasPrivateAuthority = - target.trustedPrivateCapability !== undefined || target.trustedPrivateHost !== undefined; - if (!hasPrivateAuthority) { - if (addresses.some((address) => isBlockedMcpUrlTargetHost(address))) { - throw new McpBridgeError( - "Validated public MCP target contains a private, local, or special-use address pin.", - 2, - ); - } - return new URL(normalizeMcpServerUrl(rawUrl)); - } - - if (!target.trustedPrivateHost || !target.trustedPrivateCapability) { - throw new McpBridgeError( - "Validated private MCP target has no provenance-checked endpoint capability.", - 2, - ); - } - let authority; - try { - authority = assertTrustedPrivateEndpointCapability( - target.trustedPrivateHost, - addresses, - target.trustedPrivateCapability, - { requireAllPrivate: true }, - ); - } catch { - throw new McpBridgeError( - "Validated private MCP target does not match its host-bound endpoint capability.", - 2, - ); - } - const trustedPrivateHost = authority.host; - - const rawParsed = new URL(rawUrl); - if (normalizeTrustedPrivateHost(rawParsed.hostname) !== trustedPrivateHost) { - throw new McpBridgeError( - `Validated private MCP target host '${trustedPrivateHost}' does not match URL host '${rawParsed.hostname}'.`, - 2, - ); - } - return new URL(normalizeMcpServerUrl(rawUrl, { trustedPrivateHosts: [trustedPrivateHost] })); -} diff --git a/src/lib/actions/sandbox/mcp-bridge-validation.ts b/src/lib/actions/sandbox/mcp-bridge-validation.ts index b5d8cac9d3c..5155ea52ee3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-validation.ts @@ -5,7 +5,7 @@ import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import crypto from "node:crypto"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; -import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../../sandbox-name-contract"; +import { diagnosticPreview } from "../../name-validation"; import { normalizeTrustedPrivateHost, parseTrustedPrivateHosts, @@ -34,6 +34,7 @@ export { const VALID_SERVER_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const VALID_ENV_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; const OPENSHELL_REVISIONED_CREDENTIAL_NAME_RE = /^v[0-9]+_[A-Za-z0-9_]+$/; +const VALID_SANDBOX_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const OPENSHELL_VERSION_OUTPUT_RE = /^openshell\s+([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/; const OPENSHELL_VERSION_PROBE_TIMEOUT_MS = 5_000; @@ -164,9 +165,9 @@ const SANDBOX_RUNTIME_CONTROL_ENV_KEYS = new Set(childVisibleCredentialManifest. const SANDBOX_RUNTIME_CONTROL_ENV_PREFIXES = childVisibleCredentialManifest.runtimeControlPrefixes; const MCP_PROVIDER_HASH_BYTES = 8; export function validateSandboxName(name: string): void { - if (!isValidName(name)) { + if (!name || name.length > 63 || !VALID_SANDBOX_RE.test(name)) { throw new McpBridgeError( - `Invalid sandbox name ${diagnosticPreview(name)}. Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Invalid sandbox name ${diagnosticPreview(name)}. Names must be 1-63 lowercase alphanumeric characters with optional internal hyphens.`, 2, ); } diff --git a/src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json b/src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json deleted file mode 100644 index 86a9bfaffa3..00000000000 --- a/src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "openshellVersion": "0.0.101", - "openshellCommit": "8ddd98c3dff62619a3963f99ba1e055b67650e72", - "sources": [ - "crates/openshell-core/src/google_cloud.rs", - "crates/openshell-core/src/provider_credentials.rs", - "crates/openshell-core/src/secrets.rs" - ], - "nemoclawSources": [ - "src/lib/subprocess-env.ts", - "src/lib/actions/sandbox/mcp-bridge-validation.ts", - "agents/hermes/mcp-config-transaction.py" - ], - "rawChildValueKeys": [ - "GCP_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "CLOUD_ML_REGION", - "GCP_LOCATION", - "GCP_SERVICE_ACCOUNT_EMAIL", - "GOOSE_PROVIDER", - "ANTHROPIC_VERTEX_PROJECT_ID", - "VERTEX_LOCATION" - ], - "rewrittenChildValueKeys": [ - "GCE_METADATA_HOST", - "GCE_METADATA_IP", - "METADATA_SERVER_DETECTION" - ], - "runtimeControlKeys": [ - "_JAVA_OPTIONS", - "ALL_PROXY", - "all_proxy", - "API_SERVER_KEY", - "BASH_ENV", - "BASHOPTS", - "CDPATH", - "CLASSPATH", - "CONDA_PREFIX", - "CURL_CA_BUNDLE", - "DENO_CERT", - "DOCKER_HOST", - "ENV", - "GCONV_PATH", - "GIT_SSL_CAINFO", - "GIT_SSL_CAPATH", - "GLOBIGNORE", - "grpc_proxy", - "HOME", - "HOSTNAME", - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "IFS", - "KUBECONFIG", - "LANG", - "LOCPATH", - "LOGNAME", - "NLSPATH", - "NODE_ENV", - "NODE_EXTRA_CA_CERTS", - "NO_PROXY", - "no_proxy", - "PATH", - "PROMPT_COMMAND", - "PS4", - "REQUESTS_CA_BUNDLE", - "RUST_BACKTRACE", - "RUST_LOG", - "SHELL", - "SHELLOPTS", - "SSH_AUTH_SOCK", - "SSL_CERT_DIR", - "SSL_CERT_FILE", - "TEMP", - "TERM", - "TMP", - "TMPDIR", - "USER", - "VIRTUAL_ENV", - "ZDOTDIR" - ], - "runtimeControlPrefixes": [ - "DEEPAGENTS_", - "DYLD_", - "GATEWAY_", - "GLIBC_", - "GRPC_", - "HERMES_", - "JAVA_", - "JDK_", - "LANGCHAIN_", - "LANGGRAPH_", - "LANGSMITH_", - "LC_", - "LD_", - "MALLOC_", - "NEMOCLAW_", - "NODE_", - "OPENAI_", - "OPENCLAW_", - "OPENSHELL_", - "PERL", - "PYTHON", - "RUBY", - "UV_", - "XDG_" - ] -} diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts index 0cff319aa4b..8d386643c87 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -47,7 +47,6 @@ let errSpy: MockInstance; let promptSpy: MockInstance; let applyPresetMock: MockInstance; let gatewayStateMock: MockInstance; -let npmCompatibilityStateMock: MockInstance; let refreshSpy: MockInstance; async function captureExit(action: () => Promise): Promise { @@ -90,9 +89,6 @@ beforeEach(() => { ); applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); gatewayStateMock = vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift"); - npmCompatibilityStateMock = vi - .spyOn(policies, "getOpenClawNpmCompatibilityState") - .mockReturnValue("match"); vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["pypi.example.com"]); vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); @@ -139,43 +135,6 @@ describe("addSandboxPolicy drift-aware named re-add", () => { expect(refreshSpy).not.toHaveBeenCalled(); }); - it("repairs a matching npm preset whose OpenClaw compatibility overlay is absent (#8497)", async () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "alpha", - agent: "openclaw", - policies: ["npm"], - }); - vi.spyOn(policies, "listPresets").mockReturnValue([ - { file: "npm.yaml", name: "npm", description: "npm registry access" }, - ]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["npm"]); - vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( - "network_policies:\n npm_yarn:\n name: npm_yarn\n", - ); - vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([]); - const disclosureSpy = vi - .spyOn(policies, "logOpenClawNpmCompatibilityDisclosure") - .mockImplementation(() => undefined); - gatewayStateMock.mockReturnValue("match"); - npmCompatibilityStateMock.mockReturnValue("repair"); - - await addSandboxPolicy("alpha", { preset: "npm", yes: true }); - - expect(logSpy).toHaveBeenCalledWith( - " Preset 'npm' matches the live policy, but its OpenClaw compatibility overlay requires repair.", - ); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Effective egress scope that would replace the current preset policy", - ), - ); - expect(disclosureSpy).toHaveBeenCalledTimes(1); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "npm", { - suppressDisclosure: true, - }); - expect(refreshSpy).toHaveBeenCalledTimes(1); - }); - it("re-applies when the preset is recorded but its entries are absent from the live policy", async () => { gatewayStateMock.mockReturnValue("absent"); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 85fd5a70b91..f37f49d5030 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -278,7 +278,7 @@ async function addSandboxPolicyUnlocked( const applied = policies.getAppliedPresets(sandboxName); let answer = null; - let reapplyState: policies.PresetPolicyState | null = null; + let reapplyState: "drift" | "absent" | null = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); const preset = allPresets.find((item: { name: string }) => item.name === normalized); @@ -314,35 +314,13 @@ async function addSandboxPolicyUnlocked( } const appliedState = policies.getPresetContentGatewayState(sandboxName, appliedContent); if (appliedState === "match") { - const needsOpenClawNpmCheck = - preset.name === "npm" && (sandboxAgent === null || sandboxAgent === "openclaw"); - const npmCompatibilityState = needsOpenClawNpmCheck - ? policies.getOpenClawNpmCompatibilityState(sandboxName) - : "match"; - if (npmCompatibilityState === "repair") { - reapplyState = "drift"; - console.log( - " Preset 'npm' matches the live policy, but its OpenClaw compatibility overlay requires repair.", - ); - } else if (npmCompatibilityState === "drift" || npmCompatibilityState === null) { - console.error( - npmCompatibilityState === null - ? " Could not verify the live OpenClaw npm compatibility overlay." - : " The live OpenClaw npm compatibility overlay has drifted from its reviewed state.", - ); - console.error( - " No policy changes were made; inspect the live npm policy before retrying.", - ); - process.exit(1); - } else { - // The desired state already holds: exit 0 so converging scripts can - // call `policy add` idempotently, mirroring how applyPreset treats a - // byte-identical re-application as a successful no-op. - console.log( - ` Preset '${preset.name}' is already applied and matches the live policy; nothing to do.`, - ); - return; - } + // The desired state already holds: exit 0 so converging scripts can + // call `policy add` idempotently, mirroring how applyPreset treats a + // byte-identical re-application as a successful no-op. + console.log( + ` Preset '${preset.name}' is already applied and matches the live policy; nothing to do.`, + ); + return; } if (appliedState === null) { // Live policy unreadable: drift is unverifiable, so refuse rather @@ -353,16 +331,14 @@ async function addSandboxPolicyUnlocked( ); process.exit(1); } - if (appliedState !== "match") { - // State-only notice: the downstream flow reports the dry-run, - // confirmation, and apply outcomes. - reapplyState = appliedState; - console.log( - appliedState === "drift" - ? ` Preset '${preset.name}' no longer matches the live policy.` - : ` Preset '${preset.name}' is recorded as applied but missing from the live policy.`, - ); - } + // State-only notice: the downstream flow reports the dry-run, + // confirmation, and apply outcomes. + reapplyState = appliedState; + console.log( + appliedState === "drift" + ? ` Preset '${preset.name}' no longer matches the live policy.` + : ` Preset '${preset.name}' is recorded as applied but missing from the live policy.`, + ); } answer = preset.name; } else { @@ -384,14 +360,6 @@ async function addSandboxPolicyUnlocked( } else { policies.logPresetScope(presetContent); } - const needsOpenClawNpmDisclosure = - answer === "npm" && (sandboxAgent === null || sandboxAgent === "openclaw"); - const npmBaselineExcluded = - needsOpenClawNpmDisclosure && - registry.getBaselineExclusions(sandboxName).some((entry) => entry.key === "npm_registry"); - if (needsOpenClawNpmDisclosure && !npmBaselineExcluded) { - policies.logOpenClawNpmCompatibilityDisclosure(); - } const presetWarning = policies.getPresetValidationWarning(answer); if (presetWarning) { diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts index 4146b7ef754..b5e07858ae8 100644 --- a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts @@ -5,8 +5,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { BASE_GATEWAY_STATE_DIR_NAME } from "../../onboard/gateway-binding"; - import { type CaptureOpenshellResult, stripAnsi } from "../../adapters/openshell/client"; import { captureOpenshell } from "../../adapters/openshell/runtime"; import type { SandboxEntry } from "../../state/registry"; @@ -50,7 +48,7 @@ export function shouldApplyVmDnsMonkeypatch( function dockerDriverGatewayStateDir(env: NodeJS.ProcessEnv, homeDir: string): string { const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; if (configured && configured.trim()) return path.resolve(configured.trim()); - return path.join(homeDir, ".local", "state", "nemoclaw", BASE_GATEWAY_STATE_DIR_NAME); + return path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway"); } export function parseSandboxIdFromGetOutput(output: string): string | null { diff --git a/src/lib/actions/upgrade-sandboxes-preflight.test.ts b/src/lib/actions/upgrade-sandboxes-preflight.test.ts index c21856160a9..416c728bc47 100644 --- a/src/lib/actions/upgrade-sandboxes-preflight.test.ts +++ b/src/lib/actions/upgrade-sandboxes-preflight.test.ts @@ -3,8 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { diagnosticPreview, NAME_MAX_LENGTH } from "../name-validation"; - const mocks = vi.hoisted(() => ({ captureNamedGatewaySandboxListReadOnly: vi.fn(), captureSandboxListWithGatewayPreflightOrExit: vi.fn(), @@ -92,73 +90,6 @@ describe("upgrade-sandboxes gateway preflight adapter (#6237)", () => { expect(logSpy.mock.calls.flat().join("\n")).toContain("No sandboxes found"); }); - it("reports every incompatible registered name without querying a gateway in check mode (#8497)", async () => { - const overlengthName = `a${"b".repeat(NAME_MAX_LENGTH)}`; - const invalidFormatName = "Legacy_Name"; - const unsafeDiagnosticName = "bad\u202e::error::forged"; - const routeOnlyInvalidName = "Route_Only_Invalid"; - mocks.listSandboxes.mockReturnValue({ - sandboxes: [ - { name: "alpha", provider: "nvidia-prod", model: "nemotron" }, - { name: overlengthName, provider: "nvidia-prod", model: "nemotron" }, - { name: invalidFormatName, provider: "nvidia-prod", model: "nemotron" }, - { name: unsafeDiagnosticName, provider: "nvidia-prod", model: "nemotron" }, - { - name: routeOnlyInvalidName, - provider: "nvidia-prod", - model: "nemotron", - pendingRouteReservation: true, - }, - ], - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - - await expect(upgradeSandboxes({ check: true })).resolves.toBeUndefined(); - - const output = errorSpy.mock.calls.flat().join("\n"); - expect(output).toContain(JSON.stringify(overlengthName)); - expect(output).toContain(JSON.stringify(invalidFormatName)); - expect(output).toContain(diagnosticPreview(unsafeDiagnosticName)); - expect(output).not.toContain(unsafeDiagnosticName); - expect(output).not.toContain(JSON.stringify(routeOnlyInvalidName)); - expect(output).toContain(`1-${NAME_MAX_LENGTH} characters`); - expect(output).toContain("create a replacement with a valid name and transfer its state"); - expect(exitSpy).not.toHaveBeenCalled(); - expect(mocks.captureNamedGatewaySandboxListReadOnly).not.toHaveBeenCalled(); - expect(mocks.captureSandboxListWithGatewayPreflightOrExit).not.toHaveBeenCalled(); - expect(mocks.classifyUpgradeableSandboxes).not.toHaveBeenCalled(); - expect(upgradeSandboxesDependencies.rebuildSandbox).not.toHaveBeenCalled(); - }); - - it("exits before gateway preflight or rebuild when automatic mode finds an incompatible name (#8497)", async () => { - const incompatibleNames = [`a${"b".repeat(NAME_MAX_LENGTH)}`, "Legacy_Name", "legacy--box"]; - mocks.listSandboxes.mockReturnValue({ - sandboxes: incompatibleNames.map((name) => ({ - name, - provider: "nvidia-prod", - model: "nemotron", - })), - }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${String(code)})`); - }) as never); - - await expect(upgradeSandboxes({ auto: true })).rejects.toThrow("process.exit(1)"); - - const output = errorSpy.mock.calls.flat().join("\n"); - for (const name of incompatibleNames) { - expect(output).toContain(JSON.stringify(name)); - } - expect(exitSpy).toHaveBeenCalledWith(1); - expect(mocks.captureNamedGatewaySandboxListReadOnly).not.toHaveBeenCalled(); - expect(mocks.captureSandboxListWithGatewayPreflightOrExit).not.toHaveBeenCalled(); - expect(mocks.classifyUpgradeableSandboxes).not.toHaveBeenCalled(); - expect(mocks.getLatestBackup).not.toHaveBeenCalled(); - expect(upgradeSandboxesDependencies.rebuildSandbox).not.toHaveBeenCalled(); - }); - it("queries the sandbox's recorded gateway read-only, never the recovering preflight (#7279)", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/src/lib/actions/upgrade-sandboxes-recovery.test.ts b/src/lib/actions/upgrade-sandboxes-recovery.test.ts index 92ebd4e7ba6..715538f53f9 100644 --- a/src/lib/actions/upgrade-sandboxes-recovery.test.ts +++ b/src/lib/actions/upgrade-sandboxes-recovery.test.ts @@ -482,11 +482,11 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { }); it("warns and does not recover a stale registered sandbox absent from the selected gateway", async () => { - const harness = createRecoveryHarness(["registered-away"], { - gatewayNames: { "registered-away": "gateway-b" }, + const harness = createRecoveryHarness(["registered-elsewhere"], { + gatewayNames: { "registered-elsewhere": "gateway-b" }, liveOutput: "selected-gateway-box Ready", latestBackup: null, - staleNames: ["registered-away"], + staleNames: ["registered-elsewhere"], }); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); @@ -621,8 +621,8 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { }); it("does not flag a sandbox bound to another live gateway as orphaned (#6520)", async () => { - const harness = createRecoveryHarness(["registered-away"], { - gatewayNames: { "registered-away": "gateway-b" }, + const harness = createRecoveryHarness(["registered-elsewhere"], { + gatewayNames: { "registered-elsewhere": "gateway-b" }, liveOutput: "selected-box Ready", }); vi.stubEnv("NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE", "0"); @@ -678,10 +678,10 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { }); it("does not recover an absent sandbox bound to a different gateway even when a validated backup exists", async () => { - const harness = createRecoveryHarness(["registered-away"], { - gatewayNames: { "registered-away": "gateway-b" }, + const harness = createRecoveryHarness(["registered-elsewhere"], { + gatewayNames: { "registered-elsewhere": "gateway-b" }, liveOutput: "selected-box Ready", - staleNames: ["registered-away"], + staleNames: ["registered-elsewhere"], }); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit(${code})`); @@ -776,9 +776,9 @@ describe("upgrade-sandboxes prepared backup recovery (#6114)", () => { }); it("does not recover a non-Ready sandbox bound to another valid gateway (#6114)", async () => { - const harness = createRecoveryHarness(["registered-away"], { - gatewayNames: { "registered-away": "nemoclaw-12345" }, - liveOutput: "registered-away Provisioning", + const harness = createRecoveryHarness(["registered-elsewhere"], { + gatewayNames: { "registered-elsewhere": "nemoclaw-12345" }, + liveOutput: "registered-elsewhere Provisioning", }); await expect(harness.upgradeSandboxes({ auto: true })).resolves.toBeUndefined(); diff --git a/src/lib/actions/upgrade-sandboxes.ts b/src/lib/actions/upgrade-sandboxes.ts index acffb7b641a..10348f282da 100644 --- a/src/lib/actions/upgrade-sandboxes.ts +++ b/src/lib/actions/upgrade-sandboxes.ts @@ -28,7 +28,6 @@ import { } from "../openshell-sandbox-list"; import { parseLiveSandboxEntries, parseReadySandboxNames } from "../runtime-recovery"; import * as sandboxVersion from "../sandbox/version"; -import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; @@ -238,19 +237,6 @@ function resolveCheckGatewayName( return recorded.size === 1 ? [...recorded][0] : fallbackGatewayName; } -function printIncompatibleRegisteredSandboxNames(names: readonly string[]): void { - console.error( - `\n ${YW}Registered sandbox names cannot be recreated by this NemoClaw version:${R}`, - ); - for (const name of names) { - console.error(` ${diagnosticPreview(name)}`); - } - console.error(`\n Registered sandbox names must use: ${NAME_ALLOWED_FORMAT}.`); - console.error( - ` For each listed sandbox, create a replacement with a valid name and transfer its state before rerunning \`${CLI_NAME} upgrade-sandboxes\`.`, - ); -} - export async function upgradeSandboxes( options: string[] | UpgradeSandboxesOptions = {}, ): Promise { @@ -266,20 +252,6 @@ export async function upgradeSandboxes( return; } - // OpenShell can no longer recreate legacy registry identities that fall - // outside the canonical sandbox-name contract. Detect every such identity - // before resolving or querying a gateway so check mode remains read-only and - // the mutating path cannot cross the rebuild boundary with an invalid name. - const incompatibleSandboxNames = sandboxes - .map((sandbox) => sandbox.name) - .filter((name) => !isValidName(name)) - .sort(); - if (incompatibleSandboxNames.length > 0) { - printIncompatibleRegisteredSandboxNames(incompatibleSandboxNames); - if (checkOnly) return; - process.exit(1); - } - // Resolve the configured gateway once and pin every observation to it. The // initial list, the confirmation list, and persisted-binding eligibility must // share this source; OpenShell's mutable current selection may be a sibling diff --git a/src/lib/adapters/http/curl-args.test.ts b/src/lib/adapters/http/curl-args.test.ts index f5c864b606c..8a0ece5ed1a 100644 --- a/src/lib/adapters/http/curl-args.test.ts +++ b/src/lib/adapters/http/curl-args.test.ts @@ -179,81 +179,6 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { ).not.toThrow(); }); - it("rejects capability reuse for a different host on the same private address (#8176)", async () => { - const preflight = await assertEndpointResolvesPublic( - "https://a.corp.example/v1/models", - async () => [{ address: "10.0.0.8", family: 4 }], - { trustedPrivateHosts: ["a.corp.example"] }, - ); - - expect(() => - validateCurlProbeArgs( - ["-sS", "--resolve", "b.corp.example:443:10.0.0.8", "https://b.corp.example/v1/models"], - { - pinnedAddresses: ["10.0.0.8"], - trustedPrivateCapability: preflight.trustedPrivateCapability, - }, - ), - ).toThrow(/capability host 'a\.corp\.example' does not match 'b\.corp\.example'/); - }); - - it("canonicalizes an expanded ULA answer before curl pin validation (#8176)", async () => { - const endpointUrl = "https://llm.corp.example/v1/models"; - const preflight = await assertEndpointResolvesPublic( - endpointUrl, - async () => [{ address: "fd00:0:0:0:0:0:0:10", family: 6 }], - { trustedPrivateHosts: ["llm.corp.example"] }, - ); - - expect(preflight.addresses).toEqual(["fd00::10"]); - expect(() => - validateCurlProbeArgs(["-sS", "--resolve", "llm.corp.example:443:[fd00::10]", endpointUrl], { - pinnedAddresses: preflight.addresses, - trustedPrivateCapability: preflight.trustedPrivateCapability, - }), - ).not.toThrow(); - }); - - it("requires the exact mixed public and private pin set at the curl boundary (#8176)", async () => { - const endpointUrl = "https://llm.corp.example/v1/models"; - const preflight = await assertEndpointResolvesPublic( - endpointUrl, - async () => [ - { address: "93.184.216.34", family: 4 }, - { address: "10.0.0.8", family: 4 }, - ], - { trustedPrivateHosts: ["llm.corp.example"] }, - ); - const options = { - pinnedAddresses: preflight.addresses, - trustedPrivateCapability: preflight.trustedPrivateCapability, - }; - - expect(() => - validateCurlProbeArgs( - ["-sS", "--resolve", "llm.corp.example:443:10.0.0.8,93.184.216.34", endpointUrl], - options, - ), - ).not.toThrow(); - - for (const mapping of [ - "llm.corp.example:443:10.0.0.8", - "llm.corp.example:443:93.184.216.34", - "llm.corp.example:443:10.0.0.8,93.184.216.34,8.8.8.8", - ]) { - expect(() => - validateCurlProbeArgs(["-sS", "--resolve", mapping, endpointUrl], options), - ).toThrow(/exactly match pinnedAddresses/); - } - - expect(() => - validateCurlProbeArgs( - ["-sS", "--resolve", "llm.corp.example:443:10.0.0.8,93.184.216.34", endpointUrl], - { pinnedAddresses: preflight.addresses }, - ), - ).toThrow(/unauthorized private address/); - }); - it("rejects a forged private authorization even when the address is otherwise trustable (#6861)", () => { expect(() => validateCurlProbeArgs( @@ -307,7 +232,7 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { validateCurlProbeArgs(["-sS", "http://10.0.0.8/v1/models"], options), ).not.toThrow(); expect(() => validateCurlProbeArgs(["-sS", "http://10.0.0.9/v1/models"], options)).toThrow( - /capability host '10\.0\.0\.8' does not match '10\.0\.0\.9'/, + /match the exact private IP URL/, ); }); diff --git a/src/lib/adapters/http/curl-args.ts b/src/lib/adapters/http/curl-args.ts index dd9318078d6..1afd23f1a18 100644 --- a/src/lib/adapters/http/curl-args.ts +++ b/src/lib/adapters/http/curl-args.ts @@ -4,9 +4,10 @@ import { isIP } from "node:net"; import path from "node:path"; import { - assertTrustedPrivateEndpointCapability, + isOperatorTrustablePrivateIp, + isTrustedPrivateEndpointCapability, type TrustedPrivateEndpointCapability, -} from "../../security/trusted-private-endpoint"; +} from "../../inference/endpoint-ssrf-preflight"; import { isCredentialShapedName } from "../../security/credential-env"; import { ROOT } from "../../state/paths"; @@ -22,7 +23,7 @@ export interface CurlProbeArgOptions { allowRedirects?: boolean; /** Addresses approved by the endpoint SSRF preflight. */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact pins admitted for a trusted private host. */ + /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } @@ -201,26 +202,18 @@ function isPrivateResolveAddress(address: string): boolean { return isPrivateIp(address); } +function isOperatorTrustablePrivateResolveAddress(address: string): boolean { + return isOperatorTrustablePrivateIp(address); +} + function getTrustedPrivateResolveAddresses( - target: URL, - opts: CurlProbeArgOptions, + capability: TrustedPrivateEndpointCapability | undefined, ): readonly string[] { - if (!opts.trustedPrivateCapability) return []; - if (opts.pinnedAddresses === undefined) { - throw new Error("curl probe trusted private capability requires pinnedAddresses"); + if (!capability) return []; + if (!isTrustedPrivateEndpointCapability(capability)) { + throw new Error("curl probe trusted private capability was not issued by the SSRF preflight"); } - const targetHost = normalizeHostname(target.hostname); - const authorityAddresses = - opts.pinnedAddresses.length > 0 - ? opts.pinnedAddresses - : isIP(targetHost) !== 0 - ? [targetHost] - : opts.pinnedAddresses; - return assertTrustedPrivateEndpointCapability( - target.hostname, - authorityAddresses, - opts.trustedPrivateCapability, - ).addresses; + return capability.addresses; } function assertResolveMatchesApprovedEndpoint( @@ -237,7 +230,9 @@ function assertResolveMatchesApprovedEndpoint( const port = value.slice(firstSeparator + 1, secondSeparator); const addresses = parseResolveAddresses(value.slice(secondSeparator + 1)); const approved = [...new Set(opts.pinnedAddresses ?? [])]; - const trustedPrivate = [...getTrustedPrivateResolveAddresses(target, opts)]; + const trustedPrivate = [ + ...new Set(getTrustedPrivateResolveAddresses(opts.trustedPrivateCapability)), + ]; if (approved.length === 0) { throw new Error("curl probe --resolve requires SSRF-preflight-approved pinnedAddresses"); } @@ -247,6 +242,18 @@ function assertResolveMatchesApprovedEndpoint( if (addresses.length === 0 || addresses.some((address) => isIP(address) === 0)) { throw new Error("curl probe --resolve addresses must be numeric IP addresses"); } + if ( + trustedPrivate.some( + (address) => + isIP(address) === 0 || + !isPrivateResolveAddress(address) || + !isOperatorTrustablePrivateResolveAddress(address), + ) + ) { + throw new Error( + "curl probe trusted private addresses must be numeric RFC1918, CGNAT, or IPv6 ULA addresses", + ); + } const trustedPrivateSet = new Set(trustedPrivate); if ( addresses.some((address) => isPrivateResolveAddress(address) && !trustedPrivateSet.has(address)) @@ -273,7 +280,10 @@ export function validateCurlProbeArgs( const args = [...argv]; const url = normalizeHttpProbeUrl(args.pop()); const parsedUrl = new URL(url); - const trustedPrivate = getTrustedPrivateResolveAddresses(parsedUrl, opts); + const trustedPrivate = getTrustedPrivateResolveAddresses(opts.trustedPrivateCapability); + if (trustedPrivate.length > 0 && opts.pinnedAddresses === undefined) { + throw new Error("curl probe trusted private capability requires pinnedAddresses"); + } let sawResolve = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; diff --git a/src/lib/adapters/http/probe.ts b/src/lib/adapters/http/probe.ts index 6267ce59389..f217625d4b8 100644 --- a/src/lib/adapters/http/probe.ts +++ b/src/lib/adapters/http/probe.ts @@ -40,7 +40,7 @@ export interface CurlProbeOptions { * connection with ambient proxies disabled. */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ + /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; spawnSyncImpl?: ( command: string, diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index 66a7902fc5f..f5122e6088c 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -26,14 +26,6 @@ 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, @@ -73,9 +65,5 @@ export function createPodmanContainerEngine( }); } -export type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "./socket-authority"; -export { - assertPodmanSocketAuthority, - capturePodmanSocketAuthority, - hardenPodmanSocketDirectory, -} from "./socket-authority"; +export type { PodmanSocketAuthority } from "./socket-authority"; +export { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority"; diff --git a/src/lib/adapters/podman/socket-authority.test.ts b/src/lib/adapters/podman/socket-authority.test.ts index a43b989b777..683c323ea0f 100644 --- a/src/lib/adapters/podman/socket-authority.test.ts +++ b/src/lib/adapters/podman/socket-authority.test.ts @@ -1,17 +1,9 @@ // 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, - hardenPodmanSocketDirectory, -} from "./socket-authority"; +import { assertPodmanSocketAuthority, capturePodmanSocketAuthority } from "./socket-authority"; const SOCKET_PATH = "/run/user/1000/podman/podman.sock"; @@ -93,79 +85,10 @@ describe("Podman socket authority", () => { ).toThrow("writable by another user or group"); }); - 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((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((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"); - + it.each([0o660n, 0o666n])("rejects another-user-writable socket mode %s", (mode) => { expect(() => capturePodmanSocketAuthority(SOCKET_PATH, { - lstat: secureLstat({ mode: 0o666n }, { "/run/user/1000/podman": { mode: 0o700n } }), + lstat: secureLstat({ mode }), uid: 1000, }), ).toThrow("socket authority is writable by another user or group"); diff --git a/src/lib/adapters/podman/socket-authority.ts b/src/lib/adapters/podman/socket-authority.ts index bbd23a8a885..64750a7b6d3 100644 --- a/src/lib/adapters/podman/socket-authority.ts +++ b/src/lib/adapters/podman/socket-authority.ts @@ -79,101 +79,6 @@ function normalizedSocketPath(socketPath: string): string { return normalized; } -export function hardenPodmanSocketDirectory(socketPath: string, configuredUid?: number): void { - const normalized = normalizedSocketPath(socketPath); - const uid = currentUid(configuredUid); - const lstat = (filePath: string): PodmanSocketStat => fs.lstatSync(filePath, { bigint: true }); - const socketBefore = lstat(normalized); - if (!socketBefore.isSocket()) { - throw new Error("Podman socket authority path is not a Unix socket."); - } - const socketOwnerUid = integerIdentity(socketBefore.uid, "owner"); - if (socketOwnerUid !== String(uid)) { - throw new Error( - `Podman socket authority is owned by uid ${socketOwnerUid}; expected current uid ${String(uid)}.`, - ); - } - const socketMode = integerValue(socketBefore.mode, "mode"); - if ((socketMode & 0o002n) !== 0n) { - throw new Error("Podman socket authority is writable by another user or group."); - } - const directoryChainBefore = captureDirectoryChain(normalized, uid, lstat); - const socketParentBefore = directoryChainBefore[0]; - if (!socketParentBefore) { - throw new Error("Podman socket authority has no parent directory."); - } - 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."); - } - if ( - integerIdentity(before.dev, "directory device") !== socketParentBefore.device || - integerIdentity(before.ino, "directory inode") !== socketParentBefore.inode || - integerIdentity(before.uid, "directory owner") !== socketParentBefore.ownerUid || - integerValue(before.mode, "directory mode").toString(10) !== socketParentBefore.mode - ) { - throw new Error("Podman socket directory changed before it was secured."); - } - - 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); - } - - const authority = capturePodmanSocketAuthority(normalized, { lstat, uid }); - const socketChanged = - authority.device !== integerIdentity(socketBefore.dev, "device") || - authority.inode !== integerIdentity(socketBefore.ino, "inode") || - authority.mode !== socketMode.toString(10) || - authority.ownerUid !== socketOwnerUid; - const directoryChanged = authority.directoryChain.some((component, index) => { - const before = directoryChainBefore[index]; - const componentMode = BigInt(component.mode); - const beforeMode = before ? BigInt(before.mode) : 0n; - return ( - !before || - component.device !== before.device || - component.inode !== before.inode || - component.ownerUid !== before.ownerUid || - component.path !== before.path || - (index === 0 - ? (componentMode & ~0o777n) !== (beforeMode & ~0o777n) || - (componentMode & 0o777n) !== 0o700n - : component.mode !== before.mode) - ); - }); - if ( - socketChanged || - authority.directoryChain.length !== directoryChainBefore.length || - directoryChanged - ) { - throw new Error("Podman socket authority changed while its directory was secured."); - } -} - function captureDirectoryChain( socketPath: string, uid: number, @@ -235,17 +140,11 @@ export function capturePodmanSocketAuthority( ); } const mode = integerValue(stat.mode, "mode"); - 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)) { + if ((mode & 0o022n) !== 0n) { throw new Error("Podman socket authority is writable by another user or group."); } return Object.freeze({ - directoryChain, + directoryChain: captureDirectoryChain(normalized, uid, lstat), device: integerIdentity(stat.dev, "device"), inode: integerIdentity(stat.ino, "inode"), mode: mode.toString(10), diff --git a/src/lib/deploy/index.test.ts b/src/lib/deploy/index.test.ts index f6616687fc9..ff0a84e4c73 100644 --- a/src/lib/deploy/index.test.ts +++ b/src/lib/deploy/index.test.ts @@ -10,23 +10,8 @@ import { inferDeployProvider, isBrevInstanceFailed, isBrevInstanceReady, - validateDeployInstanceName, } from "./index"; -describe("validateDeployInstanceName", () => { - it("preserves the Brev instance-name contract independently of sandbox limits (#8497)", () => { - const sixtyThreeCharacters = `a${"b".repeat(61)}z`; - - expect(validateDeployInstanceName("brev--instance-name-that-exceeds-nineteen")).toBe( - "brev--instance-name-that-exceeds-nineteen", - ); - expect(validateDeployInstanceName(sixtyThreeCharacters)).toBe(sixtyThreeCharacters); - expect(() => validateDeployInstanceName(`a${"b".repeat(63)}`)).toThrow( - /instance name too long \(max 63 chars\)/, - ); - }); -}); - describe("inferDeployProvider", () => { it("prefers an explicit provider override", () => { const provider = inferDeployProvider("openai", { @@ -290,7 +275,7 @@ describe("executeDeploy", () => { expect(errorText).toContain('Invalid sandbox name: "bad name"'); expect(errorText).toContain("Sandbox names cannot contain spaces."); expect(errorText).toContain( - "Allowed format: 1-19 characters, lowercase, starts with a letter, letters/numbers/single internal hyphens only, ends with letter/number.", + "Allowed format: 1-63 characters, lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number.", ); expect(errorText).toContain( "Brev deploy is non-interactive and cannot prompt for a corrected sandbox name.", diff --git a/src/lib/deploy/index.ts b/src/lib/deploy/index.ts index 7fa788ed583..65733f1a657 100644 --- a/src/lib/deploy/index.ts +++ b/src/lib/deploy/index.ts @@ -4,39 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; + +import { NAME_ALLOWED_FORMAT, getNameValidationGuidance } from "../name-validation"; import { sleepSeconds } from "../core/wait"; -import { - diagnosticPreview, - getNameValidationGuidance, - NAME_ALLOWED_FORMAT, -} from "../name-validation"; - -const DEPLOY_INSTANCE_NAME_MAX_LENGTH = 63; -const DEPLOY_INSTANCE_NAME_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; -const DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT = - "1-63 characters, lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number"; - -// Brev instance names are not OpenShell sandbox identities. Preserve their -// established RFC-compatible 63-character boundary while sandbox names use -// the stricter OpenShell 0.0.101 contract through the injected validator. -export function validateDeployInstanceName(name: string): string { - if (!name || typeof name !== "string") { - throw new Error( - `instance name is required. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - if (name.length > DEPLOY_INSTANCE_NAME_MAX_LENGTH) { - throw new Error( - `instance name too long (max ${DEPLOY_INSTANCE_NAME_MAX_LENGTH} chars): ${diagnosticPreview(name)}. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - if (!DEPLOY_INSTANCE_NAME_PATTERN.test(name)) { - throw new Error( - `Invalid instance name: ${diagnosticPreview(name)}. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - return name; -} type ExecLikeValue = | string @@ -321,7 +291,7 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise ); } - const name = validateDeployInstanceName(instanceName); + const name = validateName(instanceName, "instance name"); const gpu = env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp") .trim() diff --git a/src/lib/inference/compatible-endpoint-context.test.ts b/src/lib/inference/compatible-endpoint-context.test.ts index 7c0b223a4ff..fffd3e59c6c 100644 --- a/src/lib/inference/compatible-endpoint-context.test.ts +++ b/src/lib/inference/compatible-endpoint-context.test.ts @@ -223,7 +223,7 @@ describe("compatible-endpoint context window", () => { expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); }); - it("probes an allowlisted endpoint with every mixed private and public DNS pin (#8176)", async () => { + it("does not probe an allowlisted endpoint with mixed private and public DNS answers (#8176)", async () => { const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); const messages: string[] = []; const env: NodeJS.ProcessEnv = { @@ -239,17 +239,9 @@ describe("compatible-endpoint context window", () => { logger: { log: (m) => messages.push(m), warn: (m) => messages.push(m) }, }); - expect(fetchModels).toHaveBeenCalledWith( - "https://llm.corp.example/v1", - "", - ["10.0.0.8", "93.184.216.34"], - expect.objectContaining({ - host: "llm.corp.example", - addresses: ["10.0.0.8", "93.184.216.34"], - }), - ); - expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); - expect(messages).toEqual([" ✓ Using endpoint max_model_len: 65536 tokens"]); + expect(fetchModels).not.toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages.some((message) => message.includes("93.184.216.34"))).toBe(true); }); it.each([ diff --git a/src/lib/inference/compatible-endpoint-context.ts b/src/lib/inference/compatible-endpoint-context.ts index 08f6dec9a38..7760a6a5038 100644 --- a/src/lib/inference/compatible-endpoint-context.ts +++ b/src/lib/inference/compatible-endpoint-context.ts @@ -47,7 +47,7 @@ export type CompatibleEndpointModelsFetcher = ( * fakes can ignore it. */ pinnedAddresses?: string[], - /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ + /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability, ) => unknown | null; diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index 1d5ad471053..4d23cad43c5 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -571,25 +571,6 @@ describe("parseGatewayInference", () => { }); }); - it("parses the OpenShell v0.0.99 inference heading", () => { - const output = [ - "Inference:", - "", - " Workspace: default", - " Provider: compatible-endpoint", - " Model: custom-model", - " Version: 1", - "", - "System inference:", - "", - " Not configured", - ].join("\n"); - expect(parseGatewayInference(output)).toEqual({ - provider: "compatible-endpoint", - model: "custom-model", - }); - }); - it("returns null for empty output", () => { expect(parseGatewayInference("")).toBeNull(); expect(parseGatewayInference(null)).toBeNull(); diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index 355278c988f..b21fe40c2fd 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -363,7 +363,7 @@ export function parseGatewayInference(output: string | null | undefined): Gatewa let provider: string | null = null; let model: string | null = null; for (const line of lines) { - if (/^(?:Gateway )?Inference:\s*$/i.test(line)) { + if (/^Gateway inference:\s*$/i.test(line)) { inGateway = true; continue; } diff --git a/src/lib/inference/endpoint-ssrf-preflight.test.ts b/src/lib/inference/endpoint-ssrf-preflight.test.ts index 906a31018c0..64b7dbf48c4 100644 --- a/src/lib/inference/endpoint-ssrf-preflight.test.ts +++ b/src/lib/inference/endpoint-ssrf-preflight.test.ts @@ -70,7 +70,7 @@ describe("assertEndpointResolvesPublic (#6293)", () => { expect(lookup).toHaveBeenCalledWith("llm.corp.example", { all: true }); }); - it("pins mixed public and trusted-private answers for an exact trusted hostname (#8176)", async () => { + it("rejects mixed public and trusted-private answers for an exact trusted hostname (#8176)", async () => { const result = await assertEndpointResolvesPublic( "https://llm.corp.example/v1", async () => [ @@ -81,14 +81,11 @@ describe("assertEndpointResolvesPublic (#6293)", () => { ); expect(result).toMatchObject({ - ok: true, - addresses: ["10.0.0.8", "93.184.216.34"], - trustedPrivateEndpoint: true, - }); - expect(result.trustedPrivateCapability).toMatchObject({ - host: "llm.corp.example", - addresses: ["10.0.0.8", "93.184.216.34"], + ok: false, + reasonCode: "mixed-answer", + offendingAddress: "93.184.216.34", }); + expect(result.trustedPrivateCapability).toBeUndefined(); }); it("does not treat a trusted hostname as a suffix or wildcard allowlist (#6861)", async () => { @@ -270,13 +267,13 @@ describe("assertEndpointResolvesPublic (#6293)", () => { expect(result.ok).toBe(false); }); - it("preserves a URL-valid public hostname outside declaration grammar (#8176)", async () => { - const lookup = resolverTo("93.184.216.34"); + it("returns a rejected result when URL parsing accepts a non-canonical hostname (#8176)", async () => { + const lookup = vi.fn(); await expect( assertEndpointResolvesPublic("https://my_host.corp.example/v1", lookup), - ).resolves.toEqual({ ok: true, addresses: ["93.184.216.34"] }); - expect(lookup).toHaveBeenCalledWith("my_host.corp.example", { all: true }); + ).resolves.toMatchObject({ ok: false, reasonCode: "rejected" }); + expect(lookup).not.toHaveBeenCalled(); }); it.each([ diff --git a/src/lib/inference/endpoint-ssrf-preflight.ts b/src/lib/inference/endpoint-ssrf-preflight.ts index cc6c8b6bb8d..7e95c411d5c 100644 --- a/src/lib/inference/endpoint-ssrf-preflight.ts +++ b/src/lib/inference/endpoint-ssrf-preflight.ts @@ -1,42 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - assertEndpointResolvesPublic as assertSharedEndpointResolvesPublic, - type EndpointDnsLookupFn, - type EndpointSsrfPreflightOptions, - type EndpointSsrfPreflightResult, - isOpenShellManagedHost, - parseTrustedPrivateHosts, -} from "../security/trusted-private-endpoint"; +import { parseTrustedPrivateHosts } from "../security/trusted-private-endpoint"; export * from "../security/trusted-private-endpoint"; export { parseTrustedPrivateHosts as parseTrustedPrivateInferenceHosts } from "../security/trusted-private-endpoint"; -/** Preserve the established local inference routes outside the generic endpoint boundary. */ -export async function assertEndpointResolvesPublic( - endpointUrl: string, - lookup?: EndpointDnsLookupFn, - options: EndpointSsrfPreflightOptions = {}, -): Promise { - try { - const hostname = new URL(String(endpointUrl)).hostname; - const { isLoopbackHostname } = - require("../private-networks") as typeof import("../private-networks"); - // Inference retains its established local routes, except hosted-only - // onboarding explicitly disables host loopback (portable profile). - if ( - (isLoopbackHostname(hostname) && options.allowExplicitLoopback !== false) || - isOpenShellManagedHost(hostname) - ) { - return { ok: true, addresses: [] }; - } - } catch { - // The shared validator owns the stable malformed-URL result. - } - return assertSharedEndpointResolvesPublic(endpointUrl, lookup, options); -} - /** Read the generic trust source and the legacy inference-only source. */ export function parseTrustedPrivateInferenceHostsFromEnv(env: NodeJS.ProcessEnv): string[] { return [ diff --git a/src/lib/inference/llama-cpp/host-local-runtime.test.ts b/src/lib/inference/llama-cpp/host-local-runtime.test.ts index 4a17143f6bc..77f5c96b78b 100644 --- a/src/lib/inference/llama-cpp/host-local-runtime.test.ts +++ b/src/lib/inference/llama-cpp/host-local-runtime.test.ts @@ -15,7 +15,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { LLAMA_CPP_PORT } from "./contract"; import { buildLlamaCppHostLocalDockerArgv, type LlamaCppHostLocalLaunchContract, @@ -160,15 +159,6 @@ describe("llama.cpp host-local runtime materializer", () => { expect(argv.join("\n")).not.toContain("huggingface.co"); }); - it("publishes the fixed loopback host port when the bindings pin one", () => { - const argv = buildLlamaCppHostLocalDockerArgv(contract(), { - ...bindings(), - hostPort: LLAMA_CPP_PORT, - }); - - expect(valuesAfter(argv, "--publish")).toEqual([`127.0.0.1:${String(LLAMA_CPP_PORT)}:8081`]); - }); - it("takes launch settings from the declared contract instead of code defaults (#8144)", () => { const input = contract(); const changed = { diff --git a/src/lib/inference/llama-cpp/managed-status.test.ts b/src/lib/inference/llama-cpp/managed-status.test.ts index 3334f60c1fe..4508de22b9c 100644 --- a/src/lib/inference/llama-cpp/managed-status.test.ts +++ b/src/lib/inference/llama-cpp/managed-status.test.ts @@ -60,7 +60,7 @@ function engine( }; } -function reserveState(homeDir: string): void { +function publishState(homeDir: string, runtimeEngine: ContainerEngine): void { const paths = managedLlamaCppStatePaths(homeDir); const catalog = loadManagedInferenceCatalog(); const preset = catalog.presets.find( @@ -79,11 +79,6 @@ function reserveState(homeDir: string): void { recipeId: RECIPE_ID, }); loadOrCreateManagedLlamaCppApiKey(paths); -} - -function publishState(homeDir: string, runtimeEngine: ContainerEngine): void { - reserveState(homeDir); - const paths = managedLlamaCppStatePaths(homeDir); const engineAuthority = { schemaVersion: 1 as const, providerId: "docker", @@ -148,83 +143,6 @@ function publishState(homeDir: string, runtimeEngine: ContainerEngine): void { } describe("managed llama.cpp status", () => { - it("reports reserved ownership without a receipt as preparing", () => { - const homeDir = temporaryHome(); - reserveState(homeDir); - - expect(inspectManagedLlamaCppStatus("spark-agent", { homeDir })).toEqual({ - recipeId: RECIPE_ID, - modelDigest: null, - imageReference: null, - endpoint: "https://inference.local/v1", - state: "preparing", - detail: "ownership is reserved; no finalized runtime receipt is published", - }); - }); - - it("reports the exact receipt-bound container as absent without further inspection", () => { - const inspectExact = vi.fn(); - const probe = vi.fn(); - const runtimeEngine = engine( - vi.fn(() => ({ - status: 1, - stdout: "", - stderr: `Error response from daemon: No such container: ${RUNTIME_ID}`, - })), - ); - const homeDir = temporaryHome(); - publishState(homeDir, runtimeEngine); - - expect( - inspectManagedLlamaCppStatus("spark-agent", { - homeDir, - engine: runtimeEngine, - inspectExact, - probe, - }), - ).toMatchObject({ state: "absent", detail: "the exact managed container is absent" }); - expect(inspectExact).not.toHaveBeenCalled(); - expect(probe).not.toHaveBeenCalled(); - }); - - it("reports an exact stopped runtime without probing readiness", () => { - const inspectExact = vi.fn(() => ({ running: false, receipt: {} as never })); - const probe = vi.fn(); - const runtimeEngine = engine(vi.fn(() => ({ status: 0, stdout: "[]", stderr: "" }))); - const homeDir = temporaryHome(); - publishState(homeDir, runtimeEngine); - - expect( - inspectManagedLlamaCppStatus("spark-agent", { - homeDir, - engine: runtimeEngine, - inspectExact, - probe, - }), - ).toMatchObject({ state: "stopped", detail: "exact managed container is stopped" }); - expect(inspectExact).toHaveBeenCalledOnce(); - expect(probe).not.toHaveBeenCalled(); - }); - - it("does not classify a different missing container as the receipt-bound absence", () => { - const runtimeEngine = engine( - vi.fn(() => ({ - status: 1, - stdout: "", - stderr: "Error response from daemon: No such container: foreign-runtime", - })), - ); - const homeDir = temporaryHome(); - publishState(homeDir, runtimeEngine); - - expect( - inspectManagedLlamaCppStatus("spark-agent", { homeDir, engine: runtimeEngine }), - ).toMatchObject({ - state: "unknown", - detail: "Docker inspection failed", - }); - }); - it("reports exact secret-free runtime identity and running state", () => { const inspectExact = vi.fn>(() => ({ running: true, diff --git a/src/lib/inference/probe-anthropic.ts b/src/lib/inference/probe-anthropic.ts index 6ec400b2448..77955b3c61f 100644 --- a/src/lib/inference/probe-anthropic.ts +++ b/src/lib/inference/probe-anthropic.ts @@ -60,7 +60,7 @@ export interface AnthropicProbeOptions { * a private/internal address after the public preflight (TOCTOU — #6293). */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ + /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } diff --git a/src/lib/name-validation.ts b/src/lib/name-validation.ts index 899229f2b98..d56d5d7090e 100644 --- a/src/lib/name-validation.ts +++ b/src/lib/name-validation.ts @@ -36,7 +36,7 @@ function validationSubject(label: string): string { return "Names"; } -// Derive a copy-paste-ready OpenShell-compatible label from arbitrary user input. Returns +// Derive a copy-paste-ready RFC 1123 label from arbitrary user input. Returns // null when no recoverable slug exists (empty, all-symbol input) or when the // input is already a valid name (no canonicalisation is performed against // inputs the validator would accept). The transform mirrors what a user would diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0a6ea757f0e..67b08ff5e0d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -56,7 +56,10 @@ const channelState: typeof import("./onboard/channel-state") = require("./onboar const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); -const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); +const { + bestEffortForwardStop, + stopForwardForSandboxOrThrow, +} = require("./onboard/forward-cleanup"); const { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, @@ -74,6 +77,7 @@ const dockerGpuRoute: typeof import("./onboard/docker-gpu-route") = require("./o const sandboxGpuCreateFlow: typeof import("./onboard/sandbox-gpu-create-flow") = require("./onboard/sandbox-gpu-create-flow"); const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch"); const dockerDriverGatewayRuntime: typeof import("./onboard/docker-driver-gateway-runtime") = require("./onboard/docker-driver-gateway-runtime"); +const gatewayService: typeof import("./onboard/docker-driver-gateway-service") = require("./onboard/docker-driver-gateway-service"); const dockerDriverGatewayCutover: typeof import("./onboard/docker-driver-gateway-cutover") = require("./onboard/docker-driver-gateway-cutover"); const { reapHostGatewayBeforeLaunchOrFail, reapDuplicateHostGatewaysExceptOrFail } = require("./onboard/docker-driver-gateway-prelaunch") as typeof import("./onboard/docker-driver-gateway-prelaunch"); @@ -126,6 +130,9 @@ const { isLinuxDockerDriverGatewayEnabled } = dockerDriverPlatform; const { reconcileGatewayGpuReuseForGpuIntent, }: typeof import("./onboard/gateway-gpu-passthrough") = require("./onboard/gateway-gpu-passthrough"); +const { + syncPresetSelection, +}: typeof import("./onboard/policy-preset-sync") = require("./onboard/policy-preset-sync"); const { maybeForceE2eStepFailure, }: typeof import("./onboard/e2e-failure-injection") = require("./onboard/e2e-failure-injection"); @@ -491,7 +498,10 @@ const { }: typeof import("./onboard/machine/initial-flow-composition") = require("./onboard/machine/initial-flow-composition"); const { skippedStepMessage }: typeof import("./onboard/skipped-step-message") = require("./onboard/skipped-step-message"); +const policies: typeof import("./policy") = require("./policy"); const policyPresetCarry: typeof import("./onboard/policy-preset-persistence") = require("./onboard/policy-preset-persistence"); +const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); +const policyTierEnv: typeof import("./onboard/policy-tier-env") = require("./onboard/policy-tier-env"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); const { findAvailableDashboardPort, @@ -582,6 +592,7 @@ import { setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; +import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; import { filterEnabledChannelsByAgent } from "./onboard/messaging-state"; import { getValidatedMessagingTokenByEnvKey } from "./onboard/messaging-token"; import * as ollamaFlow from "./onboard/ollama-probe-failure"; @@ -592,7 +603,15 @@ import type { OpenShellInstallDeps, OpenShellInstallResult, } from "./onboard/openshell-install"; -import { createOnboardPolicyApplication } from "./onboard/policy-selection"; +import { getSuggestedPolicyPresets } from "./onboard/policy-presets"; +import { + computeSetupPresetSuggestions as computeSetupPresetSuggestionsImpl, + preparePolicyPresetResumeSelection, + type SetupPolicySelectionOptions, + type SetupPresetSuggestionOptions, + setupPoliciesWithSelection as setupPoliciesWithSelectionImpl, +} from "./onboard/policy-selection"; +import { createPolicySelectionPromptHelpers } from "./onboard/policy-selection-prompts"; import { printLowMemoryWarning, printMessagingProviderMissing, @@ -768,25 +787,6 @@ const { getGatewayReuseSnapshot, selectNamedGatewayForReuseIfNeeded } = cliDisplayName, }); -const { refreshDockerDriverGatewayReuseState } = - gatewayReuse.createDockerDriverGatewayReuseApplication({ - gatewayName: () => GATEWAY_NAME, - getGatewayCompatContainerName: () => - gatewayBinding.resolveGatewayCompatContainerName(GATEWAY_PORT), - isDockerDriverGatewayEnabled: isLinuxDockerDriverGatewayEnabled, - resolveOpenShellGatewayBinary, - getDockerDriverGatewayEnv, - runCaptureOpenshell, - getDockerDriverGatewayStateDir, - resolveOpenShellSandboxBinary, - getDockerDriverGatewayPid, - isDockerDriverGatewayProcessAlive, - getDockerDriverGatewayReuseDrift: getGatewayReuseDrift, - checkGatewayPortAvailable, - getDockerDriverGatewayPortListenerPid, - rememberDockerDriverGatewayPid, - }); - // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. const { getSandboxReuseState, getSandboxRecreateObservation } = sandboxReuse.createSandboxReuseHelpers({ runCaptureOpenshell, getSandboxStateFromOutputs, getGatewayName: () => GATEWAY_NAME }); @@ -1241,6 +1241,66 @@ function logDockerDriverGatewayRestart(reason: string): void { console.log(` Existing OpenShell Docker-driver gateway is stale (${reason}); restarting...`); } +async function refreshDockerDriverGatewayReuseState( + gatewayReuseState: GatewayReuseState, +): Promise { + if (!isLinuxDockerDriverGatewayEnabled() || gatewayReuseState !== "healthy") { + return gatewayReuseState; + } + const gatewayBin = resolveOpenShellGatewayBinary(); + const baseDesiredEnv = getDockerDriverGatewayEnv( + runCaptureOpenshell(["--version"], { ignoreError: true }), + ); + const runtimeIdentity = gatewayBin + ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ + gatewayBin, + gatewayEnv: baseDesiredEnv, + stateDir: getDockerDriverGatewayStateDir(), + sandboxBin: resolveOpenShellSandboxBinary(), + gatewayName: GATEWAY_NAME, + compatContainerName: gatewayBinding.resolveGatewayCompatContainerName(GATEWAY_PORT), + }) + : null; + const desiredEnv = runtimeIdentity?.desiredEnv ?? baseDesiredEnv; + const driftBin = dockerDriverGatewayLaunch.resolveDriftGatewayBin(runtimeIdentity, gatewayBin); + const identityBin = runtimeIdentity?.identityGatewayBin ?? gatewayBin; + const managedServicePid = gatewayService.getTrustedActiveOpenShellGatewayUserServicePid(); + const pid = getDockerDriverGatewayPid(); + if (pid !== null && isDockerDriverGatewayProcessAlive()) { + const drift = getGatewayReuseDrift(pid, desiredEnv, driftBin, managedServicePid); + if (drift) { + console.log( + ` Existing OpenShell Docker-driver gateway is stale (${drift.reason}); it will be recreated.`, + ); + return "stale"; + } + return gatewayReuseState; + } + + const portCheck = await checkGatewayPortAvailable(); + const dockerGatewayPid = getDockerDriverGatewayPortListenerPid(portCheck, { + gatewayBin: identityBin, + }); + if (dockerGatewayPid !== null) { + const drift = getGatewayReuseDrift(dockerGatewayPid, desiredEnv, driftBin, managedServicePid); + if (dockerGatewayPid !== managedServicePid) rememberDockerDriverGatewayPid(dockerGatewayPid); + if (drift) { + console.log( + ` Existing OpenShell Docker-driver gateway is stale (${drift.reason}); it will be recreated.`, + ); + return "stale"; + } + return "healthy"; + } + + // `openshell status` already proved the selected gateway is reachable. If + // the port probe cannot identify the owning PID, avoid tearing down a live + // gateway solely because the pid file is stale. + if (!portCheck.ok && !portCheck.pid) return "healthy"; + + return "stale"; +} + function destroyGateway( clearRegistry: () => void = registry.clearAll, isDockerDriverGatewayEnabledForDestroy: () => boolean = isLinuxDockerDriverGatewayEnabled, @@ -2197,6 +2257,7 @@ async function createSandboxWithBaseImageResolution( resolvedCreateIntent, ); const manageDashboard = dashboardRuntime.shouldManageDashboardForAgent(agent); + const manageDashboardForward = dashboardRuntime.shouldManageDashboardForwardForAgent(agent); const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile); let effectivePort = 0, chatUiUrl = ""; @@ -2239,7 +2300,10 @@ async function createSandboxWithBaseImageResolution( sandboxGpuConfig: effectiveSandboxGpuConfig, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, - manageDashboard, + manageDashboard: manageDashboardForward, + getSandbox: registry.getSandbox, + stopDashboardForward: (name, port) => + stopForwardForSandboxOrThrow(runOpenshell, runCaptureOpenshell, port, name), ensureDashboardForward, hermesDashboardForwarding, updateReusedSandboxMetadata, @@ -2580,7 +2644,6 @@ async function createSandboxWithBaseImageResolution( route: selectedGpuRoute, firstCreateOutput, registryImageRef, - lifecycleRegistrationFields, } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( { sandboxName, @@ -2595,7 +2658,6 @@ async function createSandboxWithBaseImageResolution( createArgv, sandboxEnv, sandboxStartupCommand, - lifecycleRegistrationFields: recreateRuntime.registrationFields, prebuild, restoreBackupPath, terminalAgent: agentDefs.isTerminalAgent(agent), @@ -2615,6 +2677,8 @@ async function createSandboxWithBaseImageResolution( process.removeListener("exit", initialSandboxPolicy.cleanup); } + // Clean up build context regardless of outcome. + // Use fs.rmSync instead of run() to avoid spawning a shell process. // Only deregister the 'exit' safety net when inline cleanup succeeded; // otherwise leave it armed so a later process.exit() still removes the // temp dir (which may hold source and env-arg API keys). @@ -2648,16 +2712,14 @@ async function createSandboxWithBaseImageResolution( runtimePatch, ); } - let actualDashboardPort = 0; let finalHermesDashboardState = hermesDashboardState; - if (manageDashboard) { + if (manageDashboardForward) { actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { rollbackSandboxOnFailure: true, }); - if (actualDashboardPort !== Number(getDashboardForwardPort(chatUiUrl))) { + if (actualDashboardPort !== Number(getDashboardForwardPort(chatUiUrl))) chatUiUrl = `http://127.0.0.1:${actualDashboardPort}`; - } process.env.CHAT_UI_URL = chatUiUrl; finalHermesDashboardState = hermesDashboardForwarding.resolveStateForPort(actualDashboardPort); hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); @@ -2724,8 +2786,8 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - dashboardForwardEnabled: manageDashboard, - ...lifecycleRegistrationFields, + dashboardForwardEnabled: manageDashboardForward, + ...recreateRuntime.registrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), @@ -3626,6 +3688,89 @@ const setupOpenclaw = createOpenclawSetup({ cleanupTempDir, }); +// ── Step 7: Policy presets ─────────────────────────────────────── + +function arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[] = []): boolean { + if (!Array.isArray(selectedPresets) || selectedPresets.length === 0) return false; + const applied = new Set(policies.getAppliedPresets(sandboxName)); + return selectedPresets.every((preset) => applied.has(preset)); +} + +function getPolicySelectionPromptHelpers(): ReturnType { + return createPolicySelectionPromptHelpers({ + tiers, + policyTierEnv, + isNonInteractive, + note, + prompt, + selectFromNumberedMenuOrExit, + makeOnboardCancelExit, + sandboxCancelRollback, + useColor: USE_COLOR, + }); +} + +async function selectPolicyTier(): Promise { + return getPolicySelectionPromptHelpers().selectPolicyTier(); +} + +async function selectTierPresetsAndAccess( + tierName: string, + allPresets: Array<{ name: string; description?: string }>, + initialSelected?: string[], +): Promise> { + return getPolicySelectionPromptHelpers().selectTierPresetsAndAccess( + tierName, + allPresets, + initialSelected, + ); +} + +async function presetsCheckboxSelector( + allPresets: Array<{ name: string; description: string }>, + initialSelected: string[], +): Promise { + return getPolicySelectionPromptHelpers().presetsCheckboxSelector(allPresets, initialSelected); +} + +const computeSetupPresetSuggestions = ( + tierName: string, + options: SetupPresetSuggestionOptions = {}, +): string[] => + computeSetupPresetSuggestionsImpl( + { policies, tiers, localInferenceProviders: [...LOCAL_INFERENCE_PROVIDERS, "llama-cpp-local"] }, + tierName, + options, + ); +async function setupPoliciesWithSelection( + sandboxName: string, + options: SetupPolicySelectionOptions = {}, +) { + return sandboxMutationLock.withSandboxMutationLock(sandboxName, () => + setupPoliciesWithSelectionImpl( + { + policies, + tiers, + localInferenceProviders: [...LOCAL_INFERENCE_PROVIDERS, "llama-cpp-local"], + step, + note, + isNonInteractive, + waitForSandboxReady, + waitForSandboxControlPlaneReady: finalizationHandlerDeps.waitForSandboxControlPlaneReady, + syncPresetSelection, + selectPolicyTier, + setPolicyTier: (s, t) => registry.updateSandbox(s, { policyTier: t }), + getRecordedPolicyTier: (s) => registry.getSandbox(s)?.policyTier ?? null, + selectTierPresetsAndAccess, + parsePolicyPresetEnv, + env: process.env, + }, + sandboxName, + options, + ), + ); +} + const { buildChain, buildControlUiUrls, @@ -3665,39 +3810,6 @@ const sandboxCancelRollback = installSandboxCancelRollback({ clearOnboardSession: onboardSession.clearSession, }); // #4614 -const { - arePolicyPresetsApplied, - computeSetupPresetSuggestions, - filterSetupPolicyPresets, - getSuggestedPolicyPresets, - mergePolicyMessagingChannels, - preparePolicyPresetResumeSelection, - presetsCheckboxSelector, - resolveSandboxBaselinePolicy, - selectPolicyTier, - selectTierPresetsAndAccess, - setupPoliciesWithSelection, - validatePolicyTierEnvEarly, -} = createOnboardPolicyApplication({ - localInferenceProviders: [...LOCAL_INFERENCE_PROVIDERS, "llama-cpp-local"], - step, - note, - isNonInteractive, - prompt, - selectFromNumberedMenuOrExit, - makeOnboardCancelExit, - sandboxCancelRollback, - useColor: USE_COLOR, - withSandboxMutationLock: sandboxMutationLock.withSandboxMutationLock, - waitForSandboxReady, - waitForSandboxControlPlaneReady: finalizationHandlerDeps.waitForSandboxControlPlaneReady, - setPolicyTier: (sandboxName, tierName) => - registry.updateSandbox(sandboxName, { policyTier: tierName }), - getRecordedPolicyTier: (sandboxName) => registry.getSandbox(sandboxName)?.policyTier ?? null, - parsePolicyPresetEnv, - env: process.env, -}); - const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); @@ -3728,7 +3840,7 @@ async function preflightAuthoritativeRebuildTarget( await authoritativeRebuildTarget.preflightAuthoritativeRebuildTarget( { ...opts, controlUiPort: opts.controlUiPort ?? null }, { - resolveBaselinePolicy: resolveSandboxBaselinePolicy, + resolveBaselinePolicy: (sandboxName) => policies.resolveSandboxBaselinePolicy(sandboxName), runFatalRuntimePreflight: () => fatalRuntimePreflight.runFatalOnboardRuntimePreflight( { @@ -3808,7 +3920,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { initialPreResolvedMetadata: opts.preResolvedBaseImageMetadata, }); const onboardingComputePlan = dockerDriverPlatform.resolveCurrentOpenShellComputePlan(); - if (isNonInteractive()) validatePolicyTierEnvEarly(); + if (isNonInteractive()) policyTierEnv.validatePolicyTierEnvEarly(); const noticeAccepted = await ensureUsageNoticeConsent({ nonInteractive: isNonInteractive(), acceptedByFlag: opts.acceptThirdPartySoftware === true, @@ -4339,7 +4451,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { mergePolicyMessagingChannels, // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell: runCoreGatewayOpenshell, redact }), - preparePolicyPresetResumeSelection, + preparePolicyPresetResumeSelection: (name, options) => + preparePolicyPresetResumeSelection({ policies }, name, options), arePolicyPresetsApplied, skippedStepMessage, recordStateSkipped, @@ -4396,7 +4509,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), - }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name) }); + }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name), verifyDashboardForward: dashboardRuntime.shouldManageDashboardForwardForAgent(agent) }); }, formatVerificationDiagnostics: (result) => { const verifyDeploymentModule: typeof import("./verify-deployment") = @@ -4548,7 +4661,7 @@ module.exports = { getSuggestedPolicyPresets, computeSetupPresetSuggestions, mergeRequiredHermesToolGatewayPolicyPresets, - filterSetupPolicyPresets, + filterSetupPolicyPresets: policies.filterSetupPolicyPresets, LOCAL_INFERENCE_PROVIDERS, presetsCheckboxSelector, selectPolicyTier, diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 7c18c8042b6..afe8c75e2fa 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -476,7 +476,7 @@ describe("onboard command options", () => { NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: "example/model-1", NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "skip", NEMOCLAW_POLICY_TIER: "personal", NEMOCLAW_TOOL_DISCLOSURE: "direct", }); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 86d031aca03..c8d87f75cb8 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -403,7 +403,7 @@ function applyPortableEnvironment( NEMOCLAW_PROVIDER: "custom", NEMOCLAW_MODEL: hostedInference.model, NEMOCLAW_OLLAMA_NO_AUTOSTART: "1", - NEMOCLAW_POLICY_MODE: "suggested", + NEMOCLAW_POLICY_MODE: "skip", NEMOCLAW_POLICY_TIER: "personal", COMPATIBLE_API_KEY: hostedInference.apiKey, NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 71a5e5dd2f4..a6a90aa6fb5 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -99,7 +99,7 @@ describe("docker-driver gateway runtime helpers", () => { expect(env.OPENSHELL_DOCKER_NETWORK_NAME).toBe("custom-openshell-docker"); expect(env.OPENSHELL_DOCKER_SUPERVISOR_BIN).toBe(path.resolve(sandboxBin)); expect(env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE).toBe( - "ghcr.io/nvidia/openshell/supervisor@sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", + "ghcr.io/nvidia/openshell/supervisor:0.0.99", ); expect(env.OPENSHELL_GATEWAY_CONFIG).toBe( path.join(path.resolve(stateDir), "openshell-gateway.toml"), @@ -130,18 +130,18 @@ describe("docker-driver gateway runtime helpers", () => { ).toBe("ghcr.io/nvidia/openshell/supervisor:dev"); }); - it("pins the stable 0.0.99 supervisor default while preserving an explicit override", () => { + it("pins the stable 0.0.85 supervisor default while preserving an explicit override", () => { const image = (fallback: string) => makeHelpers({ - getBlueprintMaxOpenshellVersion: () => "0.0.99", + getBlueprintMaxOpenshellVersion: () => "0.0.85", supportedOpenshellFallbackVersion: fallback, }).helpers.getDockerDriverGatewayEnv(null, "linux").OPENSHELL_DOCKER_SUPERVISOR_IMAGE; - const stable = withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: undefined }, () => image("0.0.99")); + const stable = withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: undefined }, () => image("0.0.85")); expect(stable).toBe( - "ghcr.io/nvidia/openshell/supervisor@sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", + "ghcr.io/nvidia/openshell/supervisor@sha256:f4226253a3525c3832adac5b38b419a0f27d1e915effe565b5885e20f93cd5e9", ); const override = "registry.example.test/supervisor@sha256:override"; - expect(withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: override }, () => image("0.0.99"))).toBe( + expect(withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: override }, () => image("0.0.85"))).toBe( override, ); }); diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index a4f293360b2..0b161768be2 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -36,8 +36,6 @@ import * as vmDriverProcess from "./vm-driver-process"; const OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS: Readonly> = { "0.0.72": "sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", "0.0.85": "sha256:f4226253a3525c3832adac5b38b419a0f27d1e915effe565b5885e20f93cd5e9", - "0.0.99": "sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", - "0.0.101": "sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb", }; export type DockerDriverGatewayRuntimeDrift = { reason: string }; diff --git a/src/lib/onboard/docker-gpu-patch-types.ts b/src/lib/onboard/docker-gpu-patch-types.ts index fb1940fca7f..f367df0ab22 100644 --- a/src/lib/onboard/docker-gpu-patch-types.ts +++ b/src/lib/onboard/docker-gpu-patch-types.ts @@ -244,7 +244,6 @@ export type DockerContainerInspect = { } | null; }> | null; NetworkMode?: string; - PortBindings?: Record | null> | null; RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; CapAdd?: string[] | null; CapDrop?: string[] | null; diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts deleted file mode 100644 index f709c835341..00000000000 --- a/src/lib/onboard/experimental/portable-demo-lifecycle-migration.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -// 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 { afterEach, describe, expect, it, vi } from "vitest"; -import type { PodmanSocketAuthorityDeps } from "../../adapters/podman"; -import { - 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 temporaryDirectories: string[] = []; -const originalHome = process.env.HOME; - -function legacyStateDir(): string { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-migration-")); - temporaryDirectories.push(stateDir); - const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); - fs.mkdirSync(path.dirname(receiptPath), { recursive: true }); - fs.writeFileSync( - receiptPath, - `${JSON.stringify({ - schemaVersion: 2, - sandboxName: "alpha", - sandboxId: SANDBOX_ID, - containerId: CONTAINER_ID, - dashboardPort: 18789, - })}\n`, - { mode: 0o600 }, - ); - return stateDir; -} - -function socketAuthorityDeps(): PodmanSocketAuthorityDeps { - const inodes = new Map(); - return { - uid: 1001, - lstat: (filePath) => { - const socket = filePath === SOCKET_PATH; - const ino = inodes.get(filePath) ?? BigInt(7000 + inodes.size); - inodes.set(filePath, ino); - return { - dev: 8n, - ino, - mode: socket ? 0o660n : filePath === path.dirname(SOCKET_PATH) ? 0o700n : 0o755n, - uid: socket ? 1001n : filePath.startsWith("/run/user/1001") ? 1001n : 0n, - isDirectory: () => !socket, - isSocket: () => socket, - }; - }, - }; -} - -function createPodman(matches = [CONTAINER_ID]) { - return vi.fn((args: readonly string[]) => { - const command = args[0] === "--url" ? args.slice(2) : args; - const handlers = { - info: () => ({ status: 0, stdout: `${SOCKET_PATH}\n` }), - inspect: () => ({ - status: 0, - stdout: JSON.stringify([ - { - Id: CONTAINER_ID, - Name: "openshell-sandbox-alpha", - Config: { - Labels: { - "openshell.managed": "true", - "openshell.sandbox-id": SANDBOX_ID, - "openshell.sandbox-name": "alpha", - }, - }, - State: { Running: true }, - }, - ]), - }), - ps: () => ({ status: 0, stdout: `${matches.join("\n")}\n` }), - }; - return handlers[command[0] as keyof typeof handlers](); - }); -} - -function migrationDeps( - stateDir: string, - podman: ReturnType, - backfill: (generation: string) => boolean, -) { - return { - backfillRegistryGeneration: backfill, - hardenSocketDirectory: vi.fn(), - platform: "linux" as const, - podman, - podmanSocketAuthorityDeps: socketAuthorityDeps(), - stateDir, - }; -} - -async function legacyRegistryEntry(stateDir: string) { - process.env.HOME = stateDir; - vi.resetModules(); - const registry = await import("../../state/registry"); - const { compareAndSetLegacySandboxLifecycleGeneration } = await import( - "../../state/registry/lifecycle-generation" - ); - registry.registerSandbox({ name: "alpha", agent: "openclaw", openshellDriver: "docker" }); - const expected = registry.getSandbox("alpha")!; - return { - backfill: vi.fn((generation: string) => - compareAndSetLegacySandboxLifecycleGeneration(expected, generation), - ), - registry, - }; -} - -afterEach(() => { - process.env.HOME = originalHome; - vi.resetModules(); - for (const directory of temporaryDirectories.splice(0)) { - fs.rmSync(directory, { force: true, recursive: true }); - } -}); - -describe("portable lifecycle legacy generation migration", () => { - it("claims a schema-2 receipt before privileged cleanup and upgrades it (#8584)", async () => { - const stateDir = legacyStateDir(); - const { backfill, registry } = await legacyRegistryEntry(stateDir); - - expect( - resolvePortableDemoPrivilegedExecTarget( - "alpha", - migrationDeps(stateDir, createPodman(), backfill), - ), - ).toMatchObject({ containerId: CONTAINER_ID, dockerHost: `unix://${SOCKET_PATH}` }); - expect(backfill).toHaveBeenCalledWith(CONTAINER_ID); - expect( - JSON.parse( - fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), - ), - ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); - expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); - }); - - it("claims a schema-2 receipt before retained-sandbox recovery (#8584)", async () => { - const stateDir = legacyStateDir(); - const { backfill, registry } = await legacyRegistryEntry(stateDir); - - expect( - recoverPortableDemoSandboxLifecycle( - "alpha", - { agent: "openclaw", gatewayName: "nemoclaw", openshellDriver: "docker" }, - { - ...migrationDeps(stateDir, createPodman(), backfill), - captureOpenshell: (args) => - args.includes("curl") ? { status: 0, stdout: "200" } : { status: 0 }, - }, - ), - ).toEqual({ kind: "already-running" }); - expect(backfill).toHaveBeenCalledWith(CONTAINER_ID); - expect( - JSON.parse( - fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), - ), - ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); - expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); - }); - - it("finishes a schema-2 receipt upgrade after its registry claim already committed (#8584)", async () => { - const stateDir = legacyStateDir(); - const { backfill, registry } = await legacyRegistryEntry(stateDir); - expect(backfill(CONTAINER_ID)).toBe(true); - backfill.mockClear(); - const podman = createPodman(); - - expect( - recoverPortableDemoSandboxLifecycle( - "alpha", - { - agent: "openclaw", - gatewayName: "nemoclaw", - lifecycleGeneration: CONTAINER_ID, - openshellDriver: "docker", - }, - { - ...migrationDeps(stateDir, podman, backfill), - captureOpenshell: (args) => - args.includes("curl") ? { status: 0, stdout: "200" } : { status: 0 }, - }, - ), - ).toEqual({ kind: "already-running" }); - expect(backfill).not.toHaveBeenCalled(); - expect(podman).toHaveBeenCalledWith( - ["info", "--format", "{{.Host.RemoteSocket.Path}}"], - expect.any(Object), - ); - expect( - JSON.parse( - fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), - ), - ).toMatchObject({ schemaVersion: 3, registryGeneration: CONTAINER_ID }); - expect(registry.getSandbox("alpha")?.lifecycleGeneration).toBe(CONTAINER_ID); - }); - - it("does not claim an ambiguous legacy portable identity (#8584)", () => { - const stateDir = legacyStateDir(); - const backfill = vi.fn(() => true); - - expect(() => - resolvePortableDemoPrivilegedExecTarget( - "alpha", - migrationDeps(stateDir, createPodman([CONTAINER_ID, "b".repeat(64)]), backfill), - ), - ).toThrow("found 2"); - expect(backfill).not.toHaveBeenCalled(); - const receipt = JSON.parse( - fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), - ); - expect(receipt).toMatchObject({ schemaVersion: 2 }); - expect(receipt).not.toHaveProperty("registryGeneration"); - }); - - it("does not upgrade the receipt when the registry row changes before its claim (#8584)", async () => { - const stateDir = legacyStateDir(); - const { backfill, registry } = await legacyRegistryEntry(stateDir); - registry.updateSandbox("alpha", { model: "replacement" }); - - expect(() => - resolvePortableDemoPrivilegedExecTarget( - "alpha", - migrationDeps(stateDir, createPodman(), backfill), - ), - ).toThrow("could not claim the current registry generation"); - const receipt = JSON.parse( - fs.readFileSync(portableDemoLifecycleInternals.receiptPath("alpha", stateDir), "utf8"), - ); - expect(receipt).toMatchObject({ schemaVersion: 2 }); - expect(receipt).not.toHaveProperty("registryGeneration"); - }); -}); diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts index 138ed4b272c..cd97f4ec0e2 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.test.ts @@ -6,21 +6,16 @@ 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 as recoverPortableDemoSandboxLifecycleUnchecked, - removePortableDemoSandboxLifecycleReceipt, - resolvePortableDemoPrivilegedExecTarget, + recoverPortableDemoSandboxLifecycle, } 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", @@ -68,24 +63,16 @@ function createPodman( let sandboxId = options.sandboxId ?? SANDBOX_ID; let managedLabel = "true"; let sandboxNameLabel = "alpha"; - 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` }; + const podman = vi.fn((args: readonly string[]) => { + switch (args[0]) { case "ps": - return { status: 0, stdout: matches.length > 0 ? `${matches.join("\n")}\n` : "" }; + return { status: 0, stdout: `${CONTAINER_ID}\n` }; case "inspect": return { status: 0, stdout: JSON.stringify([ { - Id: containerId, - Name: containerName, + Id: CONTAINER_ID, Config: { Labels: { "openshell.managed": managedLabel, @@ -117,78 +104,9 @@ 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; - onLstat?: () => void; - socketInode?: () => bigint; - socketMode?: bigint; - socketUid?: bigint; - } = {}, -): PodmanSocketAuthorityDeps { - const directoryInodes = new Map(); - return { - uid: 1001, - lstat: (filePath) => { - options.onLstat?.(); - 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 ?? 0o660n) - : 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, - overrides: Partial = {}, -) { - return resolvePortableDemoPrivilegedExecTarget("alpha", { - platform: "linux", - registryGeneration: CONTAINER_ID, - stateDir, - podman: runtime.podman, - podmanSocketAuthorityDeps: socketAuthorityDeps(), - hardenSocketDirectory: vi.fn(), - ...overrides, - }); -} - function installReceipt(stateDir: string, podman: ReturnType["podman"]): void { installPortableDemoSandboxLifecycle( "alpha", @@ -198,22 +116,6 @@ function installReceipt(stateDir: string, podman: ReturnType[1], - deps: PortableDemoLifecycleDeps = {}, -) { - return recoverPortableDemoSandboxLifecycleUnchecked( - sandboxName, - { - lifecycleGeneration: CONTAINER_ID, - openshellDriver: "docker", - ...context, - }, - deps, - ); -} - function createManagedOllamaBinary(homeDir: string): string { const binPath = path.join(homeDir, ".local", "bin", "ollama"); fs.mkdirSync(path.dirname(binPath), { recursive: true }); @@ -250,43 +152,25 @@ afterEach(() => { }); describe("portable demo sandbox lifecycle", () => { - it("removes a stale receipt without inspecting Podman for a non-portable replacement (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - runtime.podman.mockClear(); - const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + it("does not inspect Podman unless the portable profile is explicit (#8441)", () => { + const podman = vi.fn(); - installPortableDemoSandboxLifecycle( - "alpha", - STARTUP_ARGV, - {}, - { - podman: runtime.podman, - stateDir, - }, - ); + installPortableDemoSandboxLifecycle("alpha", STARTUP_ARGV, {}, { podman }); - expect(fs.existsSync(filePath)).toBe(false); - expect(runtime.podman).not.toHaveBeenCalled(); + expect(podman).not.toHaveBeenCalled(); }); - it("removes a stale receipt for another startup contract (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - runtime.podman.mockClear(); - const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); + it("does not install an OpenClaw demo receipt for another startup contract (#8441)", () => { + const podman = vi.fn(); installPortableDemoSandboxLifecycle( "alpha", ["env", "NEMOCLAW_OBSERVABILITY=0", "/usr/local/bin/nemoclaw-start"], { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { platform: "linux", podman: runtime.podman, stateDir }, + { platform: "linux", podman }, ); - expect(fs.existsSync(filePath)).toBe(false); - expect(runtime.podman).not.toHaveBeenCalled(); + expect(podman).not.toHaveBeenCalled(); }); it("ignores an installed receipt for another agent (#8441)", () => { @@ -305,26 +189,6 @@ describe("portable demo sandbox lifecycle", () => { expect(runtime.podman).not.toHaveBeenCalled(); }); - it("ignores an installed receipt for a non-Docker registry driver (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - runtime.podman.mockClear(); - - expect( - recoverPortableDemoSandboxLifecycle( - "alpha", - { - agent: "openclaw", - gatewayName: "nemoclaw", - openshellDriver: "kubernetes", - }, - { platform: "linux", stateDir, podman: runtime.podman }, - ), - ).toEqual({ kind: "not-installed" }); - expect(runtime.podman).not.toHaveBeenCalled(); - }); - it("rejects an installed receipt outside Linux (#8441)", () => { const stateDir = temporaryStateDir(); const runtime = createPodman(); @@ -381,233 +245,15 @@ describe("portable demo sandbox lifecycle", () => { const filePath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(filePath, "utf-8")); expect(receipt).toEqual({ - schemaVersion: 3, + schemaVersion: 2, sandboxName: "alpha", sandboxId: SANDBOX_ID, containerId: CONTAINER_ID, dashboardPort: 18789, - registryGeneration: CONTAINER_ID, }); 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(); - const socketEvents: string[] = []; - const hardenSocketDirectory = vi.fn(() => socketEvents.push("harden")); - const podmanSocketAuthorityDeps = socketAuthorityDeps({ - onLstat: () => socketEvents.push("capture"), - }); - - expect( - resolveTarget(stateDir, runtime, { hardenSocketDirectory, podmanSocketAuthorityDeps }), - ).toMatchObject({ - containerId: CONTAINER_ID, - dockerHost: "unix:///run/user/1001/podman/podman.sock", - }); - expect(hardenSocketDirectory).toHaveBeenCalledWith(SOCKET_PATH); - expect(socketEvents.slice(0, 2)).toEqual(["harden", "capture"]); - 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("rejects a receipt outside the current registry generation before Podman access (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - runtime.podman.mockClear(); - - expect(() => - resolveTarget(stateDir, runtime, { registryGeneration: "replacement-generation" }), - ).toThrow("does not belong to the current registry generation"); - expect(runtime.podman).not.toHaveBeenCalled(); - }); - - it("rejects recovery outside the current registry generation before Podman access (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - runtime.podman.mockClear(); - - expect(() => - recoverPortableDemoSandboxLifecycle( - "alpha", - { - agent: "openclaw", - gatewayName: "nemoclaw", - lifecycleGeneration: "replacement-generation", - openshellDriver: "docker", - }, - { - platform: "linux", - stateDir, - podman: runtime.podman, - }, - ), - ).toThrow("does not belong to the current registry generation"); - expect(runtime.podman).not.toHaveBeenCalled(); - }); - - it("rejects a legacy receipt after same-name registry replacement (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); - const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); - delete receipt.registryGeneration; - fs.writeFileSync(receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 2 })}\n`, { - mode: 0o600, - }); - runtime.podman.mockClear(); - - expect(() => - recoverPortableDemoSandboxLifecycle( - "alpha", - { - agent: "openclaw", - gatewayName: "nemoclaw", - lifecycleGeneration: "replacement-generation", - openshellDriver: "docker", - }, - { platform: "linux", stateDir, podman: runtime.podman }, - ), - ).toThrow("does not belong to the current registry generation"); - expect(runtime.podman).not.toHaveBeenCalled(); - }); - - it("retires portable lifecycle authority after its sandbox registry entry is removed (#8584)", () => { - const stateDir = temporaryStateDir(); - const runtime = createPodman(); - installReceipt(stateDir, runtime.podman); - const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); - - removePortableDemoSandboxLifecycleReceipt("alpha", stateDir); - - expect(fs.existsSync(receiptPath)).toBe(false); - }); - - 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"], - ["world-writable socket", socketAuthorityDeps({ socketMode: 0o666n }), "writable by another"], - [ - "group-writable socket outside a private parent", - socketAuthorityDeps({ directoryMode: 0o750n }), - "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(); @@ -1317,7 +963,6 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf-8")); - delete receipt.registryGeneration; fs.writeFileSync( receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 1 }, null, 2)}\n`, @@ -1381,10 +1026,7 @@ describe("portable demo sandbox lifecycle", () => { 5000, ); expect(launchOpenshell).toHaveBeenCalledOnce(); - expect(JSON.parse(fs.readFileSync(receiptPath, "utf-8"))).toMatchObject({ - schemaVersion: 3, - registryGeneration: CONTAINER_ID, - }); + expect(JSON.parse(fs.readFileSync(receiptPath, "utf-8"))).toMatchObject({ schemaVersion: 2 }); expect( recoverPortableDemoSandboxLifecycle( @@ -1402,7 +1044,6 @@ describe("portable demo sandbox lifecycle", () => { installReceipt(stateDir, runtime.podman); const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir); const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf-8")); - delete receipt.registryGeneration; fs.writeFileSync( receiptPath, `${JSON.stringify({ ...receipt, schemaVersion: 1 }, null, 2)}\n`, diff --git a/src/lib/onboard/experimental/portable-demo-lifecycle.ts b/src/lib/onboard/experimental/portable-demo-lifecycle.ts index ecdb48984f8..d9b565fce40 100644 --- a/src/lib/onboard/experimental/portable-demo-lifecycle.ts +++ b/src/lib/onboard/experimental/portable-demo-lifecycle.ts @@ -7,16 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; -import { - assertPodmanSocketAuthority, - capturePodmanSocketAuthority, - createPodmanContainerEngine, - hardenPodmanSocketDirectory, - localPodmanEnvironment, - type PodmanSocketAuthorityDeps, -} from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; import { isPortableExperimentalProfile } from "../docker-driver-platform"; import { @@ -41,15 +32,9 @@ const SANDBOX_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u; const PODMAN_MANAGED_LABEL = "openshell.managed"; const PODMAN_SANDBOX_ID_LABEL = "openshell.sandbox-id"; const PODMAN_SANDBOX_NAME_LABEL = "openshell.sandbox-name"; -const PODMAN_SANDBOX_CONTAINER_PREFIX = "openshell-sandbox-"; -const STALE_NEWER_MANAGED_BY_LABEL = "openshell.ai/managed-by"; -const STALE_NEWER_MANAGED_BY_VALUE = "openshell"; -const STALE_NEWER_SANDBOX_ID_LABEL = "openshell.ai/sandbox-id"; -const STALE_NEWER_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; const OPENSHELL_RUNTIME_CA_CERT = "/etc/openshell-tls/openshell-ca.pem"; const OPENSHELL_RUNTIME_CA_BUNDLE = "/etc/openshell-tls/ca-bundle.pem"; -const PINNED_OPENSHELL_VERSION = "0.0.85"; -const CURRENT_RECEIPT_SCHEMA_VERSION = 4; +const CURRENT_RECEIPT_SCHEMA_VERSION = 2; const STARTUP_PROCESS_PATTERN = "^(/usr/local/bin/nemoclaw-start|(bash|/bin/bash|/usr/bin/bash) /usr/local/bin/nemoclaw-start)( |$)"; const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4)); @@ -62,27 +47,17 @@ type CommandResult = { }; interface PortableDemoLifecycleReceipt { - schemaVersion: 1 | 2 | 3 | 4; + schemaVersion: 1 | 2; sandboxName: string; sandboxId: string; containerId: string; dashboardPort: number; - registryGeneration?: string; - openshellVersion?: typeof PINNED_OPENSHELL_VERSION; } interface PodmanContainerInspection { containerId: string; sandboxId: string; running: boolean; - driver: string; - resolvConfPath: string; -} - -export interface PortableDemoPrivilegedExecTarget { - readonly assertRuntimeAuthority: () => void; - readonly containerId: string; - readonly dockerHost: string; } export interface PortableDemoLifecycleDeps { @@ -90,11 +65,7 @@ export interface PortableDemoLifecycleDeps { stateDir?: string; env?: NodeJS.ProcessEnv; openshellBinary?: string; - podman?: (args: readonly string[], env?: NodeJS.ProcessEnv) => CommandResult; - podmanSocketAuthorityDeps?: PodmanSocketAuthorityDeps; - hardenSocketDirectory?: (socketPath: string) => void; - registryGeneration?: string; - backfillRegistryGeneration?: (registryGeneration: string) => boolean; + podman?: (args: readonly string[]) => CommandResult; captureOpenshell?: (args: readonly string[], timeoutMs: number) => CommandResult; launchOpenshell?: (args: readonly string[]) => void; captureHost?: (command: string, args: readonly string[], timeoutMs: number) => CommandResult; @@ -105,7 +76,6 @@ export interface PortableDemoLifecycleDeps { executableFd: number, ) => void; loadManagedOllama?: () => string | null; - ensureGateway?: () => void; sleep?: (milliseconds: number) => void; now?: () => number; log?: (message: string) => void; @@ -119,8 +89,6 @@ export type PortableDemoLifecycleRecoveryResult = export interface PortableDemoLifecycleContext { agent?: string | null; gatewayName: string; - lifecycleGeneration?: string; - openshellDriver?: string | null; provider?: string | null; } @@ -200,13 +168,6 @@ function defaultSleep(milliseconds: number): void { function commandDetail(result: CommandResult): string { if (result.error) return (result.error as NodeJS.ErrnoException).code ?? "command execution error"; - const output = `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}`; - if (/creating resolv\.conf for container[\s\S]*no such file or directory/iu.test(output)) { - return `exit ${String(result.status)}: Podman's ephemeral container run directory is missing`; - } - if (/common\.pid[\s\S]*no such file or directory/iu.test(output)) { - return `exit ${String(result.status)}: Podman's rootless pause-process state is missing`; - } return `exit ${String(result.status)}`; } @@ -251,20 +212,11 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl } const receipt = value; const keys = Object.keys(receipt).sort(); - const expectedKeys = - receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION - ? "containerId,dashboardPort,openshellVersion,registryGeneration,sandboxId,sandboxName,schemaVersion" - : receipt.schemaVersion === 3 - ? "containerId,dashboardPort,registryGeneration,sandboxId,sandboxName,schemaVersion" - : "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion"; - if (keys.join(",") !== expectedKeys) { + if (keys.join(",") !== "containerId,dashboardPort,sandboxId,sandboxName,schemaVersion") { throw new Error("Portable demo lifecycle receipt fields are invalid"); } if ( - (receipt.schemaVersion !== 1 && - receipt.schemaVersion !== 2 && - receipt.schemaVersion !== 3 && - receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION) || + (receipt.schemaVersion !== 1 && receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION) || receipt.sandboxName !== sandboxName || typeof receipt.containerId !== "string" || !CONTAINER_ID_PATTERN.test(receipt.containerId) || @@ -272,43 +224,13 @@ function parseReceipt(value: unknown, sandboxName: string): PortableDemoLifecycl !SANDBOX_ID_PATTERN.test(receipt.sandboxId) || !Number.isInteger(receipt.dashboardPort) || Number(receipt.dashboardPort) < 1024 || - Number(receipt.dashboardPort) > 65535 || - ((receipt.schemaVersion === 3 || receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION) && - (typeof receipt.registryGeneration !== "string" || - !SANDBOX_ID_PATTERN.test(receipt.registryGeneration))) || - (receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && - receipt.openshellVersion !== PINNED_OPENSHELL_VERSION) + Number(receipt.dashboardPort) > 65535 ) { throw new Error("Portable demo lifecycle receipt values are invalid"); } return receipt as unknown as PortableDemoLifecycleReceipt; } -function requireCurrentRegistryGeneration( - receipt: PortableDemoLifecycleReceipt, - registryGeneration: string | undefined, -): boolean { - // Legacy receipts predate an explicit generation field. Their immutable - // container ID may claim a missing registry generation only after exact - // local runtime validation; an existing generation must already match. - const receiptGeneration = - receipt.schemaVersion === 3 || receipt.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION - ? receipt.registryGeneration - : receipt.containerId; - if ( - registryGeneration === undefined && - receipt.schemaVersion !== 3 && - receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION - ) - return true; - if (receiptGeneration !== registryGeneration) { - throw new Error( - `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' does not belong to the current registry generation`, - ); - } - return false; -} - function loadReceipt(sandboxName: string, stateDir: string): PortableDemoLifecycleReceipt | null { let file; try { @@ -384,84 +306,19 @@ function inspectPodmanContainer( const labels = config && isRecord(config.Labels) ? config.Labels : null; const state = isRecord(inspection.State) ? inspection.State : null; const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; - const driver = inspection.Driver; - const resolvConfPath = inspection.ResolvConfPath; - const sandboxNameMatches = labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName; - const containerNameMatches = - String(inspection.Name ?? "").replace(/^\//u, "") === - `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}`; - const legacyIdentityMatches = - labels?.[PODMAN_MANAGED_LABEL] === "true" && - sandboxNameMatches && - containerNameMatches && - typeof sandboxId === "string" && - SANDBOX_ID_PATTERN.test(sandboxId); if ( inspection.Id !== containerId || - !legacyIdentityMatches || - typeof state?.Running !== "boolean" || - typeof driver !== "string" || - !/^[a-z0-9_-]+$/u.test(driver) || - typeof resolvConfPath !== "string" || - !path.isAbsolute(resolvConfPath) + labels?.[PODMAN_MANAGED_LABEL] !== "true" || + labels?.[PODMAN_SANDBOX_NAME_LABEL] !== sandboxName || + typeof sandboxId !== "string" || + !SANDBOX_ID_PATTERN.test(sandboxId) || + typeof state?.Running !== "boolean" ) { - const checks = [ - `immutable-id=${inspection.Id === containerId ? "match" : "mismatch"}`, - `container-name=${containerNameMatches ? "match" : "mismatch"}`, - `sandbox-name=${sandboxNameMatches ? "match" : "mismatch"}`, - `sandbox-id=${typeof sandboxId === "string" && SANDBOX_ID_PATTERN.test(sandboxId) ? "valid" : "invalid"}`, - `legacy-ownership=${legacyIdentityMatches ? "match" : "mismatch"}`, - `running-state=${typeof state?.Running === "boolean" ? "present" : "missing"}`, - `runtime-path=${typeof resolvConfPath === "string" && path.isAbsolute(resolvConfPath) ? "absolute" : "invalid"}`, - ].join(", "); throw new Error( - `Portable demo lifecycle refused container '${containerId}' because its OpenShell identity does not match sandbox '${sandboxName}' (${checks})`, + `Portable demo lifecycle refused container '${containerId}' because its OpenShell identity does not match sandbox '${sandboxName}'`, ); } - return { containerId, sandboxId, running: state.Running, driver, resolvConfPath }; -} - -function restoreReceiptBoundPodmanRunDirectory( - inspection: PodmanContainerInspection, - env: NodeJS.ProcessEnv, -): void { - if (inspection.running) return; - const uid = process.getuid?.(); - if (!Number.isInteger(uid) || Number(uid) < 0) { - throw new Error("Portable demo lifecycle could not resolve the current user ID"); - } - const configuredRuntimeDir = env.XDG_RUNTIME_DIR?.trim(); - const runtimeDir = configuredRuntimeDir || `/run/user/${String(uid)}`; - if (!path.isAbsolute(runtimeDir) || path.normalize(runtimeDir) !== runtimeDir) { - throw new Error("Portable demo lifecycle refused an invalid Podman runtime directory"); - } - const expectedRunDirectory = path.join( - runtimeDir, - "containers", - `${inspection.driver}-containers`, - inspection.containerId, - "userdata", - ); - if (inspection.resolvConfPath !== path.join(expectedRunDirectory, "resolv.conf")) { - throw new Error( - `Portable demo lifecycle refused an unexpected runtime path for sandbox container '${inspection.containerId}'`, - ); - } - try { - const existing = fs.lstatSync(expectedRunDirectory); - if (!existing.isDirectory() || existing.isSymbolicLink()) { - throw new Error("runtime path is not a directory"); - } - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw new Error( - `Portable demo lifecycle refused the existing runtime path for sandbox container '${inspection.containerId}'`, - ); - } - } - fs.mkdirSync(expectedRunDirectory, { mode: 0o700, recursive: true }); - fs.chmodSync(expectedRunDirectory, 0o700); + return { containerId, sandboxId, running: state.Running }; } function isMissingPodmanContainer(result: CommandResult): boolean { @@ -472,336 +329,32 @@ function isMissingPodmanContainer(result: CommandResult): boolean { ); } -interface PodmanContainerQuery { - ids: string[]; - ok: boolean; -} - -function queryPodmanContainerIds( +function discoverPodmanContainer( + sandboxName: string, podman: NonNullable, - filters: readonly string[], -): PodmanContainerQuery { +): PodmanContainerInspection { const result = podman([ "ps", "-a", "--no-trunc", - ...filters.flatMap((filter) => ["--filter", filter]), + "--filter", + `label=${PODMAN_MANAGED_LABEL}=true`, + "--filter", + `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, "--format", "{{.ID}}", ]); - if (result.status !== 0 || result.error) return { ids: [], ok: false }; - return { - ids: String(result.stdout ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean), - ok: true, - }; -} - -function podmanQueryCount(result: PodmanContainerQuery): string { - return result.ok ? String(result.ids.length) : "query-error"; -} - -function discoverPodmanContainer( - sandboxName: string, - podman: NonNullable, - expectedSandboxId?: string, -): PodmanContainerInspection { - const queries = [ - queryPodmanContainerIds(podman, [ - `label=${PODMAN_MANAGED_LABEL}=true`, - `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, - ...(expectedSandboxId - ? [`label=${PODMAN_SANDBOX_ID_LABEL}=${expectedSandboxId}`] - : []), - ]), - ]; - const candidates = [...new Set(queries.flatMap((query) => query.ids))]; - const queriesSucceeded = queries.every((query) => query.ok); - const idsAreCanonical = candidates.every((id) => CONTAINER_ID_PATTERN.test(id)); - if (!queriesSucceeded || candidates.length !== 1 || !idsAreCanonical) { - throw new Error( - `Portable demo lifecycle requires one exact OpenShell 0.0.85 Podman container for sandbox '${sandboxName}'${expectedSandboxId ? ` with sandbox ID '${expectedSandboxId}'` : ""}; found ${String(candidates.length)} (matches=${queries.map(podmanQueryCount).join(",")})`, - ); - } - const inspection = inspectPodmanContainer(candidates[0]!, sandboxName, podman); - if (expectedSandboxId && inspection.sandboxId !== expectedSandboxId) { - throw new Error( - `Portable demo lifecycle refused container '${inspection.containerId}' because its OpenShell sandbox ID changed`, - ); - } - return inspection; -} - -interface OwnedStaleContainer { - containerId: string; - sandboxId: string; -} - -function inspectOwnedStaleContainer( - containerId: string, - sandboxName: string, - podman: NonNullable, - result: CommandResult = podman(["inspect", containerId]), -): OwnedStaleContainer | null { - if (isMissingPodmanContainer(result)) return null; - requireCommand(result, `Inspecting stale portable sandbox '${sandboxName}'`); - let parsed: unknown; - try { - parsed = JSON.parse(String(result.stdout ?? "")); - } catch { - throw new Error(`Inspecting stale portable sandbox '${sandboxName}' returned invalid JSON`); - } - if (!Array.isArray(parsed) || parsed.length !== 1 || !isRecord(parsed[0])) { - throw new Error(`Inspecting stale portable sandbox '${sandboxName}' returned an invalid record`); - } - const inspection = parsed[0]; - const config = isRecord(inspection.Config) ? inspection.Config : null; - const labels = config && isRecord(config.Labels) ? config.Labels : null; - const containerNameMatches = - String(inspection.Name ?? "").replace(/^\//u, "") === - `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}`; - const legacySandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; - const newerSandboxId = labels?.[STALE_NEWER_SANDBOX_ID_LABEL]; - const legacyOwned = - labels?.[PODMAN_MANAGED_LABEL] === "true" && - labels?.[PODMAN_SANDBOX_NAME_LABEL] === sandboxName && - typeof legacySandboxId === "string" && - SANDBOX_ID_PATTERN.test(legacySandboxId); - const newerOwned = - labels?.[STALE_NEWER_MANAGED_BY_LABEL] === STALE_NEWER_MANAGED_BY_VALUE && - labels?.[STALE_NEWER_SANDBOX_NAME_LABEL] === sandboxName && - typeof newerSandboxId === "string" && - SANDBOX_ID_PATTERN.test(newerSandboxId); - if ( - inspection.Id !== containerId || - !containerNameMatches || - (!legacyOwned && !newerOwned) - ) { - return null; - } - return { - containerId, - sandboxId: (legacyOwned ? legacySandboxId : newerSandboxId) as string, - }; -} - -function removeExactOwnedStaleContainer( - owned: OwnedStaleContainer, - sandboxName: string, - podman: NonNullable, -): void { - requireCommand( - podman(["rm", "-f", owned.containerId]), - `Removing stale OpenShell-owned sandbox '${sandboxName}'`, - ); - podman(["volume", "rm", `${PODMAN_SANDBOX_CONTAINER_PREFIX}${owned.sandboxId}-workspace`]); - podman(["secret", "rm", `openshell-token-${owned.sandboxId}`]); -} - -/** - * Clear only exact OpenShell-owned name conflicts before 0.0.85 creates a sandbox. - * Receipts written by newer installer attempts are deliberately retired instead - * of being migrated into the isolated 0.0.85 gateway database. - */ -export function preparePortableDemoSandboxCreation( - sandboxName: string, - env: NodeJS.ProcessEnv = process.env, - deps: PortableDemoLifecycleDeps = {}, -): void { - if (!isPortableExperimentalProfile(env)) return; - if ((deps.platform ?? process.platform) !== "linux") { - throw new Error("Portable demo lifecycle requires Linux"); - } - const commandEnv = deps.env ?? env; - const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); - const podmanEnv = localPodmanEnvironment(commandEnv); - const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); - const receipt = loadReceipt(sandboxName, stateDir); - - if ( - receipt?.schemaVersion === CURRENT_RECEIPT_SCHEMA_VERSION && - receipt.openshellVersion === PINNED_OPENSHELL_VERSION - ) { - const result = podman(["inspect", receipt.containerId]); - if (!isMissingPodmanContainer(result)) { - const inspection = inspectPodmanContainer( - receipt.containerId, - sandboxName, - podman, - result, - ); - if (inspection.sandboxId !== receipt.sandboxId) { - throw new Error( - `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, - ); - } - requireCommand( - podman(["update", "--restart=always", receipt.containerId]), - `Restoring the portable restart policy for sandbox '${sandboxName}'`, - ); - if (!inspection.running) { - restoreReceiptBoundPodmanRunDirectory(inspection, commandEnv); - requireCommand( - podman(["start", receipt.containerId]), - `Starting portable sandbox '${sandboxName}'`, - ); - } - return; - } - removeReceipt(sandboxName, stateDir); - } else if (receipt) { - const result = podman(["inspect", receipt.containerId]); - const owned = inspectOwnedStaleContainer( - receipt.containerId, - sandboxName, - podman, - result, - ); - if (owned) { - removeExactOwnedStaleContainer(owned, sandboxName, podman); - } else if (!isMissingPodmanContainer(result)) { - throw new Error( - `Portable demo lifecycle refused to remove receipt-bound container '${receipt.containerId}' because it is not an exact OpenShell-owned '${sandboxName}' sandbox`, - ); - } - removeReceipt(sandboxName, stateDir); - } - - const queries = [ - queryPodmanContainerIds(podman, [ - `label=${PODMAN_MANAGED_LABEL}=true`, - `label=${PODMAN_SANDBOX_NAME_LABEL}=${sandboxName}`, - ]), - queryPodmanContainerIds(podman, [ - `label=${STALE_NEWER_MANAGED_BY_LABEL}=${STALE_NEWER_MANAGED_BY_VALUE}`, - `label=${STALE_NEWER_SANDBOX_NAME_LABEL}=${sandboxName}`, - ]), - ]; - if (!queries.every((query) => query.ok)) { - throw new Error(`Could not inspect stale OpenShell-owned sandbox '${sandboxName}'`); - } - const candidates = [...new Set(queries.flatMap((query) => query.ids))]; - for (const containerId of candidates) { - if (!CONTAINER_ID_PATTERN.test(containerId)) { - throw new Error(`OpenShell returned an invalid container ID for sandbox '${sandboxName}'`); - } - const owned = inspectOwnedStaleContainer(containerId, sandboxName, podman); - if (!owned) { - throw new Error( - `Portable demo lifecycle refused to remove container '${containerId}' because its ownership could not be proven`, - ); - } - removeExactOwnedStaleContainer(owned, sandboxName, podman); - } -} - -function podmanSocketPath( - podman: NonNullable, - env: NodeJS.ProcessEnv, -): string { - const result = podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env); - requireCommand(result, "Resolving the portable Podman socket"); - const socket = String(result.stdout ?? "").trim(); - if (/[\u0000-\u001f\u007f-\u009f]/u.test(socket)) { - throw new Error("The portable Podman socket path is invalid"); - } - const socketPath = socket.startsWith("unix://") ? socket.slice("unix://".length) : socket; - if (!path.posix.isAbsolute(socketPath)) { - throw new Error("The portable Podman socket path is invalid"); - } - return socketPath; -} - -function podmanCapture( - podman: NonNullable, - env: NodeJS.ProcessEnv, -): ContainerEngineCommandCapture { - return (_executable, args) => { - const result = podman(args, env); - return { - status: result.status ?? 1, - stdout: String(result.stdout ?? ""), - stderr: String(result.stderr ?? ""), - ...(result.error ? { error: result.error } : {}), - }; - }; -} - -function qualifiedPodmanAuthority(commandEnv: NodeJS.ProcessEnv, deps: PortableDemoLifecycleDeps) { - const podman = deps.podman ?? ((args, env = commandEnv) => defaultPodman(args, env)); - const podmanEnv = localPodmanEnvironment(commandEnv); - const socketPath = podmanSocketPath(podman, podmanEnv); - (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath); - const socketAuthority = capturePodmanSocketAuthority(socketPath, deps.podmanSocketAuthorityDeps); - const provider = createPodmanContainerEngine({ - operation: "sandbox-lifecycle", - socketAuthority, - authorityDeps: deps.podmanSocketAuthorityDeps, - ...(deps.podman ? { capture: podmanCapture(podman, podmanEnv) } : {}), - }); - return { - assertRuntimeAuthority: () => - assertPodmanSocketAuthority(socketAuthority, deps.podmanSocketAuthorityDeps), - dockerHost: `unix://${socketAuthority.socketPath}`, - podman: (args: readonly string[]) => provider.capture(args, COMMAND_TIMEOUT_MS), - }; -} - -function requireReceiptOwnedInspection( - receipt: PortableDemoLifecycleReceipt, - inspection: PodmanContainerInspection, -): void { - if (inspection.containerId !== receipt.containerId) { - throw new Error( - `Portable demo lifecycle refused container '${inspection.containerId}' because the recorded container identity changed`, - ); - } - if (inspection.sandboxId !== receipt.sandboxId) { - throw new Error( - `Portable demo lifecycle refused container '${receipt.containerId}' because its OpenShell sandbox ID changed`, - ); - } -} - -function requirePinnedReceipt(receipt: PortableDemoLifecycleReceipt): void { - if ( - receipt.schemaVersion !== CURRENT_RECEIPT_SCHEMA_VERSION || - receipt.openshellVersion !== PINNED_OPENSHELL_VERSION - ) { + requireCommand(result, `Finding portable sandbox '${sandboxName}'`); + const matches = String(result.stdout ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + if (matches.length !== 1 || !CONTAINER_ID_PATTERN.test(matches[0] ?? "")) { throw new Error( - `Portable sandbox '${receipt.sandboxName}' has lifecycle state from a different OpenShell stack; rerun the pinned 0.0.85 installer`, + `Portable demo lifecycle requires one exact Podman container for sandbox '${sandboxName}'; found ${matches.length}`, ); } -} - -/** Resolve the receipt-owned portable container for a host-side privileged exec. */ -export function resolvePortableDemoPrivilegedExecTarget( - sandboxName: string, - deps: PortableDemoLifecycleDeps = {}, -): PortableDemoPrivilegedExecTarget | null { - const commandEnv = deps.env ?? process.env; - const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); - const receipt = loadReceipt(sandboxName, stateDir); - if (!receipt) return null; - if ((deps.platform ?? process.platform) !== "linux") { - throw new Error("Portable demo lifecycle receipt is only valid on Linux"); - } - requirePinnedReceipt(receipt); - requireCurrentRegistryGeneration(receipt, deps.registryGeneration); - const authority = qualifiedPodmanAuthority(commandEnv, deps); - const inspection = inspectPodmanContainer(receipt.containerId, sandboxName, authority.podman); - requireReceiptOwnedInspection(receipt, inspection); - if (!inspection.running) { - throw new Error(`Portable sandbox '${sandboxName}' is not running`); - } - authority.assertRuntimeAuthority(); - return { - assertRuntimeAuthority: authority.assertRuntimeAuthority, - containerId: inspection.containerId, - dockerHost: authority.dockerHost, - }; + return inspectPodmanContainer(matches[0]!, sandboxName, podman); } function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { @@ -1051,50 +604,32 @@ export function installPortableDemoSandboxLifecycle( createdStartupArgv: readonly string[], env: NodeJS.ProcessEnv = process.env, deps: PortableDemoLifecycleDeps = {}, -): string | null { - const stateDir = deps.stateDir ?? defaultStateDir(env); +): void { + if (!isPortableExperimentalProfile(env)) return; if ( - !isPortableExperimentalProfile(env) || createdStartupArgv[createdStartupArgv.length - 1] !== "/usr/local/bin/nemoclaw-start" || startupEnvValue(createdStartupArgv, "OPENCLAW_HOME") === null ) { - removeReceipt(sandboxName, stateDir); - return null; + return; } if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle requires Linux"); } const commandEnv = deps.env ?? env; - const podmanEnv = localPodmanEnvironment(commandEnv); - const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); + const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); const inspection = discoverPodmanContainer(sandboxName, podman); - const registryGeneration = deps.registryGeneration ?? inspection.containerId; - if (!SANDBOX_ID_PATTERN.test(registryGeneration)) { - throw new Error("Portable demo lifecycle registry generation is invalid"); - } const receipt: PortableDemoLifecycleReceipt = { schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, - openshellVersion: PINNED_OPENSHELL_VERSION, sandboxName, sandboxId: inspection.sandboxId, containerId: inspection.containerId, dashboardPort: parseDashboardPort(createdStartupArgv, sandboxName), - registryGeneration, }; requireCommand( - podman(["update", "--restart=always", inspection.containerId]), + podman(["update", "--restart=unless-stopped", inspection.containerId]), `Setting the portable restart policy for sandbox '${sandboxName}'`, ); - writeReceipt(receipt, stateDir); - return registryGeneration; -} - -/** Retire portable lifecycle authority only after its sandbox registry entry is removed. */ -export function removePortableDemoSandboxLifecycleReceipt( - sandboxName: string, - stateDir = defaultStateDir(process.env), -): void { - removeReceipt(sandboxName, stateDir); + writeReceipt(receipt, deps.stateDir ?? defaultStateDir(env)); } /** @@ -1107,24 +642,19 @@ export function recoverPortableDemoSandboxLifecycle( deps: PortableDemoLifecycleDeps = {}, ): PortableDemoLifecycleRecoveryResult { if ((context.agent ?? "openclaw") !== "openclaw") return { kind: "not-installed" }; - if (context.openshellDriver !== "docker") return { kind: "not-installed" }; const commandEnv = deps.env ?? process.env; - const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); - const receipt = loadReceipt(sandboxName, stateDir); + const receipt = loadReceipt(sandboxName, deps.stateDir ?? defaultStateDir(commandEnv)); if (!receipt) return { kind: "not-installed" }; if ((deps.platform ?? process.platform) !== "linux") { throw new Error("Portable demo lifecycle receipt is only valid on Linux"); } - requirePinnedReceipt(receipt); - requireCurrentRegistryGeneration(receipt, context.lifecycleGeneration); - deps.ensureGateway?.(); - const podmanEnv = localPodmanEnvironment(commandEnv); - const podman = deps.podman ?? ((args) => defaultPodman(args, podmanEnv)); + + const podman = deps.podman ?? ((args) => defaultPodman(args, commandEnv)); + const stateDir = deps.stateDir ?? defaultStateDir(commandEnv); const initialInspection = podman(["inspect", receipt.containerId]); if (isMissingPodmanContainer(initialInspection)) { - throw new Error( - `Portable sandbox '${sandboxName}' recorded OpenShell 0.0.85 container '${receipt.containerId}' is absent; its lifecycle receipt was preserved`, - ); + removeReceipt(sandboxName, stateDir); + return { kind: "not-installed" }; } let inspection = inspectPodmanContainer( receipt.containerId, @@ -1138,7 +668,6 @@ export function recoverPortableDemoSandboxLifecycle( ); } if (!inspection.running) { - restoreReceiptBoundPodmanRunDirectory(inspection, commandEnv); requireCommand( podman(["start", receipt.containerId]), `Starting portable sandbox '${sandboxName}'`, @@ -1167,7 +696,7 @@ export function recoverPortableDemoSandboxLifecycle( } recoverManagedOllama(context, commandEnv, stateDir, timing, deps); const gatewayRunning = gatewayIsRunning(receipt, gatewayName, capture, PROBE_TIMEOUT_MS); - const refreshStartup = receipt.schemaVersion === 1; + const refreshStartup = receipt.schemaVersion < CURRENT_RECEIPT_SCHEMA_VERSION; if (!refreshStartup && gatewayRunning) { return { kind: "already-running" }; } @@ -1236,11 +765,7 @@ export function recoverPortableDemoSandboxLifecycle( } if (refreshStartup) { writeReceipt( - { - ...receipt, - schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION, - registryGeneration: context.lifecycleGeneration ?? receipt.containerId, - }, + { ...receipt, schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION }, deps.stateDir ?? defaultStateDir(commandEnv), ); } diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index 858d777d44a..4817c16b435 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -47,11 +47,7 @@ describe("preparePortableExperimentalHost", () => { .mockReturnValueOnce(result(1)) // inspect: registry not present yet .mockReturnValueOnce(result()); // run const podman = vi.fn(() => result(0, "/run/user/1001/custom/podman.sock\n")); - const hardenSocketDirectory = vi.fn(); const env: NodeJS.ProcessEnv = { - CONTAINER_CONNECTION: "attacker", - CONTAINER_HOST: "tcp://example.test:1234", - CONTAINER_SSHKEY: "/tmp/attacker-key", NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", }; @@ -62,7 +58,6 @@ describe("preparePortableExperimentalHost", () => { systemctl, podman, docker, - hardenSocketDirectory, }); expect(env).toMatchObject({ @@ -80,22 +75,7 @@ describe("preparePortableExperimentalHost", () => { ["--user", "try-restart", "podman.service"], ["--user", "enable", "--now", "podman.socket"], ]); - expect(podman).toHaveBeenCalledWith( - ["info", "--format", "{{.Host.RemoteSocket.Path}}"], - expect.not.objectContaining({ - CONTAINER_CONNECTION: expect.anything(), - CONTAINER_HOST: expect.anything(), - CONTAINER_SSHKEY: expect.anything(), - }), - ); - for (const [, commandEnv] of docker.mock.calls) { - expect(commandEnv).not.toHaveProperty("CONTAINER_CONNECTION"); - expect(commandEnv).not.toHaveProperty("CONTAINER_HOST"); - expect(commandEnv).not.toHaveProperty("CONTAINER_SSHKEY"); - expect(commandEnv.DOCKER_HOST).toBe("unix:///run/user/1001/custom/podman.sock"); - } - expect(env.CONTAINER_HOST).toBe("tcp://example.test:1234"); - expect(hardenSocketDirectory).toHaveBeenCalledWith("/run/user/1001/custom/podman.sock", 1001); + expect(podman).toHaveBeenCalledWith(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env); expect(docker.mock.calls[0]?.[0]).toEqual(["--version"]); expect(docker.mock.calls[2]?.[0]).toEqual([ "run", @@ -138,15 +118,7 @@ describe("preparePortableExperimentalHost", () => { preparePortableExperimentalHost( { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { - platform: "linux", - home, - uid: 1001, - systemctl, - podman, - docker, - hardenSocketDirectory: vi.fn(), - }, + { platform: "linux", home, uid: 1001, systemctl, podman, docker }, ); const dropIn = path.join( @@ -172,7 +144,6 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker: () => result(0, "unexpected-owner"), - hardenSocketDirectory: vi.fn(), }), ).toThrow(/unmanaged container/); }); @@ -206,7 +177,6 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker, - hardenSocketDirectory: vi.fn(), }, ), ).toThrow(/Inspecting the managed portable registry failed: registry inspection timed out/); @@ -259,7 +229,6 @@ describe("preparePortableExperimentalHost", () => { systemctl: () => result(), podman: () => result(0, "/run/user/1001/podman/podman.sock"), docker, - hardenSocketDirectory: vi.fn(), }; expect(() => preparePortableExperimentalHost(env, deps)).toThrow(/podman-docker/); diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 9d01e8799a8..aa7ef066030 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -7,7 +7,6 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; -import { hardenPodmanSocketDirectory, localPodmanEnvironment } from "../../adapters/podman"; import { ensureConfigDir } from "../../state/config-io"; import { isPortableExperimentalProfile, PORTABLE_LOCAL_REGISTRY } from "../docker-driver-platform"; @@ -28,11 +27,6 @@ firewall_driver = "iptables" [engine] env = ["NETAVARK_FW=iptables"] `; -const PORTABLE_GATEWAY_SYSTEMD_DROP_IN = `[Unit] -Requires=podman.socket -After=podman.socket -Before=podman-restart.service -`; type SpawnResult = ReturnType; @@ -43,7 +37,6 @@ export interface PortableHostPreparationDeps { systemctl?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; podman?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; docker?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; - hardenSocketDirectory?: (socketPath: string, uid: number) => void; } function commandDetail(result: SpawnResult): string { @@ -126,16 +119,6 @@ function writePortableRuntimeConfig(home: string, env: NodeJS.ProcessEnv): strin // The OpenShell gateway service and sandbox prebuild read this file through CONTAINERS_CONF. const containersConf = path.join(configHome, "nemoclaw", "portable", "containers.conf"); writePrivateConfig(containersConf, PORTABLE_CONTAINERS_CONF); - writePrivateConfig( - path.join( - configHome, - "systemd", - "user", - "nemoclaw-openshell-gateway.service.d", - "portable.conf", - ), - PORTABLE_GATEWAY_SYSTEMD_DROP_IN, - ); return containersConf; } @@ -212,10 +195,6 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: HOST_COMMAND_TIMEOUT_MS, })); - requireCommand( - systemctl(["--user", "daemon-reload"], env), - "Reloading the portable user services", - ); requireCommand( systemctl( [ @@ -236,10 +215,6 @@ export function preparePortableExperimentalHost( systemctl(["--user", "enable", "--now", "podman.socket"], env), "Starting the rootless container socket", ); - requireCommand( - systemctl(["--user", "enable", "podman-restart.service"], env), - "Enabling rootless container restart after login", - ); const podman = deps.podman ?? @@ -249,14 +224,9 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: HOST_COMMAND_TIMEOUT_MS, })); - const podmanEnv = localPodmanEnvironment(env); - const dockerHost = resolvePodmanDockerHost( - podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], podmanEnv), + env.DOCKER_HOST = resolvePodmanDockerHost( + podman(["info", "--format", "{{.Host.RemoteSocket.Path}}"], env), ); - const socketPath = dockerHost.slice("unix://".length); - (deps.hardenSocketDirectory ?? hardenPodmanSocketDirectory)(socketPath, Number(uid)); - env.DOCKER_HOST = dockerHost; - podmanEnv.DOCKER_HOST = dockerHost; const docker = deps.docker ?? @@ -266,8 +236,8 @@ export function preparePortableExperimentalHost( env: childEnv, timeout: REGISTRY_COMMAND_TIMEOUT_MS, })); - requireDockerCompatibleCli(docker, podmanEnv); - ensureRegistryContainer(podmanEnv, docker); + requireDockerCompatibleCli(docker, env); + ensureRegistryContainer(env, docker); } export const portableHostPreparationInternals = { @@ -275,6 +245,5 @@ export const portableHostPreparationInternals = { REGISTRY_IMAGE, REGISTRY_FRAGMENT, PORTABLE_CONTAINERS_CONF, - PORTABLE_GATEWAY_SYSTEMD_DROP_IN, resolvePodmanDockerHost, }; diff --git a/src/lib/onboard/forward-start.ts b/src/lib/onboard/forward-start.ts index deb07405148..0bad39b9b18 100644 --- a/src/lib/onboard/forward-start.ts +++ b/src/lib/onboard/forward-start.ts @@ -110,8 +110,8 @@ export function looksLikeUntrackedForward(diagnostic: string): boolean { * (#6099). * * Compatibility boundary: these exact diagnostics are emitted by the pinned - * OpenShell 0.0.101 forward-start path tracked in #7266. Reassess this matcher - * when NemoClaw's supported OpenShell range moves beyond 0.0.101, and remove it + * OpenShell 0.0.85 forward-start path tracked in #7266. Reassess this matcher + * when NemoClaw's supported OpenShell range moves beyond 0.0.85, and remove it * once OpenShell either keeps the attempt alive until the listener is ready or * exposes a structured retryable outcome. Keep the fragments narrow so an * unrelated SSH or gateway failure cannot enter the listener-retry path. diff --git a/src/lib/onboard/gateway-binding.ts b/src/lib/onboard/gateway-binding.ts index f7846949056..2b197b9b0ac 100644 --- a/src/lib/onboard/gateway-binding.ts +++ b/src/lib/onboard/gateway-binding.ts @@ -25,9 +25,7 @@ import type { GatewayReuseState } from "../state/gateway"; /** Gateway registration name used for the default gateway port. */ export const BASE_GATEWAY_NAME = "nemoclaw"; /** Docker-driver gateway state directory leaf name for the default port. */ -// OpenShell gateway schemas are not downgrade-compatible. The pinned 0.0.85 -// stack must never open state written by 0.0.99/0.0.101 installer attempts. -export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway-v0.0.85"; +export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway-v0.0.85-pr8578"; /** Docker-driver gateway compatibility container name for the default port. */ export const BASE_GATEWAY_COMPAT_CONTAINER_NAME = "nemoclaw-openshell-gateway"; diff --git a/src/lib/onboard/gateway-reuse.test.ts b/src/lib/onboard/gateway-reuse.test.ts index a8a798829c2..36ec82d6248 100644 --- a/src/lib/onboard/gateway-reuse.test.ts +++ b/src/lib/onboard/gateway-reuse.test.ts @@ -4,11 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; -import { - createDockerDriverGatewayReuseApplication, - type DockerDriverGatewayReuseApplicationDeps, - createGatewayReuseHelpers, -} from "./gateway-reuse"; +import { createGatewayReuseHelpers } from "./gateway-reuse"; describe("gateway reuse snapshot", () => { it("bounds OpenShell gateway inspection probes (#6752)", () => { @@ -88,128 +84,3 @@ describe("gateway reuse snapshot", () => { expect(helpers.getGatewayReuseSnapshot().gatewayReuseState).toBe("missing"); }); }); - -function createDockerDriverReuseApplication( - overrides: Partial = {}, -) { - return createDockerDriverGatewayReuseApplication({ - gatewayName: () => "nemoclaw", - getGatewayCompatContainerName: () => "openshell-gateway-nemoclaw", - isDockerDriverGatewayEnabled: () => true, - resolveOpenShellGatewayBinary: () => "/opt/openshell-gateway", - getDockerDriverGatewayEnv: () => ({ OPENSHELL_DRIVERS: "docker" }), - runCaptureOpenshell: vi.fn(() => "openshell 0.0.99"), - getDockerDriverGatewayStateDir: () => "/tmp/nemoclaw-gateway", - resolveOpenShellSandboxBinary: () => "/opt/openshell-sandbox", - getDockerDriverGatewayPid: () => 42, - isDockerDriverGatewayProcessAlive: () => true, - getDockerDriverGatewayReuseDrift: vi.fn(() => null), - checkGatewayPortAvailable: vi.fn(async () => ({ ok: true })), - getDockerDriverGatewayPortListenerPid: vi.fn(() => null), - rememberDockerDriverGatewayPid: vi.fn(), - buildDockerDriverGatewayRuntimeIdentity: vi.fn(() => ({ - launch: null, - desiredEnv: { OPENSHELL_DRIVERS: "docker" }, - driftGatewayBin: "/opt/openshell-gateway", - identityGatewayBin: "/opt/openshell-gateway", - })), - resolveDriftGatewayBin: vi.fn((runtimeIdentity, gatewayBin) => - runtimeIdentity ? runtimeIdentity.driftGatewayBin : gatewayBin, - ), - getTrustedActiveOpenShellGatewayUserServicePid: vi.fn(() => null), - log: vi.fn(), - ...overrides, - }); -} - -describe("Docker-driver gateway reuse application", () => { - it("keeps reuse state unchanged when Docker-driver inspection does not apply (#7695)", async () => { - const isDockerDriverGatewayEnabled = vi.fn(() => false); - const checkGatewayPortAvailable = vi.fn(async () => ({ ok: true })); - const application = createDockerDriverReuseApplication({ - isDockerDriverGatewayEnabled, - checkGatewayPortAvailable, - }); - - await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( - "healthy", - ); - isDockerDriverGatewayEnabled.mockReturnValue(true); - await expect(application.refreshDockerDriverGatewayReuseState("stale")).resolves.toBe("stale"); - expect(checkGatewayPortAvailable).not.toHaveBeenCalled(); - }); - - it("marks a running Docker-driver gateway stale when runtime identity drifts", async () => { - const log = vi.fn(); - const checkGatewayPortAvailable = vi.fn(async () => ({ ok: true })); - const application = createDockerDriverReuseApplication({ - getDockerDriverGatewayReuseDrift: vi.fn(() => ({ - reason: "runtime environment changed", - })), - checkGatewayPortAvailable, - log, - }); - - await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( - "stale", - ); - expect(checkGatewayPortAvailable).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - " Existing OpenShell Docker-driver gateway is stale (runtime environment changed); it will be recreated.", - ); - }); - - it("adopts a matching gateway port listener when the PID file is absent", async () => { - const rememberDockerDriverGatewayPid = vi.fn(); - const getDockerDriverGatewayReuseDrift = vi.fn(() => null); - const application = createDockerDriverReuseApplication({ - getDockerDriverGatewayPid: () => null, - isDockerDriverGatewayProcessAlive: () => false, - checkGatewayPortAvailable: vi.fn(async () => ({ ok: false, pid: 731 })), - getDockerDriverGatewayPortListenerPid: vi.fn(() => 731), - getDockerDriverGatewayReuseDrift, - getTrustedActiveOpenShellGatewayUserServicePid: vi.fn(() => 900), - rememberDockerDriverGatewayPid, - }); - - await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( - "healthy", - ); - expect(getDockerDriverGatewayReuseDrift).toHaveBeenCalledWith( - 731, - { OPENSHELL_DRIVERS: "docker" }, - "/opt/openshell-gateway", - 900, - ); - expect(rememberDockerDriverGatewayPid).toHaveBeenCalledWith(731); - }); - - it("preserves a reachable selected gateway when the port owner is ambiguous", async () => { - const rememberDockerDriverGatewayPid = vi.fn(); - const application = createDockerDriverReuseApplication({ - getDockerDriverGatewayPid: () => null, - isDockerDriverGatewayProcessAlive: () => false, - checkGatewayPortAvailable: vi.fn(async () => ({ ok: false, pid: null })), - getDockerDriverGatewayPortListenerPid: vi.fn(() => null), - rememberDockerDriverGatewayPid, - }); - - await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( - "healthy", - ); - expect(rememberDockerDriverGatewayPid).not.toHaveBeenCalled(); - }); - - it("marks a gateway stale when no Docker-driver process owns the available port", async () => { - const application = createDockerDriverReuseApplication({ - getDockerDriverGatewayPid: () => null, - isDockerDriverGatewayProcessAlive: () => false, - checkGatewayPortAvailable: vi.fn(async () => ({ ok: true })), - getDockerDriverGatewayPortListenerPid: vi.fn(() => null), - }); - - await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( - "stale", - ); - }); -}); diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index 4e6987954a0..076c3d8544b 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -2,14 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; -import { - getGatewayReuseState, - type GatewayReuseState, - shouldSelectNamedGatewayForReuse, -} from "../state/gateway"; -import * as dockerDriverGatewayLaunch from "./docker-driver-gateway-launch"; -import * as gatewayService from "./docker-driver-gateway-service"; -import type { PortProbeResult } from "./preflight"; +import { getGatewayReuseState, shouldSelectNamedGatewayForReuse } from "../state/gateway"; export type GatewayReuseSnapshot = { gatewayStatus: string; @@ -30,125 +23,6 @@ export interface GatewayReuseHelpers { selectNamedGatewayForReuseIfNeeded(snapshot: GatewayReuseSnapshot): GatewayReuseSnapshot; } -export interface DockerDriverGatewayReuseApplicationDeps { - gatewayName(): string; - getGatewayCompatContainerName(): string; - isDockerDriverGatewayEnabled(): boolean; - resolveOpenShellGatewayBinary(): string | null; - getDockerDriverGatewayEnv(versionOutput?: string | null): Record; - runCaptureOpenshell(args: string[], opts?: { ignoreError?: boolean }): string; - getDockerDriverGatewayStateDir(): string; - resolveOpenShellSandboxBinary(): string | null; - getDockerDriverGatewayPid(): number | null; - isDockerDriverGatewayProcessAlive(): boolean; - getDockerDriverGatewayReuseDrift( - pid: number, - desiredEnv: Record, - gatewayBin?: string | null, - trustedServicePid?: number | null, - ): { reason: string } | null; - checkGatewayPortAvailable(): Promise; - getDockerDriverGatewayPortListenerPid( - portCheck: PortProbeResult, - opts?: { gatewayBin?: string | null }, - ): number | null; - rememberDockerDriverGatewayPid(pid: number): void; - buildDockerDriverGatewayRuntimeIdentity?: typeof dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity; - resolveDriftGatewayBin?: typeof dockerDriverGatewayLaunch.resolveDriftGatewayBin; - getTrustedActiveOpenShellGatewayUserServicePid?: typeof gatewayService.getTrustedActiveOpenShellGatewayUserServicePid; - log?(message: string): void; -} - -export interface DockerDriverGatewayReuseApplication { - refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; -} - -export function createDockerDriverGatewayReuseApplication( - deps: DockerDriverGatewayReuseApplicationDeps, -): DockerDriverGatewayReuseApplication { - const buildRuntimeIdentity = - deps.buildDockerDriverGatewayRuntimeIdentity ?? - dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity; - const resolveDriftGatewayBin = - deps.resolveDriftGatewayBin ?? dockerDriverGatewayLaunch.resolveDriftGatewayBin; - const getTrustedServicePid = - deps.getTrustedActiveOpenShellGatewayUserServicePid ?? - gatewayService.getTrustedActiveOpenShellGatewayUserServicePid; - const log = deps.log ?? console.log; - - async function refreshDockerDriverGatewayReuseState( - state: GatewayReuseState, - ): Promise { - if (!deps.isDockerDriverGatewayEnabled() || state !== "healthy") return state; - - const gatewayBin = deps.resolveOpenShellGatewayBinary(); - const baseDesiredEnv = deps.getDockerDriverGatewayEnv( - deps.runCaptureOpenshell(["--version"], { ignoreError: true }), - ); - const runtimeIdentity = gatewayBin - ? buildRuntimeIdentity({ - gatewayBin, - gatewayEnv: baseDesiredEnv, - stateDir: deps.getDockerDriverGatewayStateDir(), - sandboxBin: deps.resolveOpenShellSandboxBinary(), - gatewayName: deps.gatewayName(), - compatContainerName: deps.getGatewayCompatContainerName(), - }) - : null; - const desiredEnv = runtimeIdentity?.desiredEnv ?? baseDesiredEnv; - const driftBin = resolveDriftGatewayBin(runtimeIdentity, gatewayBin); - const identityBin = runtimeIdentity?.identityGatewayBin ?? gatewayBin; - const managedServicePid = getTrustedServicePid(); - const pid = deps.getDockerDriverGatewayPid(); - if (pid !== null && deps.isDockerDriverGatewayProcessAlive()) { - const drift = deps.getDockerDriverGatewayReuseDrift( - pid, - desiredEnv, - driftBin, - managedServicePid, - ); - if (drift) { - log( - ` Existing OpenShell Docker-driver gateway is stale (${drift.reason}); it will be recreated.`, - ); - return "stale"; - } - return state; - } - - const portCheck = await deps.checkGatewayPortAvailable(); - const dockerGatewayPid = deps.getDockerDriverGatewayPortListenerPid(portCheck, { - gatewayBin: identityBin, - }); - if (dockerGatewayPid !== null) { - const drift = deps.getDockerDriverGatewayReuseDrift( - dockerGatewayPid, - desiredEnv, - driftBin, - managedServicePid, - ); - if (dockerGatewayPid !== managedServicePid) { - deps.rememberDockerDriverGatewayPid(dockerGatewayPid); - } - if (drift) { - log( - ` Existing OpenShell Docker-driver gateway is stale (${drift.reason}); it will be recreated.`, - ); - return "stale"; - } - return "healthy"; - } - - // OpenShell status already proved the selected gateway is reachable. Preserve it when - // the port probe cannot identify an owner, instead of deleting a potentially live gateway. - if (!portCheck.ok && !portCheck.pid) return "healthy"; - - return "stale"; - } - - return { refreshDockerDriverGatewayReuseState }; -} - export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseHelpers { const currentGatewayName = () => typeof deps.gatewayName === "function" ? deps.gatewayName() : deps.gatewayName; diff --git a/src/lib/onboard/host-gateway-process.ts b/src/lib/onboard/host-gateway-process.ts index 80b60653148..4c27ae1ed38 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -6,8 +6,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; - import { waitUntil } from "../core/wait"; import { clearDockerDriverGatewayRuntimeMarker } from "./docker-driver-gateway-runtime-marker"; import { @@ -110,7 +108,13 @@ export function resolveDockerDriverGatewayStateDir( ): string { const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; if (configured && configured.trim()) return path.resolve(configured.trim()); - return path.join(homeDir, ".local", "state", "nemoclaw", BASE_GATEWAY_STATE_DIR_NAME); + return path.join( + homeDir, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway-v0.0.85-pr8578", + ); } export function resolveDockerDriverGatewayPidFile( diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index b8d14c77bf1..80f728093cb 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -304,10 +304,7 @@ describe("inference selection validation", () => { ok: true, api: intendedApi, pinnedAddresses: ["10.0.0.8"], - trustedPrivateCapability: { - host: "anthropic.corp.example", - addresses: ["10.0.0.8"], - }, + trustedPrivateCapability: { addresses: ["10.0.0.8"] }, }); expect(probeEndpoint).toHaveBeenCalledOnce(); expect(probeEndpoint).toHaveBeenCalledWith( diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index d9a53347050..1bc1fd82803 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -51,7 +51,7 @@ export type EndpointValidationResult = retry?: undefined; /** Public addresses approved for this custom endpoint's host probes. */ pinnedAddresses?: string[]; - /** Non-forgeable proof of the exact host and complete pins admitted by the operator allowlist. */ + /** Non-forgeable proof of the exact private subset admitted by the operator allowlist. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index e07f584e2e2..5145fcc6b55 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -18,12 +18,8 @@ type PolicyRule = { type PolicyEndpoint = { host?: string; - port?: number; - access?: string; protocol?: string; - enforcement?: string; tls?: string; - allowed_ips?: string[]; request_body_credential_rewrite?: boolean; rules?: PolicyRule[]; }; @@ -331,72 +327,4 @@ describe("initial sandbox policy real preset merge", () => { } } }); - - it("keeps the Restricted OpenClaw npm baseline inspected and GET-only (#8497)", () => { - const baselinePath = repoPath("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); - const reviewed = YAML.parse(fs.readFileSync(baselinePath, "utf-8")) as PolicyDocument; - const effective = readPreparedPolicy( - prepareInitialSandboxCreatePolicy(baselinePath, [], { - agentName: "openclaw", - policyTier: "restricted", - }), - ); - - expect(effective.network_policies?.npm_registry).toEqual( - reviewed.network_policies?.npm_registry, - ); - const endpoint = effective.network_policies?.npm_registry?.endpoints?.[0]; - expect(endpoint).toMatchObject({ protocol: "rest", enforcement: "enforce" }); - expect(endpoint).not.toHaveProperty("access"); - expect(endpoint?.rules?.map((rule) => rule.allow)).toEqual([{ method: "GET", path: "/**" }]); - }); - - it("composes default OpenClaw package and pricing routes without v0.0.99 ambiguity (#8497)", () => { - const effective = readPreparedPolicy( - prepareInitialSandboxCreatePolicy( - repoPath("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), - [], - { - agentName: "openclaw", - policyTier: "balanced", - additionalPresets: ["npm", "brew", "openclaw-pricing"], - }, - ), - ); - const endpoint = (policyName: string, host: string): PolicyEndpoint => { - const match = effective.network_policies?.[policyName]?.endpoints?.find( - (candidate) => candidate.host === host, - ); - expect(match, `${policyName}:${host}`).toBeDefined(); - return match ?? {}; - }; - const connectionMetadata = (candidate: PolicyEndpoint) => ({ - tls: candidate.tls ?? "auto", - allowedIps: [...(candidate.allowed_ips ?? [])].sort(), - }); - const requestMetadata = (candidate: PolicyEndpoint) => ({ - protocol: candidate.protocol ?? "", - enforcement: candidate.enforcement ?? "audit", - }); - - const baselineNpm = endpoint("npm_registry", "registry.npmjs.org"); - const presetNpm = endpoint("npm_yarn", "registry.npmjs.org"); - expect(connectionMetadata(baselineNpm)).toEqual(connectionMetadata(presetNpm)); - expect(requestMetadata(baselineNpm)).toEqual(requestMetadata(presetNpm)); - expect(baselineNpm).toMatchObject({ access: "full", tls: "skip" }); - expect(baselineNpm).not.toHaveProperty("protocol"); - expect(baselineNpm).not.toHaveProperty("rules"); - expect(effective.network_policies?.npm_registry?.binaries).toEqual([ - { path: "/usr/local/bin/openclaw" }, - ]); - - const brewRaw = endpoint("brew", "raw.githubusercontent.com"); - const pricingRaw = endpoint("openclaw-pricing", "raw.githubusercontent.com"); - expect(connectionMetadata(brewRaw)).toEqual(connectionMetadata(pricingRaw)); - expect(brewRaw).not.toHaveProperty("protocol"); - expect(pricingRaw).toMatchObject({ protocol: "rest", enforcement: "enforce" }); - expect(effective.network_policies?.brew?.binaries).not.toEqual( - expect.arrayContaining([{ path: "/usr/local/bin/node" }, { path: "/usr/bin/node" }]), - ); - }); }); diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index 048f05c8eb7..6e0bae480f7 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -59,7 +59,7 @@ export interface ProviderInferenceSetupOptions { endpointPinnedAddresses?: readonly string[]; /** Durable route provenance to preserve when reserving a refreshed route. */ endpointSource?: InferenceEndpointSource | null; - /** Non-forgeable proof of the exact host and complete pins admitted by the custom preflight. */ + /** Non-forgeable proof of the exact private subset admitted by the custom preflight. */ endpointTrustedPrivateCapability?: TrustedPrivateEndpointCapability; /** One-shot host capability cache carried only through this onboarding run. */ inferenceCapabilityCache?: OnboardInferenceCapabilityCache; diff --git a/src/lib/onboard/managed-bootstrap/docker-test-fixture.ts b/src/lib/onboard/managed-bootstrap/docker-test-fixture.ts index bc51809b865..92b6f26c091 100644 --- a/src/lib/onboard/managed-bootstrap/docker-test-fixture.ts +++ b/src/lib/onboard/managed-bootstrap/docker-test-fixture.ts @@ -44,7 +44,7 @@ const CONFIG_ID = `sha256:${"4".repeat(64)}`; const MANIFEST = `sha256:${"5".repeat(64)}` as const; const REPOSITORY = "registry.example/nemoclaw/hermes"; const IMAGE = `${REPOSITORY}@${MANIFEST}`; -const SUPERVISOR = ["/opt/openshell/bin/openshell-sandbox", "--workdir", "/sandbox"] as const; +const SUPERVISOR = ["/opt/openshell/bin/openshell-sandbox", "supervise"] as const; export const SUPPORTED_AGENTS = ["openclaw", "hermes", "langchain-deepagents-code"] as const; type FixtureCommandResult = { diff --git a/src/lib/onboard/managed-bootstrap/docker.test.ts b/src/lib/onboard/managed-bootstrap/docker.test.ts index 1007418f30f..79f2ab3110d 100644 --- a/src/lib/onboard/managed-bootstrap/docker.test.ts +++ b/src/lib/onboard/managed-bootstrap/docker.test.ts @@ -49,41 +49,6 @@ describe("Docker managed bootstrap adapter", () => { }); }); - it("accepts the immutable image ID recorded by the OpenShell Docker driver", async () => { - const fake = fixture(); - fake.original!.Config!.Image = fake.original!.Image; - const adapter = createDockerManagedBootstrapAdapter(fake.deps); - const { handle } = authority(); - const discovered = await adapter.discoverHeldWorkload({ - sandbox: handle.sandbox, - bootstrapIdentity: handle.bootstrapIdentity, - expectedImage: handle.plan.image, - metadata: handle.plan.metadata, - }); - - await expect(adapter.inspectHeldWorkload({ handle, discovered })).resolves.toMatchObject({ - runtimeId: OLD_ID, - }); - }); - - it("rejects a configured image outside the reviewed manifest identity", async () => { - const fake = fixture(); - fake.original!.Config!.Image = `sha256:${"9".repeat(64)}`; - const adapter = createDockerManagedBootstrapAdapter(fake.deps); - const { handle } = authority(); - - await expect( - adapter.discoverHeldWorkload({ - sandbox: handle.sandbox, - bootstrapIdentity: handle.bootstrapIdentity, - expectedImage: handle.plan.image, - metadata: handle.plan.metadata, - }), - ).rejects.toThrow( - "Managed bootstrap Docker configured image is neither the exact repository@manifestDigest nor its immutable runtime content ID.", - ); - }); - it("stages Docker-derived console and protected-path defaults before cutover", async () => { const fake = fixture(); Object.assign(fake.original!.HostConfig!, { diff --git a/src/lib/onboard/managed-bootstrap/docker.ts b/src/lib/onboard/managed-bootstrap/docker.ts index 908be105621..629aeb148f5 100644 --- a/src/lib/onboard/managed-bootstrap/docker.ts +++ b/src/lib/onboard/managed-bootstrap/docker.ts @@ -395,13 +395,9 @@ function assertImage( } const expectedReference = expectedImageReference(image.repository, image.manifestDigest); const configuredImage = String(inspect.Config?.Image ?? "").trim(); - // OpenShell's Docker driver creates the sandbox from the inspected immutable image ID, so - // Docker records that ID in Config.Image. NemoClaw-created replacements retain the exact - // repository@manifestDigest instead. Accept only those two immutable spellings; the image - // inspection below still proves that the reviewed manifest resolves to this runtime content. - if (configuredImage !== expectedReference && configuredImage !== runtimeContentId) { + if (configuredImage !== expectedReference) { throw new Error( - "Managed bootstrap Docker configured image is neither the exact repository@manifestDigest nor its immutable runtime content ID.", + "Managed bootstrap Docker configured image is not the exact repository@manifestDigest.", ); } const imageOutput = deps.dockerCapture(["image", "inspect", expectedReference], { diff --git a/src/lib/onboard/managed-workload-clone-handoff.test.ts b/src/lib/onboard/managed-workload-clone-handoff.test.ts index b96c4735bf7..98bb6230a57 100644 --- a/src/lib/onboard/managed-workload-clone-handoff.test.ts +++ b/src/lib/onboard/managed-workload-clone-handoff.test.ts @@ -377,7 +377,7 @@ describe("prepareManagedWorkloadCloneHandoff", () => { tools: { disclosure: "direct", enabledGateways: ["nous-web"] }, }); const entry = source("hermes", "docker", profile); - const longestSandboxName = `a${"b".repeat(18)}`; + const longestSandboxName = `a${"b".repeat(62)}`; expect(() => prepare(entry, provider("docker"), undefined, longestSandboxName)).not.toThrow(); expect(() => prepare(entry, provider("docker"), undefined, "1destination")).toThrow( diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index b4b144c83a9..6ed533156bf 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -408,7 +408,7 @@ export function resolveOnboardManagedBootstrapLaunch(input: { }, agentIdentity: managedImageRuntimeIdentity(input.workload.source.contract.agent), intendedWorkloadArgv: input.intendedWorkloadArgv, - expectedSupervisorArgv: ["/opt/openshell/bin/openshell-sandbox", "--workdir", "/sandbox"], + expectedSupervisorArgv: ["/opt/openshell/bin/openshell-sandbox"], } as const; } diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 19e1254195b..003f88452cb 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -9,10 +9,10 @@ import { MessagingSetupApplier, type SandboxMessagingPlan, } from "../messaging"; +import { resolveMessagingPlanAuthority } from "../messaging/plan-authority"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../messaging/applier/types"; import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; import { runWechatHostQrLogin } from "../messaging/channels/wechat/login"; -import { resolveMessagingPlanAuthority } from "../messaging/plan-authority"; import * as registry from "../state/registry"; import { detectMessagingChannelsFromEnv, @@ -671,7 +671,7 @@ describe("setupMessagingChannels", () => { manifests("googlechat"), { interactive: false, - sandboxName: "e2e-oc-ch-cycle", + sandboxName: "e2e-channels-stop-start-openclaw", }, ); diff --git a/src/lib/onboard/openshell-feature-gate.test.ts b/src/lib/onboard/openshell-feature-gate.test.ts index ea4231c8865..4bdb118e1bc 100644 --- a/src/lib/onboard/openshell-feature-gate.test.ts +++ b/src/lib/onboard/openshell-feature-gate.test.ts @@ -81,7 +81,7 @@ describe("OpenShell MCP feature gate", () => { } }); - it("identifies the pinned v0.0.99 sandbox artifacts without executing them", () => { + it("identifies the pinned v0.0.85 sandbox artifacts without executing them", () => { const sandbox = path.join( fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-features-")), "openshell-sandbox", @@ -92,13 +92,13 @@ describe("OpenShell MCP feature gate", () => { `#!/bin/sh\nexit 127\n# ${REQUIRED_OPENSHELL_SANDBOX_MCP_FEATURE}\n`, { mode: 0o755 }, ); - const digest = "a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214"; - const arm64Digest = "f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e"; + const digest = "863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2"; + const arm64Digest = "680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d"; - expect(pinnedOpenShellSandboxBuildVersion(digest)).toBe("0.0.99"); - expect(pinnedOpenShellSandboxBuildVersion(arm64Digest)).toBe("0.0.99"); + expect(pinnedOpenShellSandboxBuildVersion(digest)).toBe("0.0.85"); + expect(pinnedOpenShellSandboxBuildVersion(arm64Digest)).toBe("0.0.85"); expect(resolveOpenShellComponentBuildVersion(sandbox, "sandbox", () => digest)).toBe( - "0.0.99", + "0.0.85", ); } finally { fs.rmSync(path.dirname(sandbox), { recursive: true, force: true }); diff --git a/src/lib/onboard/openshell-feature-gate.ts b/src/lib/onboard/openshell-feature-gate.ts index 552d65a1321..fb881d669aa 100644 --- a/src/lib/onboard/openshell-feature-gate.ts +++ b/src/lib/onboard/openshell-feature-gate.ts @@ -59,12 +59,6 @@ const PINNED_SANDBOX_BUILD_VERSIONS = new Map([ // OpenShell v0.0.85 standalone sandbox binaries. ["863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2", "0.0.85"], ["680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d", "0.0.85"], - // OpenShell v0.0.99 standalone sandbox binaries. - ["a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214", "0.0.99"], - ["f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e", "0.0.99"], - // OpenShell v0.0.101 standalone sandbox binaries. - ["a2704babbb468fd0a359bfdd9844de71095b730758541b4ca8cbab77d4018920", "0.0.101"], - ["88300e35f153123e4dc3021c537834dd6c0a09665a4a6d3974cd285d512345c4", "0.0.101"], ]); export function pinnedOpenShellSandboxBuildVersion(sha256: string): string | null { diff --git a/src/lib/onboard/openshell-install.test.ts b/src/lib/onboard/openshell-install.test.ts index e91b28b9e56..480e295a989 100644 --- a/src/lib/onboard/openshell-install.test.ts +++ b/src/lib/onboard/openshell-install.test.ts @@ -74,7 +74,7 @@ describe("ensureOpenshellForOnboard", () => { ); }); - it("applies the 0.0.99 floor during final validation when the blueprint omits a minimum", () => { + it("applies the 0.0.85 floor during final validation when the blueprint omits a minimum", () => { const deps = makeDeps({ isOpenshellInstalled: () => false, getInstalledOpenshellVersion: () => "0.0.81", @@ -88,6 +88,6 @@ describe("ensureOpenshellForOnboard", () => { expect(deps.error).toHaveBeenCalledWith( " \u2717 openshell 0.0.81 is below the minimum required by this NemoClaw release.", ); - expect(deps.error).toHaveBeenCalledWith(" blueprint.yaml min_openshell_version: 0.0.99"); + expect(deps.error).toHaveBeenCalledWith(" blueprint.yaml min_openshell_version: 0.0.85"); }); }); diff --git a/src/lib/onboard/policy-selection-application.test.ts b/src/lib/onboard/policy-selection-application.test.ts deleted file mode 100644 index 6e63e94722f..00000000000 --- a/src/lib/onboard/policy-selection-application.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; -import { - createOnboardPolicyApplication, - type OnboardPolicyApplicationDeps, -} from "./policy-selection"; -import { selectFromNumberedMenuOrExit } from "./prompt-helpers"; - -const { seedInitialPolicyContext, syncPresetSelection } = vi.hoisted(() => ({ - seedInitialPolicyContext: vi.fn(), - syncPresetSelection: vi.fn(), -})); - -vi.mock("../policy", () => ({ - clampSetupPolicyPresetNames: vi.fn((names: string[]) => names), - customPresetOwnsNetworkPolicyKey: vi.fn(() => false), - filterSetupPolicyPresets: vi.fn(), - getAppliedPresets: vi.fn(() => []), - listCustomPresets: vi.fn(() => []), - listSetupPolicyPresets: vi.fn(() => [{ name: "npm" }]), - resolveSandboxBaselinePolicy: vi.fn(), - setupPolicyPresetSupported: vi.fn(() => true), -})); -vi.mock("./policy-context-seed", () => ({ seedInitialPolicyContext })); -vi.mock("./policy-preset-sync", () => ({ syncPresetSelection })); - -describe("onboarding policy application", () => { - it("runs policy application while holding the sandbox mutation lock", async () => { - const events: string[] = []; - const withSandboxMutationLock: OnboardPolicyApplicationDeps["withSandboxMutationLock"] = vi.fn( - async (_sandboxName, action) => { - events.push("lock entered"); - try { - return await action(); - } finally { - events.push("lock released"); - } - }, - ); - syncPresetSelection.mockImplementation(() => events.push("policies synchronized")); - seedInitialPolicyContext.mockImplementation(() => events.push("policy context seeded")); - const application = createOnboardPolicyApplication({ - localInferenceProviders: [], - step: vi.fn(), - note: vi.fn(), - isNonInteractive: vi.fn(() => true), - prompt: vi.fn(async () => ""), - selectFromNumberedMenuOrExit, - makeOnboardCancelExit: (rollback, cleanup) => () => { - cleanup(); - rollback.markCancelled(); - }, - sandboxCancelRollback: { markCancelled: vi.fn() }, - useColor: false, - withSandboxMutationLock, - waitForSandboxReady: vi.fn(() => true), - waitForSandboxControlPlaneReady: vi.fn(() => true), - setPolicyTier: vi.fn(), - getRecordedPolicyTier: vi.fn(() => null), - parsePolicyPresetEnv: vi.fn(() => []), - env: {}, - }); - - await expect( - application.setupPoliciesWithSelection("alpha", { selectedPresets: ["npm"] }), - ).resolves.toEqual(["npm"]); - expect(withSandboxMutationLock).toHaveBeenCalledOnce(); - expect(withSandboxMutationLock).toHaveBeenCalledWith("alpha", expect.any(Function)); - expect(syncPresetSelection).toHaveBeenCalledWith("alpha", [], ["npm"]); - expect(events).toEqual([ - "lock entered", - "policies synchronized", - "policy context seeded", - "lock released", - ]); - }); -}); diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 57103f7fdb2..505b7030425 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; -import * as policies from "../policy"; import { PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; -import * as tiers from "../policy/tiers"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -14,10 +12,7 @@ import { allHermesToolGatewayPolicyPresets, HERMES_TOOL_GATEWAY_PRESET_NAMES, } from "./hermes-managed-tools"; -import { - allMessagingChannelPolicyPresets, - mergePolicyMessagingChannels, -} from "./messaging-policy-presets"; +import { allMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { isInactiveObservabilityPolicyPreset, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, @@ -29,17 +24,6 @@ import { isStaleBuiltinWebSearchPolicyPreset, mergeRequiredSetupPolicyPresets, } from "./policy-preset-reconciliation"; -import { syncPresetSelection } from "./policy-preset-sync"; -import { getSuggestedPolicyPresets } from "./policy-presets"; -import { - type PreparedPolicyResumeSelection, - preparePolicyPresetResumeSelection, -} from "./policy-resume-selection"; -import { - createPolicySelectionPromptHelpers, - type PolicySelectionPromptDeps, -} from "./policy-selection-prompts"; -import * as policyTierEnv from "./policy-tier-env"; import { agentRequiredPresetAdditions, emitSuppressedAgentRequiredPresetsNote, @@ -54,21 +38,6 @@ export { } from "./policy-preset-reconciliation"; export { suppressedAgentRequiredPresets } from "./policy-tier-suppression"; -export type OnboardPolicyApplicationDeps = Omit< - PolicySelectionPromptDeps, - "tiers" | "policyTierEnv" -> & { - step: (number: number, total: number, title: string) => void; - localInferenceProviders: readonly string[]; - withSandboxMutationLock: typeof import("../state/mcp-lifecycle-lock").withSandboxMutationLock; - waitForSandboxReady(sandboxName: string): boolean; - waitForSandboxControlPlaneReady(sandboxName: string): boolean; - setPolicyTier(sandboxName: string, tierName: string): void; - getRecordedPolicyTier(sandboxName: string): string | null | undefined; - parsePolicyPresetEnv(raw: string): string[]; - env: NodeJS.ProcessEnv; -}; - type Preset = { name: string; access?: string }; type SupportOptions = { webSearchSupported?: boolean | null; agent?: string | null }; type PoliciesApi = { @@ -147,86 +116,6 @@ export type SetupPolicySelectionDeps = { env?: NodeJS.ProcessEnv; }; -export function createOnboardPolicyApplication(deps: OnboardPolicyApplicationDeps) { - const promptHelpers = () => - createPolicySelectionPromptHelpers({ - ...deps, - tiers, - policyTierEnv, - }); - const selectPolicyTier = () => promptHelpers().selectPolicyTier(); - const selectTierPresetsAndAccess = ( - tierName: string, - allPresets: Array<{ name: string; description?: string }>, - initialSelected?: string[], - ) => promptHelpers().selectTierPresetsAndAccess(tierName, allPresets, initialSelected); - const presetsCheckboxSelector = ( - allPresets: Array<{ name: string; description: string }>, - initialSelected: string[], - ) => promptHelpers().presetsCheckboxSelector(allPresets, initialSelected); - const setupDeps: SetupPolicySelectionDeps = { - policies, - tiers, - localInferenceProviders: deps.localInferenceProviders, - step: deps.step, - note: deps.note, - isNonInteractive: deps.isNonInteractive, - waitForSandboxReady: deps.waitForSandboxReady, - waitForSandboxControlPlaneReady: deps.waitForSandboxControlPlaneReady, - syncPresetSelection, - selectPolicyTier, - setPolicyTier: deps.setPolicyTier, - getRecordedPolicyTier: deps.getRecordedPolicyTier, - selectTierPresetsAndAccess, - parsePolicyPresetEnv: deps.parsePolicyPresetEnv, - env: deps.env, - }; - - return { - arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[] = []): boolean { - if (!Array.isArray(selectedPresets) || selectedPresets.length === 0) return false; - const applied = new Set(policies.getAppliedPresets(sandboxName)); - return selectedPresets.every((preset) => applied.has(preset)); - }, - computeSetupPresetSuggestions( - tierName: string, - options: SetupPresetSuggestionOptions = {}, - ): string[] { - return computeSetupPresetSuggestions( - { - policies, - tiers, - localInferenceProviders: deps.localInferenceProviders, - }, - tierName, - options, - ); - }, - filterSetupPolicyPresets: policies.filterSetupPolicyPresets, - getSuggestedPolicyPresets, - mergePolicyMessagingChannels, - preparePolicyPresetResumeSelection( - sandboxName: string, - options: Parameters[2], - ): PreparedPolicyResumeSelection { - return preparePolicyPresetResumeSelection({ policies }, sandboxName, options); - }, - presetsCheckboxSelector, - resolveSandboxBaselinePolicy: policies.resolveSandboxBaselinePolicy, - selectPolicyTier, - selectTierPresetsAndAccess, - setupPoliciesWithSelection( - sandboxName: string, - options: SetupPolicySelectionOptions = {}, - ): Promise { - return deps.withSandboxMutationLock(sandboxName, () => - setupPoliciesWithSelection(setupDeps, sandboxName, options), - ); - }, - validatePolicyTierEnvEarly: policyTierEnv.validatePolicyTierEnvEarly, - }; -} - export function computeSetupPresetSuggestions( deps: { policies: PoliciesApi; @@ -324,7 +213,10 @@ export function computeSetupPresetSuggestions( return suggestions; } -export { type PreparedPolicyResumeSelection, preparePolicyPresetResumeSelection }; +export { + type PreparedPolicyResumeSelection, + preparePolicyPresetResumeSelection, +} from "./policy-resume-selection"; export async function setupPoliciesWithSelection( deps: SetupPolicySelectionDeps, diff --git a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test-support.ts b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test-support.ts deleted file mode 100644 index 3076523ec99..00000000000 --- a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test-support.ts +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createHash } from "node:crypto"; - -import { LLAMA_CPP_PORT } from "../../inference/llama-cpp/contract"; -import type { LlamaCppHostLocalLaunchContract } from "../../inference/llama-cpp/host-local-runtime"; - -export const HOST_PORT = String(LLAMA_CPP_PORT); -export const MODEL_DIGEST = `sha256:${"a".repeat(64)}`; -export const IMAGE = `ghcr.io/nvidia/nemoclaw/llama-cpp-server@sha256:${"c".repeat(64)}`; -export const PROBE_IMAGE = `quay.io/curl/curl@sha256:${"d".repeat(64)}`; -export const RUNTIME_ID = "e".repeat(64); -export const NETWORK_ID = "7".repeat(64); -export const TRANSACTION_ID = "9".repeat(64); -export const RECEIPT_TARGET_SHA256 = "8".repeat(64); -export const MODEL_CONTENT = Buffer.alloc(64, 0x61); -export const MODEL_FILENAME = "Nemotron-3-Nano-30B-A3B-UD-Q4_K_XL.gguf"; -export const REVISION = "f".repeat(40); - -function canonical(value: unknown): unknown { - return Array.isArray(value) - ? value.map(canonical) - : value !== null && typeof value === "object" - ? Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, nested]) => [key, canonical(nested)]), - ) - : value; -} - -export function invariant(condition: unknown, message: string): asserts condition { - switch (Boolean(condition)) { - case false: - throw new Error(message); - } -} - -export function digest(value: unknown): string { - return `sha256:${createHash("sha256") - .update(JSON.stringify(canonical(value))) - .digest("hex")}`; -} - -export function rawDigest(value: unknown): string { - return createHash("sha256") - .update(JSON.stringify(canonical(value))) - .digest("hex"); -} - -export function contract(): LlamaCppHostLocalLaunchContract { - return { - model: { - servedName: "nvidia-nemotron-3-nano-30b-a3b", - file: { - digest: MODEL_DIGEST, - path: MODEL_FILENAME, - sizeBytes: MODEL_CONTENT.length, - }, - }, - policy: { - egress: "disabled", - modelDownloads: "disabled", - modelSource: "verified-local", - }, - runtime: { - restartPolicy: "unless-stopped", - gpu: { - count: 1, - cpuFallback: "reject", - offload: "full", - vendor: "nvidia", - }, - resources: { - memoryBytes: 51_539_607_552, - pidsLimit: 256, - writableStorageBytes: 1024, - }, - }, - serve: { - authentication: "bearer", - batchSize: 2048, - chatTemplate: "nemotron-v3-embedded", - contextSize: 262_144, - flashAttention: "enabled", - idleSleepSeconds: -1, - kvCache: { key: "f16", value: "f16" }, - limits: { requestTimeoutSeconds: 900 }, - microBatchSize: 512, - port: LLAMA_CPP_PORT, - protocol: "openai-completions", - slots: 1, - speculativeDecoding: "disabled", - }, - surfaces: { - agentMode: "disabled", - mcpProxy: "disabled", - multimodalProjection: "disabled", - router: "disabled", - serverTools: "disabled", - slotInspection: "disabled", - ui: "disabled", - }, - }; -} diff --git a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts index a644bf9ddfa..d803ff1bef5 100644 --- a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts +++ b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.test.ts @@ -9,30 +9,17 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContainerEngine } from "../../adapters/container-engine"; -import { LLAMA_CPP_PORT } from "../../inference/llama-cpp/contract"; import type { LlamaCppGgufCachePlan } from "../../inference/llama-cpp/gguf-cache-plan"; -import { buildLlamaCppHostLocalServerArgv } from "../../inference/llama-cpp/host-local-runtime"; +/* Test-only reconstruction of the exact immutable command for recovery fixtures. */ +import { + buildLlamaCppHostLocalServerArgv, + type LlamaCppHostLocalLaunchContract, + type LlamaCppHostLocalRuntimeBindings, +} from "../../inference/llama-cpp/host-local-runtime"; import { createDockerLlamaCppManagedLifecycle, type DockerLlamaCppManagedLifecycleOptions, } from "./docker-llama-cpp-managed-lifecycle"; -import { - contract, - digest, - HOST_PORT, - IMAGE, - invariant, - MODEL_CONTENT, - MODEL_DIGEST, - MODEL_FILENAME, - NETWORK_ID, - PROBE_IMAGE, - RECEIPT_TARGET_SHA256, - REVISION, - RUNTIME_ID, - rawDigest, - TRANSACTION_ID, -} from "./docker-llama-cpp-managed-lifecycle.test-support"; import type { HostLocalCreateJournalExecutionLease, HostLocalCreateJournalRecord, @@ -45,12 +32,53 @@ import { } from "./host-local-inference"; import type { PersistedEngineAuthorityStore } from "./persisted-engine-authority"; +const MODEL_DIGEST = `sha256:${"a".repeat(64)}`; +const IMAGE = `ghcr.io/nvidia/nemoclaw/llama-cpp-server@sha256:${"c".repeat(64)}`; +const PROBE_IMAGE = `quay.io/curl/curl@sha256:${"d".repeat(64)}`; +const RUNTIME_ID = "e".repeat(64); +const NETWORK_ID = "7".repeat(64); +const TRANSACTION_ID = "9".repeat(64); +const RECEIPT_TARGET_SHA256 = "8".repeat(64); +const MODEL_CONTENT = Buffer.alloc(64, 0x61); +const MODEL_FILENAME = "Nemotron-3-Nano-30B-A3B-UD-Q4_K_XL.gguf"; +const REVISION = "f".repeat(40); let temporaryRoot = ""; let cacheRoot = ""; let modelPath = ""; let apiKeyRoot = ""; let apiKeyPath = ""; +function canonical(value: unknown): unknown { + return Array.isArray(value) + ? value.map(canonical) + : value !== null && typeof value === "object" + ? Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonical(nested)]), + ) + : value; +} + +function invariant(condition: unknown, message: string): asserts condition { + switch (Boolean(condition)) { + case false: + throw new Error(message); + } +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex")}`; +} + +function rawDigest(value: unknown): string { + return createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"); +} + function receiptWriter( writeExact: (serializedReceipt: string) => string = (serializedReceipt) => serializedReceipt, overrides: Partial> = {}, @@ -83,6 +111,62 @@ beforeEach(() => { afterEach(() => fs.rmSync(temporaryRoot, { force: true, recursive: true })); +function contract(): LlamaCppHostLocalLaunchContract { + return { + model: { + servedName: "nvidia-nemotron-3-nano-30b-a3b", + file: { + digest: MODEL_DIGEST, + path: MODEL_FILENAME, + sizeBytes: MODEL_CONTENT.length, + }, + }, + policy: { + egress: "disabled", + modelDownloads: "disabled", + modelSource: "verified-local", + }, + runtime: { + restartPolicy: "unless-stopped", + gpu: { + count: 1, + cpuFallback: "reject", + offload: "full", + vendor: "nvidia", + }, + resources: { + memoryBytes: 51_539_607_552, + pidsLimit: 256, + writableStorageBytes: 1024, + }, + }, + serve: { + authentication: "bearer", + batchSize: 2048, + chatTemplate: "nemotron-v3-embedded", + contextSize: 262_144, + flashAttention: "enabled", + idleSleepSeconds: -1, + kvCache: { key: "f16", value: "f16" }, + limits: { requestTimeoutSeconds: 900 }, + microBatchSize: 512, + port: 8081, + protocol: "openai-completions", + slots: 1, + speculativeDecoding: "disabled", + }, + surfaces: { + agentMode: "disabled", + mcpProxy: "disabled", + multimodalProjection: "disabled", + router: "disabled", + serverTools: "disabled", + slotInspection: "disabled", + ui: "disabled", + }, + }; +} + function plan(): LlamaCppGgufCachePlan { const payload = { schemaVersion: 1 as const, @@ -147,11 +231,10 @@ function keyRootIdentitySha256(): string { }); } -function bindings(): DockerLlamaCppManagedLifecycleOptions["bindings"] { +function bindings(): LlamaCppHostLocalRuntimeBindings { return { apiKeyHostPath: apiKeyPath, containerName: "nemoclaw-llama-cpp", - hostPort: LLAMA_CPP_PORT, imageReference: IMAGE, model: { digest: MODEL_DIGEST, @@ -299,15 +382,13 @@ interface DockerFixture { readonly onAbsentNetworkInspect: (callback: () => void) => void; readonly onNetworkCreate: (callback: () => void) => void; readonly onStart: (callback: () => void) => void; - readonly onProbe: (callback: () => void) => void; readonly onCreate: (callback: () => void) => void; - readonly setContainerState: (running: boolean, status: string) => void; readonly seedNetwork: (journal: HostLocalCreateJournalRecord) => void; readonly seed: (journal: HostLocalCreateJournalRecord, running: boolean) => void; } function dockerFixture( - configuredHostPort = HOST_PORT, + configuredHostPort = "", publishedHostPort?: string, publishedHostIp = "127.0.0.1", publishedBindingCount = 1, @@ -335,7 +416,6 @@ function dockerFixture( let absentNetworkInspectHook: (() => void) | undefined; let networkCreateHook: (() => void) | undefined; let startHook: (() => void) | undefined; - let probeHook: (() => void) | undefined; let createHook: (() => void) | undefined; let startedOnce = false; let container: @@ -546,7 +626,6 @@ function dockerFixture( return { status: 0, stdout: RUNTIME_ID, stderr: "" }; case "run": invariant(args[1] === "--rm", unexpected); - probeHook?.(); return probeFails ? { status: 1, stdout: "", stderr: "not ready" } : { status: 0, stdout: "ok", stderr: "" }; @@ -591,13 +670,7 @@ function dockerFixture( onAbsentNetworkInspect: (callback) => (absentNetworkInspectHook = callback), onNetworkCreate: (callback) => (networkCreateHook = callback), onStart: (callback) => (startHook = callback), - onProbe: (callback) => (probeHook = callback), onCreate: (callback) => (createHook = callback), - setContainerState: (running, status) => { - invariant(container !== undefined, "cannot change an absent fixture container"); - container.running = running; - container.status = status; - }, seedNetwork: (journal) => { invariant(journal.networkId !== null, "seeded network identity is missing"); networkId = journal.networkId; @@ -750,103 +823,22 @@ describe("dormant Docker llama.cpp managed lifecycle", () => { expect(lifecycle.runtime.destroy(receipt).status).toBe("removed"); expect(lifecycle.runtime.destroy(receipt).status).toBe("already-absent"); }); - - it("resumes an already-running receipt without creating or starting resources (#8144)", () => { - const fixture = dockerFixture(); - const lifecycle = controller(fixture); - const receipt = lifecycle.start(receiptWriter()); - fixture.capture.mockClear(); - - expect(lifecycle.resume(receipt)).toEqual(receipt); - const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(calls).toContainEqual(expect.arrayContaining(["container", "inspect", RUNTIME_ID])); - expect(calls).toContainEqual(expect.arrayContaining(["run", "--rm"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["start"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["create"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["network", "create"])); - }); - - it("resumes only the receipt-bound stopped runtime and rechecks readiness (#8144)", () => { - const fixture = dockerFixture(); - const lifecycle = controller(fixture); - const receipt = lifecycle.start(receiptWriter()); - lifecycle.runtime.stopManaged(receipt); - fixture.capture.mockClear(); - - expect(lifecycle.resume(receipt)).toEqual(receipt); - const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(calls.filter((args) => args[0] === "start")).toEqual([["start", RUNTIME_ID]]); - expect(calls).toContainEqual(expect.arrayContaining(["run", "--rm"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["create"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["network", "create"])); - }); - - it("rejects a non-resumable exact runtime without lifecycle mutation (#8144)", () => { - const fixture = dockerFixture(); - const lifecycle = controller(fixture); - const receipt = lifecycle.start(receiptWriter()); - fixture.setContainerState(false, "paused"); - fixture.capture.mockClear(); - - expect(() => lifecycle.resume(receipt)).toThrow("inconsistent runtime state"); - const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(calls).not.toContainEqual(expect.arrayContaining(["start"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["create"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["network", "create"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["run", "--rm"])); - }); - it.each([ - [ - "model filesystem", - (fixture: DockerFixture) => fixture.onProbe(() => fs.appendFileSync(modelPath, "drift")), - /filesystem identity/u, - ], - [ - "API-key", - (fixture: DockerFixture) => - fixture.onProbe(() => fs.writeFileSync(apiKeyPath, "changed-test-only-secret\n")), - /API-key/u, - ], - [ - "network", - (fixture: DockerFixture) => fixture.onProbe(() => fixture.setNetworkId("6".repeat(64))), - /network identity/u, - ], - ] as const)("fails closed on post-readiness %s drift without replacement (#8144)", (_kind, drift, expected) => { - const fixture = dockerFixture(); - const store = journalStore(); - const lifecycle = controller(fixture, store); - const receipt = lifecycle.start(receiptWriter()); - lifecycle.runtime.stopManaged(receipt); - drift(fixture); - fixture.capture.mockClear(); - - expect(() => lifecycle.resume(receipt)).toThrow(expected); - const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(calls.filter((args) => args[0] === "start")).toEqual([["start", RUNTIME_ID]]); - expect(calls).toContainEqual(expect.arrayContaining(["run", "--rm"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["create"])); - expect(calls).not.toContainEqual(expect.arrayContaining(["network", "create"])); - expect(store.load(TRANSACTION_ID)).toMatchObject({ phase: "finalized", runtimeId: RUNTIME_ID }); - }); - it.each([ - ["configured", "8082", undefined, /bound host port/u], - ["published", "8081", "8082", /declared binding/u], - ] as const)("rolls back exact ownership for %s loopback port drift (#8544)", (_kind, configured, published, expectedError) => { + ["configured", "8082", undefined], + ["published", "8081", "8082"], + ] as const)("rolls back exact ownership for %s loopback port drift (#8544)", (_kind, configured, published) => { const [fixture, store] = [dockerFixture(configured, published), journalStore()]; const lifecycle = createDockerLlamaCppManagedLifecycle( options(fixture, store, { ...bindings(), hostPort: 8081 }), ); - expect(() => lifecycle.start(receiptWriter())).toThrow(expectedError); + expect(() => lifecycle.start(receiptWriter())).toThrow(/binding/u); const calls = fixture.capture.mock.calls.map((call) => call[0]); expect(calls).toContainEqual(["rm", "--force", RUNTIME_ID]); expect(calls).toContainEqual(["network", "rm", NETWORK_ID]); expect(store.list()).toEqual([]); }); - it("rejects and cleans up malformed or non-loopback published bindings (#8544)", () => { + it("fails closed on non-loopback, malformed, or multiple published bindings during port-drift rollback (#8544)", () => { for (const args of [ - [HOST_PORT, HOST_PORT, "0.0.0.0", 1], ["8081", "8082", "0.0.0.0", 1], ["8081", "invalid", "127.0.0.1", 1], ["8081", "8082", "127.0.0.1", 2], @@ -855,11 +847,10 @@ describe("dormant Docker llama.cpp managed lifecycle", () => { const lifecycle = createDockerLlamaCppManagedLifecycle( options(fixture, store, { ...bindings(), hostPort: 8081 }), ); - expect(() => lifecycle.start(receiptWriter())).toThrow(/port|binding/u); + expect(() => lifecycle.start(receiptWriter())).toThrow("Exact rollback also failed"); const calls = fixture.capture.mock.calls.map((call) => call[0]); - expect(store.list()).toEqual([]); - expect(calls).toContainEqual(["rm", "--force", RUNTIME_ID]); - expect(calls).toContainEqual(["network", "rm", NETWORK_ID]); + expect(store.list()).not.toEqual([]); + expect(calls).not.toContainEqual(["rm", "--force", RUNTIME_ID]); } }); it("uses the declarative readiness timeout as both curl retry budget and capture budget", () => { diff --git a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts index 23e387e2a39..9d111354e3c 100644 --- a/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts +++ b/src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts @@ -118,8 +118,6 @@ interface DockerContainerInspection { }; } -type DockerContainerInspectionMode = "runtime" | "cleanup"; - interface StableFileIdentity { readonly dev: bigint; readonly ino: bigint; @@ -273,13 +271,21 @@ function parseLabels(value: unknown): Readonly> { return Object.freeze(labels); } +function parsePublishedPortBinding(value: unknown): Record | null { + if (value === null) return null; + if (!Array.isArray(value) || value.length !== 1) { + throw new Error("Docker llama.cpp container has unexpected published ports."); + } + return record(value[0], "Docker llama.cpp published port"); +} + function parseInspection( output: string, contract: LlamaCppHostLocalLaunchContract, - bindings: DockerLlamaCppManagedLifecycleOptions["bindings"], - mode: DockerContainerInspectionMode, + networkName: string, + hostPort: number | undefined, + portValidation: "exact" | "cleanup", ): DockerContainerInspection { - const networkName = bindings.network.name; let parsed: unknown; try { parsed = JSON.parse(output); @@ -301,43 +307,37 @@ function parseInspection( throw new Error("Docker llama.cpp container has unexpected network attachments."); } const attached = record(networks[networkName], "Docker llama.cpp network attachment"); - let hostPort: number | null = null; - if (mode === "runtime") { - const ports = record(networkSettings.Ports, "Docker llama.cpp published ports"); - const portKey = `${String(contract.serve.port)}/tcp`; - const configuredPorts = record(hostConfig.PortBindings, "Docker llama.cpp configured ports"); - if (Object.keys(configuredPorts).length !== 1) { - throw new Error("Docker llama.cpp container has extra configured ports."); - } - const configuredBindings = configuredPorts[portKey]; - if (!Array.isArray(configuredBindings) || configuredBindings.length !== 1) { - throw new Error("Docker llama.cpp container has unexpected configured ports."); - } - const configuredPort = record(configuredBindings[0], "Docker llama.cpp configured port"); - if (configuredPort.HostIp !== "127.0.0.1") { - throw new Error("Docker llama.cpp configured host port is not loopback-only."); - } - if (configuredPort.HostPort !== String(bindings.hostPort)) { - throw new Error("Docker llama.cpp configured host port is not the bound host port."); - } - const publishedBindings = ports[portKey]; - if ( - publishedBindings !== null && - (!Array.isArray(publishedBindings) || publishedBindings.length !== 1) - ) { - throw new Error("Docker llama.cpp container has unexpected published ports."); - } - const published = - publishedBindings === null - ? null - : record(publishedBindings[0], "Docker llama.cpp published port"); - if (published !== null && published.HostIp !== "127.0.0.1") { - throw new Error("Docker llama.cpp host port is not loopback-only."); - } - hostPort = published === null ? null : exactPort(published.HostPort); - if (hostPort !== null && hostPort !== bindings.hostPort) { - throw new Error("Docker llama.cpp published host port differs from its declared binding."); - } + const ports = record(networkSettings.Ports, "Docker llama.cpp published ports"); + const portKey = `${String(contract.serve.port)}/tcp`; + const configuredPorts = record(hostConfig.PortBindings, "Docker llama.cpp configured ports"); + if (Object.keys(configuredPorts).length !== 1) { + throw new Error("Docker llama.cpp container has extra configured ports."); + } + const configuredBindings = configuredPorts[portKey]; + if (!Array.isArray(configuredBindings) || configuredBindings.length !== 1) { + throw new Error("Docker llama.cpp container has unexpected configured ports."); + } + const configuredPort = record(configuredBindings[0], "Docker llama.cpp configured port"); + if (configuredPort.HostIp !== "127.0.0.1") { + throw new Error("Docker llama.cpp configured host port is not loopback-only."); + } + const configuredHostPort = + configuredPort.HostPort === "" ? null : exactPort(configuredPort.HostPort); + if (portValidation === "exact" && configuredHostPort !== (hostPort ?? null)) { + throw new Error("Docker llama.cpp configured host port does not match its loopback binding."); + } + const published = parsePublishedPortBinding(ports[portKey]); + if (published !== null && published.HostIp !== "127.0.0.1") { + throw new Error("Docker llama.cpp host port is not loopback-only."); + } + const publishedHostPort = published === null ? null : exactPort(published.HostPort); + if ( + portValidation === "exact" && + hostPort !== undefined && + publishedHostPort !== null && + publishedHostPort !== hostPort + ) { + throw new Error("Docker llama.cpp published host port differs from its declared binding."); } if (!Array.isArray(source.Mounts)) { throw new Error("Docker llama.cpp inspection returned malformed mounts."); @@ -406,7 +406,7 @@ function parseInspection( status: stateStatus, networkId: exactId(attached.NetworkID, "Docker attached network identity"), networkName, - hostPort, + hostPort: publishedHostPort, mounts: Object.freeze(mounts), hardening: Object.freeze({ user: String(config.User ?? ""), @@ -443,8 +443,9 @@ function inspectContainer( engine: ContainerEngine, target: string, contract: LlamaCppHostLocalLaunchContract, - bindings: DockerLlamaCppManagedLifecycleOptions["bindings"], - mode: DockerContainerInspectionMode = "runtime", + networkName: string, + hostPort: number | undefined, + portValidation: "exact" | "cleanup" = "exact", ): DockerContainerInspection | null { const result = engine.capture(["container", "inspect", target], INSPECT_TIMEOUT_MS); const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); @@ -455,7 +456,13 @@ function inspectContainer( if (!result.error && result.status === 1 && exactAbsent.test(result.stderr.trim())) { return null; } - return parseInspection(requireSuccess("container inspection", result), contract, bindings, mode); + return parseInspection( + requireSuccess("container inspection", result), + contract, + networkName, + hostPort, + portValidation, + ); } function currentUid(): bigint { @@ -940,7 +947,8 @@ function rollbackExact( options.engine, target, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, "cleanup", ); if (container === null && record.phase === "creating" && uncertainRecoveryUnixMs !== undefined) { @@ -956,7 +964,8 @@ function rollbackExact( options.engine, target, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, "cleanup", ); } @@ -967,8 +976,14 @@ function rollbackExact( captureMutation(options, lease, execution, ["rm", "--force", owned.id], MUTATION_TIMEOUT_MS), ); if ( - inspectContainer(options.engine, owned.id, options.contract, options.bindings, "cleanup") !== - null + inspectContainer( + options.engine, + owned.id, + options.contract, + options.bindings.network.name, + options.bindings.hostPort, + "cleanup", + ) !== null ) { throw new Error("Docker llama.cpp exact rollback left the owned runtime present."); } @@ -1224,7 +1239,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, authorized.receipt.runtime.runtimeId, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ); if (inspected === null) throw new Error("Docker llama.cpp owned runtime is absent."); const container = requireOwnedContainer(inspected, options, authorized.journal); @@ -1341,7 +1357,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, normalized.runtime.runtimeId, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ); const journal = options.journalStore.load(normalized.runtime.model.generation); if (existing !== null || journal !== null) authorizeReceipt(normalized, true); @@ -1356,7 +1373,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, normalized.runtime.runtimeId, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ); if (existing === null) { const journal = options.journalStore.load(normalized.runtime.model.generation); @@ -1395,7 +1413,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, inspected.container.id, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ) !== null ) { throw new Error("Docker llama.cpp removal left the exact runtime present."); @@ -1491,7 +1510,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, options.bindings.containerName, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ) !== null ) { throw new Error("Docker llama.cpp container name is already in use."); @@ -1572,7 +1592,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, options.bindings.containerName, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ); if (create.error || create.status !== 0 || created === null) { throw new Error( @@ -1598,7 +1619,8 @@ export function createDockerLlamaCppManagedLifecycle( options.engine, created.id, options.contract, - options.bindings, + options.bindings.network.name, + options.bindings.hostPort, ); if (started === null || !started.running) { throw new Error("Docker llama.cpp start did not leave the exact runtime running."); diff --git a/src/lib/onboard/runtime-provider/host-local-inference.ts b/src/lib/onboard/runtime-provider/host-local-inference.ts index 6d665b76b28..db2f75a96ae 100644 --- a/src/lib/onboard/runtime-provider/host-local-inference.ts +++ b/src/lib/onboard/runtime-provider/host-local-inference.ts @@ -169,7 +169,7 @@ export interface HostLocalLlamaCppLifecycleInput { readonly authorityStore: PersistedEngineAuthorityStore; readonly apiKeyRootHostPath: string; readonly bindingSha256: string; - readonly bindings: LlamaCppHostLocalRuntimeBindings & { readonly hostPort: number }; + readonly bindings: LlamaCppHostLocalRuntimeBindings; readonly cacheRootHostPath: string; readonly contract: LlamaCppHostLocalLaunchContract; readonly engine: ContainerEngine; diff --git a/src/lib/onboard/runtime-provider/podman.test.ts b/src/lib/onboard/runtime-provider/podman.test.ts index eac3e61d568..becf01ba014 100644 --- a/src/lib/onboard/runtime-provider/podman.test.ts +++ b/src/lib/onboard/runtime-provider/podman.test.ts @@ -119,7 +119,7 @@ function lifecycleEngine(sandboxName: string, authorityId = AUTHORITY_ID): Conta } function providerHarness(agent: (typeof AGENTS)[number]) { - const sandboxName = agent === "langchain-deepagents-code" ? "dcode-podman" : `${agent}-podman`; + const sandboxName = `${agent}-podman`; const lifecycle = lifecycleEngine(sandboxName); const bundle = createPodmanRuntimeProviderBundle({ engines: { hostDoctor: hostDoctorEngine(), sandboxLifecycle: lifecycle }, diff --git a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts index 0281d4efc28..680bf9bb842 100644 --- a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts +++ b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts @@ -866,8 +866,7 @@ describe("socket-free MXC action contract", () => { recordEvent, }); const providers = createRuntimeProviderBundleRegistry([["mxc", bundle]]); - const sandboxName = - agent === "langchain-deepagents-code" ? "dcode-sandbox" : `${agent}-sandbox`; + const sandboxName = `${agent}-sandbox`; const imageTag = `mxc-memory:${agent}`; const registerSandbox = vi.fn(); const entry = registerCreatedSandbox({ diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index 2028f0a2f06..87ed446fe49 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -8,7 +8,6 @@ import path from "node:path"; import { GATEWAY_PORT } from "../core/ports"; import { rejectSymlinksOnPath } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; -import { BASE_GATEWAY_STATE_DIR_NAME } from "./gateway-binding"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; @@ -48,14 +47,6 @@ function timestampForPath(now: Date): string { function gatewayLogCandidates(homeDir: string): string[] { return [ - path.join( - homeDir, - ".local", - "state", - "nemoclaw", - BASE_GATEWAY_STATE_DIR_NAME, - "openshell-gateway.log", - ), path.join( homeDir, ".local", diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index d622067fa47..f380b36d698 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -673,28 +673,14 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { it("configures the portable lifecycle after sandbox creation succeeds (#8441)", async () => { const input = createInput(); - input.lifecycleRegistrationFields = { - lifecycleGeneration: "current-generation", - lifecycleLiveIdentityFingerprint: "current-fingerprint", - }; const deps = createDeps(); - deps.installPortableDemoLifecycle = vi.fn( - () => input.lifecycleRegistrationFields?.lifecycleGeneration ?? null, - ); + deps.installPortableDemoLifecycle = vi.fn(); - await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ - lifecycleRegistrationFields: { - lifecycleGeneration: "current-generation", - lifecycleLiveIdentityFingerprint: "current-fingerprint", - }, - route: "native", - }); + await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ route: "native" }); expect(deps.installPortableDemoLifecycle).toHaveBeenCalledWith( input.sandboxName, input.sandboxStartupCommand, - process.env, - { registryGeneration: "current-generation" }, ); }); diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 994defe2747..d9cec4789f4 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -3,17 +3,14 @@ import type { StreamSandboxCreateResult } from "../sandbox/create-stream"; import { redactFull } from "../security/redact"; -import type { SandboxEntry, SandboxGpuProofResult } from "../state/registry"; +import type { SandboxGpuProofResult } from "../state/registry"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import { collectDockerGpuPatchDiagnostics } from "./docker-gpu-patch"; import type { DockerGpuPatchDeps, DockerUlimit } from "./docker-gpu-patch-types"; import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { renderCompatibilityFallbackCreateArgs } from "./docker-gpu-route"; import { adaptDockerGpuRouteForPatch } from "./docker-gpu-route-patch-adapter"; -import { - installPortableDemoSandboxLifecycle, - preparePortableDemoSandboxCreation, -} from "./experimental/portable-demo-lifecycle"; +import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; import { type ManagedBootstrapAdapter, type ManagedBootstrapAgentIdentity, @@ -69,10 +66,6 @@ function exitForManagedBootstrapRecovery(error: ManagedBootstrapRecoveryBlockedE type RunOpenshell = NonNullable; type RunCaptureOpenshell = NonNullable; type Sleep = NonNullable; -type LifecycleRegistrationFields = Pick< - SandboxEntry, - "lifecycleGeneration" | "lifecycleLiveIdentityFingerprint" ->; export interface SandboxGpuCreateFlowInput { sandboxName: string; @@ -87,7 +80,6 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; - lifecycleRegistrationFields?: LifecycleRegistrationFields; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -127,7 +119,6 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; - lifecycleRegistrationFields: LifecycleRegistrationFields; } /** @@ -147,7 +138,6 @@ export async function runSandboxGpuCreateFlow( deps: SandboxGpuCreateFlowDeps, ): Promise { let registryImageRef: string | null = input.prebuild.imageRef; - preparePortableDemoSandboxCreation(input.sandboxName); const attemptRunner = createSandboxGpuCreateAttemptRunner(input, deps); const gpuCreateOutcome = await sandboxGpuCreateAttempt .executeSandboxGpuCreatePlan(input.gpuRoutePlan, { @@ -260,19 +250,11 @@ export async function runSandboxGpuCreateFlow( process.exit(1); } - let portableLifecycleGeneration: string | null = null; try { - portableLifecycleGeneration = - (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( - input.sandboxName, - input.sandboxStartupCommand, - process.env, - { - ...(input.lifecycleRegistrationFields?.lifecycleGeneration - ? { registryGeneration: input.lifecycleRegistrationFields.lifecycleGeneration } - : {}), - }, - ) ?? null; + (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( + input.sandboxName, + input.sandboxStartupCommand, + ); } catch (error) { const detail = redactFull(error instanceof Error ? error.message : String(error)).slice(0, 500); console.warn(` Portable demo lifecycle setup did not complete: ${detail}`); @@ -283,9 +265,5 @@ export async function runSandboxGpuCreateFlow( route: gpuCreateOutcome.route, firstCreateOutput: attemptRunner.state.firstCreateOutput, registryImageRef, - lifecycleRegistrationFields: { - ...(portableLifecycleGeneration ? { lifecycleGeneration: portableLifecycleGeneration } : {}), - ...input.lifecycleRegistrationFields, - }, }; } diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 62cb4a5ac61..62391d8581d 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -41,7 +41,7 @@ export type SetupNimSelectionState = { skipHostInferenceSmoke?: boolean; /** Public addresses approved for the selected custom endpoint. */ endpointPinnedAddresses?: string[]; - /** Non-forgeable proof of the exact host and complete pins admitted by the selected preflight. */ + /** Non-forgeable proof of the exact private subset admitted by the selected preflight. */ endpointTrustedPrivateCapability?: TrustedPrivateEndpointCapability; reuseGatewayCredentialWithoutLocalKey?: boolean; /** Ephemeral selection-to-smoke validation cache; never written to session state. */ diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index ec7fce8b9bf..c50f26fbfa1 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -28,7 +28,6 @@ import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; import { ROOT, run, runCapture } from "../runner"; -import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import * as registry from "../state/registry"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; import { @@ -641,64 +640,6 @@ function policyDocumentsMatch(left: string, right: string): boolean { } } -const PERSONAL_OPEN_INTERNET_POLICY_KEY = "personal_open_internet"; -const PERSONAL_OPEN_INTERNET_PORTS = new Set([80, 443]); - -function endpointOverlapsPersonalOpenInternet(endpoint: PolicyValue): boolean { - if (!isPolicyObject(endpoint)) return false; - const ports = Array.isArray(endpoint.ports) ? endpoint.ports : [endpoint.port]; - return ports.some((port) => { - const numericPort = typeof port === "number" ? port : Number(port); - return Number.isInteger(numericPort) && PERSONAL_OPEN_INTERNET_PORTS.has(numericPort); - }); -} - -/** - * OpenShell 0.0.101 rejects a hostless endpoint when a named endpoint selects - * the same port with different L4/L7 metadata. Personal's hostless 80/443 - * route is the authoritative selector for those ports, so retain it and only - * the non-overlapping endpoints from the baseline and other selected presets. - */ -function makePersonalOpenInternetAuthoritative(policy: string): string { - let document: PolicyDocument | null = null; - try { - const parsed = YAML.parse(policy); - document = isPolicyDocument(parsed) ? parsed : null; - } catch { - document = null; - } - if (!document || !isPresetPolicyMap(document.network_policies)) { - throw new Error( - "Cannot compose Personal policy: the merged policy is not a valid network policy mapping.", - ); - } - - const networkPolicies = document.network_policies; - if (!Object.hasOwn(networkPolicies, PERSONAL_OPEN_INTERNET_POLICY_KEY)) return policy; - if (!isPolicyObject(networkPolicies[PERSONAL_OPEN_INTERNET_POLICY_KEY])) { - throw new Error("Cannot compose Personal policy: its open-internet entry is malformed."); - } - - const compatiblePolicies: PolicyObject = {}; - for (const [key, value] of Object.entries(networkPolicies)) { - if (key === PERSONAL_OPEN_INTERNET_POLICY_KEY) { - compatiblePolicies[key] = value; - continue; - } - if (!isPolicyObject(value) || !Array.isArray(value.endpoints)) { - compatiblePolicies[key] = value; - continue; - } - const endpoints = value.endpoints.filter( - (endpoint) => !endpointOverlapsPersonalOpenInternet(endpoint), - ); - if (endpoints.length > 0) compatiblePolicies[key] = { ...value, endpoints }; - } - - document.network_policies = compatiblePolicies; - return YAML.stringify(document); -} - function logPresetNoNewEgress( presetName: string, logger: (line: string) => void = console.log, @@ -727,247 +668,6 @@ function logPresetScopeForState( for (const line of renderPresetScope(content, { heading })) logger(line); } -const OPENCLAW_NPM_BASELINE_KEY = "npm_registry"; -const OPENCLAW_NPM_PRESET_KEY = "npm_yarn"; - -function npmCompatibilityEntry( - baselineEntry: PolicyObject, - npmPresetEntry: PolicyObject, -): PolicyObject { - const presetEndpoints = Array.isArray(npmPresetEntry.endpoints) - ? npmPresetEntry.endpoints.filter(isPolicyObject) - : []; - const baselineEndpoints = Array.isArray(baselineEntry.endpoints) - ? baselineEntry.endpoints.filter(isPolicyObject) - : []; - if (baselineEndpoints.length === 0) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}' with '${OPENCLAW_NPM_BASELINE_KEY}': the reviewed baseline has no endpoints.`, - ); - } - const baselineSelectors = new Set(); - const compatibleEndpoints = baselineEndpoints.map((baselineEndpoint) => { - const host = baselineEndpoint.host; - const port = baselineEndpoint.port; - if ( - typeof host !== "string" || - host.length === 0 || - typeof port !== "number" || - !Number.isInteger(port) || - port <= 0 - ) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}' with '${OPENCLAW_NPM_BASELINE_KEY}': the reviewed baseline selector is invalid.`, - ); - } - const selector = `${host}:${port}`; - if (baselineSelectors.has(selector)) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}' with '${OPENCLAW_NPM_BASELINE_KEY}': the reviewed baseline repeats selector '${selector}'.`, - ); - } - baselineSelectors.add(selector); - const matches = presetEndpoints.filter( - (candidate) => candidate.host === host && candidate.port === port, - ); - if (matches.length !== 1) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}' with '${OPENCLAW_NPM_BASELINE_KEY}': selector '${selector}' must have exactly one preset match.`, - ); - } - return structuredClone(matches[0]); - }); - return { ...structuredClone(baselineEntry), endpoints: compatibleEndpoints }; -} - -type OpenClawNpmActivation = { policy: string; widenedBaseline: boolean }; - -function openClawNpmReviewedEntries(baselinePolicyContent: string): { - baseline: PolicyObject; - preset: PolicyObject; -} { - const baseline = getBaselineEntry(baselinePolicyContent, OPENCLAW_NPM_BASELINE_KEY); - const npmPresetContent = loadPresetForAgent("npm", { agent: "openclaw" }); - const preset = parseNetworkPolicies(npmPresetContent)?.[OPENCLAW_NPM_PRESET_KEY]; - if (!baseline || !isPolicyObject(preset)) { - throw new Error("Cannot reconcile OpenClaw npm policy compatibility: reviewed inputs missing."); - } - return { baseline, preset }; -} - -/** - * OpenShell 0.0.101 rejects overlapping endpoint selectors whose TLS or L7 - * metadata differs, even when their binary lists are disjoint. Keep the - * restricted OpenClaw baseline GET-only. While the broader npm preset is - * active, its reviewed full-access L4 endpoint temporarily replaces the - * overlapping baseline endpoint metadata while retaining the baseline's - * OpenClaw-only binary scope. - */ -function activateOpenClawNpmCompatibility( - policyContent: string, - baselinePolicyContent: string, - npmWasActive: boolean, -): OpenClawNpmActivation { - const parsed = YAML.parse(policyContent); - if (!isPolicyDocument(parsed) || !isPresetPolicyMap(parsed.network_policies)) { - throw new Error("Cannot reconcile OpenClaw npm policy compatibility: invalid policy mapping."); - } - const networkPolicies = parsed.network_policies; - const currentBaselineEntry = networkPolicies[OPENCLAW_NPM_BASELINE_KEY]; - if (currentBaselineEntry === undefined) { - return { policy: policyContent, widenedBaseline: false }; - } - if (!isPolicyObject(currentBaselineEntry)) { - throw new Error(`Cannot compose '${OPENCLAW_NPM_PRESET_KEY}': baseline entry is malformed.`); - } - - const reviewed = openClawNpmReviewedEntries(baselinePolicyContent); - const compatibilityEntry = npmCompatibilityEntry(reviewed.baseline, reviewed.preset); - const currentNpmEntry = networkPolicies[OPENCLAW_NPM_PRESET_KEY]; - if (!isPolicyObject(currentNpmEntry) || !isDeepStrictEqual(currentNpmEntry, reviewed.preset)) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}': the resulting entry differs from the reviewed npm preset.`, - ); - } - if (isDeepStrictEqual(currentBaselineEntry, compatibilityEntry)) { - if (!npmWasActive) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}': found a compatibility overlay without an active npm preset.`, - ); - } - return { policy: policyContent, widenedBaseline: false }; - } - if (!isDeepStrictEqual(currentBaselineEntry, reviewed.baseline)) { - throw new Error( - `Cannot compose '${OPENCLAW_NPM_PRESET_KEY}': '${OPENCLAW_NPM_BASELINE_KEY}' differs from the reviewed baseline.`, - ); - } - networkPolicies[OPENCLAW_NPM_BASELINE_KEY] = compatibilityEntry; - parsed.network_policies = networkPolicies; - return { policy: YAML.stringify(parsed), widenedBaseline: true }; -} - -function restoreOpenClawNpmCompatibility( - currentPolicy: string, - updatedPolicy: string, - baselinePolicyContent: string, -): string { - const current = YAML.parse(currentPolicy); - const updated = YAML.parse(updatedPolicy); - if ( - !isPolicyDocument(current) || - !isPresetPolicyMap(current.network_policies) || - !isPolicyDocument(updated) || - !isPresetPolicyMap(updated.network_policies) - ) { - throw new Error("Cannot restore OpenClaw npm policy compatibility: invalid policy mapping."); - } - const currentBaselineEntry = current.network_policies[OPENCLAW_NPM_BASELINE_KEY]; - if (currentBaselineEntry === undefined) return updatedPolicy; - if (!isPolicyObject(currentBaselineEntry)) { - throw new Error(`Cannot remove '${OPENCLAW_NPM_PRESET_KEY}': baseline entry is malformed.`); - } - - const reviewed = openClawNpmReviewedEntries(baselinePolicyContent); - if (isDeepStrictEqual(currentBaselineEntry, reviewed.baseline)) return updatedPolicy; - - const currentNpmEntry = current.network_policies[OPENCLAW_NPM_PRESET_KEY]; - if (!isPolicyObject(currentNpmEntry)) { - throw new Error( - `Cannot remove '${OPENCLAW_NPM_PRESET_KEY}': found a compatibility overlay without an active npm preset.`, - ); - } - const compatibilityEntry = npmCompatibilityEntry(reviewed.baseline, currentNpmEntry); - if (!isDeepStrictEqual(currentBaselineEntry, compatibilityEntry)) { - throw new Error( - `Cannot remove '${OPENCLAW_NPM_PRESET_KEY}': '${OPENCLAW_NPM_BASELINE_KEY}' differs from both the reviewed baseline and active compatibility overlay.`, - ); - } - updated.network_policies[OPENCLAW_NPM_BASELINE_KEY] = structuredClone(reviewed.baseline); - return YAML.stringify(updated); -} - -function resolveSandboxOpenClawNpmBaseline(sandboxName: string): string | null { - const sandbox = registry.getSandbox(sandboxName); - // Legacy and unregistered rows historically use OpenClaw preset content. - // Activation still requires an exact reviewed npm_registry entry in the - // live policy, so this fallback cannot inject or broaden a missing key. - const agent = sandbox?.agent || "openclaw"; - if (agent !== "openclaw") return null; - const baseline = resolveAgentBaselinePolicy(agent); - if (!baseline) { - throw new Error( - `Cannot reconcile OpenClaw npm policy compatibility for '${sandboxName}': the reviewed baseline is unavailable.`, - ); - } - return baseline.content; -} - -function openClawNpmExclusionStateError(sandboxName: string, currentPolicy: string): string | null { - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition?.exclusion.key === OPENCLAW_NPM_BASELINE_KEY) { - return `baseline repair for '${OPENCLAW_NPM_BASELINE_KEY}' is still pending; finish that transaction before changing npm`; - } - const isExcluded = registry - .getBaselineExclusions(sandboxName) - .some((entry) => entry.key === OPENCLAW_NPM_BASELINE_KEY); - if (!isExcluded) return null; - const live = inspectLiveBaselineEntry(currentPolicy, OPENCLAW_NPM_BASELINE_KEY); - return live.state === "absent" - ? null - : `recorded exclusion for '${OPENCLAW_NPM_BASELINE_KEY}' requires the live entry to remain absent`; -} - -export type OpenClawNpmCompatibilityState = "match" | "repair" | "excluded" | "drift"; - -function getOpenClawNpmCompatibilityState( - sandboxName: string, -): OpenClawNpmCompatibilityState | null { - try { - const baselinePolicyContent = resolveSandboxOpenClawNpmBaseline(sandboxName); - if (!baselinePolicyContent) return "match"; - const currentPolicy = readCurrentSandboxPolicy(sandboxName); - if (!currentPolicy) return null; - const transition = registry.getBaselineExclusionTransition(sandboxName); - if (transition?.exclusion.key === OPENCLAW_NPM_BASELINE_KEY) return "drift"; - const isExcluded = registry - .getBaselineExclusions(sandboxName) - .some((entry) => entry.key === OPENCLAW_NPM_BASELINE_KEY); - const live = inspectLiveBaselineEntry(currentPolicy, OPENCLAW_NPM_BASELINE_KEY); - if (isExcluded) return live.state === "absent" ? "excluded" : "drift"; - if (live.state !== "present") return "drift"; - - const parsed = YAML.parse(currentPolicy); - if (!isPolicyDocument(parsed) || !isPresetPolicyMap(parsed.network_policies)) return null; - const reviewed = openClawNpmReviewedEntries(baselinePolicyContent); - const currentNpmEntry = parsed.network_policies[OPENCLAW_NPM_PRESET_KEY]; - if (!isPolicyObject(currentNpmEntry) || !isDeepStrictEqual(currentNpmEntry, reviewed.preset)) { - return "drift"; - } - if (isDeepStrictEqual(parsed.network_policies[OPENCLAW_NPM_BASELINE_KEY], reviewed.baseline)) { - return "repair"; - } - const compatibilityEntry = npmCompatibilityEntry(reviewed.baseline, currentNpmEntry); - return isDeepStrictEqual(parsed.network_policies[OPENCLAW_NPM_BASELINE_KEY], compatibilityEntry) - ? "match" - : "drift"; - } catch { - return null; - } -} - -function policyHasNetworkPolicy(policyContent: string, policyKey: string): boolean { - return isPolicyObject(parseNetworkPolicies(policyContent)?.[policyKey]); -} - -function logOpenClawNpmCompatibilityDisclosure(logger: (line: string) => void = console.log): void { - logger(" OpenClaw npm compatibility scope while this preset is active:"); - logger( - " registry.npmjs.org:443 for /usr/local/bin/openclaw changes from inspected GET-only REST to full L4 pass-through (HTTP methods and paths are not inspected).", - ); - logger(" Removing the npm preset restores the exact reviewed GET-only baseline route."); -} - function mergePresetNamesIntoPolicy( currentPolicy: string, presetNames: string[], @@ -997,28 +697,7 @@ function mergePresetNamesIntoPolicy( appliedPresets.push(presetName); } - let policy = merged; - if ( - (options.agent === undefined || options.agent === null || options.agent === "openclaw") && - appliedPresets.includes("npm") - ) { - const reviewedBaseline = resolveAgentBaselinePolicy("openclaw"); - if (!reviewedBaseline) { - throw new Error( - "Cannot reconcile OpenClaw npm policy compatibility: reviewed baseline missing.", - ); - } - policy = activateOpenClawNpmCompatibility( - merged, - reviewedBaseline.content, - policyHasNetworkPolicy(currentPolicy, OPENCLAW_NPM_PRESET_KEY), - ).policy; - } - return { - policy: makePersonalOpenInternetAuthoritative(policy), - appliedPresets, - missingPresets, - }; + return { policy: merged, appliedPresets, missingPresets }; } /** @@ -1114,10 +793,11 @@ function removePreset( ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" - if (!isValidName(sandboxName)) { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( - `Invalid or truncated sandbox name: ${diagnosticPreview(sandboxName)}. ` + - `Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } @@ -1161,21 +841,7 @@ function removePreset( return false; } - let updated = removePresetFromPolicy(currentPolicy, presetEntries); - if (!isCustom && presetName === "npm") { - try { - const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); - if (baseline) { - const exclusionError = openClawNpmExclusionStateError(sandboxName, currentPolicy); - if (exclusionError) throw new Error(exclusionError); - updated = restoreOpenClawNpmCompatibility(currentPolicy, updated, baseline); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(` Refusing to remove npm policy compatibility: ${message}`); - return false; - } - } + const updated = removePresetFromPolicy(currentPolicy, presetEntries); if (updated === currentPolicy) { console.error(` Preset '${presetName}' could not be removed from the current policy.`); @@ -1961,21 +1627,16 @@ function applyPresetContent( ): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" - if (!isValidName(sandboxName)) { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( - `Invalid or truncated sandbox name: ${diagnosticPreview(sandboxName)}. ` + - `Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } if (options.custom) { const np = parseNetworkPolicies(presetContent); - if (np && Object.prototype.hasOwnProperty.call(np, OPENCLAW_NPM_PRESET_KEY)) { - console.error( - ` Custom presets cannot own reserved network policy key '${OPENCLAW_NPM_PRESET_KEY}'.`, - ); - return false; - } const hasGeneratedPins = np !== null && networkPoliciesHasAllowedIps(np); const trustedPrivatePinsValid = isTrustedPrivatePolicyPinCapability( presetContent, @@ -2063,41 +1724,14 @@ function applyPresetContent( return false; } } - let merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - let npmBaselineWidened = false; - if (!options.custom && presetName === "npm") { - try { - const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); - if (baseline) { - const exclusionError = openClawNpmExclusionStateError(sandboxName, currentPolicy); - if (exclusionError) throw new Error(exclusionError); - const activation = activateOpenClawNpmCompatibility( - merged, - baseline, - policyHasNetworkPolicy(currentPolicy, OPENCLAW_NPM_PRESET_KEY), - ); - merged = activation.policy; - npmBaselineWidened = activation.widenedBaseline; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(` Refusing to apply npm policy compatibility: ${message}`); - return false; - } - } - merged = makePersonalOpenInternetAuthoritative(merged); + const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); const presetState = classifyPresetEntries(currentPolicy, presetEntries); - const disclosedPresetState = - npmBaselineWidened && presetState === "match" ? "drift" : presetState; const disclosedStateStillCurrent = Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") && - options.disclosedPresetState === disclosedPresetState; + options.disclosedPresetState === presetState; if (!options.suppressDisclosure && !disclosedStateStillCurrent) { - logPresetScopeForState(presetName, presetContent, disclosedPresetState); - } - if (npmBaselineWidened && !options.suppressDisclosure) { - logOpenClawNpmCompatibilityDisclosure(); + logPresetScopeForState(presetName, presetContent, presetState); } // Ownership-aware callers use a successful `policy set --wait` as part of @@ -2209,10 +1843,11 @@ function applyPreset( * preset during onboarding. */ function applyPresets(sandboxName: string, presetNames: string[]): boolean { - if (!isValidName(sandboxName)) { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( - `Invalid or truncated sandbox name: ${diagnosticPreview(sandboxName)}. ` + - `Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } @@ -2268,38 +1903,8 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { merged = mergePresetIntoPolicy(merged, presetEntries); } - let npmBaselineWidened = false; - if (uniquePresetNames.includes("npm")) { - try { - const baseline = resolveSandboxOpenClawNpmBaseline(sandboxName); - if (baseline) { - const exclusionError = openClawNpmExclusionStateError(sandboxName, originalPolicy); - if (exclusionError) throw new Error(exclusionError); - const activation = activateOpenClawNpmCompatibility( - merged, - baseline, - policyHasNetworkPolicy(originalPolicy, OPENCLAW_NPM_PRESET_KEY), - ); - merged = activation.policy; - npmBaselineWidened = activation.widenedBaseline; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(` Refusing to apply npm policy compatibility: ${message}`); - return false; - } - } - merged = makePersonalOpenInternetAuthoritative(merged); - for (const preset of presetContents) { - const disclosedPresetState = - preset.name === "npm" && npmBaselineWidened && preset.state === "match" - ? "drift" - : preset.state; - logPresetScopeForState(preset.name, preset.content, disclosedPresetState); - if (preset.name === "npm" && npmBaselineWidened) { - logOpenClawNpmCompatibilityDisclosure(); - } + logPresetScopeForState(preset.name, preset.content, preset.state); } const policyChanged = !policyDocumentsMatch(originalPolicy, merged); @@ -2699,10 +2304,11 @@ function resolvePermissivePolicyPath(sandboxName: string): string { } function applyPermissivePolicy(sandboxName: string): void { - if (!isValidName(sandboxName)) { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( - `Invalid or truncated sandbox name: ${diagnosticPreview(sandboxName)}. ` + - `Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } @@ -2736,7 +2342,6 @@ export { getBaselineExclusionRuntimeStatus, getGatewayPresets, getLiveSandboxPolicyEntryDigest, - getOpenClawNpmCompatibilityState, getPresetContentGatewayState, getPresetEndpoints, getPresetValidationWarning, @@ -2749,7 +2354,6 @@ export { loadPreset, loadPresetForSandbox, loadPresetFromFile, - logOpenClawNpmCompatibilityDisclosure, logPresetNoNewEgress, logPresetScope, logPresetScopeForState, diff --git a/src/lib/policy/trusted-private-endpoints.test.ts b/src/lib/policy/trusted-private-endpoints.test.ts index e4e1371d978..a6275544467 100644 --- a/src/lib/policy/trusted-private-endpoints.test.ts +++ b/src/lib/policy/trusted-private-endpoints.test.ts @@ -84,7 +84,7 @@ network_policies: "websocket", "jsonrpc", "mcp", - ])("pins mixed public and trusted-private DNS answers for %s endpoints (#8176)", async (protocol) => { + ])("rejects mixed public and private DNS answers for %s endpoints (#8176)", async (protocol) => { const input = preset(`preset: name: private network_policies: @@ -93,20 +93,11 @@ network_policies: - { host: api.corp.example, port: 443, protocol: ${protocol} } `); - const [prepared] = await prepareTrustedPrivatePolicyPresets([input], ["api.corp.example"], { - lookup: lookup({ "api.corp.example": ["10.20.30.40", "8.8.8.8"] }), - }); - const document = YAML.parse(prepared.content) as { - network_policies: { services: { endpoints: Array<{ allowed_ips?: string[] }> } }; - }; - - expect(document.network_policies.services.endpoints[0]?.allowed_ips).toEqual([ - "10.20.30.40", - "8.8.8.8", - ]); - expect(hasTrustedPrivatePolicyPinReceipt(prepared.content, prepared.trustedPrivatePins)).toBe( - true, - ); + await expect( + prepareTrustedPrivatePolicyPresets([input], ["api.corp.example"], { + lookup: lookup({ "api.corp.example": ["10.20.30.40", "8.8.8.8"] }), + }), + ).rejects.toThrow(/mixed public and private addresses/); expect(input.content).not.toContain("allowed_ips"); }); @@ -154,7 +145,7 @@ network_policies: }; expect(() => replayTrustedPrivatePolicyPinCapability(content, receipt)).toThrow( - /disallowed address pin/, + /non-canonical private pins/, ); }); diff --git a/src/lib/policy/trusted-private-endpoints.ts b/src/lib/policy/trusted-private-endpoints.ts index e3e6b86606b..45db11f452f 100644 --- a/src/lib/policy/trusted-private-endpoints.ts +++ b/src/lib/policy/trusted-private-endpoints.ts @@ -6,12 +6,12 @@ import { isIP } from "node:net"; import YAML from "yaml"; -import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; +import { isPrivateIp, OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; import { - assertTrustedPrivateEndpointCapability, assertEndpointResolvesPublic, - canonicalizeTrustedPrivateEndpointPins, type EndpointDnsLookupFn, + isOperatorTrustablePrivateIp, + isTrustedPrivateEndpointCapability, normalizeTrustedPrivateHost, } from "../security/trusted-private-endpoint"; @@ -107,19 +107,24 @@ function validateTrustedPrivatePinnedContent(content: string): void { if (!Array.isArray(endpoint.allowed_ips) || endpoint.allowed_ips.length === 0) { throw new Error(`trusted private policy endpoint '${host}' has no exact address pins`); } - const addresses = endpoint.allowed_ips; - let canonical: readonly string[]; - try { - canonical = canonicalizeTrustedPrivateEndpointPins( - host, - addresses as readonly string[], - ).addresses; - } catch { - throw new Error(`trusted private policy endpoint '${host}' has a disallowed address pin`); - } + const addresses = endpoint.allowed_ips.map((address) => { + if ( + typeof address !== "string" || + isIP(address) === 0 || + address !== address.toLowerCase() + ) { + throw new Error(`trusted private policy endpoint '${host}' has a malformed address pin`); + } + if (isPrivateIp(address) && !isOperatorTrustablePrivateIp(address)) { + throw new Error(`trusted private policy endpoint '${host}' has a disallowed address pin`); + } + return address; + }); + const canonical = [...new Set(addresses)].sort(); if ( canonical.length !== addresses.length || - canonical.some((address, index) => address !== addresses[index]) + canonical.some((address, index) => address !== addresses[index]) || + !addresses.some((address) => isOperatorTrustablePrivateIp(address)) ) { throw new Error(`trusted private policy endpoint '${host}' has non-canonical private pins`); } @@ -301,7 +306,7 @@ export async function prepareTrustedPrivatePolicyPresets( `Trusted private host '${host}' failed destination preflight: ${result.reason ?? "validation failed"}.`, ); } - if (!result.trustedPrivateCapability) { + if (!isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)) { if (requiredHostSet.has(host)) { throw new Error( `Trusted private host '${host}' did not resolve to an operator-trustable private address.`, @@ -309,21 +314,29 @@ export async function prepareTrustedPrivatePolicyPresets( } continue; } - const resolvedPins = result.addresses?.length - ? result.addresses - : result.trustedPrivateCapability.addresses; - let pins: readonly string[]; - try { - pins = assertTrustedPrivateEndpointCapability( - host, - resolvedPins, - result.trustedPrivateCapability, - ).addresses; - } catch { + const resolvedPins = [ + ...new Set( + (result.addresses?.length + ? result.addresses + : result.trustedPrivateCapability.addresses + ).map((address) => address.toLowerCase()), + ), + ].sort(); + const capabilityPins = [...result.trustedPrivateCapability.addresses] + .map((address) => address.toLowerCase()) + .sort(); + if ( + resolvedPins.length !== capabilityPins.length || + resolvedPins.some((address, index) => address !== capabilityPins[index]) + ) { throw new Error( - `Trusted private host '${host}' returned address pins that do not match its capability.`, + `Trusted private host '${host}' returned mixed public and private addresses. Trusted-private policy endpoints must resolve only to supported routed private addresses.`, ); } + const pins = capabilityPins; + if (pins.length === 0) { + throw new Error(`Trusted private host '${host}' produced no validated address pins.`); + } for (const { endpoint } of references) { endpoint.allowed_ips = [...pins]; for (const parsedPreset of parsedPresets) { diff --git a/src/lib/runner.ts b/src/lib/runner.ts index e3f08c6291e..777a987affc 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -11,13 +11,13 @@ import path from "node:path"; import { redirectInheritedChildStdoutToStderr } from "./cli/stdout-guard"; import { shellQuote } from "./core/shell-quote"; -import { detectDockerHost } from "./platform"; import { diagnosticPreview, NAME_ALLOWED_FORMAT, NAME_MAX_LENGTH, NAME_VALID_PATTERN, -} from "./sandbox-name-contract"; +} from "./name-validation"; +import { detectDockerHost } from "./platform"; import { redact, redactError, writeRedactedResult } from "./security/redact"; import { buildSubprocessEnv } from "./subprocess-env"; @@ -376,8 +376,7 @@ function runCaptureEx( } /** - * Validate a name (sandbox, instance, container) against the canonical - * OpenShell-compatible label rules. + * Validate a name (sandbox, instance, container) against RFC 1123 label rules. * Rejects shell metacharacters, path traversal, and empty/overlength names. */ function validateName(name: string, label = "name"): string { diff --git a/src/lib/sandbox-name-contract.ts b/src/lib/sandbox-name-contract.ts deleted file mode 100644 index 51afc48a2b0..00000000000 --- a/src/lib/sandbox-name-contract.ts +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Keep sandbox-only consumers behind one boundary so the canonical validation -// implementation does not become a high-fan-in dependency. -export { - diagnosticPreview, - isValidName, - NAME_ALLOWED_FORMAT, - NAME_MAX_LENGTH, - NAME_VALID_PATTERN, -} from "./name-validation"; diff --git a/src/lib/sandbox/privileged-exec.test.ts b/src/lib/sandbox/privileged-exec.test.ts index b7f406440a3..2598fbd6b48 100644 --- a/src/lib/sandbox/privileged-exec.test.ts +++ b/src/lib/sandbox/privileged-exec.test.ts @@ -2,54 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 import { createRequire } from "node:module"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; // The shared source hook preserves the writable CommonJS cache used by these mocks. const require = createRequire(import.meta.url); const requireCache: Record = require.cache as any; const helperPath = require.resolve("./privileged-exec"); const dockerRunPath = require.resolve("../adapters/docker/run"); -const portableLifecyclePath = require.resolve("../onboard/experimental/portable-demo-lifecycle"); const registryPath = require.resolve("../state/registry"); -const lifecycleGenerationPath = require.resolve("../state/registry/lifecycle-generation"); const { containerNameMatchesSandbox, selectDirectSandboxContainer } = require(helperPath); -function restoreRequireCacheEntry(modulePath: string, priorEntry: unknown): void { - if (priorEntry) requireCache[modulePath] = priorEntry; - else delete requireCache[modulePath]; -} - function withPrivilegedExecMocks( deps: { dockerCapture: (args: readonly string[], options?: { timeout?: number }) => string; - getSandbox: (name: string) => { - name?: string; - lifecycleGeneration?: string; - openshellDriver?: string | null; - } | null; + getSandbox: (name: string) => { name?: string; openshellDriver?: string | null } | null; listSandboxes: () => { sandboxes?: Array<{ name?: string | null }>; defaultSandbox?: string | null; }; - compareAndSetLegacySandboxLifecycleGeneration?: ( - expected: { name?: string }, - generation: string, - ) => boolean; - resolvePortableDemoPrivilegedExecTarget?: ( - sandboxName: string, - deps?: { - backfillRegistryGeneration?: (generation: string) => boolean; - registryGeneration?: string; - }, - ) => { assertRuntimeAuthority: () => void; containerId: string; dockerHost: string } | null; }, run: (helper: typeof import("./privileged-exec")) => T, ): T { const priorHelper = require.cache[helperPath]; const priorDockerRun = require.cache[dockerRunPath]; - const priorPortableLifecycle = require.cache[portableLifecyclePath]; const priorRegistry = require.cache[registryPath]; - const priorLifecycleGeneration = require.cache[lifecycleGenerationPath]; delete require.cache[helperPath]; requireCache[dockerRunPath] = { @@ -58,15 +34,6 @@ function withPrivilegedExecMocks( loaded: true, exports: { dockerCapture: deps.dockerCapture }, } as any; - requireCache[portableLifecyclePath] = { - id: portableLifecyclePath, - filename: portableLifecyclePath, - loaded: true, - exports: { - resolvePortableDemoPrivilegedExecTarget: - deps.resolvePortableDemoPrivilegedExecTarget ?? (() => null), - }, - } as any; requireCache[registryPath] = { id: registryPath, filename: registryPath, @@ -76,24 +43,18 @@ function withPrivilegedExecMocks( listSandboxes: deps.listSandboxes, }, } as any; - requireCache[lifecycleGenerationPath] = { - id: lifecycleGenerationPath, - filename: lifecycleGenerationPath, - loaded: true, - exports: { - compareAndSetLegacySandboxLifecycleGeneration: - deps.compareAndSetLegacySandboxLifecycleGeneration ?? (() => false), - }, - } as any; try { return run(require(helperPath)); } finally { - restoreRequireCacheEntry(helperPath, priorHelper); - restoreRequireCacheEntry(dockerRunPath, priorDockerRun); - restoreRequireCacheEntry(portableLifecyclePath, priorPortableLifecycle); - restoreRequireCacheEntry(registryPath, priorRegistry); - restoreRequireCacheEntry(lifecycleGenerationPath, priorLifecycleGeneration); + if (priorHelper) requireCache[helperPath] = priorHelper; + else delete requireCache[helperPath]; + + if (priorDockerRun) requireCache[dockerRunPath] = priorDockerRun; + else delete requireCache[dockerRunPath]; + + if (priorRegistry) requireCache[registryPath] = priorRegistry; + else delete requireCache[registryPath]; } } @@ -165,118 +126,6 @@ describe("privileged sandbox exec routing", () => { ); }); - it("uses the receipt-owned Podman socket when the default Docker daemon has no container (#8584)", () => { - let dockerPsCalls = 0; - const assertRuntimeAuthority = vi.fn(); - let backfillRegistryGeneration: ((generation: string) => boolean) | undefined; - const compareAndSetLegacySandboxLifecycleGeneration = vi.fn(() => true); - const resolvePortableDemoPrivilegedExecTarget = vi.fn( - ( - _sandboxName: string, - deps?: { backfillRegistryGeneration?: (generation: string) => boolean }, - ) => { - backfillRegistryGeneration = deps?.backfillRegistryGeneration; - return { - assertRuntimeAuthority, - containerId: "a".repeat(64), - dockerHost: "unix:///run/user/1001/podman/podman.sock", - }; - }, - ); - withPrivilegedExecMocks( - { - getSandbox: () => ({ - name: "alpha", - lifecycleGeneration: "current-generation", - openshellDriver: "docker", - }), - listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), - dockerCapture: () => { - dockerPsCalls += 1; - return ""; - }, - compareAndSetLegacySandboxLifecycleGeneration, - resolvePortableDemoPrivilegedExecTarget, - }, - ({ privilegedSandboxExecArgv }) => { - expect(privilegedSandboxExecArgv("alpha", ["id"], false, true)).toEqual([ - "--host", - "unix:///run/user/1001/podman/podman.sock", - "exec", - "--env", - "BASH_ENV=", - "--env", - "ENV=", - "--env", - "GCONV_PATH=", - "--env", - "GLIBC_TUNABLES=", - "--env", - "LD_AUDIT=", - "--env", - "LD_LIBRARY_PATH=", - "--env", - "LD_PRELOAD=", - "--env", - "LOCPATH=", - "--env", - "NODE_OPTIONS=", - "--env", - "PERL5OPT=", - "--env", - "PYTHONHOME=", - "--env", - "PYTHONINSPECT=", - "--env", - "PYTHONNOUSERSITE=1", - "--env", - "PYTHONPATH=", - "--env", - "PYTHONSTARTUP=", - "--env", - "PYTHONUSERBASE=", - "--env", - "RUBYOPT=", - "--user", - "root", - "a".repeat(64), - "id", - ]); - }, - ); - expect(dockerPsCalls).toBe(0); - expect(assertRuntimeAuthority).toHaveBeenCalledOnce(); - expect(resolvePortableDemoPrivilegedExecTarget).toHaveBeenCalledWith("alpha", { - backfillRegistryGeneration: expect.any(Function), - registryGeneration: "current-generation", - }); - expect(backfillRegistryGeneration?.("legacy-generation")).toBe(true); - expect(compareAndSetLegacySandboxLifecycleGeneration).toHaveBeenCalledWith( - expect.objectContaining({ name: "alpha", openshellDriver: "docker" }), - "legacy-generation", - ); - }); - - it("rejects a non-direct driver before consulting a stale portable receipt (#8584)", () => { - const resolvePortableDemoPrivilegedExecTarget = vi.fn(); - - withPrivilegedExecMocks( - { - getSandbox: () => ({ name: "alpha", openshellDriver: "kubernetes" }), - listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), - dockerCapture: vi.fn(), - resolvePortableDemoPrivilegedExecTarget, - }, - ({ privilegedSandboxExecArgv }) => { - expect(() => privilegedSandboxExecArgv("alpha", ["id"])).toThrow( - "refusing local Docker discovery for a non-direct driver", - ); - }, - ); - - expect(resolvePortableDemoPrivilegedExecTarget).not.toHaveBeenCalled(); - }); - it("bounds direct sandbox container discovery", () => { const discoveryCalls: Array<{ args: readonly string[]; @@ -405,16 +254,10 @@ describe("privileged sandbox exec routing", () => { it("rejects a Kubernetes registry owner before stale local-container discovery", () => { let dockerPsCalls = 0; - const resolvePortableDemoPrivilegedExecTarget = vi.fn(() => ({ - assertRuntimeAuthority: vi.fn(), - containerId: "a".repeat(64), - dockerHost: "unix:///run/user/1001/podman/podman.sock", - })); withPrivilegedExecMocks( { getSandbox: () => ({ name: "alpha", openshellDriver: "kubernetes" }), listSandboxes: () => ({ sandboxes: [{ name: "alpha" }], defaultSandbox: "alpha" }), - resolvePortableDemoPrivilegedExecTarget, dockerCapture: () => { dockerPsCalls += 1; return "stale-id\topenshell-alpha-stale\n"; @@ -427,7 +270,6 @@ describe("privileged sandbox exec routing", () => { }, ); expect(dockerPsCalls).toBe(0); - expect(resolvePortableDemoPrivilegedExecTarget).not.toHaveBeenCalled(); }); it("fails before docker discovery when registry disambiguation is unavailable", () => { diff --git a/src/lib/sandbox/privileged-exec.ts b/src/lib/sandbox/privileged-exec.ts index c466e2a0aa5..c45f59a60a4 100644 --- a/src/lib/sandbox/privileged-exec.ts +++ b/src/lib/sandbox/privileged-exec.ts @@ -2,15 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { dockerCapture } from "../adapters/docker/run"; -import { resolvePortableDemoPrivilegedExecTarget } from "../onboard/experimental/portable-demo-lifecycle"; import * as registry from "../state/registry"; -import { compareAndSetLegacySandboxLifecycleGeneration } from "../state/registry/lifecycle-generation"; const OPENSHELL_MANAGED_BY_LABEL = "openshell.ai/managed-by"; const OPENSHELL_MANAGED_BY_VALUE = "openshell"; const OPENSHELL_SANDBOX_NAME_LABEL = "openshell.ai/sandbox-name"; -type SandboxEntry = import("../state/registry").SandboxEntry; +type SandboxEntry = { + name?: string; + openshellDriver?: string | null; +}; type LabeledSandboxContainer = { id: string; @@ -209,41 +210,11 @@ function privilegedSandboxExecArgv( ): string[] { const entry = readSandboxEntry(sandboxName); if (!entry) throw missingRegistryEntryError(sandboxName); - const driver = normalizeDriver(entry.openshellDriver); + const driver = normalizeDriver(entry?.openshellDriver); if (driver !== null && driver !== "docker" && driver !== "vm") { throw unsupportedDirectDriverError(sandboxName, driver); } - const portableTarget = - driver === "docker" - ? resolvePortableDemoPrivilegedExecTarget(sandboxName, { - ...(entry.lifecycleGeneration ? { registryGeneration: entry.lifecycleGeneration } : {}), - backfillRegistryGeneration: (generation) => - compareAndSetLegacySandboxLifecycleGeneration(entry, generation), - }) - : null; - if (portableTarget) { - if (expectedContainerId !== undefined && portableTarget.containerId !== expectedContainerId) { - throw new Error( - `OpenShell container identity changed for sandbox '${sandboxName}'; ` + - "refusing privileged execution against a different container.", - ); - } - const sanitizedEnvArgs = sanitizeEnvironment - ? SANITIZED_PRIVILEGED_ENV.flatMap((value) => ["--env", value]) - : []; - portableTarget.assertRuntimeAuthority(); - return [ - "--host", - portableTarget.dockerHost, - "exec", - ...(stdin ? ["-i"] : []), - ...sanitizedEnvArgs, - "--user", - "root", - portableTarget.containerId, - ...cmd, - ]; - } + // Docker/direct-container is the only supported privileged mutation path. // Try it even when older registry entries do not record a driver, then fail // clearly if no matching sandbox container is running. diff --git a/src/lib/security/trusted-private-endpoint.test.ts b/src/lib/security/trusted-private-endpoint.test.ts index 0fb1b120008..92c9bfce12d 100644 --- a/src/lib/security/trusted-private-endpoint.test.ts +++ b/src/lib/security/trusted-private-endpoint.test.ts @@ -5,8 +5,6 @@ import { describe, expect, it, vi } from "vitest"; import { assertEndpointResolvesPublic, - assertTrustedPrivateEndpointCapability, - canonicalizeTrustedPrivateEndpointPins, type EndpointDnsLookupFn, isOperatorTrustablePrivateIp, isTrustedPrivateEndpointCapability, @@ -97,45 +95,9 @@ describe("trusted private endpoint preflight", () => { addresses: ["10.0.0.8"], trustedPrivateEndpoint: true, }); - expect(result.trustedPrivateCapability?.host).toBe("mcp.corp.example"); expect(result.trustedPrivateCapability?.addresses).toEqual(["10.0.0.8"]); expect(isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)).toBe(true); - expect( - isTrustedPrivateEndpointCapability({ host: "mcp.corp.example", addresses: ["10.0.0.8"] }), - ).toBe(false); - }); - - it("pins every accepted mixed public and trusted-private answer (#8176)", async () => { - const result = await assertEndpointResolvesPublic( - "https://mcp.corp.example/mcp", - vi.fn(async () => [ - { address: "93.184.216.34", family: 4 }, - { address: "10.0.0.8", family: 4 }, - ]), - { trustedPrivateHosts: ["mcp.corp.example"] }, - ); - - expect(result).toMatchObject({ - ok: true, - addresses: ["10.0.0.8", "93.184.216.34"], - trustedPrivateEndpoint: true, - }); - expect(result.trustedPrivateCapability).toMatchObject({ - host: "mcp.corp.example", - addresses: ["10.0.0.8", "93.184.216.34"], - }); - }); - - it.each([ - "http://127.0.0.1:8000/v1", - "https://inference.local/v1", - "http://host.openshell.internal:8000/v1", - ])("rejects the generic local endpoint %s before resolution (#8176)", async (endpointUrl) => { - const lookup = vi.fn(); - const result = await assertEndpointResolvesPublic(endpointUrl, lookup); - - expect(result).toMatchObject({ ok: false, reasonCode: "rejected" }); - expect(lookup).not.toHaveBeenCalled(); + expect(isTrustedPrivateEndpointCapability({ addresses: ["10.0.0.8"] })).toBe(false); }); it("rejects a private result for a different exact host (#8176)", async () => { @@ -171,40 +133,6 @@ describe("trusted private endpoint preflight", () => { }); describe("trusted private endpoint replay", () => { - it("uses one canonical pin contract for durable and capability authority (#8176)", async () => { - const result = await assertEndpointResolvesPublic( - "https://mcp.corp.example/mcp", - async () => [ - { address: "93.184.216.34", family: 4 }, - { address: "10.0.0.8", family: 4 }, - ], - { trustedPrivateHosts: ["mcp.corp.example"] }, - ); - const canonical = canonicalizeTrustedPrivateEndpointPins("MCP.CORP.EXAMPLE.", [ - "93.184.216.34", - "10.0.0.8", - ]); - - expect(canonical).toEqual({ - host: "mcp.corp.example", - addresses: ["10.0.0.8", "93.184.216.34"], - }); - expect( - assertTrustedPrivateEndpointCapability( - canonical.host, - canonical.addresses, - result.trustedPrivateCapability, - ).addresses, - ).toEqual(canonical.addresses); - expect(() => - assertTrustedPrivateEndpointCapability( - canonical.host, - ["10.0.0.8"], - result.trustedPrivateCapability, - ), - ).toThrow(/exact pins/); - }); - it("reissues capability authority from exact durable pins without DNS (#8267)", () => { const replay = replayTrustedPrivateEndpoint("MCP.CORP.EXAMPLE.", [ "fd00:0:0:0:0:0:0:10", @@ -213,28 +141,10 @@ describe("trusted private endpoint replay", () => { expect(replay.host).toBe("mcp.corp.example"); expect(replay.addresses).toEqual(["10.0.0.8", "fd00::10"]); - expect(replay.trustedPrivateCapability.host).toBe("mcp.corp.example"); expect(replay.trustedPrivateCapability.addresses).toEqual(replay.addresses); expect(isTrustedPrivateEndpointCapability(replay.trustedPrivateCapability)).toBe(true); }); - it("replays complete mixed pins for consumers that allow them (#8176)", () => { - const replay = replayTrustedPrivateEndpoint("mcp.corp.example", ["93.184.216.34", "10.0.0.8"]); - - expect(replay.trustedPrivateCapability).toMatchObject({ - host: "mcp.corp.example", - addresses: ["10.0.0.8", "93.184.216.34"], - }); - }); - - it("rejects mixed durable pins for consumers that require routed-private pins (#8267)", () => { - expect(() => - replayTrustedPrivateEndpoint("mcp.corp.example", ["10.0.0.8", "93.184.216.34"], { - requireAllPrivate: true, - }), - ).toThrow(/outside the supported private ranges/); - }); - it.each([ ["no pins", []], ["public pin", ["93.184.216.34"]], diff --git a/src/lib/security/trusted-private-endpoint.ts b/src/lib/security/trusted-private-endpoint.ts index bc4a6e036d7..543166248ed 100644 --- a/src/lib/security/trusted-private-endpoint.ts +++ b/src/lib/security/trusted-private-endpoint.ts @@ -42,14 +42,11 @@ OPERATOR_TRUSTABLE_PRIVATE_NETWORKS.addSubnet("fc00::", 7, "ipv6"); declare const trustedPrivateEndpointCapabilityBrand: unique symbol; /** - * Ephemeral proof that the shared SSRF preflight admitted the exact pins for - * an operator-trusted private host. The set includes every accepted public and - * private DNS answer so consumers can pin without re-resolving. Callers can - * carry this value, but only this module can issue one and enforcement - * boundaries validate its provenance. + * Ephemeral proof that the shared SSRF preflight admitted an exact set of + * operator-trusted private addresses. Callers can carry this value, but only + * this module can issue one and the curl boundary validates its provenance. */ export interface TrustedPrivateEndpointCapability { - readonly host: string; readonly addresses: readonly string[]; readonly [trustedPrivateEndpointCapabilityBrand]: true; } @@ -57,11 +54,9 @@ export interface TrustedPrivateEndpointCapability { const TRUSTED_PRIVATE_ENDPOINT_CAPABILITIES = new WeakSet(); function issueTrustedPrivateEndpointCapability( - host: string, addresses: readonly string[], ): TrustedPrivateEndpointCapability { const capability = Object.freeze({ - host, addresses: Object.freeze([...new Set(addresses.map(normalizeIpLiteral))].sort()), }) as unknown as TrustedPrivateEndpointCapability; TRUSTED_PRIVATE_ENDPOINT_CAPABILITIES.add(capability); @@ -99,20 +94,21 @@ export interface EndpointSsrfPreflightResult { /** Human-readable reason, present only when `ok === false`. */ reason?: string; /** Stable failure classification for callers that must not parse `reason`. */ - reasonCode?: "private-answer" | "rejected" | "unresolved"; + reasonCode?: "mixed-answer" | "private-answer" | "rejected" | "unresolved"; /** Exact rejected DNS answer when `reasonCode` identifies an address failure. */ offendingAddress?: string; /** - * Every validated address the endpoint host resolved to, for connection + * Validated public addresses the endpoint host resolved to, for connection * pinning (curl `--resolve`) so a subsequent probe cannot re-resolve the name - * to a rebound private/internal address (TOCTOU). Present when `ok === true` - * and pinning applies. An empty array is the explicit trusted-no-pin result - * for inference-local endpoints and IP literals. Callers must preserve it so - * credentialed probes bypass ambient proxies even when no curl `--resolve` - * argument is needed. + * to a rebound private/internal address (TOCTOU). Present only when + * `ok === true` and pinning applies — resolved public names and public IP + * literals. An empty array is the explicit trusted-no-pin capability for + * loopback, OpenShell-managed aliases, and public IP literals. Callers must + * preserve it so credentialed probes bypass ambient proxies even when no + * curl `--resolve` argument is needed. */ addresses?: string[]; - /** Non-forgeable proof of the exact accepted pins for an operator-trusted private host. */ + /** Non-forgeable proof of the exact private addresses admitted by the operator allowlist. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; /** True only when an exact operator allowlist entry admitted a private address. */ trustedPrivateEndpoint?: true; @@ -121,7 +117,7 @@ export interface EndpointSsrfPreflightResult { export interface EndpointSsrfPreflightOptions { /** Exact hostnames or IP literals the operator explicitly trusts on a private network. */ trustedPrivateHosts?: readonly string[]; - /** Used by inference compatibility flows to disallow their local-loopback exception. */ + /** Keep false for hosted-only flows that must never connect to a host loopback service. */ allowExplicitLoopback?: boolean; } @@ -131,13 +127,6 @@ function normalizeIpLiteral(address: string): string { return hostname.slice(1, -1).toLowerCase(); } -function normalizeEndpointHostname(hostname: string): string { - const value = - hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; - const normalized = value.replace(/\.$/, "").toLowerCase(); - return isIP(normalized) === 0 ? normalized : normalizeIpLiteral(normalized); -} - /** Normalize one exact hostname or IP literal for an operator trust decision. */ export function normalizeTrustedPrivateHost(raw: string): string { const value = String(raw).trim(); @@ -203,106 +192,31 @@ export interface TrustedPrivateEndpointReplay { readonly trustedPrivateCapability: TrustedPrivateEndpointCapability; } -export interface TrustedPrivateEndpointPinOptions { - /** Require every pin to be in a supported routed-private range. */ - requireAllPrivate?: boolean; -} - -export interface TrustedPrivateEndpointPins { - readonly host: string; - readonly addresses: readonly string[]; -} - -/** - * Validate and canonicalize exact pins without granting network authority. - * Callers must use a provenance-checked capability before enforcement. - */ -export function canonicalizeTrustedPrivateEndpointPins( +/** Reissue in-process capability authority from exact durable private pins. */ +export function replayTrustedPrivateEndpoint( host: string, addresses: readonly string[], - options: TrustedPrivateEndpointPinOptions = {}, -): TrustedPrivateEndpointPins { +): TrustedPrivateEndpointReplay { const normalizedHost = normalizeTrustedPrivateHost(host); if (addresses.length === 0) { throw new Error(`trusted private host "${normalizedHost}" has no recorded address pins`); } - const { isPrivateIp } = require("../private-networks") as typeof import("../private-networks"); const normalizedAddresses = addresses.map((address) => { - if (typeof address !== "string" || isIP(address) === 0 || address.includes("%")) { - throw new Error( - `trusted private host "${normalizedHost}" has a disallowed recorded address pin`, - ); - } - const normalizedAddress = normalizeIpLiteral(address); - if (isPrivateIp(normalizedAddress) && !isOperatorTrustablePrivateIp(normalizedAddress)) { + if (typeof address !== "string" || !isOperatorTrustablePrivateIp(address)) { throw new Error( `trusted private host "${normalizedHost}" has a disallowed recorded address pin`, ); } - return normalizedAddress; + return normalizeIpLiteral(address); }); if (new Set(normalizedAddresses).size !== normalizedAddresses.length) { throw new Error(`trusted private host "${normalizedHost}" has duplicate recorded address pins`); } - if (!normalizedAddresses.some((address) => isOperatorTrustablePrivateIp(address))) { - throw new Error( - `trusted private host "${normalizedHost}" has no recorded address pin in a supported private range`, - ); - } - if ( - options.requireAllPrivate && - normalizedAddresses.some((address) => !isOperatorTrustablePrivateIp(address)) - ) { - throw new Error( - `trusted private host "${normalizedHost}" has an address pin outside the supported private ranges`, - ); - } + const pinnedAddresses = Object.freeze([...normalizedAddresses].sort()); return Object.freeze({ host: normalizedHost, - addresses: Object.freeze([...normalizedAddresses].sort()), - }); -} - -/** Prove that an issued capability grants the exact canonical host and pins. */ -export function assertTrustedPrivateEndpointCapability( - host: string, - addresses: readonly string[], - capability: unknown, - options: TrustedPrivateEndpointPinOptions = {}, -): TrustedPrivateEndpointReplay { - if (!isTrustedPrivateEndpointCapability(capability)) { - throw new Error("trusted private endpoint capability was not issued by the SSRF preflight"); - } - const pins = canonicalizeTrustedPrivateEndpointPins(host, addresses, options); - if (capability.host !== pins.host) { - throw new Error( - `trusted private endpoint capability host '${capability.host}' does not match '${pins.host}'`, - ); - } - if ( - pins.addresses.length !== addresses.length || - pins.addresses.some((address, index) => address !== addresses[index]) || - capability.addresses.length !== pins.addresses.length || - capability.addresses.some((address, index) => address !== pins.addresses[index]) - ) { - throw new Error("trusted private endpoint capability addresses do not match the exact pins"); - } - return Object.freeze({ - ...pins, - trustedPrivateCapability: capability, - }); -} - -/** Reissue in-process capability authority from exact durable private pins. */ -export function replayTrustedPrivateEndpoint( - host: string, - addresses: readonly string[], - options: TrustedPrivateEndpointPinOptions = {}, -): TrustedPrivateEndpointReplay { - const pins = canonicalizeTrustedPrivateEndpointPins(host, addresses, options); - return Object.freeze({ - ...pins, - trustedPrivateCapability: issueTrustedPrivateEndpointCapability(pins.host, pins.addresses), + addresses: pinnedAddresses, + trustedPrivateCapability: issueTrustedPrivateEndpointCapability(pinnedAddresses), }); } @@ -318,9 +232,9 @@ export function replayTrustedPrivateEndpoint( * authoritative config-write DNS-pinning boundary (`validateUrlValueWithDnsResult`) * which runs later, before the URL is persisted. * - * Generic endpoint admission rejects loopback and managed host aliases. The - * inference compatibility wrapper owns its narrower local exceptions. A - * public name that resolves to loopback is treated as a rebinding attempt and + * Loopback (`127.0.0.0/8`, `::1`, and `localhost`) remains exempt only when the + * endpoint hostname is itself loopback and the caller permits local inference. + * A public name that resolves to loopback is treated as a rebinding attempt and * rejected. The check fails closed on a resolver error or empty result. * * See PR #6293 PRA-4 (GPT-5.5 advisor). @@ -347,9 +261,10 @@ export async function assertEndpointResolvesPublic( const { isLoopbackHostname, isPrivateHostname, isPrivateIp } = require("../private-networks") as typeof import("../private-networks"); - const normalizedHostname = normalizeEndpointHostname(hostname); + let normalizedHostname: string; let trustedPrivateHosts: string[]; try { + normalizedHostname = normalizeTrustedPrivateHost(hostname); trustedPrivateHosts = (options.trustedPrivateHosts ?? []).map(normalizeTrustedPrivateHost); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -357,14 +272,24 @@ export async function assertEndpointResolvesPublic( } const trustedPrivateHost = trustedPrivateHosts.includes(normalizedHostname); - if (isLoopbackHostname(hostname) || isOpenShellManagedHost(hostname)) { - return { - ok: false, - reason: `endpoint host "${hostname}" is a private/internal address`, - reasonCode: "rejected", - }; + // An explicit loopback host is valid only for flows that can select local inference. + if (isLoopbackHostname(hostname)) { + return options.allowExplicitLoopback !== false + ? { ok: true, addresses: [] } + : { + ok: false, + reason: `endpoint host "${hostname}" is a private/internal address`, + reasonCode: "rejected", + }; } + // NemoClaw's own OpenShell-managed aliases (inference.local, host.*.internal) + // resolve to the managed proxy/loopback by design and are trusted, not + // rebinding surfaces. Exempt like loopback — connect normally (no pinning) — + // and exempt BEFORE isPrivateHostname, which would otherwise reject their + // reserved .local/.internal suffixes (#6293). + if (isOpenShellManagedHost(hostname)) return { ok: true, addresses: [] }; + // A literal private IP or reserved private name is refused without resolving. if (isPrivateHostname(hostname) && !trustedPrivateHost) { return { @@ -383,9 +308,7 @@ export async function assertEndpointResolvesPublic( ? { ok: true, addresses: [], - trustedPrivateCapability: issueTrustedPrivateEndpointCapability(normalizedHostname, [ - bare, - ]), + trustedPrivateCapability: issueTrustedPrivateEndpointCapability([bare]), trustedPrivateEndpoint: true, } : { @@ -413,44 +336,45 @@ export async function assertEndpointResolvesPublic( reasonCode: "unresolved", }; } - const resolvedAddresses: string[] = []; for (const { address } of addresses) { - if (typeof address !== "string" || isIP(address) === 0) { - return { - ok: false, - reason: `endpoint host "${hostname}" returned a malformed DNS address`, - reasonCode: "rejected", - }; - } - const normalizedAddress = normalizeIpLiteral(address); // A resolved private address — including loopback reached via a public name // (DNS rebinding) — is refused; the explicit-loopback case returned above. - if ( - isPrivateIp(normalizedAddress) && - (!trustedPrivateHost || !isOperatorTrustablePrivateIp(normalizedAddress)) - ) { + if (isPrivateIp(address) && (!trustedPrivateHost || !isOperatorTrustablePrivateIp(address))) { return { ok: false, - reason: `endpoint host "${hostname}" resolves to private/internal address "${normalizedAddress}"`, + reason: `endpoint host "${hostname}" resolves to private/internal address "${address}"`, reasonCode: "private-answer", - offendingAddress: normalizedAddress, + offendingAddress: address, }; } - resolvedAddresses.push(normalizedAddress); } - const canonicalAddresses = [...new Set(resolvedAddresses)].sort(); + const resolvedAddresses = addresses.map(({ address }) => address); const trustedPrivateAddresses = resolvedAddresses.filter((address) => isPrivateIp(address)); + if ( + trustedPrivateHost && + trustedPrivateAddresses.length > 0 && + trustedPrivateAddresses.length !== resolvedAddresses.length + ) { + const offendingAddress = resolvedAddresses.find( + (address) => !isOperatorTrustablePrivateIp(address), + ); + return { + ok: false, + reason: + `endpoint host "${hostname}" resolves to mixed public and private addresses` + + (offendingAddress ? `, including untrusted answer "${offendingAddress}"` : ""), + reasonCode: "mixed-answer", + offendingAddress, + }; + } return trustedPrivateHost && trustedPrivateAddresses.length > 0 ? { ok: true, - addresses: canonicalAddresses, - trustedPrivateCapability: issueTrustedPrivateEndpointCapability( - normalizedHostname, - canonicalAddresses, - ), + addresses: resolvedAddresses, + trustedPrivateCapability: issueTrustedPrivateEndpointCapability(trustedPrivateAddresses), trustedPrivateEndpoint: true, } - : { ok: true, addresses: canonicalAddresses }; + : { ok: true, addresses: resolvedAddresses }; } /** diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 99d2e5663f9..10309b1a1d0 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -630,7 +630,7 @@ describe("shields command flow", () => { it("preserves a live transition owner instead of attempting portable process-tree takeover", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); fs.mkdirSync(stateDir, { recursive: true }); - const sandboxName = "live-owner"; + const sandboxName = "live-transition-owner"; const processToken = "b".repeat(32); const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); const owner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 5000)"], { @@ -685,7 +685,7 @@ describe("shields command flow", () => { ])("enters durable containment for a %s-token transition whose owner exited in the recovery gap", (_tokenRelationship, transitionOwnerToken) => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); fs.mkdirSync(stateDir, { recursive: true }); - const sandboxName = "dead-owner"; + const sandboxName = "dead-transition-owner"; const processToken = "c".repeat(32); const transitionLockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); fs.writeFileSync( diff --git a/src/lib/shields/mcp-policy-transition.test.ts b/src/lib/shields/mcp-policy-transition.test.ts index b93b82c21ed..be00142d5aa 100644 --- a/src/lib/shields/mcp-policy-transition.test.ts +++ b/src/lib/shields/mcp-policy-transition.test.ts @@ -15,10 +15,6 @@ import { buildMcpBridgePolicyName, buildMcpBridgePolicyYaml, } from "../actions/sandbox/mcp-bridge-policy-render"; -import { - isOperatorTrustablePrivateIp, - replayTrustedPrivateEndpoint, -} from "../security/trusted-private-endpoint"; import type { SandboxEntry } from "../state/registry"; import { assertLegacyMcpPolicyRestoreSafe, @@ -32,20 +28,11 @@ function registeredPolicy( server: string, address: string, ): NonNullable[number] { - const host = `${server}.example.com`; - const target = isOperatorTrustablePrivateIp(address) - ? (() => { - const replay = replayTrustedPrivateEndpoint(host, [address]); - return { - addresses: [...replay.addresses], - trustedPrivateCapability: replay.trustedPrivateCapability, - trustedPrivateHost: replay.host, - }; - })() - : { addresses: [address] }; return { name: buildMcpBridgePolicyName(server), - content: buildMcpBridgePolicyYaml(server, `https://${host}/mcp`, ADAPTER, target), + content: buildMcpBridgePolicyYaml(server, `https://${server}.example.com/mcp`, ADAPTER, [ + address, + ]), sourcePath: MCP_BRIDGE_POLICY_SOURCE, }; } diff --git a/src/lib/shields/timer-process.test.ts b/src/lib/shields/timer-process.test.ts index e1c61c6c63f..ae3a729470c 100644 --- a/src/lib/shields/timer-process.test.ts +++ b/src/lib/shields/timer-process.test.ts @@ -31,7 +31,7 @@ describe("detached Shields timer process", () => { async () => { const { killTimer } = await import("./timer-control"); const stateDir = path.join(tmpHome, ".nemoclaw", "state"); - const sandboxName = "cooperative-stop"; + const sandboxName = "cooperative-cancellation"; const snapshotPath = path.join(stateDir, "snapshot.yaml"); const restoreAtIso = new Date(Date.now() + 60_000).toISOString(); const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); diff --git a/src/lib/shields/timer-recovery-budget.test.ts b/src/lib/shields/timer-recovery-budget.test.ts index ca724d5540b..6eee12f302f 100644 --- a/src/lib/shields/timer-recovery-budget.test.ts +++ b/src/lib/shields/timer-recovery-budget.test.ts @@ -148,7 +148,7 @@ describe("detached Shields recovery budget", { timeout: 15_000 }, () => { }); it("waits beyond the recovery budget for a verified live lifecycle owner, then restores", async () => { - const sandboxName = "healthy-owner"; + const sandboxName = "healthy-backup-owner"; let markOwnerEntered!: () => void; let releaseOwner!: () => void; const ownerEntered = new Promise((resolve) => { @@ -253,7 +253,9 @@ describe("detached Shields recovery budget", { timeout: 15_000 }, () => { }); it("retains its exact deadline when publication and containment both fail", async () => { - const { args, lockPath, markerPath, sandboxName, timer } = await createFixture("publish-gate"); + const { args, lockPath, markerPath, sandboxName, timer } = await createFixture( + "publication-retained-gate", + ); const containmentPath = `${lockPath}.containment`; const deadlinePath = `${lockPath}.deadline`; const originalAsyncLink = fs.promises.link.bind(fs.promises); @@ -290,7 +292,7 @@ describe("detached Shields recovery budget", { timeout: 15_000 }, () => { expect(audits).toHaveLength(1); expect(audits[0]?.error).toContain("recovery failed after 1 attempt"); expect(audits[0]?.error).toContain("Correct the state-directory write failure"); - expect(audits[0]?.error).toContain("`nemoclaw publish-gate shields status`"); + expect(audits[0]?.error).toContain("`nemoclaw publication-retained-gate shields status`"); expect(audits[0]?.error).not.toContain("setup is retrying"); await expect( withMcpLifecycleLock( @@ -305,7 +307,7 @@ describe("detached Shields recovery budget", { timeout: 15_000 }, () => { }); it("exits immediately when durable containment already owns recovery", async () => { - const { args, lockPath, sandboxName, timer } = await createFixture("existing-contain"); + const { args, lockPath, sandboxName, timer } = await createFixture("existing-containment"); beginCommittedMcpLifecycleContainmentSync( sandboxName, PROCESS_TOKEN, diff --git a/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index 2500b75b772..ef4dae77a15 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -899,7 +899,10 @@ describe("shields timer authorization", () => { it("retains the exact deadline gates when revoked containment rollback cannot be proven", async () => { const timer = await import("./timer"); - const fixture = createFailedRestoreFixture("contain-rollback", timer.parseTimerArgs); + const fixture = createFailedRestoreFixture( + "containment-publication-rollback-failure", + timer.parseTimerArgs, + ); const { args, containmentPath, deadlinePath, markerPath, mutationLockPath, writeMarker } = fixture; const replacementToken = "e".repeat(32); @@ -941,7 +944,7 @@ describe("shields timer authorization", () => { it("retains the exact deadline gates when durable containment cannot be committed", async () => { const timer = await import("./timer"); const { args, containmentPath, deadlinePath, markerPath, mutationLockPath, stateDir } = - createFailedRestoreFixture("retry-contain", timer.parseTimerArgs); + createFailedRestoreFixture("retry-containment-failure", timer.parseTimerArgs); const originalLink = fs.linkSync.bind(fs); const rejectContainment = (): never => { const error = new Error("simulated containment commit failure") as NodeJS.ErrnoException; @@ -978,7 +981,7 @@ describe("shields timer authorization", () => { it("stops retrying when transition takeover commits durable containment", async () => { const timer = await import("./timer"); const { args, markerPath, mutationLockPath, sandboxName, stateDir } = - createFailedRestoreFixture("takeover-contain", timer.parseTimerArgs); + createFailedRestoreFixture("takeover-containment", timer.parseTimerArgs); shieldsIndexMock.prepareAutoRestoreTransitionTakeover.mockImplementationOnce(() => { beginCommittedMcpLifecycleContainmentSync( sandboxName, diff --git a/src/lib/state/registry-mcp.ts b/src/lib/state/registry-mcp.ts index d98d76ce976..335eb69590b 100644 --- a/src/lib/state/registry-mcp.ts +++ b/src/lib/state/registry-mcp.ts @@ -4,7 +4,7 @@ import { isObjectRecord } from "../core/json-types"; import { isBlockedMcpUrlTargetHost, MCP_SERVER_URL_MAX_LENGTH } from "../security/mcp-url-target"; import { - canonicalizeTrustedPrivateEndpointPins, + isOperatorTrustablePrivateIp, normalizeTrustedPrivateHost, } from "../security/trusted-private-endpoint"; @@ -157,24 +157,26 @@ function normalizeMcpBridgeEntry(server: string, value: unknown): McpBridgeEntry let allowedIps: string[] | undefined; const rawAllowedIps = value.allowedIps; if (trustedPrivateHost) { - if (!Array.isArray(rawAllowedIps)) return null; - let canonicalPins: readonly string[]; - try { - canonicalPins = canonicalizeTrustedPrivateEndpointPins( - trustedPrivateHost, - rawAllowedIps as readonly string[], - { requireAllPrivate: true }, - ).addresses; - } catch { + if ( + !Array.isArray(rawAllowedIps) || + rawAllowedIps.length === 0 || + !rawAllowedIps.every( + (address): address is string => + typeof address === "string" && + address === address.toLowerCase() && + isOperatorTrustablePrivateIp(address), + ) + ) { return null; } + const validatedAllowedIps = rawAllowedIps as string[]; + allowedIps = [...new Set(validatedAllowedIps)].sort(); if ( - canonicalPins.length !== rawAllowedIps.length || - canonicalPins.some((address, index) => address !== rawAllowedIps[index]) + allowedIps.length !== validatedAllowedIps.length || + allowedIps.some((address, index) => address !== validatedAllowedIps[index]) ) { return null; } - allowedIps = [...canonicalPins]; } else if (rawAllowedIps !== undefined) { return null; } diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index 2a1b5b05a2a..8b1a738a936 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -161,24 +161,6 @@ describe("sandbox registry normalization", () => { }); }); - it("backfills a lifecycle generation only for the unchanged legacy Docker row (#8584)", async () => { - const registry = await loadRegistryWith({}); - const { compareAndSetLegacySandboxLifecycleGeneration } = await import( - "./registry/lifecycle-generation" - ); - registry.registerSandbox({ name: "portable", openshellDriver: "docker" }); - const expected = registry.getSandbox("portable")!; - - expect(compareAndSetLegacySandboxLifecycleGeneration(expected, "a".repeat(64))).toBe(true); - expect(registry.getSandbox("portable")?.lifecycleGeneration).toBe("a".repeat(64)); - expect(compareAndSetLegacySandboxLifecycleGeneration(expected, "b".repeat(64))).toBe(false); - - registry.registerSandbox({ name: "changed", openshellDriver: "docker" }); - const stale = registry.getSandbox("changed")!; - registry.updateSandbox("changed", { model: "replacement" }); - expect(compareAndSetLegacySandboxLifecycleGeneration(stale, "c".repeat(64))).toBe(false); - }); - it("round-trips immutable serving profile provenance while preserving legacy rows (#8246)", async () => { const registry = await loadRegistryWith({ legacy: { name: "legacy" } }); expect(registry.getSandbox("legacy")?.servingProfileProvenance).toBeUndefined(); diff --git a/src/lib/state/registry/lifecycle-generation.ts b/src/lib/state/registry/lifecycle-generation.ts deleted file mode 100644 index f975afbefb1..00000000000 --- a/src/lib/state/registry/lifecycle-generation.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { isDeepStrictEqual } from "node:util"; -import { withLock } from "./lock"; -import { load, save } from "./persistence"; -import type { SandboxEntry } from "./types"; - -/** Claim a lifecycle generation for one unchanged legacy Docker registry row. */ -export function compareAndSetLegacySandboxLifecycleGeneration( - expected: SandboxEntry, - lifecycleGeneration: string, -): boolean { - if ( - expected.openshellDriver !== "docker" || - expected.lifecycleGeneration !== undefined || - lifecycleGeneration.length === 0 || - lifecycleGeneration.length > 256 || - /[\u0000-\u001f\u007f-\u009f]/u.test(lifecycleGeneration) - ) { - return false; - } - return withLock(() => { - const data = load(); - const current = data.sandboxes[expected.name]; - if (!current || !isDeepStrictEqual(current, expected)) return false; - current.lifecycleGeneration = lifecycleGeneration; - save(data); - return true; - }); -} diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index 17148690b6f..7e5631c67e9 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -159,7 +159,7 @@ function buildGatewayLogHint(sandboxName: string, customRuntimeHint: string | nu `The gateway probe failed after retrying. Inspect the in-sandbox gateway log with ` + `\`nemoclaw ${sandboxName} logs\` (the gateway writes to /tmp/gateway.log inside the sandbox when it starts). ` + `If the sandbox itself never came up, also check the host-side OpenShell gateway log at ` + - `~/.local/state/nemoclaw/openshell-docker-gateway-v0.0.85/openshell-gateway.log ` + + `~/.local/state/nemoclaw/openshell-docker-gateway/openshell-gateway.log ` + `(or ~/.local/state/openshell/openshell-gateway.log on older installs).` ); }