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..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.99" -max_openshell_version: "0.0.99" +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/nemoclaw-blueprint/policies/presets/brew.yaml b/nemoclaw-blueprint/policies/presets/brew.yaml index d6cda96f539..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.99. 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 5b12d93f165..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.99; revalidate after 0.0.99. +// 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 142fbefc658..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.99 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.99 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/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index dff8bc45081..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.99" ;; + 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.99:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "35725a358e42ef7f0f0393035536da317706b0febcc459a2011e0555f6c2b71c" + v0.0.85:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "078fa086f506832c3d47d992e6109f26074bdd55916ce268e47c3971423459eb" ;; - v0.0.99:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "d00cbf0d8779c01ddea6453ead2ad4db3d89a1f14eb6f0785f7919f42813a279" + v0.0.85:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "3cf353e7994d5835a233fe0641f9a860779190b054d0f90a04c897be782734b8" ;; *) return 1 diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh index 75c58426e3e..64cc2ae0d1f 100755 --- a/scripts/check-installer-hash.sh +++ b/scripts/check-installer-hash.sh @@ -39,9 +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" ) 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..77fc85f16e9 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.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.99" +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.99" +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.99:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "35725a358e42ef7f0f0393035536da317706b0febcc459a2011e0555f6c2b71c" + v0.0.85:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "078fa086f506832c3d47d992e6109f26074bdd55916ce268e47c3971423459eb" ;; - v0.0.99:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "d00cbf0d8779c01ddea6453ead2ad4db3d89a1f14eb6f0785f7919f42813a279" + v0.0.85:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "3cf353e7994d5835a233fe0641f9a860779190b054d0f90a04c897be782734b8" ;; - v0.0.99:openshell-aarch64-apple-darwin.tar.gz) - printf '%s\n' "e31cac5360e2adf3c971d5742a516626c58acf2fd3db4dcb0e45804def3dc844" + v0.0.85:openshell-aarch64-apple-darwin.tar.gz) + printf '%s\n' "522c963f9515c7325b978e89022de76227ac245eefe1371292af1424434e2067" ;; - v0.0.99:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "640d204dc3c6bc28bffa1f3d870897fc23bbc5ec0151a6c642083e958455cb49" + v0.0.85:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "718cc9f942f88565cacb13c39717b128d6acc8d336212d42d26243f36ab19ece" ;; - v0.0.99:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "3a5d3092ae34356beb0ff2a920f9a87af4233c7a1086a53cd9429d48358f5c09" + v0.0.85:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "09f2823f6e9c5f70f4482b200206eac455d789618da4ebe4acff042d794e7162" ;; - v0.0.99:openshell-gateway-aarch64-apple-darwin.tar.gz) - printf '%s\n' "4340619292ecb565f90eb2250db504baa37dd410361b366b42e174d34512cb6c" + v0.0.85:openshell-gateway-aarch64-apple-darwin.tar.gz) + printf '%s\n' "5de3e08ad1bdb0cdd01373999f537edca3d8aca22ae1c29bc9926969fe401e45" ;; - v0.0.99:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "84caed3dec4390e0938e89b38b1256d31e8970b4bfd85437bf92ed79f5b1ff05" + v0.0.85:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "94306f057d862cd5c34a0daa7692491733bc5ca528a7b92f9f62f717fb70a9be" ;; - v0.0.99:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "c758e7dc2b8c904baa01e2ccce0f08daf96ede0c648478b23346d8c4dd16f432" + v0.0.85:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "2c52b2971aecf125e41ed160d8d2f2addf04031906ca88f120ae3d436dd6b8f7" ;; - v0.0.99:openshell.rb) - printf '%s\n' "8dd34fc17ee9a30327664a18c9509c8a765cb010de38cda8e22841bddbe92713" + v0.0.85:openshell.rb) + printf '%s\n' "f53c62777fed23b42427822d231670451ee4358efeb2660c41a7a38919211b23" ;; *) return 1 @@ -291,10 +291,10 @@ 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) + printf '%s\n' "0.0.85" ;; *) return 1 @@ -356,6 +356,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 @@ -801,6 +815,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 @@ -997,4 +1014,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 98cba197162..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.99 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 - 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 @@ -3871,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 647f1c202df..5cd39b5cf9a 100644 --- a/scripts/lib/openshell-gateway.service.in +++ b/scripts/lib/openshell-gateway.service.in @@ -9,8 +9,9 @@ After=default.target [Service] Type=simple -StateDirectory=openshell/gateway -Environment=OPENSHELL_LOCAL_TLS_DIR=%S/openshell/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/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 95355bb5b4e..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.99.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/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/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/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/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 4e68d8e988c..5f46646d015 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -51,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"; @@ -92,22 +91,14 @@ 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(), captureOpenshell: (args, timeoutMs) => { const result = captureOpenshell([...args], { 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 2f6a6a8e876..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 { @@ -12,8 +10,8 @@ import { } from "../../security/mcp-url-target"; import { TOKEN_PREFIX_PATTERNS } from "../../security/secret-patterns"; import { - assertTrustedPrivateEndpointCapability, assertEndpointResolvesPublic, + 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.99 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.99 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.99 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.99 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 cd9477713ad..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, @@ -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.99.json"; +import childVisibleCredentialManifest from "./openshell-child-visible-credentials.v0.0.85.json"; export { MCP_SERVER_URL_MAX_LENGTH, @@ -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.99.json b/src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.99.json deleted file mode 100644 index 95312f0bfed..00000000000 --- a/src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.99.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "openshellVersion": "0.0.99", - "openshellCommit": "8c7dd148a9e6360c9d5b2830e339a0dc4b3f3032", - "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/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/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 e8ee88307f7..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.99 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 040deedaf48..7e95c411d5c 100644 --- a/src/lib/inference/endpoint-ssrf-preflight.ts +++ b/src/lib/inference/endpoint-ssrf-preflight.ts @@ -1,37 +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"); - if (isLoopbackHostname(hostname) || 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 661921cd148..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,7 +2786,8 @@ async function createSandboxWithBaseImageResolution( hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, - ...lifecycleRegistrationFields, + dashboardForwardEnabled: manageDashboardForward, + ...recreateRuntime.registrationFields, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, }), @@ -3625,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, @@ -3664,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); @@ -3727,7 +3840,7 @@ async function preflightAuthoritativeRebuildTarget( await authoritativeRebuildTarget.preflightAuthoritativeRebuildTarget( { ...opts, controlUiPort: opts.controlUiPort ?? null }, { - resolveBaselinePolicy: resolveSandboxBaselinePolicy, + resolveBaselinePolicy: (sandboxName) => policies.resolveSandboxBaselinePolicy(sandboxName), runFatalRuntimePreflight: () => fatalRuntimePreflight.runFatalOnboardRuntimePreflight( { @@ -3807,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, @@ -4338,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, @@ -4395,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") = @@ -4547,7 +4661,7 @@ module.exports = { getSuggestedPolicyPresets, computeSetupPresetSuggestions, mergeRequiredHermesToolGatewayPolicyPresets, - filterSetupPolicyPresets, + filterSetupPolicyPresets: policies.filterSetupPolicyPresets, LOCAL_INFERENCE_PROVIDERS, presetsCheckboxSelector, selectPolicyTier, 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/command.test.ts b/src/lib/onboard/command.test.ts index fd03af62888..afe8c75e2fa 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -11,8 +11,10 @@ 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"; +import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation"; afterEach(() => { vi.unstubAllEnvs(); @@ -435,7 +437,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", @@ -449,6 +451,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", @@ -466,14 +473,14 @@ 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", }); - expect(env).toMatchObject({ + expect(env).toEqual({ NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile", NEMOCLAW_PROVIDER: "previous-provider", NEMOCLAW_MODEL: "previous-model", @@ -484,6 +491,158 @@ describe("onboard command options", () => { }); }); + it("never falls back to local inference when the portable bootstrap 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 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 = { + 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).toEqual({ + 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.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 = { + 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({ + 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"); @@ -519,6 +678,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", @@ -530,6 +694,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) => { @@ -544,6 +709,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 1c66ec43d1c..c8d87f75cb8 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,27 @@ 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); + if (!hostedInference) { + throw new PortableInferenceSourceError( + "Portable hosted inference requires infrakey.txt on the desktop or an owner-only bootstrap credential at /run/nemoclaw/portable-bootstrap.", + ); + } + const portableEnvDefaults: Record = { + [EXPERIMENTAL_PROFILE_ENV]: options.experimentalProfile, [TOOL_DISCLOSURE_ENV]: "direct", - NEMOCLAW_PROVIDER: "ollama", - NEMOCLAW_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", - } as const; + COMPATIBLE_API_KEY: hostedInference.apiKey, + NEMOCLAW_ENDPOINT_URL: hostedInference.baseUrl, + NEMOCLAW_PREFERRED_API: "openai-completions", + }; const previousPortableEnv = new Map(); const restore = () => { for (const [key, value] of previousPortableEnv) { @@ -428,6 +450,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 +463,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/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/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 5ec56658ec3..0b161768be2 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -35,7 +35,7 @@ import * as vmDriverProcess from "./vm-driver-process"; const OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS: Readonly> = { "0.0.72": "sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", - "0.0.99": "sha256:ea3632b6e9528e2309103af5b6949606fcdc83ca1f69e8db81482a25bea84bb6", + "0.0.85": "sha256:f4226253a3525c3832adac5b38b419a0f27d1e915effe565b5885e20f93cd5e9", }; 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 7f060a933e0..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,10 +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 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 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)); @@ -57,12 +47,11 @@ type CommandResult = { }; interface PortableDemoLifecycleReceipt { - schemaVersion: 1 | 2 | 3; + schemaVersion: 1 | 2; sandboxName: string; sandboxId: string; containerId: string; dashboardPort: number; - registryGeneration?: string; } interface PodmanContainerInspection { @@ -71,22 +60,12 @@ interface PodmanContainerInspection { running: boolean; } -export interface PortableDemoPrivilegedExecTarget { - readonly assertRuntimeAuthority: () => void; - readonly containerId: string; - readonly dockerHost: string; -} - export interface PortableDemoLifecycleDeps { platform?: NodeJS.Platform; 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; @@ -110,8 +89,6 @@ export type PortableDemoLifecycleRecoveryResult = export interface PortableDemoLifecycleContext { agent?: string | null; gatewayName: string; - lifecycleGeneration?: string; - openshellDriver?: string | null; provider?: string | null; } @@ -235,17 +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,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 !== 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) || @@ -253,34 +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 === CURRENT_RECEIPT_SCHEMA_VERSION && - (typeof receipt.registryGeneration !== "string" || - !SANDBOX_ID_PATTERN.test(receipt.registryGeneration))) + 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.registryGeneration : receipt.containerId; - if (registryGeneration === undefined && receipt.schemaVersion !== 3) 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 { @@ -358,7 +308,6 @@ function inspectPodmanContainer( const sandboxId = labels?.[PODMAN_SANDBOX_ID_LABEL]; if ( inspection.Id !== containerId || - inspection.Name !== `${PODMAN_SANDBOX_CONTAINER_PREFIX}${sandboxName}` || labels?.[PODMAN_MANAGED_LABEL] !== "true" || labels?.[PODMAN_SANDBOX_NAME_LABEL] !== sandboxName || typeof sandboxId !== "string" || @@ -408,127 +357,6 @@ function discoverPodmanContainer( return inspectPodmanContainer(matches[0]!, 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 backfillLegacyReceiptGeneration( - receipt: PortableDemoLifecycleReceipt, - stateDir: string, - backfillRequired: boolean, - deps: PortableDemoLifecycleDeps, -): PortableDemoLifecycleReceipt { - if (receipt.schemaVersion === 3) return receipt; - if ( - backfillRequired && - (!deps.backfillRegistryGeneration || !deps.backfillRegistryGeneration(receipt.containerId)) - ) { - throw new Error( - `Portable demo lifecycle receipt for sandbox '${receipt.sandboxName}' could not claim the current registry generation`, - ); - } - 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. */ -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"); - } - const backfillRequired = requireCurrentRegistryGeneration(receipt, deps.registryGeneration); - const authority = qualifiedPodmanAuthority(commandEnv, deps); - const inspection = discoverPodmanContainer(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, - dockerHost: authority.dockerHost, - }; -} - function startupArgv(receipt: PortableDemoLifecycleReceipt): string[] { const port = String(receipt.dashboardPort); // A raw Podman restart can preserve a merged CA bundle from the previous @@ -776,49 +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, sandboxName, sandboxId: inspection.sandboxId, containerId: inspection.containerId, dashboardPort: parseDashboardPort(createdStartupArgv, sandboxName), - registryGeneration, }; requireCommand( 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)); } /** @@ -831,24 +642,15 @@ 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); - let 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"); } - 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); - } - 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)) { removeReceipt(sandboxName, stateDir); @@ -894,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" }; } @@ -963,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 ca57e815eb8..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"; @@ -38,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 { @@ -226,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 ?? @@ -243,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 = { 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..b71ecba9064 --- /dev/null +++ b/src/lib/onboard/experimental/portable-inference-source.test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + 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, + readPortableInferenceBootstrapFile, + readPortableInferenceDescriptorFromS3, + 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", +}; +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[] = []; + +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 bootstrap credential is missing", () => { + const readObject = vi.fn(); + + expect(resolvePortableInferenceSource({}, () => null, readObject)).toBeNull(); + expect(readObject).not.toHaveBeenCalled(); + }); + + 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(readPortableInferenceBootstrapFile(filePath)).toEqual(Buffer.from(TEST_BOOTSTRAP)); + expect(existsSync(filePath)).toBe(true); + }); + + 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 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 bootstrap credential without consuming 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: 0o640 }); + chmodSync(filePath, 0o640); + + expect(() => readPortableInferenceBootstrapFile(filePath)).toThrow("mode 0600"); + expect(existsSync(filePath)).toBe(true); + }); + + it("rejects a symlinked bootstrap credential", () => { + const directory = mkdtempSync(path.join(tmpdir(), "portable-inference-test-")); + tempDirectories.push(directory); + 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(() => readPortableInferenceBootstrapFile(linkPath)).toThrow( + "could not read its bootstrap credential", + ); + }); + + it("fetches and validates the descriptor from the fixed object source", () => { + const rawBootstrap = Buffer.from(TEST_BOOTSTRAP); + const readObject = vi.fn(() => descriptor(VALID_FIELDS)); + + expect(resolvePortableInferenceSource({}, () => rawBootstrap, readObject)).toEqual({ + apiKey: VALID_FIELDS.apiKey, + baseUrl: VALID_FIELDS.url, + model: VALID_FIELDS.model, + }); + expect(readObject).toHaveBeenCalledWith({ + accessKeyId: TEST_ACCESS_KEY_ID, + secretAccessKey: TEST_SECRET_ACCESS_KEY, + }); + expect(rawBootstrap.every((byte) => byte === 0)).toBe(true); + }); + + 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( + {}, + () => rawBootstrap, + () => descriptor(VALID_FIELDS), + ), + ).toEqual({ + apiKey: VALID_FIELDS.apiKey, + baseUrl: VALID_FIELDS.url, + model: VALID_FIELDS.model, + }); + expect(rawBootstrap.every((byte) => byte === 0)).toBe(true); + }); + + 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", + }); + }); + + 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( + {}, + () => Buffer.from(TEST_BOOTSTRAP), + () => { + throw new Error(descriptorSecret); + }, + ); + } catch (error) { + expect(String(error)).toContain("could not read its credential descriptor"); + expect(String(error)).not.toContain(descriptorSecret); + } + }); + + it.each([ + [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([ + [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..51c3c0c04ab --- /dev/null +++ b/src/lib/onboard/experimental/portable-inference-source.ts @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +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_BOOTSTRAP_PATH = "/run/nemoclaw/portable-bootstrap"; +export const PORTABLE_INFERENCE_DESKTOP_FILENAME = "infrakey.txt"; + +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; +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 interface PortableBootstrapCredential { + accessKeyId: string; + secretAccessKey: string; +} + +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; + 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 bootstrap credential.", + ); + } + + try { + let stats = fstatSync(bootstrapFd); + const effectiveUid = typeof process.geteuid === "function" ? process.geteuid() : null; + 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 bootstrap credential to have mode 0600.", + ); + } + + const buffer = Buffer.allocUnsafe(MAX_BOOTSTRAP_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(bootstrapFd, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset === 0 || offset > MAX_BOOTSTRAP_BYTES) { + throw new PortableInferenceSourceError( + "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 bootstrap credential.", + ); + } finally { + closeSync(bootstrapFd); + } +} + +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 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 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 ( + (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 bootstrap credential.", + ); + } + 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(); +} + +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( + "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, + readBootstrapCredential: PortableInferenceBootstrapReader = readPortableInferenceBootstrapFile, + readObject: PortableInferenceObjectReader = readPortableInferenceDescriptorFromS3, +): PortableInferenceSource | null { + let rawBootstrap: Buffer | null; + try { + rawBootstrap = readBootstrapCredential(); + } catch (error) { + if (error instanceof PortableInferenceSourceError) throw error; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its bootstrap credential.", + ); + } + if (rawBootstrap === null) return null; + + let credential: PortableBootstrapCredential; + try { + credential = parsePortableBootstrapCredential(rawBootstrap); + } finally { + rawBootstrap.fill(0); + } + let descriptor: Buffer; + try { + descriptor = readObject(credential); + } catch (error) { + if (error instanceof PortableInferenceSourceError) throw error; + throw new PortableInferenceSourceError( + "Portable hosted inference could not read its credential descriptor.", + ); + } + 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/forward-start.ts b/src/lib/onboard/forward-start.ts index 64ec4787f9b..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.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.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 de3a43ace7d..2b197b9b0ac 100644 --- a/src/lib/onboard/gateway-binding.ts +++ b/src/lib/onboard/gateway-binding.ts @@ -25,7 +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. */ -export const BASE_GATEWAY_STATE_DIR_NAME = "openshell-docker-gateway"; +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 11c3e217702..4c27ae1ed38 100644 --- a/src/lib/onboard/host-gateway-process.ts +++ b/src/lib/onboard/host-gateway-process.ts @@ -108,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", "openshell-docker-gateway"); + 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 6debd7b0aa3..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"; @@ -50,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 }; @@ -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/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/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 { }); }); - 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 7de53598967..fb881d669aa 100644 --- a/src/lib/onboard/openshell-feature-gate.ts +++ b/src/lib/onboard/openshell-feature-gate.ts @@ -56,9 +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.99 standalone sandbox binaries. - ["a4b0c38ed90a6dd4b4f312ad3727824a25ec478d88d4e65d22a82377b18e6214", "0.0.99"], - ["f60ce5b76e4dbd645f690c8519852d261c8cf6a70b5fc56db329a23d68bc7b2e", "0.0.99"], + // OpenShell v0.0.85 standalone sandbox binaries. + ["863ef21ab7ef623f5e7a8728c4e5532b46bfbae3ace3b800665a1c6353a1f7d2", "0.0.85"], + ["680115dbc2affde0e88261ab09f4044726d1cc9e01de55dc5077d1118f52968d", "0.0.85"], ]); 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/openshell-install.ts b/src/lib/onboard/openshell-install.ts index e777388ab1c..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.99"; + 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 eb6afc970c9..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.99"; +export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.85"; export function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { const openshellBin = resolveOpenshell(); 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-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 d63cda96d82..d9cec4789f4 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -3,7 +3,7 @@ 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"; @@ -66,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; @@ -84,7 +80,6 @@ export interface SandboxGpuCreateFlowInput { createArgv: string[]; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; - lifecycleRegistrationFields?: LifecycleRegistrationFields; prebuild: SandboxPrebuildResult; restoreBackupPath: string | null; terminalAgent: boolean; @@ -124,7 +119,6 @@ export interface SandboxGpuCreateFlowResult { firstCreateOutput: string; /** Mutable tag/reference retained only for registry and image-GC bookkeeping. */ registryImageRef: string | null; - lifecycleRegistrationFields: LifecycleRegistrationFields; } /** @@ -256,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}`); @@ -279,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/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index b1b751036fc..60769cf770e 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -72,6 +72,7 @@ export interface CreatedSandboxRegistryEntryInput { hermesToolGateways: string[]; hermesDashboardState: HermesDashboardOnboardState; dashboardPort: number; + dashboardForwardEnabled?: boolean; dashboardRemoteBindPrepared?: boolean; lifecycleGeneration?: string; lifecycleLiveIdentityFingerprint?: string; @@ -223,6 +224,7 @@ export function buildCreatedSandboxRegistryEntry( input.hermesToolGateways.length > 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..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"; @@ -73,6 +74,7 @@ describe("applyReusedSandboxDashboardState", () => { sandboxGpuConfig, ); expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + dashboardForwardEnabled: true, hermesDashboardEnabled: true, hermesDashboardPort: 9123, hermesDashboardInternalPort: 19123, @@ -119,6 +121,7 @@ describe("applyReusedSandboxDashboardState", () => { }); expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + dashboardForwardEnabled: true, hermesDashboardEnabled: undefined, hermesDashboardPort: undefined, hermesDashboardInternalPort: undefined, @@ -147,6 +150,7 @@ describe("applyReusedSandboxDashboardState", () => { resolveStateForPort: vi.fn(), ensureForState: vi.fn(), }; + const stopDashboardForward = vi.fn(); const updateReusedSandboxMetadata = vi.fn(); const result = applyReusedSandboxDashboardState({ @@ -161,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(); @@ -181,6 +190,7 @@ describe("applyReusedSandboxDashboardState", () => { sandboxGpuConfig, ); expect(updateSandbox).toHaveBeenCalledWith("terminal-box", { + dashboardForwardEnabled: false, hermesDashboardEnabled: undefined, hermesDashboardPort: undefined, hermesDashboardInternalPort: undefined, @@ -188,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 dc95852c038..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; @@ -105,6 +122,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/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 8f238b1d389..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 { @@ -669,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.99 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[], @@ -939,24 +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, appliedPresets, missingPresets }; + return { policy: merged, appliedPresets, missingPresets }; } /** @@ -1052,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.`, ); } @@ -1099,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.`); @@ -1899,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, @@ -2001,40 +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; - } - } + 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 @@ -2146,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.`, ); } @@ -2205,37 +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; - } - } - 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); @@ -2635,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.`, ); } @@ -2672,7 +2342,6 @@ export { getBaselineExclusionRuntimeStatus, getGatewayPresets, getLiveSandboxPolicyEntryDigest, - getOpenClawNpmCompatibilityState, getPresetContentGatewayState, getPresetEndpoints, getPresetValidationWarning, @@ -2685,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 772eb33f5ad..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,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 { @@ -129,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(); @@ -201,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), }); } @@ -316,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). @@ -345,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); @@ -355,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 { @@ -381,9 +308,7 @@ export async function assertEndpointResolvesPublic( ? { ok: true, addresses: [], - trustedPrivateCapability: issueTrustedPrivateEndpointCapability(normalizedHostname, [ - bare, - ]), + trustedPrivateCapability: issueTrustedPrivateEndpointCapability([bare]), trustedPrivateEndpoint: true, } : { @@ -411,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/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); + }); }); 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`, ); } }