diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index f8bedaef612..5a30dee9736 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -207,7 +207,8 @@ jobs: --memory 256m \ --entrypoint /bin/sh \ nemoclaw-hermes-production -eu -c ' - test "$NODE_EXTRA_CA_CERTS" = /usr/local/share/nemoclaw/corporate-ca.pem + test -z "${NODE_EXTRA_CA_CERTS:-}" + test -z "${CURL_CA_BUNDLE:-}" test ! -e /usr/local/share/nemoclaw/corporate-ca.pem test ! -L /usr/local/share/nemoclaw/corporate-ca.pem test -x /usr/local/bin/hermes diff --git a/Dockerfile b/Dockerfile index 7e383192443..16ea7fb9eb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -629,17 +629,6 @@ ARG MCPORTER_0_7_3_TARBALL=https://registry.npmjs.org/mcporter/-/mcporter-0.7.3. # final-image layer while preserving metadata on existing parent directories. COPY --from=openclaw-dependency-payload / / -# The final image owns the shipped dependency boundary independently of base -# freshness. Reassert the npm-private node-tar fix here; the helper is -# idempotent for a remediated base and fails closed on unexpected npm layouts. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ - --npm-root /usr/local/lib/node_modules/npm - -# Reassert the npm-private brace-expansion fix for the exact final filesystem. -# hadolint ignore=DL3059 -RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ - --npm-root /usr/local/lib/node_modules/npm - # OpenClaw 2026.7.1 loads some generated source through jiti. Disable its # filesystem transform cache so source fragments that mention provider marker # names do not persist under /tmp/jiti inside the sandbox. @@ -676,18 +665,35 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ && echo "[nemoclaw] baked host corporate-proxy CA into image trust (#6210)"; \ fi -# Anchor the corporate CA for build-time TLS too, not just runtime. The -# OpenClaw/mcporter reinstall path makes registry-backed npm requests; behind a -# TLS-intercepting corporate proxy those requests need the operator CA. Node -# ignores a missing file, so this is a no-op when no CA was baked; at runtime -# nemoclaw-start overrides it with the merged OpenShell + corporate bundle. -ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem +# Use the corporate CA for build-time Node TLS only when onboarding supplied +# it. The runtime entrypoint builds its own merged OpenShell and corporate +# bundle. + +# The final image owns the shipped dependency boundary independently of base +# freshness. Reassert the idempotent npm-private fixes after corporate CA setup +# so cold registry-backed remediation can use the operator-supplied trust root. +RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ + --npm-root /usr/local/lib/node_modules/npm + +# Reassert the npm-private brace-expansion fix for the exact final filesystem. +# hadolint ignore=DL3059 +RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ + --npm-root /usr/local/lib/node_modules/npm # Reassert the npm-private ip-address fix for the exact final filesystem. When # onboarding supplied a corporate CA, use it for the registry-backed download. # hadolint ignore=DL3059 RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -743,7 +749,11 @@ ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=1000 \ NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=20000 \ NPM_CONFIG_FETCH_TIMEOUT=60000 -RUN --network=default NODE_OPTIONS=--dns-result-order=ipv4first \ +RUN --network=default if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ + NODE_OPTIONS=--dns-result-order=ipv4first \ /usr/local/lib/nemoclaw-build-tools/npm-ci-locked.sh --omit=dev \ && rm -rf /usr/local/lib/nemoclaw-build-tools/npm-cache-seed \ && rm -f /usr/local/lib/nemoclaw-build-tools/npm-ci-locked.sh @@ -825,6 +835,10 @@ RUN command -v codex-acp >/dev/null # basename in a fresh directory, local-archive-only install, and cleanup. # hadolint ignore=DL3059,DL4006,DL3016 RUN --network=default set -eu; \ + if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ || { echo "ERROR: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)" >&2; exit 1; }; \ MIN_VER=$(grep -m 1 'min_openclaw_version' /opt/nemoclaw-blueprint/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index a99dcd3d707..57cf0032ec5 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -198,10 +198,9 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ && echo "[nemoclaw] baked host corporate-proxy CA into image trust (#6210)"; \ fi -# Use the decoded CA for Node.js package operations in this final stage. Node.js -# ignores the path when no CA was baked. At runtime, nemoclaw-start replaces it -# with the merged OpenShell and corporate bundle. -ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem +# Use the decoded CA for Node.js package operations in this final stage only +# when onboarding supplied it. At runtime, nemoclaw-start builds the merged +# OpenShell and corporate bundle. # Cross-stage root copies are accepted by Docker's legacy builder and create # one final-image layer while preserving metadata on existing parent paths. @@ -212,6 +211,7 @@ COPY --from=hermes-npm-patch-payload / / # onboarding supplied a corporate CA, use it for the registry-backed download. RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -222,6 +222,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ # hadolint ignore=DL3059 RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -231,6 +232,7 @@ RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ # hadolint ignore=DL3059 RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ fi; \ node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts \ --npm-root /usr/local/lib/node_modules/npm @@ -265,6 +267,10 @@ RUN chmod -R a+rX /opt/hermes/.venv \ # the sandbox user account. Remove this fallback after all supported base image # tags are built from Dockerfile.base with HERMES_WEB_DIST prepopulated. RUN set -eu; \ + if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \ + export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \ + fi; \ hermes_web_dist=/opt/hermes/hermes_cli/web_dist; \ hermes_web_dir=/opt/hermes/web; \ if [ ! -d "$hermes_web_dist" ]; then \ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 8f17b108d7f..06b2b0cb222 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -58,7 +58,7 @@ }, { "file": "test/corporate-ca-build-tls-anchor.test.ts", - "test": "decodes the CA and exports NODE_EXTRA_CA_CERTS before registry-backed dependency requests (#8925)", + "test": "uses conditional Node and curl trust in every final-stage registry step", "category": "security" }, { diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 9f999687f2b..e1de7dba6f2 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -15,7 +15,7 @@ Use the lightest recovery operation that repairs the sandbox while preserving it ## Restart a Stopped Sandbox Container -If status reports `Phase: Error` and confirms that the sandbox container exists but is stopped, restart the existing container: +If NemoClaw reports that a Docker-driver sandbox is stopped, restart the existing container: ```bash $$nemoclaw start @@ -23,7 +23,7 @@ $$nemoclaw start This path preserves the sandbox workspace and repairs the agent runtime and host-side forwards after the container starts. If the container is paused, follow the printed `docker unpause` guidance instead. -If the container is missing or OpenShell reports another terminal phase such as `Failed`, follow the printed `rebuild --yes` guidance so NemoClaw can recreate the sandbox from its recorded metadata. +If Docker no longer has the container, follow the printed `rebuild --yes` guidance so NemoClaw can recreate the sandbox from its recorded metadata. The `start` command returns success only after it authenticates the recovered agent runtime, OpenShell reports the sandbox ready, and host-side port forwards pass their checks. diff --git a/docs/security/configure-corporate-ca-trust.mdx b/docs/security/configure-corporate-ca-trust.mdx index 4fe1f8469f4..1074b16bce3 100644 --- a/docs/security/configure-corporate-ca-trust.mdx +++ b/docs/security/configure-corporate-ca-trust.mdx @@ -35,7 +35,10 @@ This lets the in-sandbox OpenShell proxy validate TLS when it opens the upstream -It sets `NODE_EXTRA_CA_CERTS` before registry-backed build-time Node.js dependency requests that cross a TLS-inspecting proxy. +When onboarding supplies a corporate CA, the Dockerfile sets `NODE_EXTRA_CA_CERTS` and `CURL_CA_BUNDLE` in each final-stage build step that accesses the npm registry. +This includes registry-backed npm remediation and runtime dependency installation that cross a TLS-inspecting proxy. +The build does not retain either variable in the image configuration. + The required `reviewed-npm-audit` CI check materializes the dependency graph from the committed lock and verifies its registry signatures. Any nonzero `npm audit signatures` status fails the check. The pull request (PR) check must pass before merge. @@ -58,8 +61,11 @@ Sandboxes still using the staged legacy direct-start path create `/tmp/nemoclaw- The Hermes discovery-runtime installer applies the corporate CA before its npm operations. -The final Hermes image stage decodes the CA immediately after `FROM ${BASE_IMAGE}` and sets `NODE_EXTRA_CA_CERTS` before later npm operations. -The registry-backed npm remediations set `CURL_CA_BUNDLE` before each download only when the decoded certificate file exists. +The final Hermes image stage decodes the CA immediately after `FROM ${BASE_IMAGE}`. +When the decoded CA file exists, the Dockerfile sets `NODE_EXTRA_CA_CERTS` and `CURL_CA_BUNDLE` in each later build step that accesses the npm registry. +These operations include registry-backed npm remediation and the fallback Hermes dashboard build. +The build does not retain either variable in the image configuration. +When the decoded CA file does not exist, these operations use their default trust configuration. The Hermes package installer clears inherited `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` values before its build-time `uv pip install` commands. When the decoded CA exists, it sets both variables to that file for those commands. If the file does not exist, uv and Python use their default trust configuration. diff --git a/src/lib/actions/sandbox/docker-health.test.ts b/src/lib/actions/sandbox/docker-health.test.ts index 5c287788f6d..d4518f91d0e 100644 --- a/src/lib/actions/sandbox/docker-health.test.ts +++ b/src/lib/actions/sandbox/docker-health.test.ts @@ -180,6 +180,7 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "none", paused: false, + running: false, containerName: null, }); }); @@ -193,6 +194,7 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "unhealthy", paused: true, + running: true, containerName: "openshell-my-assistant-live", }); }); @@ -206,6 +208,7 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "none", paused: false, + running: false, containerName: "openshell-my-assistant-12ab", }); }); @@ -215,13 +218,23 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "healthy", paused: true, + running: true, containerName: "openshell-my-assistant-12ab", }); }); - it("reports paused=false for a running container", () => { - const deps = fixture({ healthRaw: "healthy\n", pausedRaw: "false\n" }); - expect(getSandboxDockerRuntime("my-assistant", deps).paused).toBe(false); + it("reports running state for the owned container", () => { + const running = getSandboxDockerRuntime("my-assistant", fixture()); + const stopped = getSandboxDockerRuntime( + "my-assistant", + fixture({ + psNames: "openshell-cluster-nemoclaw\n", + psAllNames: "openshell-cluster-nemoclaw\nopenshell-my-assistant-12ab\n", + }), + ); + + expect(running).toMatchObject({ running: true, paused: false }); + expect(stopped).toMatchObject({ running: false, paused: false }); }); it("normalizes whitespace and case in the .State.Paused value", () => { @@ -250,6 +263,7 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "none", paused: false, + running: false, containerName: null, }); expect(findLabeledSandboxContainers).not.toHaveBeenCalled(); @@ -260,6 +274,7 @@ describe("getSandboxDockerRuntime (#4495)", () => { expect(getSandboxDockerRuntime("my-assistant", deps)).toEqual({ health: "none", paused: false, + running: false, containerName: null, }); }); diff --git a/src/lib/actions/sandbox/docker-health.ts b/src/lib/actions/sandbox/docker-health.ts index 09855089d90..bd33c742da3 100644 --- a/src/lib/actions/sandbox/docker-health.ts +++ b/src/lib/actions/sandbox/docker-health.ts @@ -16,7 +16,7 @@ export interface SandboxDockerHealth { /** * Combined Docker runtime view for a docker-driver sandbox container: the - * HEALTHCHECK signal plus whether the container is paused (`docker pause`). + * HEALTHCHECK signal plus whether the container is running or paused (`docker pause`). * A paused container can surface upstream as `Phase: Error` even though the * sandbox is intact, so `status` reads `paused` to print a recovery hint * without rewriting the authoritative phase. See #4495. @@ -24,6 +24,7 @@ export interface SandboxDockerHealth { export interface SandboxDockerRuntime { health: DockerHealthState; paused: boolean; + running: boolean; containerName: string | null; } @@ -110,12 +111,12 @@ function normalizePausedState(raw: string): boolean { /** * Resolve an OpenShell-labeled docker-driver sandbox container across all states - * and read both its HEALTHCHECK state and `.State.Paused` flag. Label-scoped - * discovery matches the ownership boundary enforced by `start`; preferring its - * running rows preserves paused-container guidance before falling back to an - * exited container that `start` can recover (#7222). Returns `health: "none", - * paused: false` when the sandbox is not on the docker driver or no owned - * container is found. See #4495. + * and read its HEALTHCHECK state, running state, and `.State.Paused` flag. + * Label-scoped discovery matches the ownership boundary enforced by `start`; + * preferring its running rows preserves paused-container guidance before + * falling back to an exited container that `start` can recover (#7222). + * Returns `health: "none"`, `paused: false`, and `running: false` when the + * sandbox is not on the docker driver or no owned container is found. See #4495. */ export function getSandboxDockerRuntime( sandboxName: string, @@ -124,16 +125,16 @@ export function getSandboxDockerRuntime( const deps: ResolveDeps = { ...defaultDeps, ...depsOverride }; try { if (deps.getSandbox(sandboxName)?.openshellDriver !== "docker") { - return { health: "none", paused: false, containerName: null }; + return { health: "none", paused: false, running: false, containerName: null }; } } catch { - return { health: "none", paused: false, containerName: null }; + return { health: "none", paused: false, running: false, containerName: null }; } let labeledContainers: ReturnType; try { labeledContainers = deps.findLabeledSandboxContainers(sandboxName); } catch { - return { health: "none", paused: false, containerName: null }; + return { health: "none", paused: false, running: false, containerName: null }; } const runningNames = labeledContainers .filter((container) => container.running) @@ -149,7 +150,12 @@ export function getSandboxDockerRuntime( ...deps, dockerPsNames: () => allNames, }); - if (!containerName) return { health: "none", paused: false, containerName: null }; + if (!containerName) { + return { health: "none", paused: false, running: false, containerName: null }; + } + const running = labeledContainers.some( + (container) => container.name === containerName && container.running, + ); let health: DockerHealthState; try { health = normalizeHealthState(deps.dockerInspectHealth(containerName)); @@ -162,5 +168,5 @@ export function getSandboxDockerRuntime( } catch { paused = false; } - return { health, paused, containerName }; + return { health, paused, running, containerName }; } diff --git a/src/lib/actions/sandbox/gateway-state-hints.test.ts b/src/lib/actions/sandbox/gateway-state-hints.test.ts index 2467763873d..20da18b7412 100644 --- a/src/lib/actions/sandbox/gateway-state-hints.test.ts +++ b/src/lib/actions/sandbox/gateway-state-hints.test.ts @@ -46,6 +46,7 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { getSandboxDockerRuntimeSpy = vi.spyOn(dockerHealth, "getSandboxDockerRuntime").mockReturnValue({ health: "none", paused: false, + running: true, containerName: "openshell-instance-a-abc", }); vi.spyOn(gatewaySelect, "selectSandboxOwningGateway").mockReturnValue({ @@ -144,6 +145,39 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { ); }); + it("reports a stopped container without crash guidance (#8695)", async () => { + captureOpenshellSpy.mockReturnValue({ + status: 0, + output: "Sandbox:\n Name: instance-a\n Phase: Error", + }); + getSandboxDockerRuntimeSpy.mockReturnValue({ + health: "none", + paused: false, + running: false, + containerName: "openshell-instance-a-abc", + }); + const lines: string[] = []; + vi.spyOn(console, "error").mockImplementation((line = "") => { + lines.push(String(line)); + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + + await expect(gatewayState.ensureLiveSandboxOrExit("instance-a")).rejects.toThrow( + "process.exit(1)", + ); + + const output = lines.join("\n"); + expect(output).toContain("Sandbox 'instance-a' is stopped."); + expect(output).toContain("Workspace state is preserved."); + expect(output).toContain("nemoclaw instance-a start"); + expect(output).not.toContain("rebuild --yes"); + expect(output).not.toContain("process crash"); + expect(output).not.toContain("stuck in 'Error'"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it("steers a non-paused Error sandbox to the workspace-preserving start path (#7222)", async () => { captureOpenshellSpy.mockReturnValue({ status: 0, @@ -177,6 +211,7 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { getSandboxDockerRuntimeSpy.mockReturnValue({ health: "none", paused: false, + running: true, containerName: null, }); const lines: string[] = []; @@ -197,11 +232,17 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); - it("keeps rebuild guidance for terminal phases other than Error", async () => { + it("keeps rebuild guidance for a paused container in a terminal phase other than Error", async () => { captureOpenshellSpy.mockReturnValue({ status: 0, output: "Sandbox:\n Name: instance-a\n Phase: Failed", }); + getSandboxDockerRuntimeSpy.mockReturnValue({ + health: "none", + paused: true, + running: true, + containerName: "openshell-instance-a-abc", + }); const lines: string[] = []; vi.spyOn(console, "error").mockImplementation((line = "") => { lines.push(String(line)); @@ -217,7 +258,8 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { const output = lines.join("\n"); expect(output).toContain("nemoclaw instance-a rebuild --yes"); expect(output).not.toContain("nemoclaw instance-a start"); - expect(getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(output).not.toContain("docker unpause"); + expect(getSandboxDockerRuntimeSpy).toHaveBeenCalledWith("instance-a"); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -229,6 +271,7 @@ describe("printGatewayLifecycleHint multi-instance hints", () => { getSandboxDockerRuntimeSpy.mockReturnValue({ health: "none", paused: true, + running: true, containerName: "openshell-instance-a-abc", }); const lines: string[] = []; diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 4e68d8e988c..418dedbcad6 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -636,8 +636,14 @@ export async function ensureLiveSandboxOrExit( printDockerRuntimeDownGuidance(sandboxName); process.exit(1); } - const dockerRuntime = phase === "Error" ? getSandboxDockerRuntime(sandboxName) : null; - if (dockerRuntime?.paused && dockerRuntime.containerName) { + const dockerRuntime = getSandboxDockerRuntime(sandboxName); + if (dockerRuntime.containerName && !dockerRuntime.running && !dockerRuntime.paused) { + console.error(` Sandbox '${sandboxName}' is stopped.`); + console.error(" Workspace state is preserved."); + console.error(` Start it again with \`${CLI_NAME} ${sandboxName} start\`.`); + process.exit(1); + } + if (phase === "Error" && dockerRuntime.paused && dockerRuntime.containerName) { console.error(` Sandbox '${sandboxName}' is stuck in '${phase}' phase.`); console.error(""); console.error( diff --git a/src/lib/actions/sandbox/status-lookup-rendering.test.ts b/src/lib/actions/sandbox/status-lookup-rendering.test.ts index 0f503679e34..b977b4062db 100644 --- a/src/lib/actions/sandbox/status-lookup-rendering.test.ts +++ b/src/lib/actions/sandbox/status-lookup-rendering.test.ts @@ -21,6 +21,7 @@ async function printGuidance({ dockerRuntime: { health: "none"; paused: boolean; + running: boolean; containerName: string | null; } | null; }): Promise { @@ -49,6 +50,7 @@ describe("printNonReadySandboxPhaseGuidance (#7222)", () => { dockerRuntime: { health: "none", paused: false, + running: true, containerName: "openshell-beta-abc", }, }); @@ -67,6 +69,28 @@ describe("printNonReadySandboxPhaseGuidance (#7222)", () => { expect(text).toContain("cannot snapshot a stopped container"); }); + it("reports an owned stopped container without crash guidance (#8695)", async () => { + const cap = captureConsoleLog(); + await printGuidance({ + phase: "Provisioning", + dockerRuntime: { + health: "none", + paused: false, + running: false, + containerName: "openshell-beta-abc", + }, + }); + const text = cap.lines(); + cap.restore(); + + expect(text).toContain("Sandbox 'beta' is stopped."); + expect(text).toContain("Workspace state is preserved."); + expect(text).toContain("nemoclaw beta start"); + expect(text).not.toContain("process crash"); + expect(text).not.toContain("is stuck"); + expect(text).not.toContain("rebuild --yes"); + }); + it("keeps the unpause hint for a paused container and never suggests start/rebuild (#4495)", async () => { const cap = captureConsoleLog(); await printGuidance({ @@ -74,6 +98,7 @@ describe("printNonReadySandboxPhaseGuidance (#7222)", () => { dockerRuntime: { health: "none", paused: true, + running: true, containerName: "openshell-beta-abc", }, }); @@ -95,7 +120,7 @@ describe("printNonReadySandboxPhaseGuidance (#7222)", () => { const cap = captureConsoleLog(); await printGuidance({ phase, - dockerRuntime: { health: "none", paused: false, containerName }, + dockerRuntime: { health: "none", paused: false, running: true, containerName }, }); const text = cap.lines(); cap.restore(); diff --git a/src/lib/actions/sandbox/status-lookup-rendering.ts b/src/lib/actions/sandbox/status-lookup-rendering.ts index a028ad3e454..910c1510d78 100644 --- a/src/lib/actions/sandbox/status-lookup-rendering.ts +++ b/src/lib/actions/sandbox/status-lookup-rendering.ts @@ -225,6 +225,13 @@ function printNonReadySandboxPhaseGuidance({ dockerRuntime: ReturnType | null; }): void { if (!phase || phase === "Ready") return; + if (dockerRuntime?.containerName && !dockerRuntime.running && !dockerRuntime.paused) { + console.log(""); + console.log(` Sandbox '${sandboxName}' is stopped.`); + console.log(" Workspace state is preserved."); + console.log(` Start it again with \`${CLI_NAME} ${sandboxName} start\`.`); + return; + } // A non-ready, non-terminal phase can mean two very different things. If // the Docker daemon is down, OpenShell can still return a present-but- // Provisioning sandbox (cached/transitional state); steering the user diff --git a/test/corporate-ca-build-tls-anchor.test.ts b/test/corporate-ca-build-tls-anchor.test.ts index 8cc8d2ae666..0c066d72f54 100644 --- a/test/corporate-ca-build-tls-anchor.test.ts +++ b/test/corporate-ca-build-tls-anchor.test.ts @@ -7,6 +7,25 @@ import { describe, expect, it } from "vitest"; import { dockerfileInstructions } from "./helpers/dockerfile-run-commands"; const DOCKERFILE = join(import.meta.dirname, "../Dockerfile"); +const CORPORATE_CA_PATH = "/usr/local/share/nemoclaw/corporate-ca.pem"; + +function expectRunUsesConditionalNodeAndCurlTrust(stage: string, commandMarker: string): void { + const matches = dockerfileInstructions(stage).filter( + (instruction) => instruction.keyword === "RUN" && instruction.body.includes(commandMarker), + ); + expect(matches, commandMarker).toHaveLength(1); + const instruction = matches[0]; + + const guardIndex = instruction.text.indexOf(`if [ -f ${CORPORATE_CA_PATH} ]; then`); + const curlIndex = instruction.text.indexOf(`export CURL_CA_BUNDLE=${CORPORATE_CA_PATH}`); + const nodeIndex = instruction.text.indexOf(`export NODE_EXTRA_CA_CERTS=${CORPORATE_CA_PATH}`); + const commandIndex = instruction.text.indexOf(commandMarker); + + expect(guardIndex, `${commandMarker}: conditional CA guard`).toBeGreaterThan(-1); + expect(curlIndex, `${commandMarker}: curl CA export`).toBeGreaterThan(guardIndex); + expect(nodeIndex, `${commandMarker}: Node CA export`).toBeGreaterThan(curlIndex); + expect(commandIndex, `${commandMarker}: command order`).toBeGreaterThan(nodeIndex); +} describe("corporate proxy CA build-time TLS anchor (#6839)", () => { const dockerfile = readFileSync(DOCKERFILE, "utf-8"); @@ -17,40 +36,27 @@ describe("corporate proxy CA build-time TLS anchor (#6839)", () => { expect(matches).toHaveLength(1); }); - // source-shape-contract: security -- The build-time TLS trust anchor must precede registry-backed dependency requests - it("decodes the CA and exports NODE_EXTRA_CA_CERTS before registry-backed dependency requests (#8925)", () => { + // source-shape-contract: security -- Every final-stage registry step must establish conditional trust inside its own Docker RUN + it("uses conditional Node and curl trust in every final-stage registry step", () => { const argIndex = dockerfile.indexOf("ARG NEMOCLAW_CORPORATE_CA_B64="); - const decodeIndex = dockerfile.indexOf('RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then'); - const anchorIndex = dockerfile.indexOf( - "ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem", - ); - const curlAnchorIndex = dockerfile.indexOf( - "export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem", - anchorIndex, - ); - const ipAddressPatchIndex = dockerfile.indexOf( + const finalFromIndex = dockerfile.indexOf("FROM ${BASE_IMAGE}"); + const finalStage = dockerfile.slice(finalFromIndex); + const decodeIndex = finalStage.indexOf('RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then'); + const registryStepMarkers = [ + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts", - curlAnchorIndex, - ); - const mcporterInstallIndex = dockerfile.indexOf( - "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime ci", - ); + "/usr/local/lib/nemoclaw-build-tools/npm-ci-locked.sh --omit=dev", + "OPENCLAW_LOCK_SHA256=none-legacy-fixture", + ]; - for (const [name, index] of Object.entries({ - argIndex, - decodeIndex, - anchorIndex, - curlAnchorIndex, - ipAddressPatchIndex, - mcporterInstallIndex, - })) { + expect(dockerfile).not.toContain("ENV NODE_EXTRA_CA_CERTS="); + for (const [name, index] of Object.entries({ argIndex, finalFromIndex, decodeIndex })) { expect(index, name).toBeGreaterThan(-1); } - expect(argIndex).toBeLessThan(decodeIndex); - expect(decodeIndex).toBeLessThan(anchorIndex); - expect(anchorIndex).toBeLessThan(curlAnchorIndex); - expect(curlAnchorIndex).toBeLessThan(ipAddressPatchIndex); - expect(anchorIndex).toBeLessThan(mcporterInstallIndex); + for (const commandMarker of registryStepMarkers) { + expectRunUsesConditionalNodeAndCurlTrust(finalStage, commandMarker); + } }); }); @@ -190,22 +196,13 @@ describe("Hermes corporate proxy CA final-stage trust", () => { 'RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then', argIndex, ); - const nodeAnchorIndex = finalStage.indexOf( - "ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem", - decodeIndex, - ); - const payloadCopyIndex = finalStage.indexOf( - "COPY --from=hermes-npm-patch-payload / /", - nodeAnchorIndex, - ); - const conditionalCurlTrust = `RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \\ - export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \\ - fi; \\`; + const payloadCopyIndex = finalStage.indexOf("COPY --from=hermes-npm-patch-payload / /"); const remediationCommands = [ "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts", ]; + const dashboardBuildCommand = "hermes_web_dist=/opt/hermes/hermes_cli/web_dist"; const agentInstallCommand = "node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install"; const packageInstallRun = dockerfileInstructions(finalStage).find( @@ -237,35 +234,21 @@ describe("Hermes corporate proxy CA final-stage trust", () => { " \"from importlib.metadata import version; expected = {'aiohttp': '3.14.3', 'cryptography': '50.0.0'}; actual = {name: version(name) for name in expected}; assert actual == expected, actual\"", "", ].join("\n"); - const npmCommandIndexes = [...finalStage.matchAll(/^\s*npm\s+(?:ci|run)\b/gmu)].map( - (match) => match.index, - ); - for (const [name, index] of Object.entries({ finalFromIndex, argIndex, decodeIndex, - nodeAnchorIndex, payloadCopyIndex, })) { expect(index, name).toBeGreaterThan(-1); } + expect(finalStage).not.toContain("ENV NODE_EXTRA_CA_CERTS="); expect(argIndex).toBeLessThan(decodeIndex); - expect(decodeIndex).toBeLessThan(nodeAnchorIndex); - expect(nodeAnchorIndex).toBeLessThan(payloadCopyIndex); + expect(decodeIndex).toBeLessThan(payloadCopyIndex); for (const remediationCommand of remediationCommands) { - const remediationIndex = finalStage.indexOf(remediationCommand, payloadCopyIndex); - expect(remediationIndex, remediationCommand).toBeGreaterThan(payloadCopyIndex); - const runIndex = finalStage.lastIndexOf("\nRUN ", remediationIndex) + 1; - expect(runIndex, remediationCommand).toBeGreaterThan(payloadCopyIndex); - expect(finalStage.slice(runIndex, remediationIndex).trim(), remediationCommand).toBe( - conditionalCurlTrust, - ); - } - expect(npmCommandIndexes.length).toBeGreaterThan(0); - for (const npmCommandIndex of npmCommandIndexes) { - expect(nodeAnchorIndex).toBeLessThan(npmCommandIndex); + expectRunUsesConditionalNodeAndCurlTrust(finalStage, remediationCommand); } + expectRunUsesConditionalNodeAndCurlTrust(finalStage, dashboardBuildCommand); expect(packageInstallRun?.text).toBe(expectedPackageInstallRun); expect(packageInstallRun?.text).not.toContain("else"); expect(managedUnionInstallRun?.text).toBe(expectedManagedUnionInstallRun); diff --git a/test/dockerfile-run-commands.test.ts b/test/dockerfile-run-commands.test.ts index 1aa4f3eb85b..e9448f61038 100644 --- a/test/dockerfile-run-commands.test.ts +++ b/test/dockerfile-run-commands.test.ts @@ -39,6 +39,7 @@ describe("Dockerfile RUN command discovery", () => { const source = [ `RUN if [ -f ${corporateCaPath} ]; then ${continuation}`, ` export CURL_CA_BUNDLE=${corporateCaPath}; ${continuation}`, + ` export NODE_EXTRA_CA_CERTS=${corporateCaPath}; ${continuation}`, ` fi; ${continuation}`, ` ${command} ${continuation}`, ` ${requiredArguments.join(" ")}`, @@ -50,6 +51,7 @@ describe("Dockerfile RUN command discovery", () => { expect(match.commandStart).toBe(source.indexOf(command)); expect(match.instruction.text).toContain(`export CURL_CA_BUNDLE=${corporateCaPath}`); + expect(match.instruction.text).toContain(`export NODE_EXTRA_CA_CERTS=${corporateCaPath}`); expect(match.instruction.text).toContain("--npm-root /usr/local/lib/node_modules/npm"); expect(match.instruction.text).not.toContain("ENV NEXT=instruction"); }); diff --git a/test/helpers/dockerfile-run-commands.ts b/test/helpers/dockerfile-run-commands.ts index 723d505a633..38d7a6148a0 100644 --- a/test/helpers/dockerfile-run-commands.ts +++ b/test/helpers/dockerfile-run-commands.ts @@ -16,7 +16,8 @@ export interface ReviewedDockerfileRunCommand { } const CORPORATE_CA_PATH = "/usr/local/share/nemoclaw/corporate-ca.pem"; -const CORPORATE_CA_GUARD = `if [ -f ${CORPORATE_CA_PATH} ]; then export CURL_CA_BUNDLE=${CORPORATE_CA_PATH}; fi;`; +const CORPORATE_CA_CURL_GUARD = `if [ -f ${CORPORATE_CA_PATH} ]; then export CURL_CA_BUNDLE=${CORPORATE_CA_PATH}; fi;`; +const CORPORATE_CA_NODE_CURL_GUARD = `if [ -f ${CORPORATE_CA_PATH} ]; then export CURL_CA_BUNDLE=${CORPORATE_CA_PATH}; export NODE_EXTRA_CA_CERTS=${CORPORATE_CA_PATH}; fi;`; function lineEnd(source: string, start: number): number { const newline = source.indexOf("\n", start); @@ -146,7 +147,11 @@ export function requireSingleReviewedDockerfileRunCommand( requiredArguments: readonly string[], ): ReviewedDockerfileRunCommand { const invocation = [command, ...requiredArguments].join(" "); - const reviewedBodies = new Set([invocation, `${CORPORATE_CA_GUARD} ${invocation}`]); + const reviewedBodies = new Set([ + invocation, + `${CORPORATE_CA_CURL_GUARD} ${invocation}`, + `${CORPORATE_CA_NODE_CURL_GUARD} ${invocation}`, + ]); const matches: ReviewedDockerfileRunCommand[] = []; let unreviewedInstructions = 0; diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 16230300267..91a4e2141a5 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -131,7 +131,10 @@ describe("OpenClaw final image layout", () => { expectManagedBootstrapNativeImageContract(dockerfile); expect(finalStage).not.toMatch(/^\s*ENV\b[^\n]*(?:\\\n[^\n]*)*NODE_OPTIONS=/mu); expect(finalStage).toContain( - "RUN --network=default NODE_OPTIONS=--dns-result-order=ipv4first \\", + "RUN --network=default if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \\", + ); + expect(finalStage).toContain( + " NODE_OPTIONS=--dns-result-order=ipv4first \\\n /usr/local/lib/nemoclaw-build-tools/npm-ci-locked.sh --omit=dev", ); expect(entrypoint).toContain('export NODE_OPTIONS="--dns-result-order=ipv4first"'); expect( @@ -178,19 +181,19 @@ describe("OpenClaw final image layout", () => { const runtime = indexOfRequired(finalStage, runtimeCopy); const tarPatch = indexOfRequired( finalStage, - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", ); const braceExpansionPatch = indexOfRequired( finalStage, - "RUN node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", + "node --experimental-strip-types /scripts/patch-bundled-npm-brace-expansion.mts", ); const ipAddressPatch = indexOfRequired( finalStage, "node --experimental-strip-types /scripts/lib/patch-bundled-npm-ip-address.mts", ); - const pluginInstall = indexOfRequired( + const dependencyInstall = indexOfRequired( finalStage, - "RUN --network=default NODE_OPTIONS=--dns-result-order=ipv4first \\", + "NODE_OPTIONS=--dns-result-order=ipv4first \\\n /usr/local/lib/nemoclaw-build-tools/npm-ci-locked.sh --omit=dev", ); const managedMessagingUnionInstall = indexOfRequired( finalStage, @@ -229,7 +232,7 @@ describe("OpenClaw final image layout", () => { expect(dependency).toBeLessThan(tarPatch); expect(tarPatch).toBeLessThan(braceExpansionPatch); expect(braceExpansionPatch).toBeLessThan(ipAddressPatch); - expect(plugin).toBeGreaterThan(pluginInstall); + expect(plugin).toBeGreaterThan(dependencyInstall); expect(plugin).toBeLessThan(pluginChmod); expect(managedMessagingUnionInstall).toBeLessThan(messagingPostInstall); expect(messagingPostInstall).toBeLessThan(neutralConfigRegeneration); diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index c0cf2526a8d..7a83a94a873 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -448,7 +448,8 @@ function validateGuardedProductionBuild( const requiredDefaultTrustFragments = [ "set -euo pipefail", "docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 64 --memory 256m --entrypoint /bin/sh nemoclaw-hermes-production -eu -c", - 'test "$NODE_EXTRA_CA_CERTS" = /usr/local/share/nemoclaw/corporate-ca.pem', + 'test -z "${NODE_EXTRA_CA_CERTS:-}"', + 'test -z "${CURL_CA_BUNDLE:-}"', "test ! -e /usr/local/share/nemoclaw/corporate-ca.pem", "test ! -L /usr/local/share/nemoclaw/corporate-ca.pem", "test -x /usr/local/bin/hermes",