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
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";

import { testTimeout } from "../../../../test/helpers/timeouts";

const SANDBOX = "conn-iso";
const NON_DEFAULT_GATEWAY_PORT = "18224";
const DEFAULT_GATEWAY_PORT = "8080";

const homes: string[] = [];

function makeHome(): string {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-gateway-port-"));
homes.push(home);
return home;
}

/**
* Load the lifecycle modules against a chosen gateway port. GATEWAY_PORT is a
* module-load constant, and both state-root resolvers keep a test escape hatch
* that fires when HOME equals NEMOCLAW_TEST_BASE_HOME, so the port only moves
* the resolved paths once those are stubbed away and the modules are reloaded.
*/
async function loadLifecycleForGatewayPort(gatewayPort: string, home: string) {
vi.resetModules();
vi.stubEnv("HOME", home);
vi.stubEnv("NEMOCLAW_TEST_BASE_HOME", "");
vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", "");
vi.stubEnv("NEMOCLAW_GATEWAY_PORT", gatewayPort);
const lock = await import("../../state/mcp-lifecycle-lock-acquisition");
const lifecycle = await import("./portable-agent-lifecycle");
const receipt = await import("./hermes-portable-receipt");
const portable = await import("../../state/portable-uninstall-retirement");
return { lock, lifecycle, receipt, portable };
}

async function requalifyUnderLifecycleLock(gatewayPort: string, home: string) {
const { lock, lifecycle } = await loadLifecycleForGatewayPort(gatewayPort, home);
return lock.withMcpLifecycleLockSync(SANDBOX, () =>
lifecycle.requalifyPortableAgentSandboxAuthority(SANDBOX, { readRegistry: () => null }),
);
}

describe("portable agent requalification across gateway ports", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
homes.splice(0).forEach((home) => fs.rmSync(home, { recursive: true, force: true }));
});

it(
"requalifies a sandbox that has no portable receipt on a non-default gateway port",
async () => {
const outcome = await requalifyUnderLifecycleLock(NON_DEFAULT_GATEWAY_PORT, makeHome());

expect(outcome).toEqual({ kind: "not-hermes" });
},
testTimeout(15_000),
);

it("reports the default gateway outcome for the same sandbox and state", async () => {
const outcome = await requalifyUnderLifecycleLock(DEFAULT_GATEWAY_PORT, makeHome());

expect(outcome).toEqual({ kind: "not-hermes" });
});

it("requires the lifecycle lock when a sandbox has a portable receipt", async () => {
const home = makeHome();
const { lifecycle, receipt, portable } = await loadLifecycleForGatewayPort(
NON_DEFAULT_GATEWAY_PORT,
home,
);
const stateDir = portable.defaultPortableStateDir(process.env);
fs.mkdirSync(receipt.hermesPortableReceiptDirectory(SANDBOX, stateDir), { recursive: true });

expect(() =>
lifecycle.requalifyPortableAgentSandboxAuthority(SANDBOX, { readRegistry: () => null }),
).toThrow(/requalification requires the sandbox lifecycle lock/u);
});
});
3 changes: 3 additions & 0 deletions src/lib/onboard/experimental/portable-agent-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
inspect: vi.fn(),
inspectClassification: vi.fn(),
inspectRequalification: vi.fn(),
hasCandidate: vi.fn(),
readRegistry: vi.fn(),
buildOpenShellCommandAuthority: vi.fn(),
buildOpenShellEnv: vi.fn(),
Expand All @@ -28,6 +29,7 @@ vi.mock("../../state/mcp-lifecycle-lock-acquisition", () => ({
}));

vi.mock("./hermes-portable-receipt", () => ({
hasHermesPortableReceiptCandidate: mocks.hasCandidate,
inspectPortableAgentReceiptAuthority: mocks.inspect,
inspectPortableAgentReceiptAuthorityForClassification: mocks.inspectClassification,
inspectPortableAgentReceiptAuthorityForRequalification: mocks.inspectRequalification,
Expand Down Expand Up @@ -123,6 +125,7 @@ describe("portable agent lifecycle dispatch", () => {
vi.clearAllMocks();
mocks.inspectClassification.mockImplementation((...args) => mocks.inspect(...args));
mocks.inspectRequalification.mockImplementation((...args) => mocks.inspect(...args));
mocks.hasCandidate.mockReturnValue(true);
mocks.buildOpenShellEnv.mockImplementation(
(env: NodeJS.ProcessEnv, authority: Record<string, string>) => ({
PATH: env.PATH,
Expand Down
18 changes: 16 additions & 2 deletions src/lib/onboard/experimental/portable-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type HermesPortableLifecycleDeps,
} from "./hermes-portable-lifecycle";
import {
hasHermesPortableReceiptCandidate,
inspectPortableAgentReceiptAuthority,
inspectPortableAgentReceiptAuthorityForClassification,
inspectPortableAgentReceiptAuthorityForRequalification,
Expand Down Expand Up @@ -198,14 +199,27 @@ export function inspectPortableAgentReceiptDisposition(
);
}

/** Classify copied Hermes authority while the probe owns its lifecycle fence. */
/**
* Classify copied Hermes authority while the probe owns its lifecycle fence.
*
* A sandbox with no Hermes portable receipt directory has nothing for the
* requalifying reader to admit: the reader returns null on that directory's
* ENOENT before it consults any of its extra admission flags, so its answer is
* already the classifying reader's answer. Demanding its lifecycle-lock
* evidence therefore buys no information, and it cannot be satisfied off the
* default gateway: the evidence is keyed on the host-global portable receipt
* root while every acquisition keys on the per-gateway state root, so the held
* lock is invisible and a plain OpenClaw sandbox fails its probe (#10783).
*/
function inspectPortableAgentReceiptDispositionForRequalification(
sandboxName: string,
env: NodeJS.ProcessEnv = process.env,
stateDir = defaultPortableDemoStateDir(env),
): PortableAgentReceiptDisposition {
return receiptDisposition(
inspectPortableAgentReceiptAuthorityForRequalification(sandboxName, stateDir),
hasHermesPortableReceiptCandidate(sandboxName, stateDir)
? inspectPortableAgentReceiptAuthorityForRequalification(sandboxName, stateDir)
: inspectPortableAgentReceiptAuthorityForClassification(sandboxName, stateDir),
);
}

Expand Down
Loading