diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index bba2ae76a07..b714522b769 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -141,7 +141,7 @@ jobs: name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 with: - node-version: 22 + node-version: 22.19.0 cache: npm - &install-root-dependencies @@ -236,6 +236,59 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh + messaging-plan-image-boundary: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - *checkout + + - *dockerhub-auth + + - *setup-node + + - name: Resolve sandbox base image + uses: ./.github/actions/resolve-sandbox-base-image + + - name: Resolve Hermes base image + uses: ./.github/actions/resolve-hermes-base-image + + - name: Build and verify OpenClaw messaging plan boundary + env: + BASE_IMAGE: ${{ env.BASE_IMAGE }} + shell: bash + run: | + set -euo pipefail + messaging_plan_b64="$(node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts plan openclaw)" + build_args=( + --build-arg "BASE_IMAGE=${BASE_IMAGE}" + --build-arg "NEMOCLAW_MESSAGING_PLAN_B64=${messaging_plan_b64}" + ) + scripts/check-production-build-args.sh "${build_args[@]}" + docker build "${build_args[@]}" -t nemoclaw-openclaw-plan-boundary . + node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify \ + nemoclaw-openclaw-plan-boundary openclaw + + - name: Build and verify Hermes messaging plan boundary + env: + HERMES_BASE_IMAGE: ${{ env.HERMES_BASE_IMAGE }} + shell: bash + run: | + set -euo pipefail + messaging_plan_b64="$(node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts plan hermes)" + build_args=( + -f agents/hermes/Dockerfile + --build-arg "BASE_IMAGE=${HERMES_BASE_IMAGE}" + --build-arg "NEMOCLAW_MESSAGING_PLAN_B64=${messaging_plan_b64}" + ) + scripts/check-production-build-args.sh "${build_args[@]}" + docker build "${build_args[@]}" -t nemoclaw-hermes-plan-boundary . + node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify \ + nemoclaw-hermes-plan-boundary hermes + + - name: Clean up Docker auth + if: always() + shell: bash + run: bash .github/scripts/docker-auth-cleanup.sh state-dir-guard-metadata: runs-on: ubuntu-latest timeout-minutes: 30 @@ -290,7 +343,6 @@ jobs: - name: Upload state-dir guard metadata artifacts if: always() uses: ./.github/actions/upload-e2e-artifacts - build-sandbox-images-arm64: if: inputs.run_arm64 runs-on: ubuntu-24.04-arm diff --git a/Dockerfile b/Dockerfile index f0f9c883be9..ed3c172ac3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -920,8 +920,10 @@ ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=1.0 # certificate, not a secret, so baking it into an image layer is acceptable. ARG NEMOCLAW_CORPORATE_CA_B64= -# SECURITY: Promote build-args to env vars so the TypeScript script reads them +# SECURITY: Promote persistent image config to env vars so TypeScript reads it # via process.env, never via string interpolation into executable source code. +# NEMOCLAW_MESSAGING_PLAN_B64 intentionally remains ARG-only: Docker exposes it +# to build RUN processes without retaining the full plan in the final image env. # Direct ARG interpolation into inline source is a code injection vector (C-2). ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ @@ -938,7 +940,6 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ NEMOCLAW_AGENT_HEARTBEAT_EVERY=${NEMOCLAW_AGENT_HEARTBEAT_EVERY} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ - NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_EXTRA_AGENTS_JSON_B64=${NEMOCLAW_EXTRA_AGENTS_JSON_B64} \ NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ NEMOCLAW_DASHBOARD_BIND=${NEMOCLAW_DASHBOARD_BIND} \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1efcef6cff0..266f2665edb 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -292,7 +292,9 @@ ARG NEMOCLAW_CORPORATE_CA_B64= # cannot override the real window (#6177). ARG NEMOCLAW_CONTEXT_WINDOW= -# Promote build-args to env vars for the config generation script. +# Promote persistent image config to env vars for config generation. +# NEMOCLAW_MESSAGING_PLAN_B64 intentionally remains ARG-only: Docker exposes it +# to build RUN processes without retaining the full plan in the final image env. ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \ @@ -301,7 +303,6 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_CONTEXT_WINDOW=${NEMOCLAW_CONTEXT_WINDOW} \ NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \ CHAT_UI_URL=${CHAT_UI_URL} \ - NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \ NEMOCLAW_WEB_SEARCH_PROVIDER=${NEMOCLAW_WEB_SEARCH_PROVIDER} \ NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 27514393b09..253a47fec88 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -261,6 +261,11 @@ "test": "runs final-destroy against a pinned Docker setup on trusted Intel macOS", "category": "compatibility" }, + { + "file": "test/messaging-image-env-contract.test.ts", + "test": "%s keeps the full plan in build processes but not final runtime environments (#5896)", + "category": "security" + }, { "file": "test/mcp-openshell-workflow.test.ts", "test": "keeps the setup docs aligned with the stable default", diff --git a/scripts/check-messaging-plan-image-boundary.mts b/scripts/check-messaging-plan-image-boundary.mts new file mode 100644 index 00000000000..fadb5d9a385 --- /dev/null +++ b/scripts/check-messaging-plan-image-boundary.mts @@ -0,0 +1,708 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +export type MessagingBoundaryAgent = "hermes" | "openclaw"; +export type DockerRunnerResult = + | string + | { + readonly error?: unknown; + readonly status: number | null; + readonly stderr?: string | null; + readonly stdout?: string | null; + }; +export type DockerRunner = (args: string[]) => DockerRunnerResult; + +type JsonRecord = Record; +type FullPlanScanOptions = { + readonly allowPlaceholder?: boolean; + readonly allowRenderedConfig?: boolean; +}; + +export const FULL_PLAN_ONLY_SENTINEL = "nemoclaw-ci-full-plan-must-not-leak"; +export const TEAMS_APP_ID = "nemoclaw-ci-teams-app-id"; +export const TEAMS_TENANT_ID = "00000000-0000-0000-0000-000000000042"; +export const TEAMS_SECRET_PLACEHOLDER = "openshell:resolve:env:MSTEAMS_APP_PASSWORD"; +export const OPENCLAW_TEAMS_PACKAGE_SPEC = "npm:@openclaw/msteams@{{openclaw.version}}"; +export const OPENCLAW_TEAMS_PACKAGE_VERSION = "2026.6.10"; +export const HERMES_TEAMS_PACKAGE_SPEC = "microsoft-teams-apps==2.0.13.4"; +export const HERMES_AIOHTTP_PACKAGE_SPEC = "aiohttp==3.14.1"; + +const PLAN_ENV_KEY = "NEMOCLAW_MESSAGING_PLAN_B64"; +const RUNTIME_PLAN_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; +const OPENCLAW_CONFIG_PATH = "/sandbox/.openclaw/openclaw.json"; +const OPENCLAW_TEAMS_MANAGED_ROOT = + /^\/sandbox\/\.openclaw\/npm\/projects\/openclaw-msteams-[a-f0-9]{10}\/node_modules\/@openclaw\/msteams$/; +const OPENCLAW_TEAMS_PRELOAD_PATH = "/usr/local/lib/nemoclaw/preloads/msteams-message-hints.js"; +const HERMES_ENV_PATH = "/sandbox/.hermes/.env"; +const HERMES_CONFIG_PATH = "/sandbox/.hermes/config.yaml"; + +const OPENCLAW_RUNTIME_PRELOAD = Object.freeze({ + channelId: "teams", + module: "msteams-message-hints", + source: OPENCLAW_TEAMS_PRELOAD_PATH, + target: "/tmp/nemoclaw-msteams-message-hints.js", + injectInto: ["boot", "connect"], + optional: false, + installMessage: "[channels] Installing Microsoft Teams message hint patch (native mentions)", + installedMessage: + "[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)", +}); + +/** + * Return a compact plan equivalent to the build-relevant output of the Teams + * manifest compiler. Keep this stdlib-only so CI can generate the build arg + * before installing the repository's TypeScript runtime dependencies. + */ +export function createMessagingBoundaryPlan(agent: unknown) { + assertAgent(agent); + const common = { + schemaVersion: 1, + sandboxName: "nemoclaw-ci-messaging-plan-boundary", + agent, + workflow: "rebuild", + channels: [{ channelId: "teams", active: true, disabled: false }], + disabledChannels: [], + credentialBindings: [ + { + channelId: "teams", + credentialId: "teamsClientSecret", + providerEnvKey: "MSTEAMS_APP_PASSWORD", + placeholder: TEAMS_SECRET_PLACEHOLDER, + }, + ], + // Deliberately present only in the full build plan. The reduced runtime + // artifact must discard this field, and neither image nor process env may + // retain the encoded full plan. + fullPlanOnlySentinel: FULL_PLAN_ONLY_SENTINEL, + }; + + if (agent === "openclaw") { + return { + ...common, + agentRender: [ + { + channelId: "teams", + renderId: "teams-openclaw-channel", + kind: "json-fragment", + agent, + target: "openclaw.json", + path: "channels.msteams", + value: { + enabled: true, + appId: TEAMS_APP_ID, + appPassword: TEAMS_SECRET_PLACEHOLDER, + tenantId: TEAMS_TENANT_ID, + webhook: { port: 3978, path: "/api/messages" }, + healthMonitor: { enabled: false }, + streaming: { mode: "off" }, + groupPolicy: "open", + requireMention: true, + }, + }, + { + channelId: "teams", + renderId: "teams-openclaw-plugin", + kind: "json-fragment", + agent, + target: "openclaw.json", + path: "plugins.entries.msteams", + value: { enabled: true }, + }, + ], + buildSteps: [ + { + channelId: "teams", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + value: { + manager: "openclaw-plugin", + spec: OPENCLAW_TEAMS_PACKAGE_SPEC, + pin: true, + }, + }, + ], + runtimeSetup: { + nodePreloads: [OPENCLAW_RUNTIME_PRELOAD], + envAliases: [], + secretScans: [], + }, + }; + } + + return { + ...common, + agentRender: [ + { + channelId: "teams", + renderId: "teams-hermes-env", + kind: "env-lines", + agent, + target: "~/.hermes/.env", + lines: [ + `TEAMS_CLIENT_ID=${TEAMS_APP_ID}`, + `TEAMS_CLIENT_SECRET=${TEAMS_SECRET_PLACEHOLDER}`, + `TEAMS_TENANT_ID=${TEAMS_TENANT_ID}`, + "TEAMS_PORT=3978", + ], + }, + { + channelId: "teams", + renderId: "teams-hermes-platform", + kind: "json-fragment", + agent, + target: "~/.hermes/config.yaml", + path: "platforms.teams", + value: { enabled: true }, + }, + ], + buildSteps: [ + { + channelId: "teams", + kind: "package-install", + outputId: "hermesTeamsAppsPackage", + required: true, + value: { manager: "hermes-uv-pip", spec: HERMES_TEAMS_PACKAGE_SPEC }, + }, + { + channelId: "teams", + kind: "package-install", + outputId: "hermesAiohttpPackage", + required: true, + value: { manager: "hermes-uv-pip", spec: HERMES_AIOHTTP_PACKAGE_SPEC }, + }, + ], + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + }; +} + +export function encodeMessagingBoundaryPlan(agent: unknown): string { + return Buffer.from(JSON.stringify(createMessagingBoundaryPlan(agent))).toString("base64"); +} + +export function defaultDockerRunner(args: string[]): DockerRunnerResult { + return spawnSync("docker", args, { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); +} + +/** + * Verify the boundary against a built image. A runner receives Docker argv + * (without the leading `docker`) and returns a spawnSync-like result; tests can + * inject a deterministic runner without weakening the production CLI path. + */ +export function verifyMessagingPlanImageBoundary( + image: unknown, + agent: unknown, + runner: DockerRunner = defaultDockerRunner, +) { + assertImage(image); + assertAgent(agent); + + const inspected = parseJson( + runDocker(runner, ["image", "inspect", image], `inspect image ${image}`), + "docker image inspect output", + ); + if (!Array.isArray(inspected) || inspected.length !== 1 || !isObject(inspected[0]?.Config)) { + throw new Error(`docker image inspect returned an unexpected shape for ${image}`); + } + const configEnv = inspected[0].Config.Env; + if (configEnv !== undefined && configEnv !== null && !Array.isArray(configEnv)) { + throw new Error(`docker image inspect returned non-array Config.Env for ${image}`); + } + assertNoPlanEnv("image Config.Env", configEnv ?? []); + + const processEnv = runDocker( + runner, + ["run", "--rm", "--network", "none", "--entrypoint", "/usr/bin/env", image, "-0"], + `read process environment from ${image}`, + ); + assertNoPlanEnv("container process environment", processEnv.split("\0").filter(Boolean)); + + const artifactText = readImageFile(runner, image, RUNTIME_PLAN_PATH); + assertReducedRuntimeArtifact(artifactText, agent); + + if (agent === "openclaw") { + assertOpenClawEvidence(runner, image); + } else { + assertHermesEvidence(runner, image); + } + + return { image, agent, runtimePlanPath: RUNTIME_PLAN_PATH }; +} + +function assertReducedRuntimeArtifact(text: string, agent: MessagingBoundaryAgent): void { + assertDoesNotContainFullPlanData("reduced runtime plan", text); + const artifact = parseJson(text, "reduced runtime plan"); + if ( + !isObject(artifact) || + artifact.schemaVersion !== 1 || + artifact.agent !== agent || + artifact.sandboxName !== "nemoclaw-ci-messaging-plan-boundary" || + artifact.workflow !== "rebuild" + ) { + throw new Error(`reduced runtime plan is not a schemaVersion 1 ${agent} plan`); + } + assertAllowedKeys( + artifact, + [ + "schemaVersion", + "sandboxName", + "agent", + "workflow", + "channels", + "disabledChannels", + "credentialBindings", + "runtimeSetup", + ], + "reduced runtime plan", + ); + for (const key of [ + "agentRender", + "buildSteps", + "stateUpdates", + "healthChecks", + "fullPlanOnlySentinel", + ]) { + if (Object.hasOwn(artifact, key)) { + throw new Error(`reduced runtime plan unexpectedly contains full-plan key ${key}`); + } + } + if ( + !Array.isArray(artifact.channels) || + artifact.channels.length !== 1 || + !isObject(artifact.channels[0]) || + artifact.channels[0]?.channelId !== "teams" || + artifact.channels[0]?.active !== true || + artifact.channels[0]?.disabled !== false + ) { + throw new Error("reduced runtime plan does not retain the active Teams channel marker"); + } + assertAllowedKeys( + artifact.channels[0], + ["channelId", "active", "disabled"], + "reduced runtime plan channel", + ); + if (!Array.isArray(artifact.disabledChannels) || artifact.disabledChannels.length !== 0) { + throw new Error("reduced runtime plan contains unexpected disabled channels"); + } + const [credentialBinding] = Array.isArray(artifact.credentialBindings) + ? artifact.credentialBindings + : []; + if ( + !Array.isArray(artifact.credentialBindings) || + artifact.credentialBindings.length !== 1 || + !isObject(credentialBinding) || + credentialBinding.channelId !== "teams" || + credentialBinding.providerEnvKey !== "MSTEAMS_APP_PASSWORD" + ) { + throw new Error("reduced runtime plan does not contain the sanitized Teams credential binding"); + } + assertAllowedKeys( + credentialBinding, + ["channelId", "providerEnvKey"], + "reduced runtime plan credential binding", + ); + const runtimeSetup = artifact.runtimeSetup; + if ( + !isObject(runtimeSetup) || + !Array.isArray(runtimeSetup.nodePreloads) || + !Array.isArray(runtimeSetup.envAliases) || + !Array.isArray(runtimeSetup.secretScans) + ) { + throw new Error("reduced runtime plan is missing the normalized runtimeSetup arrays"); + } + assertAllowedKeys( + runtimeSetup, + ["nodePreloads", "envAliases", "secretScans"], + "reduced runtime plan runtimeSetup", + ); + assertRuntimeSetupEntryAllowlist( + runtimeSetup.nodePreloads, + [ + "channelId", + "source", + "target", + "injectInto", + "optional", + "installMessage", + "installedMessage", + ], + "nodePreloads", + ); + assertRuntimeSetupEntryAllowlist( + runtimeSetup.envAliases, + ["channelId", "envKey", "match", "value", "message"], + "envAliases", + ); + assertRuntimeSetupEntryAllowlist( + runtimeSetup.secretScans, + ["channelId", "path", "pattern", "message", "exitCode"], + "secretScans", + ); + if (runtimeSetup.envAliases.length !== 0 || runtimeSetup.secretScans.length !== 0) { + throw new Error("reduced runtime plan contains unexpected Teams runtime setup entries"); + } + if (agent === "openclaw") { + const [preload] = runtimeSetup.nodePreloads; + if ( + runtimeSetup.nodePreloads.length !== 1 || + !isObject(preload) || + preload.channelId !== "teams" || + preload.source !== OPENCLAW_TEAMS_PRELOAD_PATH || + preload.target !== OPENCLAW_RUNTIME_PRELOAD.target || + JSON.stringify(preload.injectInto) !== JSON.stringify(["boot", "connect"]) || + preload.optional !== false || + Object.hasOwn(preload, "module") + ) { + throw new Error("reduced runtime plan does not contain the sanitized Teams node preload"); + } + } else if (runtimeSetup.nodePreloads.length !== 0) { + throw new Error("Hermes reduced runtime plan unexpectedly contains node preloads"); + } +} + +function assertOpenClawEvidence(runner: DockerRunner, image: string): void { + const configText = readImageFile(runner, image, OPENCLAW_CONFIG_PATH); + assertDoesNotContainFullPlanData("OpenClaw rendered config", configText, { + allowPlaceholder: true, + allowRenderedConfig: true, + }); + const config = parseJson(configText, "OpenClaw rendered config"); + const channels = isObject(config) && isObject(config.channels) ? config.channels : {}; + const teams = channels.msteams; + const plugins = isObject(config) && isObject(config.plugins) ? config.plugins : {}; + const pluginEntries = isObject(plugins.entries) ? plugins.entries : {}; + const teamsPlugin = pluginEntries.msteams; + if ( + !isObject(teams) || + teams.enabled !== true || + teams.appId !== TEAMS_APP_ID || + teams.appPassword !== TEAMS_SECRET_PLACEHOLDER || + teams.tenantId !== TEAMS_TENANT_ID || + !isObject(teamsPlugin) || + teamsPlugin.enabled !== true + ) { + throw new Error("OpenClaw image is missing the expected Teams render output"); + } + + const inspectText = runDocker( + runner, + [ + "run", + "--rm", + "--network", + "none", + "--user", + "sandbox", + "--env", + "HOME=/sandbox", + "--entrypoint", + "openclaw", + image, + "plugins", + "inspect", + "msteams", + "--runtime", + "--json", + ], + `inspect the OpenClaw Teams plugin in ${image}`, + ); + const inspect = parseJsonAfterLogPreamble(inspectText, "OpenClaw Teams plugin inspection"); + const plugin = isObject(inspect) && isObject(inspect.plugin) ? inspect.plugin : {}; + const hasTeamsChannel = + isObject(inspect) && + Array.isArray(inspect.capabilities) && + inspect.capabilities.some( + (capability) => + isObject(capability) && + capability.kind === "channel" && + Array.isArray(capability.ids) && + capability.ids.includes("msteams"), + ); + if ( + plugin.id !== "msteams" || + plugin.packageName !== "@openclaw/msteams" || + plugin.version !== OPENCLAW_TEAMS_PACKAGE_VERSION || + plugin.status !== "loaded" || + typeof plugin.rootDir !== "string" || + !OPENCLAW_TEAMS_MANAGED_ROOT.test(plugin.rootDir) || + !hasTeamsChannel + ) { + throw new Error( + `OpenClaw Teams plugin evidence must be loaded from the managed npm project as @openclaw/msteams@${OPENCLAW_TEAMS_PACKAGE_VERSION} with the msteams channel registered`, + ); + } + + const preload = readImageFile(runner, image, OPENCLAW_TEAMS_PRELOAD_PATH); + if (preload.trim().length === 0) { + throw new Error("OpenClaw Teams runtime preload is empty"); + } +} + +function assertHermesEvidence(runner: DockerRunner, image: string): void { + const envText = readImageFile(runner, image, HERMES_ENV_PATH); + assertDoesNotContainFullPlanData("Hermes rendered .env", envText, { + allowPlaceholder: true, + allowRenderedConfig: true, + }); + const renderedEnv = parseEnvFile(envText); + if ( + renderedEnv.TEAMS_CLIENT_ID !== TEAMS_APP_ID || + renderedEnv.TEAMS_CLIENT_SECRET !== TEAMS_SECRET_PLACEHOLDER || + renderedEnv.TEAMS_TENANT_ID !== TEAMS_TENANT_ID + ) { + throw new Error("Hermes image is missing the expected Teams .env render output"); + } + + const yaml = readImageFile(runner, image, HERMES_CONFIG_PATH); + assertDoesNotContainFullPlanData("Hermes rendered config", yaml, { + allowRenderedConfig: true, + }); + if (!yamlHasNestedBooleanTrue(yaml, "platforms", "teams", "enabled")) { + throw new Error("Hermes image is missing platforms.teams.enabled: true"); + } + + const packageVersionsText = runDocker( + runner, + [ + "run", + "--rm", + "--network", + "none", + "--entrypoint", + "/opt/hermes/.venv/bin/python", + image, + "-c", + 'import importlib.metadata as m,json; print(json.dumps({"microsoft-teams-apps":m.version("microsoft-teams-apps"),"aiohttp":m.version("aiohttp")}))', + ], + `read Hermes Teams package metadata from ${image}`, + ); + const packageVersions = parseJson(packageVersionsText, "Hermes package metadata"); + if ( + !isObject(packageVersions) || + packageVersions["microsoft-teams-apps"] !== "2.0.13.4" || + packageVersions.aiohttp !== "3.14.1" + ) { + throw new Error( + `Hermes package evidence must include ${HERMES_TEAMS_PACKAGE_SPEC} and ${HERMES_AIOHTTP_PACKAGE_SPEC}`, + ); + } +} + +function readImageFile(runner: DockerRunner, image: string, path: string): string { + return runDocker( + runner, + ["run", "--rm", "--network", "none", "--entrypoint", "/bin/cat", image, path], + `read ${path} from ${image}`, + ); +} + +function runDocker(runner: DockerRunner, args: string[], action: string): string { + let result: DockerRunnerResult; + try { + result = runner(args); + } catch (error) { + throw new Error(`Failed to ${action}: ${formatError(error)}`); + } + if (typeof result === "string") return result; + if (!isObject(result)) { + throw new Error(`Failed to ${action}: Docker runner returned no result`); + } + if (result.error) { + throw new Error(`Failed to ${action}: ${formatError(result.error)}`); + } + if (result.status !== 0) { + const detail = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim(); + throw new Error( + `Failed to ${action}: docker ${args.join(" ")} exited ${String(result.status)}` + + (detail ? `: ${detail}` : ""), + ); + } + return String(result.stdout ?? ""); +} + +function assertNoPlanEnv(label: string, entries: unknown): void { + if ( + !Array.isArray(entries) || + !entries.every((entry): entry is string => typeof entry === "string") + ) { + throw new Error(`${label} must be a string array`); + } + const planEntry = entries.find((entry) => entry.split("=", 1)[0] === PLAN_ENV_KEY); + if (planEntry) { + throw new Error(`${label} retains forbidden ${PLAN_ENV_KEY}`); + } + assertDoesNotContainFullPlanData(label, entries.join("\n")); +} + +function assertDoesNotContainFullPlanData( + label: string, + text: string, + { allowPlaceholder = false, allowRenderedConfig = false }: FullPlanScanOptions = {}, +): void { + const forbidden = [ + { value: FULL_PLAN_ONLY_SENTINEL, name: FULL_PLAN_ONLY_SENTINEL }, + { value: PLAN_ENV_KEY, name: PLAN_ENV_KEY }, + { + value: encodeMessagingBoundaryPlan("openclaw"), + name: "encoded openclaw messaging plan", + }, + { value: encodeMessagingBoundaryPlan("hermes"), name: "encoded Hermes messaging plan" }, + ]; + if (!allowPlaceholder) { + forbidden.push({ value: TEAMS_SECRET_PLACEHOLDER, name: TEAMS_SECRET_PLACEHOLDER }); + } + if (!allowRenderedConfig) { + forbidden.push( + { value: TEAMS_APP_ID, name: TEAMS_APP_ID }, + { value: TEAMS_TENANT_ID, name: TEAMS_TENANT_ID }, + ); + } + for (const { value, name } of forbidden) { + if (text.includes(value)) { + throw new Error(`${label} contains full messaging plan data: ${name}`); + } + } +} + +function assertAllowedKeys(value: JsonRecord, allowedKeys: readonly string[], label: string): void { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(value) + .filter((key) => !allowed.has(key)) + .sort(); + if (unexpected.length > 0) { + throw new Error(`${label} contains non-allowlisted fields: ${unexpected.join(", ")}`); + } +} + +function assertRuntimeSetupEntryAllowlist( + entries: unknown[], + allowedKeys: readonly string[], + label: string, +): void { + entries.forEach((entry, index) => { + if (!isObject(entry)) { + throw new Error(`reduced runtime plan ${label}[${index}] must be an object`); + } + assertAllowedKeys(entry, allowedKeys, `reduced runtime plan ${label}[${index}]`); + }); +} + +function parseEnvFile(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const separator = trimmed.indexOf("="); + if (separator <= 0) throw new Error(`Hermes rendered .env contains an invalid line: ${line}`); + out[trimmed.slice(0, separator)] = trimmed.slice(separator + 1); + } + return out; +} + +function yamlHasNestedBooleanTrue( + text: string, + parentKey: string, + childKey: string, + valueKey: string, +): boolean { + const lines = text.split(/\r?\n/); + let parentIndent: number | null = null; + let childIndent: number | null = null; + for (const rawLine of lines) { + if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue; + const indent = rawLine.length - rawLine.trimStart().length; + const line = rawLine.trim(); + if (parentIndent === null) { + if (line === `${parentKey}:`) parentIndent = indent; + continue; + } + if (indent <= parentIndent) { + parentIndent = line === `${parentKey}:` ? indent : null; + childIndent = null; + continue; + } + if (childIndent === null) { + if (line === `${childKey}:`) childIndent = indent; + continue; + } + if (indent <= childIndent) { + childIndent = line === `${childKey}:` ? indent : null; + continue; + } + if (line === `${valueKey}: true`) return true; + } + return false; +} + +function parseJson(text: string, label: string): unknown { + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${formatError(error)}`); + } +} + +function parseJsonAfterLogPreamble(text: string, label: string): unknown { + const jsonStart = text.search(/^\s*\{/mu); + return parseJson(jsonStart < 0 ? text : text.slice(jsonStart), label); +} + +function assertAgent(agent: unknown): asserts agent is MessagingBoundaryAgent { + if (agent !== "openclaw" && agent !== "hermes") { + throw new Error(`agent must be 'openclaw' or 'hermes', got ${String(agent)}`); + } +} + +function assertImage(image: unknown): asserts image is string { + if (typeof image !== "string" || !image.trim() || image.startsWith("-")) { + throw new Error("image must be a non-empty Docker image reference"); + } +} + +function isObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function usage(): string { + return [ + "Usage:", + " node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts plan ", + " node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify ", + ].join("\n"); +} + +function main(argv: string[]): void { + const [command, first, second, ...extra] = argv; + if (command === "plan" && first && !second && extra.length === 0) { + process.stdout.write(encodeMessagingBoundaryPlan(first)); + return; + } + if (command === "verify" && first && second && extra.length === 0) { + verifyMessagingPlanImageBoundary(first, second); + process.stdout.write(`Verified ${second} messaging plan image boundary: ${first}\n`); + return; + } + throw new Error(usage()); +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; +if (invokedPath === import.meta.url) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`ERROR: ${formatError(error)}\n`); + process.exitCode = 1; + } +} diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index f79bd096535..9d81089a20c 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -119,6 +119,47 @@ describe("sandbox image workflow boundary", () => { } }); + it("keeps messaging plan image probes isolated, guarded, local, and verified", () => { + const { imageWorkflow, mainWorkflow } = readWorkflows(); + const probe = imageWorkflow.jobs["messaging-plan-image-boundary"]; + probe["timeout-minutes"] = 60; + probe.needs = "build-sandbox-images"; + probe.steps!.find((step) => step.name === "Set up Node")!.with!["node-version"] = "22"; + probe.steps!.push({ ...probe.steps!.find((step) => step.name === "Set up Node")! }); + probe.steps!.push({ + name: "Publish probe image", + uses: "actions/upload-artifact@0000000000000000000000000000000000000000", + }); + + const openclaw = probe.steps!.find( + (step) => step.name === "Build and verify OpenClaw messaging plan boundary", + )!; + openclaw.run = openclaw.run!.replace( + 'scripts/check-production-build-args.sh "${build_args[@]}"', + 'echo "guard bypassed"', + ); + + const hermes = probe.steps!.find( + (step) => step.name === "Build and verify Hermes messaging plan boundary", + )!; + hermes.run = hermes.run!.replace( + "check-messaging-plan-image-boundary.mts verify", + "check-messaging-plan-image-boundary.mts bypass", + ); + + expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( + expect.arrayContaining([ + "messaging plan image boundary must retain its 30-minute budget", + "messaging plan image boundary must remain isolated from canonical image jobs", + "messaging plan image boundary must set up Node exactly once", + "messaging plan image boundary must use Node 22.19.0", + 'openclaw messaging plan image boundary must include scripts/check-production-build-args.sh "${build_args[@]}"', + "hermes messaging plan image boundary must include node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify nemoclaw-hermes-plan-boundary hermes", + "messaging plan image boundary must not publish probe image artifacts", + ]), + ); + }); + it("rejects coupling, rebuilding, or failing to reuse the OpenClaw image artifact", () => { const { imageWorkflow, mainWorkflow } = readWorkflows(); const producer = imageWorkflow.jobs["build-sandbox-images"]; diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 8c05a6cf0c3..b715c476111 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -326,13 +326,16 @@ describe("messaging-build-applier.mts: agent-install", () => { ); }); - it("writes a reduced runtime plan artifact for entrypoint startup", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-plan-artifact-")); + it.each([ + "openclaw", + "hermes", + ] as const)("writes a reduced %s runtime plan artifact for entrypoint startup (#5896)", (agent) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-${agent}-runtime-plan-artifact-`)); const artifactPath = path.join(tmp, "runtime", "messaging-runtime-plan.json"); const plan = { schemaVersion: 1, sandboxName: "test-sandbox", - agent: "openclaw", + agent, workflow: "rebuild", channels: [ { @@ -357,8 +360,8 @@ describe("messaging-build-applier.mts: agent-install", () => { agentRender: [ { channelId: "telegram", - agent: "openclaw", - target: "openclaw.json", + agent, + target: agent === "openclaw" ? "openclaw.json" : "config.yaml", kind: "json-fragment", path: "channels.telegram", value: { token: "do-not-persist-render-value" }, @@ -393,14 +396,7 @@ describe("messaging-build-applier.mts: agent-install", () => { try { const result = spawnSync( "node", - [ - "--experimental-strip-types", - SCRIPT_PATH, - "--agent", - "openclaw", - "--phase", - "runtime-setup", - ], + ["--experimental-strip-types", SCRIPT_PATH, "--agent", agent, "--phase", "runtime-setup"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -418,7 +414,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(artifact).toMatchObject({ schemaVersion: 1, sandboxName: "test-sandbox", - agent: "openclaw", + agent, workflow: "rebuild", channels: [ { channelId: "telegram", active: true, disabled: false }, diff --git a/test/messaging-image-env-contract.test.ts b/test/messaging-image-env-contract.test.ts new file mode 100644 index 00000000000..fa58d12273f --- /dev/null +++ b/test/messaging-image-env-contract.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const MESSAGING_PLAN_ENV_KEY = "NEMOCLAW_MESSAGING_PLAN_B64"; + +function dockerfileEnvNames(dockerfile: string): string[] { + const directives = dockerfile.match(/^ENV[ \t]+(?:.*\\\r?\n)*.*$/gm) ?? []; + return directives.flatMap((directive) => { + const body = directive + .replace(/^ENV[ \t]+/, "") + .replace(/\\\r?\n/g, " ") + .trim(); + const firstToken = body.split(/\s+/, 1)[0] ?? ""; + const modernNames = body + .split(/\s+/) + .map((token) => token.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1]) + .filter((name): name is string => Boolean(name)); + const legacyName = /^[A-Za-z_][A-Za-z0-9_]*$/.test(firstToken) ? [firstToken] : []; + return firstToken.includes("=") ? modernNames : legacyName; + }); +} + +describe("messaging plan final image environment contract", () => { + it("recognizes modern and legacy Dockerfile ENV forms", () => { + expect( + dockerfileEnvNames( + "ENV MODERN=value OTHER=second\nENV LEGACY value\nENV LEGACY_B64 eyJhIjoxfQ=\n", + ), + ).toEqual(["MODERN", "OTHER", "LEGACY", "LEGACY_B64"]); + }); + + // source-shape-contract: security -- Full serialized messaging plans must remain build-only while final agent images retain only reduced runtime metadata + it.each([ + ["OpenClaw", "Dockerfile.base", "Dockerfile", "openclaw"], + ["Hermes", "agents/hermes/Dockerfile.base", "agents/hermes/Dockerfile", "hermes"], + ])("%s keeps the full plan in build processes but not final runtime environments (#5896)", (_label, basePath, finalPath, agent) => { + const baseDockerfile = path.join(ROOT, basePath); + const finalDockerfile = path.join(ROOT, finalPath); + const dockerfile = fs.readFileSync(finalDockerfile, "utf-8"); + const planArgIndex = dockerfile.indexOf(`ARG ${MESSAGING_PLAN_ENV_KEY}=`); + + expect(planArgIndex).toBeGreaterThan(dockerfile.lastIndexOf("\nFROM ")); + const imageEnvNames = [baseDockerfile, finalDockerfile].flatMap((file) => + dockerfileEnvNames(fs.readFileSync(file, "utf-8")), + ); + expect(imageEnvNames).not.toContain(MESSAGING_PLAN_ENV_KEY); + + for (const phase of ["runtime-setup", "agent-install", "post-agent-install"]) { + const phaseIndex = dockerfile.indexOf(`--agent ${agent} --phase ${phase}`); + expect(phaseIndex, `${agent} is missing the ${phase} messaging build phase`).toBeGreaterThan( + planArgIndex, + ); + } + + const runtimeProbe = spawnSync( + process.execPath, + [ + "-e", + `process.stdout.write(String(Object.hasOwn(process.env, ${JSON.stringify(MESSAGING_PLAN_ENV_KEY)})))`, + ], + { + encoding: "utf-8", + env: Object.fromEntries(imageEnvNames.map((name) => [name, "image-config-value"])), + timeout: 5000, + }, + ); + expect(runtimeProbe.status, runtimeProbe.stderr).toBe(0); + expect(runtimeProbe.stdout).toBe("false"); + }); +}); diff --git a/test/messaging-plan-image-boundary.test.ts b/test/messaging-plan-image-boundary.test.ts new file mode 100644 index 00000000000..f1529a8745b --- /dev/null +++ b/test/messaging-plan-image-boundary.test.ts @@ -0,0 +1,397 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + createMessagingBoundaryPlan, + encodeMessagingBoundaryPlan, + FULL_PLAN_ONLY_SENTINEL, + HERMES_AIOHTTP_PACKAGE_SPEC, + HERMES_TEAMS_PACKAGE_SPEC, + OPENCLAW_TEAMS_PACKAGE_SPEC, + OPENCLAW_TEAMS_PACKAGE_VERSION, + TEAMS_APP_ID, + TEAMS_SECRET_PLACEHOLDER, + TEAMS_TENANT_ID, + verifyMessagingPlanImageBoundary, +} from "../scripts/check-messaging-plan-image-boundary.mts"; +import { teamsManifest } from "../src/lib/messaging/channels/teams/manifest"; + +type Agent = "openclaw" | "hermes"; +type DockerResult = { status: number; stdout?: string; stderr?: string }; + +const IMAGE = "nemoclaw-messaging-boundary:test"; +const RUNTIME_PLAN_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; +const PRELOAD_PATH = "/usr/local/lib/nemoclaw/preloads/msteams-message-hints.js"; +const OPENCLAW_TEAMS_ROOT = + "/sandbox/.openclaw/npm/projects/openclaw-msteams-d29647a7c0/node_modules/@openclaw/msteams"; + +function decodePlan(agent: Agent): any { + return JSON.parse(Buffer.from(encodeMessagingBoundaryPlan(agent), "base64").toString("utf8")); +} + +function reducedArtifact(agent: Agent): string { + return JSON.stringify({ + schemaVersion: 1, + sandboxName: "nemoclaw-ci-messaging-plan-boundary", + agent, + workflow: "rebuild", + channels: [{ channelId: "teams", active: true, disabled: false }], + disabledChannels: [], + credentialBindings: [{ channelId: "teams", providerEnvKey: "MSTEAMS_APP_PASSWORD" }], + runtimeSetup: { + nodePreloads: + agent === "openclaw" + ? [ + { + channelId: "teams", + source: PRELOAD_PATH, + target: "/tmp/nemoclaw-msteams-message-hints.js", + injectInto: ["boot", "connect"], + optional: false, + }, + ] + : [], + envAliases: [], + secretScans: [], + }, + }); +} + +function openClawInspectReport(): any { + return { + plugin: { + id: "msteams", + name: "Microsoft Teams", + packageName: "@openclaw/msteams", + version: OPENCLAW_TEAMS_PACKAGE_VERSION, + status: "loaded", + rootDir: OPENCLAW_TEAMS_ROOT, + }, + capabilities: [{ kind: "channel", ids: ["msteams"] }], + }; +} + +function successfulDockerRunner(agent: Agent, openClawInspect = openClawInspectReport()) { + const expected: Array<{ args: string[]; result: DockerResult }> = [ + { + args: ["image", "inspect", IMAGE], + result: { status: 0, stdout: JSON.stringify([{ Config: { Env: ["PATH=/usr/bin"] } }]) }, + }, + { + args: ["run", "--rm", "--network", "none", "--entrypoint", "/usr/bin/env", IMAGE, "-0"], + result: { status: 0, stdout: "PATH=/usr/bin\0HOME=/sandbox\0" }, + }, + { + args: imageFileArgs(RUNTIME_PLAN_PATH), + result: { status: 0, stdout: reducedArtifact(agent) }, + }, + ]; + + expected.push( + ...(agent === "openclaw" + ? [ + { + args: imageFileArgs("/sandbox/.openclaw/openclaw.json"), + result: { + status: 0, + stdout: JSON.stringify({ + channels: { + msteams: { + enabled: true, + appId: TEAMS_APP_ID, + appPassword: TEAMS_SECRET_PLACEHOLDER, + tenantId: TEAMS_TENANT_ID, + }, + }, + plugins: { entries: { msteams: { enabled: true } } }, + }), + }, + }, + { + args: [ + "run", + "--rm", + "--network", + "none", + "--user", + "sandbox", + "--env", + "HOME=/sandbox", + "--entrypoint", + "openclaw", + IMAGE, + "plugins", + "inspect", + "msteams", + "--runtime", + "--json", + ], + result: { + status: 0, + stdout: `[proxy] routing through the managed sandbox proxy\n${JSON.stringify(openClawInspect)}`, + }, + }, + { + args: imageFileArgs(PRELOAD_PATH), + result: { status: 0, stdout: "export function install() {}\n" }, + }, + ] + : [ + { + args: imageFileArgs("/sandbox/.hermes/.env"), + result: { + status: 0, + stdout: [ + `TEAMS_CLIENT_ID=${TEAMS_APP_ID}`, + `TEAMS_CLIENT_SECRET=${TEAMS_SECRET_PLACEHOLDER}`, + `TEAMS_TENANT_ID=${TEAMS_TENANT_ID}`, + "TEAMS_PORT=3978", + "", + ].join("\n"), + }, + }, + { + args: imageFileArgs("/sandbox/.hermes/config.yaml"), + result: { status: 0, stdout: "platforms:\n teams:\n enabled: true\n" }, + }, + { + args: [ + "run", + "--rm", + "--network", + "none", + "--entrypoint", + "/opt/hermes/.venv/bin/python", + IMAGE, + "-c", + expect.any(String), + ] as unknown as string[], + result: { + status: 0, + stdout: JSON.stringify({ "microsoft-teams-apps": "2.0.13.4", aiohttp: "3.14.1" }), + }, + }, + ]), + ); + + let cursor = 0; + const runner = (args: string[]): DockerResult => { + const next = expected[cursor++]; + expect(next, `unexpected docker call: ${args.join(" ")}`).toBeDefined(); + expect(args).toEqual(next!.args); + return next!.result; + }; + return { runner, assertComplete: () => expect(cursor).toBe(expected.length) }; +} + +function imageFileArgs(path: string): string[] { + return ["run", "--rm", "--network", "none", "--entrypoint", "/bin/cat", IMAGE, path]; +} + +describe("messaging plan image boundary helper", () => { + it.each([ + "openclaw", + "hermes", + ] as const)("builds a deterministic placeholder-only %s plan", (agent) => { + expect(encodeMessagingBoundaryPlan(agent)).toBe(encodeMessagingBoundaryPlan(agent)); + const plan = decodePlan(agent); + expect(plan).toEqual(createMessagingBoundaryPlan(agent)); + expect(plan.fullPlanOnlySentinel).toBe(FULL_PLAN_ONLY_SENTINEL); + expect(plan.credentialBindings).toEqual([ + expect.objectContaining({ + providerEnvKey: "MSTEAMS_APP_PASSWORD", + placeholder: TEAMS_SECRET_PLACEHOLDER, + }), + ]); + expect(JSON.stringify(plan)).not.toContain("client-secret-value"); + expect(JSON.stringify(plan)).not.toContain("password-value"); + }); + + it("tracks exact current Teams manifest package specs", () => { + const manifestSpecs = (teamsManifest.agentPackages ?? []).map(({ agent, manager, spec }) => ({ + agent, + manager, + spec, + })); + const planSpecs = (["openclaw", "hermes"] as const).flatMap((agent) => + decodePlan(agent).buildSteps.map(({ value }: any) => ({ + agent, + manager: value.manager, + spec: value.spec, + })), + ); + + expect(planSpecs).toEqual(manifestSpecs); + expect(OPENCLAW_TEAMS_PACKAGE_SPEC).toBe("npm:@openclaw/msteams@{{openclaw.version}}"); + expect(HERMES_TEAMS_PACKAGE_SPEC).toBe("microsoft-teams-apps==2.0.13.4"); + expect(HERMES_AIOHTTP_PACKAGE_SPEC).toBe("aiohttp==3.14.1"); + }); + + it("emits agent-specific Teams render, install, and runtime outputs", () => { + const openclaw = decodePlan("openclaw"); + expect(openclaw.agentRender).toEqual([ + expect.objectContaining({ + target: "openclaw.json", + path: "channels.msteams", + value: expect.objectContaining({ + appId: TEAMS_APP_ID, + appPassword: TEAMS_SECRET_PLACEHOLDER, + }), + }), + expect.objectContaining({ target: "openclaw.json", path: "plugins.entries.msteams" }), + ]); + expect(openclaw.runtimeSetup.nodePreloads).toEqual([ + expect.objectContaining({ + module: "msteams-message-hints", + source: PRELOAD_PATH, + injectInto: ["boot", "connect"], + }), + ]); + + const hermes = decodePlan("hermes"); + expect(hermes.agentRender).toEqual([ + expect.objectContaining({ + target: "~/.hermes/.env", + lines: expect.arrayContaining([ + `TEAMS_CLIENT_ID=${TEAMS_APP_ID}`, + `TEAMS_CLIENT_SECRET=${TEAMS_SECRET_PLACEHOLDER}`, + ]), + }), + expect.objectContaining({ target: "~/.hermes/config.yaml", path: "platforms.teams" }), + ]); + expect(hermes.buildSteps.map((step: any) => step.value.spec)).toEqual([ + HERMES_TEAMS_PACKAGE_SPEC, + HERMES_AIOHTTP_PACKAGE_SPEC, + ]); + }); + + it.each([ + "openclaw", + "hermes", + ] as const)("verifies real-image evidence for %s through an injectable Docker runner", (agent) => { + const mock = successfulDockerRunner(agent); + expect(verifyMessagingPlanImageBoundary(IMAGE, agent, mock.runner)).toEqual({ + image: IMAGE, + agent, + runtimePlanPath: RUNTIME_PLAN_PATH, + }); + mock.assertComplete(); + }); + + it.each([ + [ + "legacy extension layout", + (report: any) => (report.plugin.rootDir = "/sandbox/.openclaw/extensions/msteams"), + ], + ["wrong package version", (report: any) => (report.plugin.version = "2026.6.9")], + ["missing runtime channel", (report: any) => (report.capabilities = [])], + ])("rejects OpenClaw Teams plugin evidence with %s", (_label, mutate) => { + const report = openClawInspectReport(); + mutate(report); + const mock = successfulDockerRunner("openclaw", report); + + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "openclaw", mock.runner)).toThrow( + "OpenClaw Teams plugin evidence must be loaded from the managed npm project", + ); + }); + + it("fails before container execution when Config.Env retains the full plan", () => { + const runner = (args: string[]): DockerResult => { + expect(args).toEqual(["image", "inspect", IMAGE]); + return { + status: 0, + stdout: JSON.stringify([ + { + Config: { + Env: [`NEMOCLAW_MESSAGING_PLAN_B64=${encodeMessagingBoundaryPlan("openclaw")}`], + }, + }, + ]), + }; + }; + + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "openclaw", runner)).toThrow( + "image Config.Env retains forbidden NEMOCLAW_MESSAGING_PLAN_B64", + ); + }); + + it("rejects the encoded full plan even when the image renames the environment key", () => { + const runner = (): DockerResult => ({ + status: 0, + stdout: JSON.stringify([ + { + Config: { + Env: [`RENAMED_PLAN=${encodeMessagingBoundaryPlan("openclaw")}`], + }, + }, + ]), + }); + + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "openclaw", runner)).toThrow( + "image Config.Env contains full messaging plan data: encoded openclaw messaging plan", + ); + }); + + it("fails when the running container process inherits the full plan", () => { + const results: DockerResult[] = [ + { + status: 0, + stdout: JSON.stringify([{ Config: { Env: ["PATH=/usr/bin"] } }]), + }, + { + status: 0, + stdout: `PATH=/usr/bin\0NEMOCLAW_MESSAGING_PLAN_B64=${encodeMessagingBoundaryPlan("openclaw")}\0`, + }, + ]; + const runner = (): DockerResult => results.shift()!; + + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "openclaw", runner)).toThrow( + "container process environment retains forbidden NEMOCLAW_MESSAGING_PLAN_B64", + ); + expect(results).toHaveLength(0); + }); + + it.each([ + ["top-level", "fullPlanShadow", (artifact: any) => (artifact.fullPlanShadow = true)], + ["channel", "inputs", (artifact: any) => (artifact.channels[0].inputs = ["secret"])], + [ + "credential binding", + "credentialHash", + (artifact: any) => (artifact.credentialBindings[0].credentialHash = "digest"), + ], + ["runtime setup", "plan", (artifact: any) => (artifact.runtimeSetup.plan = {})], + [ + "runtime preload", + "module", + (artifact: any) => (artifact.runtimeSetup.nodePreloads[0].module = "full-plan-only"), + ], + ])("rejects non-allowlisted %s fields in the reduced artifact", (_label, field, mutate) => { + const artifact = JSON.parse(reducedArtifact("openclaw")); + mutate(artifact); + const results: DockerResult[] = [ + { + status: 0, + stdout: JSON.stringify([{ Config: { Env: ["PATH=/usr/bin"] } }]), + }, + { status: 0, stdout: "PATH=/usr/bin\0" }, + { status: 0, stdout: JSON.stringify(artifact) }, + ]; + const runner = (): DockerResult => results.shift()!; + + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "openclaw", runner)).toThrow( + `contains non-allowlisted fields: ${field}`, + ); + expect(results).toHaveLength(0); + }); + + it("fails closed when Docker cannot provide evidence", () => { + const runner = (): DockerResult => ({ + status: 125, + stderr: "Cannot connect to the Docker daemon", + }); + expect(() => verifyMessagingPlanImageBoundary(IMAGE, "hermes", runner)).toThrow( + "Cannot connect to the Docker daemon", + ); + }); +}); diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index ac1c077880e..9961e046917 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -22,9 +22,11 @@ const CLEANUP_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; const HERMES_SECRET_BOUNDARY_STEP_ID = "hermes-secret-boundary"; const HERMES_ROOT_AFTER_SECRET_CONDITION = "${{ !cancelled() && (steps.hermes-secret-boundary.outcome == 'success' || steps.hermes-secret-boundary.outcome == 'failure') }}"; +const MESSAGING_PLAN_IMAGE_BOUNDARY_JOB = "messaging-plan-image-boundary"; const IMAGE_BUILD_JOBS = [ "build-sandbox-images", "build-hermes-sandbox-image", + MESSAGING_PLAN_IMAGE_BOUNDARY_JOB, "build-sandbox-images-arm64", ] as const; const OPENCLAW_IMAGE_CONSUMER_JOBS = [ @@ -368,6 +370,118 @@ function validateGuardedProductionBuildContracts( } } +function normalizedShell(run: string | undefined): string { + return (run ?? "") + .replace(/\\\r?\n\s*/gu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +function validateMessagingPlanBoundaryBuild( + errors: string[], + job: SandboxImagesWorkflowJob, + options: { + readonly agent: "hermes" | "openclaw"; + readonly baseArgName: "BASE_IMAGE"; + readonly baseEnvName: "BASE_IMAGE" | "HERMES_BASE_IMAGE"; + readonly stepName: string; + readonly target: string; + }, +): void { + const step = requireStep(errors, MESSAGING_PLAN_IMAGE_BOUNDARY_JOB, job, options.stepName); + const run = normalizedShell(step.run); + const expectedEnv = { + [options.baseEnvName]: `\${{ env.${options.baseEnvName} }}`, + }; + const requiredFragments = [ + "set -euo pipefail", + `node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts plan ${options.agent}`, + `--build-arg \"${options.baseArgName}=\${${options.baseEnvName}}\"`, + '--build-arg "NEMOCLAW_MESSAGING_PLAN_B64=${messaging_plan_b64}"', + 'scripts/check-production-build-args.sh "${build_args[@]}"', + `docker build \"\${build_args[@]}\" -t ${options.target} .`, + `node --experimental-strip-types scripts/check-messaging-plan-image-boundary.mts verify ${options.target} ${options.agent}`, + ]; + + if (step.shell !== "bash" || !isDeepStrictEqual(record(step.env), expectedEnv)) { + errors.push(`${options.agent} messaging plan image boundary must use guarded bash env scope`); + } + for (const fragment of requiredFragments) { + if (!run.includes(fragment)) { + errors.push(`${options.agent} messaging plan image boundary must include ${fragment}`); + } + } + + const planIndex = run.indexOf("check-messaging-plan-image-boundary.mts plan"); + const guardIndex = run.indexOf("check-production-build-args.sh"); + const buildIndex = run.indexOf(`docker build \"\${build_args[@]}\" -t ${options.target}`); + const verifyIndex = run.indexOf("check-messaging-plan-image-boundary.mts verify"); + if ( + planIndex < 0 || + guardIndex <= planIndex || + buildIndex <= guardIndex || + verifyIndex <= buildIndex + ) { + errors.push(`${options.agent} messaging plan image boundary steps are out of order`); + } +} + +function validateMessagingPlanImageBoundary( + errors: string[], + workflow: SandboxImagesWorkflow, +): void { + const job = workflow.jobs[MESSAGING_PLAN_IMAGE_BOUNDARY_JOB] ?? {}; + if (job["timeout-minutes"] !== 30) { + errors.push("messaging plan image boundary must retain its 30-minute budget"); + } + if (job.needs !== undefined) { + errors.push("messaging plan image boundary must remain isolated from canonical image jobs"); + } + const nodeSetupSteps = steps(job).filter((step) => step.name === "Set up Node"); + if (nodeSetupSteps.length !== 1) { + errors.push("messaging plan image boundary must set up Node exactly once"); + } + if (record(nodeSetupSteps[0]?.with)["node-version"] !== "22.19.0") { + errors.push("messaging plan image boundary must use Node 22.19.0"); + } + for (const [stepName, action] of [ + ["Resolve sandbox base image", "./.github/actions/resolve-sandbox-base-image"], + ["Resolve Hermes base image", "./.github/actions/resolve-hermes-base-image"], + ] as const) { + if (findStep(job, stepName)?.uses !== action) { + errors.push(`messaging plan image boundary must run '${stepName}'`); + } + } + + validateMessagingPlanBoundaryBuild(errors, job, { + agent: "openclaw", + baseArgName: "BASE_IMAGE", + baseEnvName: "BASE_IMAGE", + stepName: "Build and verify OpenClaw messaging plan boundary", + target: "nemoclaw-openclaw-plan-boundary", + }); + validateMessagingPlanBoundaryBuild(errors, job, { + agent: "hermes", + baseArgName: "BASE_IMAGE", + baseEnvName: "HERMES_BASE_IMAGE", + stepName: "Build and verify Hermes messaging plan boundary", + target: "nemoclaw-hermes-plan-boundary", + }); + + const builds = dockerBuildLines(job); + if ( + !isDeepStrictEqual(builds, [ + 'docker build "${build_args[@]}" -t nemoclaw-openclaw-plan-boundary .', + 'docker build "${build_args[@]}" -t nemoclaw-hermes-plan-boundary .', + ]) + ) { + errors.push("messaging plan image boundary must build exactly two disposable local images"); + } + if (steps(job).some((step) => String(step.uses ?? "").includes("upload-artifact"))) { + errors.push("messaging plan image boundary must not publish probe image artifacts"); + } +} + function validateRuntimeImageReuse(errors: string[], workflow: SandboxImagesWorkflow): void { const producerName = "build-sandbox-images"; const producer = workflow.jobs[producerName] ?? {}; @@ -726,6 +840,7 @@ export function validateSandboxImagesWorkflow( } validateSecretScopeAndRegistryWrites(errors, workflow); validateGuardedProductionBuildContracts(errors, workflow); + validateMessagingPlanImageBoundary(errors, workflow); validateRuntimeImageReuse(errors, workflow); validateHermesImageReuse(errors, workflow); validateStateDirGuardMetadataImageReuse(errors, workflow);