Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lib/actions/sandbox/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import { isWsl } from "../../platform";
import { ROOT } from "../../runner";
import * as sandboxVersion from "../../sandbox/version";
import {
isSandboxReady,
isTerminalSandboxPhase,
parseSandboxPhase,
parseSandboxStatus,
TERMINAL_SANDBOX_PHASES,
} from "../../state/gateway";
import type { SandboxEntry } from "../../state/registry";
Expand Down Expand Up @@ -904,7 +906,6 @@ export async function connectSandbox(
// express-vLLM model preflight for them (it only steers the install path
// and would otherwise hard-exit a recovery on a stale NEMOCLAW_VLLM_MODEL).
if (!probeOnly) preflightVllmModelEnvOrExit();
const { isSandboxReady, parseSandboxStatus } = require("../../onboard");
const live = await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true });

// Fast-fail on a Docker daemon outage before the probe-only health check and
Expand Down
6 changes: 3 additions & 3 deletions src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { isTerminalSandboxPhase, parseSandboxPhase } from "../../state/gateway";
import { gatewayNamePattern, getSandboxTargetGatewayName } from "./gateway-target";

const { pruneKnownHostsEntries } = require("../../onboard") as {
const { pruneKnownHostsEntries } = require("../../onboard/known-hosts") as {
pruneKnownHostsEntries: (contents: string) => string;
};

Expand All @@ -35,11 +35,11 @@ import {
OPENSHELL_OPERATION_TIMEOUT_MS,
OPENSHELL_PROBE_TIMEOUT_MS,
} from "../../adapters/openshell/timeouts";
import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier";
import {
recoverDockerDriverSandbox,
type DockerDriverRecoveryResult,
recoverDockerDriverSandbox,
} from "../../onboard/docker-driver-sandbox-recovery";
import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier";

export type SandboxGatewayState = {
state: string;
Expand Down
101 changes: 32 additions & 69 deletions test/messaging-build-applier-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
applyMessagingBuildPhase,
OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY,
readMessagingBuildPlanFromEnv,
reviewedOpenClawPluginTarballUrlByPackageSpec,
} from "../src/lib/messaging/applier/build/messaging-build-applier.mts";
import { testTimeout } from "./helpers/timeouts";
import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper";
import { withLegacyMessagingPlanEnvDirect } from "./messaging-plan-test-helper";

const SCRIPT_PATH = path.join(
import.meta.dirname,
Expand Down Expand Up @@ -51,10 +53,19 @@ function fakeSlackNpmScript(): string {
].join("\n");
}

function thrownMessage(run: () => void): string {
try {
run();
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
throw new Error("Expected operation to throw");
}

describe("messaging-build-applier.mts: plugin archive integrity", () => {
it(
"accepts the reviewed messaging plugin registry tarball URL before install",
() => {
async () => {
expect(OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY).toEqual({
schemaVersion: 1,
packageIdentity: "exact-npm-package-spec",
Expand All @@ -79,7 +90,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
);

try {
const env = withLegacyMessagingPlanEnv(
const env = await withLegacyMessagingPlanEnvDirect(
{
PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`,
OPENCLAW_TRACE: tracePath,
Expand All @@ -90,25 +101,9 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
},
"openclaw",
);
const result = spawnSync(
"node",
[
"--experimental-strip-types",
SCRIPT_PATH,
"--agent",
"openclaw",
"--phase",
"agent-install",
],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env,
timeout: 10_000,
},
);
const plan = readMessagingBuildPlanFromEnv(env, "openclaw");

expect(result.status, result.stderr).toBe(0);
expect(applyMessagingBuildPhase(plan, "agent-install", env)).toEqual([]);
const trace = fs.readFileSync(tracePath, "utf-8");
expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity");
expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball");
Expand Down Expand Up @@ -140,7 +135,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {

it(
"fails closed before installing when the messaging plugin registry tarball URL drifts",
() => {
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-tarball-"));
const tracePath = path.join(tmp, "openclaw.trace");
fs.writeFileSync(path.join(tmp, "npm"), fakeSlackNpmScript(), { mode: 0o755 });
Expand All @@ -156,7 +151,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
);

try {
const env = withLegacyMessagingPlanEnv(
const env = await withLegacyMessagingPlanEnvDirect(
{
PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`,
OPENCLAW_TRACE: tracePath,
Expand All @@ -169,30 +164,14 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
},
"openclaw",
);
const result = spawnSync(
"node",
[
"--experimental-strip-types",
SCRIPT_PATH,
"--agent",
"openclaw",
"--phase",
"agent-install",
],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env,
timeout: 10_000,
},
);
const plan = readMessagingBuildPlanFromEnv(env, "openclaw");
const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env));

expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
expect(message).toContain(
"OpenClaw plugin @openclaw/slack@2026.6.10 npm tarball URL mismatch",
);
expect(result.stderr).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_TARBALL}`);
expect(result.stderr).toContain(
expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_TARBALL}`);
expect(message).toContain(
"Actual: https://unexpected.invalid/openclaw/slack-2026.6.10.tgz",
);
const trace = fs.readFileSync(tracePath, "utf-8");
Expand All @@ -209,7 +188,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {

it(
"fails closed before installing the 2026.6.10 Slack plugin when the packed archive integrity drifts",
() => {
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-pack-"));
const tracePath = path.join(tmp, "openclaw.trace");
fs.writeFileSync(path.join(tmp, "npm"), fakeSlackNpmScript(), { mode: 0o755 });
Expand All @@ -225,7 +204,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
);

try {
const env = withLegacyMessagingPlanEnv(
const env = await withLegacyMessagingPlanEnvDirect(
{
PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`,
OPENCLAW_TRACE: tracePath,
Expand All @@ -236,30 +215,14 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
},
"openclaw",
);
const result = spawnSync(
"node",
[
"--experimental-strip-types",
SCRIPT_PATH,
"--agent",
"openclaw",
"--phase",
"agent-install",
],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env,
timeout: 10_000,
},
);
const plan = readMessagingBuildPlanFromEnv(env, "openclaw");
const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env));

expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
expect(message).toContain(
"OpenClaw plugin @openclaw/slack@2026.6.10 downloaded tarball integrity mismatch",
);
expect(result.stderr).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`);
expect(result.stderr).toContain("Actual: sha512-packed-drift");
expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`);
expect(message).toContain("Actual: sha512-packed-drift");
const trace = fs.readFileSync(tracePath, "utf-8");
expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity");
expect(trace).toContain("npm|pack|@openclaw/slack@2026.6.10|--pack-destination");
Expand All @@ -273,7 +236,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {

it(
"rejects packed archive filenames outside the fresh pack directory",
() => {
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-pack-path-"));
const tracePath = path.join(tmp, "openclaw.trace");
fs.writeFileSync(path.join(tmp, "npm"), fakeSlackNpmScript(), { mode: 0o755 });
Expand All @@ -289,7 +252,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
);

try {
const env = withLegacyMessagingPlanEnv(
const env = await withLegacyMessagingPlanEnvDirect(
{
PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`,
OPENCLAW_TRACE: tracePath,
Expand Down
58 changes: 22 additions & 36 deletions test/messaging-build-applier-render-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";

const SCRIPT_PATH = path.join(
import.meta.dirname,
"..",
"src",
"lib",
"messaging",
"applier",
"build",
"messaging-build-applier.mts",
);
const TEST_PATH = process.env.PATH || "/usr/bin:/bin";

function runPostAgentInstall(tmp: string, agent: "hermes" | "openclaw", plan: unknown) {
return spawnSync(
"node",
["--experimental-strip-types", SCRIPT_PATH, "--agent", agent, "--phase", "post-agent-install"],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
PATH: TEST_PATH,
HOME: tmp,
NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"),
},
timeout: 10_000,
},
);
}
import {
applyMessagingAgentRenderToLocalFiles,
readMessagingBuildPlanFromEnv,
} from "../src/lib/messaging/applier/build/messaging-build-applier.mts";

describe("messaging-build-applier.mts: post-agent-install render safety", () => {
it("rejects post-agent-install render targets that escape the agent root", () => {
Expand All @@ -59,10 +33,16 @@ describe("messaging-build-applier.mts: post-agent-install render safety", () =>
};

try {
const result = runPostAgentInstall(tmp, "openclaw", plan);
const serializedPlan = readMessagingBuildPlanFromEnv(
{
NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"),
},
"openclaw",
);

expect(result.status).toBe(2);
expect(result.stderr).toContain("must stay inside");
expect(() => applyMessagingAgentRenderToLocalFiles(serializedPlan, { homeDir: tmp })).toThrow(
"must stay inside",
);
expect(fs.existsSync(path.join(tmp, "escaped.json"))).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
Expand Down Expand Up @@ -91,10 +71,16 @@ describe("messaging-build-applier.mts: post-agent-install render safety", () =>
};

try {
const result = runPostAgentInstall(tmp, "hermes", plan);
const serializedPlan = readMessagingBuildPlanFromEnv(
{
NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"),
},
"hermes",
);

expect(result.status).toBe(2);
expect(result.stderr).toContain("line breaks");
expect(() => applyMessagingAgentRenderToLocalFiles(serializedPlan, { homeDir: tmp })).toThrow(
"line breaks",
);
const envPath = path.join(tmp, ".hermes", ".env");
expect(fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "").not.toContain(
"EVIL=1",
Expand Down
Loading
Loading