Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eb5ff2f
fix(e2e): repair recovered runtime qualification
prekshivyas Aug 15, 2026
c9f95ec
Merge branch 'main' into agent/fix-e2e-runtime-regressions
prekshivyas Aug 15, 2026
5a79f35
test: linearize dashboard forward setup
prekshivyas Aug 15, 2026
db15b08
fix(onboard): bind pre-auth recovery to v0.0.44
prekshivyas Aug 15, 2026
d428f20
refactor(e2e): centralize recovery boundaries
prekshivyas Aug 15, 2026
3013dcb
test(onboard): cover legacy recovery authority
prekshivyas Aug 15, 2026
f344b41
fix(openclaw): preserve root auto-pair log access
prekshivyas Aug 15, 2026
46e771f
Merge remote-tracking branch 'origin/main' into agent/fix-e2e-runtime…
prekshivyas Aug 15, 2026
e447217
refactor(openclaw): centralize auto-pair log ownership
prekshivyas Aug 15, 2026
b337890
Merge branch 'main' into agent/fix-e2e-runtime-regressions
prekshivyas Aug 15, 2026
ddb3169
refactor(e2e): centralize forward recovery polling
prekshivyas Aug 15, 2026
4c2e01c
docs(platform): repair sandbox control evidence
prekshivyas Aug 15, 2026
a2104bd
docs(status): explain recovery probe retries
prekshivyas Aug 15, 2026
67b1644
test(onboard): name pre-auth fixture version
prekshivyas Aug 15, 2026
9b7c957
test(start): isolate auto-pair log fixture
prekshivyas Aug 15, 2026
7e09ee5
test(start): enforce fixture path guard
prekshivyas Aug 15, 2026
f32e641
merge: resolve conflicts with main
github-actions[bot] Aug 16, 2026
fd14b67
merge(main): resolve e2e qualification conflicts
cv Aug 16, 2026
5977055
merge(main): include current CI qualification fixes
cv Aug 16, 2026
3a37238
test(inference): align Anthropic retry fixture
cv Aug 16, 2026
9df0ac8
test(status): cover repeated inference failures
cv Aug 16, 2026
6e9be93
test(start): extend recovery test timeout
cv Aug 16, 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
2 changes: 1 addition & 1 deletion ci/platform-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@
{
"name": "Sandboxed execution",
"status": "caveated",
"notes": "Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (ulimit -u 512 at `scripts/lib/sandbox-init.sh:237`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:288-299` drops cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty."
"notes": "Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:275-286` drops cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty."
},
{
"name": "Routed inference",
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1869,7 +1869,9 @@ The route probe treats any final HTTP status from `200` through `499` as reachab
The request uses the live gateway route's provider and model, and falls back to the recorded values when the live route is unreadable.
When the live provider and model match the recorded route, the request also uses the sandbox's recorded API family, including `openai-responses`.
During route drift, NemoClaw does not carry the sandbox's recorded API family to the live provider and model.
Each run sends one 16-token request through the stored provider credential, so `status` waits up to 30 seconds for it and consumes provider tokens on a hosted route.
An ordinary run sends one 16-token request through the stored provider credential, with a 30-second timeout, and consumes provider tokens on a hosted route.
When the same `status` run recovers a managed gateway, it retries the route and inference request together up to three total attempts, with a two-second delay between failed attempts.
Each attempt can consume another 16 tokens on a hosted route.
When NemoClaw sends an inference request, `inferenceHealth.subprobes` reports the route probe result as the `route reachability` hop, so a failing verdict still shows that the route itself answered.
`inferenceHealth.failureLabel` reports why the inference request failed:

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/platform-support.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Each row below is a launch-facing capability claim that NemoClaw makes in docs,
| Capability | Status | Notes |
|------------|--------|-------|
| Guided onboarding | Tested | Single-command interactive wizard (`$$nemoclaw onboard`) that walks the user through inference provider selection, credential setup, and sandbox creation or update. It opens the agent dashboard when the selected runtime provides one. Non-interactive mode is supported with `--non-interactive` and `NEMOCLAW_*` environment variables for CI and scripted installs. |
| Sandboxed execution | Tested with limitations | Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (ulimit -u 512 at `scripts/lib/sandbox-init.sh:237`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:288-299` drops cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty. |
| Sandboxed execution | Tested with limitations | Landlock, seccomp, network namespace isolation, no-new-privileges, privilege dropping, and process limits (nproc 512 at `scripts/lib/sandbox-rlimits.sh:8`) are tested and on by default. The `DANGEROUS_CAPS` list at `scripts/lib/sandbox-init.sh:275-286` drops cap_sys_admin, cap_sys_ptrace, cap_net_raw, cap_dac_override, cap_sys_chroot, cap_fsetid, cap_setfcap, cap_mknod, cap_audit_write, cap_net_bind_service with `capsh --drop` when CAP_SETPCAP is present. Limitation (active issue #3280): the fail-closed bounding-set gate is opt-in via `NEMOCLAW_REQUIRE_CAP_DROP=1`; the default is warn-and-continue so hosts without CAP_SETPCAP still boot, which means dangerous caps can remain in the bounding set on some hosts even though the effective set is empty. |
| Routed inference | Tested | Provider-routed model calls through the OpenShell gateway, transparent to the agent. The agent uses `inference.local` inside the sandbox; provider credentials stay on the host. Supports every entry in the Providers table. |
| Declarative network policy | Tested | YAML-defined egress with policy presets. Presets include `slack`, `discord`, `telegram`, `weather`, `openclaw-pricing`, `huggingface`, `npm`, `pypi`, `brew`, and others. Hot-reloadable at runtime with `$$nemoclaw <name> policy add`. |
| Snapshot and restore | Tested | Create, list, and restore named snapshots of sandbox state with the `$$nemoclaw <name> snapshot` subcommands (`create`, `list`, `restore`). Credential stripping is enforced on capture. Unsafe symlinks are rejected on restore. |
Expand Down
6 changes: 5 additions & 1 deletion scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ source "${_SANDBOX_INIT_DIR}/sandbox-rlimits.sh"
# File Owner Mode Writer Reader Sourced?
# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (/etc shell hooks)
# /tmp/gateway.log gateway 644 gateway all no (world-readable for diagnostics)
# /tmp/auto-pair.log sandbox 600 sandbox sandbox no
# /tmp/auto-pair.log root* 600 root* inherited no
# /tmp/nemoclaw-plugin-refresh.log sandbox 600 sandbox sandbox no (OpenClaw refresh output)
# /tmp/.npm-cache/ sandbox 755 sandbox sandbox no (tool data)
# /tmp/.cache/ sandbox 755 sandbox sandbox no (tool data)
# /tmp/.config/ sandbox 755 sandbox sandbox no (tool data)
# /tmp/.gnupg/ sandbox 700 sandbox sandbox no (key data)
#
# * In non-root mode the sandbox user owns and opens auto-pair.log. In root
# mode PID 1 owns and opens it before the stepped-down watcher inherits the
# descriptor; PID 1 has already dropped CAP_DAC_OVERRIDE at that boundary.
#
# In non-root mode privilege separation is disabled — all files are
# owned by sandbox. chmod 444 is best-effort (owner can chmod back).
# This is an accepted limitation documented in the OpenShell security model.
Expand Down
16 changes: 12 additions & 4 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3167,6 +3167,16 @@ PYAUTOPAIR
echo "[gateway] auto-pair watcher launched (pid $AUTO_PAIR_PID)" >&2
}

prepare_auto_pair_log() {
if [ "$(id -u)" -eq 0 ]; then
# PID 1 opens the redirection after CAP_DAC_OVERRIDE is gone, then passes
# the already-open descriptor to the stepped-down watcher.
_nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 root:root
else
_nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600
fi
}

# ── Proxy environment ────────────────────────────────────────────
# OpenShell injects HTTP_PROXY/HTTPS_PROXY/NO_PROXY into the sandbox, but its
# NO_PROXY is limited to 127.0.0.1,localhost,::1 — missing the gateway IP.
Expand Down Expand Up @@ -5894,8 +5904,7 @@ if [ "$(id -u)" -ne 0 ]; then
write_auth_profile
harden_auth_profiles

# Separate log for auto-pair in non-root mode as well.
_nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600
prepare_auto_pair_log

prepare_plugin_refresh_log || exit 1

Expand Down Expand Up @@ -6028,8 +6037,7 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then
exit "$_nemoclaw_cmd_rc"
fi

# Separate log for auto-pair so sandbox user can write to it
_nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 sandbox:sandbox
prepare_auto_pair_log

prepare_plugin_refresh_log || exit 1

Expand Down
45 changes: 12 additions & 33 deletions src/lib/actions/sandbox/forward-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import {
} from "../../adapters/openshell/timeouts";
import * as agentRuntime from "../../agent/runtime";
import { DASHBOARD_PORT, HERMES_OPENAI_API_PORT } from "../../core/ports";
import { waitUntil } from "../../core/wait";
import { getActiveMessagingHostForward } from "../../messaging/host-forward";
import { hydrateDerivedSandboxMessagingPlanFields } from "../../messaging/hydration";
import type { SandboxMessagingHostForwardPlan } from "../../messaging/manifest";
import { parseSandboxMessagingPlan } from "../../messaging/plan-validation";
import { isRemoteDashboardBindRequested } from "../../onboard/dockerfile-remote-dashboard-bind-contract";
import { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
import {
waitForForwardRecoveryState,
waitForStoppedForwardPortRelease,
} from "../../onboard/forward-cleanup";
import {
resolveSandboxHermesApiPort,
retargetHermesApiPortInUrl,
Expand Down Expand Up @@ -56,9 +59,6 @@ type DashboardForwardStopRunner = (
options: { ignoreError: true; stdio: "ignore"; timeout: number },
) => { status?: number | null };

const FORWARD_RELEASE_TIMEOUT_MS = 5_000;
const FORWARD_RELEASE_POLL_MS = 250;

function isValidPort(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
}
Expand All @@ -83,20 +83,6 @@ function runDashboardForwardStopBestEffort(
}
}

function confirmDashboardForwardReleased(
port: number,
isForwardReachable: (port: number) => boolean,
): boolean {
const now = Date.now;
return waitUntil(() => !isForwardReachable(port), {
deadlineMs: now() + FORWARD_RELEASE_TIMEOUT_MS,
initialIntervalMs: FORWARD_RELEASE_POLL_MS,
maxIntervalMs: FORWARD_RELEASE_POLL_MS,
backoffFactor: 1,
now,
});
}

export function resolveSandboxDashboardPort(
sandboxName: string,
deps: SandboxPortDeps = {},
Expand Down Expand Up @@ -170,7 +156,10 @@ export function teardownSandboxDashboardForward(
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
if (result.status !== 0) return;
confirmDashboardForwardReleased(port, deps.isLocalForwardReachable ?? isLocalForwardReachable);
waitForStoppedForwardPortRelease(
port,
deps.isLocalForwardReachable ?? isLocalForwardReachable,
);
} catch {
// Defense in depth for injected or future runners: teardown is best-effort.
}
Expand Down Expand Up @@ -328,7 +317,7 @@ export function ensureSandboxPortForwardForPort(
health: forwardHealth,
portReleased: false,
};
waitUntil(
waitForForwardRecoveryState(
() => {
stopState.health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind);
stopState.portReleased = !isLocalForwardReachable(port);
Expand All @@ -338,12 +327,7 @@ export function ensureSandboxPortForwardForPort(
stopState.portReleased
);
},
{
deadlineMs: Date.now() + waitMs,
initialIntervalMs: 100,
maxIntervalMs: 500,
backoffFactor: 1.5,
},
waitMs,
);
if (stopState.health === true && !forceRestart) return acceptSuccessfulForward();
if (stopState.health === "occupied") return false;
Expand Down Expand Up @@ -382,7 +366,7 @@ export function ensureSandboxPortForwardForPort(
if (waitMs === 0) return false;

let occupied = false;
const settled = waitUntil(
const settled = waitForForwardRecoveryState(
() => {
health = isSandboxPortForwardHealthy(sandboxName, port, expectedBind);
if (health === "occupied") {
Expand All @@ -391,12 +375,7 @@ export function ensureSandboxPortForwardForPort(
}
return health === true;
},
{
deadlineMs: Date.now() + waitMs,
initialIntervalMs: 100,
maxIntervalMs: 500,
backoffFactor: 1.5,
},
waitMs,
);
return settled && !occupied && acceptSuccessfulForward();
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe("startSandbox", () => {

it(
"retries startup after a structured recovery failure (#8662)",
testTimeoutOptions(15_000),
testTimeoutOptions(30_000),
async () => {
const h = harness();
h.restoreStartupState.mockReturnValueOnce(FAILED_RECOVERY);
Expand Down
95 changes: 95 additions & 0 deletions src/lib/actions/sandbox/status-snapshot-inference-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,101 @@ describe("collectSandboxStatusSnapshot inference route health", () => {
expect(snapshot.inferenceHealth).toMatchObject({ ok: true, probed: true });
});

it("waits for the authoritative invocation after recovering the agent gateway", async () => {
const healthy: SandboxInferenceRouteHealth = {
ok: true,
endpoint: "https://inference.local/v1/models",
httpStatus: 200,
detail: "reachable",
};
const options = snapshotDeps(healthy);
options.deps.reconcile = async () => ({
state: "present",
output: "Phase: Ready",
recoveredSandbox: true,
recoverySandboxVia: "started-stopped-original",
});
const probeSandboxInferenceGatewayHealthImpl = vi.fn(async () => healthy);
const probeSandboxInferenceInvocationImpl = vi
.fn()
.mockReturnValueOnce({
ok: false,
detail: "sandbox inference invocation failed with status 6",
httpStatus: null,
})
.mockReturnValueOnce({ ok: true });
const delayInferenceRecoveryProbe = vi.fn(async () => undefined);
const recoverSandboxProcesses = vi.fn(() => ({
checked: true,
wasRunning: false,
recovered: true,
forwardRecovered: true,
}));

const snapshot = await collectSandboxStatusSnapshot("alpha", {
...options,
deps: {
...options.deps,
delayInferenceRecoveryProbe,
probeSandboxInferenceGatewayHealthImpl,
probeSandboxInferenceInvocationImpl,
recoverSandboxProcesses,
},
});

expect(probeSandboxInferenceGatewayHealthImpl).toHaveBeenCalledTimes(2);
expect(probeSandboxInferenceInvocationImpl).toHaveBeenCalledTimes(2);
expect(delayInferenceRecoveryProbe).toHaveBeenCalledOnce();
expect(snapshot.inferenceHealth).toMatchObject({ ok: true, probed: true });
});

it("reports unhealthy after every recovered inference request fails", async () => {
const healthy: SandboxInferenceRouteHealth = {
ok: true,
endpoint: "https://inference.local/v1/models",
httpStatus: 200,
detail: "reachable",
};
const failedInvocation: SandboxInferenceInvocationResult = {
ok: false,
detail: "sandbox inference invocation failed with status 503",
httpStatus: 503,
};
const options = snapshotDeps(healthy);
options.deps.reconcile = async () => ({
state: "present",
output: "Phase: Ready",
recoveredSandbox: true,
recoverySandboxVia: "started-stopped-original",
});
const probeSandboxInferenceGatewayHealthImpl = vi.fn(async () => healthy);
const probeSandboxInferenceInvocationImpl = vi.fn(() => failedInvocation);
const delayInferenceRecoveryProbe = vi.fn(async () => undefined);
const recoverSandboxProcesses = vi.fn(() => ({
checked: true,
wasRunning: false,
recovered: true,
forwardRecovered: true,
}));

const snapshot = await collectSandboxStatusSnapshot("alpha", {
...options,
deps: {
...options.deps,
delayInferenceRecoveryProbe,
probeSandboxInferenceGatewayHealthImpl,
probeSandboxInferenceInvocationImpl,
recoverSandboxProcesses,
},
});

expect(probeSandboxInferenceGatewayHealthImpl).toHaveBeenCalledTimes(3);
expect(probeSandboxInferenceInvocationImpl).toHaveBeenCalledTimes(3);
expect(delayInferenceRecoveryProbe).toHaveBeenCalledTimes(2);
expect(delayInferenceRecoveryProbe).toHaveBeenCalledWith(2_000);
expect(snapshot.inferenceHealth).toMatchObject({ ok: false, failureLabel: "unhealthy" });
});

it("reports the inference route as unreachable after all post-recovery probes", async () => {
const unreachable: SandboxInferenceRouteHealth = {
ok: false,
Expand Down
Loading
Loading