Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bfe76a6
fix(onboard): produce pairing before final observation
jyaunches Aug 23, 2026
23b7c33
test(onboard): assert pairing observation poll
jyaunches Aug 23, 2026
fcd2a00
fix(onboard): use direct pairing request producer
jyaunches Aug 23, 2026
380d525
fix(onboard): preserve device-authenticated pairing probe
jyaunches Aug 23, 2026
41ceb08
fix(onboard): pin pairing producer gateway
jyaunches Aug 24, 2026
62af543
fix(onboard): bind trusted local pairing request
jyaunches Aug 24, 2026
9692947
merge: resolve pairing finalization conflicts
prekshivyas Aug 24, 2026
b60424a
fix(onboard): bind warm-up to local device
jyaunches Aug 24, 2026
92632eb
Merge branch 'main' into fix/pairing-finalization-e2e
prekshivyas Aug 24, 2026
a0ab14c
Merge remote-tracking branch 'origin/fix/pairing-finalization-e2e' in…
jyaunches Aug 24, 2026
7068023
fix(onboard): centralize pairing observation
prekshivyas Aug 24, 2026
7cd432d
docs(cli): fix sessions export flag parity
prekshivyas Aug 24, 2026
a96525f
merge: update main
prekshivyas Aug 24, 2026
b47f853
test(onboard): harden warm-up fixtures
prekshivyas Aug 24, 2026
5dbadea
merge: update main
prekshivyas Aug 24, 2026
accf8ae
fix(onboard): await pending scope upgrade
prekshivyas Aug 24, 2026
ef4e0e5
merge: update main
prekshivyas Aug 24, 2026
baa3ee0
Merge branch 'main' into fix/pairing-finalization-e2e
cv Aug 24, 2026
c1acdb8
docs(cli): verify retained session path cleanup
jyaunches Aug 24, 2026
a93d5ee
fix(onboard): approve exact local pairing request
jyaunches Aug 24, 2026
7f613d3
merge: resolve conflicts with main
github-actions[bot] Aug 24, 2026
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
14 changes: 2 additions & 12 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3576,14 +3576,9 @@ Session JSONL can contain pasted secrets, such as API keys or tokens, so exporte
The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes.
If the staging cleanup fails, the command warns with the retained path and a manual removal command.
The retained artifact can contain session JSONL with pasted secrets.
Run the exact command from the warning, which has this form:

```bash
openshell sandbox exec --name <sandbox-name> -- rm -f <retained-staging-path>
```
Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists.

The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail.
Confirm that the command prints no further cleanup warning after a later export.

</AgentOnly>
<AgentOnly variant="hermes">
Expand Down Expand Up @@ -3612,14 +3607,9 @@ Session JSONL can contain pasted secrets, such as API keys or tokens, so exporte
The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes.
If the staging cleanup fails, the command warns with the retained path and a manual removal command.
The retained artifact can contain session JSONL with pasted secrets.
Run the exact command from the warning, which has this form:

```bash
openshell sandbox exec --name <sandbox-name> -- rm -f <retained-staging-path>
```
Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists.

The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail.
Confirm that the command prints no further cleanup warning after a later export.

</AgentOnly>

Expand Down
162 changes: 96 additions & 66 deletions src/lib/actions/sandbox/auto-pair-warmup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { describe, expect, it } from "vitest";

import {
RESTORED_CLONE_WARMUP_SCRIPT,
sandboxWarmupExecArgs,
WARMUP_PROBE_TIMEOUT_S,
WARMUP_SCRIPT,
WARMUP_TIMEOUT_MS,
} from "./auto-pair-warmup";
import { buildTrustedProxyEnvSourceShell } from "./trusted-proxy-env";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";

const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0;
Expand All @@ -29,14 +31,14 @@ const itWithSh = shAvailable ? it : it.skip;
// spawn/wiring path to the `test/sandbox-connect-inference/` integration
// harness (real compiled CLI + fake openshell on PATH). These cases therefore
// pin the contract surface that IS testable in-process — the timeout bound and
// the OpenShell-exec wrapping the leaf depends on — and the
// finalization.test.ts ordering tests pin the provoke→approve wiring.
// the OpenShell-exec wrapping the leaf depends on — and the finalization tests
// pin the producer→observation→approval wiring.

describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => {
it("uses a fixed 30s outer cap so a wedged warm-up can never block onboard", () => {
// The direct call performs no inference work. Thirty seconds covers gateway
// connection, the scope-upgrade request, the bounded list poll, and shell
// startup while still bounding a hung sandbox.
// connection, the scope-upgrade request, and shell startup while still
// bounding a hung sandbox.
expect(WARMUP_TIMEOUT_MS).toBe(30_000);
expect(typeof WARMUP_TIMEOUT_MS).toBe("number");
expect(WARMUP_TIMEOUT_MS).toBeGreaterThan(0);
Expand All @@ -53,11 +55,23 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => {
});

describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", () => {
it("keeps the real warm-up as one multiline command argument", () => {
it("keeps the real warm-up as one multiline command on the owning gateway (#10014)", () => {
expect(WARMUP_SCRIPT).toContain("\n");
expect(WARMUP_SCRIPT).toContain("command -v openclaw");
expect(WARMUP_SCRIPT).not.toContain("base64 -d");
expect(WARMUP_SCRIPT).not.toContain("mktemp");
expect(sandboxWarmupExecArgs("alpha", "nemoclaw-19000", WARMUP_SCRIPT)).toEqual([
"sandbox",
"exec",
"--name",
"alpha",
"-g",
"nemoclaw-19000",
"--",
"sh",
"-c",
WARMUP_SCRIPT,
]);
});

itWithSh("runs a multiline warm-up-shaped payload and preserves its exit-0 status", () => {
Expand Down Expand Up @@ -115,6 +129,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", (
"export OPENCLAW_GATEWAY_PORT=18789",
"",
].join("\n"),
{ mode: 0o444 },
);
fs.writeFileSync(
path.join(binDir, "openclaw"),
Expand All @@ -135,7 +150,11 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", (
);

try {
const script = RESTORED_CLONE_WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv);
expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain(buildTrustedProxyEnvSourceShell());
const script = RESTORED_CLONE_WARMUP_SCRIPT.replace(
buildTrustedProxyEnvSourceShell(),
buildTrustedProxyEnvSourceShell(proxyEnv),
);
const result = spawnSync("sh", ["-c", script], {
encoding: "utf-8",
env: {
Expand All @@ -155,63 +174,61 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", (
}
});

it("scopes forced device pairing to the provoke command on OpenClaw 2026.7.1", () => {
const [provoke, poll] = WARMUP_SCRIPT.split("i=0\nwhile", 2);
expect(provoke.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1);
expect(poll).not.toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1");
it("scopes forced device pairing to the request producer on OpenClaw 2026.7.1", () => {
expect(WARMUP_SCRIPT.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1);
expect(WARMUP_SCRIPT).not.toContain("export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING");
});

itWithSh(
"polls after a hung direct probe reaches its own timeout (#9844)",
"bounds a hung direct request without polling pairing state (#10014)",
() => {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-timeout-"));
const binDir = path.join(fixtureRoot, "bin");
const proxyEnv = path.join(fixtureRoot, "proxy-env.sh");
const pollLog = path.join(fixtureRoot, "poll.log");
const callLog = path.join(fixtureRoot, "call.log");
fs.mkdirSync(binDir);
fs.writeFileSync(proxyEnv, "");
fs.writeFileSync(
path.join(binDir, "openclaw"),
[
"#!/bin/sh",
'printf \'%s\\n\' "$*" >> "$NEMOCLAW_TEST_CALL_LOG"',
'if [ "${1:-}" = "gateway" ]; then',
" exec sleep 60",
"fi",
"printf 'poll\\n' > \"$NEMOCLAW_TEST_POLL_LOG\"",
'printf \'%s\\n\' \'{"pending":[{"scopes":["operator.write"]}],"paired":[]}\'',
"exit 64",
"",
].join("\n"),
{ mode: 0o700 },
);

try {
const script = WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv);
const result = spawnSync("sh", ["-c", script], {
const result = spawnSync("sh", ["-c", WARMUP_SCRIPT], {
encoding: "utf-8",
env: {
...process.env,
NEMOCLAW_TEST_POLL_LOG: pollLog,
NEMOCLAW_TEST_CALL_LOG: callLog,
PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`,
},
timeout: 12_000,
});

expect(result.status, result.stderr).toBe(0);
expect(fs.readFileSync(pollLog, "utf8")).toBe("poll\n");
expect(fs.readFileSync(callLog, "utf8")).toMatch(
/^gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/,
);
expect(fs.readFileSync(callLog, "utf8")).not.toContain("devices list");
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
},
12_000,
20_000,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

itWithSh("polls the pending upgrade with pairing-only stored device auth (#9844)", () => {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-poll-"));
itWithSh("uses device auth after consuming the trusted proxy environment (#10014)", () => {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-producer-"));
const binDir = path.join(fixtureRoot, "bin");
const proxyEnv = path.join(fixtureRoot, "proxy-env.sh");
const pollEnvLog = path.join(fixtureRoot, "poll-env.log");
const provokeEnvLog = path.join(fixtureRoot, "provoke-env.log");
const sourceLog = path.join(fixtureRoot, "source.log");
const callLog = path.join(fixtureRoot, "call.log");
fs.mkdirSync(binDir);
fs.writeFileSync(
proxyEnv,
Expand All @@ -223,77 +240,90 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", (
"export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=ambient-force-marker",
"export NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING=ambient-clone-marker",
"export NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT=ambient-settlement-marker",
'printf \'consumed\\n\' > "$NEMOCLAW_TEST_PROXY_SOURCE_LOG"',
"",
].join("\n"),
{ mode: 0o444 },
);
fs.writeFileSync(
path.join(binDir, "openclaw"),
[
"#!/bin/sh",
'if [ "${1:-}" = "gateway" ]; then',
" {",
" printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"",
" printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"",
" printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"",
" printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"",
" printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"",
" printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"",
" printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"",
" printf 'argv=%s\\n' \"$*\"",
' } > "$NEMOCLAW_TEST_PROVOKE_ENV_LOG"',
" exit 1",
"fi",
"{",
" printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"",
" printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"",
" printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"",
" printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"",
" printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"",
" printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"",
" printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"",
'} > "$NEMOCLAW_TEST_POLL_ENV_LOG"',
'printf \'%s\\n\' \'{"pending":[{"scopes":["operator.write"]}],"paired":[]}\'',
"",
].join("\n"),
[
"#!/bin/sh",
"{",
" printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"",
" printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"",
" printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"",
" printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"",
" printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"",
" printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"",
" printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"",
" printf 'argv=%s\\n' \"$*\"",
'} > "$NEMOCLAW_TEST_CALL_LOG"',
"exit 1",
"",
].join("\n"),
{ mode: 0o700 },
);

try {
const script = WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv);
const script = WARMUP_SCRIPT.replace(
buildTrustedProxyEnvSourceShell(),
buildTrustedProxyEnvSourceShell(proxyEnv),
);
const result = spawnSync("sh", ["-c", script], {
encoding: "utf-8",
env: {
...process.env,
NEMOCLAW_TEST_POLL_ENV_LOG: pollEnvLog,
NEMOCLAW_TEST_PROVOKE_ENV_LOG: provokeEnvLog,
NEMOCLAW_TEST_CALL_LOG: callLog,
NEMOCLAW_TEST_PROXY_SOURCE_LOG: sourceLog,
PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`,
},
timeout: 10_000,
});

expect(result.status, result.stderr).toBe(0);
expect(fs.readFileSync(provokeEnvLog, "utf8")).toMatch(
expect(fs.readFileSync(sourceLog, "utf8")).toBe("consumed\n");
expect(fs.readFileSync(callLog, "utf8")).toMatch(
/^url=unset\nport=unset\ntoken=unset\npassword=unset\nforce=1\nrestored=unset\nsettlement=unset\nargv=gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/,
);
expect(fs.readFileSync(pollEnvLog, "utf8")).toBe(
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});

it("rejects unsafe proxy source paths before a warm-up child can read credentials (#10014)", () => {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-unsafe-proxy-"));
const unsafeProxy = path.join(fixtureRoot, "proxy-env.sh");
const evaluated = path.join(fixtureRoot, "evaluated");
fs.writeFileSync(unsafeProxy, `printf evaluated > ${evaluated}\n`, { mode: 0o666 });

try {
const result = spawnSync(
"sh",
[
"url=unset",
"port=unset",
"token=unset",
"password=unset",
"force=unset",
"restored=unset",
"settlement=1",
"",
].join("\n"),
"-c",
WARMUP_SCRIPT.replace(
buildTrustedProxyEnvSourceShell(),
buildTrustedProxyEnvSourceShell(unsafeProxy),
),
],
{
encoding: "utf8",
env: { ...process.env, OPENCLAW_GATEWAY_TOKEN: "inherited-secret" },
timeout: 10_000,
},
);
expect(result.status).toBe(126);
expect(fs.existsSync(evaluated)).toBe(false);
expect(result.stderr).not.toContain("inherited-secret");
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});

it("uses a direct write-scope probe without an embedded inference fallback (#9844)", () => {
expect(WARMUP_SCRIPT).not.toContain("openclaw agent");
expect(WARMUP_SCRIPT).not.toContain("devices list");
expect(WARMUP_SCRIPT).not.toContain("setsid");
expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID");
expect(WARMUP_SCRIPT).not.toContain("warmup_cleanup_attempt");
Expand Down
Loading