Skip to content

Commit 3606cb4

Browse files
authored
fix(policy): remove a preset the gateway enforces without a local record (#9306)
<!-- markdownlint-disable MD041 --> ## Summary <!-- 1-3 plain sentences: what changes and why. Describe before-and-after behavior when it applies. Follow the NemoClaw Writing Guide: https://github.com/NVIDIA/NemoClaw/blob/main/WRITING.md. Do not add unrelated prose cleanup. --> `policy list` reports a preset as active when either the local registry or the live gateway holds it, but `policy remove` consulted the registry alone and exited non-zero with `Preset 'X' is not applied.`, so the one state `policy list` exists to flag — active on gateway, missing from local state — was the only state with no removal path. `policy remove` now builds a single removable set from both sources and uses it for the named-preset guard and the interactive picker alike, and it distinguishes a gateway it could not query from a gateway that does not hold the preset. Applying a built-in preset to a sandbox that is missing from the registry no longer returns success in silence, which closes one way that divergence is created. ## Related Issue <!-- Fixes #NNN or Closes #NNN. Remove this section if none. --> Fixes #9295 ## Changes <!-- List concrete changes. If this adds an abstraction, configuration, fallback, migration, or compatibility path, name its current requirement and consumer, explain why a direct change is insufficient, and identify the test that protects it. --> - `removeSandboxPolicyUnlocked` in `src/lib/actions/sandbox/policy-channel.ts` derives one `removable` set from `getAppliedPresets` and `getGatewayPresets`, and both the named-preset guard and the `selectForRemoval` picker read it. Computing it once is what keeps the two entry points from disagreeing, which is the shape of the reported defect. `removePreset` already reconciles a gateway-held preset without a registry entry, so the CLI guard was the only obstacle. Covered by `removes a preset the gateway enforces but the registry never recorded`, `offers a gateway-only preset in the removal picker`, and `lists a preset both sources hold only once in the removal picker`. - A `null` from `getGatewayPresets` means the gateway could not be queried, not that the preset is absent, so the command falls back to the registry and names the evidence it had instead of asserting the preset is not applied. Covered by `names the unreachable gateway when it refuses on local state alone`; the unchanged refusal path is covered by `refuses a preset neither the registry nor the gateway holds`. - The built-in arm of `applyPresetContent` in `src/lib/policy/index.ts` warns when the sandbox has no registry entry instead of returning `true` in silence. It keeps the successful gateway mutation, because a built-in preset stays discoverable from the gateway and is now removable; the custom arm still returns `false`, because a custom preset is discoverable only through the registry. Covered by `warns but keeps the mutation when a built-in preset cannot be recorded locally`. - `src/lib/actions/sandbox/policy-channel-refresh.test.ts` and `policy-channel-lock.test.ts` gain a `getGatewayPresets` stub. Without it the new gateway read in `policy remove` would make those unit tests spawn a real `openshell policy get`. - `docs/network-policy/apply-policy-presets.mdx` documents removing a preset the gateway enforces without a local record, the unreachable-gateway behavior, and the unrecorded-sandbox case for maintained and custom presets. `docs/reference/commands.mdx` updates the `policy add` and `policy remove` reference sections to match. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates <!-- Check one tests line and one docs line. Check other lines when applicable. Add every requested justification or approval reference. --> - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [ ] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## Documentation Writer Review <!-- Required for code and documentation changes after the changes and applicable validation are complete. Keep one review checkbox and one instance of each visible or hidden field. For Evidence, list changed documentation paths. For documentation-only changes, also state that the writing rules and documentation style were reviewed. For other results, explain why no documentation change is needed or why the review is blocked. For Agent, use a consistent product and surface name, such as Codex Desktop, Codex CLI, Claude Code, or Cursor. After committing all review changes, put `git rev-parse --short HEAD` and `git rev-parse --short HEAD:AGENTS.md` in the hidden metadata below. Rerun the review and refresh that metadata after any new commit. This receipt is advisory during the data-collection pilot. --> - [x] Documentation writer subagent reviewed the completed changes - Result: `docs-updated` - Evidence: `docs/network-policy/apply-policy-presets.mdx`, `docs/reference/commands.mdx`. The review returned request-changes. Its blocking finding was that the persistence paragraph stated the built-in outcome without scoping it, so a reader applying a custom preset with `--from-file` to an unregistered sandbox would expect a gateway-active preset when the command in fact exits non-zero and the preset appears nowhere. It also found the quoted `policy list` row indented two spaces where `formatPolicyListPresetRow` emits four, no command block before the `Expected output:` lead-in, the gateway named without its OpenShell qualifier on first use, an unreachable-gateway sentence that did not scope its message to the named form, and both reference sections in `docs/reference/commands.mdx` left describing the previous behavior. All findings were verified against source and applied. - Agent: Claude Code <!-- docs-review-head-sha: 7ebf1a6 --> <!-- docs-review-agents-blob-sha: b9fb6a9 --> ## DGX Station Hardware Evidence <!-- Required only when scripts/prepare-dgx-station-host.sh changes. Maintainers must review the linked evidence before approving or merging. This is human-reviewed evidence, not authenticated hardware provenance. Exceptional bypasses use existing repository governance and must be documented on the PR. --> - [ ] Tested on DGX Station - Tested commit: - Station profile/scenario: - Result: - Supporting evidence: ## Verification <!-- Check each applicable item only when supported by the requested evidence. Run targeted tests once per relevant change set and rerun after later edits or hook autofixes that can affect the tested behavior. Do not rerun hook-covered checks. --> - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result or justification: `npx vitest run src/lib/actions/sandbox/policy-channel-policy.test.ts src/lib/actions/sandbox/policy-channel-refresh.test.ts src/lib/actions/sandbox/policy-channel-lock.test.ts src/lib/actions/sandbox/policy-channel-list.test.ts` — 4 files, 81 tests passed; `npx vitest run --project integration test/policies.test.ts` — 1 file, 76 tests passed. Reverting only the two changed source files fails 5 of the 6 new cases, so they guard the behavior rather than restate it; the sixth is the preserved refusal path and passes either way by design. `npm run typecheck:cli` and `npm run lint` are clean. The two `fern check` warnings are pre-existing — rebuilding with the doc changes stashed reports the same two. - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [x] `npm run docs` builds without warnings (doc changes only) - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- <!-- DCO sign-off is required in this PR description, and every commit must appear as Verified in GitHub. Run: git config user.name && git config user.email --> Signed-off-by: Tinson Lai <tinsonl@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Policy removal now detects presets enforced by the gateway, including presets missing from local state. - Interactive removal lists combine locally recorded and gateway-reported presets. - Built-in presets can be applied successfully even when local registry recording is unavailable, with a warning. - **Bug Fixes** - Improved handling and diagnostics when gateway state cannot be queried or presets exist in neither source. - **Documentation** - Updated policy command and preset guides to explain gateway-only presets, warnings, and offline removal behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 9b7f391 commit 3606cb4

8 files changed

Lines changed: 152 additions & 7 deletions

File tree

docs/network-policy/apply-policy-presets.mdx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
title: "Apply Policy Presets"
55
sidebar-title: "Apply Policy Presets"
66
description: "Add, reapply, list, or remove policy presets for a running NemoClaw sandbox."
7-
description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, or removing access."
8-
keywords: ["nemoclaw policy presets", "policy add", "policy remove"]
7+
description-agent: "Applies and manages policy presets for a running sandbox. Use when adding maintained integration access, previewing preset scope, reapplying an edited preset, removing access, or removing a preset the gateway enforces without a local record."
8+
keywords: ["nemoclaw policy presets", "policy add", "policy remove", "active on gateway missing from local state"]
99
content:
1010
type: "how_to"
1111
skill:
@@ -111,13 +111,44 @@ $$nemoclaw my-assistant policy remove weather --yes
111111

112112
`policy remove` accepts maintained and custom preset names.
113113

114+
## Remove a Preset the Gateway Enforces Without a Local Record
115+
116+
`policy list` marks a preset that the OpenShell gateway enforces while no local record explains it:
117+
118+
```bash
119+
$$nemoclaw my-assistant policy list
120+
```
121+
122+
Expected output:
123+
124+
```text
125+
● github [source unverified] — GitHub.com and GitHub API access (git) (active on gateway, missing from local state)
126+
```
127+
128+
`policy remove` accepts that preset:
129+
130+
```bash
131+
$$nemoclaw my-assistant policy remove github --yes
132+
```
133+
134+
It narrows the live policy and clears whatever local record remains.
135+
136+
When NemoClaw cannot reach the gateway, `policy remove <preset>` has only the local record to check.
137+
It refuses an unrecorded preset and reports that it could not query the gateway, rather than treating an unanswered query as absence.
138+
The interactive picker lists the recorded presets only in that case.
139+
114140
## Understand Persistence
115141

116142
Dynamic changes apply to the current live policy.
117143
NemoClaw also records maintained presets and custom presets applied through `--from-file` or `--from-dir`.
118144
The custom preset record includes the full YAML content.
119145
Snapshot restore and rebuild replay the recorded presets, even when the original custom file no longer exists.
120146

147+
A sandbox that is absent from the local registry has nothing to record a preset against.
148+
For a maintained preset, `policy add` still applies it to the gateway and warns that `policy list` reports it as active on gateway, missing from local state.
149+
For a custom preset applied with `--from-file` or `--from-dir`, `policy add` reaches the gateway but exits non-zero, because a custom preset is discoverable only through the registry and would appear in neither `policy list` nor `status`.
150+
Recover or re-onboard the sandbox to restore the record, then re-apply any custom preset that failed this way.
151+
121152
`$$nemoclaw <name> rebuild` reapplies every recorded policy preset to the recreated sandbox.
122153
For baseline changes that apply to every future sandbox, follow [Change the Baseline Network Policy](change-baseline-network-policy).
123154

docs/reference/commands.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2510,6 +2510,8 @@ If the base policy read returns non-empty output that NemoClaw cannot parse, the
25102510
Fix the gateway or policy read problem, then rerun the command.
25112511
For custom presets, the command also reports when the preset reached the gateway but NemoClaw could not record it in the local sandbox registry, because unrecorded custom presets will not appear in `policy list` or `status`.
25122512
Recover or re-onboard the sandbox, then re-apply the custom preset.
2513+
For built-in presets in that same case, the command applies the preset and returns success, because a built-in preset stays discoverable from the gateway.
2514+
It warns that `policy list` will report the preset as active on gateway, missing from local state.
25132515

25142516
With `--from-file` or `--from-dir`, pass a repeatable `--trusted-private-host <exact-host-or-ip>` option to admit matching RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local endpoints.
25152517
The option is invalid for built-in presets.
@@ -2590,7 +2592,7 @@ $$nemoclaw my-assistant policy list
25902592
### `$$nemoclaw <name> policy remove`
25912593

25922594
Remove a previously applied policy preset from a sandbox.
2593-
The command lists only the presets currently applied, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress.
2595+
The command lists the presets the local registry records together with the presets the live gateway enforces, prompts you to select one, shows the endpoints that would be removed, and asks for confirmation before narrowing egress.
25942596

25952597
```bash
25962598
$$nemoclaw my-assistant policy remove
@@ -2604,7 +2606,9 @@ $$nemoclaw my-assistant policy remove pypi --yes
26042606

26052607
Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`.
26062608
Without a preset name, `policy remove` reports the same two picker errors as `policy add` and exits non-zero.
2607-
If the preset is unknown or not currently applied, the command exits non-zero with a clear error.
2609+
If the preset is unknown, or neither the local registry nor the live gateway holds it, the command exits non-zero with a clear error.
2610+
A preset the gateway enforces without a local registry record is removable, which is the state `policy list` reports as active on gateway, missing from local state.
2611+
When NemoClaw cannot query the gateway, the command checks the local registry alone; with a preset name it also reports that the gateway could not be queried.
26082612

26092613
| Flag | Description |
26102614
|------|-------------|

src/lib/actions/sandbox/policy-channel-lock.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("policy and channel sandbox mutation locking", () => {
5151
]);
5252
vi.spyOn(policies, "listCustomPresets").mockReturnValue([]);
5353
vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]);
54+
vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null);
5455
vi.spyOn(policies, "loadPresetForSandbox").mockImplementation(
5556
(_sandboxName, presetName) =>
5657
`network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: example.com\n port: 443\n`,

src/lib/actions/sandbox/policy-channel-policy.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ let exitSpy: MockInstance;
4646
let promptMock: MockInstance;
4747
let getSandboxMock: MockInstance;
4848
let getAppliedPresetsMock: MockInstance;
49+
let getGatewayPresetsMock: MockInstance;
4950
let selectFromListMock: MockInstance;
5051
let selectForRemovalMock: MockInstance;
5152
let loadPresetForSandboxMock: MockInstance;
@@ -108,6 +109,7 @@ beforeEach(() => {
108109
vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS);
109110
vi.spyOn(policies, "listCustomPresets").mockReturnValue([]);
110111
getAppliedPresetsMock = vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]);
112+
getGatewayPresetsMock = vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null);
111113
selectFromListMock = vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi");
112114
selectForRemovalMock = vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi");
113115
vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => {
@@ -420,4 +422,59 @@ describe("removeSandboxPolicy", () => {
420422
expect(printedText()).toContain("No input available on stdin");
421423
expect(removePresetMock).not.toHaveBeenCalled();
422424
});
425+
426+
it("removes a preset the gateway enforces but the registry never recorded (#9295)", async () => {
427+
getAppliedPresetsMock.mockReturnValue([]);
428+
getGatewayPresetsMock.mockReturnValue(["npm"]);
429+
430+
await removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true });
431+
432+
expect(removePresetMock).toHaveBeenCalledWith("test-sandbox", "npm");
433+
});
434+
435+
it("refuses a preset neither the registry nor the gateway holds (#9295)", async () => {
436+
getAppliedPresetsMock.mockReturnValue([]);
437+
getGatewayPresetsMock.mockReturnValue(["pypi"]);
438+
439+
await expect(
440+
captureExit(() => removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true })),
441+
).resolves.toBe(1);
442+
443+
expect(printedText()).toContain("Preset 'npm' is not applied.");
444+
expect(removePresetMock).not.toHaveBeenCalled();
445+
});
446+
447+
it("names the unreachable gateway when it refuses on local state alone (#9295)", async () => {
448+
getAppliedPresetsMock.mockReturnValue([]);
449+
getGatewayPresetsMock.mockReturnValue(null);
450+
451+
await expect(
452+
captureExit(() => removeSandboxPolicy("test-sandbox", { preset: "npm", yes: true })),
453+
).resolves.toBe(1);
454+
455+
expect(printedText()).toContain(
456+
"Could not query the gateway, so only local state was checked.",
457+
);
458+
expect(removePresetMock).not.toHaveBeenCalled();
459+
});
460+
461+
it("offers a gateway-only preset in the removal picker (#9295)", async () => {
462+
getGatewayPresetsMock.mockReturnValue(["npm"]);
463+
464+
await removeSandboxPolicy("test-sandbox");
465+
466+
expect(selectForRemovalMock).toHaveBeenCalledWith(POLICY_PRESETS, {
467+
applied: ["pypi", "npm"],
468+
});
469+
});
470+
471+
it("lists a preset both sources hold only once in the removal picker (#9295)", async () => {
472+
getGatewayPresetsMock.mockReturnValue(["pypi", "npm"]);
473+
474+
await removeSandboxPolicy("test-sandbox");
475+
476+
expect(selectForRemovalMock).toHaveBeenCalledWith(POLICY_PRESETS, {
477+
applied: ["pypi", "npm"],
478+
});
479+
});
423480
});

src/lib/actions/sandbox/policy-channel-refresh.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ beforeEach(() => {
9292
vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS);
9393
vi.spyOn(policies, "listCustomPresets").mockReturnValue([]);
9494
vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]);
95+
vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null);
9596
vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi");
9697
vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi");
9798
vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => {

src/lib/actions/sandbox/policy-channel.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,7 +1981,15 @@ async function removeSandboxPolicyUnlocked(
19811981
const builtinPresets = policies.listPresets();
19821982
const customPresets = policies.listCustomPresets(sandboxName);
19831983
const allPresets = [...builtinPresets, ...customPresets];
1984+
// `policy list` reports a preset as active when either the registry or the
1985+
// gateway holds it, so removal has to accept the same set. A preset the
1986+
// gateway enforces but the registry never recorded is exactly the state
1987+
// `policy list` flags as "active on gateway, missing from local state", and
1988+
// removePreset() reconciles it without needing the registry entry. Null means
1989+
// the gateway could not be queried, which is not evidence of absence. (#9295)
19841990
const applied = policies.getAppliedPresets(sandboxName);
1991+
const gatewayPresets = policies.getGatewayPresets(sandboxName);
1992+
const removable = gatewayPresets ? [...new Set([...applied, ...gatewayPresets])] : applied;
19851993

19861994
const presetArg = options.preset;
19871995
let answer = null;
@@ -1995,8 +2003,11 @@ async function removeSandboxPolicyUnlocked(
19952003
);
19962004
process.exit(1);
19972005
}
1998-
if (!applied.includes(preset.name)) {
2006+
if (!removable.includes(preset.name)) {
19992007
console.error(` Preset '${preset.name}' is not applied.`);
2008+
if (gatewayPresets === null) {
2009+
console.error(" Could not query the gateway, so only local state was checked.");
2010+
}
20002011
process.exit(1);
20012012
}
20022013
answer = preset.name;
@@ -2009,7 +2020,7 @@ async function removeSandboxPolicyUnlocked(
20092020
exitPromptStdinClosed(usage);
20102021
}
20112022
answer = await pickPresetOrExit(
2012-
() => policies.selectForRemoval(allPresets, { applied }),
2023+
() => policies.selectForRemoval(allPresets, { applied: removable }),
20132024
usage,
20142025
);
20152026
}

src/lib/policy/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2131,6 +2131,16 @@ function applyPresetContent(
21312131
`re-onboard the sandbox, then re-apply.`,
21322132
);
21332133
return false;
2134+
} else {
2135+
// A built-in preset stays discoverable from the gateway, so the mutation
2136+
// stands. Name the gap anyway: silence here is what leaves an operator
2137+
// holding egress that no local state explains. (#9295)
2138+
console.error(
2139+
` Warning: '${presetName}' was applied to the gateway but could not be ` +
2140+
`recorded locally because sandbox '${sandboxName}' is not in the ` +
2141+
`registry, so policy list will report it as active on gateway, missing ` +
2142+
`from local state.`,
2143+
);
21342144
}
21352145

21362146
return true;

test/policies.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,11 +737,12 @@ exit 1
737737
});
738738
});
739739

740-
describe("policy-add --from-file false success when the sandbox is absent from the registry (#4510)", () => {
740+
describe("policy-add when the sandbox is absent from the registry (#4510, #9295)", () => {
741741
const registryModule = requireForTest(
742742
path.join(REPO_ROOT, "src", "lib", "state", "registry.ts"),
743743
) as Record<string, any>;
744744
const CUSTOM_CONTENT = "network_policies:\n slack-files-upload:\n host: files.slack.com\n";
745+
const BUILTIN_CONTENT = "network_policies:\n github:\n host: github.com\n";
745746
const SOURCE_PATH = "/tmp/slack-files-upload-case.yaml";
746747

747748
let tmpHome: string;
@@ -750,6 +751,7 @@ exit 1
750751
let resolveSpy: ReturnType<typeof vi.spyOn>;
751752
let savedGetSandbox: any;
752753
let savedAddCustomPolicy: any;
754+
let savedUpdateSandbox: any;
753755

754756
beforeEach(() => {
755757
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-issue4510-"));
@@ -768,6 +770,7 @@ exit 1
768770
.mockReturnValue(fakeOpenshell);
769771
savedGetSandbox = registryModule.getSandbox;
770772
savedAddCustomPolicy = registryModule.addCustomPolicy;
773+
savedUpdateSandbox = registryModule.updateSandbox;
771774
});
772775

773776
afterEach(() => {
@@ -776,6 +779,7 @@ exit 1
776779
resolveSpy.mockRestore();
777780
registryModule.getSandbox = savedGetSandbox;
778781
registryModule.addCustomPolicy = savedAddCustomPolicy;
782+
registryModule.updateSandbox = savedUpdateSandbox;
779783
fs.rmSync(tmpHome, { recursive: true, force: true });
780784
});
781785

@@ -811,6 +815,32 @@ exit 1
811815
}
812816
});
813817

818+
it("warns but keeps the mutation when a built-in preset cannot be recorded locally (#9295)", () => {
819+
registryModule.getSandbox = () => null;
820+
const updateSpy = vi.fn(() => true);
821+
registryModule.updateSandbox = updateSpy;
822+
const errors: string[] = [];
823+
const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
824+
errors.push(a.map((x) => String(x)).join(" "));
825+
});
826+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
827+
try {
828+
// A built-in preset stays discoverable from the gateway, so the applied
829+
// policy stands. The warning is what tells the operator why policy list
830+
// will report it without local state behind it.
831+
const result = policies.applyPresetContent("my-assistant", "github", BUILTIN_CONTENT, {});
832+
expect(result).toBe(true);
833+
expect(updateSpy).not.toHaveBeenCalled();
834+
const combined = errors.join("\n");
835+
expect(combined).toContain("my-assistant");
836+
expect(combined).toMatch(/could not be\s+recorded locally/);
837+
expect(combined).toMatch(/active on gateway, missing\s+from local state/);
838+
} finally {
839+
errSpy.mockRestore();
840+
logSpy.mockRestore();
841+
}
842+
});
843+
814844
it("records the custom preset and returns true when the sandbox is registered", () => {
815845
registryModule.getSandbox = (name: string) => ({ name });
816846
const addSpy = vi.fn(() => true);

0 commit comments

Comments
 (0)