Skip to content
Open
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
25 changes: 22 additions & 3 deletions src/lib/actions/sandbox/auto-pair-warmup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

import { wrapSandboxShellScript } from "./auto-pair-approval";
import { WARMUP_TIMEOUT_MS } from "./auto-pair-warmup";
import { WARMUP_SCRIPT, WARMUP_TIMEOUT_MS } from "./auto-pair-warmup";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";

// NOTE on coverage shape (#4504-v2): `runSandboxScopeWarmupRun` is not exercised
// in-process here. Like its sibling `runSandboxAutoPairApprovalPass`, the leaf
Expand Down Expand Up @@ -50,7 +51,7 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
const warmupShaped = [
"command -v openclaw >/dev/null 2>&1 || exit 0",
'openclaw agent --agent main -m "ping" \\',
' --session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true',
` --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true`,
"exit 0",
"",
].join("\n");
Expand All @@ -60,7 +61,10 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
expect(wrapped).toContain("mktemp");
});

it("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => {
const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0;
const itWithSh = shAvailable ? it : it.skip;

itWithSh("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => {
// Mirror the real warm-up: the provoke command itself may "fail" (the agent
// falls back to embedded mode), but `|| true` + trailing `exit 0` mean the
// wrapped script always exits 0 — so a failed provoke never surfaces as a
Expand All @@ -72,3 +76,18 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
expect(result.status).toBe(0);
});
});

describe("warm-up tags its throwaway session for user-facing filters (#5511)", () => {
it("tags the provoke session with the shared warm-up prefix", () => {
expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-");
expect(WARMUP_SCRIPT).toContain(`--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`);
});

it("keeps the #4504-v2 provoke run foreground and within the original budget", () => {
expect(WARMUP_SCRIPT).toContain('openclaw agent --agent main -m "ping" \\');
expect(WARMUP_SCRIPT).toContain(">/dev/null 2>&1 || true");
expect(WARMUP_SCRIPT).not.toContain("setsid");
expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID");
expect(WARMUP_SCRIPT).not.toContain("warmup_cleanup_attempt");
});
});
5 changes: 3 additions & 2 deletions src/lib/actions/sandbox/auto-pair-warmup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process";

import { ROOT } from "../../state/paths";
import { wrapSandboxShellScript } from "./auto-pair-approval";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";

// Outer spawnSync cap (ms) for the throwaway warm-up agent run. The `-m`
// one-shot prompt ("ping") returns fast even when it falls back to embedded
Expand Down Expand Up @@ -65,12 +66,12 @@ export const WARMUP_POLL_LIST_TIMEOUT_S = 2;
// the approval pass that runs immediately after could otherwise list devices
// before the gateway has registered the upgrade. The poll bounds are
// interpolated so the cap is asserted on real values, not source text.
const WARMUP_SCRIPT = `
export const WARMUP_SCRIPT = `
PROXY_ENV=/tmp/nemoclaw-proxy-env.sh
[ -r "$PROXY_ENV" ] && . "$PROXY_ENV"
command -v openclaw >/dev/null 2>&1 || exit 0
openclaw agent --agent main -m "ping" \\
--session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true
--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true
command -v python3 >/dev/null 2>&1 || exit 0
OPENCLAW_BIN="$(command -v openclaw)"
i=0
Expand Down
92 changes: 88 additions & 4 deletions src/lib/actions/sandbox/sessions/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

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

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

Expand All @@ -15,6 +16,7 @@ vi.mock("../../../adapters/openshell/runtime", () => ({
}));

import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime";
import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session";
import { buildSandboxTarArgv, exportSandboxSessions, parseSessionIndex } from "./export";

const captureMock = captureOpenshell as unknown as ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -86,6 +88,14 @@ describe("parseSessionIndex", () => {
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
});

it("tolerates log noise after a pretty JSON payload", () => {
const output = [
JSON.stringify({ sessions: [{ key: "agent:main:main", sessionId: "sid-1" }] }, null, 2),
"(node:1) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental",
].join("\n");
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
});

it("returns [] when the upstream emits an empty index (empty array)", () => {
expect(parseSessionIndex("[]")).toEqual([]);
});
Expand All @@ -102,6 +112,80 @@ describe("parseSessionIndex", () => {
const output = JSON.stringify([{ alias: "agent:main:main", uuid: "sid-1" }]);
expect(parseSessionIndex(output)).toBeNull();
});

it("does not accept session-shaped arrays under an unknown wrapper", () => {
const output = JSON.stringify({
records: [{ key: "agent:main:main", sessionId: "sid-1" }],
});
expect(parseSessionIndex(output)).toBeNull();
});
});

describe("isWarmupSessionId", () => {
it("matches the onboard warm-up session id prefix (#5511)", () => {
expect(isWarmupSessionId(`${WARMUP_SESSION_ID_PREFIX}123`)).toBe(true);
expect(isWarmupSessionId("sid-real")).toBe(false);
});
});

describe("exportSandboxSessions warm-up filtering", () => {
it("excludes the onboard warm-up session from export-all but keeps real sessions (#5511)", async () => {
captureMock.mockReturnValueOnce(
makeCapture(
JSON.stringify([
{ key: "agent:main:main", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` },
{ key: "agent:main:telegram:t-1", sessionId: "sid-real" },
]),
),
);

const result = await exportSandboxSessions({
sandboxName: "alpha",
out: "./out.tgz",
format: "tar",
});

const tarCall = runMock.mock.calls[0]?.[0] as string[];
const shellCommand = tarCall[7] as string;
expect(result.resolvedSessionIds).toEqual(["sid-real"]);
expect(shellCommand).toMatch(/-- \.\/sid-real\.jsonl/);
expect(shellCommand).not.toContain(WARMUP_SESSION_ID_PREFIX);
});

it("refuses export-all when only the onboard warm-up session remains (#5511)", async () => {
captureMock.mockReturnValueOnce(
makeCapture(
JSON.stringify([
{ key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` },
]),
),
);

await expect(
exportSandboxSessions({
sandboxName: "alpha",
out: "./sessions-alpha",
}),
).rejects.toThrow(/agent 'main' has no sessions to bundle/);
expect(runMock).not.toHaveBeenCalled();
});

it("still exports a warm-up session when the caller names it explicitly", async () => {
const warmupId = `${WARMUP_SESSION_ID_PREFIX}explicit`;
captureMock.mockReturnValueOnce(
makeCapture(JSON.stringify([{ key: "agent:main:main", sessionId: warmupId }])),
);

const result = await exportSandboxSessions({
sandboxName: "alpha",
keys: ["agent:main:main"],
out: "./out.tgz",
format: "tar",
});

expect(result.resolvedSessionIds).toEqual([warmupId]);
expect(result.resolvedFiles).toEqual([`${warmupId}.jsonl`]);
});
});

describe("exportSandboxSessions", () => {
Expand Down Expand Up @@ -188,10 +272,10 @@ describe("exportSandboxSessions", () => {
"download",
"alpha",
"/sandbox/.openclaw/agents/main/sessions/sid-a.jsonl",
"sessions-alpha/sid-a.jsonl",
path.join("sessions-alpha", "sid-a.jsonl"),
]);
// Each downloaded file is locked to owner-only (session JSONL may hold secrets).
expect(chmodSpy).toHaveBeenCalledWith("sessions-alpha/sid-a.jsonl", 0o600);
expect(chmodSpy).toHaveBeenCalledWith(path.join("sessions-alpha", "sid-a.jsonl"), 0o600);

expect(result.format).toBe("dir");
expect(result.hostDest).toBe("./sessions-alpha");
Expand All @@ -200,13 +284,13 @@ describe("exportSandboxSessions", () => {
{
key: "agent:main:main",
sessionId: "sid-a",
path: "sessions-alpha/sid-a.jsonl",
path: path.join("sessions-alpha", "sid-a.jsonl"),
sizeBytes: 42,
},
{
key: "agent:main:telegram:t-1",
sessionId: "sid-b",
path: "sessions-alpha/sid-b.jsonl",
path: path.join("sessions-alpha", "sid-b.jsonl"),
sizeBytes: 42,
},
]);
Expand Down
55 changes: 53 additions & 2 deletions src/lib/actions/sandbox/sessions/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import path from "node:path";
import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime";
import { CLI_NAME } from "../../../cli/branding";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { isWarmupSessionId } from "../warmup-session";
import {
DEFAULT_AGENT_ID,
parseAgentIdFromSessionKey,
Expand Down Expand Up @@ -323,7 +324,11 @@ function resolveSelectedFiles(

const entries: { key: string; sessionId: string }[] = [];
if (keys.length === 0) {
for (const entry of index) entries.push(entry);
// Export-all hides internal warm-up sessions; explicit keys are honored below.
for (const entry of index) {
if (isWarmupSessionId(entry.sessionId)) continue;
entries.push(entry);
}
} else {
const missing: string[] = [];
for (const key of keys) {
Expand Down Expand Up @@ -424,7 +429,7 @@ export function parseSessionIndex(output: string): SessionIndexEntry[] | null {
const trimmed = output.trim();
if (!trimmed) return [];
const lines = trimmed.split(/\r?\n/);
const candidates: string[] = [];
const candidates = balancedJsonCandidates(trimmed);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const candidate = lines[index]?.trim();
if (candidate && (candidate.startsWith("[") || candidate.startsWith("{"))) {
Expand All @@ -443,6 +448,52 @@ export function parseSessionIndex(output: string): SessionIndexEntry[] | null {
return null;
}

export function balancedJsonCandidates(text: string): string[] {
const candidates: string[] = [];
const lineStartJson = /^(\s*)([\[{])/gm;
let match: RegExpExecArray | null;
while ((match = lineStartJson.exec(text)) !== null) {
const candidate = balancedJsonFrom(text, match.index + match[1].length);
if (candidate) candidates.push(candidate);
}
return candidates;
}

function balancedJsonFrom(text: string, start: number): string | null {
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
stack.push("}");
continue;
}
if (char === "[") {
stack.push("]");
continue;
}
if (char !== "}" && char !== "]") continue;
if (stack.pop() !== char) return null;
if (stack.length === 0) return text.slice(start, index + 1);
}
return null;
}

function tryExtractIndex(text: string): SessionIndexEntry[] | null {
let parsed: unknown;
try {
Expand Down
Loading