Skip to content
9 changes: 5 additions & 4 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
"src/lib/actions/sandbox/mcp-bridge-contracts.ts": 26,
"src/lib/actions/sandbox/process-recovery.ts": 27,
"src/lib/adapters/docker/index.ts": 43,
"src/lib/adapters/openshell/client.ts": 23,
"src/lib/adapters/openshell/client.ts": 20,
"src/lib/adapters/openshell/resolve.ts": 27,
"src/lib/adapters/openshell/runtime.ts": 54,
"src/lib/adapters/openshell/timeouts.ts": 38,
"src/lib/adapters/openshell/timeouts.ts": 39,
"src/lib/agent/defs.ts": 33,
"src/lib/cli/branding.ts": 87,
"src/lib/cli/nemoclaw-oclif-command.ts": 107,
Expand Down Expand Up @@ -37,10 +37,11 @@
"defaultMax": 20,
"maxByFile": {
"src/lib/actions/inference-set.ts": 32,
"src/lib/actions/sandbox/connect.ts": 42,
"src/lib/actions/sandbox/connect.ts": 43,
"src/lib/actions/sandbox/destroy.ts": 29,
"src/lib/actions/sandbox/doctor.ts": 29,
"src/lib/actions/sandbox/status-snapshot.ts": 20,
"src/lib/actions/sandbox/gateway-state.ts": 21,
"src/lib/actions/sandbox/status-snapshot.ts": 19,
"src/lib/actions/sandbox/policy-channel.ts": 30,
"src/lib/actions/sandbox/process-recovery.ts": 21,
"src/lib/actions/sandbox/rebuild-pipeline.ts": 29,
Expand Down
138 changes: 61 additions & 77 deletions src/lib/actions/maintenance.test.ts

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions src/lib/actions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
import { SANDBOX_IMAGE_REPOS } from "../domain/sandbox/image-tag";
import { resolveGatewayName, resolveSandboxGatewayName } from "../onboard/gateway-binding";
import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandbox-list";
import { parseLiveSandboxNames, parseReadySandboxNames } from "../runtime-recovery";
import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock";
import * as registry from "../state/registry";
import * as sandboxState from "../state/sandbox";
Expand Down Expand Up @@ -303,7 +302,11 @@ async function backupAllWithoutPortableAuthority(): Promise<void> {
},
{ gatewayName: selectedGatewayName },
);
const readyNames = parseReadySandboxNames(liveList.output || "");
const readyNames = new Set(
liveList.sandboxes
.filter((sandbox) => sandbox.readiness === "ready")
.map((sandbox) => sandbox.name),
);
// Source-of-truth review (#6520):
//
// - Invalid state: a sandbox the selected gateway does not observe, whose
Expand Down Expand Up @@ -332,7 +335,7 @@ async function backupAllWithoutPortableAuthority(): Promise<void> {
// candidate the gateway observes again reverts to a genuine strict skip.
const orphanNames = new Set(
classifyOrphanedRegistrySandboxes(sandboxes, {
observedNames: parseLiveSandboxNames(liveList.output || ""),
observedNames: new Set(liveList.sandboxes.map((sandbox) => sandbox.name)),
reconnectedNames: new Set(),
selectedGatewayName,
resolveGatewayBinding: resolveSandboxGatewayName,
Expand Down Expand Up @@ -448,7 +451,7 @@ async function backupAllWithoutPortableAuthority(): Promise<void> {
},
{ gatewayName: selectedGatewayName },
);
const observedOnRecheck = parseLiveSandboxNames(confirmation.output || "");
const observedOnRecheck = new Set(confirmation.sandboxes.map((sandbox) => sandbox.name));
confirmedStranded = strandedOrphans.filter(
(name) => !observedOnRecheck.has(name) && isSandboxContainerDefinitivelyAbsent(name),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,11 @@ function makePassthroughDeps(
getSandbox: ((name) => ({ name, agent: "openclaw", ...route })) as NonNullable<
AgentPassthroughDeps["getSandbox"]
>,
ensureLive: (async () => ({ state: "present", output: "Phase: Ready" })) as NonNullable<
AgentPassthroughDeps["ensureLive"]
>,
ensureLive: (async () => ({
state: "present",
phase: "Ready",
output: "Phase: Ready",
})) as NonNullable<AgentPassthroughDeps["ensureLive"]>,
execNonJson: ((): never => {
events.push("dispatch");
throw new Error("__exit:0");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit";

const execMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureLiveMock = vi.hoisted(() =>
vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }),
vi.fn(async () => ({ state: "present", phase: "Ready", output: "Phase: Ready" })),
);
const getSandboxMock = vi.hoisted(() => vi.fn(() => ({ agent: "openclaw" })));
const listAgentsMock = vi.hoisted(() => vi.fn(() => ["langchain-deepagents-code", "openclaw"]));
Expand Down Expand Up @@ -210,12 +210,10 @@ describe("runAgentPassthrough shields-relock warning", () => {

it("does not consult OpenClaw relock history for terminal-runtime passthroughs (#5922)", async () => {
getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" });
const getRecentShieldsAutoRestore = vi.fn(
(): ShieldsAutoRestoreReadResult => ({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
}),
);
const getRecentShieldsAutoRestore = vi.fn((): ShieldsAutoRestoreReadResult => ({
kind: "event",
event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 },
}));
const { writes, proc } = makeProcMock();

await runAgentPassthrough(
Expand Down
47 changes: 34 additions & 13 deletions src/lib/actions/sandbox/agent/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const execMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureLiveMock = vi.hoisted(() =>
vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }),
vi.fn(
async () =>
({ state: "present", phase: "Ready", output: "Phase: Ready" }) as {
state: string;
phase: string | null;
output: string;
},
),
);
const getSandboxMock = vi.hoisted(() =>
vi.fn(
Expand Down Expand Up @@ -164,10 +171,7 @@ describe("runAgentPassthrough", () => {
headless_command: "python3 /app/run_with_harness.py",
},
});
await runAgentPassthrough(
"alpha",
{ extraArgs: ["start", "--task-id", "demo"] },
);
await runAgentPassthrough("alpha", { extraArgs: ["start", "--task-id", "demo"] });
expect(execMock).toHaveBeenCalledWith(
"alpha",
["python3", "/app/run_with_harness.py", "start", "--task-id", "demo"],
Expand Down Expand Up @@ -599,7 +603,11 @@ describe("runAgentPassthrough", () => {
});

it("prints recovery hints with exit 1 before selector rejection for the literal stopped-sandbox repro `agent -m ping` (#5655)", async () => {
ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" });
ensureLiveMock.mockResolvedValueOnce({
state: "present",
phase: "Error",
output: "Phase: Error",
});
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
const { writes, exit, proc } = makeProcMock();
await expect(
Expand Down Expand Up @@ -652,7 +660,11 @@ describe("runAgentPassthrough", () => {
});

it("rejects with exit 1 + recovery hints when sandbox phase is non-Ready", async () => {
ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" });
ensureLiveMock.mockResolvedValueOnce({
state: "present",
phase: "Error",
output: "Phase: Error",
});
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
const { writes, exit, proc } = makeProcMock();
await expect(
Expand All @@ -673,8 +685,12 @@ describe("runAgentPassthrough", () => {
expect(all).toMatch(/onboard --resume/);
});

it("fails closed with exit 2 when ensureLive returns output without a parseable Phase line, never invoking exec", async () => {
ensureLiveMock.mockResolvedValueOnce({ output: "Name: alpha\n(no phase line here)\n" });
it("fails closed with exit 2 when ensureLive returns no observed phase, never invoking exec", async () => {
ensureLiveMock.mockResolvedValueOnce({
state: "present",
phase: null,
output: "Name: alpha\n(no phase line here)\n",
});
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
const { writes, exit, proc } = makeProcMock();
await expect(
Expand Down Expand Up @@ -781,10 +797,15 @@ describe("runAgentNonJsonPassthrough", () => {
const { proc } = makeNonJsonProcMock();
const runDispatchMock = makeDispatchMock("PONG\n", "", 0);
await expect(
runAgentNonJsonPassthrough("my-sb", ["openclaw", "agent", "--agent", "main", "-m", "ping"], proc, {
getOpenshellBinary: stubBinary,
runDispatch: runDispatchMock,
}),
runAgentNonJsonPassthrough(
"my-sb",
["openclaw", "agent", "--agent", "main", "-m", "ping"],
proc,
{
getOpenshellBinary: stubBinary,
runDispatch: runDispatchMock,
},
),
).rejects.toThrow("__exit:0");
expect(buildOpenshellExecArgsMock.mock.calls[0]?.[2]?.timeoutSeconds).toBeUndefined();
});
Expand Down
3 changes: 1 addition & 2 deletions src/lib/actions/sandbox/agent/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ import { CLI_NAME } from "../../../cli/branding";
import { isStdinTty } from "../../../core/stdin";
import { resolveSandboxHermesApiPort } from "../../../onboard/hermes-api-port";
import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit";
import { parseSandboxPhase } from "../../../state/gateway";
import * as registry from "../../../state/registry";
import {
buildOpenshellExecArgs,
Expand Down Expand Up @@ -541,7 +540,7 @@ export async function runAgentPassthrough(
if (!command) return;
const ensureLive = deps.ensureLive ?? ensureLiveSandboxOrExit;
const state = await ensureLive(sandboxName, { allowNonReadyPhase: true });
const phase = parseSandboxPhase(state?.output ?? "");
const phase = state?.phase ?? null;
if (!phase) {
rejectUnparseablePhase(sandboxName, proc);
}
Expand Down
Loading
Loading