Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6607c79
test(policy): pin the default portable policy composition (#9206)
AzeelSajjad Aug 16, 2026
b1caa89
fix(policy): report the authoritative OpenShell policy result (#9206)
AzeelSajjad Aug 16, 2026
55d41a7
docs(policy): document rejected and unconfirmed policy updates (#9206)
AzeelSajjad Aug 16, 2026
aaf3365
test(policy): keep the policy outcome test bodies linear (#9206)
AzeelSajjad Aug 16, 2026
cbcab8e
fix(policy): require an explicit refusal status and prove temp cleanu…
AzeelSajjad Aug 16, 2026
734ad7a
fix(policy): bind the refusal status to the diagnostic that carries i…
AzeelSajjad Aug 16, 2026
730001a
merge(main): resolve policy finality conflict
cv Aug 16, 2026
f5dfc76
chore(ci): align merged architecture budget
cv Aug 16, 2026
71f583b
test(policy): remove retained test policy material
cv Aug 16, 2026
dc3625e
test(policy): exercise refusal frame parsing
cv Aug 16, 2026
2bdc74f
docs(policy): add retained policy cleanup steps
cv Aug 16, 2026
68a0e66
docs(policy): clarify partial policy recovery
cv Aug 16, 2026
11739e8
test(policy): keep finality cases linear
cv Aug 17, 2026
6a915ff
docs(policy): distinguish removal recovery states
cv Aug 17, 2026
2d282c7
Merge branch 'main' into fix/portable-default-policy-finality-9206
cv Aug 17, 2026
0439b34
merge(main): refresh policy finality slice
senthilr-nv Aug 17, 2026
44baee6
fix(policy): preserve batch failure status
senthilr-nv Aug 17, 2026
7c4693c
docs(policy): align finality recovery claims
senthilr-nv Aug 17, 2026
398a008
fix(policy): close finality review gaps
senthilr-nv Aug 17, 2026
2d5dae4
fix(policy): limit readiness retries
senthilr-nv Aug 17, 2026
08cccbf
merge(main): refresh policy finality slice
senthilr-nv Aug 17, 2026
aea9779
fix(docs): bind retained policy cleanup
senthilr-nv Aug 17, 2026
3df42aa
merge(main): reconcile policy finality with Personal defaults
senthilr-nv Aug 19, 2026
b974fc8
merge(main): refresh policy finality base
senthilr-nv Aug 19, 2026
621d226
merge(main): refresh policy finality base
senthilr-nv Aug 19, 2026
64c22af
Merge branch 'main' into fix/portable-default-policy-finality-9206
prekshivyas Aug 19, 2026
06e8538
merge(main): refresh policy finality base
senthilr-nv Aug 19, 2026
39270ad
merge(main): refresh policy finality base
jyaunches Aug 19, 2026
82d2b63
merge(main): refresh policy finality base
jyaunches Aug 19, 2026
48efd61
Merge branch 'main' into fix/portable-default-policy-finality-9206
prekshivyas Aug 19, 2026
e23a373
Merge branch 'main' into fix/portable-default-policy-finality-9206
prekshivyas Aug 19, 2026
340972d
merge(main): refresh policy finality
cv Aug 20, 2026
a3fb707
Merge branch 'main' into fix/portable-default-policy-finality-9206
prekshivyas Aug 20, 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
5 changes: 3 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"src/lib/messaging/channels/index.ts": 25,
"src/lib/onboard/gateway-binding.ts": 52,
"src/lib/runner.ts": 87,
"src/lib/security/redact.ts": 52,
"src/lib/security/redact.ts": 53,
"src/lib/state/onboard-session.ts": 36,
"src/lib/state/registry.ts": 101,
"src/lib/state/state-root.ts": 21,
Expand All @@ -40,16 +40,17 @@
"src/lib/actions/sandbox/connect.ts": 40,
"src/lib/actions/sandbox/destroy.ts": 29,
"src/lib/actions/sandbox/doctor.ts": 30,
"src/lib/actions/sandbox/status-snapshot.ts": 20,
"src/lib/actions/sandbox/policy-channel.ts": 30,
"src/lib/actions/sandbox/process-recovery.ts": 21,
"src/lib/actions/sandbox/rebuild-pipeline.ts": 28,
"src/lib/actions/sandbox/snapshot.ts": 40,
"src/lib/actions/sandbox/status-snapshot.ts": 20,
"src/lib/actions/uninstall/run-plan.ts": 26,
"src/lib/inference/onboard-probes.ts": 21,
"src/lib/inference/vllm.ts": 21,
"src/lib/onboard.ts": 202,
"src/lib/onboard/machine/handlers/sandbox.ts": 21,
"src/lib/policy/index.ts": 22,
"src/lib/sandbox/config.ts": 22,
"src/lib/shields/index.ts": 23
}
Expand Down
48 changes: 48 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,54 @@ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
$$nemoclaw onboard
```

### Onboarding Reports a Rejected or Unconfirmed Policy Update

The onboarding policy step composes a policy document for the policy presets you selected and deselected, then submits it to the OpenShell gateway.
When the submission finishes, NemoClaw removes the temporary file that carries the composed policy, whether or not the gateway accepted it.
If it cannot remove that file, it reports the directory that still holds the policy instead of reporting a clean result.
If the submission does not succeed, onboarding stops and:

- does not record the presets as applied to the sandbox;
- marks the session failed and resumable;
- reports one of the two results below.

The gateway read the policy and refused it:

```text
OpenShell rejected the policy for sandbox 'my-assistant' (exit <exit status>): <OpenShell diagnostic>. The policy was not applied and re-applying it will be rejected again; change the preset selection instead.
```

The live policy is unchanged.
Resume reapplies the recorded selection, so it submits the same document and receives the same refusal.
Use the quoted OpenShell diagnostic to identify what the gateway refused.
Then follow [Previous onboarding session failed](#previous-onboarding-session-failed) to start fresh onboarding, and choose a different preset selection.
The default selection can be the refused one, so read the diagnostic before you assume a preset you added caused it.

NemoClaw could not confirm the result.
This covers a connection that ended before the result arrived, an unreachable gateway, an elapsed deadline, a rejected credential, and a refusal the gateway reported with a status NemoClaw does not recognize as final.
NemoClaw reports this whenever the gateway did not return an explicit refusal, because only an explicit refusal proves the policy was not applied:

```text
Could not confirm the policy update for sandbox 'my-assistant': <transport or command error>. The gateway may or may not have applied it; read the current policy back before retrying.
```

The gateway state is unknown, so read the sandbox policy back before you retry:

```bash
$$nemoclaw <name> policy list
```

`policy list` compares the local registry with the live gateway policy and flags a preset that is applied in one place but not the other.
Because the failed step did not record the presets as applied, a preset that the gateway did apply appears as that disagreement.

The same connection problem that made the result unconfirmed can also stop `policy list` from reaching the gateway.
When that happens, `policy list` prints `⚠ Could not query gateway — showing local state only.` and still exits 0.
If the container runtime is down, it prints that runtime's recovery guidance instead.
That output reports local state, so it does not tell you whether the gateway applied the policy.
Restore the gateway connection and run `policy list` again before you decide.

After you know the live policy, follow [Previous onboarding session failed](#previous-onboarding-session-failed) to resume or restart onboarding.

### Previous onboarding session failed

If a previous `$$nemoclaw onboard` attempt fails partway through (for example, a provider or inference-setup step reporting an error), NemoClaw records the failure in `~/.nemoclaw/onboard-session.json`.
Expand Down
114 changes: 114 additions & 0 deletions src/lib/onboard/policy-preset-sync-finality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from "vitest";
import { waitForPolicyMutation } from "./policy-preset-sync";

/**
* `waitForPolicyMutation` polls through `waitUntil(fn, 10, 2000)`: a 10-second
* budget probed every 2 seconds. That admits attempts at 0s, 2s, 4s, 6s and 8s
* before the budget expires.
*/
const MUTATION_BUDGET_MS = 10_000;
const MUTATION_POLL_INTERVAL_MS = 2_000;
const MAX_MUTATION_ATTEMPTS = 5;

/** Produced by `policySetFailure` when OpenShell returns an authoritative refusal. */
const REJECTION_ERROR_MESSAGE =
"OpenShell rejected the policy for sandbox 'sb-9206' (exit 1): " +
"unsupported field in network_policies.weather. The policy was not applied and " +
"re-applying it will be rejected again; change the preset selection instead.";

/** Produced by `policySetFailure` when the submission result is unconfirmed. */
const UNCONFIRMED_ERROR_MESSAGE =
"Could not confirm the policy update for sandbox 'sb-9206': h2 protocol error. " +
"The gateway may or may not have applied it; read the current policy back before retrying.";

/** Thrown while a sandbox is still starting; the one condition worth re-polling. */
const TRANSIENT_STARTUP_ERROR_MESSAGE = "sandbox not found: sb-9206";

describe("waitForPolicyMutation", () => {
let clockMs = 0;
let sleptMs: number[] = [];

beforeEach(() => {
// `waitUntil` measures its budget with Date.now and burns it in a blocking
// Atomics.wait. Driving one synthetic clock from the other keeps the real
// polling arithmetic while costing no wall-clock time.
clockMs = 1_700_000_000_000;
sleptMs = [];
vi.spyOn(Date, "now").mockImplementation(() => clockMs);
vi.spyOn(Atomics, "wait").mockImplementation((_typedArray, _index, _value, timeout) => {
const durationMs = typeof timeout === "number" ? timeout : 0;
sleptMs.push(durationMs);
clockMs += durationMs;
return "timed-out";
});
});

it("attempts a policy submission that OpenShell rejected exactly once (#9206)", () => {
let attempts = 0;
const mutate = (): boolean => {
attempts += 1;
throw new Error(REJECTION_ERROR_MESSAGE);
};

expect(() => waitForPolicyMutation("applyPresets(weather)", mutate)).toThrow(
REJECTION_ERROR_MESSAGE,
);
expect(attempts).toBe(1);
expect(sleptMs).toEqual([]);
});

it("attempts a policy submission with an unconfirmed result exactly once (#9206)", () => {
let attempts = 0;
const mutate = (): boolean => {
attempts += 1;
throw new Error(UNCONFIRMED_ERROR_MESSAGE);
};

expect(() => waitForPolicyMutation("applyPresets(weather)", mutate)).toThrow(
UNCONFIRMED_ERROR_MESSAGE,
);
expect(attempts).toBe(1);
expect(sleptMs).toEqual([]);
});

it("keeps re-polling a sandbox that has not appeared yet until the mutation lands (#9206)", () => {
const behaviours: ReadonlyArray<() => boolean> = [
() => {
throw new Error(TRANSIENT_STARTUP_ERROR_MESSAGE);
},
() => {
throw new Error(TRANSIENT_STARTUP_ERROR_MESSAGE);
},
() => true,
];
let attempts = 0;
const mutate = (): boolean => {
const behaviour = behaviours[attempts] ?? (() => true);
attempts += 1;
return behaviour();
};

expect(() => waitForPolicyMutation("applyPreset(slack)", mutate)).not.toThrow();
expect(attempts).toBe(3);
expect(sleptMs).toEqual([MUTATION_POLL_INTERVAL_MS, MUTATION_POLL_INTERVAL_MS]);
});

it("stops re-polling a mutation that keeps reporting failure once the budget expires (#9206)", () => {
const startedAtMs = clockMs;
let attempts = 0;
const mutate = (): boolean => {
attempts += 1;
return false;
};

expect(() => waitForPolicyMutation("applyPreset(slack)", mutate)).toThrow(
"applyPreset(slack) returned false",
);
expect(attempts).toBe(MAX_MUTATION_ATTEMPTS);
expect(attempts).toBeLessThanOrEqual(MUTATION_BUDGET_MS / MUTATION_POLL_INTERVAL_MS);
expect(clockMs - startedAtMs).toBeLessThanOrEqual(MUTATION_BUDGET_MS);
});
});
45 changes: 45 additions & 0 deletions src/lib/onboard/portable-default-policy-composition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import { mergeRequiredSetupPolicyPresets } from "./policy-preset-reconciliation";

const KNOWN_PRESET_NAMES = [
"personal-open-internet",
"openclaw-pricing",
"openclaw-diagnostics-otel-local",
"github",
"npm",
"weather",
];

describe("portable onboarding default policy composition", () => {
it("the authoritative default portable preset list is not widened by required-preset reconciliation (#9206)", () => {
const merged = mergeRequiredSetupPolicyPresets(["personal-open-internet"], {
agent: "openclaw",
tierName: "personal",
env: {},
observabilityEnabled: false,
enabledChannels: [],
hermesToolGateways: [],
knownPresetNames: KNOWN_PRESET_NAMES,
});

expect(merged).toEqual(["personal-open-internet"]);
});

it("enabling OpenClaw OTEL on a local endpoint widens the default portable preset list (#9206)", () => {
const merged = mergeRequiredSetupPolicyPresets(["personal-open-internet"], {
agent: "openclaw",
tierName: "personal",
env: { NEMOCLAW_OPENCLAW_OTEL: "1" },
observabilityEnabled: false,
enabledChannels: [],
hermesToolGateways: [],
knownPresetNames: KNOWN_PRESET_NAMES,
});

expect(merged).toEqual(["personal-open-internet", "openclaw-diagnostics-otel-local"]);
});
});
30 changes: 30 additions & 0 deletions src/lib/onboard/portable-environment-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ describe("portable onboarding environment scope", () => {
expect(env).toEqual(before);
});

it("fresh portable onboarding selects only the default open-internet preset when no preset list is set (#9206)", () => {
const env: NodeJS.ProcessEnv = { HOME: "/home/alice" };
const before = { ...env };
const scope = createPortableOnboardEnvironmentScope(env, null);

expect(env.NEMOCLAW_POLICY_PRESETS).toBe("personal-open-internet");
expect(env.NEMOCLAW_POLICY_MODE).toBe("custom");
expect(env.NEMOCLAW_POLICY_TIER).toBe("personal");

scope.restore();
expect(env).not.toHaveProperty("NEMOCLAW_POLICY_PRESETS");
expect(env).toEqual(before);
});

it("fresh portable onboarding preserves an explicit preset list instead of the default (#9206)", () => {
const env: NodeJS.ProcessEnv = {
HOME: "/home/alice",
NEMOCLAW_POLICY_PRESETS: "weather,github",
};
const before = { ...env };
const scope = createPortableOnboardEnvironmentScope(env, null);

expect(env.NEMOCLAW_POLICY_PRESETS).toBe("weather,github");
expect(env.NEMOCLAW_POLICY_MODE).toBe("custom");
expect(env.NEMOCLAW_POLICY_TIER).toBe("personal");

scope.restore();
expect(env).toEqual(before);
});

it("clears ambient inference selectors while preserving an explicit resume policy list (#9035)", () => {
const env: NodeJS.ProcessEnv = {
NEMOCLAW_PROVIDER: "ollama",
Expand Down
Loading