diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index e45a64e..e4a4126 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -32,6 +32,26 @@ interface AppSettings { }; } +type WindowsNodeMxcAmbientAccessCategory = + | "desktop" + | "documents" + | "downloads" + | "microclaw-state" + | "openclaw-state" + | "openclaw-roaming-state" + | "ssh" + | "gnupg" + | "aws" + | "azure" + | "kube" + | "gcloud" + | "windows-credentials" + | "powershell-history" + | "browser-chrome" + | "browser-edge" + | "browser-brave" + | "browser-firefox"; + interface ChatAttachment { type: "image" | "file"; mimeType: string; @@ -256,7 +276,11 @@ interface OpenClawAPI { onExecCommand?(callback: (data: { shell: string; command: string }) => void): () => void; }; cron: { - list(): Promise<{ jobs?: unknown[] }>; + list(): Promise<{ + jobs?: unknown[]; + pausedByMxc?: boolean; + requestedMode?: "off" | "full" | "limited-dacl"; + }>; }; agents: { list(): Promise<{ agents?: { id: string; name: string; description?: string }[] }>; @@ -276,6 +300,9 @@ interface OpenClawAPI { weixin: { getStatus(): Promise<{ enabled: boolean; + requestedEnabled: boolean; + effectiveEnabled: boolean; + pausedByMxc: boolean; installed: boolean; loggedIn: boolean; loginInProgress: boolean; @@ -348,8 +375,24 @@ interface OpenClawAPI { }; windowsNodeMxc: { getStatus(): Promise<{ + requestedMode: "off" | "full" | "limited-dacl"; + effectiveStrength: "off" | "full" | "limited-dacl"; + effectiveTier: string | null; desiredEnabled: boolean; effectiveEnabled: boolean; + hooks: { + requestedExternalEnabled: boolean; + requestedInternalEnabled: boolean; + effectiveExternalEnabled: boolean; + effectiveInternalEnabled: boolean; + pausedByMxc: boolean; + }; + canvas: { + state: "off" | "paused" | "active"; + commandCount: 8; + nativeA2uiOnly: true; + localFilesAllowed: false; + }; lifecycleState: { phase: | "idle" @@ -359,6 +402,7 @@ interface OpenClawAPI { | "starting-locked" | "smoking-locked" | "starting-active" + | "promoting-active" | "smoking-active" | "verifying-active" | "starting-standard" @@ -440,24 +484,57 @@ interface OpenClawAPI { needsDaclAugmentation: boolean; degraded: boolean; warnings: string[]; + probes: { + baseContainerApiPresent: boolean | null; + bfscfgPresent: boolean | null; + bfsCompiledIn: boolean | null; + }; reason: string | null; }; + fullAvailability: { + available: boolean; + summary: string | null; + blockers: string[]; + remediation: string[]; + }; + limitedAvailability: { + available: boolean; + summary: string | null; + blockers: string[]; + remediation: string[]; + }; + ambientAccess: { + contract: "microclaw.windows-node-mxc-ambient-access.v1"; + hash: string; + checkedAt: string; + entries: Array<{ + category: WindowsNodeMxcAmbientAccessCategory; + status: "denied" | "read" | "read-write"; + }>; + } | null; smoke: { + profile: "full-smoke" | "limited-minimal-smoke"; gatewayGeneration: string; nodeId: string; settingsFingerprint: string; probeTier: string; + containmentStrength: "full" | "limited-dacl"; + ambientAccessHash: string | null; checkedAt: string; - hostname: { outcome: string; reason: string }; - powershell: { outcome: string; reason: string }; - deniedOutsideRoot: { outcome: string; reason: string }; + execution: { outcome: string; reason: string }; + deniedOutsideRoot: { outcome: string; reason: string } | null; } | null; blockers: string[]; warnings: string[]; remediation: string[]; }>; + preflight(params?: { + requestedMode?: "off" | "full" | "limited-dacl"; + }): Promise; setEnabled(params: { - enabled: boolean; + enabled?: boolean; + requestedMode?: "off" | "full" | "limited-dacl"; + acknowledgeLimitedFilesystem?: boolean; nodeId?: string; }): Promise>>; validateFolderPolicy(draft: { diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index 65f8540..60cc688 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -8,10 +8,13 @@ :status="gateway.status" :connected="chatStore.wsConnected" :warming="gateway.warming" - :errorMessage="gateway.lastError" - :mxcDesired="windowsNodeMxcStatus?.desiredEnabled ?? false" - :mxcPhase="windowsNodeMxcStatus?.lifecycleState.phase" - :mxcDetail="windowsNodeMxcStatus?.lifecycleState.detail" + :error-message="gateway.lastError" + :mxc-desired="windowsNodeMxcStatus?.requestedMode !== 'off'" + :mxc-phase="windowsNodeMxcStatus?.lifecycleState.phase" + :mxc-detail="windowsNodeMxcStatus?.lifecycleState.detail" + :mxc-requested-mode="windowsNodeMxcStatus?.requestedMode" + :mxc-effective-strength="windowsNodeMxcStatus?.effectiveStrength" + :mxc-effective-tier="windowsNodeMxcStatus?.effectiveTier" @retry="handleRetry" @disable-mxc="disableWindowsNodeMxc" /> @@ -239,7 +242,7 @@ async function handleRetry() { if (windowsNodeMxcStatus.value?.desiredEnabled) { try { windowsNodeMxcStatus.value = await window.openclaw.windowsNodeMxc.setEnabled({ - enabled: true, + requestedMode: windowsNodeMxcStatus.value.requestedMode, }); } catch (error) { gateway.addLog( @@ -256,7 +259,7 @@ async function disableWindowsNodeMxc() { chatStore.wsConnected = false; try { windowsNodeMxcStatus.value = await window.openclaw.windowsNodeMxc.setEnabled({ - enabled: false, + requestedMode: "off", }); } catch (error) { gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`); @@ -280,6 +283,8 @@ interface PermissionRequestData { source?: "sandbox" | "windows-node-mxc"; allowedDecisions?: Array<"deny" | "allow-once" | "allow-always">; declaredAccess?: Array<{ access: "ro" | "rw"; path: string }>; + containmentStrength?: "full" | "limited-dacl"; + ambientAccess?: NonNullable["entries"]; } const permissionQueue = ref([]); const currentPermission = computed(() => @@ -290,7 +295,7 @@ type WindowsNodeMxcApproval = NonNullable< Awaited> >; -function enqueueWindowsNodeMxcApproval(request: WindowsNodeMxcApproval | null) { +async function enqueueWindowsNodeMxcApproval(request: WindowsNodeMxcApproval | null) { if (!request) { permissionQueue.value = permissionQueue.value.filter( (pending) => pending.source !== "windows-node-mxc", @@ -305,6 +310,31 @@ function enqueueWindowsNodeMxcApproval(request: WindowsNodeMxcApproval | null) { ) { return; } + if (!windowsNodeMxcStatus.value) { + try { + applyWindowsNodeMxcStatus(await window.openclaw.windowsNodeMxc.getStatus()); + } catch (error) { + gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`); + } + } + if ( + permissionQueue.value.some( + (pending) => + pending.source === "windows-node-mxc" && pending.requestId === request.id, + ) + ) { + return; + } + const containmentStrength = + windowsNodeMxcStatus.value && + windowsNodeMxcStatus.value.effectiveStrength !== "limited-dacl" && + windowsNodeMxcStatus.value.requestedMode !== "limited-dacl" + ? "full" + : "limited-dacl"; + const allowedDecisions = + containmentStrength === "limited-dacl" + ? request.allowedDecisions.filter((decision) => decision !== "allow-always") + : request.allowedDecisions; const command = request.commandText ?? [request.executable, ...request.arguments].join(" "); permissionQueue.value.push({ requestId: request.id, @@ -314,10 +344,20 @@ function enqueueWindowsNodeMxcApproval(request: WindowsNodeMxcApproval | null) { command, declaredAccess: request.declaredAccess, source: "windows-node-mxc", - allowedDecisions: request.allowedDecisions, + containmentStrength, + ambientAccess: + containmentStrength === "limited-dacl" + ? (windowsNodeMxcStatus.value?.ambientAccess?.entries ?? []) + : undefined, + allowedDecisions, }); if (chatStore.streaming) { - chatStore.addPendingToolCall(request.id, "Contained MXC command approval"); + chatStore.addPendingToolCall( + request.id, + containmentStrength === "limited-dacl" + ? "Limited filesystem containment approval" + : "Contained MXC command approval", + ); } } diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 4ecfea9..2001e01 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -74,8 +74,24 @@ export function createBrowserOpenClawMock(): OpenClawAPI { }, windowsNodeMxc: { getStatus: async () => ({ + requestedMode: "off" as const, + effectiveStrength: "off" as const, + effectiveTier: null, desiredEnabled: false, effectiveEnabled: false, + hooks: { + requestedExternalEnabled: false, + requestedInternalEnabled: false, + effectiveExternalEnabled: false, + effectiveInternalEnabled: false, + pausedByMxc: false, + }, + canvas: { + state: "off" as const, + commandCount: 8 as const, + nativeA2uiOnly: true as const, + localFilesAllowed: false as const, + }, lifecycleState: { phase: "idle" as const, detail: null, @@ -113,13 +129,34 @@ export function createBrowserOpenClawMock(): OpenClawAPI { needsDaclAugmentation: false, degraded: false, warnings: [], + probes: { + baseContainerApiPresent: null, + bfscfgPresent: null, + bfsCompiledIn: null, + }, reason: "Unavailable in browser development", }, + fullAvailability: { + available: false, + summary: "Unavailable in browser development", + blockers: ["Unavailable in browser development"], + remediation: [], + }, + limitedAvailability: { + available: false, + summary: "Unavailable in browser development", + blockers: ["Unavailable in browser development"], + remediation: [], + }, + ambientAccess: null, smoke: null, blockers: ["Unavailable in browser development"], warnings: [], remediation: [], }), + preflight: async () => { + throw new Error("Unavailable in browser development"); + }, setEnabled: async () => { throw new Error("Unavailable in browser development"); }, @@ -220,7 +257,7 @@ export function createBrowserOpenClawMock(): OpenClawAPI { onToolEvent: noopSub, onExecCommand: noopSub, }, - cron: { list: async () => ({ jobs: [] }) }, + cron: { list: async () => ({ jobs: [], pausedByMxc: false, requestedMode: "off" as const }) }, channels: { list: async () => ({ channels: [] }) }, model: { selectDefault: async () => { @@ -291,6 +328,9 @@ export function createBrowserOpenClawMock(): OpenClawAPI { weixin: { getStatus: async () => ({ enabled: false, + requestedEnabled: false, + effectiveEnabled: false, + pausedByMxc: false, installed: false, loggedIn: false, loginInProgress: false, diff --git a/desktop/renderer/src/components/GatewayLoading.test.ts b/desktop/renderer/src/components/GatewayLoading.test.ts index 148ef7a..7998136 100644 --- a/desktop/renderer/src/components/GatewayLoading.test.ts +++ b/desktop/renderer/src/components/GatewayLoading.test.ts @@ -22,6 +22,8 @@ describe("GatewayLoading MXC lifecycle", () => { warming: false, mxcDesired: true, mxcPhase: "smoking-locked", + mxcRequestedMode: "limited-dacl", + mxcEffectiveStrength: "off", }, }); @@ -29,9 +31,30 @@ describe("GatewayLoading MXC lifecycle", () => { "Running internal probes in locked generation", ); expect(wrapper.find(".loading-status").text()).not.toContain("Ready"); + expect(wrapper.find(".loading-mxc-strength").text()).toBe( + "Limited containment (DACL) — Starting", + ); + expect(wrapper.find(".loading-mxc-strength").text()).not.toContain("Attested"); expect(wrapper.text()).not.toMatch(/approve|permission|allow once/i); }); + it("shows in-process policy promotion as a guarded startup phase", () => { + const wrapper = shallowMount(GatewayLoading, { + props: { + status: "running", + connected: true, + warming: false, + mxcDesired: true, + mxcPhase: "promoting-active", + }, + }); + + expect(wrapper.find(".loading-status").text()).toContain( + "Promoting verified policy in the current generation", + ); + expect(wrapper.find(".loading-status").text()).not.toContain("Ready"); + }); + it("shows fail-closed recovery without labeling MXC failure as a Gateway outage", async () => { const wrapper = shallowMount(GatewayLoading, { props: { diff --git a/desktop/renderer/src/components/GatewayLoading.vue b/desktop/renderer/src/components/GatewayLoading.vue index c790f44..e66bda9 100644 --- a/desktop/renderer/src/components/GatewayLoading.vue +++ b/desktop/renderer/src/components/GatewayLoading.vue @@ -92,6 +92,9 @@ {{ statusText }} {{ Math.floor(displayProgress) }}% +
+ {{ mxcStrengthText }} +
@@ -158,6 +161,9 @@ const props = defineProps<{ mxcDesired?: boolean; mxcPhase?: string; mxcDetail?: string | null; + mxcRequestedMode?: "off" | "full" | "limited-dacl"; + mxcEffectiveStrength?: "off" | "full" | "limited-dacl"; + mxcEffectiveTier?: string | null; }>(); defineEmits<{ retry: []; "disable-mxc": [] }>(); @@ -177,6 +183,19 @@ const isFailed = computed( () => isMxcFailure.value || props.status === "timeout" || props.status === "failed", ); const displayError = computed(() => props.mxcDetail || props.errorMessage); +const mxcStrengthText = computed(() => { + const mode = + props.mxcRequestedMode === "limited-dacl" + ? t("settings.windowsNodeMxcModeLimited") + : t("settings.windowsNodeMxcModeFull"); + const state = + props.mxcPhase === "active" + ? t("settings.windowsNodeMxcStatusActive") + : isMxcFailure.value + ? t("settings.windowsNodeMxcActionRequired") + : t("settings.windowsNodeMxcStarting"); + return t("settings.windowsNodeMxcCompactStatus", { mode, state }); +}); // ── Window controls ── @@ -207,6 +226,7 @@ const targetProgress = computed(() => { "smoking-locked": 80, "starting-active": 86, "smoking-active": 92, + "promoting-active": 92, "verifying-active": 97, "starting-standard": 88, }; diff --git a/desktop/renderer/src/components/MxcCapabilityConsentDialog.test.ts b/desktop/renderer/src/components/MxcCapabilityConsentDialog.test.ts new file mode 100644 index 0000000..24fb1c4 --- /dev/null +++ b/desktop/renderer/src/components/MxcCapabilityConsentDialog.test.ts @@ -0,0 +1,125 @@ +import { enableAutoUnmount, mount } from "@vue/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { setLocale } from "@/i18n"; +import MxcCapabilityConsentDialog from "./MxcCapabilityConsentDialog.vue"; + +const dialogStub = { + props: [ + "modelValue", + "title", + "beforeClose", + "closeOnClickModal", + "closeOnPressEscape", + "showClose", + ], + emits: ["opened"], + template: + '

{{ title }}

', +}; + +const buttonStub = { + props: ["disabled", "loading"], + emits: ["click"], + template: + '', +}; + +function mountDialog(targetMode: "full" | "limited-dacl" = "full") { + return mount(MxcCapabilityConsentDialog, { + attachTo: document.body, + props: { + modelValue: true, + targetMode, + }, + global: { + stubs: { + "el-dialog": dialogStub, + "el-button": buttonStub, + }, + }, + }); +} + +enableAutoUnmount(afterEach); +afterEach(() => setLocale("en-US")); + +describe("MxcCapabilityConsentDialog", () => { + it("renders the versioned policy groups and Full boundary in English", () => { + setLocale("en-US"); + const wrapper = mountDialog("full"); + + expect(wrapper.find(".stub-title").text()).toBe("Enable Full containment?"); + expect(wrapper.find('[data-testid="mxc-consent-group-disabled"]').text()).toContain( + "read, write, edit, and apply_patch", + ); + expect(wrapper.find('[data-testid="mxc-consent-group-disabled"]').text()).toContain( + "default Gateway memory plugin", + ); + expect(wrapper.find('[data-testid="mxc-consent-group-paused"]').text()).toContain( + "no catch-up", + ); + expect(wrapper.find('[data-testid="mxc-consent-group-paused"]').text()).toContain( + "return 503", + ); + expect(wrapper.find('[data-testid="mxc-consent-group-available-restricted"]').text()).toContain( + "Canvas Phase 1", + ); + expect(wrapper.text()).toContain("backend preflight can still fail"); + expect(wrapper.text()).toContain("asks again for every explicit enable or switch"); + }); + + it("renders natural Limited disclosure and retained acknowledgement in Chinese", () => { + setLocale("zh-CN"); + const wrapper = mountDialog("limited-dacl"); + + expect(wrapper.find(".stub-title").text()).toBe("启用有限隔离(DACL)?"); + expect(wrapper.text()).toContain("暂停 Cron 和计划任务"); + expect(wrapper.text()).toContain("仅限 HTTP(S) 的 web_fetch"); + expect(wrapper.text()).toContain("叠加 DACL 授权"); + expect(wrapper.text()).toContain("仍必须完成单独的有限模式确认"); + expect(wrapper.find('[data-testid="mxc-consent-accept"]').text()).toBe("启用 MXC"); + }); + + it("emits accept only once across duplicate clicks", async () => { + const wrapper = mountDialog(); + const accept = wrapper.find('[data-testid="mxc-consent-accept"]'); + + await accept.trigger("click"); + await accept.trigger("click"); + + expect(wrapper.emitted("accept")).toHaveLength(1); + expect(accept.attributes("disabled")).toBeDefined(); + }); + + it("focuses Enable MXC when the dialog opens", async () => { + const wrapper = mountDialog(); + wrapper.findComponent(dialogStub).vm.$emit("opened"); + await wrapper.vm.$nextTick(); + + expect(document.activeElement).toBe( + wrapper.find('[data-testid="mxc-consent-accept"]').element, + ); + }); + + it("cancels from the button and close, Escape, or backdrop close path", async () => { + const wrapper = mountDialog(); + await wrapper.find('[data-testid="mxc-consent-cancel"]').trigger("click"); + expect(wrapper.emitted("cancel")).toHaveLength(1); + expect(wrapper.emitted("update:modelValue")).toEqual([[false]]); + + const closeWrapper = mountDialog(); + const beforeClose = closeWrapper.findComponent(dialogStub).props("beforeClose") as ( + done: () => void, + ) => void; + let closed = false; + beforeClose(() => { + closed = true; + }); + + expect(closed).toBe(true); + expect(closeWrapper.emitted("cancel")).toHaveLength(1); + expect(closeWrapper.findComponent(dialogStub).props("closeOnClickModal")).toBe(true); + expect(closeWrapper.findComponent(dialogStub).props("closeOnPressEscape")).toBe(true); + expect(closeWrapper.findComponent(dialogStub).props("showClose")).toBe(true); + }); +}); diff --git a/desktop/renderer/src/components/MxcCapabilityConsentDialog.vue b/desktop/renderer/src/components/MxcCapabilityConsentDialog.vue new file mode 100644 index 0000000..52290a1 --- /dev/null +++ b/desktop/renderer/src/components/MxcCapabilityConsentDialog.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/desktop/renderer/src/components/PermissionDialog.test.ts b/desktop/renderer/src/components/PermissionDialog.test.ts index c29714e..933b05f 100644 --- a/desktop/renderer/src/components/PermissionDialog.test.ts +++ b/desktop/renderer/src/components/PermissionDialog.test.ts @@ -112,6 +112,50 @@ describe("PermissionDialog", () => { ]); }); + it("labels Limited filesystem containment, summarizes ambient access, and omits Allow always", () => { + const wrapper = mount(PermissionDialog, { + props: { + request: { + requestId: "mxc-limited-request", + type: "mxc-approval", + command: "Get-ChildItem", + dirPath: "isolated-scratch:v1", + containmentStrength: "limited-dacl", + declaredAccess: [{ access: "ro", path: String.raw`C:\Work` }], + ambientAccess: [ + { category: "desktop", status: "read-write" }, + { category: "ssh", status: "denied" }, + ], + allowedDecisions: ["deny", "allow-once", "allow-always"], + }, + }, + }); + + expect(wrapper.find(".perm-title").text()).toBe("Limited filesystem containment"); + expect(wrapper.find(".perm-subtitle").text()).toContain( + "Limited filesystem containment", + ); + expect(wrapper.findAll(".perm-body")[0].text()).toBe( + "AI wants to run this command with Limited filesystem containment.", + ); + expect(wrapper.find(".perm-command-label").text()).toBe( + "Additional filesystem grants", + ); + expect(wrapper.findAll(".perm-mxc-ambient").map((entry) => entry.text())).toEqual([ + "DesktopRead/write", + "SSHDenied", + ]); + expect(wrapper.text()).toContain("absolute paths are not displayed"); + expect(wrapper.findAll(".perm-actions button").map((button) => button.text())).toEqual([ + "Deny", + "Allow once", + ]); + expect(wrapper.text()).not.toContain("Allow always"); + expect(wrapper.findAll(".perm-body")[1].text()).toContain( + "additive, not an exclusive allowlist", + ); + }); + it("keeps high-risk actions visible without optional semantic color tokens", () => { const globalStyles = readFileSync(resolve(process.cwd(), "src/styles/global.css"), "utf8"); diff --git a/desktop/renderer/src/components/PermissionDialog.vue b/desktop/renderer/src/components/PermissionDialog.vue index 762f1a8..d05a541 100644 --- a/desktop/renderer/src/components/PermissionDialog.vue +++ b/desktop/renderer/src/components/PermissionDialog.vue @@ -19,6 +19,11 @@ interface PermissionRequest { accessNeeded?: string; allowedDecisions?: Array<"deny" | "allow-once" | "allow-always">; declaredAccess?: Array<{ access: "ro" | "rw"; path: string }>; + containmentStrength?: "full" | "limited-dacl"; + ambientAccess?: Array<{ + category: WindowsNodeMxcAmbientAccessCategory; + status: "denied" | "read" | "read-write"; + }>; } const props = defineProps<{ @@ -91,17 +96,25 @@ const commandHtml = computed(() => { }); const isMxcApproval = computed(() => props.request?.type === "mxc-approval"); +const isLimitedMxcApproval = computed( + () => isMxcApproval.value && props.request?.containmentStrength === "limited-dacl", +); const isAttendedApproval = computed( () => props.request?.type === "app-approval" || isMxcApproval.value, ); const declaredAccess = computed(() => props.request?.declaredAccess ?? []); +const ambientAccess = computed(() => props.request?.ambientAccess ?? []); const allowsOnce = computed( () => !props.request?.allowedDecisions || props.request.allowedDecisions.includes("allow-once"), ); const allowsAlways = computed( - () => !props.request?.allowedDecisions || props.request.allowedDecisions.includes("allow-always"), + () => + !isLimitedMxcApproval.value && + (!props.request?.allowedDecisions || + props.request.allowedDecisions.includes("allow-always")), ); const mxcScopeText = computed(() => { + if (isLimitedMxcApproval.value) return t("perm.mxcLimitedScope"); if (declaredAccess.value.length > 0) { return allowsAlways.value ? t("perm.mxcScope") : t("perm.mxcScopeOnce"); } @@ -127,6 +140,7 @@ const dialogIcon = computed(() => { }); const dialogTitle = computed(() => { if (!props.request) return t("perm.title"); + if (isLimitedMxcApproval.value) return t("perm.titleMxcLimited"); if (isMxcApproval.value) return t("perm.titleMxc"); if (props.request.type === "app-approval") return t("perm.titleApp"); if (isShellRequest.value) return t("perm.titleCommand"); @@ -134,6 +148,7 @@ const dialogTitle = computed(() => { }); const riskHint = computed(() => { if (!props.request) return ""; + if (isLimitedMxcApproval.value) return t("perm.riskMxcLimited"); if (isMxcApproval.value) return t("perm.riskMxc"); if (props.request.type === "app-approval") return t("perm.riskApp"); if (isShellRequest.value) @@ -154,6 +169,14 @@ const grantLabel = computed(() => function respond(decision: string) { emit("respond", decision); } + +function ambientCategoryLabel(category: NonNullable[number]["category"]) { + return t(`settings.windowsNodeMxcAmbientCategory.${category}`); +} + +function ambientStatusLabel(status: NonNullable[number]["status"]) { + return t(`settings.windowsNodeMxcAmbientStatus.${status}`); +} @@ -1333,6 +1658,8 @@ import microclawLogo from "../../../assets/microclaw.png"; import { getLocale, t, setLocale } from "@/i18n"; import type { Locale } from "@/i18n"; import ModelSetupDialog from "@/components/ModelSetupDialog.vue"; +import MxcCapabilityConsentDialog from "@/components/MxcCapabilityConsentDialog.vue"; +import type { WindowsNodeMxcDisclosureTargetMode } from "../../../src/windows-node-mxc-disclosure"; import LocalModelSetupDialog from "@/components/LocalModelSetupDialog.vue"; import LocalModelDownloadDialog from "@/components/LocalModelDownloadDialog.vue"; import SessionModelRecoveryNotice from "@/components/SessionModelRecoveryNotice.vue"; @@ -1457,10 +1784,19 @@ const sandboxCapabilities = ref([]); const capsRestarting = ref(false); const sandboxRestarting = ref(false); type WindowsNodeMxcStatus = Awaited>; +type WindowsNodeMxcMode = WindowsNodeMxcStatus["requestedMode"]; +type WindowsNodeMxcAmbientEntry = NonNullable< + WindowsNodeMxcStatus["ambientAccess"] +>["entries"][number]; const windowsNodeMxcStatus = ref(null); const windowsNodeMxcApplying = ref(false); const windowsNodeMxcFolderApplying = ref(false); const windowsNodeMxcTechnicalDetailsOpen = ref(false); +const windowsNodeMxcConsentVisible = ref(false); +const windowsNodeMxcConsentTarget = ref(null); +const windowsNodeMxcConsentSubmitting = ref(false); +const windowsNodeMxcLimitedAcknowledgementVisible = ref(false); +const windowsNodeMxcLimitedAcknowledged = ref(false); const windowsNodeMxcTransitionPhases = new Set([ "locking", "validating", @@ -1469,14 +1805,20 @@ const windowsNodeMxcTransitionPhases = new Set([ "smoking-locked", "starting-active", "smoking-active", + "promoting-active", "verifying-active", "starting-standard", ]); const windowsNodeMxcProtectionLabel = computed(() => { const status = windowsNodeMxcStatus.value; - if (!status?.desiredEnabled) return t("settings.windowsNodeMxcProtectionOff"); + if (!status || status.requestedMode === "off") { + return t("settings.windowsNodeMxcProtectionOff"); + } if (status.effectiveEnabled && status.lifecycleState.phase === "active") { + if (status.effectiveStrength === "limited-dacl") { + return t("settings.windowsNodeMxcLimitedActive"); + } return t("settings.windowsNodeMxcProtected"); } if (windowsNodeMxcTransitionPhases.has(status.lifecycleState.phase)) { @@ -1490,13 +1832,14 @@ const windowsNodeMxcProtectionClass = computed(() => { if (status?.effectiveEnabled && status.lifecycleState.phase === "active") { return "mxc-state-ok"; } - return status?.desiredEnabled ? "mxc-state-blocked" : "mxc-state-neutral"; + return status?.requestedMode !== "off" ? "mxc-state-blocked" : "mxc-state-neutral"; }); const windowsNodeMxcProtectionDetail = computed(() => { const status = windowsNodeMxcStatus.value; if ( - !status?.desiredEnabled || + !status || + status.requestedMode === "off" || (status.effectiveEnabled && status.lifecycleState.phase === "active") ) { return ""; @@ -1505,6 +1848,124 @@ const windowsNodeMxcProtectionDetail = computed(() => { return status.lifecycleState.detail ? `${phase} · ${status.lifecycleState.detail}` : phase; }); +const windowsNodeMxcHooksPausedSummary = computed(() => { + const hooks = windowsNodeMxcStatus.value?.hooks; + if (!hooks?.pausedByMxc) return ""; + if (hooks.requestedExternalEnabled && hooks.requestedInternalEnabled) { + return t("settings.windowsNodeMxcHooksPausedBoth"); + } + return hooks.requestedExternalEnabled + ? t("settings.windowsNodeMxcHooksPausedExternal") + : t("settings.windowsNodeMxcHooksPausedInternal"); +}); + +const windowsNodeMxcLimitedMode = computed(() => { + const status = windowsNodeMxcStatus.value; + return status?.requestedMode === "limited-dacl" || status?.effectiveStrength === "limited-dacl"; +}); + +const windowsNodeMxcRequestedModeLabel = computed(() => + windowsNodeMxcModeLabel(windowsNodeMxcStatus.value?.requestedMode ?? "off"), +); + +const windowsNodeMxcEffectiveStrengthLabel = computed(() => + windowsNodeMxcModeLabel(windowsNodeMxcStatus.value?.effectiveStrength ?? "off"), +); + +const windowsNodeMxcCompactStatus = computed(() => { + const status = windowsNodeMxcStatus.value; + if (!status || status.requestedMode === "off") { + return t("settings.windowsNodeMxcCompactOff"); + } + const mode = + status.effectiveEnabled && status.lifecycleState.phase === "active" + ? windowsNodeMxcModeLabel(status.effectiveStrength) + : windowsNodeMxcModeLabel(status.requestedMode); + const state = + status.effectiveEnabled && status.lifecycleState.phase === "active" + ? t("settings.windowsNodeMxcStatusActive") + : windowsNodeMxcTransitionPhases.has(status.lifecycleState.phase) + ? t("settings.windowsNodeMxcStarting") + : t("settings.windowsNodeMxcActionRequired"); + return t("settings.windowsNodeMxcCompactStatus", { mode, state }); +}); + +const windowsNodeMxcAmbientEntries = computed( + () => windowsNodeMxcStatus.value?.ambientAccess?.entries ?? [], +); + +const windowsNodeMxcAmbientExceptions = computed(() => + windowsNodeMxcAmbientEntries.value.filter((entry) => entry.status !== "denied"), +); + +const windowsNodeMxcAmbientSummary = computed(() => { + const entries = windowsNodeMxcAmbientEntries.value; + if (entries.length === 0) { + return t("settings.windowsNodeMxcAmbientUnavailable"); + } + const denied = entries.length - windowsNodeMxcAmbientExceptions.value.length; + if (windowsNodeMxcAmbientExceptions.value.length === 0) { + return t("settings.windowsNodeMxcAmbientAllDenied", { + denied, + total: entries.length, + }); + } + const details = windowsNodeMxcAmbientExceptions.value + .map( + (entry) => + `${windowsNodeMxcAmbientCategoryLabel(entry.category)} — ${windowsNodeMxcAmbientStatusLabel(entry.status)}`, + ) + .join(", "); + return t("settings.windowsNodeMxcAmbientExceptions", { + details, + denied, + total: entries.length, + }); +}); + +const windowsNodeMxcAmbientSummaryClass = computed(() => { + if (windowsNodeMxcAmbientEntries.value.length === 0) { + return "mxc-summary-warning"; + } + return windowsNodeMxcAmbientExceptions.value.length === 0 + ? "mxc-summary-ok" + : "mxc-summary-error"; +}); + +const windowsNodeMxcFolderTitle = computed(() => { + if (windowsNodeMxcLimitedMode.value) { + return t("settings.windowsNodeMxcLimitedFolders"); + } + if (windowsNodeMxcStatus.value?.requestedMode === "off") { + return t("settings.windowsNodeMxcConfiguredFolders"); + } + return t("settings.windowsNodeMxcFullFolders"); +}); + +const windowsNodeMxcFolderHint = computed(() => { + if (windowsNodeMxcLimitedMode.value) { + return t("settings.windowsNodeMxcLimitedFoldersHint"); + } + if (windowsNodeMxcStatus.value?.requestedMode === "off") { + return t("settings.windowsNodeMxcConfiguredFoldersHint"); + } + return t("settings.windowsNodeMxcFoldersHint"); +}); + +function windowsNodeMxcModeLabel(mode: WindowsNodeMxcMode) { + if (mode === "full") return t("settings.windowsNodeMxcModeFull"); + if (mode === "limited-dacl") return t("settings.windowsNodeMxcModeLimited"); + return t("settings.windowsNodeMxcModeOff"); +} + +function windowsNodeMxcAmbientCategoryLabel(category: WindowsNodeMxcAmbientEntry["category"]) { + return t(`settings.windowsNodeMxcAmbientCategory.${category}`); +} + +function windowsNodeMxcAmbientStatusLabel(status: WindowsNodeMxcAmbientEntry["status"]) { + return t(`settings.windowsNodeMxcAmbientStatus.${status}`); +} + const windowsNodeMxcFolderDraftDirty = computed( () => policyIdentity(windowsNodeMxcFolderDraft) !== policyIdentity(windowsNodeMxcFolderBaseline), ); @@ -1549,12 +2010,71 @@ async function loadWindowsNodeMxcStatus() { } } -async function toggleWindowsNodeMxc(enabled: boolean) { +function selectWindowsNodeMxcMode(mode: WindowsNodeMxcMode) { + if ( + windowsNodeMxcApplying.value || + windowsNodeMxcConsentVisible.value || + windowsNodeMxcLimitedAcknowledgementVisible.value || + windowsNodeMxcStatus.value?.requestedMode === mode + ) { + return; + } + if (mode === "off") { + void applyWindowsNodeMxcMode(mode); + return; + } + windowsNodeMxcConsentTarget.value = mode; + windowsNodeMxcConsentVisible.value = true; +} + +function cancelWindowsNodeMxcCapabilityConsent() { + if (windowsNodeMxcConsentSubmitting.value) return; + windowsNodeMxcConsentVisible.value = false; + windowsNodeMxcConsentTarget.value = null; +} + +async function acceptWindowsNodeMxcCapabilityConsent() { + const requestedMode = windowsNodeMxcConsentTarget.value; + if (!requestedMode || windowsNodeMxcConsentSubmitting.value) return; + + windowsNodeMxcConsentSubmitting.value = true; + windowsNodeMxcConsentVisible.value = false; + windowsNodeMxcConsentTarget.value = null; + try { + if (requestedMode === "limited-dacl") { + windowsNodeMxcLimitedAcknowledged.value = false; + windowsNodeMxcLimitedAcknowledgementVisible.value = true; + return; + } + await applyWindowsNodeMxcMode(requestedMode); + } finally { + windowsNodeMxcConsentSubmitting.value = false; + } +} + +async function confirmWindowsNodeMxcLimitedMode() { + if (!windowsNodeMxcLimitedAcknowledged.value || windowsNodeMxcApplying.value) return; + await applyWindowsNodeMxcMode("limited-dacl", true); + if (windowsNodeMxcStatus.value?.requestedMode === "limited-dacl") { + windowsNodeMxcLimitedAcknowledgementVisible.value = false; + } +} + +async function applyWindowsNodeMxcMode( + requestedMode: WindowsNodeMxcMode, + acknowledgeLimitedFilesystem = false, +) { windowsNodeMxcApplying.value = true; - gateway.resetReady(); - chatStore.wsConnected = false; try { - const status = await window.openclaw.windowsNodeMxc.setEnabled({ enabled }); + if (requestedMode !== "off") { + await window.openclaw.windowsNodeMxc.preflight({ requestedMode }); + } + gateway.resetReady(); + chatStore.wsConnected = false; + const status = await window.openclaw.windowsNodeMxc.setEnabled({ + requestedMode, + ...(requestedMode === "limited-dacl" ? { acknowledgeLimitedFilesystem } : {}), + }); updateWindowsNodeMxcStatus(status); await loadSandboxStatus(); } catch (error) { @@ -1721,7 +2241,7 @@ function showWindowsNodeMxcFolderPolicyError(reason: string) { async function addWindowsNodeMxcFolder(access: "rw" | "ro") { try { - if (!windowsNodeMxcStatus.value?.desiredEnabled) { + if (windowsNodeMxcStatus.value?.requestedMode === "off") { await addSandboxDir(access, "windows-node-mxc"); await loadWindowsNodeMxcStatus(); return; @@ -1751,7 +2271,7 @@ async function addWindowsNodeMxcFolder(access: "rw" | "ro") { async function changeWindowsNodeMxcFolderAccess(dir: string, access: "rw" | "ro") { try { - if (!windowsNodeMxcStatus.value?.desiredEnabled) { + if (windowsNodeMxcStatus.value?.requestedMode === "off") { const result = await window.openclaw.sandbox.setUserDirAccess({ dir, access }); if (!result.ok) showWindowsNodeMxcFolderPolicyError(result.reason ?? ""); await Promise.all([loadSandboxStatus(), loadWindowsNodeMxcStatus()]); @@ -1772,7 +2292,7 @@ async function changeWindowsNodeMxcFolderAccess(dir: string, access: "rw" | "ro" async function removeWindowsNodeMxcFolder(dir: string, access: "rw" | "ro") { try { - if (!windowsNodeMxcStatus.value?.desiredEnabled) { + if (windowsNodeMxcStatus.value?.requestedMode === "off") { await removeSandboxDir(dir, access); await loadWindowsNodeMxcStatus(); return; @@ -3529,11 +4049,81 @@ async function clearChatHistory() { .mxc-card { border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border)); } +.mxc-heading { + padding: 16px 16px 12px; + border-bottom: 1px solid var(--border-row); +} .mxc-subtitle { margin-top: 3px; color: var(--text-muted); font-size: 12px; } +.mxc-mode-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-row); +} +.mxc-mode-card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg-secondary); +} +.mxc-mode-card.selected { + border-color: color-mix(in srgb, var(--accent-color) 70%, var(--border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 35%, transparent); +} +.mxc-mode-card-limited { + border-color: rgba(230, 162, 60, 0.45); +} +.mxc-mode-card-off { + border-color: color-mix(in srgb, var(--settings-action-danger) 35%, var(--border)); +} +.mxc-mode-card-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} +.mxc-mode-card p, +.mxc-availability-list { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.mxc-mode-card .el-button { + align-self: flex-start; + margin-top: auto; +} +.mxc-mode-badge { + padding: 2px 6px; + border-radius: 999px; + background: var(--accent-subtle); + color: var(--accent-color); + font-size: 10px; + font-weight: 700; +} +.mxc-mode-badge-warning { + background: rgba(230, 162, 60, 0.12); + color: #b26a00; +} +.mxc-mode-warning, +.mxc-availability-blocker { + color: var(--settings-action-danger) !important; +} +.mxc-availability-summary { + font-weight: 600; +} +.mxc-availability-list { + padding-left: 18px; +} .mxc-state { font-size: 12px; font-weight: 700; @@ -3554,6 +4144,64 @@ async function clearChatHistory() { line-height: 1.45; overflow-wrap: anywhere; } +.mxc-status-summary { + justify-content: flex-start; +} +.mxc-status-summary .mxc-state { + font-size: 14px; +} +.mxc-ambient-compact { + display: block; + padding: 12px 24px; + font-size: 12px; + font-weight: 600; + line-height: 1.45; +} +.mxc-summary-ok { + color: #388e3c; +} +.mxc-summary-warning { + color: #b26a00; +} +.mxc-summary-error { + color: var(--settings-action-danger); +} +.mxc-ambient-diagnostics { + flex-direction: column; + align-items: stretch; + gap: 8px; +} +.mxc-ambient-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.mxc-ambient-entry { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; +} +.mxc-ambient-status-denied { + color: #4caf50; +} +.mxc-ambient-status-read { + color: #b26a00; +} +.mxc-ambient-status-read-write { + color: var(--settings-action-danger); +} +.mxc-ambient-metadata { + display: grid; + gap: 4px; + color: var(--text-muted); + font-size: 11px; + overflow-wrap: anywhere; +} .mxc-technical-summary { width: 100%; min-height: 48px; @@ -3657,10 +4305,38 @@ async function clearChatHistory() { border-color: rgba(230, 162, 60, 0.45); background: rgba(230, 162, 60, 0.08); } +.mxc-alert-success { + border-color: rgba(103, 194, 58, 0.45); + background: rgba(103, 194, 58, 0.08); +} .mxc-alert-error { border-color: color-mix(in srgb, var(--settings-action-danger) 45%, transparent); background: color-mix(in srgb, var(--settings-action-danger) 8%, transparent); } +.mxc-limited-dialog-warning { + margin: 0 0 16px; + color: var(--text-secondary); + line-height: 1.55; +} +.mxc-limited-checkbox { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--text-primary); + font-size: 13px; + line-height: 1.5; +} +.mxc-limited-checkbox input { + margin-top: 3px; +} +@container (max-width: 760px) { + .mxc-mode-grid { + grid-template-columns: 1fr; + } + .mxc-ambient-grid { + grid-template-columns: 1fr; + } +} .external-apps-list { display: flex; flex-wrap: wrap; diff --git a/desktop/renderer/src/views/TasksView.test.ts b/desktop/renderer/src/views/TasksView.test.ts new file mode 100644 index 0000000..f4a7519 --- /dev/null +++ b/desktop/renderer/src/views/TasksView.test.ts @@ -0,0 +1,57 @@ +import { createPinia, setActivePinia } from "pinia"; +import { flushPromises, shallowMount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "@/i18n"; +import TasksView from "./TasksView.vue"; + +describe("TasksView MXC pause status", () => { + beforeEach(() => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + openclaw: { + chat: { isConnected: vi.fn().mockResolvedValue(true) }, + cron: { + list: vi.fn().mockResolvedValue({ + jobs: [], + pausedByMxc: true, + requestedMode: "full", + }), + }, + }, + }, + }); + }); + + it.each([ + ["en-US", "Scheduled tasks paused by MXC", "without catch-up"], + ["zh-CN", "MXC 已暂停定时任务", "不会补跑"], + ] as const)("shows the persistent pause banner in %s", async (locale, title, detail) => { + setLocale(locale); + const pinia = createPinia(); + setActivePinia(pinia); + const wrapper = shallowMount(TasksView, { + global: { + plugins: [pinia], + stubs: { + ElButton: true, + ElAlert: { + props: ["title", "description"], + template: + '
{{ title }}{{ description }}
', + }, + ElTable: true, + ElTableColumn: true, + ElTag: true, + }, + }, + }); + + await flushPromises(); + + const alert = wrapper.find('[data-test="mxc-pause-alert"]'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain(title); + expect(alert.text()).toContain(detail); + }); +}); diff --git a/desktop/renderer/src/views/TasksView.vue b/desktop/renderer/src/views/TasksView.vue index e7ffddf..8dbd9d2 100644 --- a/desktop/renderer/src/views/TasksView.vue +++ b/desktop/renderer/src/views/TasksView.vue @@ -12,6 +12,16 @@ >
+ +
{{ t("tasks.loadFailed") }}
{{ taskStore.error }}
@@ -39,8 +49,17 @@ @@ -81,6 +100,10 @@ onActivated(() => { margin-bottom: 24px; } +.mxc-pause-alert { + margin-bottom: 20px; +} + .view-header h2 { font-size: 20px; font-weight: 600; diff --git a/desktop/scripts/prepare-windows-node-resources.mjs b/desktop/scripts/prepare-windows-node-resources.mjs index c8515a0..93529fc 100644 --- a/desktop/scripts/prepare-windows-node-resources.mjs +++ b/desktop/scripts/prepare-windows-node-resources.mjs @@ -41,6 +41,7 @@ const expectedPatchedHostPrepSha256 = { }; const expectedWindowsNodeRevision = "fc9add75eda78daf548d80a55ffb64e63b159961"; const expectedMxcHostPrepPatchRevision = "695c2b89c6142090a098ec4484f49aff8157f0b3"; +const expectedMxcSourceRevision = "0e02a720e32416b151dbcb0a00e3d4ac2b891559"; const rid = ridByArch[targetArch]; if (process.platform !== "win32" || !rid) { @@ -97,6 +98,7 @@ if (upstreamRevision.stdout.trim() !== expectedWindowsNodeRevision) { const resourceRoot = path.join(desktopDir, "resources", "windows-node"); const hostOutput = path.join(resourceRoot, "host"); +const canvasHostOutput = path.join(resourceRoot, "canvas-host"); const patchedHostPrepOutput = path.join(resourceRoot, "host-prep"); const mxcOutput = path.join(resourceRoot, "mxc"); rmSync(resourceRoot, { recursive: true, force: true }); @@ -127,6 +129,30 @@ const publish = spawnSync( if (publish.error) throw publish.error; if (publish.status !== 0) process.exit(publish.status ?? 1); +const canvasHostPublish = spawnSync( + "dotnet", + [ + "publish", + path.join(repositoryDir, "windows-node-canvas-host", "MicroClaw.WindowsNodeCanvasHost.csproj"), + "--configuration", + "Release", + "--runtime", + rid, + "--self-contained", + "true", + "--output", + canvasHostOutput, + "-p:DebugType=None", + "-p:ImportDirectoryBuildProps=false", + "-p:ImportDirectoryBuildTargets=false", + "-p:DisableGitVersionTask=true", + "-m:1", + ], + { stdio: "inherit" }, +); +if (canvasHostPublish.error) throw canvasHostPublish.error; +if (canvasHostPublish.status !== 0) process.exit(canvasHostPublish.status ?? 1); + const hostPrepPublish = spawnSync( "dotnet", [ @@ -182,6 +208,12 @@ for (const entry of readdirSync(hostOutput)) { rmSync(path.join(hostOutput, entry), { recursive: true, force: true }); } } +const canvasHostExe = path.join(canvasHostOutput, "microclaw-windows-node-canvas-host.exe"); +if (!existsSync(canvasHostExe)) { + throw new Error(`Canvas host publish did not produce ${canvasHostExe}`); +} +const canvasHostHash = createHash("sha256").update(readFileSync(canvasHostExe)).digest("hex"); +const canvasHostTreeHash = hashDirectoryTree(canvasHostOutput); const patchedHostPrepExe = path.join(patchedHostPrepOutput, "microclaw-mxc-host-prep.exe"); if (!existsSync(patchedHostPrepExe)) { throw new Error(`Patched host-prep publish did not produce ${patchedHostPrepExe}`); @@ -207,9 +239,15 @@ writeFileSync( architecture: targetArch, runtimeIdentifier: rid, mxcVersion: packageJson.version, + mxcSourceRevision: expectedMxcSourceRevision, + mxcProductionBfsCompiledIn: false, + mxcExactBackendProvisioning: "host-base-container-api", wxcExecSha256: actualWxcExecHash, officialWxcHostPrepSha256: actualOfficialWxcHostPrepHash, windowsNodeRevision: expectedWindowsNodeRevision, + canvasContract: "microclaw.windows-node-mxc.canvas.v1", + canvasHostSha256: canvasHostHash, + canvasHostTreeSha256: canvasHostTreeHash, mxcHostPrepPatchRevision: expectedMxcHostPrepPatchRevision, microclawHostPrepSha256: actualPatchedHostPrepHash, microclawHostPrepOperations: ["prepare-system-drive", "unprepare-system-drive"], @@ -222,5 +260,44 @@ writeFileSync( console.log( `[windows-node] staged ${rid}, MXC ${packageJson.version}, ` + `wxc-exec sha256=${actualWxcExecHash}, ` + + `canvas-host sha256=${canvasHostHash}, ` + + `canvas-host-tree sha256=${canvasHostTreeHash}, ` + `microclaw-mxc-host-prep sha256=${actualPatchedHostPrepHash}`, ); + +function hashDirectoryTree(root) { + const files = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + compareOrdinal(left.name, right.name), + )) { + const fullPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Canvas host resource tree contains a link: ${fullPath}`); + } + if (entry.isDirectory()) visit(fullPath); + else if (entry.isFile()) files.push(fullPath); + else throw new Error("Canvas host resource tree contains an unsupported entry"); + } + }; + visit(root); + files.sort((left, right) => + compareOrdinal( + path.relative(root, left).split(path.sep).join("/"), + path.relative(root, right).split(path.sep).join("/"), + ), + ); + const hash = createHash("sha256"); + for (const filePath of files) { + const relative = path.relative(root, filePath).split(path.sep).join("/"); + const bytes = readFileSync(filePath); + hash.update(`${relative}\0${bytes.byteLength}\0`, "utf8"); + hash.update(bytes); + hash.update(Buffer.from([0])); + } + return hash.digest("hex"); +} + +function compareOrdinal(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/desktop/src/bundled-windows-node-host.test.ts b/desktop/src/bundled-windows-node-host.test.ts index ff112d1..b7e6a9f 100644 --- a/desktop/src/bundled-windows-node-host.test.ts +++ b/desktop/src/bundled-windows-node-host.test.ts @@ -1,11 +1,16 @@ import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { BUNDLED_WINDOWS_NODE_CWD_CONTRACT, + BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT, + BUNDLED_WINDOWS_NODE_REVISION, BundledWindowsNodeHost, + MXC_EXACT_BACKEND_PROVISIONING, MXC_HOST_PREP_PATCH_REVISION, + MXC_SDK_SOURCE_REVISION, assertApprovalResponseMatches, assertLoopbackGateway, createBundledWindowsNodeEnvironment, @@ -14,10 +19,13 @@ import { findBundledNodePairRequest, replaceFileWithRetry, revokeActivationLeaseFile, + sha256DirectoryTree, terminateBundledWindowsNodeProcessTree, sensitiveWindowsRoots, validateApprovalRequest, + validateBundledWindowsNodeRuntimeManifest, } from "./bundled-windows-node-host"; +import { WINDOWS_NODE_MXC_NODE_COMMANDS } from "./windows-node-mxc"; vi.mock("electron", () => ({ app: { @@ -26,6 +34,11 @@ vi.mock("electron", () => ({ }, })); +const policyCanvasRoot = mkdtempSync(path.join(os.tmpdir(), "microclaw-policy-canvas-")); +const policyCanvasHostPath = path.join(policyCanvasRoot, "canvas-host.exe"); +writeFileSync(policyCanvasHostPath, "canvas-host"); +afterAll(() => rmSync(policyCanvasRoot, { recursive: true, force: true })); + describe("bundled Windows node host", () => { it("ends bootstrap stdin and binds host lifetime to the Electron owner", () => { const end = vi.fn(); @@ -43,10 +56,78 @@ describe("bundled Windows node host", () => { expect(BUNDLED_WINDOWS_NODE_CWD_CONTRACT).toBe("microclaw.windows-cwd.v1"); }); + it("binds every Canvas host code and resource file into one tree hash", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "microclaw-canvas-tree-")); + try { + mkdirSync(path.join(root, "nested")); + writeFileSync(path.join(root, "host.exe"), "apphost"); + writeFileSync(path.join(root, "nested", "host.dll"), "managed-code"); + const original = sha256DirectoryTree(root); + expect(sha256DirectoryTree(root)).toBe(original); + writeFileSync(path.join(root, "nested", "host.dll"), "changed-code"); + expect(sha256DirectoryTree(root)).not.toBe(original); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("pins the reviewed MXC target-only host-prep patch", () => { expect(MXC_HOST_PREP_PATCH_REVISION).toBe("695c2b89c6142090a098ec4484f49aff8157f0b3"); }); + it("attests the exact MXC package provenance and backend build contract", () => { + expect(() => + validateBundledWindowsNodeRuntimeManifest( + { + architecture: "x64", + mxcVersion: "0.7.0", + mxcSourceRevision: MXC_SDK_SOURCE_REVISION, + mxcProductionBfsCompiledIn: false, + mxcExactBackendProvisioning: MXC_EXACT_BACKEND_PROVISIONING, + wxcExecSha256: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", + windowsNodeRevision: BUNDLED_WINDOWS_NODE_REVISION, + canvasContract: BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT, + canvasHostSha256: "1".repeat(64), + canvasHostTreeSha256: "2".repeat(64), + mxcHostPrepPatchRevision: MXC_HOST_PREP_PATCH_REVISION, + microclawHostPrepOrigin: "microclaw-built", + microclawHostPrepOperations: ["prepare-system-drive", "unprepare-system-drive"], + }, + "x64", + ), + ).not.toThrow(); + }); + + it.each([ + ["version", { mxcVersion: "0.7.1" }], + ["source revision", { mxcSourceRevision: "0".repeat(40) }], + ["BFS build flag", { mxcProductionBfsCompiledIn: true }], + ["backend provisioning contract", { mxcExactBackendProvisioning: "appcontainer-bfs" }], + ["runtime hash", { wxcExecSha256: "0".repeat(64) }], + ])("rejects an MXC runtime manifest with a mismatched %s", (_label, override) => { + expect(() => + validateBundledWindowsNodeRuntimeManifest( + { + architecture: "x64", + mxcVersion: "0.7.0", + mxcSourceRevision: MXC_SDK_SOURCE_REVISION, + mxcProductionBfsCompiledIn: false, + mxcExactBackendProvisioning: MXC_EXACT_BACKEND_PROVISIONING, + wxcExecSha256: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", + windowsNodeRevision: BUNDLED_WINDOWS_NODE_REVISION, + canvasContract: BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT, + canvasHostSha256: "1".repeat(64), + canvasHostTreeSha256: "2".repeat(64), + mxcHostPrepPatchRevision: MXC_HOST_PREP_PATCH_REVISION, + microclawHostPrepOrigin: "microclaw-built", + microclawHostPrepOperations: ["prepare-system-drive", "unprepare-system-drive"], + ...override, + }, + "x64", + ), + ).toThrow("runtime manifest does not match"); + }); + it("terminates the exact bundled node process tree and verifies exit", () => { const run = vi.fn(); const alive = vi.fn().mockReturnValue(false); @@ -112,7 +193,7 @@ describe("bundled Windows node host", () => { }); it("creates a fresh 256-bit proof context bound to policy and node identity", () => { - const host = Object.create(BundledWindowsNodeHost.prototype) as BundledWindowsNodeHost; + const host = createPolicyHost(); const folders = [ { path: String.raw`C:\Users\Alice\Documents`, access: "ro" as const }, { path: String.raw`C:\Users\Alice\Desktop`, access: "rw" as const }, @@ -141,6 +222,63 @@ describe("bundled Windows node host", () => { expect(first.policyFingerprint).toBe(second.policyFingerprint); }); + it("binds requested strength, effective tier, and ambient assessment into policy identity", () => { + const host = createPolicyHost(); + const folders = [{ path: String.raw`C:\Approved`, access: "rw" as const }]; + const args = [ + "generation-1", + "a".repeat(64), + folders, + String.raw`C:\OpenClaw`, + "12345678-1234-4234-8234-123456789abc", + ] as const; + const full = host.createApprovalProofContext(...args, "full", "base-container", null); + const targets = [ + ["desktop", String.raw`C:\Users\Alice\Desktop`], + ["documents", String.raw`C:\Users\Alice\Documents`], + ["downloads", String.raw`C:\Users\Alice\Downloads`], + ["microclaw-state", String.raw`C:\Users\Alice\AppData\Roaming\MicroClaw`], + ["openclaw-state", String.raw`C:\OpenClaw`], + ["openclaw-roaming-state", String.raw`C:\Users\Alice\AppData\Roaming\openclaw`], + ["ssh", String.raw`C:\Users\Alice\.ssh`], + ["gnupg", String.raw`C:\Users\Alice\.gnupg`], + ["aws", String.raw`C:\Users\Alice\.aws`], + ["azure", String.raw`C:\Users\Alice\.azure`], + ["kube", String.raw`C:\Users\Alice\.kube`], + ["gcloud", String.raw`C:\Users\Alice\.config\gcloud`], + ["windows-credentials", String.raw`C:\Users\Alice\AppData\Roaming\Microsoft\Credentials`], + [ + "powershell-history", + String.raw`C:\Users\Alice\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine`, + ], + ["browser-chrome", String.raw`C:\Users\Alice\AppData\Local\Google\Chrome\User Data`], + ["browser-edge", String.raw`C:\Users\Alice\AppData\Local\Microsoft\Edge\User Data`], + [ + "browser-brave", + String.raw`C:\Users\Alice\AppData\Local\BraveSoftware\Brave-Browser\User Data`, + ], + ["browser-firefox", String.raw`C:\Users\Alice\AppData\Roaming\Mozilla\Firefox\Profiles`], + ] as const; + const ambient = { + contract: "microclaw.windows-node-mxc-ambient-access.v1" as const, + hash: "b".repeat(64), + checkedAt: "2026-10-09T00:00:00.000Z", + entries: targets.map(([category]) => ({ + category, + status: category === "downloads" ? ("read-write" as const) : ("denied" as const), + })), + targets: targets.map(([category, path]) => ({ category, path })), + }; + const limited = host.createApprovalProofContext( + ...args, + "limited-dacl", + "appcontainer-dacl", + ambient, + ); + + expect(full.policyFingerprint).not.toBe(limited.policyFingerprint); + }); + it("protects both helper state and the resolved OpenClaw state root", () => { const roots = sensitiveWindowsRoots( "C:\\Users\\Alice\\AppData\\Roaming\\MicroClaw\\windows-node", @@ -259,15 +397,9 @@ describe("bundled Windows node host", () => { ).toBeNull(); }); - it("approves only the exact app-owned system-only node surface", () => { + it("approves only the exact app-owned system and Canvas node surface", () => { const nodeId = "d".repeat(64); - const commands = [ - "system.run", - "system.run.readiness", - "system.run.prepare", - "system.which", - "system.run.cwd-policy", - ]; + const commands = [...WINDOWS_NODE_MXC_NODE_COMMANDS]; expect( findBundledNodePairRequest( { @@ -300,6 +432,15 @@ describe("bundled Windows node host", () => { ).toBe("owned"); }); + function createPolicyHost(): BundledWindowsNodeHost { + const host = Object.create(BundledWindowsNodeHost.prototype) as BundledWindowsNodeHost; + Object.assign(host, { + wxcExecPath: process.execPath, + canvasHostPath: policyCanvasHostPath, + }); + return host; + } + it("binds approval responses to the exact pending request", () => { expect(() => assertApprovalResponseMatches("request-1", "request-1")).not.toThrow(); expect(() => assertApprovalResponseMatches("request-1", "request-2")).toThrow(/does not match/); diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index 062b231..5b910f5 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -1,18 +1,37 @@ import { app } from "electron"; -import { type ChildProcessWithoutNullStreams, execFileSync, spawn } from "node:child_process"; +import { + type ChildProcessWithoutNullStreams, + execFile, + execFileSync, + spawn, +} from "node:child_process"; import * as fs from "node:fs"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { createHash, createHmac, randomBytes } from "node:crypto"; -import { WINDOWS_NODE_MXC_NODE_COMMANDS } from "./windows-node-mxc"; +import { + WINDOWS_NODE_MXC_CANVAS_ACTIONS, + WINDOWS_NODE_MXC_CANVAS_COMMANDS, + WINDOWS_NODE_MXC_NODE_COMMANDS, +} from "./windows-node-mxc"; +import { + validateWindowsNodeMxcAmbientAccessAssessment, + type WindowsNodeMxcAmbientAccessAssessment, + type WindowsNodeMxcAmbientAccessCategory, + type WindowsNodeMxcEffectiveStrength, +} from "./windows-node-mxc-modes"; +import { resolveWindowsNodeMxcWeixinMediaRoots } from "./windows-node-mxc-weixin"; export const BUNDLED_WINDOWS_NODE_CWD_CONTRACT = "microclaw.windows-cwd.v1"; export const BUNDLED_WINDOWS_NODE_ACTIVATION_CONTRACT = "microclaw.windows-activation.v1"; export const BUNDLED_WINDOWS_NODE_APPROVAL_PROOF_CONTRACT = "microclaw.windows-node-approval.v1"; +export const BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT = "microclaw.windows-node-mxc.canvas.v1"; export const BUNDLED_WINDOWS_NODE_DISPLAY_NAME = "MicroClaw Bundled Windows Node"; export const BUNDLED_WINDOWS_NODE_REVISION = "fc9add75eda78daf548d80a55ffb64e63b159961"; export const MXC_HOST_PREP_PATCH_REVISION = "695c2b89c6142090a098ec4484f49aff8157f0b3"; +export const MXC_SDK_SOURCE_REVISION = "0e02a720e32416b151dbcb0a00e3d4ac2b891559"; +export const MXC_EXACT_BACKEND_PROVISIONING = "host-base-container-api"; const MXC_WXC_EXEC_SHA256 = { x64: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", arm64: "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab", @@ -42,6 +61,9 @@ export interface BundledWindowsNodeHostStatus { gatewayGeneration: string | null; policyFingerprint: string | null; activationLease: BundledWindowsNodeActivationLease | null; + requestedStrength: WindowsNodeMxcEffectiveStrength | null; + effectiveTier: string | null; + ambientAccess: WindowsNodeMxcAmbientAccessAssessment | null; } export interface BundledWindowsNodeActivationLease { @@ -81,10 +103,20 @@ interface StartOptions { uiLocale: string; openClawStateRoot: string; folders: BundledWindowsNodeFolder[]; + requestedStrength: Exclude; + effectiveTier: string; + ambientAccess: BundledWindowsNodeAmbientAccess | null; approvalProof: BundledWindowsNodeApprovalProofContext; onApproval: (approval: BundledApprovalRequest | null) => void; } +export interface BundledWindowsNodeAmbientAccess extends WindowsNodeMxcAmbientAccessAssessment { + targets: Array<{ + category: WindowsNodeMxcAmbientAccessCategory; + path: string; + }>; +} + export interface BundledWindowsNodeGateway { request(method: string, params?: unknown): Promise; } @@ -113,6 +145,11 @@ export class BundledWindowsNodeHost { "microclaw-windows-node-host.exe", ); private readonly wxcExecPath = path.join(this.resourceRoot, "mxc", "wxc-exec.exe"); + private readonly canvasHostPath = path.join( + this.resourceRoot, + "canvas-host", + "microclaw-windows-node-canvas-host.exe", + ); createApprovalProofContext( gatewayGeneration: string, @@ -120,13 +157,22 @@ export class BundledWindowsNodeHost { folders: BundledWindowsNodeFolder[], openClawStateRoot: string, readinessTransitionId: string, + requestedStrength: Exclude = "full", + effectiveTier = "base-container", + ambientAccess: BundledWindowsNodeAmbientAccess | null = null, ): BundledWindowsNodeApprovalProofContext { if (!gatewayGeneration.trim()) throw new Error("Gateway generation is required"); if (!/^[a-f0-9]{64}$/i.test(nodeId)) throw new Error("Bundled Windows node ID is invalid"); if (!isUuid(readinessTransitionId)) { throw new Error("Bundled Windows node readiness transition ID is invalid"); } - const policyJson = this.buildPolicyJson(folders, openClawStateRoot); + const policyJson = this.buildPolicyJson( + folders, + openClawStateRoot, + requestedStrength, + effectiveTier, + ambientAccess, + ); return { contract: BUNDLED_WINDOWS_NODE_APPROVAL_PROOF_CONTRACT, secretBase64: randomBytes(32).toString("base64"), @@ -143,7 +189,7 @@ export class BundledWindowsNodeHost { this.startOptions = options; this.lastError = null; assertLoopbackGateway(options.gatewayUrl); - assertBundledArtifacts(this.hostPath, this.wxcExecPath); + assertBundledArtifacts(this.hostPath, this.wxcExecPath, this.canvasHostPath); const stateRoot = path.join(app.getPath("userData"), "windows-node"); const identityDirectory = path.join(stateRoot, "identity"); @@ -151,9 +197,17 @@ export class BundledWindowsNodeHost { const policyPath = path.join(stateRoot, "policy.json"); const activationLeasePath = path.join(stateRoot, "activation-lease.json"); const scratchRoot = path.join(stateRoot, "scratch"); + const ambientScratchRoot = path.join(stateRoot, "ambient-scratch"); await fs.promises.mkdir(stateRoot, { recursive: true }); await fs.promises.mkdir(scratchRoot, { recursive: true }); - const policyJson = this.buildPolicyJson(options.folders, options.openClawStateRoot); + await fs.promises.mkdir(ambientScratchRoot, { recursive: true }); + const policyJson = this.buildPolicyJson( + options.folders, + options.openClawStateRoot, + options.requestedStrength, + options.effectiveTier, + options.ambientAccess, + ); await writeTextAtomically(policyPath, policyJson); await fs.promises.rm(activationLeasePath, { force: true }); const activationLeaseSecret = randomBytes(32); @@ -247,27 +301,107 @@ export class BundledWindowsNodeHost { }); } - private buildPolicyJson(folders: BundledWindowsNodeFolder[], openClawStateRoot: string): string { + private buildPolicyJson( + folders: BundledWindowsNodeFolder[], + openClawStateRoot: string, + requestedStrength: Exclude, + effectiveTier: string, + ambientAccess: BundledWindowsNodeAmbientAccess | null, + ): string { const stateRoot = path.join(app.getPath("userData"), "windows-node"); + const ambientAssessmentScratchRoot = path.join(stateRoot, "ambient-scratch"); + const { inboundRoot: weixinInboundRoot } = resolveWindowsNodeMxcWeixinMediaRoots( + app.getPath("userData"), + ); return JSON.stringify( { - approvedRoots: folders.map((folder) => ({ - path: folder.path, - access: folder.access === "rw" ? "ReadWrite" : "ReadOnly", - })), + policySchemaVersion: 2, + requestedStrength, + effectiveTier, + approvedRoots: [ + ...folders.map((folder) => ({ + path: folder.path, + access: folder.access === "rw" ? "ReadWrite" : "ReadOnly", + })), + { path: weixinInboundRoot, access: "ReadOnly" }, + ], deniedRoots: sensitiveWindowsRoots(stateRoot, openClawStateRoot), wxcExecPath: this.wxcExecPath, - networkAllowed: false, + canvas: { + enabled: true, + contract: BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT, + hostPath: this.canvasHostPath, + hostSha256: sha256File(this.canvasHostPath), + hostTreeSha256: sha256DirectoryTree(path.dirname(this.canvasHostPath)), + renderer: "native-a2ui", + commands: [...WINDOWS_NODE_MXC_CANVAS_COMMANDS], + actions: [...WINDOWS_NODE_MXC_CANVAS_ACTIONS], + allowEval: false, + allowNavigation: false, + allowLocalFiles: false, + allowRemoteMedia: false, + snapshotSurfaceOnly: true, + }, + networkAllowed: true, allowWindowsUi: true, clipboard: "none", inputInjection: false, strictNoHostFallback: true, + ambientAssessmentScratchRoot, + ambientAccess: ambientAccess + ? { + contract: ambientAccess.contract, + hash: ambientAccess.hash, + entries: ambientAccess.entries.map((entry) => ({ + ...entry, + path: + ambientAccess.targets.find((target) => target.category === entry.category) + ?.path ?? "", + })), + } + : null, }, null, 2, ); } + async assessLimitedAmbientAccess( + openClawStateRoot: string, + ): Promise { + assertBundledArtifacts(this.hostPath, this.wxcExecPath, this.canvasHostPath); + const stateRoot = path.join(app.getPath("userData"), "windows-node"); + const scratchRoot = path.join(stateRoot, "ambient-scratch"); + await fs.promises.mkdir(scratchRoot, { recursive: true }); + const targets = resolveAmbientAccessTargets(openClawStateRoot); + const requestPath = path.join( + stateRoot, + `ambient-assessment-${process.pid}-${randomBytes(8).toString("hex")}.json`, + ); + try { + await writeTextAtomically( + requestPath, + JSON.stringify( + { + wxcExecPath: this.wxcExecPath, + scratchRoot, + targets, + }, + null, + 2, + ), + ); + const stdout = await execFileText(this.hostPath, ["--ambient-assess", requestPath], 180_000); + const parsed = JSON.parse(stdout) as unknown; + if (!validateWindowsNodeMxcAmbientAccessAssessment(parsed)) { + throw new Error("Bundled Windows node returned an invalid ambient-access assessment"); + } + return { ...parsed, targets }; + } finally { + await fs.promises.rm(requestPath, { force: true }); + } + } + async setActivationLease( mode: "diagnostic" | "active", ttlMs: number, @@ -415,7 +549,7 @@ export class BundledWindowsNodeHost { } ensureIdentityNodeId(): string { - assertBundledArtifacts(this.hostPath, this.wxcExecPath); + assertBundledArtifacts(this.hostPath, this.wxcExecPath, this.canvasHostPath); const identityDirectory = path.join(app.getPath("userData"), "windows-node", "identity"); const nodeId = execFileSync(this.hostPath, ["--identity", identityDirectory], { encoding: "utf8", @@ -428,6 +562,11 @@ export class BundledWindowsNodeHost { return nodeId.toLowerCase(); } + getValidatedMxcExecutablePath(): string { + assertBundledMxcProbeArtifact(this.wxcExecPath); + return this.wxcExecPath; + } + respond(requestId: string, decision: "deny" | "allow-once" | "allow-always"): void { const pending = this.pendingApproval; if (!pending) throw new Error("No bundled Windows node approval is pending"); @@ -482,6 +621,16 @@ export class BundledWindowsNodeHost { nodeId: this.tryReadNodeId(), gatewayGeneration: this.gatewayGeneration, policyFingerprint: this.policyFingerprint, + requestedStrength: this.startOptions?.requestedStrength ?? null, + effectiveTier: this.startOptions?.effectiveTier ?? null, + ambientAccess: this.startOptions?.ambientAccess + ? { + contract: this.startOptions.ambientAccess.contract, + hash: this.startOptions.ambientAccess.hash, + checkedAt: this.startOptions.ambientAccess.checkedAt, + entries: this.startOptions.ambientAccess.entries.map((entry) => ({ ...entry })), + } + : null, activationLease: this.activationLease && this.activationLease.expiresAtUnixMs > Date.now() ? { ...this.activationLease } @@ -498,6 +647,78 @@ export class BundledWindowsNodeHost { } } +function resolveAmbientAccessTargets( + openClawStateRoot: string, +): BundledWindowsNodeAmbientAccess["targets"] { + const home = os.homedir(); + const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"); + const roamingAppData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming"); + return [ + { category: "desktop", path: app.getPath("desktop") }, + { category: "documents", path: app.getPath("documents") }, + { category: "downloads", path: app.getPath("downloads") }, + { category: "microclaw-state", path: app.getPath("userData") }, + { category: "openclaw-state", path: openClawStateRoot }, + { category: "openclaw-roaming-state", path: path.join(roamingAppData, "openclaw") }, + { category: "ssh", path: path.join(home, ".ssh") }, + { category: "gnupg", path: path.join(home, ".gnupg") }, + { category: "aws", path: path.join(home, ".aws") }, + { category: "azure", path: path.join(home, ".azure") }, + { category: "kube", path: path.join(home, ".kube") }, + { category: "gcloud", path: path.join(home, ".config", "gcloud") }, + { + category: "windows-credentials", + path: path.join(roamingAppData, "Microsoft", "Credentials"), + }, + { + category: "powershell-history", + path: path.join(roamingAppData, "Microsoft", "Windows", "PowerShell", "PSReadLine"), + }, + { + category: "browser-chrome", + path: path.join(localAppData, "Google", "Chrome", "User Data"), + }, + { + category: "browser-edge", + path: path.join(localAppData, "Microsoft", "Edge", "User Data"), + }, + { + category: "browser-brave", + path: path.join(localAppData, "BraveSoftware", "Brave-Browser", "User Data"), + }, + { + category: "browser-firefox", + path: path.join(roamingAppData, "Mozilla", "Firefox", "Profiles"), + }, + ]; +} + +function execFileText(executable: string, args: string[], timeout: number): Promise { + return new Promise((resolve, reject) => { + execFile( + executable, + args, + { + windowsHide: true, + timeout, + maxBuffer: 1024 * 1024, + encoding: "utf8", + }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `Bundled Windows node ambient assessment failed: ${stderr.trim() || error.message}`, + ), + ); + return; + } + resolve(stdout); + }, + ); + }); +} + function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } @@ -758,28 +979,23 @@ export function assertLoopbackGateway(gatewayUrl: string): void { } } -function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { - if (!fs.existsSync(hostPath)) - throw new Error(`Bundled Windows node host is missing: ${hostPath}`); - if (!fs.existsSync(wxcExecPath)) throw new Error(`Pinned MXC runtime is missing: ${wxcExecPath}`); - if (process.arch !== "x64" && process.arch !== "arm64") { - throw new Error(`Bundled Windows node does not support architecture ${process.arch}`); - } - const resourceRoot = path.dirname(path.dirname(hostPath)); - const patchedHostPrepPath = path.join(resourceRoot, "host-prep", "microclaw-mxc-host-prep.exe"); - const officialHostPrepPath = path.join(path.dirname(wxcExecPath), "wxc-host-prep.exe"); - if (!fs.existsSync(patchedHostPrepPath)) { - throw new Error(`MicroClaw MXC host-prep patch is missing: ${patchedHostPrepPath}`); - } - if (!fs.existsSync(officialHostPrepPath)) { - throw new Error(`Official MXC host-prep runtime is missing: ${officialHostPrepPath}`); - } - const manifestPath = path.join(resourceRoot, "RUNTIME.json"); - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; +export function validateBundledWindowsNodeRuntimeManifest( + manifest: Record, + architecture: "x64" | "arm64", +): void { if ( - manifest.architecture !== process.arch || + manifest.architecture !== architecture || manifest.mxcVersion !== "0.7.0" || + manifest.mxcSourceRevision !== MXC_SDK_SOURCE_REVISION || + manifest.mxcProductionBfsCompiledIn !== false || + manifest.mxcExactBackendProvisioning !== MXC_EXACT_BACKEND_PROVISIONING || + manifest.wxcExecSha256 !== MXC_WXC_EXEC_SHA256[architecture] || manifest.windowsNodeRevision !== BUNDLED_WINDOWS_NODE_REVISION || + manifest.canvasContract !== BUNDLED_WINDOWS_NODE_CANVAS_CONTRACT || + typeof manifest.canvasHostSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(manifest.canvasHostSha256) || + typeof manifest.canvasHostTreeSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(manifest.canvasHostTreeSha256) || manifest.mxcHostPrepPatchRevision !== MXC_HOST_PREP_PATCH_REVISION || manifest.microclawHostPrepOrigin !== "microclaw-built" || JSON.stringify(manifest.microclawHostPrepOperations) !== @@ -787,15 +1003,35 @@ function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { ) { throw new Error("Bundled Windows node runtime manifest does not match this application"); } - const actualHash = createHash("sha256").update(fs.readFileSync(wxcExecPath)).digest("hex"); - if (actualHash !== MXC_WXC_EXEC_SHA256[process.arch]) { - throw new Error(`Pinned MXC runtime hash mismatch: ${actualHash}`); +} + +function assertBundledArtifacts( + hostPath: string, + wxcExecPath: string, + canvasHostPath: string, +): void { + if (!fs.existsSync(hostPath)) + throw new Error(`Bundled Windows node host is missing: ${hostPath}`); + if (!fs.existsSync(canvasHostPath)) + throw new Error(`Bundled Windows node Canvas host is missing: ${canvasHostPath}`); + const architecture = assertBundledMxcProbeArtifact(wxcExecPath); + const resourceRoot = path.dirname(path.dirname(hostPath)); + const patchedHostPrepPath = path.join(resourceRoot, "host-prep", "microclaw-mxc-host-prep.exe"); + const officialHostPrepPath = path.join(path.dirname(wxcExecPath), "wxc-host-prep.exe"); + if (!fs.existsSync(patchedHostPrepPath)) { + throw new Error(`MicroClaw MXC host-prep patch is missing: ${patchedHostPrepPath}`); + } + if (!fs.existsSync(officialHostPrepPath)) { + throw new Error(`Official MXC host-prep runtime is missing: ${officialHostPrepPath}`); } + const manifest = JSON.parse( + fs.readFileSync(path.join(resourceRoot, "RUNTIME.json"), "utf8"), + ) as Record; const officialHostPrepHash = createHash("sha256") .update(fs.readFileSync(officialHostPrepPath)) .digest("hex"); if ( - officialHostPrepHash !== MXC_OFFICIAL_HOST_PREP_SHA256[process.arch] || + officialHostPrepHash !== MXC_OFFICIAL_HOST_PREP_SHA256[architecture] || manifest.officialWxcHostPrepSha256 !== officialHostPrepHash ) { throw new Error(`Official MXC host-prep hash mismatch: ${officialHostPrepHash}`); @@ -809,6 +1045,74 @@ function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { ) { throw new Error(`MicroClaw MXC host-prep hash mismatch: ${patchedHostPrepHash}`); } + const canvasHostHash = sha256File(canvasHostPath); + if (manifest.canvasHostSha256 !== canvasHostHash) { + throw new Error(`Bundled Windows node Canvas host hash mismatch: ${canvasHostHash}`); + } + const canvasHostTreeHash = sha256DirectoryTree(path.dirname(canvasHostPath)); + if (manifest.canvasHostTreeSha256 !== canvasHostTreeHash) { + throw new Error( + `Bundled Windows node Canvas host resource tree hash mismatch: ${canvasHostTreeHash}`, + ); + } +} + +function sha256File(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +export function sha256DirectoryTree(root: string): string { + const files: string[] = []; + const visit = (directory: string): void => { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => compareOrdinal(left.name, right.name))) { + const fullPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Bundled Windows node Canvas host tree contains a link: ${fullPath}`); + } + if (entry.isDirectory()) visit(fullPath); + else if (entry.isFile()) files.push(fullPath); + else throw new Error(`Bundled Windows node Canvas host tree contains an unsupported entry`); + } + }; + visit(root); + files.sort((left, right) => + compareOrdinal( + path.relative(root, left).replaceAll(path.sep, "/"), + path.relative(root, right).replaceAll(path.sep, "/"), + ), + ); + const hash = createHash("sha256"); + for (const filePath of files) { + const relative = path.relative(root, filePath).replaceAll(path.sep, "/"); + const bytes = fs.readFileSync(filePath); + hash.update(`${relative}\0${bytes.byteLength}\0`, "utf8"); + hash.update(bytes); + hash.update(Buffer.from([0])); + } + return hash.digest("hex"); +} + +function compareOrdinal(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function assertBundledMxcProbeArtifact(wxcExecPath: string): "x64" | "arm64" { + if (!fs.existsSync(wxcExecPath)) throw new Error(`Pinned MXC runtime is missing: ${wxcExecPath}`); + if (process.arch !== "x64" && process.arch !== "arm64") { + throw new Error(`Bundled Windows node does not support architecture ${process.arch}`); + } + const resourceRoot = path.dirname(path.dirname(wxcExecPath)); + const manifest = JSON.parse( + fs.readFileSync(path.join(resourceRoot, "RUNTIME.json"), "utf8"), + ) as Record; + validateBundledWindowsNodeRuntimeManifest(manifest, process.arch); + const actualHash = createHash("sha256").update(fs.readFileSync(wxcExecPath)).digest("hex"); + if (actualHash !== MXC_WXC_EXEC_SHA256[process.arch]) { + throw new Error(`Pinned MXC runtime hash mismatch: ${actualHash}`); + } + return process.arch; } async function writeJsonAtomically( diff --git a/desktop/src/constants.test.ts b/desktop/src/constants.test.ts index f26f071..2658f2d 100644 --- a/desktop/src/constants.test.ts +++ b/desktop/src/constants.test.ts @@ -7,6 +7,10 @@ import { HEALTH_CHECK_HTTP_TIMEOUT_MS, HEALTH_CHECK_BUSY_GRACE_MS, GATEWAY_READY_TIMEOUT_MS, + MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + MXC_GATEWAY_RPC_READY_TIMEOUT_MS, PORT_WAIT_TIMEOUT_MS, MODEL_CONNECTION_TEST_TIMEOUT_MS, POST_SPAWN_CHANNEL_READY_TIMEOUT_MS, @@ -58,6 +62,14 @@ describe("constants", () => { expect(GATEWAY_READY_TIMEOUT_MS).toBeGreaterThanOrEqual(HEALTH_CHECK_BUSY_GRACE_MS); }); + it("gates bundled-node startup on a bounded long-running authenticated RPC", () => { + expect(MXC_GATEWAY_RPC_READY_TIMEOUT_MS).toBeLessThanOrEqual(GATEWAY_READY_TIMEOUT_MS); + expect(MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS).toBeLessThan(MXC_GATEWAY_RPC_READY_TIMEOUT_MS); + expect(MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS).toBeLessThanOrEqual( + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + ); + }); + it("model connection test has a finite timeout", () => { expect(MODEL_CONNECTION_TEST_TIMEOUT_MS).toBeGreaterThan(0); expect(MODEL_CONNECTION_TEST_TIMEOUT_MS).toBeLessThanOrEqual(30_000); diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index 2b84e96..0732722 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -54,6 +54,16 @@ export const HEALTH_CHECK_BUSY_GRACE_MS = 240_000; * than two minutes before the Gateway binds its health endpoint. */ export const GATEWAY_READY_TIMEOUT_MS = 300_000; +/** Total authenticated RPC readiness budget before the bundled Windows node may start. */ +export const MXC_GATEWAY_RPC_READY_TIMEOUT_MS = 180_000; + +/** Per-attempt budget for the representative authenticated config.get readiness RPC. */ +export const MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS = 120_000; + +/** Initial and maximum retry delays for authenticated Gateway readiness. */ +export const MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS = 500; +export const MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS = 5_000; + /** Max time to wait for the gateway port to become free (ms). */ export const PORT_WAIT_TIMEOUT_MS = 30_000; diff --git a/desktop/src/gateway-http-readiness.test.ts b/desktop/src/gateway-http-readiness.test.ts new file mode 100644 index 0000000..8a7813f --- /dev/null +++ b/desktop/src/gateway-http-readiness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { waitForGatewayHttpReadiness } from "./gateway-http-readiness"; + +function clock() { + let value = 0; + return { + now: () => value, + sleep: vi.fn(async (milliseconds: number) => { + value += milliseconds; + }), + }; +} + +describe("Gateway HTTP readiness", () => { + it("waits through delayed health responses", async () => { + const time = clock(); + const probe = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + await expect( + waitForGatewayHttpReadiness({ + probe, + isProcessAlive: () => true, + timeoutMs: 5_000, + pollIntervalMs: 500, + ...time, + }), + ).resolves.toBe(true); + + expect(probe).toHaveBeenCalledTimes(3); + expect(time.sleep).toHaveBeenCalledTimes(2); + }); + + it("stops immediately when the managed process exits", async () => { + const probe = vi.fn(); + + await expect( + waitForGatewayHttpReadiness({ + probe, + isProcessAlive: () => false, + timeoutMs: 5_000, + pollIntervalMs: 500, + }), + ).resolves.toBe(false); + + expect(probe).not.toHaveBeenCalled(); + }); + + it("returns false at the bounded deadline", async () => { + const time = clock(); + + await expect( + waitForGatewayHttpReadiness({ + probe: vi.fn().mockResolvedValue(false), + isProcessAlive: () => true, + timeoutMs: 1_200, + pollIntervalMs: 500, + ...time, + }), + ).resolves.toBe(false); + }); +}); diff --git a/desktop/src/gateway-http-readiness.ts b/desktop/src/gateway-http-readiness.ts new file mode 100644 index 0000000..dfe4e3b --- /dev/null +++ b/desktop/src/gateway-http-readiness.ts @@ -0,0 +1,31 @@ +export type GatewayHttpReadinessOptions = { + probe: () => Promise; + isProcessAlive: () => boolean; + timeoutMs: number; + pollIntervalMs: number; + sleep?: (milliseconds: number) => Promise; + now?: () => number; +}; + +export async function waitForGatewayHttpReadiness( + options: GatewayHttpReadinessOptions, +): Promise { + if (options.timeoutMs <= 0 || options.pollIntervalMs <= 0) { + throw new Error("Gateway HTTP readiness timings must be positive"); + } + + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const deadline = now() + options.timeoutMs; + + while (now() < deadline) { + if (!options.isProcessAlive()) return false; + if (await options.probe()) return true; + const remainingMs = deadline - now(); + if (remainingMs <= 0) break; + await sleep(Math.min(options.pollIntervalMs, remainingMs)); + } + return false; +} diff --git a/desktop/src/gateway-rpc-readiness.test.ts b/desktop/src/gateway-rpc-readiness.test.ts new file mode 100644 index 0000000..90c12c9 --- /dev/null +++ b/desktop/src/gateway-rpc-readiness.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GatewayRpcReadinessCancelledError, + startAfterGatewayRpcReady, + waitForGatewayRpcReadiness, +} from "./gateway-rpc-readiness"; + +function clock() { + let value = 0; + return { + now: () => value, + sleep: vi.fn(async (milliseconds: number) => { + value += milliseconds; + }), + }; +} + +describe("Gateway authenticated RPC readiness", () => { + it("retries HTTP-ready but RPC-delayed generations before succeeding", async () => { + const time = clock(); + const probe = vi + .fn<(timeoutMs: number) => Promise>() + .mockRejectedValueOnce(new Error("dispatcher busy")) + .mockRejectedValueOnce(new Error("compatibility modules loading")) + .mockResolvedValue(undefined); + + await expect( + waitForGatewayRpcReadiness({ + probe, + isCurrent: () => true, + timeoutMs: 10_000, + attemptTimeoutMs: 2_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + ...time, + }), + ).resolves.toEqual({ attempts: 3, elapsedMs: 750 }); + + expect(probe).toHaveBeenCalledTimes(3); + }); + + it("never starts the bundled node until authenticated RPC responds", async () => { + let releaseProbe!: () => void; + const probe = new Promise((resolve) => { + releaseProbe = resolve; + }); + const start = vi.fn().mockResolvedValue(undefined); + const startup = startAfterGatewayRpcReady({ + probe: () => probe, + start, + isCurrent: () => true, + timeoutMs: 10_000, + attemptTimeoutMs: 5_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + }); + + await Promise.resolve(); + expect(start).not.toHaveBeenCalled(); + releaseProbe(); + await startup; + expect(start).toHaveBeenCalledOnce(); + }); + + it("times out without starting the bundled node", async () => { + const time = clock(); + const start = vi.fn(); + + await expect( + startAfterGatewayRpcReady({ + probe: vi.fn(async () => { + await time.sleep(1_000); + throw new Error("RPC unavailable"); + }), + start, + isCurrent: () => true, + timeoutMs: 2_500, + attemptTimeoutMs: 1_000, + initialBackoffMs: 250, + maxBackoffMs: 500, + ...time, + }), + ).rejects.toThrow(/authenticated RPC readiness timed out/); + expect(start).not.toHaveBeenCalled(); + }); + + it("cancels stale generations before node launch", async () => { + let current = true; + const start = vi.fn(); + + await expect( + startAfterGatewayRpcReady({ + probe: async () => { + current = false; + }, + start, + isCurrent: () => current, + timeoutMs: 10_000, + attemptTimeoutMs: 5_000, + initialBackoffMs: 250, + maxBackoffMs: 1_000, + }), + ).rejects.toBeInstanceOf(GatewayRpcReadinessCancelledError); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/gateway-rpc-readiness.ts b/desktop/src/gateway-rpc-readiness.ts new file mode 100644 index 0000000..3e6ad6f --- /dev/null +++ b/desktop/src/gateway-rpc-readiness.ts @@ -0,0 +1,86 @@ +export type GatewayRpcReadinessResult = { + attempts: number; + elapsedMs: number; +}; + +export type GatewayRpcReadinessOptions = { + probe: (timeoutMs: number) => Promise; + isCurrent: () => boolean; + timeoutMs: number; + attemptTimeoutMs: number; + initialBackoffMs: number; + maxBackoffMs: number; + sleep?: (milliseconds: number) => Promise; + now?: () => number; + onAttemptFailure?: (error: unknown, attempt: number) => void; +}; + +export class GatewayRpcReadinessCancelledError extends Error { + constructor() { + super("Gateway RPC readiness was cancelled because the managed generation changed"); + this.name = "GatewayRpcReadinessCancelledError"; + } +} + +export async function waitForGatewayRpcReadiness( + options: GatewayRpcReadinessOptions, +): Promise { + if (options.timeoutMs <= 0 || options.attemptTimeoutMs <= 0) { + throw new Error("Gateway RPC readiness timeouts must be positive"); + } + if (options.initialBackoffMs <= 0 || options.maxBackoffMs < options.initialBackoffMs) { + throw new Error("Gateway RPC readiness backoff is invalid"); + } + + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const startedAt = now(); + const deadline = startedAt + options.timeoutMs; + let attempts = 0; + let backoffMs = options.initialBackoffMs; + let lastError: unknown = new Error("Gateway RPC probe has not completed"); + + while (now() < deadline) { + assertCurrent(options.isCurrent); + attempts += 1; + const remainingMs = Math.max(1, deadline - now()); + try { + await options.probe(Math.min(options.attemptTimeoutMs, remainingMs)); + assertCurrent(options.isCurrent); + return { attempts, elapsedMs: now() - startedAt }; + } catch (error) { + assertCurrent(options.isCurrent); + lastError = error; + options.onAttemptFailure?.(error, attempts); + } + + const remainingMsAfterProbe = deadline - now(); + if (remainingMsAfterProbe <= 0) break; + await sleep(Math.min(backoffMs, remainingMsAfterProbe)); + backoffMs = Math.min(backoffMs * 2, options.maxBackoffMs); + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `Gateway authenticated RPC readiness timed out after ${options.timeoutMs}ms (${attempts} attempts): ${detail}`, + ); +} + +export async function startAfterGatewayRpcReady( + options: GatewayRpcReadinessOptions & { + start: () => Promise; + onReady?: (result: GatewayRpcReadinessResult) => void; + }, +): Promise { + const result = await waitForGatewayRpcReadiness(options); + assertCurrent(options.isCurrent); + options.onReady?.(result); + await options.start(); + return result; +} + +function assertCurrent(isCurrent: () => boolean): void { + if (!isCurrent()) throw new GatewayRpcReadinessCancelledError(); +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts index a07f14b..00659e5 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -67,6 +67,10 @@ import { DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, GATEWAY_READY_TIMEOUT_MS, + MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + MXC_GATEWAY_RPC_READY_TIMEOUT_MS, HEALTH_CHECK_INTERVAL_MS, HEALTH_CHECK_HTTP_TIMEOUT_MS, HEALTH_CHECK_FAILURE_THRESHOLD, @@ -154,6 +158,7 @@ import { WINDOWS_NODE_MXC_NODE_COMMANDS, applyWindowsNodeMxcGatewayPolicy, assertWindowsNodeMxcFolderPolicyMutable, + getWindowsNodeMxcIngressBackup, getWindowsNodeMxcGatewayPolicyState, isWindowsNodeMxcFolderConfigured, isWindowsNodeMxcIngressReleased, @@ -168,6 +173,21 @@ import { type WindowsNodeMxcApprovalDecision, type WindowsNodeMxcGatewayApproval, } from "./windows-node-mxc"; +import { + annotateWindowsNodeMxcPausedCronJobs, + createWindowsNodeMxcCronResumeFence, + getWindowsNodeMxcCronResumeEnvironment, + isWindowsNodeMxcCronResumeAcknowledgement, + parseWindowsNodeMxcCronResumeFence, + shouldSuspendWindowsNodeMxcCronBeforePreflight, + type WindowsNodeMxcCronResumeFence, +} from "./windows-node-mxc-cron"; +import { + createWindowsNodeMxcHooksGatePolicy, + getWindowsNodeMxcHooksEnvironment, + inspectWindowsNodeMxcHooksStatus, + type WindowsNodeMxcHooksStatus, +} from "./windows-node-mxc-hooks"; import { commitWindowsNodeMxcFolderPolicyAtomically, runWindowsNodeMxcFolderPolicyTransaction, @@ -175,6 +195,13 @@ import { type WindowsNodeMxcFolderTransactionPhase, } from "./windows-node-mxc-folder-transaction"; import { runWindowsNodeMxcAutomaticTransition } from "./windows-node-mxc-auto-transition"; +import { + attestWindowsNodeMxcGatewaySnapshot, + promoteWindowsNodeMxcPolicy, + type GatewayConfigSnapshot, +} from "./windows-node-mxc-policy-promotion"; +import { startAfterGatewayRpcReady } from "./gateway-rpc-readiness"; +import { waitForGatewayHttpReadiness } from "./gateway-http-readiness"; import { WindowsNodeMxcDurableApprovalStore, consumeExactDurableApproval, @@ -184,18 +211,37 @@ import { import { inspectWindowsNodeMxc, isCurrentBundledWindowsNodeApprovalCallback, + preflightWindowsNodeMxcBackend, runWindowsNodeMxcSmoke, + isWindowsNodeMxcSmokePassed, shouldStopManagedGatewayForWindowsNodeMxc, type StoredWindowsNodeMxcSmoke, type WindowsNodeMxcRuntimeStatus, } from "./windows-node-mxc-service"; +import { + createWindowsNodeMxcSelection, + migrateWindowsNodeMxcSelection, + summarizeWindowsNodeMxcAmbientAccess, + type WindowsNodeMxcAmbientAccessAssessment, + type WindowsNodeMxcRequestedMode, + type WindowsNodeMxcSelection, +} from "./windows-node-mxc-modes"; import { BundledWindowsNodeHost, sensitiveWindowsRoots, type BundledApprovalRequest, + type BundledWindowsNodeAmbientAccess, type BundledWindowsNodeApprovalProofContext, type BundledWindowsNodeFolder, } from "./bundled-windows-node-host"; +import { + getWindowsNodeMxcWeixinEnvironment, + preflightWindowsNodeMxcWeixinAuthorization, + prepareWindowsNodeMxcWeixinContext, + releaseWindowsNodeMxcWeixinIngress, + revokeWindowsNodeMxcWeixinIngress, + type WindowsNodeMxcWeixinContext, +} from "./windows-node-mxc-weixin"; /** * Normalize a directory path for comparison/storage. @@ -281,8 +327,14 @@ const settingsStore = new Store<{ windowsNodeMxcToolBackups: Record; /** AppContainer preference captured before entering the experimental mode. */ windowsNodeMxcPreviousSandboxEnabled: boolean; - /** Last contained hostname + PowerShell readiness proof. */ + /** Versioned explicit MXC containment mode selection. */ + windowsNodeMxcSelection?: WindowsNodeMxcSelection; + /** Last Limited-mode ambient-access assessment; paths are never persisted here. */ + windowsNodeMxcAmbientAccess?: WindowsNodeMxcAmbientAccessAssessment; + /** Last mode-specific contained readiness smoke proof. */ windowsNodeMxcSmoke?: StoredWindowsNodeMxcSmoke; + /** One-shot durable fence that prevents Cron catch-up when leaving MXC. */ + windowsNodeMxcCronResumeFence?: WindowsNodeMxcCronResumeFence; /** Recoverable policy state retained across a failed apply/reactivate transaction. */ windowsNodeMxcFolderPolicyRecovery?: { previous: WindowsNodeMxcFolderPolicy; @@ -467,8 +519,12 @@ const bundledWindowsNodeHost = new BundledWindowsNodeHost(); let bundledWindowsNodeStartup: Promise | null = null; let bundledWindowsNodeGeneration = 0; let gatewayGenerationId = ""; +let gatewayHttpReadyGeneration: string | null = null; let windowsNodeMxcApprovalProofContext: BundledWindowsNodeApprovalProofContext | null = null; +let windowsNodeMxcCurrentAmbientAccess: BundledWindowsNodeAmbientAccess | null = null; +let windowsNodeMxcCurrentEffectiveTier: string | null = null; let windowsNodeMxcIngressGeneration: string | null = null; +let windowsNodeMxcWeixinContext: WindowsNodeMxcWeixinContext | null = null; let windowsNodeMxcActivationInProgress = false; let windowsNodeMxcGatewayStartPolicyOverride: "active" | "locked" | null = null; let windowsNodeMxcSecurityTransitionInProgress = false; @@ -482,10 +538,16 @@ let pendingWindowsNodeMxcGatewayApproval: { gatewayGeneration: string; } | null = null; let windowsNodeMxcFailClosedPromise: Promise | null = null; +let windowsNodeMxcStartupTiming: { + startedAt: number; + previousAt: number; + generation: string | null; +} | null = null; let windowsNodeMxcDurableApprovalStore: WindowsNodeMxcDurableApprovalStore | null = null; let windowsNodeMxcDurableApprovalOperation: Promise = Promise.resolve(); let windowsNodeMxcApprovalResolutionInProgress = false; let windowsNodeMxcApprovalResolutionCompletion: Promise | null = null; +const WINDOWS_NODE_MXC_CRON_RESUME_ACK_TIMEOUT_MS = 30_000; type WindowsNodeMxcFolderPolicyRecovery = { previous: WindowsNodeMxcFolderPolicy; draft: WindowsNodeMxcFolderPolicy; @@ -493,6 +555,36 @@ type WindowsNodeMxcFolderPolicyRecovery = { lastError: string | null; }; +async function tryConsumeWindowsNodeMxcCronResumeAcknowledgement(): Promise { + const fence = parseWindowsNodeMxcCronResumeFence( + settingsStore.get("windowsNodeMxcCronResumeFence"), + ); + if (!fence) return true; + try { + const acknowledgement = JSON.parse( + await fs.promises.readFile(fence.acknowledgementPath, "utf8"), + ); + if (!isWindowsNodeMxcCronResumeAcknowledgement(acknowledgement, fence)) return false; + settingsStore.delete("windowsNodeMxcCronResumeFence"); + await fs.promises.rm(fence.acknowledgementPath, { force: true }); + console.log("[cron] consumed acknowledged MXC no-catch-up resume fence"); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function waitForWindowsNodeMxcCronResumeAcknowledgement(): Promise { + if (await tryConsumeWindowsNodeMxcCronResumeAcknowledgement()) return; + const deadline = Date.now() + WINDOWS_NODE_MXC_CRON_RESUME_ACK_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await tryConsumeWindowsNodeMxcCronResumeAcknowledgement()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for MXC Cron no-catch-up resume acknowledgement"); +} + async function withWindowsNodeMxcDurableApprovalLock(operation: () => Promise): Promise { const previous = windowsNodeMxcDurableApprovalOperation; let release!: () => void; @@ -584,6 +676,22 @@ function logStartupTiming(phase: string): void { console.log(`[startup-timing] ${phase} +${Date.now() - appStartupStartedAt}ms`); } +function beginWindowsNodeMxcStartupTiming(): void { + const now = Date.now(); + windowsNodeMxcStartupTiming = { startedAt: now, previousAt: now, generation: null }; +} + +function markWindowsNodeMxcStartupTiming(phase: string, detail = ""): void { + const timing = windowsNodeMxcStartupTiming; + if (!timing) return; + const now = Date.now(); + if (gatewayGenerationId) timing.generation = gatewayGenerationId; + console.log( + `[windows-node-mxc][startup-timing] phase=${phase} durationMs=${now - timing.previousAt} totalMs=${now - timing.startedAt} generation=${timing.generation ?? "pending"}${detail ? ` ${detail}` : ""}`, + ); + timing.previousAt = now; +} + function setGatewayStatus(status: GatewayStatus): void { gatewayStatus = status; updateTrayMenu(status, resolveSupportedLocale(settingsStore.get("language") ?? "en-US")); @@ -1047,8 +1155,91 @@ function readConfig(): any { } } +function getWindowsNodeMxcSelection(): WindowsNodeMxcSelection { + const current = settingsStore.get("windowsNodeMxcSelection"); + const migrated = migrateWindowsNodeMxcSelection( + current, + settingsStore.get("securityMode") === WINDOWS_NODE_MXC_MODE, + ); + if (JSON.stringify(current) !== JSON.stringify(migrated)) { + settingsStore.set("windowsNodeMxcSelection", migrated); + } + return migrated; +} + +function getWindowsNodeMxcRequestedMode(): WindowsNodeMxcRequestedMode { + return getWindowsNodeMxcSelection().requestedMode; +} + function isWindowsNodeMxcDesired(): boolean { - return settingsStore.get("securityMode") === WINDOWS_NODE_MXC_MODE; + return getWindowsNodeMxcRequestedMode() !== "off"; +} + +function closeWindowsNodeMxcIngress(): void { + windowsNodeMxcIngressGeneration = null; + revokeWindowsNodeMxcWeixinIngress(windowsNodeMxcWeixinContext); +} + +function releaseWindowsNodeMxcIngress(gatewayGeneration: string): void { + const context = windowsNodeMxcWeixinContext; + if ( + !context || + context.gatewayGeneration !== gatewayGeneration || + context.policyFingerprint !== windowsNodeMxcApprovalProofContext?.policyFingerprint + ) { + throw new Error("WeChat ingress release context does not match the active MXC generation"); + } + const stateDir = getOpenClawStateDir(); + const gatewayEnvironment = loadGatewayEnvironment(stateDir); + const oauthDir = gatewayEnvironment.OPENCLAW_OAUTH_DIR?.trim(); + preflightWindowsNodeMxcWeixinAuthorization({ + stateDir, + credentialsDir: oauthDir + ? path.resolve(path.dirname(resolveOpenClawEntry()), oauthDir) + : undefined, + config: readConfig(), + }); + releaseWindowsNodeMxcWeixinIngress(context); + windowsNodeMxcIngressGeneration = gatewayGeneration; +} + +async function prepareWindowsNodeMxcContainment( + requestedMode: Exclude, +): Promise<{ + effectiveTier: string; + ambientAccess: BundledWindowsNodeAmbientAccess | null; +}> { + const probe = await preflightWindowsNodeMxcBackend( + bundledWindowsNodeHost.getValidatedMxcExecutablePath(), + requestedMode, + ); + if (!probe.tier) throw new Error("MXC preflight did not attest an effective tier"); + if (requestedMode === "full") { + windowsNodeMxcCurrentAmbientAccess = null; + windowsNodeMxcCurrentEffectiveTier = probe.tier; + settingsStore.delete("windowsNodeMxcAmbientAccess"); + return { effectiveTier: probe.tier, ambientAccess: null }; + } + + const ambientAccess = + await bundledWindowsNodeHost.assessLimitedAmbientAccess(getOpenClawStateDir()); + const summary = summarizeWindowsNodeMxcAmbientAccess(ambientAccess); + if (summary.sensitiveAccessible.length > 0) { + throw new Error( + `Limited containment activation blocked because sensitive path categories are ambiently accessible: ${summary.sensitiveAccessible + .map((entry) => entry.category) + .join(", ")}`, + ); + } + windowsNodeMxcCurrentAmbientAccess = ambientAccess; + windowsNodeMxcCurrentEffectiveTier = probe.tier; + settingsStore.set("windowsNodeMxcAmbientAccess", { + contract: ambientAccess.contract, + hash: ambientAccess.hash, + checkedAt: ambientAccess.checkedAt, + entries: ambientAccess.entries.map((entry) => ({ ...entry })), + }); + return { effectiveTier: probe.tier, ambientAccess }; } function isApplicationIngressReady(): boolean { @@ -1095,19 +1286,42 @@ async function getWindowsNodeMxcStatus(): Promise< }; folderPolicyRecovery: WindowsNodeMxcFolderPolicyRecovery | null; durableApprovals: WindowsNodeMxcDurableApprovalInspection; + hooks: WindowsNodeMxcHooksStatus; + canvas: { + state: "off" | "paused" | "active"; + commandCount: 8; + nativeA2uiOnly: true; + localFilesAllowed: false; + }; } > { + const config = readConfig(); + const requestedMode = getWindowsNodeMxcRequestedMode(); + const backups = settingsStore.get("windowsNodeMxcToolBackups"); + const hooksConfig = + requestedMode === "off" ? config.hooks : getWindowsNodeMxcIngressBackup(backups, "hooks"); const bundledFolders = getBundledWindowsNodeFolders(); + const bundledHost = bundledWindowsNodeHost.status(); + let runtimeIntegrityError: string | undefined; + try { + bundledWindowsNodeHost.getValidatedMxcExecutablePath(); + } catch (error) { + runtimeIntegrityError = error instanceof Error ? error.message : String(error); + } const status = await inspectWindowsNodeMxc({ + requestedMode, desiredEnabled: isWindowsNodeMxcDesired(), selectedNodeId: settingsStore.get("windowsNodeMxcNodeId"), - config: readConfig(), + config, gateway: gwClient, managedGateway: gatewaySpawnedByUs && isManagedGatewayProcessAlive(), gatewayGeneration: gatewayGenerationId, storedSmoke: settingsStore.get("windowsNodeMxcSmoke") ?? null, - bundledHost: bundledWindowsNodeHost.status(), + bundledHost, bundledFolders, + runtimeIntegrityError, + ambientAccess: + bundledHost.ambientAccess ?? settingsStore.get("windowsNodeMxcAmbientAccess") ?? null, }); const durable = getWindowsNodeMxcDurableApprovalStore().inspect(); const lifecyclePhase = windowsNodeMxcSecurityTransitionInProgress @@ -1126,6 +1340,13 @@ async function getWindowsNodeMxcStatus(): Promise< }, folderPolicyRecovery: settingsStore.get("windowsNodeMxcFolderPolicyRecovery") ?? null, durableApprovals: durable, + hooks: inspectWindowsNodeMxcHooksStatus(hooksConfig, requestedMode), + canvas: { + state: requestedMode === "off" ? "off" : status.effectiveEnabled ? "active" : "paused", + commandCount: 8, + nativeA2uiOnly: true, + localFilesAllowed: false, + }, }; } @@ -1199,7 +1420,10 @@ async function handleWindowsNodeMxcGatewayApproval( const identity = proofContext ? durableApprovalIdentityFromGateway(approval, proofContext.policyFingerprint) : null; - const durable = identity ? getWindowsNodeMxcDurableApprovalStore().findExact(identity) : null; + const durable = + getWindowsNodeMxcRequestedMode() === "full" && identity + ? getWindowsNodeMxcDurableApprovalStore().findExact(identity) + : null; if (durable) { try { const consumed = await withWindowsNodeMxcApprovalResolution(async () => { @@ -1259,13 +1483,22 @@ async function handleWindowsNodeMxcGatewayApproval( } return; } + const rendererApproval = + getWindowsNodeMxcRequestedMode() === "limited-dacl" + ? { + ...approval, + allowedDecisions: approval.allowedDecisions.filter( + (decision) => decision !== "allow-always", + ), + } + : approval; pendingWindowsNodeMxcGatewayApproval = { - request: approval, + request: rendererApproval, gatewayGeneration: approvalGeneration, }; sendToWindow(mainWindow, "windows-node-mxc:approval-request", { - ...approval, - commandText: approval.command, + ...rendererApproval, + commandText: rendererApproval.command, approvalLayer: "gateway", }); } @@ -1330,8 +1563,23 @@ function assertWindowsNodeMxcBaseReady( failures.push("Strict MXC no-host-fallback is not effective"); if (!status.allowWindowsUiEffective) failures.push("PowerShell compatibility policy is not effective"); - if (status.probe.outcome !== "supported" || !status.probe.tier) { - failures.push(status.probe.reason ?? "MXC did not report a supported containment tier"); + const expectedStrength = getWindowsNodeMxcRequestedMode(); + const probeTier = status.probe.tier?.toLowerCase(); + const probeAccepted = + expectedStrength === "limited-dacl" + ? status.probe.outcome !== "error" && probeTier === "appcontainer-dacl" + : status.probe.outcome === "supported" && + !status.probe.degraded && + (probeTier === "base-container" || probeTier === "appcontainer-bfs"); + if (!probeAccepted) { + failures.push(status.probe.reason ?? "MXC did not report the explicitly requested tier"); + } + if ( + expectedStrength === "limited-dacl" && + (!status.ambientAccess || + summarizeWindowsNodeMxcAmbientAccess(status.ambientAccess).sensitiveAccessible.length > 0) + ) { + failures.push("Limited containment ambient-access assessment is missing or unsafe"); } if (!status.settingsFingerprint) failures.push("The bundled node security policy is unavailable"); if (!status.gatewayPolicyReady || status.gatewayPolicyState !== expectedPolicy) { @@ -1347,11 +1595,9 @@ function assertWindowsNodeMxcBaseReady( requireSmoke && (!status.smoke || status.smoke.gatewayGeneration !== gatewayGenerationId || - status.smoke.deniedOutsideRoot.outcome !== "passed" || - status.smoke.hostname.outcome !== "passed" || - status.smoke.powershell.outcome !== "passed") + !isWindowsNodeMxcSmokePassed(status.smoke)) ) { - failures.push("Current-generation denied-CWD, hostname, and PowerShell smokes must pass"); + failures.push("Current-generation mode-specific contained smoke must pass"); } if (failures.length > 0) { throw new Error(`Windows Node + MXC activation blocked: ${failures.join("; ")}`); @@ -1432,7 +1678,10 @@ async function runCurrentWindowsNodeMxcSmoke( ): Promise { if (!gwClient?.connected) throw new Error("MicroClaw managed Gateway is not connected"); if (!status.settingsFingerprint || !status.probe.tier) { - throw new Error("Current MXC settings and containment tier are required"); + throw new Error( + status.probe.reason ?? + "Current MXC settings and an exact filesystem isolation tier are required", + ); } const expectedGeneration = gatewayGenerationId; const transitionId = windowsNodeMxcReadinessTransitionId; @@ -1453,8 +1702,10 @@ async function runCurrentWindowsNodeMxcSmoke( expectedGeneration, status.selectedNodeId, status.settingsFingerprint, - status.probe.tier, + status.probe, { transitionId, proofContext }, + getWindowsNodeMxcRequestedMode() === "limited-dacl" ? "limited-dacl" : "full", + status.ambientAccess?.hash ?? null, ); if (gatewayGenerationId !== expectedGeneration || !gwClient.connected) { throw new Error("Managed Gateway generation changed during contained smokes"); @@ -1470,13 +1721,9 @@ function assertWindowsNodeMxcSmokePassed( smoke: StoredWindowsNodeMxcSmoke, generation: "Locked" | "Active", ): void { - if ( - smoke.deniedOutsideRoot.outcome !== "passed" || - smoke.hostname.outcome !== "passed" || - smoke.powershell.outcome !== "passed" - ) { + if (!isWindowsNodeMxcSmokePassed(smoke)) { throw new Error( - `${generation}-generation contained smokes failed: ${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, + `${generation}-generation ${smoke.profile} failed: execution=${smoke.execution.reason}; outsideRoot=${smoke.deniedOutsideRoot?.reason ?? "not-required"}`, ); } } @@ -1489,49 +1736,68 @@ async function runAutomaticWindowsNodeMxcReadiness(): Promise { + beginWindowsNodeMxcStartupTiming(); windowsNodeMxcActivationInProgress = true; - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); bundledWindowsNodeHost.revokeActivationLease(); settingsStore.delete("windowsNodeMxcSmoke"); sendToWindow(mainWindow, "gateway:ws-disconnected", "MXC readiness attestation"); sendToWindow(mainWindow, "gateway:service-loading"); }, + preflightExactBackend: async () => { + const requestedMode = getWindowsNodeMxcRequestedMode(); + if (requestedMode === "off") throw new Error("MXC containment mode is off"); + await prepareWindowsNodeMxcContainment(requestedMode); + }, startLockedGeneration: async () => { await startGatewayWithWindowsNodeMxcPolicy("locked"); const lockedGeneration = gatewayGenerationId; await waitForBundledWindowsNodeGeneration(lockedGeneration); - return waitForWindowsNodeMxcBaseReady(lockedGeneration, "locked", false); + const status = await waitForWindowsNodeMxcBaseReady(lockedGeneration, "locked", false); + markWindowsNodeMxcStartupTiming("attestation"); + return status; }, smokeLockedGeneration: async (lockedStatus) => { - assertWindowsNodeMxcSmokePassed(await runCurrentWindowsNodeMxcSmoke(lockedStatus), "Locked"); + const smoke = await runCurrentWindowsNodeMxcSmoke(lockedStatus); + assertWindowsNodeMxcSmokePassed(smoke, "Locked"); + markWindowsNodeMxcStartupTiming(smoke.profile); }, - startActiveGeneration: async () => { - const config = readConfig(); - if (!config || typeof config !== "object" || Array.isArray(config)) { - throw new Error("OpenClaw configuration is unavailable"); - } - const configuredPort = config?.gateway?.port || gatewayPort || DEFAULT_PORT; + promoteActivePolicy: async () => { + const generation = gatewayGenerationId; + const gateway = gwClient; + const gatewayProcessAtPromotion = gatewayProcess; const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); - const activePolicy = applyWindowsNodeMxcGatewayPolicy( - config, + if (!gateway || !gatewayProcessAtPromotion) { + throw new Error("Managed Gateway is unavailable for MXC policy promotion"); + } + const promoted = await promoteWindowsNodeMxcPolicy({ + gateway, nodeId, - settingsStore.get("windowsNodeMxcToolBackups"), - "active", - ); - await stopGatewayForSecurityTransition(configuredPort); - settingsStore.delete("windowsNodeMxcSmoke"); - settingsStore.set("windowsNodeMxcToolBackups", activePolicy.backups); - writeConfigTextAtomically(JSON.stringify(activePolicy.config, null, 2)); - await startGatewayWithWindowsNodeMxcPolicy("active"); - activeGeneration = gatewayGenerationId; - await waitForBundledWindowsNodeGeneration(activeGeneration); - return waitForWindowsNodeMxcBaseReady(activeGeneration, "active", false); + backups: settingsStore.get("windowsNodeMxcToolBackups"), + expectedGeneration: generation, + currentGeneration: () => gatewayGenerationId, + isCurrent: () => + gwClient === gateway && + gateway.connected && + gatewayProcess === gatewayProcessAtPromotion && + isManagedGatewayProcessAlive(), + requestTimeoutMs: MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + }); + settingsStore.set("windowsNodeMxcToolBackups", promoted.backups); + activeGeneration = generation; + const status = await waitForWindowsNodeMxcBaseReady(generation, "active", false); + markWindowsNodeMxcStartupTiming("policy-promoted"); + return status; }, - smokeActiveGeneration: async (activeStatus) => { - assertWindowsNodeMxcSmokePassed(await runCurrentWindowsNodeMxcSmoke(activeStatus), "Active"); + smokeActivePolicy: async (activeStatus) => { + const smoke = await runCurrentWindowsNodeMxcSmoke(activeStatus); + assertWindowsNodeMxcSmokePassed(smoke, "Active"); + await waitForWindowsNodeMxcBaseReady(activeGeneration, "active", true); + markWindowsNodeMxcStartupTiming(`active-${smoke.profile}`); }, mintActivationLease: async () => { await bundledWindowsNodeHost.setActivationLease("active", 120_000); + markWindowsNodeMxcStartupTiming("activation-lease"); }, verifyActiveGeneration: async () => { const finalStatus = await getWindowsNodeMxcStatus(); @@ -1543,14 +1809,18 @@ async function runAutomaticWindowsNodeMxcReadiness(): Promise { - windowsNodeMxcIngressGeneration = activeGeneration; + releaseWindowsNodeMxcIngress(activeGeneration); windowsNodeMxcActivationInProgress = false; + markWindowsNodeMxcStartupTiming("ingress-release"); + windowsNodeMxcStartupTiming = null; }, lockAfterFailure: async (error) => { windowsNodeMxcActivationInProgress = false; + windowsNodeMxcStartupTiming = null; await failClosedWindowsNodeMxc( `Automatic readiness failed: ${error instanceof Error ? error.message : String(error)}`, ); @@ -2771,19 +3041,18 @@ async function waitForGatewayReady( timeoutMs = GATEWAY_READY_TIMEOUT_MS, isProcessAlive: () => boolean = () => true, ): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (!isProcessAlive()) return false; - const ok = await checkExistingGateway(port); - if (ok) return true; - await new Promise((r) => setTimeout(r, 500)); - } - return false; + return waitForGatewayHttpReadiness({ + probe: () => checkExistingGateway(port), + isProcessAlive, + timeoutMs, + pollIntervalMs: 500, + }); } /** Kill the managed gateway and any listener still holding gatewayPort. */ function stopGatewayProcess(): void { stopBundledWindowsNodeHost(); + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; const knownPid = gatewayProcess?.pid; gatewayProcess = null; @@ -2898,6 +3167,7 @@ async function stopGatewayForSecurityTransition(port: number): Promise { pendingWindowsNodeMxcGatewayApproval = null; sendToWindow(mainWindow, "windows-node-mxc:approval-request", null); stopBundledWindowsNodeHost(true); + gatewayHttpReadyGeneration = null; const managedPid = gatewaySpawnedByUs && isManagedGatewayProcessAlive() ? gatewayProcess?.pid : undefined; const listenerPids = getGatewayListenerPids(port); @@ -2948,7 +3218,7 @@ async function failClosedWindowsNodeMxc(reason: string): Promise { if (!isWindowsNodeMxcDesired()) return; if (windowsNodeMxcFailClosedPromise) return windowsNodeMxcFailClosedPromise; const failClosed = (async () => { - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); setWindowsNodeMxcLifecycleState("locked", reason); sendToWindow(mainWindow, "gateway:service-loading"); bundledWindowsNodeHost.revokeActivationLease(); @@ -3709,6 +3979,22 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { // to the renderer via the gateway:log IPC channel (visible in Settings). const windowsNodeMxcDesired = isWindowsNodeMxcDesired(); + const windowsNodeMxcRequestedMode = getWindowsNodeMxcRequestedMode(); + if (!windowsNodeMxcDesired) { + await tryConsumeWindowsNodeMxcCronResumeAcknowledgement(); + } + const storedCronResumeFence = settingsStore.get("windowsNodeMxcCronResumeFence"); + const cronResumeFence = + windowsNodeMxcDesired || storedCronResumeFence === undefined + ? null + : parseWindowsNodeMxcCronResumeFence(storedCronResumeFence); + if (!windowsNodeMxcDesired && storedCronResumeFence !== undefined && !cronResumeFence) { + throw new Error("Stored MXC Cron resume fence is invalid"); + } + const containmentPreparation = + windowsNodeMxcDesired && windowsNodeMxcRequestedMode !== "off" + ? await prepareWindowsNodeMxcContainment(windowsNodeMxcRequestedMode) + : null; const nextGatewayGenerationId = randomUUID(); const nextApprovalProofContext = windowsNodeMxcDesired ? bundledWindowsNodeHost.createApprovalProofContext( @@ -3717,8 +4003,33 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { getBundledWindowsNodeFolders(), stateDir, windowsNodeMxcReadinessTransitionId ?? randomUUID(), + windowsNodeMxcRequestedMode === "limited-dacl" ? "limited-dacl" : "full", + containmentPreparation?.effectiveTier ?? "", + containmentPreparation?.ambientAccess ?? null, ) : null; + const nextWeixinContext = nextApprovalProofContext + ? prepareWindowsNodeMxcWeixinContext( + app.getPath("userData"), + nextApprovalProofContext.gatewayGeneration, + nextApprovalProofContext.policyFingerprint, + getBundledWindowsNodeFolders(), + ) + : null; + const nextHooksEnvironment = + nextApprovalProofContext && windowsNodeMxcRequestedMode !== "off" + ? getWindowsNodeMxcHooksEnvironment( + createWindowsNodeMxcHooksGatePolicy({ + gatewayGeneration: nextApprovalProofContext.gatewayGeneration, + policyFingerprint: nextApprovalProofContext.policyFingerprint, + requestedMode: windowsNodeMxcRequestedMode, + hooksConfig: getWindowsNodeMxcIngressBackup( + settingsStore.get("windowsNodeMxcToolBackups"), + "hooks", + ), + }), + ) + : {}; const gwEnv: Record = { ...gatewayEnvironment, OPENCLAW_STATE_DIR: stateDir, @@ -3731,7 +4042,27 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { OPENCLAW_NO_RESPAWN: "1", // HMAC key for verifying the external apps whitelist file OPENCLAW_SANDBOX_HMAC_KEY: sandboxHmacKey, + ...(nextWeixinContext ? getWindowsNodeMxcWeixinEnvironment(nextWeixinContext) : {}), }; + for (const reservedName of [ + "MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED", + "MICROCLAW_MXC_CRON_PAUSED", + "MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS", + "MICROCLAW_MXC_CRON_RESUME_ACK_PATH", + "MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN", + "MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64", + "MICROCLAW_MXC_CANVAS_PHASE1", + ]) { + delete gwEnv[reservedName]; + } + if (windowsNodeMxcDesired) { + gwEnv.MICROCLAW_MXC_CRON_PAUSED = "1"; + gwEnv.MICROCLAW_MXC_CANVAS_PHASE1 = "1"; + } + Object.assign(gwEnv, nextHooksEnvironment); + if (cronResumeFence) { + Object.assign(gwEnv, getWindowsNodeMxcCronResumeEnvironment(cronResumeFence)); + } // Determine spawn command const launcherPath = resolveAppContainerLauncher(); @@ -3845,25 +4176,27 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { } gwEnv.MICROCLAW_FS_SAFE_COMPAT = "1"; gwEnv.MICROCLAW_OPENCLAW_PACKAGE_DIR = openClawPackageDir; - if (windowsNodeMxcDesired) { + if (windowsNodeMxcDesired || cronResumeFence) { if (!fs.existsSync(approvalCompatPath)) { - const msg = `[error] Windows Node + MXC approval compatibility preload is missing: ${approvalCompatPath}`; + const msg = `[error] Windows Node + MXC compatibility preload is missing: ${approvalCompatPath}`; console.error(msg); mainWindow?.webContents.send("gateway:log", msg); setGatewayStatus("failed"); rollbackStartupAgentSkills(); return; } - gwEnv.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; - if (!nextApprovalProofContext) { - throw new Error("Windows Node + MXC approval proof context is unavailable"); + if (windowsNodeMxcDesired) { + gwEnv.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; + if (!nextApprovalProofContext) { + throw new Error("Windows Node + MXC approval proof context is unavailable"); + } + gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_SECRET = nextApprovalProofContext.secretBase64; + gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION = + nextApprovalProofContext.gatewayGeneration; + gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT = + nextApprovalProofContext.policyFingerprint; + gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_NODE_ID = nextApprovalProofContext.nodeId; } - gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_SECRET = nextApprovalProofContext.secretBase64; - gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION = - nextApprovalProofContext.gatewayGeneration; - gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT = - nextApprovalProofContext.policyFingerprint; - gwEnv.MICROCLAW_MXC_APPROVAL_PROOF_NODE_ID = nextApprovalProofContext.nodeId; } const gwArgs = [ @@ -3871,7 +4204,9 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { pathToFileURL(fsSafeCompatPath).href, "--import", pathToFileURL(localModelCompatPath).href, - ...(windowsNodeMxcDesired ? ["--import", pathToFileURL(approvalCompatPath).href] : []), + ...(windowsNodeMxcDesired || cronResumeFence + ? ["--import", pathToFileURL(approvalCompatPath).href] + : []), entryPath, "gateway", "run", @@ -3898,8 +4233,11 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { gatewayProcess = child; gatewayGenerationId = nextGatewayGenerationId; + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = nextApprovalProofContext; - windowsNodeMxcIngressGeneration = null; + revokeWindowsNodeMxcWeixinIngress(windowsNodeMxcWeixinContext); + windowsNodeMxcWeixinContext = nextWeixinContext; + closeWindowsNodeMxcIngress(); gatewaySpawnedByUs = true; // Only allow post-spawn restart on the very first gateway launch. // Do NOT reset on subsequent restarts — it causes an infinite restart loop. @@ -3939,7 +4277,8 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { safeSendLog("gateway:log", `[info] node=${nodePath} entry=${entryPath}`); if (gatewayProcess === child) { if (!postInstallReadySignaled) signalPostInstallFailure(err); - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); @@ -3959,7 +4298,8 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { new Error("Gateway rejected its configuration (exit 78); see the desktop handoff log"), ); } - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); + gatewayHttpReadyGeneration = null; windowsNodeMxcApprovalProofContext = null; bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); @@ -4279,6 +4619,10 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise { if (ready) { try { logStartupTiming("gateway-ready"); + gatewayHttpReadyGeneration = nextGatewayGenerationId; + if (windowsNodeMxcActivationInProgress) { + markWindowsNodeMxcStartupTiming("http-ready"); + } commitStartupAgentSkills(); setGatewayStatus("running"); } catch (error) { @@ -4408,6 +4752,11 @@ function connectGatewayWs(): void { onConnected: () => { console.log("[gateway-ws] connected"); wsAuthRestartInProgress = false; + if (!isWindowsNodeMxcDesired()) { + void waitForWindowsNodeMxcCronResumeAcknowledgement().catch((error) => { + console.error("[cron] MXC resume acknowledgement failed:", error); + }); + } if (isWindowsNodeMxcDesired()) { const gateway = gwClient; if (gateway && !bundledWindowsNodeStartup) { @@ -4431,6 +4780,16 @@ function connectGatewayWs(): void { return; } const hostGeneration = ++bundledWindowsNodeGeneration; + const expectedGatewayPolicy = getWindowsNodeMxcGatewayPolicyState( + readConfig(), + approvalProof.nodeId, + ); + if (expectedGatewayPolicy === "drift") { + void failClosedWindowsNodeMxc( + "Gateway policy drifted before authenticated RPC readiness", + ); + return; + } const startOptions = { gatewayUrl: `ws://127.0.0.1:${gatewayPort}`, gatewayToken, @@ -4439,6 +4798,12 @@ function connectGatewayWs(): void { uiLocale: resolveSupportedLocale(settingsStore.get("language") ?? "en-US"), openClawStateRoot: getOpenClawStateDir(), folders: getBundledWindowsNodeFolders(), + requestedStrength: + getWindowsNodeMxcRequestedMode() === "limited-dacl" + ? ("limited-dacl" as const) + : ("full" as const), + effectiveTier: windowsNodeMxcCurrentEffectiveTier ?? "", + ambientAccess: windowsNodeMxcCurrentAmbientAccess, approvalProof, onApproval: (approval: BundledApprovalRequest | null) => { if ( @@ -4473,6 +4838,7 @@ function connectGatewayWs(): void { bundledWindowsNodeGeneration !== hostGeneration || gwClient !== gateway || !gateway.connected || + gatewayHttpReadyGeneration !== approvalProof.gatewayGeneration || !gatewayGeneration || gatewayProcess !== gatewayGeneration || gatewayGeneration.exitCode !== null || @@ -4481,15 +4847,55 @@ function connectGatewayWs(): void { throw new Error("Managed Gateway generation changed before Windows node startup"); } }; - const startup = Promise.resolve() - .then(() => { + const startup = startAfterGatewayRpcReady({ + probe: async (timeoutMs) => { assertCurrentGatewayGeneration(); - return bundledWindowsNodeHost.start(startOptions); - }) - .then(() => { + const snapshot = await gateway.request( + "config.get", + {}, + timeoutMs, + ); assertCurrentGatewayGeneration(); - return bundledWindowsNodeHost.ensurePaired(gateway); - }) + attestWindowsNodeMxcGatewaySnapshot( + snapshot, + approvalProof.nodeId, + expectedGatewayPolicy, + ); + }, + isCurrent: () => { + try { + assertCurrentGatewayGeneration(); + return true; + } catch { + return false; + } + }, + timeoutMs: MXC_GATEWAY_RPC_READY_TIMEOUT_MS, + attemptTimeoutMs: MXC_GATEWAY_RPC_PROBE_TIMEOUT_MS, + initialBackoffMs: MXC_GATEWAY_RPC_PROBE_INITIAL_BACKOFF_MS, + maxBackoffMs: MXC_GATEWAY_RPC_PROBE_MAX_BACKOFF_MS, + onAttemptFailure: (error, attempt) => { + console.warn( + `[bundled-windows-node] authenticated RPC readiness attempt ${attempt} failed:`, + error, + ); + }, + onReady: (result) => { + markWindowsNodeMxcStartupTiming( + "rpc-ready", + `attempts=${result.attempts} probeElapsedMs=${result.elapsedMs}`, + ); + }, + start: async () => { + assertCurrentGatewayGeneration(); + await bundledWindowsNodeHost.start(startOptions); + assertCurrentGatewayGeneration(); + await bundledWindowsNodeHost.ensurePaired(gateway); + assertCurrentGatewayGeneration(); + markWindowsNodeMxcStartupTiming("node-connected"); + }, + }) + .then(() => undefined) .catch((error) => { if (bundledWindowsNodeGeneration === hostGeneration) { bundledWindowsNodeHost.stop(); @@ -4502,6 +4908,13 @@ function connectGatewayWs(): void { bundledWindowsNodeStartup = startup; void startup.catch((error) => { console.error("[bundled-windows-node] start failed:", error); + if (!windowsNodeMxcSecurityTransitionInProgress) { + void failClosedWindowsNodeMxc( + `Bundled node startup failed during authenticated RPC readiness: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } }); } } @@ -4529,7 +4942,7 @@ function connectGatewayWs(): void { isWindowsNodeMxcDesired() && windowsNodeMxcIngressGeneration !== null && windowsNodeMxcIngressGeneration === gatewayGenerationId; - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); stopBundledWindowsNodeHost(); sendToWindow(mainWindow, "gateway:ws-disconnected", reason); if (activeGeneration && !windowsNodeMxcActivationInProgress && !gatewayRestarting) { @@ -5611,9 +6024,12 @@ function registerIpcHandlers(): void { // --- Cron / Scheduled Tasks --- ipcMain.handle("cron:list", async () => { - if (isWindowsNodeMxcDesired()) return { jobs: [] }; if (!gwClient?.connected) throw new Error("Gateway not connected"); - return await gwClient.listCronJobs(); + const result = await gwClient.listCronJobs(); + const requestedMode = getWindowsNodeMxcRequestedMode(); + return requestedMode === "off" + ? result + : annotateWindowsNodeMxcPausedCronJobs(result, requestedMode); }); // --- Agents --- @@ -5673,11 +6089,14 @@ function registerIpcHandlers(): void { // --- WeChat Plugin --- ipcMain.handle("plugin:weixin:get-status", () => { const config = readConfig(); - const enabled = !!config?.plugins?.entries?.["openclaw-weixin"]?.enabled; + const requestedEnabled = !!config?.plugins?.entries?.["openclaw-weixin"]?.enabled; + const pausedByMxc = + requestedEnabled && isWindowsNodeMxcDesired() && !isApplicationIngressReady(); + const effectiveEnabled = requestedEnabled && !pausedByMxc; const installed = !!config?.plugins?.installs?.["openclaw-weixin"]; // Check if plugin is installed, enabled, AND has saved login accounts let loggedIn = false; - if (installed && enabled) { + if (installed && requestedEnabled) { try { const accountsPath = path.join(getOpenClawStateDir(), "openclaw-weixin", "accounts.json"); if (fs.existsSync(accountsPath)) { @@ -5693,7 +6112,15 @@ function registerIpcHandlers(): void { } } catch {} } - return { enabled, installed, loggedIn, loginInProgress: !!weixinLoginProcess }; + return { + enabled: requestedEnabled, + requestedEnabled, + effectiveEnabled, + pausedByMxc, + installed, + loggedIn, + loginInProgress: !!weixinLoginProcess, + }; }); ipcMain.handle("plugin:weixin:set-enabled", async (_event, enabled: boolean) => { @@ -6368,6 +6795,14 @@ function registerIpcHandlers(): void { // --- Experimental Windows Node + MXC sandbox (security framework #202) --- ipcMain.handle("windows-node-mxc:get-status", () => getWindowsNodeMxcStatus()); + ipcMain.handle( + "windows-node-mxc:preflight", + async (_event, params?: { requestedMode?: WindowsNodeMxcRequestedMode }) => { + const requestedMode = params?.requestedMode ?? "full"; + if (requestedMode === "off") return null; + return prepareWindowsNodeMxcContainment(requestedMode); + }, + ); ipcMain.handle("windows-node-mxc:approval-current", () => getPendingWindowsNodeMxcApprovalForRenderer(), ); @@ -6392,20 +6827,43 @@ function registerIpcHandlers(): void { ipcMain.handle( "windows-node-mxc:set-enabled", - async (_event, params: { enabled: boolean; nodeId?: string }) => { + async ( + _event, + params: { + enabled?: boolean; + requestedMode?: WindowsNodeMxcRequestedMode; + acknowledgeLimitedFilesystem?: boolean; + nodeId?: string; + }, + ) => { localModelService.assertIdle(); sessionModelService.assertIdle(); beginWindowsNodeMxcLifecycleOperation(); let transitionSucceeded = false; let automaticReadinessStarted = false; + let transitionMutationStarted = false; + let cronSuspendedBeforePreflight = false; + let standardRuntimeRecovered = false; try { - const enabled = params?.enabled === true; - setWindowsNodeMxcLifecycleState("locking"); - windowsNodeMxcIngressGeneration = null; - windowsNodeMxcActivationInProgress = false; - bundledWindowsNodeHost.revokeActivationLease(); - sendToWindow(mainWindow, "gateway:ws-disconnected", "Security mode transition"); - sendToWindow(mainWindow, "gateway:service-loading"); + const requestedMode = + params?.requestedMode ?? + (params?.enabled === true ? "full" : params?.enabled === false ? "off" : null); + if ( + requestedMode !== "off" && + requestedMode !== "full" && + requestedMode !== "limited-dacl" + ) { + throw new Error("A valid MXC containment mode is required"); + } + const enabled = requestedMode !== "off"; + const currentSelection = getWindowsNodeMxcSelection(); + const nextSelection = createWindowsNodeMxcSelection( + requestedMode, + params?.acknowledgeLimitedFilesystem === true || + (requestedMode === "limited-dacl" && + currentSelection.requestedMode === "limited-dacl" && + currentSelection.limitedFilesystemAcknowledgedAt !== null), + ); if (gwClient?.connected && (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive())) { throw new Error( "Windows Node + MXC mode requires MicroClaw's managed Gateway; stop the external Gateway first", @@ -6417,7 +6875,37 @@ function registerIpcHandlers(): void { } const configuredPort = config?.gateway?.port || gatewayPort || DEFAULT_PORT; + const suspendCronBeforePreflight = shouldSuspendWindowsNodeMxcCronBeforePreflight( + getWindowsNodeMxcRequestedMode(), + requestedMode, + ); + if (suspendCronBeforePreflight) { + transitionMutationStarted = true; + setWindowsNodeMxcLifecycleState("locking"); + closeWindowsNodeMxcIngress(); + windowsNodeMxcActivationInProgress = false; + bundledWindowsNodeHost.revokeActivationLease(); + sendToWindow(mainWindow, "gateway:ws-disconnected", "Security mode transition"); + sendToWindow(mainWindow, "gateway:service-loading"); + await stopGatewayForSecurityTransition(configuredPort); + cronSuspendedBeforePreflight = true; + } + if (enabled) { + setWindowsNodeMxcLifecycleState("validating"); + await prepareWindowsNodeMxcContainment(requestedMode); + } + if (!cronSuspendedBeforePreflight) { + transitionMutationStarted = true; + setWindowsNodeMxcLifecycleState("locking"); + closeWindowsNodeMxcIngress(); + windowsNodeMxcActivationInProgress = false; + bundledWindowsNodeHost.revokeActivationLease(); + sendToWindow(mainWindow, "gateway:ws-disconnected", "Security mode transition"); + sendToWindow(mainWindow, "gateway:service-loading"); + } else { + setWindowsNodeMxcLifecycleState("locking"); + } if (enabled) { const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); const applied = applyWindowsNodeMxcGatewayPolicy( @@ -6426,7 +6914,9 @@ function registerIpcHandlers(): void { settingsStore.get("windowsNodeMxcToolBackups"), "locked", ); - await stopGatewayForSecurityTransition(configuredPort); + if (!cronSuspendedBeforePreflight) { + await stopGatewayForSecurityTransition(configuredPort); + } if (!isWindowsNodeMxcDesired()) { settingsStore.set( "windowsNodeMxcPreviousSandboxEnabled", @@ -6435,7 +6925,14 @@ function registerIpcHandlers(): void { } settingsStore.set("windowsNodeMxcToolBackups", applied.backups); settingsStore.set("windowsNodeMxcNodeId", nodeId); + settingsStore.set("windowsNodeMxcSelection", nextSelection); + settingsStore.delete("windowsNodeMxcCronResumeFence"); settingsStore.set("securityMode", WINDOWS_NODE_MXC_MODE); + if (requestedMode === "limited-dacl") { + await withWindowsNodeMxcDurableApprovalLock(() => + getWindowsNodeMxcDurableApprovalStore().revokeAll(), + ); + } settingsStore.set("sandboxEnabled", false); settingsStore.delete("windowsNodeMxcSmoke"); toolSandbox?.setEnabled(false); @@ -6446,6 +6943,23 @@ function registerIpcHandlers(): void { return status; } else { stopBundledWindowsNodeHost(); + const cronResumeAcknowledgementToken = randomUUID(); + const cronResumeAcknowledgementDir = path.join( + app.getPath("userData"), + "mxc-cron-resume", + ); + fs.mkdirSync(cronResumeAcknowledgementDir, { recursive: true }); + const cronResumeAcknowledgementPath = path.join( + cronResumeAcknowledgementDir, + `${cronResumeAcknowledgementToken}.json`, + ); + settingsStore.set( + "windowsNodeMxcCronResumeFence", + createWindowsNodeMxcCronResumeFence( + cronResumeAcknowledgementPath, + cronResumeAcknowledgementToken, + ), + ); const restored = restoreWindowsNodeMxcGatewayPolicy( config, settingsStore.get("windowsNodeMxcToolBackups"), @@ -6459,7 +6973,11 @@ function registerIpcHandlers(): void { writeConfigTextAtomically(JSON.stringify(restored, null, 2)); settingsStore.set("windowsNodeMxcToolBackups", {}); settingsStore.delete("windowsNodeMxcSmoke"); + settingsStore.delete("windowsNodeMxcAmbientAccess"); + settingsStore.set("windowsNodeMxcSelection", nextSelection); settingsStore.set("securityMode", "appcontainer"); + windowsNodeMxcCurrentAmbientAccess = null; + windowsNodeMxcCurrentEffectiveTier = null; setWindowsNodeMxcLifecycleState("starting-standard"); postSpawnRestartDone = false; mainWindow?.webContents.send( @@ -6468,6 +6986,7 @@ function registerIpcHandlers(): void { ); await startGateway(); await waitForManagedGatewayConnection(); + await waitForWindowsNodeMxcCronResumeAcknowledgement(); setWindowsNodeMxcLifecycleState("idle"); transitionSucceeded = true; return getWindowsNodeMxcStatus(); @@ -6481,16 +7000,46 @@ function registerIpcHandlers(): void { } } else { setWindowsNodeMxcLifecycleState("failed", detail); - try { - await stopGatewayForSecurityTransition(gatewayPort || DEFAULT_PORT); - } finally { - setGatewayStatus("failed"); + if (cronSuspendedBeforePreflight && !automaticReadinessStarted) { + try { + setWindowsNodeMxcLifecycleState("starting-standard"); + postSpawnRestartDone = false; + mainWindow?.webContents.send( + "gateway:log", + "[start] Restoring standard Gateway after MXC preflight failure", + ); + await startGateway(); + await waitForManagedGatewayConnection(); + setWindowsNodeMxcLifecycleState("idle"); + standardRuntimeRecovered = true; + } catch (recoveryError) { + const recoveryDetail = + recoveryError instanceof Error ? recoveryError.message : String(recoveryError); + setWindowsNodeMxcLifecycleState( + "failed", + `MXC preflight failed and the standard Gateway could not be restored: ${recoveryDetail}`, + ); + setGatewayStatus("failed"); + throw new AggregateError( + [error, recoveryError], + `MXC preflight failed and the standard Gateway could not be restored: ${recoveryDetail}`, + { cause: recoveryError }, + ); + } + } else if (transitionMutationStarted) { + try { + await stopGatewayForSecurityTransition(gatewayPort || DEFAULT_PORT); + } finally { + setGatewayStatus("failed"); + } + } else { + throw error; } } throw error; } finally { endWindowsNodeMxcLifecycleOperation(); - if (transitionSucceeded) notifyRendererApplicationReady(); + if (transitionSucceeded || standardRuntimeRecovered) notifyRendererApplicationReady(); } }, ); @@ -6516,6 +7065,14 @@ function registerIpcHandlers(): void { throw new Error("Invalid Windows node approval decision"); } const normalizedDecision = decision as WindowsNodeMxcApprovalDecision; + if ( + normalizedDecision === "allow-always" && + getWindowsNodeMxcRequestedMode() === "limited-dacl" + ) { + throw new Error( + "Durable command approvals are disabled under Limited filesystem containment", + ); + } const gatewayApproval = pendingWindowsNodeMxcGatewayApproval; if (gatewayApproval?.request.id === requestId) { await withWindowsNodeMxcApprovalResolution(async () => { @@ -7150,7 +7707,7 @@ function registerIpcHandlers(): void { await runWindowsNodeMxcFolderPolicyTransaction(draft, previous, { setPhase: setWindowsNodeMxcLifecycleState, closeIngress: () => { - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); sendToWindow(mainWindow, "gateway:ws-disconnected", "Applying MXC folder policy"); }, rejectPendingApprovals: async () => { @@ -7171,6 +7728,11 @@ function registerIpcHandlers(): void { windowsNodeMxcApprovalProofContext = null; settingsStore.delete("windowsNodeMxcSmoke"); }, + preflightExactBackend: async () => { + const requestedMode = getWindowsNodeMxcRequestedMode(); + if (requestedMode === "off") throw new Error("MXC containment mode is off"); + await prepareWindowsNodeMxcContainment(requestedMode); + }, validatePolicy: (candidate) => normalizeCompleteWindowsNodeMxcFolderPolicy(candidate), stopCurrentGeneration: async () => { await stopGatewayForSecurityTransition(gatewayPort || DEFAULT_PORT); @@ -7235,13 +7797,9 @@ function registerIpcHandlers(): void { assertWindowsNodeMxcBaseReady(status, "locked", false), smokeLockedGeneration: async (status) => { const smoke = await runCurrentWindowsNodeMxcSmoke(status); - if ( - smoke.deniedOutsideRoot.outcome !== "passed" || - smoke.hostname.outcome !== "passed" || - smoke.powershell.outcome !== "passed" - ) { + if (!isWindowsNodeMxcSmokePassed(smoke)) { throw new Error( - `Locked-generation contained smokes failed: ${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, + `Locked-generation ${smoke.profile} failed: execution=${smoke.execution.reason}; outsideRoot=${smoke.deniedOutsideRoot?.reason ?? "not-required"}`, ); } }, @@ -7270,13 +7828,9 @@ function registerIpcHandlers(): void { assertWindowsNodeMxcBaseReady(status, "active", false), smokeActiveGeneration: async (status) => { const smoke = await runCurrentWindowsNodeMxcSmoke(status); - if ( - smoke.deniedOutsideRoot.outcome !== "passed" || - smoke.hostname.outcome !== "passed" || - smoke.powershell.outcome !== "passed" - ) { + if (!isWindowsNodeMxcSmokePassed(smoke)) { throw new Error( - `Active-generation contained smokes failed: ${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, + `Active-generation ${smoke.profile} failed: execution=${smoke.execution.reason}; outsideRoot=${smoke.deniedOutsideRoot?.reason ?? "not-required"}`, ); } }, @@ -7293,12 +7847,12 @@ function registerIpcHandlers(): void { return status; }, releaseIngress: (status) => { - windowsNodeMxcIngressGeneration = status.gatewayGeneration; + releaseWindowsNodeMxcIngress(status.gatewayGeneration); windowsNodeMxcActivationInProgress = false; sendToWindow(mainWindow, "gateway:ws-connected", gwClient?.mainSessionKey || null); }, lockAfterFailure: async (error, context) => { - windowsNodeMxcIngressGeneration = null; + closeWindowsNodeMxcIngress(); windowsNodeMxcActivationInProgress = true; bundledWindowsNodeHost.revokeActivationLease(); settingsStore.delete("windowsNodeMxcSmoke"); diff --git a/desktop/src/openclaw-approval-replay-compat.mjs b/desktop/src/openclaw-approval-replay-compat.mjs index 5936696..00443fd 100644 --- a/desktop/src/openclaw-approval-replay-compat.mjs +++ b/desktop/src/openclaw-approval-replay-compat.mjs @@ -2,9 +2,9 @@ import { Buffer } from "node:buffer"; import { createHash, createHmac, randomUUID } from "node:crypto"; import { readFileSync, realpathSync } from "node:fs"; import { registerHooks } from "node:module"; -import { isAbsolute, join, relative } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; +import { URL, fileURLToPath } from "node:url"; import { isMainThread } from "node:worker_threads"; const EXPECTED_VERSION = "2026.10.2"; @@ -17,12 +17,47 @@ const EXPECTED_SYSTEM_RUN_SHA256 = const EXPECTED_EXEC_APPROVAL_MODULE = "exec-approval-mGU7M4-K.mjs"; const EXPECTED_EXEC_APPROVAL_SHA256 = "aacf6a38e1ea77a9ce02fc4f60069230eec6b9c0a2e7123d35032eb105253267"; +const EXPECTED_WEB_TOOLS_MODULE = "openclaw-tools-Dxfbrtff.mjs"; +const EXPECTED_WEB_TOOLS_SHA256 = + "181c5f9673480fbdacfa3ff88ae83686d8d1cb6402f583c3546d87b6e468d53e"; +const EXPECTED_CRON_SERVICE_MODULE = "service-31u04axN.mjs"; +const EXPECTED_CRON_SERVICE_SHA256 = + "5f3fda2b466273f79606dbccb53e2952d3c33e198d32c8a0eed30f7be5cda3ab"; +const EXPECTED_CRON_HANDLERS_MODULE = "cron-D3Bd3ZFt.mjs"; +const EXPECTED_CRON_HANDLERS_SHA256 = + "3dd84c2aebf7cfe326f1ae4d3f40e5c68e4c897a623d834c60a613888c1f6ea1"; +const EXPECTED_HOOKS_SERVER_MODULE = "server-start-DfGddYU-.mjs"; +const EXPECTED_HOOKS_SERVER_SHA256 = + "86b9912a5458cd617e22dcdb24cc8ba757ea2206b298bf8831b22cd874e5efae"; +const EXPECTED_INTERNAL_HOOKS_MODULE = "internal-hooks-DfVqU7ax.mjs"; +const EXPECTED_INTERNAL_HOOKS_SHA256 = + "3e0200cbe95b537ac5c4fc36509197b2d13700a72688e26839620248c6c967d0"; +const EXPECTED_CANVAS_EXTENSION_MODULE = "extensions/canvas/index.js"; +const EXPECTED_CANVAS_EXTENSION_SHA256 = + "81a8476cf4bdf208710f7be014aa2bb72321399d2b46f3d8101826223182b9df"; +const EXPECTED_CANVAS_TOOL_MODULE = "tool-CpRzQ0s7.mjs"; +const EXPECTED_CANVAS_TOOL_SHA256 = + "2e45e3320ec6f3e8eb6005b06b90a29f1e620541079cf2b0f14d4342be60056a"; +const EXPECTED_CANVAS_ELIGIBILITY_MODULE = "node-eligibility-UXb9xB0F.mjs"; +const EXPECTED_CANVAS_ELIGIBILITY_SHA256 = + "4e80d6add8a2cd46f315e1698b65001fe837d1b16056a8255d34f546358c6431"; export const APPROVAL_PROOF_CONTRACT = "microclaw.windows-node-approval.v1"; export const APPROVAL_PROOF_PLAN_CONTRACT = "microclaw.windows-node-approval-plan.v2"; export const APPROVAL_PROOF_TTL_MS = 15_000; const APPROVAL_PROOF_MINTER_SYMBOL = Symbol.for("microclaw.windows-node-mxc.approval-proof.v1"); +const HOOKS_GATE_SYMBOL = Symbol.for("microclaw.windows-node-mxc.hooks-gate.v1"); const PRELOAD_INITIALIZED_ENV = "MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED"; +export const PINNED_WEB_FETCH_HTTP_ONLY_SOURCE = + 'if (!["http:", "https:"].includes(parsedUrl.protocol)) throw new Error("Invalid URL: must be http or https");'; + +export function attestPinnedOpenClawWebFetchHttpOnly(source) { + if (!source.includes(PINNED_WEB_FETCH_HTTP_ONLY_SOURCE)) { + throw new Error("Pinned OpenClaw web_fetch no longer rejects non-HTTP URL schemes"); + } + return source; +} + export const PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE = "async function sanitizeSystemRunParamsForForwarding(opts) {"; const PATCHED_NODE_GATEWAY_MINT_INSERT_SOURCE = `function mintMicroclawNodeApprovalProof(params) { @@ -234,6 +269,351 @@ export function patchPinnedOpenClawExecApproval(source) { ); } +export const PINNED_CRON_STARTUP_MISSED_JOBS_SOURCE = `\t\tawait runMissedJobs(state, { +\t\t\tskipJobIds: skipJobIds.size > 0 ? skipJobIds : void 0, +\t\t\tdeferAgentWork: true +\t\t});`; +const PATCHED_CRON_STARTUP_MISSED_JOBS_SOURCE = `\t\tconst microclawCronResumeFenceRaw = process.env.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS; +\t\tconst microclawCronResumeFenceUnixMs = microclawCronResumeFenceRaw === void 0 ? void 0 : Number(microclawCronResumeFenceRaw); +\t\tif (microclawCronResumeFenceUnixMs !== void 0 && Number.isSafeInteger(microclawCronResumeFenceUnixMs) && microclawCronResumeFenceUnixMs >= 0 && microclawCronResumeFenceUnixMs <= state.deps.nowMs()) { +\t\t\tawait recomputeUnownedCronSchedules(state, { +\t\t\t\trecomputeExpired: true, +\t\t\t\tnowMs: microclawCronResumeFenceUnixMs +\t\t\t}); +\t\t\tstate.deps.log.info({ +\t\t\t\tresumeFenceUnixMs: microclawCronResumeFenceUnixMs +\t\t\t}, "cron: advanced schedules missed during MXC pause"); +\t\t} +\t\tawait runMissedJobs(state, { +\t\t\tskipJobIds: skipJobIds.size > 0 ? skipJobIds : void 0, +\t\t\tdeferAgentWork: true +\t\t});`; + +export function patchPinnedOpenClawCronService(source) { + let patched = replaceExactlyOnce( + source, + PINNED_CRON_STARTUP_MISSED_JOBS_SOURCE, + PATCHED_CRON_STARTUP_MISSED_JOBS_SOURCE, + "Cron startup missed-job fence", + ); + patched = replaceExactlyOnce( + patched, + PINNED_CRON_STARTUP_ACK_SOURCE, + PATCHED_CRON_STARTUP_ACK_SOURCE, + "Cron startup resume acknowledgement", + ); + return patched; +} + +export const PINNED_CRON_STARTUP_ACK_SOURCE = `\t\tarmTimer(state); +\t\tresumeForeignReceiptMonitor(state); +\t\tstate.deps.log.info({`; +const PATCHED_CRON_STARTUP_ACK_SOURCE = `\t\tarmTimer(state); +\t\tresumeForeignReceiptMonitor(state); +\t\tconst microclawCronResumeAckPath = process.env.MICROCLAW_MXC_CRON_RESUME_ACK_PATH; +\t\tconst microclawCronResumeAckToken = process.env.MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN; +\t\tconst microclawCronResumeFenceRaw = process.env.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS; +\t\tif (microclawCronResumeAckPath !== void 0 && microclawCronResumeAckToken !== void 0 && microclawCronResumeFenceRaw !== void 0) { +\t\t\tconst microclawCronResumeFenceUnixMs = Number(microclawCronResumeFenceRaw); +\t\t\tawait (await import("node:fs/promises")).writeFile(microclawCronResumeAckPath, JSON.stringify({ +\t\t\t\tcontract: "microclaw.windows-node-mxc.cron-resume.v1", +\t\t\t\tresumeAfterUnixMs: microclawCronResumeFenceUnixMs, +\t\t\t\tacknowledgementToken: microclawCronResumeAckToken, +\t\t\t\tcompletedAtUnixMs: state.deps.nowMs() +\t\t\t}), { encoding: "utf8", flag: "wx" }); +\t\t\tdelete process.env.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS; +\t\t\tdelete process.env.MICROCLAW_MXC_CRON_RESUME_ACK_PATH; +\t\t\tdelete process.env.MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN; +\t\t} +\t\tstate.deps.log.info({`; + +export const PINNED_CRON_HANDLER_MAP_SOURCE = "const cronHandlers = {"; +const PATCHED_CRON_HANDLER_MAP_SOURCE = `function rejectMicroclawMxcPausedCronMutation(method, respond) { +\tif (process.env.MICROCLAW_MXC_CRON_PAUSED !== "1") return false; +\trespond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, \`\${method} is paused while MicroClaw MXC is enabled\`)); +\treturn true; +} +const cronHandlers = {`; +export const PINNED_CRON_MUTATION_HANDLER_SOURCES = [ + '\t"cron.scratch.set": scopedCronJobHandler("cron.scratch.set", validateCronScratchSetParams, async ({ params, respond, context, client, sessionMutationCommitGuard, hasCurrentClientAuthority }, { jobId, callerScope }) => {', + '\t"cron.add": async ({ params, respond, context, client, sessionMutationCommitGuard, hasCurrentClientAuthority }) => {', + '\t"cron.update": async ({ params, respond, context, client, sessionMutationCommitGuard, hasCurrentClientAuthority }) => {', + '\t"cron.remove": scopedCronJobHandler("cron.remove", validateCronRemoveParams, async ({ respond, context, client, sessionMutationCommitGuard, hasCurrentClientAuthority }, { jobId, callerScope, job }) => {', + '\t"cron.run": scopedCronJobHandler("cron.run", validateCronRunParams, async (options, { jobId, callerScope, job }) => {', +]; + +export function patchPinnedOpenClawCronHandlers(source) { + let patched = replaceExactlyOnce( + source, + PINNED_CRON_HANDLER_MAP_SOURCE, + PATCHED_CRON_HANDLER_MAP_SOURCE, + "Cron handler map", + ); + for (const handlerSource of PINNED_CRON_MUTATION_HANDLER_SOURCES) { + const method = handlerSource.match(/"([^"]+)"/)?.[1]; + if (!method) throw new Error("Pinned OpenClaw Cron mutation handler name is invalid"); + const respondExpression = method === "cron.run" ? "options.respond" : "respond"; + patched = replaceExactlyOnce( + patched, + handlerSource, + `${handlerSource}\n\t\tif (rejectMicroclawMxcPausedCronMutation("${method}", ${respondExpression})) return;`, + `${method} MXC pause fence`, + ); + } + return patched; +} + +export const PINNED_EXTERNAL_HOOK_HANDLER_SOURCE = `\tconst handleHooksRequest = async (req, res) => { +\t\tconst hooksConfig = params.hooksConfig();`; +const PATCHED_EXTERNAL_HOOK_HANDLER_SOURCE = `\tconst handleHooksRequest = async (req, res) => { +\t\tconst microclawMxcHooksGate = globalThis[Symbol.for("microclaw.windows-node-mxc.hooks-gate.v1")]; +\t\tif (microclawMxcHooksGate?.rejectExternal(req, res)) return true; +\t\tconst hooksConfig = params.hooksConfig();`; + +export function patchPinnedOpenClawExternalHooks(source) { + return replaceExactlyOnce( + source, + PINNED_EXTERNAL_HOOK_HANDLER_SOURCE, + PATCHED_EXTERNAL_HOOK_HANDLER_SOURCE, + "external Hooks admission gate", + ); +} + +export const PINNED_INTERNAL_HOOK_TRIGGER_SOURCE = `async function triggerInternalHook(event) { +\tif (!internalHooksEnabledState.enabled) return;`; +const PATCHED_INTERNAL_HOOK_TRIGGER_SOURCE = `async function triggerInternalHook(event) { +\tconst microclawMxcHooksGate = globalThis[Symbol.for("microclaw.windows-node-mxc.hooks-gate.v1")]; +\tif (microclawMxcHooksGate?.dropInternal(event)) return; +\tif (!internalHooksEnabledState.enabled) return;`; + +export function patchPinnedOpenClawInternalHooks(source) { + return replaceExactlyOnce( + source, + PINNED_INTERNAL_HOOK_TRIGGER_SOURCE, + PATCHED_INTERNAL_HOOK_TRIGGER_SOURCE, + "internal Hooks dispatch gate", + ); +} + +const PINNED_CANVAS_EXTENSION_COMMANDS = `const CANVAS_NODE_COMMANDS = [ +\t"canvas.present", +\t"canvas.hide", +\t"canvas.navigate" +];`; +const PATCHED_CANVAS_EXTENSION_COMMANDS = `const CANVAS_NODE_COMMANDS = [ +\t"canvas.present", +\t"canvas.hide", +\t"canvas.snapshot", +\t"canvas.caps", +\t"canvas.a2ui.push", +\t"canvas.a2ui.pushJSONL", +\t"canvas.a2ui.reset", +\t"canvas.a2ui.dump" +];`; + +export function patchPinnedOpenClawCanvasExtension(source) { + let patched = replaceExactlyOnce( + source, + PINNED_CANVAS_EXTENSION_COMMANDS, + PATCHED_CANVAS_EXTENSION_COMMANDS, + "Canvas Phase 1 command registration", + ); + patched = replaceExactlyOnce( + patched, + 'description: "Presents hosted widget documents on paired macOS panels.",', + 'description: "Presents the managed native A2UI Canvas on paired MicroClaw Windows nodes.",', + "Canvas Phase 1 plugin description", + ); + return replaceExactlyOnce( + patched, + 'defaultPlatforms: ["macos"],', + 'defaultPlatforms: ["windows"],', + "Canvas Windows platform registration", + ); +} + +const PINNED_CANVAS_TOOL_PRESENT_CASE = `\t\t\t\tcase "present": { +\t\t\t\t\tconst placement = { +\t\t\t\t\t\tx: readFiniteNumberParam(params, "x"), +\t\t\t\t\t\ty: readFiniteNumberParam(params, "y"), +\t\t\t\t\t\twidth: readFiniteNumberParam(params, "width"), +\t\t\t\t\t\theight: readFiniteNumberParam(params, "height") +\t\t\t\t\t}; +\t\t\t\t\tconst invokeParams = {}; +\t\t\t\t\tconst presentTarget = readToolStringParam(params, "target", { trim: true }) ?? readToolStringParam(params, "url", { trim: true }); +\t\t\t\t\tif (presentTarget) invokeParams.url = presentTarget; +\t\t\t\t\tif (Object.values(placement).some(Number.isFinite)) invokeParams.placement = placement; +\t\t\t\t\tconst node = await invoke(CANVAS_PRESENT_COMMAND, invokeParams); +\t\t\t\t\treturn jsonResult({ +\t\t\t\t\t\tok: true, +\t\t\t\t\t\tnode, +\t\t\t\t\t\t...presentTarget ? { url: presentTarget } : {} +\t\t\t\t\t}); +\t\t\t\t}`; +const PATCHED_CANVAS_TOOL_PRESENT_CASE = `\t\t\t\tcase "present": { +\t\t\t\t\tconst { node } = await invoke(CANVAS_PRESENT_COMMAND, {}); +\t\t\t\t\treturn jsonResult({ +\t\t\t\t\t\tok: true, +\t\t\t\t\t\tnode +\t\t\t\t\t}); +\t\t\t\t}`; +const PINNED_CANVAS_TOOL_NAVIGATE_CASE = `\t\t\t\tcase "navigate": { +\t\t\t\t\tconst url = readToolStringParam(params, "url", { trim: true }) ?? readToolStringParam(params, "target", { +\t\t\t\t\t\trequired: true, +\t\t\t\t\t\ttrim: true, +\t\t\t\t\t\tlabel: "url" +\t\t\t\t\t}); +\t\t\t\t\tconst node = await invoke("canvas.navigate", { url }); +\t\t\t\t\treturn jsonResult({ +\t\t\t\t\t\tok: true, +\t\t\t\t\t\tnode, +\t\t\t\t\t\turl +\t\t\t\t\t}); +\t\t\t\t}`; + +export function patchPinnedOpenClawCanvasTool(source) { + let patched = replaceExactlyOnce( + source, + '\t\t\t\tawait callGatewayTool("node.invoke", {', + '\t\t\t\tconst result = await callGatewayTool("node.invoke", {', + "Canvas invocation result capture", + ); + patched = replaceExactlyOnce( + patched, + "\t\t\t\treturn nodeId;\n\t\t\t};", + "\t\t\t\treturn { node: nodeId, result };\n\t\t\t};", + "Canvas invocation result projection", + ); + patched = replaceExactlyOnce( + patched, + PINNED_CANVAS_TOOL_PRESENT_CASE, + PATCHED_CANVAS_TOOL_PRESENT_CASE, + "Canvas present target removal", + ); + patched = replaceExactlyOnce( + patched, + PINNED_CANVAS_TOOL_NAVIGATE_CASE, + "", + "Canvas navigate removal", + ); + patched = replaceExactlyOnce( + patched, + 'const node = await invoke("canvas.hide");', + 'const { node } = await invoke("canvas.hide");', + "Canvas hide result projection", + ); + return replaceExactlyOnce( + patched, + "\t\t\t\tdefault: throw new Error(`Unknown action: ${action}`);", + `\t\t\t\tcase "snapshot": { +\t\t\t\t\tconst format = readToolStringParam(params, "format", { trim: true }); +\t\t\t\t\tconst { node, result } = await invoke("canvas.snapshot", format ? { format } : {}); +\t\t\t\t\treturn jsonResult({ ok: true, node, result }); +\t\t\t\t} +\t\t\t\tcase "caps": { +\t\t\t\t\tconst { node, result } = await invoke("canvas.caps", {}); +\t\t\t\t\treturn jsonResult({ ok: true, node, result }); +\t\t\t\t} +\t\t\t\tcase "a2ui_push": +\t\t\t\tcase "a2ui_push_jsonl": { +\t\t\t\t\tconst jsonl = readToolStringParam(params, "jsonl", { required: true }); +\t\t\t\t\tconst command = action === "a2ui_push" ? "canvas.a2ui.push" : "canvas.a2ui.pushJSONL"; +\t\t\t\t\tconst { node, result } = await invoke(command, { +\t\t\t\t\t\tjsonl, +\t\t\t\t\t\tprops: options?.agentSessionKey ? { sessionKey: options.agentSessionKey } : {} +\t\t\t\t\t}); +\t\t\t\t\treturn jsonResult({ ok: true, node, result }); +\t\t\t\t} +\t\t\t\tcase "a2ui_reset": { +\t\t\t\t\tconst { node, result } = await invoke("canvas.a2ui.reset", {}); +\t\t\t\t\treturn jsonResult({ ok: true, node, result }); +\t\t\t\t} +\t\t\t\tcase "a2ui_dump": { +\t\t\t\t\tconst { node, result } = await invoke("canvas.a2ui.dump", {}); +\t\t\t\t\treturn jsonResult({ ok: true, node, result }); +\t\t\t\t} +\t\t\t\tdefault: throw new Error(\`Unknown action: \${action}\`);`, + "Canvas Phase 1 safe tool actions", + ); +} + +const PINNED_CANVAS_TOOL_SCHEMA = `parameters: Type.Object({ +\t\taction: stringEnum([ +\t\t\t"present", +\t\t\t"hide", +\t\t\t"navigate" +\t\t]), +\t\tgatewayUrl: Type.Optional(Type.String()), +\t\tgatewayToken: Type.Optional(Type.String()), +\t\ttimeoutMs: optionalPositiveIntegerSchema(), +\t\tnode: Type.Optional(Type.String()), +\t\ttarget: Type.Optional(Type.String()), +\t\tx: optionalFiniteNumberSchema(), +\t\ty: optionalFiniteNumberSchema(), +\t\twidth: optionalFiniteNumberSchema(), +\t\theight: optionalFiniteNumberSchema(), +\t\turl: Type.Optional(Type.String()) +\t})`; +const PATCHED_CANVAS_TOOL_SCHEMA = `parameters: Type.Object({ +\t\taction: stringEnum([ +\t\t\t"present", +\t\t\t"hide", +\t\t\t"snapshot", +\t\t\t"caps", +\t\t\t"a2ui_push", +\t\t\t"a2ui_push_jsonl", +\t\t\t"a2ui_reset", +\t\t\t"a2ui_dump" +\t\t]), +\t\tgatewayUrl: Type.Optional(Type.String()), +\t\tgatewayToken: Type.Optional(Type.String()), +\t\ttimeoutMs: optionalPositiveIntegerSchema(), +\t\tnode: Type.Optional(Type.String()), +\t\tjsonl: Type.Optional(Type.String()), +\t\tformat: Type.Optional(Type.String()) +\t})`; +const PINNED_CANVAS_ELIGIBILITY = `function isEligibleCanvasNode(node) { +\tconst commands = node.invocableCommands ?? node.commands ?? []; +\treturn /^macos(?:\\s|$)/i.test(node.platform ?? "") && node.connected === true && commands.includes("canvas.present"); +} +const CANVAS_NODE_MESSAGES = { +\tineligibleExact: (query, eligibleIds) => \`node "\${query}" is not an eligible Canvas panel (requires a connected macOS node advertising \${CANVAS_PRESENT_COMMAND}; eligible node ids: \${eligibleIds})\`, +\tnameResolveFailed: (reason, eligibleIds) => \`\${reason} (eligible Canvas panel node ids: \${eligibleIds})\`, +\tnoneEligible: () => \`no eligible Canvas panel (requires a connected macOS node advertising \${CANVAS_PRESENT_COMMAND})\`, +\tmultipleEligible: (eligible) => \`multiple eligible Canvas panels connected; pass node explicitly: \${eligible.map((node) => node.nodeId).join(", ")}\` +};`; +const PATCHED_CANVAS_ELIGIBILITY = `function isEligibleCanvasNode(node) { +\tconst commands = node.invocableCommands ?? node.commands ?? []; +\treturn /^windows(?:\\s|$)/i.test(node.platform ?? "") && node.connected === true && commands.includes("canvas.present"); +} +const CANVAS_NODE_MESSAGES = { +\tineligibleExact: (query, eligibleIds) => \`node "\${query}" is not an eligible MicroClaw Canvas (requires a connected Windows node advertising \${CANVAS_PRESENT_COMMAND}; eligible node ids: \${eligibleIds})\`, +\tnameResolveFailed: (reason, eligibleIds) => \`\${reason} (eligible MicroClaw Canvas node ids: \${eligibleIds})\`, +\tnoneEligible: () => \`no eligible MicroClaw Canvas (requires a connected Windows node advertising \${CANVAS_PRESENT_COMMAND})\`, +\tmultipleEligible: (eligible) => \`multiple eligible MicroClaw Canvas nodes connected; pass node explicitly: \${eligible.map((node) => node.nodeId).join(", ")}\` +};`; + +export function patchPinnedOpenClawCanvasEligibility(source) { + let patched = replaceExactlyOnce( + source, + 'description: "Present, hide, or navigate the widget panel on a paired macOS node.",', + 'description: "Present, update, inspect, snapshot, or hide the managed native A2UI Canvas on a paired MicroClaw Windows node.",', + "Canvas Phase 1 tool description", + ); + patched = replaceExactlyOnce( + patched, + PINNED_CANVAS_TOOL_SCHEMA, + PATCHED_CANVAS_TOOL_SCHEMA, + "Canvas Phase 1 tool schema", + ); + return replaceExactlyOnce( + patched, + PINNED_CANVAS_ELIGIBILITY, + PATCHED_CANVAS_ELIGIBILITY, + "Canvas Windows node eligibility", + ); +} + function encodeField(name, value) { return `${name}=${Buffer.byteLength(value, "utf8")}:${value}`; } @@ -413,7 +793,111 @@ function installApprovalProofMinter() { }); } -export function validatePinnedOpenClawApprovalPackage(packageDir) { +export function isWindowsNodeMxcProtectedHooksPath(rawUrl, basePath) { + let pathname; + try { + pathname = new URL(rawUrl ?? "/", "http://127.0.0.1").pathname; + } catch { + return false; + } + const candidates = new Set(); + const addCandidate = (candidate) => { + candidates.add(candidate); + candidates.add(candidate.replaceAll("\\", "/")); + }; + addCandidate(pathname); + let decoded = pathname; + for (let depth = 0; depth < 3; depth += 1) { + try { + const next = decodeURIComponent(decoded); + if (next === decoded) break; + addCandidate(next); + decoded = next; + } catch { + break; + } + } + return [...candidates].some( + (candidate) => candidate === basePath || candidate.startsWith(`${basePath}/`), + ); +} + +export function createWindowsNodeMxcHooksGate(policy) { + if ( + !policy || + typeof policy !== "object" || + Array.isArray(policy) || + policy.contract !== "microclaw.windows-node-mxc.hooks-gate.v1" || + typeof policy.gatewayGeneration !== "string" || + !policy.gatewayGeneration || + !/^[a-f0-9]{64}$/i.test(policy.policyFingerprint ?? "") || + (policy.requestedMode !== "full" && policy.requestedMode !== "limited-dacl") || + typeof policy.externalRequested !== "boolean" || + typeof policy.internalRequested !== "boolean" || + typeof policy.basePath !== "string" || + !policy.basePath.startsWith("/") || + policy.basePath === "/" || + policy.basePath.endsWith("/") || + /[%?#\\]/.test(policy.basePath) || + policy.basePath.split("/").some((segment) => segment === "." || segment === "..") + ) { + throw new Error("MicroClaw MXC Hooks gate policy is invalid"); + } + const immutablePolicy = Object.freeze({ ...policy }); + return Object.freeze({ + policy: immutablePolicy, + rejectExternal(req, res) { + if (!isWindowsNodeMxcProtectedHooksPath(req?.url, immutablePolicy.basePath)) { + return false; + } + res.statusCode = 503; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end( + JSON.stringify({ + ok: false, + error: "Hooks and webhooks are paused while MicroClaw MXC is enabled", + code: "MXC_HOOKS_PAUSED", + }), + ); + return true; + }, + dropInternal() { + return true; + }, + }); +} + +function installWindowsNodeMxcHooksGate() { + const encoded = readRequiredEnvironment("MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64", true); + const bytes = Buffer.from(encoded, "base64"); + if (bytes.toString("base64") !== encoded) { + throw new Error("MicroClaw MXC Hooks gate policy encoding is invalid"); + } + let policy; + try { + policy = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error("MicroClaw MXC Hooks gate policy JSON is invalid"); + } + Object.defineProperty(globalThis, HOOKS_GATE_SYMBOL, { + value: createWindowsNodeMxcHooksGate(policy), + writable: false, + enumerable: false, + configurable: false, + }); +} + +export function validatePinnedOpenClawApprovalPackage( + packageDir, + capabilities = { + approval: true, + cronPause: false, + cronResume: false, + hooksPause: false, + canvasPhase1: false, + }, +) { if (!packageDir || !isAbsolute(packageDir)) { throw new Error("MICROCLAW_OPENCLAW_PACKAGE_DIR must be an absolute path"); } @@ -426,24 +910,82 @@ export function validatePinnedOpenClawApprovalPackage(packageDir) { ); } - return [ - { - module: EXPECTED_NODE_GATEWAY_MODULE, - sha256: EXPECTED_NODE_GATEWAY_SHA256, - patch: patchPinnedOpenClawNodeGateway, - }, - { - module: EXPECTED_SYSTEM_RUN_MODULE, - sha256: EXPECTED_SYSTEM_RUN_SHA256, - patch: patchPinnedOpenClawSystemRun, - }, - { - module: EXPECTED_EXEC_APPROVAL_MODULE, - sha256: EXPECTED_EXEC_APPROVAL_SHA256, - patch: patchPinnedOpenClawExecApproval, - }, - ].map((target) => { - const targetPath = realpathSync(join(canonicalPackageDir, "dist", target.module)); + const requestedTargets = []; + if (capabilities.approval) { + requestedTargets.push( + { + module: EXPECTED_NODE_GATEWAY_MODULE, + sha256: EXPECTED_NODE_GATEWAY_SHA256, + patch: patchPinnedOpenClawNodeGateway, + }, + { + module: EXPECTED_SYSTEM_RUN_MODULE, + sha256: EXPECTED_SYSTEM_RUN_SHA256, + patch: patchPinnedOpenClawSystemRun, + }, + { + module: EXPECTED_EXEC_APPROVAL_MODULE, + sha256: EXPECTED_EXEC_APPROVAL_SHA256, + patch: patchPinnedOpenClawExecApproval, + }, + { + module: EXPECTED_WEB_TOOLS_MODULE, + sha256: EXPECTED_WEB_TOOLS_SHA256, + patch: attestPinnedOpenClawWebFetchHttpOnly, + }, + ); + } + if (capabilities.cronResume) { + requestedTargets.push({ + module: EXPECTED_CRON_SERVICE_MODULE, + sha256: EXPECTED_CRON_SERVICE_SHA256, + patch: patchPinnedOpenClawCronService, + }); + } + if (capabilities.cronPause) { + requestedTargets.push({ + module: EXPECTED_CRON_HANDLERS_MODULE, + sha256: EXPECTED_CRON_HANDLERS_SHA256, + patch: patchPinnedOpenClawCronHandlers, + }); + } + if (capabilities.hooksPause) { + requestedTargets.push( + { + module: EXPECTED_HOOKS_SERVER_MODULE, + sha256: EXPECTED_HOOKS_SERVER_SHA256, + patch: patchPinnedOpenClawExternalHooks, + }, + { + module: EXPECTED_INTERNAL_HOOKS_MODULE, + sha256: EXPECTED_INTERNAL_HOOKS_SHA256, + patch: patchPinnedOpenClawInternalHooks, + }, + ); + } + if (capabilities.canvasPhase1) { + requestedTargets.push( + { + module: EXPECTED_CANVAS_EXTENSION_MODULE, + sha256: EXPECTED_CANVAS_EXTENSION_SHA256, + patch: patchPinnedOpenClawCanvasExtension, + }, + { + module: EXPECTED_CANVAS_TOOL_MODULE, + sha256: EXPECTED_CANVAS_TOOL_SHA256, + patch: patchPinnedOpenClawCanvasTool, + }, + { + module: EXPECTED_CANVAS_ELIGIBILITY_MODULE, + sha256: EXPECTED_CANVAS_ELIGIBILITY_SHA256, + patch: patchPinnedOpenClawCanvasEligibility, + }, + ); + } + + return requestedTargets.map((target) => { + const modulePath = join(canonicalPackageDir, "dist", target.module); + const targetPath = realpathSync(modulePath); const targetRelative = relative(canonicalPackageDir, targetPath); if (targetRelative.startsWith("..") || isAbsolute(targetRelative)) { throw new Error("Pinned OpenClaw approval module escaped its package directory"); @@ -454,36 +996,89 @@ export function validatePinnedOpenClawApprovalPackage(packageDir) { throw new Error(`Pinned OpenClaw approval module hash mismatch (${target.module}): ${hash}`); } target.patch(original.toString("utf8")); - return { ...target, targetPath, original }; + return { ...target, modulePath, targetPath, original }; }); } function initialize() { - const targets = validatePinnedOpenClawApprovalPackage(process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR); - installApprovalProofMinter(); + const approvalEnabled = process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT === "1"; + const cronPauseEnabled = process.env.MICROCLAW_MXC_CRON_PAUSED === "1"; + const cronResumeFence = process.env.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS; + const cronResumeEnabled = cronResumeFence !== void 0; + const hooksPauseEnabled = process.env.MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64 !== void 0; + const canvasPhase1Enabled = process.env.MICROCLAW_MXC_CANVAS_PHASE1 === "1"; + if (cronResumeEnabled) { + const parsedFence = Number(cronResumeFence); + const acknowledgementPath = process.env.MICROCLAW_MXC_CRON_RESUME_ACK_PATH; + const acknowledgementToken = process.env.MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN; + if ( + !Number.isSafeInteger(parsedFence) || + parsedFence < 0 || + parsedFence > Date.now() || + !acknowledgementPath || + !isAbsolute(acknowledgementPath) || + !acknowledgementToken + ) { + throw new Error("MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS is invalid"); + } + } + const targets = validatePinnedOpenClawApprovalPackage( + process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR, + { + approval: approvalEnabled, + cronPause: cronPauseEnabled, + cronResume: cronResumeEnabled, + hooksPause: hooksPauseEnabled, + canvasPhase1: canvasPhase1Enabled, + }, + ); + if (approvalEnabled) installApprovalProofMinter(); + if (hooksPauseEnabled) installWindowsNodeMxcHooksGate(); registerHooks({ load(url, context, nextLoad) { const result = nextLoad(url, context); - const loadedPath = url.startsWith("file:") ? realpathSync(fileURLToPath(url)) : null; - const target = targets.find((candidate) => candidate.targetPath === loadedPath); + const requestedPath = url.startsWith("file:") ? resolve(fileURLToPath(url)) : null; + const lexicalTarget = targets.find( + (candidate) => resolve(candidate.modulePath) === requestedPath, + ); + const loadedPath = requestedPath ? realpathSync(requestedPath) : null; + if (lexicalTarget && lexicalTarget.targetPath !== loadedPath) { + throw new Error( + `Pinned OpenClaw module changed after validation (${lexicalTarget.module})`, + ); + } + const target = + lexicalTarget ?? targets.find((candidate) => candidate.targetPath === loadedPath); if (target) { - const source = - typeof result.source === "string" - ? result.source - : Buffer.from(result.source ?? target.original).toString("utf8"); - return { ...result, source: target.patch(source) }; + return { ...result, source: target.patch(target.original.toString("utf8")) }; } return result; }, }); globalThis.console.log( - "[microclaw-openclaw-compat] enabled one-use node proof and prepared-plan backports", + `[microclaw-openclaw-compat] enabled ${[ + approvalEnabled ? "one-use node proof and prepared-plan backports" : null, + cronPauseEnabled ? "MXC Cron mutation pause fence" : null, + cronResumeEnabled ? "MXC Cron no-catch-up resume fence" : null, + hooksPauseEnabled ? "MXC Hooks/Webhooks admission and dispatch pause" : null, + canvasPhase1Enabled ? "MXC Canvas Phase 1 exact tool and command surface" : null, + ] + .filter(Boolean) + .join("; ")}`, ); } export function shouldInitializeApprovalPreload(environment, mainThread) { - if (environment.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT !== "1") return false; + if ( + environment.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT !== "1" && + environment.MICROCLAW_MXC_CRON_PAUSED !== "1" && + environment.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS === void 0 && + environment.MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64 === void 0 && + environment.MICROCLAW_MXC_CANVAS_PHASE1 !== "1" + ) { + return false; + } if (environment[PRELOAD_INITIALIZED_ENV] === "1") return false; if (!mainThread) { throw new Error("MicroClaw approval preload must initialize on the Gateway main thread"); diff --git a/desktop/src/openclaw-approval-replay-compat.test.ts b/desktop/src/openclaw-approval-replay-compat.test.ts index 83901ba..fa98fa8 100644 --- a/desktop/src/openclaw-approval-replay-compat.test.ts +++ b/desktop/src/openclaw-approval-replay-compat.test.ts @@ -4,20 +4,35 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { WINDOWS_NODE_MXC_BLOCKED_CRON_METHODS } from "./windows-node-mxc-cron"; // @ts-expect-error The compatibility preload is intentionally external ESM for the Gateway child. import * as replayCompat from "./openclaw-approval-replay-compat.mjs"; const { APPROVAL_PROOF_CONTRACT, APPROVAL_PROOF_TTL_MS, + PINNED_WEB_FETCH_HTTP_ONLY_SOURCE, PINNED_EXEC_APPROVAL_VALIDATION_SOURCE, + PINNED_CRON_STARTUP_MISSED_JOBS_SOURCE, + PINNED_CRON_STARTUP_ACK_SOURCE, + PINNED_CRON_HANDLER_MAP_SOURCE, + PINNED_CRON_MUTATION_HANDLER_SOURCES, + PINNED_EXTERNAL_HOOK_HANDLER_SOURCE, + PINNED_INTERNAL_HOOK_TRIGGER_SOURCE, PINNED_NODE_GATEWAY_APPROVAL_SOURCE, PINNED_NODE_GATEWAY_ASK_FALLBACK_SOURCE, PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE, PINNED_SYSTEM_RUN_PLAN_SOURCE, + attestPinnedOpenClawWebFetchHttpOnly, createWindowsNodeMxcApprovalProofMinter, computeWindowsNodeMxcApprovalPlanSha256, + createWindowsNodeMxcHooksGate, + isWindowsNodeMxcProtectedHooksPath, patchPinnedOpenClawExecApproval, + patchPinnedOpenClawCronService, + patchPinnedOpenClawCronHandlers, + patchPinnedOpenClawExternalHooks, + patchPinnedOpenClawInternalHooks, patchPinnedOpenClawNodeGateway, patchPinnedOpenClawSystemRun, shouldInitializeApprovalPreload, @@ -63,11 +78,25 @@ describe("pinned OpenClaw package validation", () => { const publishedPackage = process.env.MICROCLAW_OPENCLAW_TEST_PACKAGE_DIR; describe.skipIf(!publishedPackage)("official npm package integration", () => { it("validates all published hashes and patch points and parses the patched bundles", () => { - const targets = validatePinnedOpenClawApprovalPackage(publishedPackage); + const targets = validatePinnedOpenClawApprovalPackage(publishedPackage, { + approval: true, + cronPause: true, + cronResume: true, + hooksPause: true, + canvasPhase1: true, + }); expect(targets.map((target: { module: string }) => target.module)).toEqual([ "nodes-CUbe_1MQ.mjs", "system-run-approval-plan-Cu-Ftdkp.mjs", "exec-approval-mGU7M4-K.mjs", + "openclaw-tools-Dxfbrtff.mjs", + "service-31u04axN.mjs", + "cron-D3Bd3ZFt.mjs", + "server-start-DfGddYU-.mjs", + "internal-hooks-DfVqU7ax.mjs", + "extensions/canvas/index.js", + "tool-CpRzQ0s7.mjs", + "node-eligibility-UXb9xB0F.mjs", ]); for (const target of targets) { const patched = target.patch(target.original.toString("utf8")); @@ -83,6 +112,24 @@ describe("pinned OpenClaw package validation", () => { patched.indexOf("next.microclawApprovalProof ="), ); } + if (target.module === "extensions/canvas/index.js") { + expect(patched).toContain('"canvas.snapshot"'); + expect(patched).not.toContain('"canvas.navigate"'); + expect(patched).toContain('defaultPlatforms: ["windows"]'); + } + if (target.module === "tool-CpRzQ0s7.mjs") { + expect(patched).not.toContain('case "navigate"'); + expect(patched).not.toContain("presentTarget"); + expect(patched).toContain('case "a2ui_push"'); + expect(patched).toContain('"canvas.snapshot"'); + } + if (target.module === "node-eligibility-UXb9xB0F.mjs") { + expect(patched).toContain("/^windows(?:\\s|$)/i"); + expect(patched).not.toContain('"navigate"'); + expect(patched).toContain('"a2ui_push"'); + expect(patched).not.toContain("target: Type.Optional"); + expect(patched).not.toContain("url: Type.Optional"); + } } }); @@ -104,6 +151,7 @@ assert.equal(typeof globalThis[Symbol.for("microclaw.windows-node-mxc.approval-p env: { ...process.env, MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT: "1", + MICROCLAW_MXC_CANVAS_PHASE1: "1", MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED: "", MICROCLAW_OPENCLAW_PACKAGE_DIR: publishedPackage, MICROCLAW_MXC_APPROVAL_PROOF_SECRET: Buffer.alloc(32, 1).toString("base64"), @@ -119,6 +167,211 @@ assert.equal(typeof globalThis[Symbol.for("microclaw.windows-node-mxc.approval-p expect(initialized.status, initialized.stderr).toBe(0); expect(initialized.stdout).toContain("enabled one-use node proof"); }); + + it("initializes the Cron-only resume fence without approval secrets", () => { + const hook = pathToFileURL(resolve(__dirname, "openclaw-approval-replay-compat.mjs")).href; + const initialized = spawnSync(process.execPath, ["--import", hook, "-e", ""], { + env: { + ...process.env, + MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT: "", + MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED: "", + MICROCLAW_OPENCLAW_PACKAGE_DIR: publishedPackage, + MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS: "1735689600000", + MICROCLAW_MXC_CRON_RESUME_ACK_PATH: join(tmpdir(), "microclaw-cron-resume-test.json"), + MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN: "test-token", + MICROCLAW_MXC_APPROVAL_PROOF_SECRET: "", + }, + encoding: "utf8", + timeout: 10_000, + }); + expect(initialized.error).toBeUndefined(); + expect(initialized.status, initialized.stderr).toBe(0); + expect(initialized.stdout).toContain("MXC Cron no-catch-up resume fence"); + }); + }); +}); + +describe("pinned OpenClaw web fetch contract", () => { + it("attests the HTTP(S)-only URL scheme guard", () => { + expect(attestPinnedOpenClawWebFetchHttpOnly(PINNED_WEB_FETCH_HTTP_ONLY_SOURCE)).toBe( + PINNED_WEB_FETCH_HTTP_ONLY_SOURCE, + ); + expect(() => + attestPinnedOpenClawWebFetchHttpOnly( + 'if (parsedUrl.protocol === "file:") return readFile(parsedUrl);', + ), + ).toThrow(/no longer rejects non-HTTP URL schemes/); + }); + + describe("pinned OpenClaw Cron resume contract", () => { + it("advances only pre-fence schedules before normal startup catch-up", () => { + const patched = patchPinnedOpenClawCronService( + `${PINNED_CRON_STARTUP_MISSED_JOBS_SOURCE}\n${PINNED_CRON_STARTUP_ACK_SOURCE}`, + ); + + expect(patched).toContain("MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS"); + expect(patched).toContain("cron: advanced schedules missed during MXC pause"); + expect(patched).toContain("nowMs: microclawCronResumeFenceUnixMs"); + expect(patched).toContain("await runMissedJobs(state"); + expect(patched).toContain("MICROCLAW_MXC_CRON_RESUME_ACK_PATH"); + expect(patched).toContain('flag: "wx"'); + expect(patched).toContain("delete process.env.MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS"); + expect(patched.indexOf("nowMs: microclawCronResumeFenceUnixMs")).toBeLessThan( + patched.indexOf("await runMissedJobs(state"), + ); + }); + + describe("pinned OpenClaw Hooks pause contract", () => { + it("rejects the configured external hook path before reading or dispatching the request", () => { + const patched = patchPinnedOpenClawExternalHooks(PINNED_EXTERNAL_HOOK_HANDLER_SOURCE); + expect(patched).toContain("microclawMxcHooksGate?.rejectExternal(req, res)"); + expect(patched.indexOf("rejectExternal(req, res)")).toBeLessThan( + patched.indexOf("params.hooksConfig()"), + ); + }); + + it("drops internal hook events before listener lookup or handler execution", () => { + const patched = patchPinnedOpenClawInternalHooks(PINNED_INTERNAL_HOOK_TRIGGER_SOURCE); + expect(patched).toContain("microclawMxcHooksGate?.dropInternal(event)"); + expect(patched.indexOf("dropInternal(event)")).toBeLessThan( + patched.indexOf("internalHooksEnabledState.enabled"), + ); + }); + + it("returns an explicit unavailable response only at the protected external path", () => { + const gate = createWindowsNodeMxcHooksGate({ + contract: "microclaw.windows-node-mxc.hooks-gate.v1", + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "limited-dacl", + externalRequested: true, + internalRequested: true, + basePath: "/hooks", + }); + const headers: Record = {}; + let body = ""; + const response = { + statusCode: 200, + setHeader(name: string, value: string) { + headers[name] = value; + }, + end(value: string) { + body = value; + }, + }; + + expect(gate.rejectExternal({ url: "/health" }, response)).toBe(false); + expect(gate.rejectExternal({ url: "/hooks/agent?token=secret" }, response)).toBe(true); + expect(response.statusCode).toBe(503); + expect(headers["Cache-Control"]).toBe("no-store"); + expect(JSON.parse(body)).toMatchObject({ ok: false, code: "MXC_HOOKS_PAUSED" }); + expect(body).not.toContain("secret"); + expect(gate.dropInternal({ type: "command:new" })).toBe(true); + }); + + it("rejects encoded protected paths without reading or admitting request content", () => { + expect(isWindowsNodeMxcProtectedHooksPath("/%68ooks/agent", "/hooks")).toBe(true); + expect(isWindowsNodeMxcProtectedHooksPath("/%2568ooks/agent", "/hooks")).toBe(true); + expect(isWindowsNodeMxcProtectedHooksPath("/hooks%5Cagent", "/hooks")).toBe(true); + expect(isWindowsNodeMxcProtectedHooksPath("/hooks/%E0", "/hooks")).toBe(true); + expect(isWindowsNodeMxcProtectedHooksPath("/health/%68ooks", "/hooks")).toBe(false); + + const gate = createWindowsNodeMxcHooksGate({ + contract: "microclaw.windows-node-mxc.hooks-gate.v1", + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "full", + externalRequested: true, + internalRequested: true, + basePath: "/hooks", + }); + const request = { + url: "/%68ooks/agent", + get body() { + throw new Error("request body must not be read"); + }, + }; + const response = { + statusCode: 200, + setHeader() {}, + end() {}, + }; + + expect(gate.rejectExternal(request, response)).toBe(true); + expect(response.statusCode).toBe(503); + }); + + it("returns explicit unavailability at the default hook path even when none was configured", () => { + const gate = createWindowsNodeMxcHooksGate({ + contract: "microclaw.windows-node-mxc.hooks-gate.v1", + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "full", + externalRequested: false, + internalRequested: false, + basePath: "/hooks", + }); + const response = { + statusCode: 200, + setHeader() {}, + end() {}, + }; + + expect(gate.rejectExternal({ url: "/hooks/wake" }, response)).toBe(true); + expect(response.statusCode).toBe(503); + }); + + it("fails closed when either pinned hook boundary drifts", () => { + expect(() => patchPinnedOpenClawExternalHooks("unrecognized source")).toThrow( + /did not match exactly once/, + ); + expect(() => patchPinnedOpenClawInternalHooks("unrecognized source")).toThrow( + /did not match exactly once/, + ); + }); + + it("rejects ambiguous gate policy paths", () => { + for (const basePath of ["/hooks/%68idden", "/hooks?token=x", "/a/../hooks"]) { + expect(() => + createWindowsNodeMxcHooksGate({ + contract: "microclaw.windows-node-mxc.hooks-gate.v1", + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "limited-dacl", + externalRequested: true, + internalRequested: true, + basePath, + }), + ).toThrow(/policy is invalid/); + } + }); + }); + + it("blocks every Cron mutation and run-now handler while MXC is requested", () => { + const patched = patchPinnedOpenClawCronHandlers( + [PINNED_CRON_HANDLER_MAP_SOURCE, ...PINNED_CRON_MUTATION_HANDLER_SOURCES].join("\n"), + ); + + for (const handler of PINNED_CRON_MUTATION_HANDLER_SOURCES) { + const method = handler.match(/"([^"]+)"/)?.[1]; + const respondExpression = method === "cron.run" ? "options.respond" : "respond"; + expect(patched).toContain( + `rejectMicroclawMxcPausedCronMutation("${method}", ${respondExpression})`, + ); + } + expect( + PINNED_CRON_MUTATION_HANDLER_SOURCES.map( + (handler: string) => handler.match(/"([^"]+)"/)?.[1], + ), + ).toEqual(WINDOWS_NODE_MXC_BLOCKED_CRON_METHODS); + expect(patched).toContain("MICROCLAW_MXC_CRON_PAUSED"); + }); + + it("fails closed when the pinned Cron startup boundary drifts", () => { + expect(() => patchPinnedOpenClawCronService("unrecognized source")).toThrow( + /did not match exactly once/, + ); + }); }); }); @@ -343,6 +596,19 @@ describe("Windows Node MXC approval proof minter", () => { ), ).toBe(false); expect(() => shouldInitializeApprovalPreload(enabled, false)).toThrow(/main thread/); + expect( + shouldInitializeApprovalPreload( + { MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS: "1735689600000" }, + true, + ), + ).toBe(true); + expect(shouldInitializeApprovalPreload({ MICROCLAW_MXC_CRON_PAUSED: "1" }, true)).toBe(true); + expect( + shouldInitializeApprovalPreload({ MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64: "e30=" }, true), + ).toBe(true); + expect(shouldInitializeApprovalPreload({ MICROCLAW_MXC_CANVAS_PHASE1: "1" }, true)).toBe( + true, + ); expect(shouldInitializeApprovalPreload({}, true)).toBe(false); }); }); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 0d796bd..a006517 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -348,7 +348,14 @@ contextBridge.exposeInMainWorld("openclaw", { windowsNodeMxc: { getStatus: () => ipcRenderer.invoke("windows-node-mxc:get-status"), - setEnabled: (params: { enabled: boolean; nodeId?: string }) => + preflight: (params?: { requestedMode?: "off" | "full" | "limited-dacl" }) => + ipcRenderer.invoke("windows-node-mxc:preflight", params), + setEnabled: (params: { + enabled?: boolean; + requestedMode?: "off" | "full" | "limited-dacl"; + acknowledgeLimitedFilesystem?: boolean; + nodeId?: string; + }) => ipcRenderer.invoke("windows-node-mxc:set-enabled", params), validateFolderPolicy: (draft: { rw: string[]; ro: string[] }) => ipcRenderer.invoke("windows-node-mxc:validate-folder-policy", draft), diff --git a/desktop/src/startup-order.test.ts b/desktop/src/startup-order.test.ts index a8603f2..db074a7 100644 --- a/desktop/src/startup-order.test.ts +++ b/desktop/src/startup-order.test.ts @@ -11,11 +11,60 @@ describe("desktop startup order", () => { ); const integrityIndex = lifecycle.indexOf("verifySkillIntegrity()"); - const gatewayIndex = lifecycle.indexOf("startGateway().catch"); + const gatewayIndex = lifecycle.indexOf("startApplicationServices().catch"); const rendererIndex = lifecycle.indexOf("mainWindow.loadFile(indexPath)"); expect(integrityIndex).toBeGreaterThan(-1); expect(gatewayIndex).toBeGreaterThan(integrityIndex); expect(rendererIndex).toBeGreaterThan(gatewayIndex); }); + + it("stops the standard Gateway before Off-to-MXC preflight so paused ingress cannot race", () => { + const source = readFileSync(resolve(process.cwd(), "src/main.ts"), "utf8"); + const handler = source.slice( + source.indexOf('ipcMain.handle(\n "windows-node-mxc:set-enabled"'), + source.indexOf('ipcMain.handle("windows-node-mxc:retry-readiness"'), + ); + const suspensionBranch = handler.indexOf("if (suspendCronBeforePreflight)"); + const gatewayStop = handler.indexOf( + "await stopGatewayForSecurityTransition(configuredPort)", + suspensionBranch, + ); + const preflight = handler.indexOf( + "await prepareWindowsNodeMxcContainment(requestedMode)", + suspensionBranch, + ); + + expect(suspensionBranch).toBeGreaterThan(-1); + expect(handler.indexOf("closeWindowsNodeMxcIngress()", suspensionBranch)).toBeGreaterThan( + suspensionBranch, + ); + expect(gatewayStop).toBeGreaterThan(suspensionBranch); + expect(preflight).toBeGreaterThan(gatewayStop); + expect(handler).toContain("Restoring standard Gateway after MXC preflight failure"); + }); + + it("blocks Gateway restart and generic config mutation before they can change Hooks state", () => { + const source = readFileSync(resolve(process.cwd(), "src/main.ts"), "utf8"); + const restartHandler = source.slice( + source.indexOf('ipcMain.handle("gateway:restart"'), + source.indexOf("// --- Config ---"), + ); + const configWriteHandler = source.slice( + source.indexOf('ipcMain.handle("config:write"'), + source.indexOf("// --- Skills ---"), + ); + const restartGuard = restartHandler.indexOf("assertWindowsNodeMxcConfigurationMutable()"); + const restartMutation = restartHandler.indexOf("restartManagedGateway"); + const configGuard = configWriteHandler.indexOf("isWindowsNodeMxcDesired()"); + const configMutation = configWriteHandler.indexOf("fs.writeFileSync"); + + expect(restartGuard).toBeGreaterThan(-1); + expect(restartMutation).toBeGreaterThan(restartGuard); + expect(configGuard).toBeGreaterThan(-1); + expect(configMutation).toBeGreaterThan(configGuard); + expect(configWriteHandler).toContain( + "config:write is blocked while Windows Node + MXC mode is enabled", + ); + }); }); diff --git a/desktop/src/windows-node-mxc-auto-transition.test.ts b/desktop/src/windows-node-mxc-auto-transition.test.ts index 04c70e0..031d88e 100644 --- a/desktop/src/windows-node-mxc-auto-transition.test.ts +++ b/desktop/src/windows-node-mxc-auto-transition.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { runWindowsNodeMxcAutomaticTransition } from "./windows-node-mxc-auto-transition"; describe("automatic Windows Node + MXC transition", () => { - it("keeps ingress closed until both internally probed generations verify", async () => { + it("keeps one generation locked until its policy is promoted and verified", async () => { const calls: string[] = []; const status = await runWindowsNodeMxcAutomaticTransition({ setPhase: (phase) => calls.push(`phase:${phase}`), closeIngress: async () => { calls.push("close"); }, + preflightExactBackend: async () => { + calls.push("preflight"); + }, startLockedGeneration: async () => { calls.push("start-locked"); return "locked"; @@ -16,11 +19,11 @@ describe("automatic Windows Node + MXC transition", () => { smokeLockedGeneration: async () => { calls.push("smoke-locked"); }, - startActiveGeneration: async () => { - calls.push("start-active"); + promoteActivePolicy: async () => { + calls.push("promote-active"); return "active"; }, - smokeActiveGeneration: async () => { + smokeActivePolicy: async () => { calls.push("smoke-active"); }, mintActivationLease: async () => { @@ -39,12 +42,14 @@ describe("automatic Windows Node + MXC transition", () => { expect(status).toBe("verified"); expect(calls).toEqual([ "close", + "phase:validating", + "preflight", "phase:starting-locked", "start-locked", "phase:smoking-locked", "smoke-locked", - "phase:starting-active", - "start-active", + "phase:promoting-active", + "promote-active", "phase:smoking-active", "smoke-active", "lease", @@ -55,7 +60,53 @@ describe("automatic Windows Node + MXC transition", () => { ]); }); - it.each(["start-locked", "smoke-locked", "start-active", "smoke-active", "lease", "verify"])( + it("does not start or pair a node when exact-backend preflight fails", async () => { + const startLockedGeneration = vi.fn(); + const smokeLockedGeneration = vi.fn(); + const promoteActivePolicy = vi.fn(); + const smokeActivePolicy = vi.fn(); + const mintActivationLease = vi.fn(); + const verifyActiveGeneration = vi.fn(); + const releaseIngress = vi.fn(); + const lockAfterFailure = vi.fn(); + + await expect( + runWindowsNodeMxcAutomaticTransition({ + setPhase: vi.fn(), + closeIngress: vi.fn(), + preflightExactBackend: async () => { + throw new Error("exact backend unavailable"); + }, + startLockedGeneration, + smokeLockedGeneration, + promoteActivePolicy, + smokeActivePolicy, + mintActivationLease, + verifyActiveGeneration, + releaseIngress, + lockAfterFailure, + }), + ).rejects.toThrow("exact backend unavailable"); + + expect(startLockedGeneration).not.toHaveBeenCalled(); + expect(smokeLockedGeneration).not.toHaveBeenCalled(); + expect(promoteActivePolicy).not.toHaveBeenCalled(); + expect(smokeActivePolicy).not.toHaveBeenCalled(); + expect(mintActivationLease).not.toHaveBeenCalled(); + expect(verifyActiveGeneration).not.toHaveBeenCalled(); + expect(releaseIngress).not.toHaveBeenCalled(); + expect(lockAfterFailure).toHaveBeenCalledTimes(1); + }); + + it.each([ + "preflight", + "start-locked", + "smoke-locked", + "promote-active", + "smoke-active", + "lease", + "verify", + ])( "fails locked without releasing ingress when %s fails", async (failure) => { const releaseIngress = vi.fn(); @@ -69,16 +120,17 @@ describe("automatic Windows Node + MXC transition", () => { runWindowsNodeMxcAutomaticTransition({ setPhase: (phase, detail) => phases.push({ phase, detail }), closeIngress: vi.fn(), + preflightExactBackend: async () => fail("preflight"), startLockedGeneration: async () => { fail("start-locked"); return "locked"; }, smokeLockedGeneration: async () => fail("smoke-locked"), - startActiveGeneration: async () => { - fail("start-active"); + promoteActivePolicy: async () => { + fail("promote-active"); return "active"; }, - smokeActiveGeneration: async () => fail("smoke-active"), + smokeActivePolicy: async () => fail("smoke-active"), mintActivationLease: async () => fail("lease"), verifyActiveGeneration: async () => { fail("verify"); diff --git a/desktop/src/windows-node-mxc-auto-transition.ts b/desktop/src/windows-node-mxc-auto-transition.ts index 5da0fca..fcba21e 100644 --- a/desktop/src/windows-node-mxc-auto-transition.ts +++ b/desktop/src/windows-node-mxc-auto-transition.ts @@ -3,10 +3,11 @@ import type { WindowsNodeMxcFolderTransactionPhase } from "./windows-node-mxc-fo export interface WindowsNodeMxcAutomaticTransitionHooks { setPhase(phase: WindowsNodeMxcFolderTransactionPhase, detail?: string | null): void; closeIngress(): Promise | void; + preflightExactBackend(): Promise; startLockedGeneration(): Promise; smokeLockedGeneration(status: TStatus): Promise; - startActiveGeneration(): Promise; - smokeActiveGeneration(status: TStatus): Promise; + promoteActivePolicy(status: TStatus): Promise; + smokeActivePolicy(status: TStatus): Promise; mintActivationLease(): Promise; verifyActiveGeneration(): Promise; releaseIngress(status: TStatus): Promise | void; @@ -19,17 +20,20 @@ export async function runWindowsNodeMxcAutomaticTransition( try { await hooks.closeIngress(); + hooks.setPhase("validating"); + await hooks.preflightExactBackend(); + hooks.setPhase("starting-locked"); const locked = await hooks.startLockedGeneration(); hooks.setPhase("smoking-locked"); await hooks.smokeLockedGeneration(locked); - hooks.setPhase("starting-active"); - const active = await hooks.startActiveGeneration(); + hooks.setPhase("promoting-active"); + const active = await hooks.promoteActivePolicy(locked); hooks.setPhase("smoking-active"); - await hooks.smokeActiveGeneration(active); + await hooks.smokeActivePolicy(active); await hooks.mintActivationLease(); hooks.setPhase("verifying-active"); diff --git a/desktop/src/windows-node-mxc-cron.test.ts b/desktop/src/windows-node-mxc-cron.test.ts new file mode 100644 index 0000000..9d81e9e --- /dev/null +++ b/desktop/src/windows-node-mxc-cron.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + WINDOWS_NODE_MXC_BLOCKED_CRON_METHODS, + WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + annotateWindowsNodeMxcPausedCronJobs, + createWindowsNodeMxcCronResumeFence, + getWindowsNodeMxcCronResumeEnvironment, + isWindowsNodeMxcCronResumeAcknowledgement, + parseWindowsNodeMxcCronResumeFence, + shouldSuspendWindowsNodeMxcCronBeforePreflight, +} from "./windows-node-mxc-cron"; + +describe("Windows Node MXC Cron lifecycle", () => { + it("suspends Cron before only Off-to-contained preflight transitions", () => { + expect(shouldSuspendWindowsNodeMxcCronBeforePreflight("off", "full")).toBe(true); + expect(shouldSuspendWindowsNodeMxcCronBeforePreflight("off", "limited-dacl")).toBe(true); + expect(shouldSuspendWindowsNodeMxcCronBeforePreflight("full", "limited-dacl")).toBe(false); + expect(shouldSuspendWindowsNodeMxcCronBeforePreflight("limited-dacl", "full")).toBe(false); + expect(shouldSuspendWindowsNodeMxcCronBeforePreflight("full", "off")).toBe(false); + }); + + it("pins every Cron mutation and execution RPC as unavailable under MXC", () => { + expect(WINDOWS_NODE_MXC_BLOCKED_CRON_METHODS).toEqual([ + "cron.scratch.set", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + ]); + }); + + it("creates and validates a typed durable resume fence", () => { + const fence = createWindowsNodeMxcCronResumeFence( + "C:\\MicroClaw\\cron-resume.json", + "token-1", + 1_735_689_600_000, + ); + + expect(fence).toEqual({ + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: 1_735_689_600_000, + createdAt: "2025-01-01T00:00:00.000Z", + acknowledgementPath: "C:\\MicroClaw\\cron-resume.json", + acknowledgementToken: "token-1", + }); + expect(parseWindowsNodeMxcCronResumeFence(fence)).toEqual(fence); + expect(getWindowsNodeMxcCronResumeEnvironment(fence)).toEqual({ + MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS: "1735689600000", + MICROCLAW_MXC_CRON_RESUME_ACK_PATH: "C:\\MicroClaw\\cron-resume.json", + MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN: "token-1", + }); + }); + + it("rejects malformed or self-inconsistent resume fences", () => { + expect(parseWindowsNodeMxcCronResumeFence(null)).toBeNull(); + expect( + parseWindowsNodeMxcCronResumeFence({ + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: 1, + createdAt: "wrong", + acknowledgementPath: "C:\\MicroClaw\\cron-resume.json", + acknowledgementToken: "token-1", + }), + ).toBeNull(); + expect(() => + getWindowsNodeMxcCronResumeEnvironment({ + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: -1, + createdAt: "", + acknowledgementPath: "relative", + acknowledgementToken: "", + }), + ).toThrow(/invalid/); + }); + + it("returns preserved jobs with truthful requested and effective states", () => { + expect( + annotateWindowsNodeMxcPausedCronJobs( + { + jobs: [ + { + id: "daily", + enabled: true, + schedule: { kind: "cron", expr: "0 9 * * *" }, + }, + { + id: "disabled", + enabled: false, + schedule: { kind: "cron", expr: "0 10 * * *" }, + }, + ], + }, + "limited-dacl", + ), + ).toEqual({ + jobs: [ + { + id: "daily", + enabled: true, + requestedEnabled: true, + effectiveEnabled: false, + pausedByMxc: true, + requestedMode: "limited-dacl", + schedule: { kind: "cron", expr: "0 9 * * *" }, + }, + { + id: "disabled", + enabled: false, + requestedEnabled: false, + effectiveEnabled: false, + pausedByMxc: false, + requestedMode: "limited-dacl", + schedule: { kind: "cron", expr: "0 10 * * *" }, + }, + ], + pausedByMxc: true, + requestedMode: "limited-dacl", + }); + }); + + it("accepts only the acknowledgement bound to the exact durable fence", () => { + const fence = createWindowsNodeMxcCronResumeFence( + "C:\\MicroClaw\\cron-resume.json", + "token-1", + 100, + ); + expect( + isWindowsNodeMxcCronResumeAcknowledgement( + { + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: 100, + acknowledgementToken: "token-1", + completedAtUnixMs: 101, + }, + fence, + ), + ).toBe(true); + expect( + isWindowsNodeMxcCronResumeAcknowledgement( + { + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: 100, + acknowledgementToken: "wrong", + completedAtUnixMs: 101, + }, + fence, + ), + ).toBe(false); + }); +}); diff --git a/desktop/src/windows-node-mxc-cron.ts b/desktop/src/windows-node-mxc-cron.ts new file mode 100644 index 0000000..132bb3e --- /dev/null +++ b/desktop/src/windows-node-mxc-cron.ts @@ -0,0 +1,117 @@ +import type { WindowsNodeMxcRequestedMode } from "./windows-node-mxc-modes"; +import { isAbsolute } from "node:path"; + +export const WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT = + "microclaw.windows-node-mxc.cron-resume.v1"; +export const WINDOWS_NODE_MXC_BLOCKED_CRON_METHODS = [ + "cron.scratch.set", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", +] as const; + +export interface WindowsNodeMxcCronResumeFence { + contract: typeof WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT; + resumeAfterUnixMs: number; + createdAt: string; + acknowledgementPath: string; + acknowledgementToken: string; +} + +export function shouldSuspendWindowsNodeMxcCronBeforePreflight( + currentMode: WindowsNodeMxcRequestedMode, + requestedMode: WindowsNodeMxcRequestedMode, +): boolean { + return currentMode === "off" && requestedMode !== "off"; +} + +export function createWindowsNodeMxcCronResumeFence( + acknowledgementPath: string, + acknowledgementToken: string, + nowUnixMs = Date.now(), +): WindowsNodeMxcCronResumeFence { + if (!Number.isSafeInteger(nowUnixMs) || nowUnixMs < 0) { + throw new Error("MXC Cron resume fence timestamp is invalid"); + } + if (!isAbsolute(acknowledgementPath) || !acknowledgementToken.trim()) { + throw new Error("MXC Cron resume acknowledgement binding is invalid"); + } + return { + contract: WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT, + resumeAfterUnixMs: nowUnixMs, + createdAt: new Date(nowUnixMs).toISOString(), + acknowledgementPath, + acknowledgementToken, + }; +} + +export function parseWindowsNodeMxcCronResumeFence( + value: unknown, +): WindowsNodeMxcCronResumeFence | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + if ( + candidate.contract !== WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT || + !Number.isSafeInteger(candidate.resumeAfterUnixMs) || + (candidate.resumeAfterUnixMs as number) < 0 || + typeof candidate.createdAt !== "string" || + candidate.createdAt !== new Date(candidate.resumeAfterUnixMs as number).toISOString() || + typeof candidate.acknowledgementPath !== "string" || + !isAbsolute(candidate.acknowledgementPath) || + typeof candidate.acknowledgementToken !== "string" || + !candidate.acknowledgementToken.trim() + ) { + return null; + } + return candidate as unknown as WindowsNodeMxcCronResumeFence; +} + +export function getWindowsNodeMxcCronResumeEnvironment(value: unknown): Record { + const fence = parseWindowsNodeMxcCronResumeFence(value); + if (!fence) throw new Error("Stored MXC Cron resume fence is invalid"); + return { + MICROCLAW_MXC_CRON_RESUME_FENCE_UNIX_MS: String(fence.resumeAfterUnixMs), + MICROCLAW_MXC_CRON_RESUME_ACK_PATH: fence.acknowledgementPath, + MICROCLAW_MXC_CRON_RESUME_ACK_TOKEN: fence.acknowledgementToken, + }; +} + +export function isWindowsNodeMxcCronResumeAcknowledgement( + value: unknown, + fence: WindowsNodeMxcCronResumeFence, +): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + candidate.contract === WINDOWS_NODE_MXC_CRON_RESUME_FENCE_CONTRACT && + candidate.resumeAfterUnixMs === fence.resumeAfterUnixMs && + candidate.acknowledgementToken === fence.acknowledgementToken && + Number.isSafeInteger(candidate.completedAtUnixMs) && + (candidate.completedAtUnixMs as number) >= fence.resumeAfterUnixMs + ); +} + +export function annotateWindowsNodeMxcPausedCronJobs( + result: { jobs?: unknown[] }, + requestedMode: WindowsNodeMxcRequestedMode, +): { jobs: unknown[]; pausedByMxc: true; requestedMode: WindowsNodeMxcRequestedMode } { + if (requestedMode === "off") { + throw new Error("MXC Cron pause metadata requires an active containment mode"); + } + return { + jobs: (Array.isArray(result.jobs) ? result.jobs : []).map((job) => { + if (!job || typeof job !== "object" || Array.isArray(job)) return job; + const requestedEnabled = (job as Record).enabled !== false; + return { + ...job, + requestedEnabled, + pausedByMxc: requestedEnabled, + effectiveEnabled: false, + requestedMode, + }; + }), + pausedByMxc: true, + requestedMode, + }; +} diff --git a/desktop/src/windows-node-mxc-disclosure.test.ts b/desktop/src/windows-node-mxc-disclosure.test.ts new file mode 100644 index 0000000..eda4189 --- /dev/null +++ b/desktop/src/windows-node-mxc-disclosure.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + applyWindowsNodeMxcGatewayPolicy, + WINDOWS_NODE_MXC_CANVAS_ACTIONS, + WINDOWS_NODE_MXC_CANVAS_COMMANDS, + WINDOWS_NODE_MXC_DISABLED_PLUGIN_IDS, + WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS, + WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST, + WINDOWS_NODE_MXC_TOOL_ALLOWLIST, + validateWindowsNodeMxcSettings, +} from "./windows-node-mxc"; +import { + WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE, + WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE_VERSION, +} from "./windows-node-mxc-disclosure"; + +const disclosureIds = WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE.groups.flatMap((group) => + group.items.map((item) => item.id), +); + +describe("Windows Node MXC capability disclosure", () => { + it("has a typed versioned render model with unique critical entries", () => { + expect(WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE.version).toBe( + WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE_VERSION, + ); + expect(new Set(disclosureIds).size).toBe(disclosureIds.length); + expect(WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE.groups.map((group) => group.id)).toEqual([ + "disabled", + "paused", + "available-restricted", + ]); + }); + + it("locks disabled disclosure entries to current policy invariants", () => { + expect(WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS).toEqual([ + "read", + "write", + "edit", + "apply_patch", + ]); + expect(WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST).toEqual( + expect.arrayContaining(["group:runtime", "group:fs", "group:plugins", "browser"]), + ); + expect(WINDOWS_NODE_MXC_DISABLED_PLUGIN_IDS).toContain("memory-core"); + expect(disclosureIds).toEqual( + expect.arrayContaining([ + "gateway-native-filesystem", + "browser-automation", + "mcp", + "screen", + "camera", + "location", + "tts", + "stt", + "gateway-memory", + ]), + ); + + const strictSettings = { + EnableNodeMode: true, + NodeSystemRunEnabled: true, + NodeCanvasEnabled: true, + SystemRunSandboxEnabled: true, + SystemRunBlockHostFallbackWhenMxcUnavailable: true, + SystemRunAllowWindowsUi: true, + SystemRunAllowOutbound: true, + SandboxClipboard: 0, + EnableMcpServer: false, + NodeScreenEnabled: false, + NodeCameraEnabled: false, + NodeLocationEnabled: false, + NodeBrowserProxyEnabled: false, + NodeSttEnabled: false, + NodeTtsEnabled: false, + }; + expect(validateWindowsNodeMxcSettings(strictSettings).ready).toBe(true); + for (const property of [ + "EnableMcpServer", + "NodeScreenEnabled", + "NodeCameraEnabled", + "NodeLocationEnabled", + "NodeBrowserProxyEnabled", + "NodeSttEnabled", + "NodeTtsEnabled", + ] as const) { + expect( + validateWindowsNodeMxcSettings({ ...strictSettings, [property]: true }).ready, + property, + ).toBe(false); + } + }); + + it("locks paused and restricted disclosure entries to the reviewed surface", () => { + expect(WINDOWS_NODE_MXC_TOOL_ALLOWLIST).toEqual([ + "exec", + "web_search", + "web_fetch", + "message", + "canvas", + ]); + expect(WINDOWS_NODE_MXC_CANVAS_COMMANDS).toEqual([ + "canvas.present", + "canvas.hide", + "canvas.snapshot", + "canvas.caps", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "canvas.a2ui.dump", + ]); + expect(WINDOWS_NODE_MXC_CANVAS_ACTIONS).toEqual(["cancel", "click", "confirm", "submit"]); + expect(disclosureIds).toEqual( + expect.arrayContaining([ + "cron", + "hooks-webhooks", + "exec", + "gateway-web", + "wechat-message", + "canvas-phase-1", + ]), + ); + + const applied = applyWindowsNodeMxcGatewayPolicy( + { + agents: { list: [{ id: "main" }] }, + channels: { + discord: { enabled: true }, + "openclaw-weixin": { enabled: true }, + }, + cron: { enabled: true }, + hooks: { enabled: true, internal: { enabled: true } }, + plugins: { + enabled: true, + entries: { + "openclaw-weixin": { enabled: true }, + "memory-core": { enabled: true }, + }, + }, + }, + "node-123", + {}, + "active", + ).config; + expect(applied).toMatchObject({ + channels: { + discord: { enabled: false }, + "openclaw-weixin": { enabled: true }, + }, + cron: { enabled: false }, + hooks: { enabled: false, internal: { enabled: false } }, + plugins: { + allow: ["canvas", "openclaw-weixin"], + slots: { memory: "none" }, + entries: { + canvas: { enabled: true }, + "openclaw-weixin": { enabled: true }, + "memory-core": { enabled: false }, + }, + }, + }); + }); +}); diff --git a/desktop/src/windows-node-mxc-disclosure.ts b/desktop/src/windows-node-mxc-disclosure.ts new file mode 100644 index 0000000..56f1b6d --- /dev/null +++ b/desktop/src/windows-node-mxc-disclosure.ts @@ -0,0 +1,106 @@ +import type { WindowsNodeMxcRequestedMode } from "./windows-node-mxc-modes"; + +export const WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE_VERSION = 1 as const; + +export type WindowsNodeMxcDisclosureTargetMode = Exclude< + WindowsNodeMxcRequestedMode, + "off" +>; + +export type WindowsNodeMxcDisclosureGroupId = + | "disabled" + | "paused" + | "available-restricted"; + +export type WindowsNodeMxcDisclosureItemId = + | "gateway-native-filesystem" + | "browser-automation" + | "mcp" + | "screen" + | "camera" + | "location" + | "tts" + | "stt" + | "gateway-memory" + | "cron" + | "hooks-webhooks" + | "exec" + | "gateway-web" + | "wechat-message" + | "canvas-phase-1"; + +export interface WindowsNodeMxcDisclosureItem { + id: WindowsNodeMxcDisclosureItemId; + translationKey: `settings.windowsNodeMxcConsent.item.${WindowsNodeMxcDisclosureItemId}`; +} + +export interface WindowsNodeMxcDisclosureGroup { + id: WindowsNodeMxcDisclosureGroupId; + titleKey: `settings.windowsNodeMxcConsent.group.${WindowsNodeMxcDisclosureGroupId}`; + items: readonly WindowsNodeMxcDisclosureItem[]; +} + +export interface WindowsNodeMxcCapabilityDisclosure { + version: typeof WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE_VERSION; + groups: readonly WindowsNodeMxcDisclosureGroup[]; +} + +export const WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE = { + version: WINDOWS_NODE_MXC_CAPABILITY_DISCLOSURE_VERSION, + groups: [ + { + id: "disabled", + titleKey: "settings.windowsNodeMxcConsent.group.disabled", + items: [ + { + id: "gateway-native-filesystem", + translationKey: "settings.windowsNodeMxcConsent.item.gateway-native-filesystem", + }, + { + id: "browser-automation", + translationKey: "settings.windowsNodeMxcConsent.item.browser-automation", + }, + { id: "mcp", translationKey: "settings.windowsNodeMxcConsent.item.mcp" }, + { id: "screen", translationKey: "settings.windowsNodeMxcConsent.item.screen" }, + { id: "camera", translationKey: "settings.windowsNodeMxcConsent.item.camera" }, + { id: "location", translationKey: "settings.windowsNodeMxcConsent.item.location" }, + { id: "tts", translationKey: "settings.windowsNodeMxcConsent.item.tts" }, + { id: "stt", translationKey: "settings.windowsNodeMxcConsent.item.stt" }, + { + id: "gateway-memory", + translationKey: "settings.windowsNodeMxcConsent.item.gateway-memory", + }, + ], + }, + { + id: "paused", + titleKey: "settings.windowsNodeMxcConsent.group.paused", + items: [ + { id: "cron", translationKey: "settings.windowsNodeMxcConsent.item.cron" }, + { + id: "hooks-webhooks", + translationKey: "settings.windowsNodeMxcConsent.item.hooks-webhooks", + }, + ], + }, + { + id: "available-restricted", + titleKey: "settings.windowsNodeMxcConsent.group.available-restricted", + items: [ + { id: "exec", translationKey: "settings.windowsNodeMxcConsent.item.exec" }, + { + id: "gateway-web", + translationKey: "settings.windowsNodeMxcConsent.item.gateway-web", + }, + { + id: "wechat-message", + translationKey: "settings.windowsNodeMxcConsent.item.wechat-message", + }, + { + id: "canvas-phase-1", + translationKey: "settings.windowsNodeMxcConsent.item.canvas-phase-1", + }, + ], + }, + ], +} as const satisfies WindowsNodeMxcCapabilityDisclosure; diff --git a/desktop/src/windows-node-mxc-durable-approvals.test.ts b/desktop/src/windows-node-mxc-durable-approvals.test.ts index 1a1d890..f388cf1 100644 --- a/desktop/src/windows-node-mxc-durable-approvals.test.ts +++ b/desktop/src/windows-node-mxc-durable-approvals.test.ts @@ -102,6 +102,7 @@ describe("WindowsNodeMxcDurableApprovalStore", () => { agent: identity.agentId, sessionKey: identity.sessionKey, declaredAccess: identity.declaredAccess, + hasMutableFileOperand: false, allowedDecisions: ["deny", "allow-once", "allow-always"], }; expect(durableApprovalIdentityFromGateway(approval, identity.policyFingerprint)).toEqual( @@ -113,6 +114,12 @@ describe("WindowsNodeMxcDurableApprovalStore", () => { identity.policyFingerprint, ), ).toBeNull(); + expect( + durableApprovalIdentityFromGateway( + { ...approval, hasMutableFileOperand: true }, + identity.policyFingerprint, + ), + ).toBeNull(); }); it("serializes concurrent writes instead of losing records", async () => { diff --git a/desktop/src/windows-node-mxc-durable-approvals.ts b/desktop/src/windows-node-mxc-durable-approvals.ts index 14674b0..c1b6eb4 100644 --- a/desktop/src/windows-node-mxc-durable-approvals.ts +++ b/desktop/src/windows-node-mxc-durable-approvals.ts @@ -172,7 +172,8 @@ export function durableApprovalIdentityFromGateway( !/^[a-f0-9]{64}$/i.test(approval.executableSha256) || !/^[a-f0-9]{64}$/i.test(policyFingerprint) || !path.win32.isAbsolute(approval.executablePath) || - !approval.sessionKey + !approval.sessionKey || + approval.hasMutableFileOperand ) { return null; } diff --git a/desktop/src/windows-node-mxc-folder-transaction.test.ts b/desktop/src/windows-node-mxc-folder-transaction.test.ts index 37b5bfe..ba1257b 100644 --- a/desktop/src/windows-node-mxc-folder-transaction.test.ts +++ b/desktop/src/windows-node-mxc-folder-transaction.test.ts @@ -20,6 +20,7 @@ function createHooks(log: string[]): WindowsNodeMxcFolderTransactionHooks { "rejectPendingApprovals", "revokeAuthorization", "phase:validating", + "preflightExactBackend", "validatePolicy", "stopCurrentGeneration", "phase:persisting", @@ -119,6 +121,7 @@ describe("runWindowsNodeMxcFolderPolicyTransaction", () => { "closeIngress", "rejectPendingApprovals", "revokeAuthorization", + "preflightExactBackend", "validatePolicy", "stopCurrentGeneration", "persistPolicy", @@ -144,9 +147,13 @@ describe("runWindowsNodeMxcFolderPolicyTransaction", () => { expect.objectContaining({ previous, draft, - applied: ["closeIngress", "rejectPendingApprovals", "revokeAuthorization", "validatePolicy"].includes( - failure, - ) + applied: [ + "closeIngress", + "rejectPendingApprovals", + "revokeAuthorization", + "preflightExactBackend", + "validatePolicy", + ].includes(failure) ? null : draft, persisted: [ diff --git a/desktop/src/windows-node-mxc-folder-transaction.ts b/desktop/src/windows-node-mxc-folder-transaction.ts index 10bad16..e2da7ad 100644 --- a/desktop/src/windows-node-mxc-folder-transaction.ts +++ b/desktop/src/windows-node-mxc-folder-transaction.ts @@ -7,6 +7,7 @@ export type WindowsNodeMxcFolderTransactionPhase = | "smoking-locked" | "starting-active" | "smoking-active" + | "promoting-active" | "verifying-active" | "starting-standard" | "active" @@ -23,6 +24,7 @@ export interface WindowsNodeMxcFolderTransactionHooks { closeIngress(): Promise | void; rejectPendingApprovals(): Promise | void; revokeAuthorization(): Promise | void; + preflightExactBackend(): Promise; validatePolicy( draft: WindowsNodeMxcFolderPolicy, ): Promise | WindowsNodeMxcFolderPolicy; @@ -87,6 +89,7 @@ export async function runWindowsNodeMxcFolderPolicyTransaction( await hooks.revokeAuthorization(); hooks.setPhase("validating"); + await hooks.preflightExactBackend(); const validated = await hooks.validatePolicy(draft); applied = validated; await hooks.stopCurrentGeneration(); diff --git a/desktop/src/windows-node-mxc-hooks.test.ts b/desktop/src/windows-node-mxc-hooks.test.ts new file mode 100644 index 0000000..fda4217 --- /dev/null +++ b/desktop/src/windows-node-mxc-hooks.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + WINDOWS_NODE_MXC_HOOKS_GATE_CONTRACT, + createWindowsNodeMxcHooksGatePolicy, + getWindowsNodeMxcHooksEnvironment, + inspectWindowsNodeMxcHooksStatus, +} from "./windows-node-mxc-hooks"; + +describe("Windows Node MXC Hooks pause policy", () => { + it("binds requested hook state to the current Gateway generation and MXC mode", () => { + const policy = createWindowsNodeMxcHooksGatePolicy({ + gatewayGeneration: "generation-1", + policyFingerprint: "a".repeat(64), + requestedMode: "limited-dacl", + hooksConfig: { + enabled: true, + path: "automations/", + internal: { entries: { audit: { enabled: true } } }, + }, + }); + + expect(policy).toEqual({ + contract: WINDOWS_NODE_MXC_HOOKS_GATE_CONTRACT, + gatewayGeneration: "generation-1", + policyFingerprint: "a".repeat(64), + requestedMode: "limited-dacl", + externalRequested: true, + internalRequested: true, + basePath: "/automations", + }); + expect( + JSON.parse( + Buffer.from( + getWindowsNodeMxcHooksEnvironment(policy).MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64, + "base64", + ).toString("utf8"), + ), + ).toEqual(policy); + }); + + it("reports backed-up requested hooks as paused rather than disabled by the user", () => { + expect( + inspectWindowsNodeMxcHooksStatus({ enabled: true, internal: { enabled: true } }, "full"), + ).toEqual({ + requestedExternalEnabled: true, + requestedInternalEnabled: true, + effectiveExternalEnabled: false, + effectiveInternalEnabled: false, + pausedByMxc: true, + }); + }); + + it("reports restored effective state only when MXC is Off", () => { + expect( + inspectWindowsNodeMxcHooksStatus({ enabled: true, internal: { enabled: false } }, "off"), + ).toEqual({ + requestedExternalEnabled: true, + requestedInternalEnabled: false, + effectiveExternalEnabled: true, + effectiveInternalEnabled: false, + pausedByMxc: false, + }); + }); + + it("preserves an explicitly disabled Hooks configuration without a paused-shaped status", () => { + expect( + inspectWindowsNodeMxcHooksStatus( + { enabled: false, internal: { enabled: false } }, + "limited-dacl", + ), + ).toEqual({ + requestedExternalEnabled: false, + requestedInternalEnabled: false, + effectiveExternalEnabled: false, + effectiveInternalEnabled: false, + pausedByMxc: false, + }); + }); + + it("rejects ambiguous hook paths and malformed generation binding", () => { + expect(() => + createWindowsNodeMxcHooksGatePolicy({ + gatewayGeneration: "", + policyFingerprint: "a".repeat(64), + requestedMode: "full", + hooksConfig: {}, + }), + ).toThrow(/generation context/); + expect(() => + createWindowsNodeMxcHooksGatePolicy({ + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "full", + hooksConfig: { enabled: true, path: "/" }, + }), + ).toThrow(/root HTTP path/); + for (const unsafePath of [ + "/hooks/%68idden", + "/hooks?token=x", + "/hooks#fragment", + "/a/../hooks", + ]) { + expect(() => + createWindowsNodeMxcHooksGatePolicy({ + gatewayGeneration: "generation", + policyFingerprint: "a".repeat(64), + requestedMode: "full", + hooksConfig: { enabled: true, path: unsafePath }, + }), + ).toThrow(/unambiguous local HTTP path/); + } + }); +}); diff --git a/desktop/src/windows-node-mxc-hooks.ts b/desktop/src/windows-node-mxc-hooks.ts new file mode 100644 index 0000000..f9220fd --- /dev/null +++ b/desktop/src/windows-node-mxc-hooks.ts @@ -0,0 +1,103 @@ +import type { WindowsNodeMxcRequestedMode } from "./windows-node-mxc-modes"; + +export const WINDOWS_NODE_MXC_HOOKS_GATE_CONTRACT = "microclaw.windows-node-mxc.hooks-gate.v1"; + +export interface WindowsNodeMxcHooksGatePolicy { + contract: typeof WINDOWS_NODE_MXC_HOOKS_GATE_CONTRACT; + gatewayGeneration: string; + policyFingerprint: string; + requestedMode: Exclude; + externalRequested: boolean; + internalRequested: boolean; + basePath: string; +} + +export interface WindowsNodeMxcHooksStatus { + requestedExternalEnabled: boolean; + requestedInternalEnabled: boolean; + effectiveExternalEnabled: boolean; + effectiveInternalEnabled: boolean; + pausedByMxc: boolean; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeHooksPath(value: unknown): string { + const raw = typeof value === "string" && value.trim() ? value.trim() : "/hooks"; + const withSlash = raw.startsWith("/") ? raw : `/${raw}`; + const normalized = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash; + if (normalized === "/") throw new Error("MXC Hooks gate cannot protect the root HTTP path"); + if ( + /[%?#\\]/.test(normalized) || + normalized.split("/").some((segment) => segment === "." || segment === "..") + ) { + throw new Error("MXC Hooks gate path must be an unambiguous local HTTP path"); + } + return normalized; +} + +function isInternalHooksRequested(hooks: Record): boolean { + const internal = asRecord(hooks.internal); + if (internal.enabled === false) return false; + if (internal.enabled === true) return true; + return Object.values(asRecord(internal.entries)).some( + (entry) => asRecord(entry).enabled !== false, + ); +} + +export function inspectWindowsNodeMxcHooksStatus( + hooksConfig: unknown, + requestedMode: WindowsNodeMxcRequestedMode, +): WindowsNodeMxcHooksStatus { + const hooks = asRecord(hooksConfig); + const requestedExternalEnabled = hooks.enabled === true; + const requestedInternalEnabled = isInternalHooksRequested(hooks); + const pausedByMxc = + requestedMode !== "off" && (requestedExternalEnabled || requestedInternalEnabled); + return { + requestedExternalEnabled, + requestedInternalEnabled, + effectiveExternalEnabled: requestedMode === "off" && requestedExternalEnabled, + effectiveInternalEnabled: requestedMode === "off" && requestedInternalEnabled, + pausedByMxc, + }; +} + +export function createWindowsNodeMxcHooksGatePolicy(params: { + gatewayGeneration: string; + policyFingerprint: string; + requestedMode: Exclude; + hooksConfig: unknown; +}): WindowsNodeMxcHooksGatePolicy { + const hooks = asRecord(params.hooksConfig); + if ( + !params.gatewayGeneration || + !/^[a-f0-9]{64}$/i.test(params.policyFingerprint) || + (params.requestedMode !== "full" && params.requestedMode !== "limited-dacl") + ) { + throw new Error("MXC Hooks gate generation context is invalid"); + } + return { + contract: WINDOWS_NODE_MXC_HOOKS_GATE_CONTRACT, + gatewayGeneration: params.gatewayGeneration, + policyFingerprint: params.policyFingerprint.toLowerCase(), + requestedMode: params.requestedMode, + externalRequested: hooks.enabled === true, + internalRequested: isInternalHooksRequested(hooks), + basePath: normalizeHooksPath(hooks.path), + }; +} + +export function getWindowsNodeMxcHooksEnvironment( + policy: WindowsNodeMxcHooksGatePolicy, +): Record { + return { + MICROCLAW_MXC_HOOKS_GATE_POLICY_BASE64: Buffer.from(JSON.stringify(policy), "utf8").toString( + "base64", + ), + }; +} diff --git a/desktop/src/windows-node-mxc-modes.test.ts b/desktop/src/windows-node-mxc-modes.test.ts new file mode 100644 index 0000000..d803185 --- /dev/null +++ b/desktop/src/windows-node-mxc-modes.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + createWindowsNodeMxcSelection, + describeWindowsNodeMxcLimitedAvailability, + migrateWindowsNodeMxcSelection, + summarizeWindowsNodeMxcAmbientAccess, + validateWindowsNodeMxcAmbientAccessAssessment, +} from "./windows-node-mxc-modes"; +import { classifyMxcProbe } from "./windows-node-mxc"; + +describe("Windows Node MXC containment modes", () => { + it("migrates a remembered enabled legacy setting conservatively to Full", () => { + expect(migrateWindowsNodeMxcSelection(undefined, true)).toEqual({ + version: 1, + requestedMode: "full", + limitedFilesystemAcknowledgedAt: null, + }); + }); + + it("preserves only explicit persisted Limited selection evidence", () => { + expect( + migrateWindowsNodeMxcSelection( + { + version: 1, + requestedMode: "limited-dacl", + limitedFilesystemAcknowledgedAt: "2026-10-09T00:00:00.000Z", + }, + true, + ), + ).toEqual({ + version: 1, + requestedMode: "limited-dacl", + limitedFilesystemAcknowledgedAt: "2026-10-09T00:00:00.000Z", + }); + }); + + it("requires explicit Limited filesystem acknowledgment", () => { + expect(() => createWindowsNodeMxcSelection("limited-dacl", false)).toThrow( + "explicit acknowledgment", + ); + }); + + it("accepts only an attested appcontainer-dacl tier for Limited", () => { + const dacl = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", + ); + const full = classifyMxcProbe(0, JSON.stringify({ tier: "base-container" }), ""); + expect(describeWindowsNodeMxcLimitedAvailability(dacl).available).toBe(true); + expect(describeWindowsNodeMxcLimitedAvailability(full).available).toBe(false); + }); + + it("separates sensitive activation blockers from ordinary ambient warnings", () => { + const summary = summarizeWindowsNodeMxcAmbientAccess({ + contract: "microclaw.windows-node-mxc-ambient-access.v1", + hash: "a".repeat(64), + checkedAt: "2026-10-09T00:00:00.000Z", + entries: [ + { category: "downloads", status: "read-write" }, + { category: "ssh", status: "read" }, + { category: "browser-edge", status: "denied" }, + ], + }); + expect(summary.ordinaryAccessible).toEqual([ + { category: "downloads", status: "read-write" }, + ]); + expect(summary.sensitiveAccessible).toEqual([{ category: "ssh", status: "read" }]); + }); + + it("requires every bounded ambient-access category", () => { + expect( + validateWindowsNodeMxcAmbientAccessAssessment({ + contract: "microclaw.windows-node-mxc-ambient-access.v1", + hash: "a".repeat(64), + checkedAt: "2026-10-09T00:00:00.000Z", + entries: [{ category: "downloads", status: "read-write" }], + }), + ).toBe(false); + }); +}); diff --git a/desktop/src/windows-node-mxc-modes.ts b/desktop/src/windows-node-mxc-modes.ts new file mode 100644 index 0000000..37c5dfa --- /dev/null +++ b/desktop/src/windows-node-mxc-modes.ts @@ -0,0 +1,246 @@ +import type { MxcProbeResult } from "./windows-node-mxc"; + +export const WINDOWS_NODE_MXC_SELECTION_VERSION = 1 as const; + +export type WindowsNodeMxcRequestedMode = "off" | "full" | "limited-dacl"; +export type WindowsNodeMxcEffectiveStrength = "off" | "full" | "limited-dacl"; +export type WindowsNodeMxcAmbientAccessStatus = "denied" | "read" | "read-write"; +export type WindowsNodeMxcAmbientAccessCategory = + | "desktop" + | "documents" + | "downloads" + | "microclaw-state" + | "openclaw-state" + | "openclaw-roaming-state" + | "ssh" + | "gnupg" + | "aws" + | "azure" + | "kube" + | "gcloud" + | "windows-credentials" + | "powershell-history" + | "browser-chrome" + | "browser-edge" + | "browser-brave" + | "browser-firefox"; + +export interface WindowsNodeMxcSelection { + version: typeof WINDOWS_NODE_MXC_SELECTION_VERSION; + requestedMode: WindowsNodeMxcRequestedMode; + limitedFilesystemAcknowledgedAt: string | null; +} + +export interface WindowsNodeMxcAmbientAccessEntry { + category: WindowsNodeMxcAmbientAccessCategory; + status: WindowsNodeMxcAmbientAccessStatus; +} + +export interface WindowsNodeMxcAmbientAccessAssessment { + contract: "microclaw.windows-node-mxc-ambient-access.v1"; + hash: string; + checkedAt: string; + entries: WindowsNodeMxcAmbientAccessEntry[]; +} + +export interface WindowsNodeMxcModeAvailability { + available: boolean; + summary: string | null; + blockers: string[]; + remediation: string[]; +} + +const SENSITIVE_AMBIENT_CATEGORIES = new Set([ + "microclaw-state", + "openclaw-state", + "openclaw-roaming-state", + "ssh", + "gnupg", + "aws", + "azure", + "kube", + "gcloud", + "windows-credentials", + "powershell-history", + "browser-chrome", + "browser-edge", + "browser-brave", + "browser-firefox", +]); +const ALL_AMBIENT_CATEGORIES = new Set([ + "desktop", + "documents", + "downloads", + "microclaw-state", + "openclaw-state", + "openclaw-roaming-state", + "ssh", + "gnupg", + "aws", + "azure", + "kube", + "gcloud", + "windows-credentials", + "powershell-history", + "browser-chrome", + "browser-edge", + "browser-brave", + "browser-firefox", +]); + +export function migrateWindowsNodeMxcSelection( + value: unknown, + legacyDesiredEnabled: boolean, +): WindowsNodeMxcSelection { + if (isRecord(value) && value.version === WINDOWS_NODE_MXC_SELECTION_VERSION) { + const requestedMode = value.requestedMode; + if ( + requestedMode === "off" || + requestedMode === "full" || + requestedMode === "limited-dacl" + ) { + const acknowledgedAt = + typeof value.limitedFilesystemAcknowledgedAt === "string" && + !Number.isNaN(Date.parse(value.limitedFilesystemAcknowledgedAt)) + ? value.limitedFilesystemAcknowledgedAt + : null; + return { + version: WINDOWS_NODE_MXC_SELECTION_VERSION, + requestedMode, + limitedFilesystemAcknowledgedAt: + requestedMode === "limited-dacl" ? acknowledgedAt : null, + }; + } + } + + return { + version: WINDOWS_NODE_MXC_SELECTION_VERSION, + requestedMode: legacyDesiredEnabled ? "full" : "off", + limitedFilesystemAcknowledgedAt: null, + }; +} + +export function createWindowsNodeMxcSelection( + requestedMode: WindowsNodeMxcRequestedMode, + acknowledgeLimitedFilesystem: boolean, + now = new Date(), +): WindowsNodeMxcSelection { + if (requestedMode === "limited-dacl" && !acknowledgeLimitedFilesystem) { + throw new Error( + "Limited containment requires explicit acknowledgment that selected folders are additive grants, not an exclusive filesystem allowlist", + ); + } + return { + version: WINDOWS_NODE_MXC_SELECTION_VERSION, + requestedMode, + limitedFilesystemAcknowledgedAt: + requestedMode === "limited-dacl" ? now.toISOString() : null, + }; +} + +export function describeWindowsNodeMxcLimitedAvailability( + probe: MxcProbeResult, +): WindowsNodeMxcModeAvailability { + if ( + probe.tier?.trim().toLowerCase() === "appcontainer-dacl" && + probe.outcome !== "error" + ) { + return { + available: true, + summary: + "The pinned MXC runtime attested appcontainer-dacl. Filesystem grants are additive and ambient Windows ACL access must be assessed before activation.", + blockers: [], + remediation: [], + }; + } + const reason = + probe.reason ?? + `The pinned MXC runtime attested ${probe.tier ?? "no usable containment tier"}, not appcontainer-dacl.`; + return { + available: false, + summary: reason, + blockers: [reason], + remediation: [ + "Repair the pinned, signed MXC runtime or use Full containment when an exact backend is available. MicroClaw never downgrades between containment tiers automatically.", + ], + }; +} + +export function summarizeWindowsNodeMxcAmbientAccess( + assessment: WindowsNodeMxcAmbientAccessAssessment | null, +): { + sensitiveAccessible: WindowsNodeMxcAmbientAccessEntry[]; + ordinaryAccessible: WindowsNodeMxcAmbientAccessEntry[]; +} { + const entries = assessment?.entries ?? []; + return { + sensitiveAccessible: entries.filter( + (entry) => + entry.status !== "denied" && SENSITIVE_AMBIENT_CATEGORIES.has(entry.category), + ), + ordinaryAccessible: entries.filter( + (entry) => + entry.status !== "denied" && !SENSITIVE_AMBIENT_CATEGORIES.has(entry.category), + ), + }; +} + +export function validateWindowsNodeMxcAmbientAccessAssessment( + value: unknown, +): value is WindowsNodeMxcAmbientAccessAssessment { + if ( + !isRecord(value) || + value.contract !== "microclaw.windows-node-mxc-ambient-access.v1" || + typeof value.hash !== "string" || + !/^[a-f0-9]{64}$/.test(value.hash) || + typeof value.checkedAt !== "string" || + Number.isNaN(Date.parse(value.checkedAt)) || + !Array.isArray(value.entries) + ) { + return false; + } + const categories = new Set(); + const validEntries = value.entries.every((entry) => { + if (!isRecord(entry)) return false; + const category = entry.category; + const status = entry.status; + if ( + typeof category !== "string" || + ![ + "desktop", + "documents", + "downloads", + "microclaw-state", + "openclaw-state", + "openclaw-roaming-state", + "ssh", + "gnupg", + "aws", + "azure", + "kube", + "gcloud", + "windows-credentials", + "powershell-history", + "browser-chrome", + "browser-edge", + "browser-brave", + "browser-firefox", + ].includes(category) || + categories.has(category) || + (status !== "denied" && status !== "read" && status !== "read-write") + ) { + return false; + } + categories.add(category); + return true; + }); + return ( + validEntries && + categories.size === ALL_AMBIENT_CATEGORIES.size && + [...ALL_AMBIENT_CATEGORIES].every((category) => categories.has(category)) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/desktop/src/windows-node-mxc-policy-promotion.test.ts b/desktop/src/windows-node-mxc-policy-promotion.test.ts new file mode 100644 index 0000000..efa5661 --- /dev/null +++ b/desktop/src/windows-node-mxc-policy-promotion.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyWindowsNodeMxcGatewayPolicy } from "./windows-node-mxc"; +import { promoteWindowsNodeMxcPolicy } from "./windows-node-mxc-policy-promotion"; + +const nodeId = "a".repeat(64); +const generation = "generation-1"; + +function config(state: "locked" | "active") { + const baseline = { + agents: { + ownership: "explicit", + entries: { + main: { + tools: { + allow: ["read"], + deny: [], + }, + }, + }, + }, + gateway: {}, + channels: {}, + cron: { enabled: true }, + hooks: { enabled: true, internal: { enabled: true } }, + plugins: { enabled: true, entries: {}, slots: {} }, + }; + return applyWindowsNodeMxcGatewayPolicy(baseline, nodeId, {}, state).config; +} + +function snapshot(state: "locked" | "active", hash: string) { + return { + config: config(state), + hash, + configRevisionHash: `${hash}-applied`, + appliedConfigHash: `${hash}-applied`, + }; +} + +function legacyConfig(state: "locked" | "active") { + const base = config(state); + return { + ...base, + agents: { + list: [ + { + id: "main", + tools: ( + (base.agents as { entries: Record }).entries.main + ).tools, + }, + ], + }, + }; +} + +function patchAck(hash: string) { + return { + ok: true, + hash, + changedPaths: ["agents.entries.main.tools.allow", "agents.entries.main.tools.deny"], + sentinel: { payload: { stats: { requiresRestart: false } } }, + }; +} + +describe("MXC in-process policy promotion", () => { + it("CAS-patches only agents and verifies the exact applied active revision", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockResolvedValueOnce(patchAck("active-hash")) + .mockResolvedValueOnce(snapshot("active", "active-hash")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).resolves.toMatchObject({ appliedHash: "active-hash-applied" }); + + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[1][0]).toBe("config.patch"); + expect(JSON.parse(request.mock.calls[1][1].raw)).toEqual({ + agents: config("active").agents, + }); + expect(request.mock.calls[1][1].replacePaths).toEqual([ + "agents.entries.main.tools.allow", + "agents.entries.main.tools.deny", + ]); + }); + + it("uses an explicit whole-array replacement for a legacy agent roster", async () => { + const lockedConfig = legacyConfig("locked"); + const activeConfig = legacyConfig("active"); + const request = vi + .fn() + .mockResolvedValueOnce({ + config: lockedConfig, + hash: "locked-hash", + configRevisionHash: "locked-applied", + appliedConfigHash: "locked-applied", + }) + .mockResolvedValueOnce({ + ...patchAck("active-hash"), + changedPaths: ["agents.list"], + }) + .mockResolvedValueOnce({ + config: activeConfig, + hash: "active-hash", + configRevisionHash: "active-applied", + appliedConfigHash: "active-applied", + }); + + await promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }); + + expect(request.mock.calls[1][1].replacePaths).toEqual(["agents.list"]); + }); + + it("rolls back to the locked policy when active read-back drifts", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockResolvedValueOnce(patchAck("active-hash")) + .mockResolvedValueOnce({ + ...snapshot("active", "active-hash"), + appliedConfigHash: "stale-runtime-hash", + }) + .mockResolvedValueOnce(snapshot("active", "active-hash")) + .mockResolvedValueOnce(patchAck("rollback-hash")) + .mockResolvedValueOnce(snapshot("locked", "rollback-hash")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/has not been applied/); + + expect(JSON.parse(request.mock.calls[4][1].raw)).toEqual({ + agents: config("locked").agents, + }); + }); + + it("fails closed when rollback cannot be proven", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(snapshot("locked", "locked-hash")) + .mockRejectedValueOnce(new Error("promotion failed")) + .mockRejectedValueOnce(new Error("rollback snapshot unavailable")); + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => generation, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/rollback also failed/); + }); + + it("rejects a stale generation before issuing config.patch", async () => { + let currentGeneration = generation; + let requestCount = 0; + const request = async () => { + requestCount += 1; + currentGeneration = "generation-2"; + return snapshot("locked", "locked-hash") as T; + }; + + await expect( + promoteWindowsNodeMxcPolicy({ + gateway: { request }, + nodeId, + backups: {}, + expectedGeneration: generation, + currentGeneration: () => currentGeneration, + isCurrent: () => true, + requestTimeoutMs: 5_000, + }), + ).rejects.toThrow(/generation changed/); + expect(requestCount).toBe(1); + }); +}); diff --git a/desktop/src/windows-node-mxc-policy-promotion.ts b/desktop/src/windows-node-mxc-policy-promotion.ts new file mode 100644 index 0000000..50f7ba9 --- /dev/null +++ b/desktop/src/windows-node-mxc-policy-promotion.ts @@ -0,0 +1,294 @@ +import { + applyWindowsNodeMxcGatewayPolicy, + validateWindowsNodeMxcGatewayPolicy, + type AgentToolsBackup, +} from "./windows-node-mxc"; +import { isDeepStrictEqual } from "node:util"; + +export type GatewayConfigSnapshot = { + config?: unknown; + hash?: unknown; + configRevisionHash?: unknown; + appliedConfigHash?: unknown; +}; + +type GatewayConfigPatchAck = { + ok?: unknown; + hash?: unknown; + changedPaths?: unknown; + restart?: unknown; + sentinel?: { + payload?: { + stats?: { + requiresRestart?: unknown; + }; + }; + }; +}; + +export type WindowsNodeMxcPolicyPromotionOptions = { + gateway: { + request(method: string, params?: unknown, timeoutMs?: number): Promise; + }; + nodeId: string; + backups: AgentToolsBackup; + expectedGeneration: string; + currentGeneration: () => string; + isCurrent: () => boolean; + requestTimeoutMs: number; +}; + +export type WindowsNodeMxcPolicyPromotionResult = { + backups: AgentToolsBackup; + appliedHash: string; +}; + +export function attestWindowsNodeMxcGatewaySnapshot( + snapshot: GatewayConfigSnapshot, + nodeId: string, + expectedPolicy: "locked" | "active" | "either", +): { + config: Record; + hash: string; + configRevisionHash: string; + appliedConfigHash: string; +} { + if (!snapshot.config || typeof snapshot.config !== "object" || Array.isArray(snapshot.config)) { + throw new Error("Gateway config.get did not return a configuration object"); + } + const hash = nonEmptyString(snapshot.hash, "persisted config hash"); + const configRevisionHash = nonEmptyString( + snapshot.configRevisionHash, + "resolved config revision hash", + ); + const appliedConfigHash = nonEmptyString(snapshot.appliedConfigHash, "applied config hash"); + if (configRevisionHash !== appliedConfigHash) { + throw new Error("Gateway persisted policy has not been applied to the active runtime"); + } + const policy = validateWindowsNodeMxcGatewayPolicy(snapshot.config, nodeId, expectedPolicy); + if (!policy.ready) { + throw new Error( + `Gateway ${expectedPolicy} policy attestation failed: ${policy.blockers.join("; ")}`, + ); + } + return { + config: snapshot.config as Record, + hash, + configRevisionHash, + appliedConfigHash, + }; +} + +export async function promoteWindowsNodeMxcPolicy( + options: WindowsNodeMxcPolicyPromotionOptions, +): Promise { + assertCurrent(options); + const lockedSnapshot = await readAppliedSnapshot(options, "locked"); + const active = applyWindowsNodeMxcGatewayPolicy( + lockedSnapshot.config, + options.nodeId, + options.backups, + "active", + ); + + try { + const ack = await patchAgents( + options, + lockedSnapshot.hash, + active.config.agents, + getAgentToolReplacePaths(active.config.agents), + ); + assertHotAppliedAck(ack); + const activeSnapshot = await readAppliedSnapshot(options, "active"); + assertMatchingAckHash(ack, activeSnapshot.hash); + assertExactConfig(activeSnapshot.config, active.config, "active policy"); + return { backups: active.backups, appliedHash: activeSnapshot.appliedConfigHash }; + } catch (error) { + assertCurrent(options); + try { + await rollbackWindowsNodeMxcPolicy(options, lockedSnapshot.config); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "MXC policy promotion failed and the locked-policy rollback also failed", + { cause: rollbackError }, + ); + } + throw error; + } +} + +async function rollbackWindowsNodeMxcPolicy( + options: WindowsNodeMxcPolicyPromotionOptions, + lockedConfig: Record, +): Promise { + const current = await readSnapshot(options, "either", false); + if (validateWindowsNodeMxcGatewayPolicy(current.config, options.nodeId, "locked").ready) { + const verified = await readAppliedSnapshot(options, "locked"); + assertExactConfig(verified.config, lockedConfig, "locked rollback"); + return; + } + + const ack = await patchAgents( + options, + current.hash, + lockedConfig.agents, + getAgentToolReplacePaths(lockedConfig.agents), + ); + assertHotAppliedAck(ack); + const verified = await readAppliedSnapshot(options, "locked"); + assertMatchingAckHash(ack, verified.hash); + assertExactConfig(verified.config, lockedConfig, "locked rollback"); +} + +async function readAppliedSnapshot( + options: WindowsNodeMxcPolicyPromotionOptions, + expectedPolicy: "locked" | "active" | "either", +): Promise> { + return readSnapshot(options, expectedPolicy, true); +} + +async function readSnapshot( + options: WindowsNodeMxcPolicyPromotionOptions, + expectedPolicy: "locked" | "active" | "either", + requireApplied: boolean, +): Promise<{ + config: Record; + hash: string; + configRevisionHash: string; + appliedConfigHash: string; +}> { + assertCurrent(options); + const snapshot = await options.gateway.request( + "config.get", + {}, + options.requestTimeoutMs, + ); + assertCurrent(options); + if (!requireApplied) { + if (!snapshot.config || typeof snapshot.config !== "object" || Array.isArray(snapshot.config)) { + throw new Error("Gateway config.get did not return a configuration object"); + } + const hash = nonEmptyString(snapshot.hash, "persisted config hash"); + const policy = validateWindowsNodeMxcGatewayPolicy( + snapshot.config, + options.nodeId, + expectedPolicy, + ); + if (!policy.ready) { + throw new Error( + `Gateway ${expectedPolicy} policy attestation failed: ${policy.blockers.join("; ")}`, + ); + } + return { + config: snapshot.config as Record, + hash, + configRevisionHash: + typeof snapshot.configRevisionHash === "string" ? snapshot.configRevisionHash : "", + appliedConfigHash: + typeof snapshot.appliedConfigHash === "string" ? snapshot.appliedConfigHash : "", + }; + } + return attestWindowsNodeMxcGatewaySnapshot(snapshot, options.nodeId, expectedPolicy); +} + +async function patchAgents( + options: WindowsNodeMxcPolicyPromotionOptions, + baseHash: string, + agents: unknown, + replacePaths: string[], +): Promise { + assertCurrent(options); + const ack = await options.gateway.request( + "config.patch", + { + baseHash, + raw: JSON.stringify({ agents }), + replacePaths, + note: "Promote verified MicroClaw MXC policy without restarting the Gateway", + }, + options.requestTimeoutMs, + ); + assertCurrent(options); + return ack; +} + +function assertHotAppliedAck(ack: GatewayConfigPatchAck): void { + if (ack.ok !== true) throw new Error("Gateway rejected the MXC policy promotion"); + if (ack.restart !== undefined && ack.restart !== null) { + throw new Error("Gateway attempted to restart while promoting the MXC policy"); + } + if (ack.sentinel?.payload?.stats?.requiresRestart !== false) { + throw new Error("Gateway did not prove that MXC policy promotion was hot-applied"); + } + if ( + !Array.isArray(ack.changedPaths) || + ack.changedPaths.length === 0 || + !ack.changedPaths.every( + (path) => + typeof path === "string" && + ((path.startsWith("agents.entries.") && path.includes(".tools")) || path === "agents.list"), + ) + ) { + throw new Error("Gateway MXC policy promotion changed an unexpected configuration path"); + } +} + +function assertMatchingAckHash(ack: GatewayConfigPatchAck, snapshotHash: string): void { + const ackHash = nonEmptyString(ack.hash, "config.patch acknowledgement hash"); + if (ackHash !== snapshotHash) { + throw new Error("Gateway config.patch acknowledgement did not match exact read-back"); + } +} + +function assertExactConfig( + actual: Record, + expected: Record, + label: string, +): void { + if (!isDeepStrictEqual(actual, expected)) { + throw new Error(`Gateway ${label} read-back drifted from the exact requested configuration`); + } +} + +function getAgentToolReplacePaths(agents: unknown): string[] { + if (!agents || typeof agents !== "object" || Array.isArray(agents)) { + throw new Error("MXC one-process promotion requires an explicit agent roster"); + } + const roster = agents as Record; + if (Array.isArray(roster.list)) { + if (roster.list.length === 0) { + throw new Error("MXC one-process promotion requires at least one agent entry"); + } + return ["agents.list"]; + } + const entries = roster.entries; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + throw new Error("MXC one-process promotion requires agents.entries or agents.list"); + } + const agentIds = Object.keys(entries); + if (agentIds.length === 0) { + throw new Error("MXC one-process promotion requires at least one agent entry"); + } + return agentIds.flatMap((agentId) => [ + `agents.entries.${agentId}.tools.allow`, + `agents.entries.${agentId}.tools.deny`, + ]); +} + +function assertCurrent(options: WindowsNodeMxcPolicyPromotionOptions): void { + if ( + !options.isCurrent() || + options.currentGeneration() !== options.expectedGeneration || + !options.expectedGeneration + ) { + throw new Error("Managed Gateway generation changed during MXC policy promotion"); + } +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Gateway response is missing its ${label}`); + } + return value; +} diff --git a/desktop/src/windows-node-mxc-readiness-proof.test.ts b/desktop/src/windows-node-mxc-readiness-proof.test.ts index 8f33243..d0e7806 100644 --- a/desktop/src/windows-node-mxc-readiness-proof.test.ts +++ b/desktop/src/windows-node-mxc-readiness-proof.test.ts @@ -13,7 +13,8 @@ import { const transitionId = "12345678-1234-4234-8234-123456789abc"; const secret = Buffer.from(Array.from({ length: 32 }, (_, index) => index)).toString("base64"); -function plan(kind: "hostname" | "powershell"): WindowsNodeMxcReadinessPlan { +function plan(): WindowsNodeMxcReadinessPlan { + const kind = "execution"; const argv = [...WINDOWS_NODE_MXC_READINESS_PROBES[kind]]; return { argv, @@ -50,8 +51,8 @@ describe("Windows Node MXC internal readiness proof", () => { const proof = minter().mint({ transitionId, nodeId: "B".repeat(64), - probeKind: "hostname", - plan: plan("hostname"), + probeKind: "execution", + plan: plan(), }); expect(proof).toEqual({ @@ -61,30 +62,30 @@ describe("Windows Node MXC internal readiness proof", () => { gatewayGeneration: "generation-test-001", policyFingerprint: "a".repeat(64), nodeId: "b".repeat(64), - probeKind: "hostname", - planSha256: "1cf9d0658d325540aba0d944d8d00937b2c86626a6a814cbc9017a2ab63c2c45", + probeKind: "execution", + planSha256: "35fc1fc5eb08a374a0247f7dde073db0ca0578d26158bbd6fdc5110ac6e50bda", issuedAtUnixMs: 1_735_689_600_000, expiresAtUnixMs: 1_735_689_600_000 + WINDOWS_NODE_MXC_READINESS_PROOF_TTL_MS, - signature: "7c964f4e5c2c0f169eb295185edf317f609cbdfe105b29ec29980bebb577744d", + signature: "223e2c971807546fe05478ebf9e28790b3b6339a92504e7f9fd6a559aab167c7", }); }); it.each([ - ["argv", { argv: [...plan("hostname").argv, "extra"] }], + ["argv", { argv: [...plan().argv, "extra"] }], ["cwd", { cwd: "C:\\Windows" }], ["declaration", { declaredAccess: [{ access: "ro" as const, path: "C:\\Work" }] }], ["agent", { agentId: "main" }], ["session", { sessionKey: "agent:main:main" }], ["scratch", { cwdBinding: "C:\\Work" }], - ["preview", { commandPreview: "hostname" }], + ["preview", { commandPreview: "execution" }], ["executable", { executablePath: "C:\\Windows\\System32\\whoami.exe" }], ])("rejects altered %s identity", (_label, changed) => { expect(() => minter().mint({ transitionId, nodeId: "b".repeat(64), - probeKind: "hostname", - plan: { ...plan("hostname"), ...changed }, + probeKind: "execution", + plan: { ...plan(), ...changed }, }), ).toThrow(/exact built-in probe/); }); @@ -94,26 +95,26 @@ describe("Windows Node MXC internal readiness proof", () => { minter().mint({ transitionId: "12345678-1234-4234-8234-123456789abe", nodeId: "b".repeat(64), - probeKind: "hostname", - plan: plan("hostname"), + probeKind: "execution", + plan: plan(), }), ).toThrow(/outside the current transition/); expect(() => minter().mint({ transitionId, nodeId: "d".repeat(64), - probeKind: "hostname", - plan: plan("hostname"), + probeKind: "execution", + plan: plan(), }), ).toThrow(/outside the current transition/); expect(() => minter().mint({ transitionId, nodeId: "b".repeat(64), - probeKind: "powershell", - plan: plan("hostname"), + probeKind: "hostname" as never, + plan: plan(), }), - ).toThrow(/exact built-in probe/); + ).toThrow(/outside the current transition/); expect(() => createWindowsNodeMxcReadinessProofMinter({ secretBase64: Buffer.alloc(31).toString("base64"), diff --git a/desktop/src/windows-node-mxc-readiness-proof.ts b/desktop/src/windows-node-mxc-readiness-proof.ts index 891dd7e..30c97d0 100644 --- a/desktop/src/windows-node-mxc-readiness-proof.ts +++ b/desktop/src/windows-node-mxc-readiness-proof.ts @@ -7,26 +7,19 @@ export const WINDOWS_NODE_MXC_READINESS_PROOF_TTL_MS = 15_000; export const WINDOWS_NODE_MXC_APPROVAL_PLAN_CONTRACT = "microclaw.windows-node-approval-plan.v2"; export const WINDOWS_NODE_MXC_SCRATCH_BINDING = "isolated-scratch:v1"; -export type WindowsNodeMxcReadinessProbeKind = "hostname" | "powershell"; +export type WindowsNodeMxcReadinessProbeKind = "execution"; export const WINDOWS_NODE_MXC_READINESS_PROBES: Record< WindowsNodeMxcReadinessProbeKind, readonly string[] > = { - hostname: [ - "C:\\Windows\\System32\\cmd.exe", - "/d", - "/s", - "/c", - "C:\\Windows\\System32\\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", - ], - powershell: [ + execution: [ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", - "[Console]::Out.Write('MICROCLAW_MXC_POWERSHELL_OK')", + "$child=& 'C:\\Windows\\System32\\hostname.exe'; if ([string]::IsNullOrWhiteSpace(($child -join ''))) { exit 41 }; [Console]::Out.Write('MICROCLAW_MXC_EXECUTION_OK')", ], }; diff --git a/desktop/src/windows-node-mxc-service.test.ts b/desktop/src/windows-node-mxc-service.test.ts index 1a3a0c1..c8993a9 100644 --- a/desktop/src/windows-node-mxc-service.test.ts +++ b/desktop/src/windows-node-mxc-service.test.ts @@ -6,13 +6,26 @@ import { WINDOWS_NODE_MXC_SCRATCH_BINDING, windowsNodeMxcReadinessSessionKey, } from "./windows-node-mxc-readiness-proof"; -import { runWindowsNodeMxcSmoke, type WindowsNodeMxcGateway } from "./windows-node-mxc-service"; +import { classifyMxcProbe } from "./windows-node-mxc"; +import { + preflightWindowsNodeMxcBackend, + preflightWindowsNodeMxcExactBackend, + runWindowsNodeMxcSmoke, + type WindowsNodeMxcGateway, +} from "./windows-node-mxc-service"; const transitionId = "12345678-1234-4234-8234-123456789abc"; const generation = "generation-test-001"; const nodeId = "b".repeat(64); +const strongProbe = classifyMxcProbe(0, JSON.stringify({ tier: "base-container" }), ""); +const limitedProbe = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", +); -function preparedPlan(probeKind: "hostname" | "powershell") { +function preparedPlan() { + const probeKind = "execution"; const argv = [...WINDOWS_NODE_MXC_READINESS_PROBES[probeKind]]; return { argv, @@ -52,18 +65,20 @@ describe("Windows Node MXC readiness smoke", () => { throw new Error("cwd-outside-approved-roots"); } if (call.command === "system.run.prepare") { - const command = call.params.command as string[]; - const kind = command[0].toLowerCase().endsWith("\\cmd.exe") ? "hostname" : "powershell"; - const wirePlan: Record = { ...preparedPlan(kind) }; + const wirePlan: Record = { ...preparedPlan() }; delete wirePlan.cwd; delete wirePlan.commandPreview; return { payload: { plan: wirePlan } }; } if (call.command === WINDOWS_NODE_MXC_READINESS_COMMAND) { - const kind = call.params.probeKind as "hostname" | "powershell"; - const marker = - kind === "hostname" ? "MICROCLAW_MXC_HOSTNAME_OK" : "MICROCLAW_MXC_POWERSHELL_OK"; - return { payload: { stdout: marker, stderr: "", exitCode: 0, timedOut: false } }; + return { + payload: { + stdout: "MICROCLAW_MXC_EXECUTION_OK", + stderr: "", + exitCode: 0, + timedOut: false, + }, + }; } throw new Error(`Unexpected command ${call.command}`); }); @@ -74,25 +89,23 @@ describe("Windows Node MXC readiness smoke", () => { generation, nodeId, "settings-fingerprint", - "appcontainer-dacl", + strongProbe, authorization(), ); - expect(smoke.deniedOutsideRoot.outcome).toBe("passed"); - expect(smoke.hostname.outcome).toBe("passed"); - expect(smoke.powershell.outcome).toBe("passed"); + expect(smoke.profile).toBe("full-smoke"); + expect(smoke.deniedOutsideRoot?.outcome).toBe("passed"); + expect(smoke.execution.outcome).toBe("passed"); const envelopes = request.mock.calls.map((call) => call[1] as Record); expect(envelopes.map((entry) => entry.command)).toEqual([ "system.run", "system.run.prepare", WINDOWS_NODE_MXC_READINESS_COMMAND, - "system.run.prepare", - WINDOWS_NODE_MXC_READINESS_COMMAND, ]); const readinessCalls = envelopes.filter( (entry) => entry.command === WINDOWS_NODE_MXC_READINESS_COMMAND, ); - expect(readinessCalls).toHaveLength(2); + expect(readinessCalls).toHaveLength(1); for (const envelope of readinessCalls) { const params = envelope.params as Record; expect(params.agentId).toBe(WINDOWS_NODE_MXC_READINESS_AGENT_ID); @@ -105,6 +118,80 @@ describe("Windows Node MXC readiness smoke", () => { } }); + it("runs only the scratch-based minimal execution smoke for Limited mode", async () => { + let now = 1_000; + const logs: string[] = []; + const request = vi.fn(async (_method: string, envelope: unknown) => { + const call = envelope as { command: string; params: Record }; + if (call.command === "system.run") { + throw new Error("Limited mode must not run the Full outside-root denial smoke"); + } + if (call.command === "system.run.prepare") { + now += 60_000; + return { payload: { plan: preparedPlan() } }; + } + if (call.command === WINDOWS_NODE_MXC_READINESS_COMMAND) { + const proof = call.params.microclawReadinessProof as { issuedAtUnixMs: number }; + expect(proof.issuedAtUnixMs).toBe(now); + expect(call.params.cwd).toBeNull(); + expect(call.params.systemRunPlan).toMatchObject({ + cwd: null, + cwdBinding: WINDOWS_NODE_MXC_SCRATCH_BINDING, + }); + return { + payload: { + stdout: "MICROCLAW_MXC_EXECUTION_OK", + stderr: "", + exitCode: 0, + timedOut: false, + }, + }; + } + throw new Error(`Unexpected command ${call.command}`); + }); + + const smoke = await runWindowsNodeMxcSmoke( + { connected: true, request } as unknown as WindowsNodeMxcGateway, + generation, + nodeId, + "settings-fingerprint", + limitedProbe, + authorization(), + "limited-dacl", + "d".repeat(64), + { now: () => now, log: (message) => logs.push(message) }, + ); + + expect(smoke.profile).toBe("limited-minimal-smoke"); + expect(smoke.deniedOutsideRoot).toBeNull(); + expect(smoke.execution.outcome).toBe("passed"); + expect(logs).toContainEqual(expect.stringContaining("proofIssueToDispatchMs=0")); + expect(request.mock.calls.map((call) => (call[1] as { command: string }).command)).toEqual([ + "system.run.prepare", + WINDOWS_NODE_MXC_READINESS_COMMAND, + ]); + }); + + it("fails the minimal smoke when deterministic output does not match", async () => { + const request = vi.fn(async (_method: string, envelope: unknown) => { + const call = envelope as { command: string }; + if (call.command === "system.run.prepare") return { payload: { plan: preparedPlan() } }; + return { payload: { stdout: "unexpected", stderr: "", exitCode: 0, timedOut: false } }; + }); + const smoke = await runWindowsNodeMxcSmoke( + { connected: true, request } as unknown as WindowsNodeMxcGateway, + generation, + nodeId, + "settings-fingerprint", + limitedProbe, + authorization(), + "limited-dacl", + "d".repeat(64), + ); + + expect(smoke.execution.outcome).toBe("failed"); + }); + it("fails before node invocation for stale generation, node, or transition authorization", async () => { const request = vi.fn(); const gateway = { connected: true, request } as unknown as WindowsNodeMxcGateway; @@ -115,7 +202,7 @@ describe("Windows Node MXC readiness smoke", () => { "another-generation", nodeId, "settings", - "appcontainer-dacl", + strongProbe, authorization(), ), ).rejects.toThrow(/does not match/); @@ -125,16 +212,90 @@ describe("Windows Node MXC readiness smoke", () => { generation, "d".repeat(64), "settings", - "appcontainer-dacl", + strongProbe, authorization(), ), ).rejects.toThrow(/does not match/); await expect( - runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", "appcontainer-dacl", { + runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", strongProbe, { ...authorization(), transitionId: "12345678-1234-4234-8234-123456789abe", }), ).rejects.toThrow(/does not match/); expect(request).not.toHaveBeenCalled(); }); + + it("rejects degraded containment before invoking the node", async () => { + const request = vi.fn(); + const gateway = { connected: true, request } as unknown as WindowsNodeMxcGateway; + const degraded = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", + ); + + await expect( + runWindowsNodeMxcSmoke(gateway, generation, nodeId, "settings", degraded, authorization()), + ).rejects.toThrow(/cannot enforce the configured filesystem roots/); + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("Windows Node MXC exact-backend preflight", () => { + it("accepts a proven exact backend", async () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ + tier: "base-container", + probes: { baseContainerApiPresent: true }, + }), + "", + ); + const runner = vi.fn(async () => probe); + + await expect( + preflightWindowsNodeMxcExactBackend("C:\\MXC\\wxc-exec.exe", runner), + ).resolves.toBe(probe); + expect(runner).toHaveBeenCalledWith("C:\\MXC\\wxc-exec.exe"); + }); + + it("accepts appcontainer-dacl only for an explicit Limited request", async () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", + ); + await expect( + preflightWindowsNodeMxcBackend( + "C:\\MXC\\wxc-exec.exe", + "limited-dacl", + async () => probe, + ), + ).resolves.toBe(probe); + await expect( + preflightWindowsNodeMxcBackend("C:\\MXC\\wxc-exec.exe", "full", async () => probe), + ).rejects.toThrow(/exact-containment backend/); + }); + + it("reports the unavailable OS API and compiled-out BFS backend before startup", async () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ + tier: "appcontainer-dacl", + needsDaclAugmentation: true, + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + bfsCompiledIn: false, + }, + }), + "", + ); + + await expect( + preflightWindowsNodeMxcExactBackend("C:\\MXC\\wxc-exec.exe", async () => probe), + ).rejects.toThrow( + /Base Container API is unavailable.*built without the appcontainer-bfs backend.*cannot safely install or emulate/, + ); + }); }); diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index ba8699c..1850c56 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -14,6 +14,7 @@ import { classifyMxcProbe, classifyMxcSmoke, classifyMissingEffectiveToolSession, + describeMxcExactBackendEligibility, extractEffectiveToolNames, getWindowsNodeMxcGatewayPolicyState, getMxcTierWarning, @@ -25,6 +26,14 @@ import { validateWindowsNodeMxcSettings, type WindowsNodeMxcGatewayPolicyState, } from "./windows-node-mxc"; +import { + describeWindowsNodeMxcLimitedAvailability, + summarizeWindowsNodeMxcAmbientAccess, + type WindowsNodeMxcAmbientAccessAssessment, + type WindowsNodeMxcEffectiveStrength, + type WindowsNodeMxcModeAvailability, + type WindowsNodeMxcRequestedMode, +} from "./windows-node-mxc-modes"; import type { BundledWindowsNodeActivationLease, BundledWindowsNodeHostStatus, @@ -41,10 +50,11 @@ import { } from "./windows-node-mxc-readiness-proof"; const WINDOWS_NODE_SETTINGS_FILENAME = "settings.json"; -const HOSTNAME_MARKER = "MICROCLAW_MXC_HOSTNAME_OK"; -const POWERSHELL_MARKER = "MICROCLAW_MXC_POWERSHELL_OK"; +const EXECUTION_MARKER = "MICROCLAW_MXC_EXECUTION_OK"; const ATTENDED_SMOKE_GATEWAY_TIMEOUT_MS = 90_000; const ATTENDED_SMOKE_CLIENT_TIMEOUT_MS = 95_000; +const READINESS_PREPARE_GATEWAY_TIMEOUT_MS = 90_000; +const READINESS_PREPARE_CLIENT_TIMEOUT_MS = 95_000; export interface WindowsNodeMxcGateway { connected: boolean; @@ -70,14 +80,16 @@ export function isCurrentBundledWindowsNodeApprovalCallback(state: { } export interface StoredWindowsNodeMxcSmoke { + profile: "full-smoke" | "limited-minimal-smoke"; gatewayGeneration: string; nodeId: string; settingsFingerprint: string; probeTier: string; + containmentStrength: Exclude; + ambientAccessHash: string | null; checkedAt: string; - hostname: MxcSmokeResult; - powershell: MxcSmokeResult; - deniedOutsideRoot: MxcSmokeResult; + execution: MxcSmokeResult; + deniedOutsideRoot: MxcSmokeResult | null; } export interface WindowsNodeMxcReadinessAuthorization { @@ -86,6 +98,9 @@ export interface WindowsNodeMxcReadinessAuthorization { } export interface WindowsNodeMxcRuntimeStatus { + requestedMode: WindowsNodeMxcRequestedMode; + effectiveStrength: WindowsNodeMxcEffectiveStrength; + effectiveTier: string | null; desiredEnabled: boolean; effectiveEnabled: boolean; selectedNodeId: string; @@ -113,6 +128,9 @@ export interface WindowsNodeMxcRuntimeStatus { effectiveToolsState: "unverified" | "verified" | "drift"; durableApprovalsPresent: boolean | null; probe: MxcProbeResult; + fullAvailability: WindowsNodeMxcModeAvailability; + limitedAvailability: WindowsNodeMxcModeAvailability; + ambientAccess: WindowsNodeMxcAmbientAccessAssessment | null; smoke: StoredWindowsNodeMxcSmoke | null; blockers: string[]; warnings: string[]; @@ -120,6 +138,7 @@ export interface WindowsNodeMxcRuntimeStatus { } export interface InspectWindowsNodeMxcOptions { + requestedMode: WindowsNodeMxcRequestedMode; desiredEnabled: boolean; selectedNodeId: string; config: unknown; @@ -133,6 +152,8 @@ export interface InspectWindowsNodeMxcOptions { environment?: NodeJS.ProcessEnv; bundledHost?: BundledWindowsNodeHostStatus; bundledFolders?: WindowsNodeMxcFolder[]; + runtimeIntegrityError?: string; + ambientAccess?: WindowsNodeMxcAmbientAccessAssessment | null; } export function resolveWindowsNodeSettingsPath( @@ -165,6 +186,7 @@ export async function inspectWindowsNodeMxc( const localAppData = options.localAppData ?? environment.LOCALAPPDATA ?? ""; const userProfile = options.userProfile ?? environment.USERPROFILE ?? os.homedir(); const selectedNodeId = options.selectedNodeId.trim(); + const desiredEnabled = options.requestedMode !== "off"; const bundled = options.bundledHost; const settingsPath = bundled ? "MicroClaw-managed policy" @@ -179,7 +201,7 @@ export async function inspectWindowsNodeMxc( ? ({ EnableNodeMode: true, NodeSystemRunEnabled: true, - NodeCanvasEnabled: false, + NodeCanvasEnabled: true, NodeScreenEnabled: false, NodeCameraEnabled: false, NodeLocationEnabled: false, @@ -190,17 +212,14 @@ export async function inspectWindowsNodeMxc( SystemRunSandboxEnabled: true, SystemRunBlockHostFallbackWhenMxcUnavailable: true, SystemRunAllowWindowsUi: true, - SystemRunAllowOutbound: false, + SystemRunAllowOutbound: true, SandboxClipboard: 0, } satisfies WindowsNodeMxcSettings) : await readWindowsNodeSettings(settingsPath); const settingsCheck = validateWindowsNodeMxcSettings(settings); blockers.push(...settingsCheck.blockers); warnings.push(...settingsCheck.warnings); - if (bundled && !bundled.processRunning) { - blockers.push(bundled.lastError ?? "Bundled MicroClaw Windows node host is not running"); - remediation.push("Restart MicroClaw so it can start its app-owned Windows node host."); - } else if (!settings) { + if (!settings) { remediation.push( `Install and launch OpenClaw Windows Companion, then configure its Sandbox page. Expected settings: ${settingsPath}`, ); @@ -230,16 +249,78 @@ export async function inspectWindowsNodeMxc( ); } - const probe = await runMxcProbe(wxcExecPath); - if (probe.outcome !== "supported") { - blockers.push(probe.reason ?? "MXC probe did not report a usable tier"); - remediation.push( - `Repair MXC or set OPENCLAW_WXC_EXEC to the pinned wxc-exec.exe; checked ${wxcExecPath}`, - ); + const probe = options.runtimeIntegrityError + ? classifyMxcProbe(null, "", "", options.runtimeIntegrityError) + : await runMxcProbe(wxcExecPath); + const backendEligibility = describeMxcExactBackendEligibility(probe); + const fullAvailability: WindowsNodeMxcModeAvailability = { + available: backendEligibility.eligible, + summary: backendEligibility.summary, + blockers: backendEligibility.blockers, + remediation: backendEligibility.remediation, + }; + const limitedAvailability = describeWindowsNodeMxcLimitedAvailability(probe); + const selectedAvailability = + options.requestedMode === "limited-dacl" ? limitedAvailability : fullAvailability; + if (desiredEnabled && !selectedAvailability.available) { + blockers.push(...selectedAvailability.blockers); + remediation.push(...selectedAvailability.remediation); } - const tierWarning = getMxcTierWarning(probe); + const tierWarning = options.requestedMode === "full" ? getMxcTierWarning(probe) : null; if (tierWarning) warnings.push(tierWarning); + if (options.requestedMode === "limited-dacl") { + warnings.push( + "Limited filesystem containment is active: selected folders are additive grants and ambient Windows ACLs may permit access elsewhere.", + ); + } warnings.push(...probe.warnings); + if (bundled && desiredEnabled) { + const expectedStrength = options.requestedMode === "limited-dacl" ? "limited-dacl" : "full"; + if (bundled.requestedStrength !== null && bundled.requestedStrength !== expectedStrength) { + blockers.push( + `Bundled node containment strength drifted from ${expectedStrength} to ${bundled.requestedStrength}`, + ); + } + if ( + bundled.effectiveTier !== null && + probe.tier !== null && + bundled.effectiveTier.toLowerCase() !== probe.tier.toLowerCase() + ) { + blockers.push(`Bundled node MXC tier drifted from ${bundled.effectiveTier} to ${probe.tier}`); + } + if ( + options.requestedMode === "limited-dacl" && + bundled.ambientAccess !== null && + options.ambientAccess != null && + bundled.ambientAccess.hash !== options.ambientAccess.hash + ) { + blockers.push("Bundled node ambient-access assessment hash drifted"); + } + } + const ambientSummary = summarizeWindowsNodeMxcAmbientAccess(options.ambientAccess ?? null); + if (options.requestedMode === "limited-dacl") { + if (!options.ambientAccess) { + blockers.push("A current Limited-mode ambient filesystem access assessment is required"); + } + if (ambientSummary.sensitiveAccessible.length > 0) { + blockers.push( + `Limited containment can access sensitive path categories: ${ambientSummary.sensitiveAccessible + .map((entry) => entry.category) + .join(", ")}`, + ); + } + if (ambientSummary.ordinaryAccessible.length > 0) { + warnings.push( + `Known Limited-mode ambient access: ${ambientSummary.ordinaryAccessible + .map((entry) => `${entry.category} (${entry.status})`) + .join(", ")}`, + ); + } + } + if (bundled && !bundled.processRunning && selectedAvailability.available && desiredEnabled) { + blockers.push(bundled.lastError ?? "Bundled MicroClaw Windows node host is not running"); + remediation.push("Restart MicroClaw so it can start its app-owned Windows node host."); + } const gatewayPolicyState = getWindowsNodeMxcGatewayPolicyState(options.config, selectedNodeId); const gatewayPolicyReady = gatewayPolicyState !== "drift"; @@ -262,7 +343,7 @@ export async function inspectWindowsNodeMxc( : "Select one paired local Windows node by stable node ID", ); } - if (options.desiredEnabled && !options.managedGateway) { + if (desiredEnabled && !options.managedGateway) { blockers.push("Windows Node + MXC mode requires a freshly started MicroClaw managed Gateway"); } if (!options.gateway?.connected) { @@ -287,7 +368,10 @@ export async function inspectWindowsNodeMxc( timeoutMs: 30_000, idempotencyKey: randomUUID(), }); - const attestationCheck = validateBundledCwdAttestation(attestation); + const attestationCheck = validateBundledCwdAttestation( + attestation, + options.requestedMode, + ); cwdAttestationReady = attestationCheck.ready; durableApprovalsPresent = attestationCheck.durableApprovalsPresent; if (!attestationCheck.ready) blockers.push(...attestationCheck.blockers); @@ -366,15 +450,14 @@ export async function inspectWindowsNodeMxc( selectedNodeId, settingsFingerprint, probe.tier, + options.requestedMode === "limited-dacl" ? "limited-dacl" : "full", + options.ambientAccess?.hash ?? null, ); - if ( - !smoke || - smoke.hostname.outcome !== "passed" || - smoke.powershell.outcome !== "passed" || - smoke.deniedOutsideRoot.outcome !== "passed" - ) { + if (!smoke || !isWindowsNodeMxcSmokePassed(smoke)) { blockers.push( - "A current contained hostname.exe and PowerShell child-process smoke proof is required", + options.requestedMode === "limited-dacl" + ? "A current Limited minimal contained execution smoke proof is required" + : "Current Full contained execution and outside-root denial smoke proofs are required", ); remediation.push( bundled @@ -404,13 +487,29 @@ export async function inspectWindowsNodeMxc( } const effectiveEnabled = - options.desiredEnabled && + desiredEnabled && gatewayPolicyState === "active" && effectiveToolsReady && blockers.length === 0; + const ambientSafe = + options.requestedMode !== "limited-dacl" || + (options.ambientAccess != null && ambientSummary.sensitiveAccessible.length === 0); + const containmentAttested = + desiredEnabled && + ambientSafe && + (options.requestedMode === "limited-dacl" + ? limitedAvailability.available + : fullAvailability.available); return { - desiredEnabled: options.desiredEnabled, + requestedMode: options.requestedMode, + effectiveStrength: containmentAttested + ? options.requestedMode === "limited-dacl" + ? "limited-dacl" + : "full" + : "off", + effectiveTier: containmentAttested ? probe.tier : null, + desiredEnabled, effectiveEnabled, selectedNodeId, settingsPath, @@ -441,6 +540,9 @@ export async function inspectWindowsNodeMxc( effectiveToolsState, durableApprovalsPresent, probe, + fullAvailability, + limitedAvailability, + ambientAccess: options.ambientAccess ?? null, smoke, blockers: [...new Set(blockers)], warnings: [...new Set(warnings)], @@ -461,29 +563,46 @@ export function shouldStopManagedGatewayForWindowsNodeMxc( export function isCurrentWindowsNodeMxcSmoke(value: unknown): value is StoredWindowsNodeMxcSmoke { if (!isRecord(value)) return false; return ( + (value.profile === "full-smoke" || value.profile === "limited-minimal-smoke") && typeof value.gatewayGeneration === "string" && typeof value.nodeId === "string" && typeof value.settingsFingerprint === "string" && typeof value.probeTier === "string" && + (value.containmentStrength === "full" || value.containmentStrength === "limited-dacl") && + (value.ambientAccessHash === null || + (typeof value.ambientAccessHash === "string" && + /^[a-f0-9]{64}$/.test(value.ambientAccessHash))) && typeof value.checkedAt === "string" && - isMxcSmokeResult(value.hostname) && - isMxcSmokeResult(value.powershell) && - isMxcSmokeResult(value.deniedOutsideRoot) + isMxcSmokeResult(value.execution) && + (value.deniedOutsideRoot === null || isMxcSmokeResult(value.deniedOutsideRoot)) ); } +export function isWindowsNodeMxcSmokePassed(smoke: StoredWindowsNodeMxcSmoke): boolean { + if (smoke.execution.outcome !== "passed") return false; + return smoke.profile === "limited-minimal-smoke" + ? smoke.containmentStrength === "limited-dacl" && smoke.deniedOutsideRoot === null + : smoke.containmentStrength === "full" && smoke.deniedOutsideRoot?.outcome === "passed"; +} + export function selectCurrentWindowsNodeMxcSmoke( value: unknown, gatewayGeneration: string, nodeId: string, settingsFingerprint: string | null, probeTier: string | null | undefined, + containmentStrength: Exclude = "full", + ambientAccessHash: string | null = null, ): StoredWindowsNodeMxcSmoke | null { return isCurrentWindowsNodeMxcSmoke(value) && value.gatewayGeneration === gatewayGeneration && value.nodeId === nodeId && value.settingsFingerprint === settingsFingerprint && - value.probeTier === probeTier + value.probeTier === probeTier && + value.containmentStrength === containmentStrength && + value.profile === + (containmentStrength === "limited-dacl" ? "limited-minimal-smoke" : "full-smoke") && + value.ambientAccessHash === ambientAccessHash ? value : null; } @@ -503,7 +622,10 @@ export function isCurrentBundledActivationLease( ); } -export function validateBundledCwdAttestation(value: unknown): { +export function validateBundledCwdAttestation( + value: unknown, + requestedMode: WindowsNodeMxcRequestedMode = "full", +): { ready: boolean; blockers: string[]; durableApprovalsPresent: boolean | null; @@ -530,12 +652,21 @@ export function validateBundledCwdAttestation(value: unknown): { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, durableApprovalStoreProtected: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: requestedMode === "full", + degradedIsolationTierRejected: requestedMode === "full", + requestedContainmentStrength: requestedMode === "limited-dacl" ? "limited-dacl" : "full", + folderPolicyIsExclusiveBoundary: requestedMode === "full", + durableApprovalsAllowed: requestedMode === "full", + ambientAssessmentBound: requestedMode === "limited-dacl", + outboundNetworkEnabled: true, }; const blockers = Object.entries(expected) .filter(([key, expectedValue]) => record?.[key] !== expectedValue) @@ -564,13 +695,29 @@ export async function runWindowsNodeMxcSmoke( gatewayGeneration: string, nodeId: string, settingsFingerprint: string, - probeTier: string, + probe: MxcProbeResult, authorization: WindowsNodeMxcReadinessAuthorization, + containmentStrength: Exclude = "full", + ambientAccessHash: string | null = null, + dependencies: { + now?: () => number; + log?: (message: string) => void; + } = {}, ): Promise { if (!gateway.connected) throw new Error("MicroClaw managed Gateway is not connected"); if (!nodeId.trim()) throw new Error("A stable Windows node ID is required"); if (!settingsFingerprint) throw new Error("Strict Windows Companion settings are not loaded"); - if (!probeTier) throw new Error("A supported MXC tier is required"); + const tier = probe.tier?.toLowerCase(); + const tierAccepted = + containmentStrength === "full" + ? probe.outcome === "supported" && !probe.degraded + : tier === "appcontainer-dacl" && probe.outcome !== "error"; + if (!tierAccepted || !probe.tier) { + throw new Error( + probe.reason ?? + "Exact filesystem isolation requires the base-container or appcontainer-bfs MXC tier", + ); + } if ( authorization.transitionId !== authorization.proofContext.readinessTransitionId || gatewayGeneration !== authorization.proofContext.gatewayGeneration || @@ -578,39 +725,33 @@ export async function runWindowsNodeMxcSmoke( ) { throw new Error("Internal readiness authorization does not match this lifecycle generation"); } - const minter = createWindowsNodeMxcReadinessProofMinter(authorization.proofContext); - - const deniedOutsideRoot = await invokeDeniedCwdSmoke(gateway, nodeId); - const hostname = await invokeInternalReadinessProbe( + const minter = createWindowsNodeMxcReadinessProofMinter(authorization.proofContext, { + now: dependencies.now, + }); + const profile = containmentStrength === "limited-dacl" ? "limited-minimal-smoke" : "full-smoke"; + const deniedOutsideRoot = + containmentStrength === "full" ? await invokeDeniedCwdSmoke(gateway, nodeId) : null; + const execution = await invokeInternalReadinessProbe( gateway, nodeId, - "hostname", - HOSTNAME_MARKER, + "execution", + EXECUTION_MARKER, authorization.transitionId, minter, + profile, + dependencies.now ?? Date.now, + dependencies.log ?? console.info, ); - let powershell: MxcSmokeResult = { - outcome: "failed", - reason: "PowerShell smoke was not run because hostname.exe did not pass", - }; - if (hostname.outcome === "passed") { - powershell = await invokeInternalReadinessProbe( - gateway, - nodeId, - "powershell", - POWERSHELL_MARKER, - authorization.transitionId, - minter, - ); - } return { + profile, gatewayGeneration, nodeId: nodeId.trim(), settingsFingerprint, - probeTier, + probeTier: probe.tier, + containmentStrength, + ambientAccessHash, checkedAt: new Date().toISOString(), - hostname, - powershell, + execution, deniedOutsideRoot, }; } @@ -664,6 +805,9 @@ async function invokeInternalReadinessProbe( marker: string, transitionId: string, minter: ReturnType, + profile: StoredWindowsNodeMxcSmoke["profile"], + now: () => number, + log: (message: string) => void, ): Promise { const command = [...WINDOWS_NODE_MXC_READINESS_PROBES[probeKind]]; const sessionKey = windowsNodeMxcReadinessSessionKey(transitionId); @@ -679,18 +823,19 @@ async function invokeInternalReadinessProbe( agentId: WINDOWS_NODE_MXC_READINESS_AGENT_ID, sessionKey, }, - timeoutMs: 20_000, + timeoutMs: READINESS_PREPARE_GATEWAY_TIMEOUT_MS, idempotencyKey: randomUUID(), }, - 25_000, + READINESS_PREPARE_CLIENT_TIMEOUT_MS, ); const plan = findReadinessPlan(prepared); if (!plan) { throw new Error(`The bundled node did not return an exact ${probeKind} readiness plan`); } const proof = minter.mint({ transitionId, nodeId, probeKind, plan }); - console.info( - `[windows-node-mxc] Running internal fixed readiness probe kind=${probeKind} transition=${transitionId} generation=${proof.gatewayGeneration}`, + const dispatchAtUnixMs = Math.floor(now()); + log( + `[windows-node-mxc] ${profile} dispatch kind=${probeKind} transition=${transitionId} generation=${proof.gatewayGeneration} proofIssueToDispatchMs=${Math.max(0, dispatchAtUnixMs - proof.issuedAtUnixMs)}`, ); const result = await gateway.request( "node.invoke", @@ -841,6 +986,43 @@ function hasDurableApprovals(payload: unknown): boolean { }); } +export async function preflightWindowsNodeMxcExactBackend( + wxcExecPath: string, + probeRunner: (path: string) => Promise = runMxcProbe, +): Promise { + const probe = await probeRunner(wxcExecPath); + const eligibility = describeMxcExactBackendEligibility(probe); + if (!eligibility.eligible) { + const remediation = eligibility.remediation[0]; + throw new Error( + `No eligible MXC exact-containment backend is available. ${eligibility.summary}${ + remediation ? ` ${remediation}` : "" + }`, + ); + } + return probe; +} + +export async function preflightWindowsNodeMxcBackend( + wxcExecPath: string, + requestedMode: Exclude, + probeRunner: (path: string) => Promise = runMxcProbe, +): Promise { + if (requestedMode === "full") { + return preflightWindowsNodeMxcExactBackend(wxcExecPath, probeRunner); + } + const probe = await probeRunner(wxcExecPath); + const eligibility = describeWindowsNodeMxcLimitedAvailability(probe); + if (!eligibility.available) { + throw new Error( + `No eligible MXC Limited DACL backend is available. ${eligibility.summary}${ + eligibility.remediation[0] ? ` ${eligibility.remediation[0]}` : "" + }`, + ); + } + return probe; +} + async function runMxcProbe(wxcExecPath: string): Promise { if (!fs.existsSync(wxcExecPath)) { return classifyMxcProbe(null, "", "", `wxc-exec.exe not found at ${wxcExecPath}`); diff --git a/desktop/src/windows-node-mxc-weixin.test.ts b/desktop/src/windows-node-mxc-weixin.test.ts new file mode 100644 index 0000000..820ed47 --- /dev/null +++ b/desktop/src/windows-node-mxc-weixin.test.ts @@ -0,0 +1,132 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + getWindowsNodeMxcWeixinEnvironment, + preflightWindowsNodeMxcWeixinAuthorization, + prepareWindowsNodeMxcWeixinContext, + releaseWindowsNodeMxcWeixinIngress, + revokeWindowsNodeMxcWeixinIngress, + WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT, + WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT, +} from "./windows-node-mxc-weixin"; + +describe("Windows Node MXC WeChat lifecycle", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("binds ingress release and media roots to one Gateway generation and policy", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-")); + roots.push(root); + const selected = path.join(root, "selected"); + fs.mkdirSync(selected); + const context = prepareWindowsNodeMxcWeixinContext( + root, + "generation-1", + "a".repeat(64), + [{ path: selected, access: "rw" }], + ); + + expect(fs.existsSync(context.releasePath)).toBe(false); + const environment = getWindowsNodeMxcWeixinEnvironment(context); + expect(environment.MICROCLAW_MXC_INGRESS_RELEASE_TOKEN).toBe(context.releaseToken); + const policy = JSON.parse( + Buffer.from(environment.MICROCLAW_MXC_WEIXIN_MEDIA_POLICY, "base64").toString("utf8"), + ); + expect(policy).toMatchObject({ + contract: WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT, + gatewayGeneration: "generation-1", + policyFingerprint: "a".repeat(64), + strictNoHostFallback: true, + inboundRoot: context.inboundRoot, + outboundTempRoot: context.outboundTempRoot, + }); + expect(policy.approvedRoots).toEqual([ + { path: selected, access: "rw", purpose: "selected-folder" }, + { path: context.inboundRoot, access: "ro", purpose: "weixin-inbound" }, + ]); + + releaseWindowsNodeMxcWeixinIngress(context); + expect(JSON.parse(fs.readFileSync(context.releasePath, "utf8"))).toMatchObject({ + contract: WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT, + gatewayGeneration: "generation-1", + policyFingerprint: "a".repeat(64), + token: context.releaseToken, + }); + revokeWindowsNodeMxcWeixinIngress(context); + expect(fs.existsSync(context.releasePath)).toBe(false); + }); + + it("migrates a stored owner id and accepts explicit allowlists for enabled accounts", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-auth-")); + roots.push(root); + const accountsDir = path.join(root, "openclaw-weixin", "accounts"); + const credentialsDir = path.join(root, "credentials"); + fs.mkdirSync(accountsDir, { recursive: true }); + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync( + path.join(root, "openclaw-weixin", "accounts.json"), + JSON.stringify(["owner-im-bot", "paired-im-bot", "disabled-im-bot"]), + ); + fs.writeFileSync( + path.join(accountsDir, "owner-im-bot.json"), + JSON.stringify({ userId: "owner@im.wechat" }), + ); + fs.writeFileSync( + path.join(credentialsDir, "openclaw-weixin-paired-im-bot-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["paired@im.wechat"] }), + ); + + expect( + preflightWindowsNodeMxcWeixinAuthorization({ + stateDir: root, + credentialsDir, + config: { + plugins: { entries: { "openclaw-weixin": { enabled: true } } }, + channels: { + "openclaw-weixin": { + accounts: { "disabled-im-bot": { enabled: false } }, + }, + }, + }, + }), + ).toEqual({ migratedAccountIds: ["owner-im-bot"] }); + expect( + JSON.parse( + fs.readFileSync( + path.join(credentialsDir, "openclaw-weixin-owner-im-bot-allowFrom.json"), + "utf8", + ), + ), + ).toMatchObject({ version: 1, allowFrom: ["owner@im.wechat"] }); + }); + + it("blocks ingress release when an enabled account has no explicit sender identity", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-auth-")); + roots.push(root); + const accountsDir = path.join(root, "openclaw-weixin", "accounts"); + fs.mkdirSync(accountsDir, { recursive: true }); + fs.writeFileSync( + path.join(root, "openclaw-weixin", "accounts.json"), + JSON.stringify(["unknown-im-bot"]), + ); + fs.writeFileSync( + path.join(accountsDir, "unknown-im-bot.json"), + JSON.stringify({ token: "token-without-owner" }), + ); + + expect(() => + preflightWindowsNodeMxcWeixinAuthorization({ + stateDir: root, + config: { + plugins: { entries: { "openclaw-weixin": { enabled: true } } }, + }, + }), + ).toThrow(/unknown-im-bot/); + }); +}); diff --git a/desktop/src/windows-node-mxc-weixin.ts b/desktop/src/windows-node-mxc-weixin.ts new file mode 100644 index 0000000..1992567 --- /dev/null +++ b/desktop/src/windows-node-mxc-weixin.ts @@ -0,0 +1,248 @@ +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import type { BundledWindowsNodeFolder } from "./bundled-windows-node-host"; + +export const WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT = + "microclaw.windows-ingress-release.v1"; +export const WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT = + "microclaw.windows-weixin-media.v1"; + +export interface WindowsNodeMxcWeixinContext { + gatewayGeneration: string; + policyFingerprint: string; + releasePath: string; + releaseToken: string; + inboundRoot: string; + outboundTempRoot: string; + mediaPolicyBase64: string; +} + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function safeAccountKey(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") throw new Error("MXC WeChat account id is invalid"); + return safe; +} + +function readJsonRecord(filePath: string): JsonRecord | null { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as JsonRecord) + : null; + } catch { + return null; + } +} + +function readAllowFrom(filePath: string): string[] { + const allowFrom = readJsonRecord(filePath)?.allowFrom; + return Array.isArray(allowFrom) + ? allowFrom + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; +} + +function resolveStoredAccountPath(stateDir: string, accountId: string): string { + const accountsDir = path.join(stateDir, "openclaw-weixin", "accounts"); + const primary = path.join(accountsDir, `${accountId}.json`); + if (fs.existsSync(primary)) return primary; + if (accountId.endsWith("-im-bot")) { + return path.join(accountsDir, `${accountId.slice(0, -7)}@im.bot.json`); + } + if (accountId.endsWith("-im-wechat")) { + return path.join(accountsDir, `${accountId.slice(0, -10)}@im.wechat.json`); + } + return primary; +} + +export function preflightWindowsNodeMxcWeixinAuthorization(params: { + stateDir: string; + credentialsDir?: string; + config: unknown; +}): { migratedAccountIds: string[] } { + const root = asRecord(params.config); + const plugins = asRecord(root.plugins); + const pluginEntries = asRecord(plugins.entries); + if (asRecord(pluginEntries["openclaw-weixin"]).enabled !== true) { + return { migratedAccountIds: [] }; + } + + const accountsPath = path.join(params.stateDir, "openclaw-weixin", "accounts.json"); + if (!fs.existsSync(accountsPath)) return { migratedAccountIds: [] }; + let accountIds: string[] = []; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(accountsPath, "utf8")); + if ( + !Array.isArray(parsed) || + parsed.some((value) => typeof value !== "string" || value.trim().length === 0) + ) { + throw new Error("account index is invalid"); + } + accountIds = parsed; + } catch (error) { + throw new Error( + `MXC WeChat authorization preflight failed: account index is unreadable: ${String(error)}`, + ); + } + + const channel = asRecord(asRecord(root.channels)["openclaw-weixin"]); + const configuredAccounts = asRecord(channel.accounts); + const credentialsDir = + params.credentialsDir?.trim() || path.join(params.stateDir, "credentials"); + const migratedAccountIds: string[] = []; + const blockedAccountIds: string[] = []; + + for (const accountId of accountIds) { + const accountConfig = + Object.prototype.hasOwnProperty.call(configuredAccounts, accountId) + ? asRecord(configuredAccounts[accountId]) + : channel; + if (accountConfig.enabled === false) continue; + + const allowFromPath = path.join( + credentialsDir, + `openclaw-weixin-${safeAccountKey(accountId)}-allowFrom.json`, + ); + if (readAllowFrom(allowFromPath).length > 0) continue; + + const account = readJsonRecord(resolveStoredAccountPath(params.stateDir, accountId)); + const ownerUserId = + typeof account?.userId === "string" ? account.userId.trim() : ""; + if (!ownerUserId) { + blockedAccountIds.push(accountId); + continue; + } + + fs.mkdirSync(credentialsDir, { recursive: true }); + const existing = readJsonRecord(allowFromPath) ?? {}; + fs.writeFileSync( + allowFromPath, + JSON.stringify( + { + ...existing, + version: typeof existing.version === "number" ? existing.version : 1, + allowFrom: [ownerUserId], + }, + null, + 2, + ), + "utf8", + ); + migratedAccountIds.push(accountId); + } + + if (blockedAccountIds.length > 0) { + throw new Error( + `MXC WeChat authorization preflight failed: enabled account(s) lack an explicit sender allowlist or stored owner userId: ${blockedAccountIds.join(", ")}`, + ); + } + return { migratedAccountIds }; +} + +export function resolveWindowsNodeMxcWeixinMediaRoots(userDataPath: string): { + inboundRoot: string; + outboundTempRoot: string; +} { + const mediaRoot = path.join(userDataPath, "mxc-weixin-media"); + return { + inboundRoot: path.join(mediaRoot, "inbound"), + outboundTempRoot: path.join(mediaRoot, "outbound-temp"), + }; +} + +export function prepareWindowsNodeMxcWeixinContext( + userDataPath: string, + gatewayGeneration: string, + policyFingerprint: string, + folders: BundledWindowsNodeFolder[], +): WindowsNodeMxcWeixinContext { + if (!gatewayGeneration.trim()) throw new Error("Gateway generation is required"); + if (!/^[a-f0-9]{64}$/i.test(policyFingerprint)) { + throw new Error("MXC policy fingerprint is invalid"); + } + const { inboundRoot, outboundTempRoot } = + resolveWindowsNodeMxcWeixinMediaRoots(userDataPath); + fs.mkdirSync(inboundRoot, { recursive: true }); + fs.mkdirSync(outboundTempRoot, { recursive: true }); + const releasePath = path.join(userDataPath, "windows-node", "ingress-release.json"); + fs.rmSync(releasePath, { force: true }); + const releaseToken = randomBytes(32).toString("base64url"); + const approvedRoots = [ + ...folders.map((folder) => ({ + path: path.resolve(folder.path), + access: folder.access, + purpose: "selected-folder", + })), + { + path: path.resolve(inboundRoot), + access: "ro" as const, + purpose: "weixin-inbound", + }, + ]; + const mediaPolicyBase64 = Buffer.from( + JSON.stringify({ + contract: WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT, + gatewayGeneration, + policyFingerprint: policyFingerprint.toLowerCase(), + strictNoHostFallback: true, + approvedRoots, + inboundRoot: path.resolve(inboundRoot), + outboundTempRoot: path.resolve(outboundTempRoot), + }), + "utf8", + ).toString("base64"); + return { + gatewayGeneration, + policyFingerprint: policyFingerprint.toLowerCase(), + releasePath, + releaseToken, + inboundRoot, + outboundTempRoot, + mediaPolicyBase64, + }; +} + +export function getWindowsNodeMxcWeixinEnvironment( + context: WindowsNodeMxcWeixinContext, +): Record { + return { + MICROCLAW_MXC_INGRESS_RELEASE_PATH: context.releasePath, + MICROCLAW_MXC_INGRESS_RELEASE_TOKEN: context.releaseToken, + MICROCLAW_MXC_WEIXIN_MEDIA_POLICY: context.mediaPolicyBase64, + }; +} + +export function releaseWindowsNodeMxcWeixinIngress( + context: WindowsNodeMxcWeixinContext, +): void { + const record = { + contract: WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT, + gatewayGeneration: context.gatewayGeneration, + policyFingerprint: context.policyFingerprint, + token: context.releaseToken, + releasedAtUnixMs: Date.now(), + }; + fs.mkdirSync(path.dirname(context.releasePath), { recursive: true }); + const temporary = `${context.releasePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(record), { encoding: "utf8", flag: "wx" }); + fs.renameSync(temporary, context.releasePath); +} + +export function revokeWindowsNodeMxcWeixinIngress( + context: WindowsNodeMxcWeixinContext | null, +): void { + if (context) fs.rmSync(context.releasePath, { force: true }); +} diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index ba00444..0ea0fac 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -8,6 +8,7 @@ import { classifyMxcProbe, classifyMxcSmoke, classifyMissingEffectiveToolSession, + describeMxcExactBackendEligibility, extractEffectiveToolNames, getMxcTierWarning, getWindowsNodeMxcGatewayPolicyState, @@ -22,10 +23,14 @@ import { restoreWindowsNodeMxcGatewayPolicy, selectWindowsNodeMxcGatewayStartPolicy, validateEffectiveToolNames, + validateNoGatewayNativeFilesystemTools, validateSelectedWindowsNode, validateWindowsNodeMxcGatewayPolicy, validateWindowsNodeMxcFolderPath, validateWindowsNodeMxcSettings, + WINDOWS_NODE_MXC_CANVAS_COMMANDS, + WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS, + WINDOWS_NODE_MXC_TOOL_ALLOWLIST, } from "./windows-node-mxc"; import { classifyDeniedCwdSmoke, @@ -40,7 +45,7 @@ import { const strictSettings = { EnableNodeMode: true, NodeSystemRunEnabled: true, - NodeCanvasEnabled: false, + NodeCanvasEnabled: true, NodeScreenEnabled: false, NodeCameraEnabled: false, NodeLocationEnabled: false, @@ -50,7 +55,7 @@ const strictSettings = { EnableMcpServer: false, SystemRunSandboxEnabled: true, SystemRunBlockHostFallbackWhenMxcUnavailable: true, - SystemRunAllowOutbound: false, + SystemRunAllowOutbound: true, SystemRunAllowWindowsUi: true, SandboxClipboard: 0, }; @@ -86,6 +91,7 @@ describe("Gateway node exec approval bridge", () => { arguments: ["-Command", "Set-Content test.txt ok"], executablePath: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", executableSha256: "c".repeat(64), + hasMutableFileOperand: false, canonicalCwd: "isolated-scratch:v1", agent: "main", sessionKey: "agent:main:approval-regression", @@ -124,6 +130,7 @@ describe("Gateway node exec approval bridge", () => { arguments: ["-Command", "Set-Content test.txt ok"], executablePath: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", executableSha256: "c".repeat(64), + hasMutableFileOperand: false, canonicalCwd: "isolated-scratch:v1", agent: "main", sessionKey: "agent:main:approval-regression", @@ -314,13 +321,22 @@ describe("bundled Windows Node CWD attestation", () => { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: true, + degradedIsolationTierRejected: true, durableApprovalStoreProtected: true, durableApprovalsPresent: true, + requestedContainmentStrength: "full", + folderPolicyIsExclusiveBoundary: true, + durableApprovalsAllowed: true, + ambientAssessmentBound: false, + outboundNetworkEnabled: true, }, }), ).toEqual({ ready: true, blockers: [], durableApprovalsPresent: true }); @@ -332,11 +348,18 @@ describe("bundled Windows Node CWD attestation", () => { "approvalProofBindsPreparedPlan", "approvalProofBindsExecutableContent", "approvalProofBindsActivation", + "launchTimeApprovalProofRevalidation", "readinessProofBindsTransition", "readinessProofBindsPreparedPlan", "readinessProofExactBuiltInsOnly", "readinessProofOneUse", + "launchTimeReadinessProofRevalidation", + "exactFilesystemIsolationRequired", + "degradedIsolationTierRejected", "durableApprovalStoreProtected", + "folderPolicyIsExclusiveBoundary", + "durableApprovalsAllowed", + "outboundNetworkEnabled", ])("fails closed when %s is not attested", (property) => { const payload = { contract: "microclaw.windows-cwd.v1", @@ -359,13 +382,22 @@ describe("bundled Windows Node CWD attestation", () => { approvalProofPlanContract: "microclaw.windows-node-approval-plan.v2", approvalProofBindsExecutableContent: true, approvalProofBindsActivation: true, + launchTimeApprovalProofRevalidation: true, readinessProofContract: "microclaw.windows-node-readiness.v1", readinessProofBindsTransition: true, readinessProofBindsPreparedPlan: true, readinessProofExactBuiltInsOnly: true, readinessProofOneUse: true, + launchTimeReadinessProofRevalidation: true, + exactFilesystemIsolationRequired: true, + degradedIsolationTierRejected: true, durableApprovalStoreProtected: true, durableApprovalsPresent: false, + requestedContainmentStrength: "full", + folderPolicyIsExclusiveBoundary: true, + durableApprovalsAllowed: true, + ambientAssessmentBound: false, + outboundNetworkEnabled: true, }; expect( @@ -414,22 +446,24 @@ describe("bundled Windows Node CWD attestation", () => { settingsFingerprint: "settings", probeTier: "appcontainer-dacl", checkedAt: "2026-08-18T00:00:00.000Z", - hostname: { outcome: "passed", reason: "ok" }, - powershell: { outcome: "passed", reason: "ok" }, + profile: "limited-minimal-smoke", + execution: { outcome: "passed", reason: "ok" }, }), ).toBe(false); }); it("binds smoke proof and ingress release to the exact Gateway generation", () => { const smoke = { + profile: "limited-minimal-smoke" as const, gatewayGeneration: "generation-1", nodeId: "node-1", settingsFingerprint: "settings-1", probeTier: "appcontainer-dacl", + containmentStrength: "limited-dacl" as const, + ambientAccessHash: "a".repeat(64), checkedAt: new Date().toISOString(), - hostname: { outcome: "passed" as const, reason: "ok" }, - powershell: { outcome: "passed" as const, reason: "ok" }, - deniedOutsideRoot: { outcome: "passed" as const, reason: "denied" }, + execution: { outcome: "passed" as const, reason: "ok" }, + deniedOutsideRoot: null, }; expect( @@ -439,6 +473,8 @@ describe("bundled Windows Node CWD attestation", () => { "node-1", "settings-1", "appcontainer-dacl", + "limited-dacl", + "a".repeat(64), ), ).toBe(smoke); expect( @@ -448,6 +484,8 @@ describe("bundled Windows Node CWD attestation", () => { "node-1", "settings-1", "appcontainer-dacl", + "limited-dacl", + "a".repeat(64), ), ).toBeNull(); expect(isWindowsNodeMxcIngressReleased(true, "generation-1", "generation-1", false)).toBe( @@ -511,11 +549,11 @@ describe("Windows Node MXC Gateway policy", () => { expect(selectWindowsNodeMxcGatewayStartPolicy(null, false)).toBe("locked"); }); - it("exposes only exec and pins it to one stable node", () => { + it("exposes contained exec, Gateway web, brokered messaging, and reviewed Canvas while pinning exec", () => { expect(buildWindowsNodeMxcToolPolicy("node-123")).toEqual({ profile: "full", - allow: ["exec"], - deny: ["process", "group:fs", "group:plugins"], + allow: ["exec", "web_search", "web_fetch", "message", "canvas"], + deny: ["process", "group:fs"], codeMode: false, exec: { host: "node", @@ -532,7 +570,10 @@ describe("Windows Node MXC Gateway policy", () => { const source = { agents: { list: [ - { id: "main", tools: { allow: ["read"] } }, + { + id: "main", + tools: { allow: [...WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS] }, + }, { id: "coder", name: "Coder" }, ], }, @@ -606,10 +647,13 @@ describe("Windows Node MXC Gateway policy", () => { }); }); - it("disables and restores every MicroClaw-managed external ingress surface", () => { + it("keeps only requested WeChat lifecycle loaded while pausing other ingress", () => { const source = { agents: { list: [{ id: "main" }] }, - channels: { discord: { enabled: true, token: "preserved" } }, + channels: { + discord: { enabled: true, token: "preserved" }, + "openclaw-weixin": { enabled: true }, + }, hooks: { enabled: true, token: "preserved", internal: { enabled: true } }, cron: { enabled: true, maxConcurrentRuns: 2 }, plugins: { @@ -622,13 +666,22 @@ describe("Windows Node MXC Gateway policy", () => { const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "locked"); expect(applied.config).toMatchObject({ - channels: { discord: { enabled: false, token: "preserved" } }, + channels: { + discord: { enabled: false, token: "preserved" }, + "openclaw-weixin": { enabled: true }, + }, hooks: { enabled: false, token: "preserved", internal: { enabled: false } }, cron: { enabled: false, maxConcurrentRuns: 2 }, plugins: { - enabled: false, - allow: ["openclaw-weixin"], - entries: { "openclaw-weixin": { enabled: false } }, + enabled: true, + allow: ["canvas", "openclaw-weixin"], + slots: { + memory: "none", + }, + entries: { + "openclaw-weixin": { enabled: true }, + canvas: { enabled: true }, + }, }, }); expect(validateWindowsNodeMxcGatewayPolicy(applied.config, "node-123", "locked").ready).toBe( @@ -652,6 +705,36 @@ describe("Windows Node MXC Gateway policy", () => { for (const drift of drifts) { expect(validateWindowsNodeMxcGatewayPolicy(drift, "node-123", "active").ready).toBe(false); } + const implicitMemoryCore = structuredClone(baseline); + ( + implicitMemoryCore.plugins as { + slots: Record; + } + ).slots = {}; + expect( + validateWindowsNodeMxcGatewayPolicy(implicitMemoryCore, "node-123", "active").blockers, + ).toContain('Gateway memory plugin slot must remain disabled with plugins.slots.memory="none"'); + }); + + it("never exposes Cron or automations tools and preserves native filesystem denial", () => { + expect(WINDOWS_NODE_MXC_TOOL_ALLOWLIST).not.toContain("cron"); + expect(WINDOWS_NODE_MXC_TOOL_ALLOWLIST).not.toContain("automations"); + + for (const forbiddenTool of ["cron", "automations"]) { + const source = { agents: { list: [{ id: "main" }] } }; + const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "active").config; + const agent = (applied.agents as { list: Array<{ tools: { allow: string[] } }> }).list[0]; + agent.tools.allow.push(forbiddenTool); + + const result = validateWindowsNodeMxcGatewayPolicy(applied, "node-123", "active"); + expect(result.ready).toBe(false); + } + + const policy = buildWindowsNodeMxcToolPolicy("node-123"); + expect(policy.deny).toContain("group:fs"); + for (const filesystemTool of WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS) { + expect(policy.allow).not.toContain(filesystemTool); + } }); it("uses an empty locked tool inventory until all readiness proofs pass", () => { @@ -668,7 +751,31 @@ describe("Windows Node MXC Gateway policy", () => { expect(validateEffectiveToolNames(["exec"], "locked").ready).toBe(false); }); - it("detects file-tool, plugin, or host-exec policy drift", () => { + it.each(WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS)( + "fails closed when policy drift enables the Gateway-native %s tool", + (filesystemTool) => { + const config = { + agents: { + list: [ + { + id: "main", + tools: { + ...buildWindowsNodeMxcToolPolicy("node-123"), + allow: ["exec", filesystemTool], + }, + }, + ], + }, + }; + const result = validateWindowsNodeMxcGatewayPolicy(config, "node-123"); + expect(result.ready).toBe(false); + expect(result.blockers).toContain( + `Agent "main" enables Gateway-native filesystem tools while MXC is enabled: ${filesystemTool}`, + ); + }, + ); + + it("detects plugin or host-exec policy drift", () => { const config = { agents: { list: [ @@ -676,7 +783,6 @@ describe("Windows Node MXC Gateway policy", () => { id: "main", tools: { ...buildWindowsNodeMxcToolPolicy("node-123"), - allow: ["exec", "read"], exec: { host: "gateway" }, }, }, @@ -688,11 +794,62 @@ describe("Windows Node MXC Gateway policy", () => { expect(result.blockers[0]).toContain("tool policy drifted"); }); - it("requires the effective runtime inventory to contain only exec", () => { - expect(validateEffectiveToolNames(["exec"]).ready).toBe(true); + it("requires the effective inventory to contain only contained exec, Web, messaging, and Canvas", () => { + expect( + validateEffectiveToolNames(["exec", "web_search", "web_fetch", "message", "canvas"]).ready, + ).toBe(true); + expect(validateEffectiveToolNames(["exec"]).ready).toBe(false); expect(validateEffectiveToolNames(["exec", "read"]).ready).toBe(false); }); + it.each(["full", "limited-dacl"] as const)( + "keeps Gateway-native filesystem tools forbidden for %s when safe capabilities are restored", + () => { + const restoredCapabilities = ["exec", "web_search", "web_fetch", "message", "canvas"]; + expect(validateNoGatewayNativeFilesystemTools(restoredCapabilities).ready).toBe(true); + + for (const filesystemTool of WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS) { + const check = validateNoGatewayNativeFilesystemTools([ + ...restoredCapabilities, + filesystemTool, + ]); + expect(check.ready).toBe(false); + expect(check.blockers[0]).toContain(filesystemTool); + const effectiveCheck = validateEffectiveToolNames([ + "exec", + "web_search", + "web_fetch", + "message", + filesystemTool, + ]); + expect(effectiveCheck.ready).toBe(false); + expect(validateEffectiveToolNames([filesystemTool], "locked").ready).toBe(false); + expect( + shouldStopManagedGatewayForWindowsNodeMxc({ + effectiveEnabled: false, + effectiveToolsState: effectiveCheck.ready ? "verified" : "drift", + gatewayPolicyState: "active", + }), + ).toBe(true); + } + }, + ); + + it("fails closed when the filesystem group denial is removed", () => { + const policy = buildWindowsNodeMxcToolPolicy("node-123") as { + deny: string[]; + }; + policy.deny = policy.deny.filter((entry) => entry !== "group:fs"); + const result = validateWindowsNodeMxcGatewayPolicy( + { agents: { list: [{ id: "main", tools: policy }] } }, + "node-123", + ); + expect(result.ready).toBe(false); + expect(result.blockers).toContain( + 'Agent "main" does not deny the Gateway-native filesystem tool group', + ); + }); + it("parses pinned OpenClaw grouped tool IDs and rejects unknown payloads", () => { expect( extractEffectiveToolNames({ @@ -746,11 +903,11 @@ describe("Windows Node strict settings", () => { expect(validateWindowsNodeMxcSettings(strictSettings).ready).toBe(true); }); - it("blocks host fallback, network, clipboard, MCP, and peripheral drift", () => { + it("blocks host fallback, missing contained network, clipboard, MCP, and peripheral drift", () => { const result = validateWindowsNodeMxcSettings({ ...strictSettings, SystemRunBlockHostFallbackWhenMxcUnavailable: false, - SystemRunAllowOutbound: true, + SystemRunAllowOutbound: false, SandboxClipboard: 1, EnableMcpServer: true, NodeScreenEnabled: true, @@ -759,7 +916,7 @@ describe("Windows Node strict settings", () => { expect(result.blockers).toEqual( expect.arrayContaining([ "Strict MXC host-fallback blocking is disabled", - "Outbound network access must remain blocked", + "Contained outbound network access must be enabled for this mode", "Sandbox clipboard policy must be None", "Local MCP must remain disabled for this mode", "Screen capability must be disabled", @@ -966,21 +1123,96 @@ describe("Windows Node MXC global folder policy", () => { }); describe("MXC probe and smoke classification", () => { - it("accepts DACL-only machines with a prominent degraded warning", () => { + it("fails closed on DACL-only machines", () => { const probe = classifyMxcProbe( 0, - JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + JSON.stringify({ + tier: "appcontainer-dacl", + needsDaclAugmentation: true, + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + bfsCompiledIn: false, + }, + }), "", ); - expect(probe.outcome).toBe("supported"); + expect(probe.outcome).toBe("unsupported"); + expect(probe.degraded).toBe(true); + expect(probe.probes).toEqual({ + baseContainerApiPresent: false, + bfscfgPresent: false, + bfsCompiledIn: false, + }); + expect(probe.reason).toContain("cannot enforce the configured filesystem roots"); + expect(getMxcTierWarning(probe)).toContain("Rejected degraded MXC containment"); + expect(describeMxcExactBackendEligibility(probe)).toMatchObject({ + eligible: false, + blockers: [ + expect.stringContaining("Base Container API is unavailable"), + expect.stringContaining("runtime was built without the appcontainer-bfs backend"), + ], + remediation: [ + expect.stringContaining("processmodel.dll"), + expect.stringContaining("Do not copy bfscfg.exe"), + ], + }); + }); + + it("fails closed when an otherwise strong tier requests DACL augmentation", () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ tier: "base-container", needsDaclAugmentation: true }), + "", + ); + expect(probe.outcome).toBe("unsupported"); expect(probe.degraded).toBe(true); - expect(getMxcTierWarning(probe)).toContain("requires host DACL augmentation"); }); it("treats base-container and BFS as full-strength", () => { - for (const tier of ["base-container", "appcontainer-bfs"]) { - expect(classifyMxcProbe(0, JSON.stringify({ tier }), "").degraded).toBe(false); - } + expect( + classifyMxcProbe( + 0, + JSON.stringify({ + tier: "base-container", + probes: { baseContainerApiPresent: true }, + }), + "", + ).outcome, + ).toBe("supported"); + expect( + classifyMxcProbe( + 0, + JSON.stringify({ + tier: "appcontainer-bfs", + probes: { bfsCompiledIn: true, bfscfgPresent: true }, + }), + "", + ).outcome, + ).toBe("supported"); + }); + + it("rejects contradictory exact-backend probe evidence", () => { + expect( + classifyMxcProbe( + 0, + JSON.stringify({ + tier: "base-container", + probes: { baseContainerApiPresent: false }, + }), + "", + ).outcome, + ).toBe("error"); + expect( + classifyMxcProbe( + 0, + JSON.stringify({ + tier: "appcontainer-bfs", + probes: { bfsCompiledIn: false, bfscfgPresent: true }, + }), + "", + ).outcome, + ).toBe("error"); }); it("fails closed on an unknown isolation tier", () => { @@ -1035,7 +1267,18 @@ describe("selected node readiness", () => { "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", ); - node!.commands.push("system.run.readiness", "system.which", "system.run.cwd-policy"); + node!.commands.push( + "system.run.readiness", + "system.which", + "system.run.cwd-policy", + ...WINDOWS_NODE_MXC_CANVAS_COMMANDS, + ); expect(validateSelectedWindowsNode(node).ready).toBe(true); + + node!.commands.push("canvas.eval"); + expect(validateSelectedWindowsNode(node)).toMatchObject({ + ready: false, + blockers: ["Selected node declares unexpected commands: canvas.eval"], + }); }); }); diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index f1a9a84..64212de 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -10,15 +10,40 @@ export const WINDOWS_NODE_MXC_REQUIRED_COMMANDS = [ "system.which", ] as const; export const WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND = "system.run.cwd-policy"; +export const WINDOWS_NODE_MXC_CANVAS_COMMANDS = [ + "canvas.present", + "canvas.hide", + "canvas.snapshot", + "canvas.caps", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "canvas.a2ui.dump", +] as const; +export const WINDOWS_NODE_MXC_CANVAS_ACTIONS = ["cancel", "click", "confirm", "submit"] as const; export const WINDOWS_NODE_MXC_NODE_COMMANDS = [ ...WINDOWS_NODE_MXC_REQUIRED_COMMANDS, WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, + ...WINDOWS_NODE_MXC_CANVAS_COMMANDS, +] as const; +export const WINDOWS_NODE_MXC_TOOL_ALLOWLIST = [ + "exec", + "web_search", + "web_fetch", + "message", + "canvas", ] as const; -export const WINDOWS_NODE_MXC_TOOL_ALLOWLIST = ["exec"] as const; export const WINDOWS_NODE_MXC_LOCKED_TOOL_ALLOWLIST = [ "__microclaw_windows_node_mxc_locked__", ] as const; -export const WINDOWS_NODE_MXC_TOOL_DENYLIST = ["process", "group:fs", "group:plugins"] as const; +export const WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS = [ + "read", + "write", + "edit", + "apply_patch", +] as const; +export const WINDOWS_NODE_MXC_TOOL_DENYLIST = ["process", "group:fs"] as const; +export const WINDOWS_NODE_MXC_DISABLED_PLUGIN_IDS = ["memory-core"] as const; export const WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST = [ "group:runtime", "group:fs", @@ -106,6 +131,7 @@ export interface WindowsNodeMxcGatewayApproval { access: SandboxFolderAccess; path: string; }>; + hasMutableFileOperand: boolean; allowedDecisions: WindowsNodeMxcApprovalDecision[]; } @@ -228,6 +254,10 @@ export function normalizeWindowsNodeMxcGatewayApproval( const cwdBinding = typeof plan.cwdBinding === "string" ? plan.cwdBinding.trim() : ""; const previewDeclaredAccess = parseGatewayDeclaredAccess(commandPreview); const declaredAccess = normalizeStructuredDeclaredAccess(plan.declaredAccess); + const hasMutableFileOperand = + Object.hasOwn(plan, "mutableFileOperand") && + plan.mutableFileOperand !== null && + plan.mutableFileOperand !== undefined; const hasCanonicalCwd = Object.hasOwn(plan, "cwd") && (plan.cwd === null || (typeof plan.cwd === "string" && plan.cwd.trim().length > 0)); @@ -286,6 +316,7 @@ export function normalizeWindowsNodeMxcGatewayApproval( agent: typeof plan.agentId === "string" && plan.agentId.trim() ? plan.agentId : null, sessionKey: plan.sessionKey as string, declaredAccess, + hasMutableFileOperand, allowedDecisions: [...new Set(allowed)], }; } @@ -328,15 +359,31 @@ export interface WindowsNodeReadiness { warnings: string[]; } +export interface MxcBackendProbeFacts { + baseContainerApiPresent: boolean | null; + bfscfgPresent: boolean | null; + bfsCompiledIn: boolean | null; +} + export interface MxcProbeResult { outcome: "supported" | "unsupported" | "error"; tier: string | null; needsDaclAugmentation: boolean; degraded: boolean; warnings: string[]; + probes: MxcBackendProbeFacts; reason: string | null; } +export interface MxcExactBackendEligibility { + eligible: boolean; + summary: string | null; + blockers: string[]; + remediation: string[]; +} + +const MXC_EXACT_FILESYSTEM_ISOLATION_TIERS = new Set(["base-container", "appcontainer-bfs"]); + export interface MxcSmokeResult { outcome: | "passed" @@ -363,6 +410,7 @@ const INGRESS_BACKUP_KEYS = { hooks: "$microclaw.ingress.hooks", plugins: "$microclaw.ingress.plugins", } as const; +export type WindowsNodeMxcIngressField = keyof typeof INGRESS_BACKUP_KEYS; export interface WindowsNodeMxcPolicyApplication { config: Record; @@ -370,6 +418,15 @@ export interface WindowsNodeMxcPolicyApplication { agentIds: string[]; } +export function getWindowsNodeMxcIngressBackup( + backups: AgentToolsBackup, + field: WindowsNodeMxcIngressField, +): unknown { + const backupKey = INGRESS_BACKUP_KEYS[field]; + if (!Object.hasOwn(backups, backupKey)) return undefined; + return structuredClone(backups[backupKey]); +} + export function migrateWindowsNodeMxcToolBackupAliases( backups: AgentToolsBackup, aliases: Readonly>, @@ -473,7 +530,10 @@ export function applyWindowsNodeMxcGatewayPolicy( config.channels = Object.fromEntries( Object.entries(channels).map(([channelId, value]) => [ channelId, - { ...asRecord(value), enabled: false }, + { + ...asRecord(value), + enabled: channelId === "openclaw-weixin" && asRecord(value).enabled === true, + }, ]), ); const hooks = asRecord(config.hooks); @@ -485,15 +545,34 @@ export function applyWindowsNodeMxcGatewayPolicy( config.cron = { ...asRecord(config.cron), enabled: false }; const plugins = asRecord(config.plugins); const pluginEntries = asRecord(plugins.entries); + const weixinRequested = asRecord(pluginEntries["openclaw-weixin"]).enabled === true; + const pluginAllow = ["canvas", ...(weixinRequested ? ["openclaw-weixin"] : [])]; config.plugins = { ...plugins, - enabled: false, - entries: Object.fromEntries( - Object.entries(pluginEntries).map(([pluginId, value]) => [ - pluginId, - { ...asRecord(value), enabled: false }, - ]), - ), + enabled: true, + allow: pluginAllow, + deny: Array.isArray(plugins.deny) + ? plugins.deny.filter((value) => value !== "canvas" && value !== "openclaw-weixin") + : [], + slots: { + ...asRecord(plugins.slots), + memory: "none", + }, + entries: { + ...Object.fromEntries( + Object.entries(pluginEntries).map(([pluginId, value]) => [ + pluginId, + { + ...asRecord(value), + enabled: pluginId === "canvas" || (pluginId === "openclaw-weixin" && weixinRequested), + }, + ]), + ), + canvas: { + ...asRecord(pluginEntries.canvas), + enabled: true, + }, + }, }; return { config, backups, agentIds }; } @@ -577,8 +656,22 @@ export function validateWindowsNodeMxcGatewayPolicy( : expectedState === "active" ? matchesActive : matchesLocked; + const tools = asRecord(entry.tools); + const allow = Array.isArray(tools?.allow) ? tools.allow : []; + const deny = Array.isArray(tools?.deny) ? tools.deny : []; + const forbiddenFilesystemTools = findGatewayNativeFilesystemTools(allow); + if (forbiddenFilesystemTools.length > 0) { + blockers.push( + `Agent "${id}" enables Gateway-native filesystem tools while MXC is enabled: ${forbiddenFilesystemTools.join(", ")}`, + ); + } + if (!deny.includes("group:fs")) { + blockers.push(`Agent "${id}" does not deny the Gateway-native filesystem tool group`); + } if (!matchesExpected) { - blockers.push(`Agent "${id}" tool policy drifted from exec-only Windows-node routing`); + blockers.push( + `Agent "${id}" tool policy drifted from contained exec and Gateway web routing`, + ); } } @@ -590,6 +683,9 @@ export function validateWindowsNodeMxcGatewayPolicy( if (globalDeny.some((value) => value === "exec" || value === "group:runtime")) { blockers.push("Global Gateway tool policy blocks node exec"); } + if (globalDeny.some((value) => value === "canvas" || value === "group:plugins")) { + blockers.push("Global Gateway tool policy blocks the reviewed Canvas tool"); + } const gateway = root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway) ? (root.gateway as Record) @@ -602,10 +698,14 @@ export function validateWindowsNodeMxcGatewayPolicy( const allowCommands = Array.isArray(commands.allow) ? commands.allow : []; const denyCommands = Array.isArray(commands.deny) ? commands.deny : []; if (!isDeepStrictEqual(allowCommands, [...WINDOWS_NODE_MXC_NODE_COMMANDS])) { - blockers.push("Gateway node command allowlist drifted from the bundled system-only surface"); + blockers.push( + "Gateway node command allowlist drifted from the bundled system and Canvas surface", + ); } if (!isDeepStrictEqual(denyCommands, [])) { - blockers.push("Gateway node command denylist conflicts with the bundled system-only surface"); + blockers.push( + "Gateway node command denylist conflicts with the bundled system and Canvas surface", + ); } const cron = asRecord(root.cron); if (cron.enabled !== false) blockers.push("Gateway cron ingress must remain disabled"); @@ -615,15 +715,53 @@ export function validateWindowsNodeMxcGatewayPolicy( blockers.push("Gateway internal-hook ingress must remain disabled"); } const channels = asRecord(root.channels); + let weixinChannelEnabled = false; for (const [channelId, value] of Object.entries(channels)) { + if (channelId === "openclaw-weixin") { + weixinChannelEnabled = asRecord(value).enabled === true; + continue; + } if (asRecord(value).enabled !== false) { blockers.push(`Gateway channel "${channelId}" must remain disabled`); } } const plugins = asRecord(root.plugins); - if (plugins.enabled !== false) blockers.push("Gateway plugins must remain disabled"); + const canvasPluginEnabled = asRecord(asRecord(plugins.entries).canvas).enabled === true; + const weixinPluginEnabled = + asRecord(asRecord(plugins.entries)["openclaw-weixin"]).enabled === true; + if (plugins.enabled !== true || !canvasPluginEnabled) { + blockers.push("Gateway Canvas plugin must remain enabled under MXC"); + } + if (weixinChannelEnabled && !weixinPluginEnabled) { + blockers.push("Gateway WeChat channel cannot be enabled while its plugin is disabled"); + } + const pluginAllow = Array.isArray(plugins.allow) ? plugins.allow : []; + const pluginDeny = Array.isArray(plugins.deny) ? plugins.deny : []; + const expectedPluginAllow = ["canvas", ...(weixinPluginEnabled ? ["openclaw-weixin"] : [])]; + if (!isDeepStrictEqual(pluginAllow, expectedPluginAllow) || pluginDeny.includes("canvas")) { + blockers.push("Gateway plugin policy drifted from the reviewed Canvas/WeChat surface"); + } + if ( + weixinPluginEnabled && + (!pluginAllow.includes("openclaw-weixin") || pluginDeny.includes("openclaw-weixin")) + ) { + blockers.push("Gateway WeChat plugin is not present in the effective plugin allowlist"); + } + const memorySlot = asRecord(plugins.slots).memory; + if (memorySlot !== "none" && memorySlot !== null) { + blockers.push( + 'Gateway memory plugin slot must remain disabled with plugins.slots.memory="none"', + ); + } for (const [pluginId, value] of Object.entries(asRecord(plugins.entries))) { - if (asRecord(value).enabled !== false) { + if ( + pluginId !== "canvas" && + pluginId !== "openclaw-weixin" && + !WINDOWS_NODE_MXC_DISABLED_PLUGIN_IDS.includes( + pluginId as (typeof WINDOWS_NODE_MXC_DISABLED_PLUGIN_IDS)[number], + ) && + asRecord(value).enabled !== false + ) { blockers.push(`Gateway plugin "${pluginId}" must remain disabled`); } } @@ -662,6 +800,7 @@ export function validateWindowsNodeMxcSettings( const requiredTrue: Array<[keyof WindowsNodeMxcSettings, string]> = [ ["EnableNodeMode", "Windows Companion node mode is disabled"], ["NodeSystemRunEnabled", "Windows node system.run is disabled"], + ["NodeCanvasEnabled", "Reviewed Canvas Phase 1 capability is disabled"], ["SystemRunSandboxEnabled", "Windows node MXC sandbox is disabled"], [ "SystemRunBlockHostFallbackWhenMxcUnavailable", @@ -671,15 +810,14 @@ export function validateWindowsNodeMxcSettings( "SystemRunAllowWindowsUi", "Windows UI APIs must be allowed for PowerShell compatibility with MXC 0.7", ], + ["SystemRunAllowOutbound", "Contained outbound network access must be enabled for this mode"], ]; for (const [property, message] of requiredTrue) { if (settings[property] !== true) blockers.push(message); } const requiredFalse: Array<[keyof WindowsNodeMxcSettings, string]> = [ - ["SystemRunAllowOutbound", "Outbound network access must remain blocked"], ["EnableMcpServer", "Local MCP must remain disabled for this mode"], - ["NodeCanvasEnabled", "Canvas capability must be disabled"], ["NodeScreenEnabled", "Screen capability must be disabled"], ["NodeCameraEnabled", "Camera capability must be disabled"], ["NodeLocationEnabled", "Location capability must be disabled"], @@ -694,7 +832,7 @@ export function validateWindowsNodeMxcSettings( if (settings.SandboxClipboard !== 0) blockers.push("Sandbox clipboard policy must be None"); warnings.push( - "allowWindowsUi is a PowerShell compatibility relaxation; it does not enable screen, input, canvas, camera, microphone, browser, location, or speech capabilities.", + "allowWindowsUi is a PowerShell compatibility relaxation; it does not enable screen, input, camera, microphone, browser, location, or speech capabilities. Canvas is separately restricted to the reviewed native A2UI subset.", ); return { ready: blockers.length === 0, blockers, warnings }; } @@ -761,6 +899,10 @@ export function validateSelectedWindowsNode( "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", ); } + for (const command of WINDOWS_NODE_MXC_CANVAS_COMMANDS) { + if (!node.commands.includes(command)) + blockers.push(`Selected node does not declare ${command}`); + } const allowedCommands = new Set(WINDOWS_NODE_MXC_NODE_COMMANDS); const unexpectedCommands = node.commands.filter((command) => !allowedCommands.has(command)); if (unexpectedCommands.length > 0) { @@ -774,17 +916,49 @@ export function validateEffectiveToolNames( expectedState: Exclude = "active", ): WindowsNodeReadiness { const unique = [...new Set(toolNames)].sort(); - const expected = expectedState === "active" ? ["exec"] : []; - const blockers = isDeepStrictEqual(unique, expected) - ? [] - : [ - `Effective Gateway tools must be ${ - expectedState === "active" ? 'exactly "exec"' : "empty while readiness is locked" - }; observed: ${unique.join(", ") || "none"}`, - ]; + const expected = expectedState === "active" ? [...WINDOWS_NODE_MXC_TOOL_ALLOWLIST].sort() : []; + const filesystemCheck = validateNoGatewayNativeFilesystemTools(unique); + const blockers = [...filesystemCheck.blockers]; + if (!isDeepStrictEqual(unique, expected)) { + blockers.push( + `Effective Gateway tools must be ${ + expectedState === "active" + ? `exactly ${expected.map((tool) => `"${tool}"`).join(", ")}` + : "empty while readiness is locked" + }; observed: ${unique.join(", ") || "none"}`, + ); + } return { ready: blockers.length === 0, blockers, warnings: [] }; } +function findGatewayNativeFilesystemTools(toolNames: readonly unknown[]): string[] { + const forbidden = new Set(WINDOWS_NODE_MXC_GATEWAY_NATIVE_FS_TOOLS); + return [ + ...new Set( + toolNames + .filter((toolName): toolName is string => typeof toolName === "string") + .map((toolName) => toolName.trim()) + .filter((toolName) => forbidden.has(toolName)), + ), + ].sort(); +} + +export function validateNoGatewayNativeFilesystemTools( + toolNames: readonly string[], +): WindowsNodeReadiness { + const forbidden = findGatewayNativeFilesystemTools(toolNames); + return { + ready: forbidden.length === 0, + blockers: + forbidden.length === 0 + ? [] + : [ + `Gateway-native filesystem tools are forbidden while MXC is enabled: ${forbidden.join(", ")}`, + ], + warnings: [], + }; +} + export function classifyMissingEffectiveToolSession( agentId: string, expectedState: Exclude, @@ -908,6 +1082,17 @@ export function classifyMxcProbe( const warnings = Array.isArray(parsed.warnings) ? parsed.warnings.filter((entry): entry is string => typeof entry === "string") : []; + const nestedProbes = + parsed.probes && typeof parsed.probes === "object" && !Array.isArray(parsed.probes) + ? (parsed.probes as Record) + : {}; + const probes: MxcBackendProbeFacts = { + baseContainerApiPresent: optionalBoolean( + nestedProbes.baseContainerApiPresent ?? parsed.baseContainerApiPresent, + ), + bfscfgPresent: optionalBoolean(nestedProbes.bfscfgPresent ?? parsed.bfscfgPresent), + bfsCompiledIn: optionalBoolean(nestedProbes.bfsCompiledIn ?? parsed.bfsCompiledIn), + }; if (typeof parsed.error === "string" || parsed.supported === false) { return { outcome: "unsupported", @@ -915,6 +1100,7 @@ export function classifyMxcProbe( needsDaclAugmentation: false, degraded: false, warnings, + probes, reason: typeof parsed.error === "string" ? parsed.error @@ -925,26 +1111,117 @@ export function classifyMxcProbe( const tier = typeof parsed.tier === "string" ? parsed.tier.trim() : ""; if (!tier) return probeError("wxc-exec --probe did not report an isolation tier"); const normalizedTier = tier.toLowerCase(); - if (!["base-container", "appcontainer-bfs", "appcontainer-dacl"].includes(normalizedTier)) { + if (![...MXC_EXACT_FILESYSTEM_ISOLATION_TIERS, "appcontainer-dacl"].includes(normalizedTier)) { return probeError(`wxc-exec --probe reported an unrecognized isolation tier: ${tier}`); } const needsDaclAugmentation = parsed.needsDaclAugmentation === true; + const degraded = + needsDaclAugmentation || !MXC_EXACT_FILESYSTEM_ISOLATION_TIERS.has(normalizedTier); + if (degraded) { + return { + outcome: "unsupported", + tier, + needsDaclAugmentation, + degraded: true, + warnings, + probes, + reason: + `MXC isolation tier ${tier} cannot enforce the configured filesystem roots as a hard ` + + "capability boundary; base-container or appcontainer-bfs is required", + }; + } + if (normalizedTier === "base-container" && probes.baseContainerApiPresent === false) { + return probeError( + "wxc-exec --probe reported base-container while the Base Container API was unavailable", + ); + } + if ( + normalizedTier === "appcontainer-bfs" && + (probes.bfsCompiledIn === false || probes.bfscfgPresent === false) + ) { + return probeError( + "wxc-exec --probe reported appcontainer-bfs without proving compiled BFS support and bfscfg.exe", + ); + } return { outcome: "supported", tier, needsDaclAugmentation, - degraded: - needsDaclAugmentation || !["base-container", "appcontainer-bfs"].includes(normalizedTier), + degraded: false, warnings, + probes, reason: null, }; } +export function describeMxcExactBackendEligibility( + probe: MxcProbeResult, +): MxcExactBackendEligibility { + if (probe.outcome === "supported") { + return { + eligible: true, + summary: null, + blockers: [], + remediation: [], + }; + } + + const blockers: string[] = []; + const remediation: string[] = []; + + if (probe.outcome === "error") { + blockers.push(probe.reason ?? "The MXC exact-containment probe failed"); + remediation.push( + "Repair the pinned, signed MXC runtime and retry. MicroClaw will not start the Windows node until the probe succeeds.", + ); + } else { + if (probe.probes.baseContainerApiPresent === false) { + blockers.push( + "The Base Container API is unavailable on this Windows installation, so MXC cannot create a base-container sandbox.", + ); + remediation.push( + "Use a supported Windows build that exposes processmodel.dll and Experimental_CreateProcessInSandbox. MicroClaw cannot safely install or emulate this operating-system API.", + ); + } + if (probe.probes.bfsCompiledIn === false) { + blockers.push( + "The pinned official MXC 0.7.0 production runtime was built without the appcontainer-bfs backend.", + ); + remediation.push( + "Do not copy bfscfg.exe onto the host: it cannot enable BFS in this runtime. Use only a future reviewed and signed MXC release that enables an exact backend on a documented supported Windows build.", + ); + } else if (probe.probes.bfsCompiledIn === true && probe.probes.bfscfgPresent === false) { + blockers.push( + "The MXC runtime supports appcontainer-bfs, but the required bfscfg.exe host component is unavailable.", + ); + remediation.push( + "Provision BFS only through a reviewed vendor installer on a documented supported Windows build; MicroClaw will not copy or execute an unverified host component.", + ); + } + if (blockers.length === 0) { + blockers.push( + probe.reason ?? + "MXC did not prove a base-container or appcontainer-bfs exact-containment backend", + ); + remediation.push( + "Install a reviewed exact-containment backend supported by this Windows build, then retry. AppContainer + DACL remains intentionally unsupported.", + ); + } + } + + return { + eligible: false, + summary: blockers.join(" "), + blockers, + remediation, + }; +} + export function getMxcTierWarning(probe: MxcProbeResult): string | null { - if (probe.outcome !== "supported" || !probe.degraded) return null; - return `Degraded MXC containment: ${probe.tier ?? "unknown tier"}${ + if (!probe.degraded) return null; + return `Rejected degraded MXC containment: ${probe.tier ?? "unknown tier"}${ probe.needsDaclAugmentation ? " requires host DACL augmentation" : "" - }. This mode remains usable but is weaker than base-container or appcontainer-bfs.`; + }. This mode cannot enforce the configured folder policy exactly.`; } export function classifyMxcSmoke(value: unknown, expectedMarker: string): MxcSmokeResult { @@ -1344,10 +1621,19 @@ function probeError(reason: string): MxcProbeResult { needsDaclAugmentation: false, degraded: false, warnings: [], + probes: { + baseContainerApiPresent: null, + bfscfgPresent: null, + bfsCompiledIn: null, + }, reason, }; } +function optionalBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + function normalizeWindowsPath(value: string): string { return normalizeWindowsPathPreservingCase(value).toLowerCase(); } diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index d8c6cc7..213de79 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -10,9 +10,11 @@ It remains independent of the Docker and direct-MXC experiments. MicroClaw Electron -> app-owned loopback OpenClaw Gateway -> bundled MicroClaw Windows Node Host - -> exact proof-only startup probes or attended normal-command approval - -> official @microsoft/mxc-sdk@0.7.0 wxc-exec.exe - -> MXC-contained child process + -> exact proof-only startup probes or attended normal-command approval + -> official @microsoft/mxc-sdk@0.7.0 wxc-exec.exe + -> MXC-contained child process + -> authenticated managed Canvas child + -> pinned native A2UI surface ``` OpenClaw Companion, its tray UI, setup code, and local MCP endpoint are not used. The helper has a @@ -23,64 +25,226 @@ The managed Gateway PID is also passed in bootstrap metadata. Before every crede handshake or reconnect, the helper verifies that this exact process still owns the IPv4 loopback listener; MicroClaw stops and recreates the helper for each Gateway generation. -The helper advertises only `system.run`, proof-only `system.run.readiness`, `system.run.prepare`, -`system.which`, and `system.run.cwd-policy`. It contains no host command runner and cannot fall back -when MXC is absent or fails. Network, clipboard, input injection, MCP, screen, camera, canvas, -browser, location, and speech capabilities are absent. `allowWindowsUi=true` is required for -PowerShell compatibility with MXC 0.7; it does not activate any UI capture or input capability. - -Gateway-native file/process tools remain removed from the agent surface. The policy pins `exec` to -the app-owned node and denies the runtime/filesystem/plugin groups. An empty OpenClaw allowlist is -unrestricted, so the pre-attestation state uses a non-tool lock sentinel. The Gateway node-command -policy allowlists exactly the five bundled commands above. MicroClaw pairs/reapproves only the exact -app-owned device identity and does not expose manual node selection, even if another Windows -Companion is connected. - -## CWD and durable approval contract +The helper advertises the five reviewed system commands plus the exact Canvas Phase 1 command set +documented below. It contains no host command runner and cannot fall back +when MXC is absent or fails. Contained commands receive unrestricted public outbound network access +through MXC's `internetClient` capability, without a host fallback or host allowlist. Clipboard, +input injection, MCP, screen, camera, browser, location, and speech capabilities are absent. +`allowWindowsUi=true` is required for PowerShell compatibility with MXC 0.7; it does not activate +any screen capture or input capability. + +MicroClaw exposes three explicit requested states: + +| Requested state | Accepted MXC tier | Filesystem boundary | +|---|---|---| +| **Full containment** | `base-container` or `appcontainer-bfs` only | Selected RO/RW roots are a strict, exclusive capability ceiling | +| **Limited containment (DACL)** | `appcontainer-dacl` only | Selected roots are additive ACL grants; ambient Windows ACL access can remain | +| **Off** | No bundled MXC host | Restore the remembered standard sandbox route; it may be unsandboxed if that setting is off | + +Every explicit user transition from Off to Full or Limited, and every explicit switch between Full +and Limited, first opens an in-app capability-disclosure consent dialog for the target mode. The +dialog appears before backend preflight, requested-mode persistence, Gateway or Node shutdown, +configuration rewriting, or any other lifecycle side effect. Canceling with the button, close +control, Escape, or backdrop leaves the requested and effective mode, persisted configuration, +Gateway readiness, and visible mode selection unchanged. Accepting continues the existing +transition once; Limited still requires its separate additive-DACL warning acknowledgement. +MicroClaw does not offer a "don't ask again" option and repeats the disclosure for every explicit +enable or Full/Limited switch. Internal refresh, relock, startup recovery, and fail-closed repair do +not prompt because they do not represent a new user-selected capability boundary. + +Full containment is the security contract established by +[#224](https://github.com/microsofthackathons/MicroClaw/pull/224). It rejects DACL augmentation, +sets `fallback.allowDaclMutation=false`, and preserves the exact RO/RW ceiling, denied-root +precedence, and out-of-root denial invariants. + +Limited containment is a separate, explicitly acknowledged threat model. It accepts only a +probe-attested `appcontainer-dacl` tier and sets `fallback.allowDaclMutation=true` for the reviewed +MXC DACL path. AppContainer process isolation, contained public outbound networking, clipboard +denial, input-injection denial, pinned-node execution, and strict no-host-fallback remain enforced. +However, Windows ACLs +may already allow the contained identity to access user folders outside the selected grants. +Selected RO/RW folders therefore **add access** and are not an exclusive allowlist; a read-only +selection cannot reduce a broader pre-existing ACL. MicroClaw never automatically changes Full to +Limited after an OS update, probe change, startup failure, or settings migration. + +Gateway-native file/process tools remain removed from the agent surface in both Full and Limited +mode. The active Gateway surface is exactly `exec`, `web_search`, `web_fetch`, `message`, and the +reviewed `canvas` tool; +`exec` is pinned to the app-owned node, and `message` can reach only the requested WeChat channel. +The pinned OpenClaw `web_fetch` implementation is startup-attested to accept only HTTP(S), so +`file:` and other local URL schemes remain rejected. The policy keeps `group:fs` denied, including +at least the native `read`, `write`, `edit`, and `apply_patch` tools. Native Gateway filesystem +tools execute on the Windows host and would bypass Windows Node/MXC. Local file access must use +MXC-contained Windows Node `exec` or the MicroClaw WeChat media broker. MXC Off restores the user's +backed-up original tool policy. + +## Canvas Phase 1 + +Canvas Phase 1 is available only while Full or Limited MXC is **Active**. It is a Windows Node UI +capability, not an MXC-contained `system.run` child. The same Gateway generation, policy +fingerprint, node identity, and signed active lease gate every Canvas command and action. The +bundled node launches a dedicated authenticated child that reuses the pinned native WinUI A2UI +router, component renderers, action bridge, and managed-surface snapshot implementation. The +standalone child constructs its window shell programmatically instead of loading the Companion +window XAML, so it does not activate unrelated Companion resources or capabilities. It does not +launch OpenClaw Companion or the legacy WebView Canvas. + +The exact node command set is: + +- `canvas.present` +- `canvas.hide` +- `canvas.snapshot` +- `canvas.caps` +- `canvas.a2ui.push` +- `canvas.a2ui.pushJSONL` +- `canvas.a2ui.reset` +- `canvas.a2ui.dump` + +The agent-facing `canvas` tool maps only to the eight reviewed operations: `present`, `hide`, +`snapshot`, `caps`, `a2ui_push`, `a2ui_push_jsonl`, `a2ui_reset`, and `a2ui_dump`. +`canvas.navigate` and `canvas.eval` are neither advertised nor accepted. Presentation opens only +the managed native A2UI +shell: URL, HTML, file URI, UNC, absolute/relative local path, `javascript:`, arbitrary `data:`, +and unknown-scheme navigation are unavailable. A2UI pushes must be inline; `jsonlPath` is rejected. +Phase 1 media is restricted to bounded base64 inline PNG/JPEG/WebP image payloads. HTTPS media hosts +and staged local artifacts are intentionally deferred rather than widening the filesystem boundary. +Multiple surfaces are presented in a simple stacked layout in Phase 1. Tabbed surface chrome is +deferred because the unpackaged standalone host cannot reliably activate the pinned `TabView` +resource stack. This presentation limitation does not change the command, action, media, lease, or +snapshot boundaries. + +Only `click`, `submit`, `confirm`, and `cancel` A2UI callbacks are forwarded. They re-enter the +normal Gateway as `agent.request` events after immediate lease and identity revalidation; they do +not directly invoke process, filesystem, config, Cron, Hooks, plugin, MCP, browser, screen, camera, +or node commands. Each surface is bound to the agent session that created it; a different session +cannot update, delete, or receive actions from that surface. There is no offline action queue or +relock replay. Snapshot captures only the managed XAML Canvas surface and returns in-memory image +data; `NodeScreenEnabled` remains false and no arbitrary local path is returned. + +Locked startup, transitions, stale generation/policy/node/lease, node loss, or policy drift reject +Canvas before UI work. The broker rechecks immediately before host-side actions. Lease loss closes +the current Canvas and clears its surfaces; subsequent A2UI work remains rejected until a new +current-generation active lease exists. The exact command/action sets, native renderer, executable +hash, complete published Canvas-host code/resource tree hash, and all negative capability flags are +fingerprinted into the same policy and attestation used for activation. Unexpected command, action, +plugin, binary/resource, or local-capability drift relocks fail closed. The Canvas child also binds +to the exact node-host process identity and exits if that parent is lost. + +Scheduled tasks keep their stored definitions while MXC is requested, but the effective Gateway +Cron scheduler remains disabled. Cron mutation, scratch-write, and run-now RPCs reject while MXC +is requested. The managed standard Gateway is stopped before the Off-to-MXC containment preflight, +so an already queued occurrence cannot race the transition; if preflight fails, MicroClaw restores +the unchanged standard Gateway before surfacing the failure. The Tasks view keeps listing saved +jobs, shows an MXC pause banner, labels requested-enabled jobs as paused, and leaves user-disabled +jobs disabled. Leaving MXC records a +one-shot durable resume fence; the pinned OpenClaw compatibility preload advances schedules due at +or before that fence, then allows ordinary startup recovery for later due times. +It durably acknowledges completion only after the scheduler resumes, so jobs missed during the +containment interval are not replayed without suppressing later work. OpenClaw heartbeat selection +is a separate agent-default setting rather than this Cron scheduler path and is unchanged. + +External webhooks and internal Hooks follow the same requested-versus-effective model. Their +original configuration remains in the MXC backup while the effective Gateway config keeps both +surfaces disabled. A generation- and policy-bound compatibility gate returns HTTP 503 at the +configured (or default) webhook base path before reading a request body or admitting work. The +gate also recognizes bounded percent-decoded forms of the protected path and rejects ambiguous +configured paths rather than permitting a routing mismatch. Internal events are dropped before +listener lookup or handler execution. Neither surface queues paused work, so there is no replay +when MXC is turned Off; only newly arriving requests and events after restoration may run. The +same managed-Gateway stop that suspends Cron before Off-to-MXC preflight also closes these ingress +paths before containment validation, and a failed preflight restores the unchanged standard +Gateway. MicroClaw exposes no Hooks mutation bridge while MXC is requested, generic config writes +and Gateway restarts are blocked, and unreviewed hook plugins remain outside the exact plugin +allowlist. Settings reports the backed-up requested state as paused in both Locked and Active; +the original configuration is restored only after the Off transition. Any drift that enables +either hook surface while Full or Limited is requested fails policy validation and relocks. + +The active policy also denies the runtime and plugin tool groups. The requested WeChat channel +plugin may remain loaded, but its long-poll loop does not call `getUpdates` until a release record +matches the current Gateway generation and MXC policy fingerprint. Outbound text and media use the +same dynamic release gate. Relocking removes the record before authorization is revoked, so paused +messages are not consumed and later replayed by MicroClaw. An empty OpenClaw allowlist is +unrestricted, so the pre-attestation tool state uses a non-tool lock sentinel. The Gateway +node-command policy allowlists exactly the five bundled commands above. MicroClaw pairs/reapproves +only the exact app-owned device identity and does not expose manual node selection, even if another +Windows Companion is connected. + +Inbound WeChat media is decrypted directly into an app-owned staging root that is injected into the +same Windows Node policy as read-only. Outbound local media is brokered on the native Gateway only +after canonical-path, regular-file, selected-root/staging-root, and reparse-component validation. +Remote HTTP(S) media is downloaded into a broker-owned scratch root before upload. The broker policy +is immutable environment data bound to the Gateway generation and MXC policy fingerprint; a missing, +stale, malformed, or out-of-root policy fails closed without falling back to unrestricted host file +access. + +## CWD and approval contract The exact attestation contract is `microclaw.windows-cwd.v1`. A supplied CWD must: - be an existing local drive-qualified directory; - reject UNC, device, NT object-manager, junction, symlink, and other reparse components; - resolve to the same final case-insensitive Windows path; -- be equal to or below one canonical global RO/RW root; +- be equal to or below one canonical selected RO/RW root; - not overlap a protected credential, OpenClaw state, SSH, cloud, or browser-profile root. -CWD inherits the matched root's access; it never creates a grant. Omitted CWD binds approval to -`isolated-scratch:v1` and launches in a per-run writable scratch directory. The node re-hashes the -executable through its held handle; policy, reparse state, root membership, exact CWD binding, and -declared access are revalidated immediately before launch. Delete-denying directory handles retain -every approved-root/CWD path component through MXC completion, preventing a validated directory -from being replaced by a junction during launch. - -Settings > Security presents this global policy as separate Read-only and Read/write lists. Folder -selection uses Electron's trusted native directory picker. Equal paths are deduplicated, redundant -nested grants are collapsed, and a narrower RW root under a broader RO root remains explicit. While -MXC is enabled, edits remain a renderer draft and cannot mutate live ACLs or settings. **Apply changes -and reactivate** performs one serialized fail-closed transaction: lock ingress, reject pending -approvals, revoke proof/lease state, validate the complete draft, stop the exact generation, apply -and atomically persist the same `sandboxUserDirsRO`/`sandboxUserDirsRW` policy, attest and smoke a -locked generation, attest and smoke a fresh active generation, mint its lease, verify the effective -route, then release ingress. The fixed smokes use internal one-use readiness proofs and never enter -the user approval queue. Cancellation or any failure leaves execution locked and retains the -attempted draft and previous policy for explicit retry or revert; MicroClaw never silently restores -old active execution. Folder preparation that requires elevation fails locked without opening an -elevation prompt. UNC, device-namespace, missing, reparse, final-target-changing, and protected -credential/state/browser roots are rejected before persistence. +CWD inherits the matched root's requested access; it never creates a grant. Omitted CWD binds +approval to `isolated-scratch:v1` and launches in a per-run writable scratch directory. This check +is launch-location validation, not proof that every target file is bounded by the selected roots. +That stronger statement is true only in Full containment. In Limited containment, ambient Windows +ACL access may still permit target-file access elsewhere. The node re-hashes the executable through +its held handle; policy, reparse state, root membership, exact CWD binding, and declared access are +revalidated immediately before launch. Delete-denying directory handles retain every approved-root +/CWD path component through MXC completion, preventing a validated directory from being replaced by +a junction during launch. + +Settings > Security presents the mode cards and separate Read-only and Read/write lists. In Full, +these lists define the hard capability ceiling. In Limited, they are labeled as additional grants +and do not claim to override ambient ACLs. Folder selection uses Electron's trusted native directory +picker. Equal paths are deduplicated, redundant nested grants are collapsed, and a narrower RW root +under a broader RO root remains explicit. While MXC is enabled, edits remain a renderer draft and +cannot mutate live ACLs or settings. **Apply changes and reactivate** performs one serialized +fail-closed transaction: lock ingress, reject pending approvals, revoke proof/lease state, validate +the complete draft, stop the exact generation, apply and atomically persist the same +`sandboxUserDirsRO`/`sandboxUserDirsRW` policy, attest and smoke a locked generation, attest and +smoke a fresh active generation, mint its lease, verify the effective route, then release ingress. +Full runs its outside-root denial check plus one deterministic contained execution smoke. Limited +runs only the contained execution smoke because ambient-access assessment, not out-of-root denial, +is its filesystem safety gate. The fixed smokes use internal one-use readiness proofs and never +enter the user approval queue. +Cancellation or any failure leaves execution locked and retains the attempted draft and previous +policy for explicit retry or revert; MicroClaw never silently restores old active execution. Folder +preparation that requires elevation fails locked without opening an elevation prompt. UNC, +device-namespace, missing, reparse, final-target-changing, and protected credential/state/browser +roots are rejected before persistence. + +Persistent AppContainer folder ACL ownership is scoped to the exact +`(MicroClaw AppContainer principal, canonical folder, requested RO/RW access)` tuple. Policy +replacement revokes only the MicroClaw principal on changed folders, applies the new tuples in +deterministic parent-before-child order, verifies propagation, and rolls the complete tuple set back +if mutation or persistence fails. The recoverable previous/draft policy retains access levels, while +the grant-history reconciliation removes stale MicroClaw grants on startup. Revocation is +principal-targeted and does not replace the folder DACL, so pre-existing Windows, administrator, +user, or third-party ACEs remain untouched. The normal active route has one visible Gateway/MicroClaw prompt. It displays executable, exact argv, -agent/session scope, canonical CWD, and declared folder use, and offers Deny, Allow once, and eligible -Allow always. MicroClaw resolves both allow choices upstream as one-use; it never delegates durable -authority to the Gateway or node. The Gateway then mints a fresh 15-second, one-use HMAC proof bound +agent/session scope, canonical CWD, declared folder use, the attested containment strength, and in +Limited mode the category-only ambient-access summary. Full offers Deny, Allow once, and eligible +Allow always. Limited offers only Deny and Allow once: durable approvals are rejected, including +records created under another mode. MicroClaw resolves allowed executions upstream as one-use; it +never delegates durable authority to the Gateway or node. The Gateway then mints a fresh 15-second, +one-use HMAC proof bound to the exact generation, node, policy fingerprint, prepared plan, executable path/content hash, CWD, declarations, agent, and session. The node silently accepts only that proof after recapturing the held executable identity. Direct `node.invoke`, malformed, stale, replayed, or concurrent reuse is denied. Fixed readiness commands use a separate HMAC contract bound to the transition ID, Gateway generation, node, policy, probe kind, exact prepared executable path/content hash, argv, isolated -scratch CWD, empty declarations, short expiry, and one-use consumption. Only -`system.run.readiness` accepts that proof; ordinary `system.run`, including identical hostname or -PowerShell argv after startup, still requires the normal visible approval. Each contained child -inherits `TEMP`/`TMP`/`TMPDIR` pointing at its own writable scratch grant. +scratch CWD, empty declarations, short expiry, and one-use consumption. Readiness preparation +performs the mode-bound ambient revalidation first; only after it returns does MicroClaw mint and +immediately dispatch the proof for that single invocation. The helper retains a bounded, one-use +preparation record and revalidates the proof immediately before MXC process launch. Only +`system.run.readiness` accepts that proof; ordinary `system.run`, including identical PowerShell +argv after startup, still requires the normal visible approval. Each contained child inherits +`TEMP`/`TMP`/`TMPDIR` pointing at its own writable scratch grant. Optional `[declare-access]ro:;rw:[/declare-access]` metadata is accepted only on leading metadata lines (with an optional `#`, `REM`, or `::` comment prefix). It can describe only canonical @@ -96,8 +260,8 @@ command, canonical CWD or scratch semantic, normalized declarations, agent/sessi fingerprint, and plan contract. Writes are atomic and replace exact duplicates. Legacy, malformed, unbound, policy-stale, hash-stale, path-stale, or scope-mismatched records authorize nothing. Security settings shows creation/last-use details and supports individual or complete revocation. A -valid exact match suppresses the visible prompt but still receives a fresh one-use Gateway proof and -full node launch-time revalidation. It never changes configured folder grants. +valid exact match suppresses the visible prompt in Full mode but still receives a fresh one-use +Gateway proof and full node launch-time revalidation. It never changes configured folder grants. An unlisted declaration, or an RW declaration covered only by RO policy, is rejected before the command-approval prompt with localized remediation to change Settings > Security and retry. Omitting declaration metadata never expands the MXC policy: only the configured global roots are emitted, and @@ -116,9 +280,12 @@ owner-lifetime signal; an Electron exit closes the pipe, exits the host, and clo `@microsoft/mxc-sdk` is lockfile-pinned to `0.7.0`. Resource staging validates the official architecture-specific `wxc-exec.exe` SHA-256 before copying the complete matching x64/ARM64 runtime -layout unchanged. Package architecture is mandatory rather than inferred from the build machine, -and a runtime manifest is checked again by the app. Development resources, portable/NSIS extra -resources, and MSIX preparation all use the same staging script. +layout unchanged. The published package binaries identify source revision +`0e02a720e32416b151dbcb0a00e3d4ac2b891559`; the runtime manifest records and revalidates that +revision, the binary hash, and the official production build's `tier2_bfs`-disabled contract. +Package architecture is mandatory rather than inferred from the build machine, and the manifest is +checked again by the app before the executable can be probed or launched. Development resources, +portable/NSIS extra resources, and MSIX preparation all use the same staging script. MXC 0.7.0's official `wxc-host-prep.exe` is affected by [`microsoft/mxc#648`](https://github.com/microsoft/mxc/issues/648): its @@ -141,25 +308,91 @@ signed package hash; this avoids both excluding the MicroClaw-owned executable f and rejecting the package because Authenticode changed its bytes. The helper build disables repository-HEAD suffixes in its informational version so its pinned unsigned hash remains reproducible across MicroClaw commits. +Host-prep prepare/unprepare is idempotent and removes only the exact non-callback +All Application Packages metadata-read ACE tuple that MicroClaw owns. Tests preserve the existing +child DACL byte-for-byte and preserve unrelated or callback ACEs, including ACEs for the same SID. + +## Exact-backend host support + +MicroClaw does not provision an exact backend by changing host security state. It first validates +the pinned runtime manifest and hashes, then runs `wxc-exec --probe`. Full activation does not start +unless the probe proves `base-container` or `appcontainer-bfs` with no DACL augmentation. Limited +activation does not start unless the same pinned runtime proves exactly `appcontainer-dacl`. + +| Probe and host state | Full | Limited | Behavior | +|---|---:|---:|---| +| `base-container`, Base Container API usable | Yes | No | Full may continue with locked-generation attestation | +| `appcontainer-bfs`, BFS compiled in and `bfscfg.exe` present | Yes | No | Full may continue with locked-generation attestation | +| `appcontainer-dacl` | No | Yes | Full remains disabled; Limited requires acknowledgment and ambient assessment | +| Base Container API absent and official MXC 0.7 BFS compiled out | No | Probe-dependent | Show the exact [#228](https://github.com/microsofthackathons/MicroClaw/pull/228) Base Container/BFS prerequisite and remediation; never downgrade Full | +| Probe, version, source revision, or hash mismatch | No | No | Stay fail-closed and require repair of the reviewed runtime | + +For MXC 0.7, Tier 1 dynamically requires `processmodel.dll` and +`Experimental_CreateProcessInSandbox`. The API is supplied by Windows, not by the SDK package or +`wxc-host-prep.exe`. MXC maintainers document current Tier 1 availability as Windows Insider-only +in [`microsoft/mxc#693`](https://github.com/microsoft/mxc/issues/693), despite broader SDK +documentation describing Windows 11 24H2 process containment. MicroClaw therefore uses successful +capability probing rather than an edition/build-number allowlist. + +Tier 2 is compile-time gated by MXC's `tier2_bfs` feature. Official production 0.7 builds leave that +feature disabled because the BFS path can hang affected Windows 11 25H2 hosts; copying +`bfscfg.exe` onto the machine cannot enable it in the packaged runtime. The official host-prep +utility and MicroClaw's reviewed patch prepare only Tier 3 DACL prerequisites and cannot install +the Base Container API or add BFS support to `wxc-exec.exe`. The minimal upstream requirement for +GA Windows Full containment is an official signed runtime with a reviewed exact backend, plus a +documented supported Windows build/feature matrix. Until one is available, Full remains disabled on +unsupported hosts even when Limited is available. + +## Limited ambient-access assessment + +Before Limited activation, MicroClaw runs a bounded assessment through the exact pinned Windows +Node host and `wxc-exec` path. It records only a stable category and `denied`, `read`, or +`read-write`; it does not read file contents, enumerate names into output, or expose sensitive +absolute paths in normal UI or logs. The assessed categories are Desktop, Documents, Downloads, +MicroClaw state, the active and roaming OpenClaw state roots, `.ssh`, GnuPG, AWS, Azure, +Kubernetes, Google Cloud, Windows Credentials, PowerShell history, and common Chrome, Edge, Brave, +and Firefox profile roots. + +Access to any state, credential, shell-history, cloud, SSH/GnuPG, or browser profile root blocks +activation. Ambient access to Desktop, Documents, or Downloads is a warning and is shown truthfully +as a known Limited-mode capability. An existing sensitive root whose contents cannot be enumerated +is treated as accessible rather than denied. For an absent root, the assessment tests whether the +nearest existing parent can create it; writable parents block activation. The deterministic +category/status result hash is embedded in the fingerprinted host policy. Consequently the +requested strength, effective tier, assessment hash, approval/readiness proofs, activation lease, +and Gateway generation all describe the same attested state. + +The host re-runs the assessment at startup and in the serialized readiness/command preparation +immediately preceding every Limited launch. A changed +status or hash, missing result, tier change, restart, or policy change closes ingress, revokes the +lease, and requires a fresh explicit transition. The assessment is enforcement evidence, not a +one-time warning dialog. ## Readiness and activation transaction The helper also enforces `microclaw.windows-activation.v1`: an HMAC-SHA256 lease bound to the exact -Gateway generation, helper policy fingerprint, mode, and expiry. Its per-generation key is delivered -only through inherited stdin. A diagnostic lease admits only the fixed `hostname.exe` and PowerShell -probe declarations; the denied-CWD check fails at canonical CWD policy before launch. An active +Gateway generation, helper policy fingerprint, requested containment strength, effective tier, +Limited assessment hash when applicable, mode, and expiry. Its per-generation key is delivered only +through inherited stdin. A diagnostic lease admits only the fixed deterministic PowerShell probe, +which launches the built-in `hostname.exe` child and verifies deterministic marker output from +isolated scratch. Full additionally runs the denied-CWD check at canonical CWD policy before launch; +Limited does not claim that check as a filesystem guarantee. An active lease admits the normal attended `system.run` path. The helper checks the lease before authorization and again immediately before MXC launch. -MicroClaw remembers the selected security mode. When Windows Node + MXC is off, startup follows the -normal Gateway/chat-ready path without starting the bundled node. When it is on, the loading screen -remains visible while MicroClaw automatically starts every Gateway generation locked and runs the -same secure readiness and activation sequence. Fixed readiness probes are internally authorized by -the current serialized lifecycle and produce no permission dialog; this authority cannot be used by -agent commands. Readiness requires the exact CWD/activation attestation payload, selected app-owned -node identity, paired and connected state, strict no-fallback settings, current-generation effective -tools, MXC tier, contained `hostname.exe`, contained PowerShell, and denied-access proof. -`appcontainer-dacl` is accepted with a prominent degraded-containment warning. +MicroClaw stores a versioned requested mode separately from the attested effective strength. A +legacy enabled boolean migrates conservatively to Full, never Limited; Limited survives migration +only with explicit persisted evidence and an acknowledgment timestamp. When MXC is Off, startup +follows the remembered standard Gateway/sandbox route without starting the bundled node. When Full +or Limited is requested, the loading screen remains visible while MicroClaw closes ingress, proves +the mode-specific backend, then starts every Gateway generation locked and runs the secure +readiness and activation sequence. Fixed readiness probes are internally authorized by the current +serialized lifecycle and produce no permission dialog; this authority cannot be used by agent +commands. Readiness requires the exact CWD/activation attestation payload, selected app-owned node +identity, paired and connected state, strict no-fallback settings, current-generation effective +tools, exact mode/tier agreement, and the mode-specific current smoke record. Full requires the +contained PowerShell/child execution smoke plus outside-root denial. Limited requires the one +contained execution smoke plus the current ambient-assessment hash and category/status result. Every MicroClaw-owned `chat.send` path, including startup warm-up and generated session titles, passes through the same current-generation active-ingress gate. Locked or attesting startup skips @@ -167,22 +400,27 @@ warm-up without queuing or replaying it; warm-up can run only after final activa Activation is an automatic fail-closed transaction: -1. Attest the locked generation and its smoke record. -2. Stop the Gateway and reject, rather than queue, MicroClaw chat sends. -3. Write an active policy that exposes only node-pinned `exec` and disables configured channels, - webhooks, internal hooks, cron, plugin loading, and plugin entries. -4. Start a new managed loopback Gateway generation and pair the exact bundled node. -5. Verify the node declaration, CWD contract, strict helper policy, and effective `exec`-only tool - inventory. -6. Repeat all contained smokes for the active generation under a diagnostic lease. -7. Issue an active lease, perform a final attestation, then release MicroClaw chat ingress. - -Any restart, disconnect, timeout, policy/tool drift, missing or expired lease, helper failure, or -attestation error revokes the lease, clears the smoke proof, rewrites the locked policy, and stops -the managed Gateway. Configuration and restart operations that could invalidate an active -transaction are rejected while this mode is enabled. External channel, hook, cron, and plugin -entry points remain disabled, and inventory APIs return no active ingress. The loading screen shows -the failed MXC phase and recovery detail; +1. Close ingress, validate the pinned runtime provenance/hashes, and prove the exact backend allowed + by the explicitly requested mode. +2. Start and attest the locked generation and its smoke record. +3. Stop the Gateway and reject, rather than queue, MicroClaw chat sends. +4. Write an active policy that exposes only node-pinned `exec`, reviewed Web/WeChat routing, and + the exact Canvas tool; disable other channels, webhooks, internal hooks, cron, and plugins. +5. Start a new managed loopback Gateway generation and pair the exact bundled node. +6. Verify the node declaration, requested/effective containment strength, exact tier, CWD contract, + strict helper policy, Limited assessment hash if applicable, and exact effective tool/Canvas + command inventory. +7. Repeat the mode-specific contained smoke profile for the active generation under a diagnostic + lease. +8. Issue an active lease, perform a final attestation, then release MicroClaw chat ingress. + +Any restart, disconnect, timeout, policy/tool/tier/assessment drift, missing or expired lease, +helper failure, or attestation error revokes the lease, clears the smoke proof, rewrites the locked +policy, and stops the managed Gateway. A new probe never authorizes a Full-to-Limited downgrade. +Configuration and restart operations that could invalidate an active transaction are rejected while +this mode is enabled. Unreviewed external channels, hooks, cron, and plugins remain disabled. The +loading screen shows the requested mode, attested +effective strength/tier, failed MXC phase, and recovery detail; the user may retry the same serialized transaction or turn MXC off. Toggling either direction returns to loading immediately, rejects chat during the transition, and releases ingress only after the selected route is ready. Security settings keep the protection state, actionable failures, @@ -230,14 +468,18 @@ back to the chat session while replay normalizes the plan to `null`, which the G rejects as `approval id does not match request`. After explicit consent, the MicroClaw-built system-drive helper and the official MXC null-device -helper both completed successfully. Non-elevated probing reports `appcontainer-dacl` with no host- -preparation warning. Live locked-generation proof reached the exact bundled node and official -`wxc-exec.exe`; protected `C:\Windows` CWD denial, `cmd.exe -> hostname.exe`, and PowerShell child -execution all pass under transition-bound internal one-use proofs with no durable approval, user -prompt, or host fallback. Ordinary agent execution retains the visible approval contract. +helper both completed successfully. A Full request remains locked on `appcontainer-dacl`; only +`base-container` or `appcontainer-bfs` satisfies Full. An explicitly acknowledged Limited request +can proceed on `appcontainer-dacl` only after its sensitive ambient-access assessment passes. On a +supported host, the locked-generation proof reaches the exact bundled node and official +`wxc-exec.exe`; the deterministic PowerShell probe launches `hostname.exe` from isolated scratch +under a transition-bound internal one-use proof with no durable approval, user prompt, or host +fallback. Full also proves protected `C:\Windows` CWD denial. Ordinary agent execution retains the +visible approval contract. The active transaction also passed end to end: MicroClaw reported chat disconnected before release, started a new Gateway generation, re-attested the exact node and `exec`-only tool surface, repeated -all three smokes, issued and renewed the generation-bound active lease, then reported chat connected. +the mode-specific smoke, issued and renewed the generation-bound active lease, then reported chat +connected. The active config had channels, webhooks, internal hooks, cron, plugins, and every plugin entry disabled; channel and cron inventories were empty, and a direct Gateway restart request was rejected. diff --git a/plugins/openclaw-weixin/src/auth/authorization.ts b/plugins/openclaw-weixin/src/auth/authorization.ts index 83d3b0f..43e2e80 100644 --- a/plugins/openclaw-weixin/src/auth/authorization.ts +++ b/plugins/openclaw-weixin/src/auth/authorization.ts @@ -4,15 +4,15 @@ import { shouldComputeCommandAuthorized, } from "openclaw/plugin-sdk/command-auth-native"; +import { isWeixinSenderAllowedByPolicy } from "../security/mxc-policy.js"; + export function resolveWeixinCommandAuthorization( cfg: OpenClawConfig, rawBody: string, senderId: string, allowFrom: string[], ): { senderAllowedForCommands: boolean; commandAuthorized: boolean | undefined } { - // Preserve the released plugin's empty-list legacy DM behavior; command - // access remains subject to the host's access-groups policy. - const senderAllowedForCommands = allowFrom.length === 0 || allowFrom.includes(senderId); + const senderAllowedForCommands = isWeixinSenderAllowedByPolicy(senderId, allowFrom); const commandAuthorized = shouldComputeCommandAuthorized(rawBody, cfg) ? resolveCommandAuthorizedFromAuthorizers({ useAccessGroups: cfg.commands?.useAccessGroups !== false, diff --git a/plugins/openclaw-weixin/src/auth/pairing.ts b/plugins/openclaw-weixin/src/auth/pairing.ts index 41266c7..ee231a4 100644 --- a/plugins/openclaw-weixin/src/auth/pairing.ts +++ b/plugins/openclaw-weixin/src/auth/pairing.ts @@ -54,7 +54,10 @@ export function readFrameworkAllowFromList(accountId: string): string[] { const raw = fs.readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw) as AllowFromFileContent; if (Array.isArray(parsed.allowFrom)) { - return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== ""); + return parsed.allowFrom + .filter((id): id is string => typeof id === "string") + .map((id) => id.trim()) + .filter(Boolean); } } catch { // best-effort diff --git a/plugins/openclaw-weixin/src/cdn/upload.ts b/plugins/openclaw-weixin/src/cdn/upload.ts index 1f4f14d..d546313 100644 --- a/plugins/openclaw-weixin/src/cdn/upload.ts +++ b/plugins/openclaw-weixin/src/cdn/upload.ts @@ -11,6 +11,7 @@ import { redactUrl } from "../util/redact.js"; import { getExtensionFromContentTypeOrUrl } from "../media/mime.js"; import { tempFileName } from "../util/random.js"; import { UploadMediaType } from "../api/types.js"; +import { fetchWeixinRemoteMediaWithPolicy } from "../security/mxc-policy.js"; export type UploadedFileInfo = { filekey: string; @@ -32,7 +33,7 @@ export async function downloadRemoteImageToTemp(url: string, destDir: string): P logger.debug(`downloadRemoteImageToTemp: fetching url=${redactUrl(url)}`); let res: Response; try { - res = await fetch(url); + res = await fetchWeixinRemoteMediaWithPolicy(url); } catch (err) { const cause = (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? ""; diff --git a/plugins/openclaw-weixin/src/channel.ts b/plugins/openclaw-weixin/src/channel.ts index c2007d2..ce78018 100644 --- a/plugins/openclaw-weixin/src/channel.ts +++ b/plugins/openclaw-weixin/src/channel.ts @@ -33,26 +33,28 @@ import { applyWeixinMessageSendingHook, emitWeixinMessageSent } from "./messagin import { sendWeixinMediaFile } from "./messaging/send-media.js"; import { sendMessageWeixin, StreamingMarkdownFilter } from "./messaging/send.js"; import { downloadRemoteImageToTemp } from "./cdn/upload.js"; +import { + assertMxcWeixinIngressReleased, + assertMxcWeixinOutboundMediaSource, + authorizeMxcWeixinOutboundMedia, + isMxcManagedWeixin, + resolveWeixinLocalMediaPath, + resolveWeixinOutboundTempRoot, +} from "./security/mxc-policy.js"; /** Returns true when mediaUrl refers to a local filesystem path (absolute or relative). */ function isLocalFilePath(mediaUrl: string): boolean { - // Treat anything without a URL scheme (no "://") as a local path. - return !mediaUrl.includes("://"); + return mediaUrl.startsWith("file://") || !mediaUrl.includes("://"); } function isRemoteUrl(mediaUrl: string): boolean { return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://"); } -const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp"); - -/** Resolve any local path scheme to an absolute filesystem path. */ -function resolveLocalPath(mediaUrl: string): string { - if (mediaUrl.startsWith("file://")) return new URL(mediaUrl).pathname; - // Resolve any relative path (./foo, ../foo, .openclaw/foo, foo/bar) against cwd - if (!path.isAbsolute(mediaUrl)) return path.resolve(mediaUrl); - return mediaUrl; -} +const LEGACY_MEDIA_OUTBOUND_TEMP_DIR = path.join( + resolvePreferredOpenClawTmpDir(), + "weixin/media/outbound-temp", +); /** * Resolve the effective accountId for an outbound message when the caller @@ -113,6 +115,7 @@ async function sendWeixinOutbound(params: { accountId?: string | null; contextToken?: string; }): Promise<{ channel: string; messageId: string }> { + assertMxcWeixinIngressReleased(); const account = resolveWeixinAccount(params.cfg, params.accountId); const aLog = logger.withAccount(account.accountId); assertSessionActive(account.accountId); @@ -139,6 +142,7 @@ async function sendWeixinOutbound(params: { filteredText = sendingResult.text; try { + assertMxcWeixinIngressReleased(); const result = await sendMessageWeixin({ to: params.to, text: filteredText, opts: { baseUrl: account.baseUrl, token: account.token, @@ -194,13 +198,22 @@ export const weixinPlugin: ChannelPlugin = { }, }, agentPrompt: { - messageToolHints: () => [ - "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.", - "When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.", - "IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.", - "IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation) AND set delivery.accountId to the current AccountId. Without an explicit 'to', the cron delivery will fail with 'requires target'. Without an explicit 'accountId', the message may be sent from the wrong bot account. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '', accountId: '' }.", - "IMPORTANT: When outputting a MEDIA: directive to send a file, the MEDIA: tag MUST be on its own line — never inline with other text. Correct:\nSome text here\nMEDIA:/path/to/file.mp4\nIncorrect: Some text here MEDIA:/path/to/file.mp4", - ], + messageToolHints: () => { + const mediaHints = isMxcManagedWeixin() + ? [ + "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a broker-approved local file path. Remote HTTP(S) media URLs are blocked in MXC mode. You do not need to specify 'to' — the current conversation recipient is used automatically.", + ] + : [ + "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.", + "When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.", + ]; + return [ + ...mediaHints, + "IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.", + "IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation) AND set delivery.accountId to the current AccountId. Without an explicit 'to', the cron delivery will fail with 'requires target'. Without an explicit 'accountId', the message may be sent from the wrong bot account. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '', accountId: '' }.", + "IMPORTANT: When outputting a MEDIA: directive to send a file, the MEDIA: tag MUST be on its own line — never inline with other text. Correct:\nSome text here\nMEDIA:/path/to/file.mp4\nIncorrect: Some text here MEDIA:/path/to/file.mp4", + ]; + }, }, reload: { configPrefixes: ["channels.openclaw-weixin"] }, config: { @@ -229,6 +242,7 @@ export const weixinPlugin: ChannelPlugin = { return result; }, sendMedia: async (ctx) => { + assertMxcWeixinIngressReleased(); const accountId = ctx.accountId || resolveOutboundAccountId(ctx.cfg, ctx.to); const account = resolveWeixinAccount(ctx.cfg, accountId); const aLog = logger.withAccount(account.accountId); @@ -241,6 +255,7 @@ export const weixinPlugin: ChannelPlugin = { } const mediaUrl = ctx.mediaUrl; + if (mediaUrl) assertMxcWeixinOutboundMediaSource(mediaUrl); let text = ctx.text ?? ""; const sendingResult = await applyWeixinMessageSendingHook({ @@ -258,15 +273,20 @@ export const weixinPlugin: ChannelPlugin = { if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) { let filePath: string; if (isLocalFilePath(mediaUrl)) { - filePath = resolveLocalPath(mediaUrl); + filePath = resolveWeixinLocalMediaPath(mediaUrl); aLog.debug(`sendMedia: uploading local file ${filePath}`); } else { aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`); - filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR); + filePath = await downloadRemoteImageToTemp( + mediaUrl, + resolveWeixinOutboundTempRoot(LEGACY_MEDIA_OUTBOUND_TEMP_DIR), + ); aLog.debug(`sendMedia: remote image downloaded to ${filePath}`); } + filePath = await authorizeMxcWeixinOutboundMedia(filePath); const contextToken = getContextToken(account.accountId, ctx.to); try { + assertMxcWeixinIngressReleased(); const result = await sendWeixinMediaFile({ filePath, to: ctx.to, @@ -284,6 +304,7 @@ export const weixinPlugin: ChannelPlugin = { const contextToken = getContextToken(account.accountId, ctx.to); try { + assertMxcWeixinIngressReleased(); const result = await sendMessageWeixin({ to: ctx.to, text, opts: { baseUrl: account.baseUrl, token: account.token, diff --git a/plugins/openclaw-weixin/src/messaging/process-message.ts b/plugins/openclaw-weixin/src/messaging/process-message.ts index 7cd62ca..81f7382 100644 --- a/plugins/openclaw-weixin/src/messaging/process-message.ts +++ b/plugins/openclaw-weixin/src/messaging/process-message.ts @@ -32,8 +32,20 @@ import { StreamingMarkdownFilter } from "./markdown-filter.js"; import { sendMessageWeixin } from "./send.js"; import { WeixinReplyProgressSender } from "./reply-progress-sender.js"; import { handleSlashCommand } from "./slash-commands.js"; - -const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp"); +import { + assertMxcWeixinIngressReleased, + assertMxcWeixinOutboundMediaSource, + authorizeMxcWeixinOutboundMedia, + hasMxcWeixinMediaPolicy, + resolveWeixinLocalMediaPath, + resolveWeixinOutboundTempRoot, + stageMxcWeixinInboundMedia, +} from "../security/mxc-policy.js"; + +const LEGACY_MEDIA_OUTBOUND_TEMP_DIR = path.join( + resolvePreferredOpenClawTmpDir(), + "weixin/media/outbound-temp", +); /** Dependencies for processOneMessage, injected by the monitor loop. */ export type ProcessMessageDeps = { @@ -67,6 +79,7 @@ export async function processOneMessage( full: WeixinMessage, deps: ProcessMessageDeps, ): Promise { + assertMxcWeixinIngressReleased(); if (!deps?.channelRuntime) { logger.error( `processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`, @@ -144,7 +157,10 @@ export async function processOneMessage( const label = refMediaItem ? "ref" : "inbound"; const downloaded = await downloadMediaFromItem(mediaItem, { cdnBaseUrl: deps.cdnBaseUrl, - saveMedia: deps.channelRuntime.media.saveMediaBuffer, + saveMedia: hasMxcWeixinMediaPolicy() + ? (buffer, contentType, _subdir, maxBytes, originalFilename) => + stageMxcWeixinInboundMedia(buffer, contentType, maxBytes, originalFilename) + : deps.channelRuntime.media.saveMediaBuffer, log: deps.log, errLog: deps.errLog, label, @@ -230,6 +246,7 @@ export async function processOneMessage( const finalized = deps.channelRuntime.reply.finalizeInboundContext( ctx as Parameters[0], ); + assertMxcWeixinIngressReleased(); logger.info( `inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length} hasMedia=${Boolean(finalized.MediaPath ?? finalized.MediaUrl)}`, @@ -311,11 +328,13 @@ export async function processOneMessage( typingCallbacks, deliver: async (payload) => { const rawText = payload.text ?? ""; + const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0]; + assertMxcWeixinIngressReleased(); + if (mediaUrl) assertMxcWeixinOutboundMediaSource(mediaUrl); let text = (() => { const f = new StreamingMarkdownFilter(); return f.feed(rawText) + f.flush(); })(); - const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0]; logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`); logger.info( `outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`, @@ -344,21 +363,18 @@ export async function processOneMessage( text = sendingResult.text; try { + assertMxcWeixinIngressReleased(); if (mediaUrl) { let filePath: string; if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) { - if (mediaUrl.startsWith("file://")) { - filePath = new URL(mediaUrl).pathname; - } else if (!path.isAbsolute(mediaUrl)) { - filePath = path.resolve(mediaUrl); - logger.debug(`outbound: resolved relative path ${mediaUrl} -> ${filePath}`); - } else { - filePath = mediaUrl; - } + filePath = resolveWeixinLocalMediaPath(mediaUrl); logger.debug(`outbound: local file path resolved filePath=${filePath}`); } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) { logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`); - filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR); + filePath = await downloadRemoteImageToTemp( + mediaUrl, + resolveWeixinOutboundTempRoot(LEGACY_MEDIA_OUTBOUND_TEMP_DIR), + ); logger.debug(`outbound: remote image downloaded to filePath=${filePath}`); } else { logger.warn( @@ -374,6 +390,8 @@ export async function processOneMessage( logger.info(`outbound: text sent to=${ctx.To}`); return; } + filePath = await authorizeMxcWeixinOutboundMedia(filePath); + assertMxcWeixinIngressReleased(); await sendWeixinMediaFile({ filePath, to: ctx.To, @@ -385,6 +403,7 @@ export async function processOneMessage( logger.info(`outbound: media sent OK to=${ctx.To}`); } else { logger.debug(`outbound: sending text message to=${ctx.To}`); + assertMxcWeixinIngressReleased(); await sendMessageWeixin({ to: ctx.To, text, opts: { baseUrl: deps.baseUrl, token: deps.token, diff --git a/plugins/openclaw-weixin/src/monitor/monitor.ts b/plugins/openclaw-weixin/src/monitor/monitor.ts index 58e1497..66943c5 100644 --- a/plugins/openclaw-weixin/src/monitor/monitor.ts +++ b/plugins/openclaw-weixin/src/monitor/monitor.ts @@ -9,6 +9,10 @@ import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../sto import { logger } from "../util/logger.js"; import type { Logger } from "../util/logger.js"; import { redactBody } from "../util/redact.js"; +import { + isMxcWeixinIngressReleased, + waitForMxcWeixinIngressRelease, +} from "../security/mxc-policy.js"; const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; const MAX_CONSECUTIVE_FAILURES = 3; @@ -88,6 +92,11 @@ export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise { + const roots: string[] = []; + const originalEnvironment = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnvironment }; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + }); + + function configure() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "weixin-mxc-policy-")); + roots.push(root); + const selected = path.join(root, "selected"); + const inbound = path.join(root, "inbound"); + const outbound = path.join(root, "outbound"); + fs.mkdirSync(selected); + fs.mkdirSync(inbound); + fs.mkdirSync(outbound); + const generation = "generation-1"; + const fingerprint = "a".repeat(64); + const token = "release-token"; + const releasePath = path.join(root, "release.json"); + process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; + process.env.MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION = generation; + process.env.MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT = fingerprint; + process.env.MICROCLAW_MXC_INGRESS_RELEASE_PATH = releasePath; + process.env.MICROCLAW_MXC_INGRESS_RELEASE_TOKEN = token; + process.env.MICROCLAW_MXC_WEIXIN_MEDIA_POLICY = Buffer.from( + JSON.stringify({ + contract: "microclaw.windows-weixin-media.v1", + gatewayGeneration: generation, + policyFingerprint: fingerprint, + strictNoHostFallback: true, + approvedRoots: [ + { path: selected, access: "rw", purpose: "selected-folder" }, + { path: inbound, access: "ro", purpose: "weixin-inbound" }, + ], + inboundRoot: inbound, + outboundTempRoot: outbound, + }), + ).toString("base64"); + return { root, selected, inbound, outbound, generation, fingerprint, token, releasePath }; + } + + it("keeps inbound polling paused until the exact release record exists", () => { + const policy = configure(); + expect(isMxcWeixinIngressReleased()).toBe(false); + fs.writeFileSync( + policy.releasePath, + JSON.stringify({ + contract: "microclaw.windows-ingress-release.v1", + gatewayGeneration: policy.generation, + policyFingerprint: policy.fingerprint, + token: policy.token, + }), + ); + expect(isMxcWeixinIngressReleased()).toBe(true); + fs.writeFileSync( + policy.releasePath, + JSON.stringify({ + contract: "microclaw.windows-ingress-release.v1", + gatewayGeneration: "stale-generation", + policyFingerprint: policy.fingerprint, + token: policy.token, + }), + ); + expect(isMxcWeixinIngressReleased()).toBe(false); + }); + + it("fails closed for empty MXC sender policy while preserving legacy behavior", () => { + process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; + expect(isWeixinSenderAllowedByPolicy("sender@im.wechat", [])).toBe(false); + expect( + isWeixinSenderAllowedByPolicy("owner@im.wechat", ["owner@im.wechat"]), + ).toBe(true); + + delete process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT; + expect(isWeixinSenderAllowedByPolicy("sender@im.wechat", [])).toBe(true); + }); + + it("rejects remote outbound media under MXC before fetch", async () => { + process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; + const fetchMock = vi.fn(); + + await expect( + fetchWeixinRemoteMediaWithPolicy("https://example.test/image.png", fetchMock), + ).rejects.toThrow(/blocks remote HTTP\(S\) media/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("stages inbound media and permits selected or staged files only", async () => { + const policy = configure(); + const selectedFile = path.join(policy.selected, "report.txt"); + const outsideFile = path.join(policy.root, "secret.txt"); + fs.writeFileSync(selectedFile, "selected"); + fs.writeFileSync(outsideFile, "outside"); + + await expect(authorizeMxcWeixinOutboundMedia(selectedFile)).resolves.toBe( + fs.realpathSync(selectedFile), + ); + await expect(authorizeMxcWeixinOutboundMedia(outsideFile)).rejects.toThrow( + /outside selected folders/, + ); + const staged = await stageMxcWeixinInboundMedia( + Buffer.from("image"), + "image/png", + 1024, + ); + expect(path.dirname(staged.path)).toBe(fs.realpathSync(policy.inbound)); + expect(path.extname(staged.path)).toBe(".png"); + }); + + it("permits local outbound media sources to continue through broker policy", async () => { + const policy = configure(); + const selectedFile = path.join(policy.selected, "photo.png"); + fs.writeFileSync(selectedFile, "image"); + + expect(() => assertMxcWeixinOutboundMediaSource(selectedFile)).not.toThrow(); + await expect(authorizeMxcWeixinOutboundMedia(selectedFile)).resolves.toBe( + fs.realpathSync(selectedFile), + ); + }); + + it("rejects reparse-point escapes without falling back to host access", async () => { + const policy = configure(); + const outside = path.join(policy.root, "outside"); + fs.mkdirSync(outside); + const outsideFile = path.join(outside, "secret.txt"); + fs.writeFileSync(outsideFile, "secret"); + const link = path.join(policy.selected, "escape"); + fs.symlinkSync(outside, link, "junction"); + + await expect( + authorizeMxcWeixinOutboundMedia(path.join(link, "secret.txt")), + ).rejects.toThrow(); + }); + + it("fails closed when the media policy belongs to a stale generation", async () => { + const policy = configure(); + const selectedFile = path.join(policy.selected, "report.txt"); + fs.writeFileSync(selectedFile, "selected"); + process.env.MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION = "generation-2"; + + await expect(authorizeMxcWeixinOutboundMedia(selectedFile)).rejects.toThrow( + /does not match the active generation/, + ); + }); +}); diff --git a/plugins/openclaw-weixin/src/security/mxc-policy.ts b/plugins/openclaw-weixin/src/security/mxc-policy.ts new file mode 100644 index 0000000..d7c7ad4 --- /dev/null +++ b/plugins/openclaw-weixin/src/security/mxc-policy.ts @@ -0,0 +1,276 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const INGRESS_RELEASE_CONTRACT = "microclaw.windows-ingress-release.v1"; +const MEDIA_POLICY_CONTRACT = "microclaw.windows-weixin-media.v1"; + +type MediaRoot = { + path: string; + access: "ro" | "rw"; + purpose: "selected-folder" | "weixin-inbound"; +}; + +type MediaPolicy = { + contract: typeof MEDIA_POLICY_CONTRACT; + gatewayGeneration: string; + policyFingerprint: string; + strictNoHostFallback: true; + approvedRoots: MediaRoot[]; + inboundRoot: string; + outboundTempRoot: string; +}; + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`MXC WeChat policy is missing ${name}`); + return value; +} + +export function isMxcManagedWeixin(): boolean { + return process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT === "1"; +} + +export function isMxcWeixinIngressReleased(): boolean { + if (!isMxcManagedWeixin()) return true; + try { + const releasePath = requiredEnvironment("MICROCLAW_MXC_INGRESS_RELEASE_PATH"); + const record = JSON.parse(fs.readFileSync(releasePath, "utf8")) as Record; + return ( + record.contract === INGRESS_RELEASE_CONTRACT && + record.gatewayGeneration === + requiredEnvironment("MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION") && + record.policyFingerprint === + requiredEnvironment("MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT").toLowerCase() && + record.token === requiredEnvironment("MICROCLAW_MXC_INGRESS_RELEASE_TOKEN") + ); + } catch { + return false; + } +} + +export function assertMxcWeixinIngressReleased(): void { + if (!isMxcWeixinIngressReleased()) { + throw new Error("WeChat delivery is paused until the current MXC generation is active"); + } +} + +export function isWeixinSenderAllowedByPolicy( + senderId: string, + allowFrom: string[], +): boolean { + if (allowFrom.includes(senderId)) return true; + return !isMxcManagedWeixin() && allowFrom.length === 0; +} + +export function assertMxcWeixinOutboundMediaSource(mediaSource: string): void { + if (isMxcManagedWeixin() && /^https?:\/\//i.test(mediaSource.trim())) { + throw new Error( + "MXC WeChat blocks remote HTTP(S) media; use a broker-approved local file", + ); + } +} + +export async function fetchWeixinRemoteMediaWithPolicy( + url: string, + fetchImplementation: typeof fetch = fetch, +): Promise { + assertMxcWeixinOutboundMediaSource(url); + return fetchImplementation(url); +} + +export async function waitForMxcWeixinIngressRelease( + signal?: AbortSignal, + intervalMs = 1000, +): Promise { + while (!isMxcWeixinIngressReleased()) { + if (signal?.aborted) throw new Error("aborted"); + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new Error("aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, intervalMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } +} + +export function hasMxcWeixinMediaPolicy(): boolean { + return isMxcManagedWeixin(); +} + +function loadMediaPolicy(): MediaPolicy { + const encoded = requiredEnvironment("MICROCLAW_MXC_WEIXIN_MEDIA_POLICY"); + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + } catch { + throw new Error("MXC WeChat media policy is malformed"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MXC WeChat media policy is invalid"); + } + const policy = parsed as Partial; + const generation = requiredEnvironment("MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION"); + const fingerprint = requiredEnvironment( + "MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT", + ).toLowerCase(); + if ( + policy.contract !== MEDIA_POLICY_CONTRACT || + policy.gatewayGeneration !== generation || + policy.policyFingerprint !== fingerprint || + policy.strictNoHostFallback !== true || + !Array.isArray(policy.approvedRoots) || + typeof policy.inboundRoot !== "string" || + typeof policy.outboundTempRoot !== "string" + ) { + throw new Error("MXC WeChat media policy does not match the active generation"); + } + const approvedRoots = policy.approvedRoots.map((entry) => { + if ( + !entry || + typeof entry.path !== "string" || + !path.isAbsolute(entry.path) || + (entry.access !== "ro" && entry.access !== "rw") || + (entry.purpose !== "selected-folder" && entry.purpose !== "weixin-inbound") + ) { + throw new Error("MXC WeChat media policy contains an invalid root"); + } + return { ...entry, path: path.resolve(entry.path) }; + }); + if (!path.isAbsolute(policy.inboundRoot) || !path.isAbsolute(policy.outboundTempRoot)) { + throw new Error("MXC WeChat media staging roots must be absolute"); + } + return { + contract: MEDIA_POLICY_CONTRACT, + gatewayGeneration: generation, + policyFingerprint: fingerprint, + strictNoHostFallback: true, + approvedRoots, + inboundRoot: path.resolve(policy.inboundRoot), + outboundTempRoot: path.resolve(policy.outboundTempRoot), + }; +} + +export function resolveWeixinOutboundTempRoot(legacyRoot: string): string { + if (!hasMxcWeixinMediaPolicy()) return legacyRoot; + const policy = loadMediaPolicy(); + fs.mkdirSync(policy.outboundTempRoot, { recursive: true }); + assertDirectoryHasNoReparsePoints(policy.outboundTempRoot); + return policy.outboundTempRoot; +} + +export async function stageMxcWeixinInboundMedia( + buffer: Buffer, + contentType?: string, + maxBytes?: number, + originalFilename?: string, +): Promise<{ path: string }> { + const policy = loadMediaPolicy(); + if (maxBytes !== undefined && buffer.byteLength > maxBytes) { + throw new Error(`WeChat inbound media exceeds the ${maxBytes}-byte staging limit`); + } + fs.mkdirSync(policy.inboundRoot, { recursive: true }); + assertDirectoryHasNoReparsePoints(policy.inboundRoot); + const extension = safeExtension(originalFilename, contentType); + const destination = path.join(policy.inboundRoot, `${randomUUID()}${extension}`); + await fs.promises.writeFile(destination, buffer, { flag: "wx" }); + const authorized = await authorizeMxcWeixinOutboundMedia(destination); + return { path: authorized }; +} + +export function resolveWeixinLocalMediaPath(mediaUrl: string): string { + if (mediaUrl.startsWith("file://")) return fileURLToPath(mediaUrl); + return path.resolve(mediaUrl); +} + +export async function authorizeMxcWeixinOutboundMedia(filePath: string): Promise { + const resolved = path.resolve(filePath); + if (!hasMxcWeixinMediaPolicy()) return resolved; + const policy = loadMediaPolicy(); + let canonicalFile: string; + try { + canonicalFile = await fs.promises.realpath(resolved); + } catch { + throw new Error("WeChat media broker rejected an unavailable local file"); + } + const stat = await fs.promises.stat(canonicalFile); + if (!stat.isFile()) throw new Error("WeChat media broker accepts regular files only"); + + const candidateRoots = [...policy.approvedRoots.map((entry) => entry.path), policy.outboundTempRoot]; + for (const configuredRoot of candidateRoots) { + const resolvedRoot = path.resolve(configuredRoot); + if (containsPath(resolvedRoot, resolved)) { + assertNoReparsePoints(resolvedRoot, resolved); + } + let canonicalRoot: string; + try { + canonicalRoot = await fs.promises.realpath(resolvedRoot); + } catch { + continue; + } + if (!containsPath(canonicalRoot, canonicalFile)) continue; + assertNoReparsePoints(canonicalRoot, canonicalFile); + return canonicalFile; + } + throw new Error( + "WeChat media broker rejected a file outside selected folders, inbound staging, and broker scratch", + ); +} + +function containsPath(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function assertDirectoryHasNoReparsePoints(directory: string): void { + if (fs.lstatSync(directory).isSymbolicLink()) { + throw new Error("WeChat media broker rejected a reparse-point staging root"); + } + const canonical = fs.realpathSync(directory); + assertNoReparsePoints(canonical, canonical); +} + +function assertNoReparsePoints(root: string, target: string): void { + assertPathChainHasNoReparsePoints(root); + const relative = path.relative(root, target); + const components = relative ? relative.split(path.sep) : []; + let current = root; + for (const component of components) { + current = path.join(current, component); + if (fs.lstatSync(current).isSymbolicLink()) { + throw new Error("WeChat media broker rejected a reparse-point path"); + } + } + + function assertPathChainHasNoReparsePoints(target: string): void { + const parsed = path.parse(target); + let current = parsed.root; + const relative = path.relative(parsed.root, target); + for (const component of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, component); + if (fs.lstatSync(current).isSymbolicLink()) { + throw new Error("WeChat media broker rejected a reparse-point root"); + } + } + } +} + +function safeExtension(originalFilename?: string, contentType?: string): string { + const original = originalFilename ? path.extname(path.basename(originalFilename)) : ""; + if (/^\.[a-z0-9]{1,10}$/i.test(original)) return original.toLowerCase(); + const extensions: Record = { + "audio/silk": ".silk", + "audio/wav": ".wav", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "video/mp4": ".mp4", + }; + return extensions[contentType ?? ""] ?? ".bin"; +} diff --git a/windows-node-canvas-host/A2UICanvasWindow.Compatibility.cs b/windows-node-canvas-host/A2UICanvasWindow.Compatibility.cs new file mode 100644 index 0000000..e641d94 --- /dev/null +++ b/windows-node-canvas-host/A2UICanvasWindow.Compatibility.cs @@ -0,0 +1,156 @@ +using System.Collections.ObjectModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; + +namespace OpenClawTray.Windows; + +public sealed partial class A2UICanvasWindow +{ + private Grid RootGrid = null!; + private StackPanel EmptyPanel = null!; + private TextBlock WaitingForContentText = null!; + private ContentControl SingleSurfaceHost = null!; + private StandaloneTabView MultiSurfaceTabs = null!; + + public void InitializeComponent() + { + Width = 900; + Height = 650; + MinWidth = 320; + MinHeight = 240; + + RootGrid = new Grid(); + EmptyPanel = new StackPanel + { + Padding = new Thickness(32), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 12, + Visibility = Visibility.Visible, + }; + var icon = new FontIcon + { + FontSize = 32, + Glyph = "\uE71B", + Opacity = 0.55, + }; + AutomationProperties.SetAccessibilityView(icon, AccessibilityView.Raw); + WaitingForContentText = new TextBlock + { + HorizontalAlignment = HorizontalAlignment.Center, + FontSize = 20, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + Opacity = 0.7, + }; + AutomationProperties.SetLiveSetting(WaitingForContentText, AutomationLiveSetting.Polite); + EmptyPanel.Children.Add(icon); + EmptyPanel.Children.Add(WaitingForContentText); + + SingleSurfaceHost = new ContentControl + { + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + Visibility = Visibility.Collapsed, + }; + MultiSurfaceTabs = new StandaloneTabView + { + Spacing = 12, + Visibility = Visibility.Collapsed, + }; + RootGrid.Children.Add(EmptyPanel); + RootGrid.Children.Add(SingleSurfaceHost); + RootGrid.Children.Add(MultiSurfaceTabs); + Content = RootGrid; + } +} + +internal sealed class StandaloneTabView : StackPanel +{ + public StandaloneTabView() => TabItems = new TabItemCollection(this); + + public Collection TabItems { get; } + + public object? SelectedItem { get; set; } + + private sealed class TabItemCollection(StandaloneTabView owner) : Collection + { + protected override void InsertItem(int index, TabViewItem item) + { + base.InsertItem(index, item); + owner.Children.Insert(index, item); + } + + protected override void SetItem(int index, TabViewItem item) + { + base.SetItem(index, item); + owner.Children[index] = item; + } + + protected override void RemoveItem(int index) + { + if (ReferenceEquals(owner.SelectedItem, this[index])) + owner.SelectedItem = null; + owner.Children.RemoveAt(index); + base.RemoveItem(index); + } + + protected override void ClearItems() + { + owner.SelectedItem = null; + owner.Children.Clear(); + base.ClearItems(); + } + } +} + +internal sealed class TabViewItem : StackPanel +{ + private object? _header; + private object? _content; + + public TabViewItem() => Spacing = 4; + + public object? Header + { + get => _header; + set + { + _header = value; + Rebuild(); + } + } + + public object? Content + { + get => _content; + set + { + _content = value; + Rebuild(); + } + } + + public bool IsClosable { get; set; } + + private void Rebuild() + { + Children.Clear(); + if (_header is UIElement headerElement) + { + Children.Add(headerElement); + } + else if (_header != null) + { + Children.Add(new TextBlock + { + Text = _header.ToString(), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + } + + if (_content is UIElement contentElement) + Children.Add(contentElement); + } +} diff --git a/windows-node-canvas-host/App.xaml b/windows-node-canvas-host/App.xaml new file mode 100644 index 0000000..22496b2 --- /dev/null +++ b/windows-node-canvas-host/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/windows-node-canvas-host/App.xaml.cs b/windows-node-canvas-host/App.xaml.cs new file mode 100644 index 0000000..c5c2e4d --- /dev/null +++ b/windows-node-canvas-host/App.xaml.cs @@ -0,0 +1,37 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Dispatching; + +namespace MicroClaw.WindowsNodeCanvasHost; + +public partial class App : Application +{ + private CanvasHostServer? _server; + + public App() + { + InitializeComponent(); + UnhandledException += (_, args) => + { + Console.Error.WriteLine($"[canvas-host] unhandled: {args.Exception.GetType().Name}: {args.Exception.Message}"); + args.Handled = true; + }; + } + + protected override async void OnLaunched(LaunchActivatedEventArgs args) + { + try + { + var bootstrapJson = await Console.In.ReadLineAsync(); + var bootstrap = CanvasHostBootstrap.Parse(bootstrapJson); + OpenClawTray.Helpers.LocalizationHelper.Locale = bootstrap.UiLocale; + _server = new CanvasHostServer(bootstrap, DispatcherQueue.GetForCurrentThread()); + await _server.RunAsync(); + Exit(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[canvas-host] startup failed: {ex.GetType().Name}: {ex.Message}"); + Exit(); + } + } +} diff --git a/windows-node-canvas-host/CanvasHostServer.cs b/windows-node-canvas-host/CanvasHostServer.cs new file mode 100644 index 0000000..c71c8c7 --- /dev/null +++ b/windows-node-canvas-host/CanvasHostServer.cs @@ -0,0 +1,776 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.UI.Dispatching; +using OpenClaw.Shared; +using OpenClawTray.A2UI.Actions; +using OpenClawTray.A2UI.Protocol; +using OpenClawTray.A2UI.Rendering; +using OpenClawTray.Windows; + +namespace MicroClaw.WindowsNodeCanvasHost; + +internal static class CanvasPhase1Policy +{ + public const string Contract = "microclaw.windows-node-mxc.canvas.v1"; + public static readonly IReadOnlySet Commands = new HashSet( + [ + "canvas.present", + "canvas.hide", + "canvas.snapshot", + "canvas.caps", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "canvas.a2ui.dump", + ], + StringComparer.Ordinal); + public static readonly IReadOnlySet Actions = new HashSet( + ["click", "submit", "confirm", "cancel"], + StringComparer.Ordinal); +} + +internal sealed record CanvasHostBootstrap( + string Contract, + string CommandPipeName, + string ActionPipeName, + string IpcToken, + string ActivationLeasePath, + string ActivationLeaseSecret, + string GatewayGeneration, + string PolicyFingerprint, + string NodeId, + int ParentProcessId, + long ParentProcessStartTimeUtcTicks, + string UiLocale) +{ + public static CanvasHostBootstrap Parse(string? json) + { + var value = JsonSerializer.Deserialize( + json ?? string.Empty, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("Canvas bootstrap is empty."); + if (!string.Equals(value.Contract, CanvasPhase1Policy.Contract, StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(value.CommandPipeName) + || string.IsNullOrWhiteSpace(value.ActionPipeName) + || Convert.FromBase64String(value.IpcToken).Length != 32 + || !Guid.TryParse(value.GatewayGeneration, out _) + || !System.Text.RegularExpressions.Regex.IsMatch(value.PolicyFingerprint, "^[a-f0-9]{64}$") + || !System.Text.RegularExpressions.Regex.IsMatch(value.NodeId, "^[a-f0-9]{64}$") + || value.ParentProcessId <= 0 + || value.ParentProcessStartTimeUtcTicks <= 0) + { + throw new InvalidOperationException("Canvas bootstrap is invalid."); + } + return value; + } +} + +internal sealed class CanvasHostServer +{ + private readonly CanvasHostBootstrap _bootstrap; + private readonly DispatcherQueue _dispatcher; + private readonly CanvasLeaseGuard _lease; + private readonly CancellationTokenSource _shutdown = new(); + private readonly Phase1ActionSink _actions; + private readonly MediaResolver _media; + private readonly object _surfaceSessionLock = new(); + private readonly Dictionary _surfaceSessions = new(StringComparer.Ordinal); + private A2UICanvasWindow? _window; + + public CanvasHostServer(CanvasHostBootstrap bootstrap, DispatcherQueue dispatcher) + { + _bootstrap = bootstrap; + _dispatcher = dispatcher; + _lease = new CanvasLeaseGuard( + bootstrap.ActivationLeasePath, + bootstrap.ActivationLeaseSecret, + bootstrap.GatewayGeneration, + bootstrap.PolicyFingerprint); + _actions = new Phase1ActionSink( + ResolveSurfaceSession, + SendActionAsync, + _lease, + new CanvasLogger()); + _media = new MediaResolver(new CanvasLogger()); + } + + public async Task RunAsync() + { + var leaseMonitor = MonitorLeaseAsync(_shutdown.Token); + var parentMonitor = MonitorParentAsync(_shutdown.Token); + try + { + while (!_shutdown.IsCancellationRequested) + { + using var pipe = new NamedPipeServerStream( + _bootstrap.CommandPipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.WaitForConnectionAsync(_shutdown.Token); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 64 * 1024, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 64 * 1024, leaveOpen: true) + { + AutoFlush = true, + }; + var line = await reader.ReadLineAsync(_shutdown.Token); + var response = await HandleRequestAsync(line, _shutdown.Token); + await writer.WriteLineAsync(JsonSerializer.Serialize(response)); + } + } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + { + } + finally + { + _shutdown.Cancel(); + try { await leaseMonitor; } catch (OperationCanceledException) { } + try { await parentMonitor; } catch (OperationCanceledException) { } + await CloseWindowAsync(); + _media.Dispose(); + _actions.Dispose(); + } + } + + private async Task HandleRequestAsync(string? json, CancellationToken cancellationToken) + { + try + { + var request = JsonSerializer.Deserialize( + json ?? string.Empty, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new CanvasPolicyException("CANVAS_REQUEST_INVALID", "Canvas request is empty."); + ValidateRequestIdentity(request); + if (!CanvasPhase1Policy.Commands.Contains(request.Command)) + throw new CanvasPolicyException("CANVAS_COMMAND_DENIED", "Canvas command is outside the Phase 1 allowlist."); + var lease = _lease.ValidateActive(); + var payload = await ExecuteAsync(request.Command, request.Args, lease, cancellationToken); + return new { ok = true, payload }; + } + catch (CanvasPolicyException ex) + { + return new { ok = false, error = $"{ex.Code}: {ex.Message}" }; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[canvas-host] command failed: {ex}"); + return new { ok = false, error = $"CANVAS_HOST_FAILURE: {ex.GetType().Name}: {ex.Message}" }; + } + } + + private async Task ExecuteAsync( + string command, + JsonElement args, + ValidatedCanvasLease lease, + CancellationToken cancellationToken) + { + switch (command) + { + case "canvas.present": + ValidatePresentArgs(args); + return await OnUiAsync(() => + { + _lease.RevalidateActive(lease); + EnsureWindow(); + _window!.Activate(); + _window.BringToFront(false); + return (object)new { presented = true, renderer = "native-a2ui" }; + }); + case "canvas.hide": + await CloseWindowAsync(lease); + return new { hidden = true }; + case "canvas.a2ui.push": + case "canvas.a2ui.pushJSONL": + { + var jsonl = CanvasRequestValidation.ReadInlineJsonl(args); + CanvasRequestValidation.ValidateJsonl(jsonl); + var sessionKey = ReadSessionKey(args); + var plannedSurfaceSessions = PrepareSurfaceSessions(jsonl, sessionKey); + return await OnUiAsync(() => + { + _lease.RevalidateActive(lease); + EnsureWindow(); + _window!.Push(jsonl); + ReplaceSurfaceSessions(plannedSurfaceSessions); + _lease.RevalidateActive(lease); + return (object)new { pushed = true }; + }); + } + case "canvas.a2ui.reset": + return await OnUiAsync(() => + { + _lease.RevalidateActive(lease); + _window?.Reset(); + ClearSurfaceSessions(); + return (object)new { reset = true }; + }); + case "canvas.a2ui.dump": + return await OnUiAsync(() => + { + _lease.RevalidateActive(lease); + return (object)JsonNode.Parse( + _window?.GetStateSnapshot() + ?? """{"renderer":"none","a2uiVersion":"0.8","surfaceCount":0,"surfaces":{}}""")!; + }); + case "canvas.snapshot": + { + var format = CanvasRequestValidation.ReadSnapshotFormat(args); + var base64 = await OnUiAsync(async () => + { + _lease.RevalidateActive(lease); + if (_window is null || _window.IsClosed) + throw new CanvasPolicyException("CANVAS_NOT_OPEN", "No managed Canvas surface is open."); + var result = await _window.CaptureSnapshotAsync(format); + _lease.RevalidateActive(lease); + return result; + }); + return new { format, base64, surface = "managed-canvas", storedPath = (string?)null }; + } + case "canvas.caps": + return new + { + contract = CanvasPhase1Policy.Contract, + renderer = _window is not null && !_window.IsClosed ? "native-a2ui" : "none", + eval = false, + navigate = false, + snapshot = true, + screen = false, + localFiles = false, + commands = CanvasPhase1Policy.Commands.Order().ToArray(), + actions = CanvasPhase1Policy.Actions.Order().ToArray(), + a2ui = new { version = "0.8", push = true, reset = true, introspect = true }, + }; + default: + throw new CanvasPolicyException("CANVAS_COMMAND_DENIED", "Canvas command is not supported."); + } + } + + private void EnsureWindow() + { + if (_window is not null && !_window.IsClosed) return; + _window = new A2UICanvasWindow(_actions, _media, new CanvasLogger()); + _window.Closed += (_, _) => _window = null; + } + + private async Task MonitorLeaseAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250)); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + if (_lease.IsActive()) continue; + await CloseWindowAsync(); + } + } + + private async Task MonitorParentAsync(CancellationToken cancellationToken) + { + Process parent; + try + { + parent = Process.GetProcessById(_bootstrap.ParentProcessId); + if (parent.StartTime.ToUniversalTime().Ticks != _bootstrap.ParentProcessStartTimeUtcTicks) + { + parent.Dispose(); + _shutdown.Cancel(); + return; + } + } + catch (Exception) + { + _shutdown.Cancel(); + return; + } + using (parent) + using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250))) + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + if (!parent.HasExited) continue; + _shutdown.Cancel(); + return; + } + } + } + + private Task CloseWindowAsync(ValidatedCanvasLease? expected = null) => + OnUiAsync(() => + { + if (expected is not null) _lease.RevalidateActive(expected); + if (_window is not null && !_window.IsClosed) _window.Close(); + _window = null; + ClearSurfaceSessions(); + return true; + }); + + private async Task SendActionAsync(A2UIAction action, string sessionKey) + { + var lease = _lease.ValidateActive(); + using var pipe = new NamedPipeClientStream( + ".", + _bootstrap.ActionPipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous, + System.Security.Principal.TokenImpersonationLevel.Identification); + await pipe.ConnectAsync(2_000, _shutdown.Token); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 16 * 1024, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 16 * 1024, leaveOpen: true) + { + AutoFlush = true, + }; + _lease.RevalidateActive(lease); + await writer.WriteLineAsync(JsonSerializer.Serialize(new + { + contract = CanvasPhase1Policy.Contract, + token = _bootstrap.IpcToken, + gatewayGeneration = _bootstrap.GatewayGeneration, + policyFingerprint = _bootstrap.PolicyFingerprint, + nodeId = _bootstrap.NodeId, + sessionKey, + key = action.Id, + action = A2UIActionEnvelope.ToEnvelope(action), + })); + var response = await reader.ReadLineAsync(_shutdown.Token); + if (response is null || !JsonDocument.Parse(response).RootElement.GetProperty("ok").GetBoolean()) + throw new CanvasPolicyException("CANVAS_ACTION_REJECTED", "Gateway rejected the Canvas action."); + } + + private void ValidateRequestIdentity(CanvasHostRequest request) + { + var token = Convert.FromBase64String(request.Token ?? string.Empty); + var expected = Convert.FromBase64String(_bootstrap.IpcToken); + if (!CryptographicOperations.FixedTimeEquals(token, expected) + || !string.Equals(request.Contract, CanvasPhase1Policy.Contract, StringComparison.Ordinal) + || !string.Equals(request.GatewayGeneration, _bootstrap.GatewayGeneration, StringComparison.Ordinal) + || !string.Equals(request.PolicyFingerprint, _bootstrap.PolicyFingerprint, StringComparison.Ordinal) + || !string.Equals(request.NodeId, _bootstrap.NodeId, StringComparison.Ordinal)) + { + throw new CanvasPolicyException("CANVAS_REQUEST_IDENTITY", "Canvas request identity is stale or invalid."); + } + } + + private static void ValidatePresentArgs(JsonElement args) + { + if (args.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return; + if (args.ValueKind != JsonValueKind.Object) + throw new CanvasPolicyException("CANVAS_PRESENT_INVALID", "Canvas present parameters must be an object."); + if (args.EnumerateObject().Any()) + throw new CanvasPolicyException( + "CANVAS_PRESENT_TRUSTED_ORIGIN_ONLY", + "Phase 1 Canvas present opens only the managed native A2UI shell."); + } + + private static string ReadSessionKey(JsonElement args) + { + if (args.ValueKind != JsonValueKind.Object + || !args.TryGetProperty("props", out var props) + || props.ValueKind != JsonValueKind.Object + || !props.TryGetProperty("sessionKey", out var session) + || session.ValueKind != JsonValueKind.String) + { + throw new CanvasPolicyException( + "CANVAS_SESSION_REQUIRED", + "Canvas updates require the current agent session binding."); + } + var value = session.GetString()?.Trim(); + if (!string.IsNullOrEmpty(value) + && value.Length <= 256 + && value.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' or ':' or '.')) + { + return value; + } + throw new CanvasPolicyException("CANVAS_SESSION_INVALID", "Canvas session key is invalid."); + } + + private Dictionary PrepareSurfaceSessions(string jsonl, string sessionKey) + { + Dictionary planned; + lock (_surfaceSessionLock) + planned = new Dictionary(_surfaceSessions, StringComparer.Ordinal); + foreach (var line in jsonl.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var root = JsonNode.Parse(line)?.AsObject() + ?? throw new CanvasPolicyException("CANVAS_A2UI_INVALID", "A2UI JSONL contains invalid JSON."); + foreach (var envelope in new[] { "surfaceUpdate", "beginRendering", "dataModelUpdate" }) + { + if (root[envelope] is not JsonObject body) continue; + var surfaceId = ReadSurfaceId(body); + if (planned.TryGetValue(surfaceId, out var owner) + && !string.Equals(owner, sessionKey, StringComparison.Ordinal)) + { + throw new CanvasPolicyException( + "CANVAS_SURFACE_SESSION_MISMATCH", + "Canvas surface belongs to a different agent session."); + } + planned[surfaceId] = sessionKey; + } + if (root["deleteSurface"] is JsonObject deletion) + { + var surfaceId = ReadSurfaceId(deletion); + if (planned.TryGetValue(surfaceId, out var owner) + && !string.Equals(owner, sessionKey, StringComparison.Ordinal)) + { + throw new CanvasPolicyException( + "CANVAS_SURFACE_SESSION_MISMATCH", + "Canvas surface belongs to a different agent session."); + } + planned.Remove(surfaceId); + } + } + return planned; + } + + private static string ReadSurfaceId(JsonObject body) + { + var surfaceId = body["surfaceId"]?.GetValue()?.Trim(); + if (string.IsNullOrEmpty(surfaceId) + || surfaceId.Length > 256 + || surfaceId.Any(char.IsControl)) + { + throw new CanvasPolicyException("CANVAS_SURFACE_ID_INVALID", "Canvas surface id is invalid."); + } + return surfaceId; + } + + private string? ResolveSurfaceSession(string surfaceId) + { + lock (_surfaceSessionLock) + return _surfaceSessions.GetValueOrDefault(surfaceId); + } + + private void ReplaceSurfaceSessions(Dictionary planned) + { + lock (_surfaceSessionLock) + { + _surfaceSessions.Clear(); + foreach (var entry in planned) + _surfaceSessions.Add(entry.Key, entry.Value); + } + } + + private void ClearSurfaceSessions() + { + lock (_surfaceSessionLock) + _surfaceSessions.Clear(); + } + + private Task OnUiAsync(Func action) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_dispatcher.TryEnqueue(() => + { + try { tcs.TrySetResult(action()); } + catch (Exception ex) { tcs.TrySetException(ex); } + })) + { + tcs.TrySetException(new CanvasPolicyException( + "CANVAS_DISPATCHER_UNAVAILABLE", + "Canvas UI dispatcher is unavailable.")); + } + return tcs.Task; + } + + private Task OnUiAsync(Func> action) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_dispatcher.TryEnqueue(async () => + { + try { tcs.TrySetResult(await action()); } + catch (Exception ex) { tcs.TrySetException(ex); } + })) + { + tcs.TrySetException(new CanvasPolicyException( + "CANVAS_DISPATCHER_UNAVAILABLE", + "Canvas UI dispatcher is unavailable.")); + } + return tcs.Task; + } +} + +internal sealed record CanvasHostRequest( + string? Contract, + string? Token, + string? GatewayGeneration, + string? PolicyFingerprint, + string? NodeId, + string Command, + JsonElement Args); + +internal sealed class Phase1ActionSink( + Func resolveSessionKey, + Func deliver, + CanvasLeaseGuard lease, + IOpenClawLogger logger) : IActionSink, IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public void Raise(A2UIAction action) + { + if (!CanvasPhase1Policy.Actions.Contains(action.Name)) + { + logger.Warn($"[A2UI] blocked action outside Phase 1 allowlist: {action.Name}"); + return; + } + _ = DeliverAsync(action); + } + + private async Task DeliverAsync(A2UIAction action) + { + await _gate.WaitAsync(); + try + { + var active = lease.ValidateActive(); + var sessionKey = resolveSessionKey(action.SurfaceId); + if (string.IsNullOrWhiteSpace(sessionKey)) + throw new CanvasPolicyException("CANVAS_ACTION_SESSION", "Canvas action session is unavailable."); + lease.RevalidateActive(active); + await deliver(action, sessionKey); + } + catch (Exception ex) + { + logger.Warn($"[A2UI] action rejected: {ex.Message}"); + } + finally + { + _gate.Release(); + } + } + + public void Dispose() => _gate.Dispose(); +} + +internal static class CanvasRequestValidation +{ + private const int MaxJsonlBytes = 4 * 1024 * 1024; + private const int MaxJsonlLines = 4096; + + public static string ReadInlineJsonl(JsonElement args) + { + if (args.ValueKind != JsonValueKind.Object) + throw new CanvasPolicyException("CANVAS_A2UI_INVALID", "A2UI parameters must be an object."); + if (args.TryGetProperty("jsonlPath", out _)) + throw new CanvasPolicyException( + "CANVAS_LOCAL_FILE_DENIED", + "Phase 1 rejects local A2UI files; provide inline JSONL."); + if (!args.TryGetProperty("jsonl", out var value) || value.ValueKind != JsonValueKind.String) + throw new CanvasPolicyException("CANVAS_A2UI_INVALID", "Inline jsonl is required."); + var jsonl = value.GetString() ?? string.Empty; + if (Encoding.UTF8.GetByteCount(jsonl) > MaxJsonlBytes) + throw new CanvasPolicyException("CANVAS_A2UI_TOO_LARGE", "A2UI JSONL exceeds the Phase 1 size limit."); + if (jsonl.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Length > MaxJsonlLines) + throw new CanvasPolicyException("CANVAS_A2UI_TOO_MANY_LINES", "A2UI JSONL exceeds the Phase 1 line limit."); + return jsonl; + } + + public static void ValidateJsonl(string jsonl) + { + foreach (var line in jsonl.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + JsonNode root; + try { root = JsonNode.Parse(line) ?? throw new JsonException(); } + catch (JsonException) + { + throw new CanvasPolicyException("CANVAS_A2UI_INVALID", "A2UI JSONL contains invalid JSON."); + } + ValidateNode(root); + } + } + + public static string ReadSnapshotFormat(JsonElement args) + { + if (args.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return "png"; + if (args.ValueKind != JsonValueKind.Object) + throw new CanvasPolicyException("CANVAS_SNAPSHOT_INVALID", "Snapshot parameters must be an object."); + if (args.EnumerateObject().Any(property => property.Name != "format")) + throw new CanvasPolicyException("CANVAS_SNAPSHOT_INVALID", "Snapshot accepts only a format."); + var format = args.TryGetProperty("format", out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString()?.ToLowerInvariant() + : "png"; + if (format is not ("png" or "jpeg" or "jpg")) + throw new CanvasPolicyException("CANVAS_SNAPSHOT_FORMAT", "Canvas snapshot supports png or jpeg only."); + return format!; + } + + private static void ValidateNode(JsonNode node) + { + switch (node) + { + case JsonObject obj: + foreach (var (name, value) in obj) + { + if (value is null) continue; + if (IsMediaProperty(name)) ValidateMediaValue(value); + else ValidateNode(value); + } + break; + case JsonArray array: + foreach (var value in array) + { + if (value is not null) ValidateNode(value); + } + break; + } + } + + private static bool IsMediaProperty(string name) => + name.Equals("url", StringComparison.OrdinalIgnoreCase) + || name.Equals("src", StringComparison.OrdinalIgnoreCase) + || name.Equals("uri", StringComparison.OrdinalIgnoreCase); + + private static void ValidateMediaValue(JsonNode node) + { + if (node is JsonValue direct && direct.TryGetValue(out var directText)) + { + ValidateMediaReference(directText); + return; + } + if (node is JsonObject value) + { + if (value["path"] is not null) + throw new CanvasPolicyException( + "CANVAS_MEDIA_BINDING_DENIED", + "Phase 1 Canvas media must use a bounded inline literal."); + if (value["literalString"] is JsonValue literal + && literal.TryGetValue(out var literalText)) + { + ValidateMediaReference(literalText); + return; + } + } + throw new CanvasPolicyException( + "CANVAS_MEDIA_REFERENCE_DENIED", + "Canvas media reference is not approved."); + } + + private static void ValidateMediaReference(string value) + { + var trimmed = value.Trim(); + if (IsApprovedInlineImage(trimmed)) return; + if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + throw new CanvasPolicyException("CANVAS_MEDIA_DATA_DENIED", "Canvas data media type is not allowed."); + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + if (uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new CanvasPolicyException( + "CANVAS_MEDIA_HOST_NOT_ALLOWED", + "Phase 1 has no remote media host allowlist."); + } + throw new CanvasPolicyException("CANVAS_MEDIA_SCHEME_DENIED", "Canvas media scheme is not allowed."); + } + if (Path.IsPathFullyQualified(trimmed) + || trimmed.StartsWith(@"\\", StringComparison.Ordinal) + || trimmed.StartsWith("./", StringComparison.Ordinal) + || trimmed.StartsWith("../", StringComparison.Ordinal) + || trimmed.StartsWith(@".\", StringComparison.Ordinal) + || trimmed.StartsWith(@"..\", StringComparison.Ordinal)) + { + throw new CanvasPolicyException("CANVAS_LOCAL_FILE_DENIED", "Local Canvas media references are denied."); + } + throw new CanvasPolicyException("CANVAS_MEDIA_REFERENCE_DENIED", "Canvas media reference is not approved."); + } + + private static bool IsApprovedInlineImage(string value) + { + if (!value.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return false; + var comma = value.IndexOf(','); + if (comma < 0) return false; + var metadata = value[5..comma].Split(';', StringSplitOptions.RemoveEmptyEntries); + if (metadata.Length < 2 || !metadata.Skip(1).Contains("base64", StringComparer.OrdinalIgnoreCase)) + return false; + return metadata[0].Equals("image/png", StringComparison.OrdinalIgnoreCase) + || metadata[0].Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || metadata[0].Equals("image/webp", StringComparison.OrdinalIgnoreCase); + } +} + +internal sealed record ValidatedCanvasLease(string Signature, long ExpiresAtUnixMs); + +internal sealed class CanvasLeaseGuard( + string path, + string secretBase64, + string generation, + string policyFingerprint) +{ + private readonly byte[] _secret = Convert.FromBase64String(secretBase64); + + public ValidatedCanvasLease ValidateActive() + { + CanvasLeaseRecord record; + try + { + record = JsonSerializer.Deserialize( + File.ReadAllText(path), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new JsonException(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + throw new CanvasPolicyException("CANVAS_LEASE_UNAVAILABLE", "Current Canvas activation lease is unavailable."); + } + if (!string.Equals(record.Contract, "microclaw.windows-activation.v1", StringComparison.Ordinal) + || !string.Equals(record.Mode, "active", StringComparison.OrdinalIgnoreCase) + || !string.Equals(record.GatewayGeneration, generation, StringComparison.Ordinal) + || !string.Equals(record.PolicyFingerprint, policyFingerprint, StringComparison.Ordinal) + || record.ExpiresAtUnixMs <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + { + throw new CanvasPolicyException("CANVAS_LEASE_INACTIVE", "Canvas is available only while MXC is Active."); + } + var expected = HMACSHA256.HashData( + _secret, + Encoding.UTF8.GetBytes(string.Join( + "\n", + record.Contract, + "active", + generation, + policyFingerprint, + record.ExpiresAtUnixMs.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + byte[] actual; + try { actual = Convert.FromHexString(record.Signature); } + catch (FormatException) + { + throw new CanvasPolicyException("CANVAS_LEASE_SIGNATURE", "Canvas activation lease signature is malformed."); + } + if (!CryptographicOperations.FixedTimeEquals(actual, expected)) + throw new CanvasPolicyException("CANVAS_LEASE_SIGNATURE", "Canvas activation lease signature is invalid."); + return new ValidatedCanvasLease(record.Signature, record.ExpiresAtUnixMs); + } + + public void RevalidateActive(ValidatedCanvasLease expected) + { + if (ValidateActive() != expected) + throw new CanvasPolicyException("CANVAS_LEASE_CHANGED", "Canvas activation changed before execution."); + } + + public bool IsActive() + { + try { ValidateActive(); return true; } + catch (CanvasPolicyException) { return false; } + } +} + +internal sealed record CanvasLeaseRecord( + string Contract, + string Mode, + string GatewayGeneration, + string PolicyFingerprint, + long ExpiresAtUnixMs, + string Signature); + +internal sealed class CanvasPolicyException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} + +internal sealed class CanvasLogger : IOpenClawLogger +{ + public void Info(string message) => Console.Error.WriteLine($"[canvas-host][info] {message}"); + public void Debug(string message) { } + public void Warn(string message) => Console.Error.WriteLine($"[canvas-host][warn] {message}"); + public void Error(string message, Exception? ex = null) => + Console.Error.WriteLine($"[canvas-host][error] {message}{(ex is null ? string.Empty : $": {ex.GetType().Name}")}"); +} diff --git a/windows-node-canvas-host/CompatibilityShims.cs b/windows-node-canvas-host/CompatibilityShims.cs new file mode 100644 index 0000000..b3f51d0 --- /dev/null +++ b/windows-node-canvas-host/CompatibilityShims.cs @@ -0,0 +1,32 @@ +namespace OpenClawTray.Helpers +{ + public static class LocalizationHelper + { + public static string Locale { get; set; } = "en-US"; + + public static string GetString(string key) + { + var zh = Locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase); + return key switch + { + "A2UI_CanvasTitle" => zh ? "MicroClaw 画布" : "MicroClaw Canvas", + "Canvas_WaitingForContent" => zh ? "等待安全画布内容" : "Waiting for secure Canvas content", + "A2UI_TabFallback" => zh ? "画布" : "Canvas", + "A2UI_ModalClose" => zh ? "关闭" : "Close", + "A2UI_DateTimeSelectDate" => zh ? "选择日期" : "Select date", + "A2UI_MultipleChoiceSelect" => zh ? "请选择" : "Select", + "A2UI_UnsupportedComponent" => zh ? "不支持的组件:{0}" : "Unsupported component: {0}", + _ => key, + }; + } + } +} + +namespace OpenClawTray.Services +{ + public static class Logger + { + public static void Debug(string message) => Console.Error.WriteLine($"[canvas-host][debug] {message}"); + public static void Warn(string message) => Console.Error.WriteLine($"[canvas-host][warn] {message}"); + } +} diff --git a/windows-node-canvas-host/MicroClaw.WindowsNodeCanvasHost.csproj b/windows-node-canvas-host/MicroClaw.WindowsNodeCanvasHost.csproj new file mode 100644 index 0000000..aee21e9 --- /dev/null +++ b/windows-node-canvas-host/MicroClaw.WindowsNodeCanvasHost.csproj @@ -0,0 +1,44 @@ + + + WinExe + net10.0-windows10.0.22621.0 + win-x64;win-arm64 + true + enable + enable + None + true + app.manifest + microclaw-windows-node-canvas-host + MicroClaw.WindowsNodeCanvasHost + + + + + + + + + + + + + + + + + + + diff --git a/windows-node-canvas-host/app.manifest b/windows-node-canvas-host/app.manifest new file mode 100644 index 0000000..e5ee212 --- /dev/null +++ b/windows-node-canvas-host/app.manifest @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/windows-node-host.Tests/ApprovalProofTests.cs b/windows-node-host.Tests/ApprovalProofTests.cs index 31f39d0..8e20a9c 100644 --- a/windows-node-host.Tests/ApprovalProofTests.cs +++ b/windows-node-host.Tests/ApprovalProofTests.cs @@ -57,6 +57,24 @@ public void RejectsSameApprovalIdWithAnotherValidNonce() Assert.Equal("approval-proof-replayed", replay.Code); } + [Fact] + public void RevalidationRejectsProofThatExpiresBeforeLaunch() + { + var now = IssuedAtUnixMs; + var verifier = new ApprovalProofVerifier( + Secret, + "generation-test-001", + PolicyFingerprint, + NodeId, + () => now); + var validated = verifier.ValidateAndConsume(Args(VectorProof()), CurrentIdentity); + + now = validated.ExpiresAtUnixMs; + var error = Assert.Throws(() => verifier.Revalidate(validated)); + + Assert.Equal("approval-proof-stale", error.Code); + } + [Fact] public async Task ConcurrentConsumptionAllowsExactlyOneInvocation() { diff --git a/windows-node-host.Tests/CanvasPhase1Tests.cs b/windows-node-host.Tests/CanvasPhase1Tests.cs new file mode 100644 index 0000000..d23d7ec --- /dev/null +++ b/windows-node-host.Tests/CanvasPhase1Tests.cs @@ -0,0 +1,302 @@ +using System.Security.Cryptography; +using System.Text.Json; +using MicroClaw.WindowsNodeHost; +using OpenClaw.Shared; +using Xunit; + +namespace MicroClaw.WindowsNodeHost.Tests; + +public sealed class CanvasPhase1Tests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "microclaw-canvas-tests-" + Guid.NewGuid().ToString("N")); + + public CanvasPhase1Tests() => Directory.CreateDirectory(_root); + + [Fact] + public void AdvertisesExactReviewedCommandsAndActions() + { + Assert.Equal( + [ + "canvas.present", + "canvas.hide", + "canvas.snapshot", + "canvas.caps", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "canvas.a2ui.dump", + ], + CanvasPhase1Policy.Commands); + Assert.DoesNotContain("canvas.eval", CanvasPhase1Policy.Commands); + Assert.DoesNotContain("canvas.navigate", CanvasPhase1Policy.Commands); + Assert.Equal(["cancel", "click", "confirm", "submit"], CanvasPhase1Policy.Actions); + } + + [Fact] + public async Task CanvasHostTreeHashChangesWithAnyResource() + { + var hostDirectory = Path.Combine(_root, "canvas-host"); + Directory.CreateDirectory(Path.Combine(hostDirectory, "nested")); + await File.WriteAllTextAsync( + Path.Combine(hostDirectory, "host.exe"), + "apphost", + TestContext.Current.CancellationToken); + var dependency = Path.Combine(hostDirectory, "nested", "host.dll"); + await File.WriteAllTextAsync( + dependency, + "managed-code", + TestContext.Current.CancellationToken); + var original = CanvasHostBroker.ComputeDirectoryTreeHash(hostDirectory); + Assert.Equal(original, CanvasHostBroker.ComputeDirectoryTreeHash(hostDirectory)); + await File.WriteAllTextAsync( + dependency, + "changed-code", + TestContext.Current.CancellationToken); + Assert.NotEqual(original, CanvasHostBroker.ComputeDirectoryTreeHash(hostDirectory)); + } + + [Fact] + public void StandaloneCanvasShellDoesNotLoadPinnedWindowXamlOrTabView() + { + var repoRoot = FindRepoRoot(); + var project = File.ReadAllText( + Path.Combine( + repoRoot, + "windows-node-canvas-host", + "MicroClaw.WindowsNodeCanvasHost.csproj")); + var compatibilityShell = File.ReadAllText( + Path.Combine( + repoRoot, + "windows-node-canvas-host", + "A2UICanvasWindow.Compatibility.cs")); + var projectXml = System.Xml.Linq.XDocument.Parse(project); + Assert.DoesNotContain( + projectXml.Descendants().Where(element => element.Name.LocalName == "Page"), + element => element.Attribute("Include")?.Value.EndsWith( + "A2UICanvasWindow.xaml", + StringComparison.OrdinalIgnoreCase) == true); + Assert.Contains("void InitializeComponent()", compatibilityShell); + Assert.Contains("private StandaloneTabView MultiSurfaceTabs", compatibilityShell); + Assert.Contains("internal sealed class TabViewItem : StackPanel", compatibilityShell); + Assert.DoesNotContain("Microsoft.UI.Xaml.Controls.TabView", compatibilityShell); + } + + [Theory] + [InlineData("""{"url":"file:///C:/secret.txt"}""")] + [InlineData("""{"target":"https://example.com"}""")] + [InlineData("""{"html":"

unsafe

"}""")] + [InlineData("""{"path":"C:\\secret.txt"}""")] + public void PresentRejectsNavigationAndLocalContent(string json) + { + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateCommand("canvas.present", Parse(json))); + Assert.Equal("canvas-present-trusted-origin-only", error.Code); + } + + [Theory] + [InlineData("file:///C:/secret.png")] + [InlineData("\\\\server\\share\\secret.png")] + [InlineData("C:\\secret.png")] + [InlineData("../secret.png")] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html,unsafe")] + [InlineData("ftp://example.com/image.png")] + [InlineData("https://example.com/image.png")] + public void A2uiRejectsUnapprovedMediaReferences(string reference) + { + var jsonl = JsonSerializer.Serialize(new + { + surfaceUpdate = new + { + surfaceId = "main", + components = new[] { new { id = "image", component = new { Image = new { url = reference } } } }, + }, + }); + var args = Parse(JsonSerializer.Serialize(new { jsonl })); + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateCommand("canvas.a2ui.push", args)); + Assert.True( + new[] + { + "canvas-local-file-denied", + "canvas-media-scheme-denied", + "canvas-media-host-not-allowed", + }.Contains(error.Code), + error.Code); + } + + [Fact] + public void A2uiAllowsBoundedInlineImageButRejectsJsonlPath() + { + var jsonl = """{"surfaceUpdate":{"surfaceId":"main","components":[{"id":"image","component":{"Image":{"url":{"literalString":"data:image/png;base64,AA=="}}}},{"id":"text","component":{"Text":{"text":{"path":"/title"}}}}]}}"""; + CanvasPhase1Policy.ValidateCommand( + "canvas.a2ui.pushJSONL", + Parse(JsonSerializer.Serialize(new { jsonl }))); + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateCommand( + "canvas.a2ui.push", + Parse("""{"jsonlPath":"C:\\payload.jsonl"}"""))); + Assert.Equal("canvas-local-file-denied", error.Code); + } + + [Theory] + [InlineData("data:image/svg+xml;base64,PHN2Zy8+")] + [InlineData("data:image/png-evil;base64,AA==")] + [InlineData("data:image/png,raw")] + public void A2uiRejectsUnapprovedInlineImageForms(string reference) + { + var jsonl = JsonSerializer.Serialize(new + { + surfaceUpdate = new + { + surfaceId = "main", + components = new[] + { + new + { + id = "image", + component = new + { + Image = new + { + url = new { literalString = reference }, + }, + }, + }, + }, + }, + }); + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateCommand( + "canvas.a2ui.push", + Parse(JsonSerializer.Serialize(new { jsonl })))); + Assert.Equal("canvas-media-scheme-denied", error.Code); + } + + [Fact] + public void A2uiRejectsDataModelBoundMedia() + { + var jsonl = """{"surfaceUpdate":{"surfaceId":"main","components":[{"id":"image","component":{"Image":{"url":{"path":"/image"}}}}]}}"""; + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateCommand( + "canvas.a2ui.push", + Parse(JsonSerializer.Serialize(new { jsonl })))); + Assert.Equal("canvas-media-binding-denied", error.Code); + } + + [Theory] + [InlineData("click")] + [InlineData("submit")] + [InlineData("confirm")] + [InlineData("cancel")] + public void ReviewedActionsAreAllowed(string action) => + CanvasPhase1Policy.ValidateAction(action); + + [Theory] + [InlineData("system.run")] + [InlineData("read")] + [InlineData("write")] + [InlineData("gateway.config.set")] + [InlineData("cron.add")] + [InlineData("hooks.run")] + [InlineData("plugin.install")] + [InlineData("canvas.eval")] + public void DangerousActionsAreRejected(string action) + { + var error = Assert.Throws( + () => CanvasPhase1Policy.ValidateAction(action)); + Assert.Equal("canvas-action-denied", error.Code); + } + + [Fact] + public async Task DiagnosticLeaseRejectsBeforeCanvasHostLaunch() + { + var leasePath = Path.Combine(_root, "lease.json"); + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + const string generation = "gateway-generation"; + var fingerprint = new string('a', 64); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds(); + await File.WriteAllTextAsync( + leasePath, + JsonSerializer.Serialize(new + { + contract = ActivationLeaseContract.Version, + mode = "diagnostic", + gatewayGeneration = generation, + policyFingerprint = fingerprint, + expiresAtUnixMs = expiresAt, + signature = ActivationLeaseGuard.ComputeSignature( + secret, + ActivationLeaseMode.Diagnostic, + generation, + fingerprint, + expiresAt), + }), + TestContext.Current.CancellationToken); + var guard = new ActivationLeaseGuard(leasePath, secret, generation, fingerprint); + var policy = new HostPolicy + { + ApprovedRoots = [], + DeniedRoots = [], + WxcExecPath = Path.Combine(Environment.SystemDirectory, "hostname.exe"), + NetworkAllowed = true, + AllowWindowsUi = true, + StrictNoHostFallback = true, + Canvas = new CanvasHostPolicy( + Path.Combine(_root, "missing-canvas-host.exe"), + new string('0', 64), + new string('1', 64), + CanvasPhase1Policy.Commands, + CanvasPhase1Policy.Actions), + }; + using var broker = new CanvasHostBroker( + policy, + leasePath, + secret, + generation, + fingerprint, + new string('b', 64), + "en-US", + guard, + _ => Task.FromResult(true)); + var capability = new BundledCanvasCapability(broker, guard); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "canvas.present", + Args = Parse("{}"), + }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("activation-lease-active-required", response.Error); + Assert.False(File.Exists(policy.Canvas.HostPath)); + } + + private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private static string FindRepoRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); + current != null; + current = current.Parent) + { + if ((Directory.Exists(Path.Combine(current.FullName, ".git")) + || File.Exists(Path.Combine(current.FullName, ".git"))) + && Directory.Exists(Path.Combine(current.FullName, "windows-node-canvas-host"))) + { + return current.FullName; + } + } + + throw new DirectoryNotFoundException("Could not locate the MicroClaw repository root."); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } +} diff --git a/windows-node-host.Tests/CwdPolicyTests.cs b/windows-node-host.Tests/CwdPolicyTests.cs index 89d1823..72bab62 100644 --- a/windows-node-host.Tests/CwdPolicyTests.cs +++ b/windows-node-host.Tests/CwdPolicyTests.cs @@ -12,6 +12,8 @@ namespace MicroClaw.WindowsNodeHost.Tests; public sealed class CwdPolicyTests : IDisposable { + private static readonly Func NoopIsolationGuard = + _ => Task.CompletedTask; private readonly string _root = Path.Combine(Path.GetTempPath(), "microclaw-cwd-tests-" + Guid.NewGuid().ToString("N")); public CwdPolicyTests() @@ -213,6 +215,16 @@ public void AttestationRequiresEverySecurityProperty() Assert.True(attestation.ReadinessProofBindsPreparedPlan); Assert.True(attestation.ReadinessProofExactBuiltInsOnly); Assert.True(attestation.ReadinessProofOneUse); + Assert.True(attestation.OutboundNetworkEnabled); + Assert.True(attestation.CanvasPhase1Enabled); + Assert.True(attestation.CanvasExactCommandSet); + Assert.True(attestation.CanvasExactActionSet); + Assert.True(attestation.CanvasNativeA2uiOnly); + Assert.True(attestation.CanvasHostTreeBound); + Assert.True(attestation.CanvasEvalAbsent); + Assert.True(attestation.CanvasNavigationAbsent); + Assert.True(attestation.CanvasLocalFilesAbsent); + Assert.True(attestation.CanvasSurfaceOnlySnapshot); Assert.False(attestation.DurableApprovalsPresent); } @@ -249,6 +261,7 @@ public void AttestationUsesTheVersionedCamelCaseWireContract() root.GetProperty("approvalProofPlanContract").GetString()); Assert.True(root.GetProperty("approvalProofBindsExecutableContent").GetBoolean()); Assert.True(root.GetProperty("approvalProofBindsActivation").GetBoolean()); + Assert.True(root.GetProperty("launchTimeApprovalProofRevalidation").GetBoolean()); Assert.Equal( "microclaw.windows-node-readiness.v1", root.GetProperty("readinessProofContract").GetString()); @@ -256,6 +269,9 @@ public void AttestationUsesTheVersionedCamelCaseWireContract() Assert.True(root.GetProperty("readinessProofBindsPreparedPlan").GetBoolean()); Assert.True(root.GetProperty("readinessProofExactBuiltInsOnly").GetBoolean()); Assert.True(root.GetProperty("readinessProofOneUse").GetBoolean()); + Assert.True(root.GetProperty("launchTimeReadinessProofRevalidation").GetBoolean()); + Assert.True(root.GetProperty("exactFilesystemIsolationRequired").GetBoolean()); + Assert.True(root.GetProperty("degradedIsolationTierRejected").GetBoolean()); Assert.True(root.GetProperty("durableApprovalStoreProtected").GetBoolean()); Assert.False(root.GetProperty("durableApprovalsPresent").GetBoolean()); Assert.False(root.TryGetProperty("Contract", out _)); @@ -270,7 +286,8 @@ public async Task LoadedPolicyMustMatchTheBootstrapFingerprint() approvedRoots = new[] { new { path = _root, access = "ReadOnly" } }, deniedRoots = Array.Empty(), wxcExecPath = Path.Combine(Environment.SystemDirectory, "hostname.exe"), - networkAllowed = false, + canvas = CreateCanvasPolicyInput(Path.Combine(Environment.SystemDirectory, "hostname.exe")), + networkAllowed = true, allowWindowsUi = true, clipboard = "none", inputInjection = false, @@ -347,14 +364,7 @@ await WriteActivationLease( "policy-1", expiresAt); var guard = new ActivationLeaseGuard(leasePath, secret, "gateway-1", "policy-1"); - var smoke = new[] - { - @"C:\Windows\System32\cmd.exe", - "/d", - "/s", - "/c", - @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", - }; + var smoke = ReadinessProbeContract.Probes["execution"]; var validated = guard.Validate(smoke); Assert.Equal( @@ -410,7 +420,8 @@ await File.WriteAllTextAsync( ], "deniedRoots": [], "wxcExecPath": {{System.Text.Json.JsonSerializer.Serialize(wxc)}}, - "networkAllowed": false, + "canvas": {{System.Text.Json.JsonSerializer.Serialize(CreateCanvasPolicyInput(wxc))}}, + "networkAllowed": true, "allowWindowsUi": true, "clipboard": "none", "inputInjection": false, @@ -422,6 +433,37 @@ await File.WriteAllTextAsync( var policy = await HostPolicy.LoadAsync(policyPath); Assert.Equal(FolderAccess.ReadOnly, Assert.Single(policy.ApprovedRoots).Access); + Assert.True(policy.NetworkAllowed); + } + + [Fact] + public async Task RejectsPolicyWithoutContainedOutboundNetwork() + { + var wxc = Path.Combine(Environment.SystemDirectory, "hostname.exe"); + var policyPath = Path.Combine(_root, "network-disabled-policy.json"); + await File.WriteAllTextAsync( + policyPath, + $$""" + { + "approvedRoots": [ + { "path": {{System.Text.Json.JsonSerializer.Serialize(_root)}}, "access": "ReadOnly" } + ], + "deniedRoots": [], + "wxcExecPath": {{System.Text.Json.JsonSerializer.Serialize(wxc)}}, + "canvas": {{System.Text.Json.JsonSerializer.Serialize(CreateCanvasPolicyInput(wxc))}}, + "networkAllowed": false, + "allowWindowsUi": true, + "clipboard": "none", + "inputInjection": false, + "strictNoHostFallback": true + } + """, + TestContext.Current.CancellationToken); + + var error = await Assert.ThrowsAsync( + () => HostPolicy.LoadAsync(policyPath)); + + Assert.Equal("outbound-network-disabled", error.Code); } [Fact] @@ -460,7 +502,8 @@ await File.WriteAllTextAsync( ], "deniedRoots": [{{System.Text.Json.JsonSerializer.Serialize(protectedRoot)}}], "wxcExecPath": {{System.Text.Json.JsonSerializer.Serialize(wxc)}}, - "networkAllowed": false, + "canvas": {{System.Text.Json.JsonSerializer.Serialize(CreateCanvasPolicyInput(wxc))}}, + "networkAllowed": true, "allowWindowsUi": true, "clipboard": "none", "inputInjection": false, @@ -491,7 +534,8 @@ await File.WriteAllTextAsync( ], "deniedRoots": [{{System.Text.Json.JsonSerializer.Serialize(protectedRoot)}}], "wxcExecPath": {{System.Text.Json.JsonSerializer.Serialize(wxc)}}, - "networkAllowed": false, + "canvas": {{System.Text.Json.JsonSerializer.Serialize(CreateCanvasPolicyInput(wxc))}}, + "networkAllowed": true, "allowWindowsUi": true, "clipboard": "none", "inputInjection": false, @@ -660,7 +704,8 @@ public async Task InvalidDeclarationStopsBeforePromptOrDurableApproval( Path.Combine(_root, "missing-lease.json"), Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), "unused-generation", - "unused-fingerprint")); + "unused-fingerprint"), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -740,6 +785,244 @@ public void OmittedDeclarationCannotExpandMxcFilesystemPolicy() Assert.DoesNotContain( readonlyPaths.Concat(readwritePaths), item => string.Equals(item, outside, StringComparison.OrdinalIgnoreCase)); + Assert.Equal( + ["internetClient"], + Assert.IsType(config.ProcessContainer!.Capabilities)); + Assert.Equal("allow", config.Network!.DefaultPolicy); + Assert.Equal("capabilities", config.Network.EnforcementMode); + } + + [Fact] + public void DeniedRootsOverrideApprovedRootsWhenBuildingMxcPolicy() + { + var denied = Directory.CreateDirectory(Path.Combine(_root, "approved", "sensitive")).FullName; + var approved = Directory.GetParent(denied)!.FullName; + var scratch = Directory.CreateDirectory(Path.Combine(_root, "scratch-denied")).FullName; + + var error = Assert.Throws(() => + BundledSystemCapability.BuildMxcConfig( + Policy( + [new ApprovedRoot(approved, FolderAccess.ReadWrite)], + [WindowsPathCanonicalizer.CanonicalizeDirectory(denied)]), + Path.Combine(Environment.SystemDirectory, "hostname.exe"), + [], + approved, + scratch, + 30_000)); + + Assert.Equal("approved-root-overlaps-sensitive-root", error.Code); + } + + [Fact] + public void FilesystemPolicyKeepsApprovalSeparateFromConfiguredRootCapabilities() + { + var desktop = Directory.CreateDirectory(Path.Combine(_root, "Desktop")).FullName; + var nestedDesktop = Directory.CreateDirectory(Path.Combine(desktop, "nested")).FullName; + var downloads = Directory.CreateDirectory(Path.Combine(_root, "Downloads")).FullName; + var scratch = Directory.CreateDirectory(Path.Combine(_root, "scratch-boundary")).FullName; + var executable = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + var arguments = new[] + { + "-NoProfile", + "-Command", + $"Set-Content -Path '{Path.Combine(downloads, "outside.txt")}' -Value denied", + }; + + var desktopReadWrite = BundledSystemCapability.BuildMxcConfig( + Policy([new ApprovedRoot(desktop, FolderAccess.ReadWrite)]), + executable, + arguments, + nestedDesktop, + scratch, + 30_000); + Assert.Equal([desktop, scratch], desktopReadWrite.Filesystem!.ReadwritePaths!); + Assert.Empty(desktopReadWrite.Filesystem.ReadonlyPaths!); + Assert.DoesNotContain( + downloads, + desktopReadWrite.Filesystem.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + using (var serialized = JsonDocument.Parse( + BundledSystemCapability.SerializeMxcConfig(desktopReadWrite))) + { + Assert.False( + serialized.RootElement + .GetProperty("fallback") + .GetProperty("allowDaclMutation") + .GetBoolean()); + } + using (var serialized = JsonDocument.Parse( + BundledSystemCapability.SerializeMxcConfig( + desktopReadWrite, + allowDaclMutation: true))) + { + Assert.True( + serialized.RootElement + .GetProperty("fallback") + .GetProperty("allowDaclMutation") + .GetBoolean()); + } + + var desktopReadOnly = BundledSystemCapability.BuildMxcConfig( + Policy([new ApprovedRoot(desktop, FolderAccess.ReadOnly)]), + executable, + arguments, + desktop, + scratch, + 30_000); + Assert.Equal([desktop], desktopReadOnly.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain( + desktop, + desktopReadOnly.Filesystem.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + + var downloadsReadWrite = BundledSystemCapability.BuildMxcConfig( + Policy( + [ + new ApprovedRoot(desktop, FolderAccess.ReadWrite), + new ApprovedRoot(downloads, FolderAccess.ReadWrite), + ]), + executable, + arguments, + desktop, + scratch, + 30_000); + Assert.Contains( + downloads, + downloadsReadWrite.Filesystem!.ReadwritePaths!, + StringComparer.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("base-container")] + [InlineData("appcontainer-bfs")] + public void ExactFilesystemIsolationTiersAreAccepted(string tier) + { + var actual = MxcIsolationTierGuard.ValidateProbeResult( + 0, + $$"""{"tier":{{JsonSerializer.Serialize(tier)}},"needsDaclAugmentation":false}""", + string.Empty); + + Assert.Equal(tier, actual); + } + + [Theory] + [InlineData("appcontainer-dacl", false)] + [InlineData("base-container", true)] + [InlineData("future-tier", false)] + public void DegradedFilesystemIsolationTiersFailClosed(string tier, bool needsDaclAugmentation) + { + var error = Assert.Throws(() => + MxcIsolationTierGuard.ValidateProbeResult( + 0, + $$"""{"tier":{{JsonSerializer.Serialize(tier)}},"needsDaclAugmentation":{{JsonSerializer.Serialize(needsDaclAugmentation)}}}""", + string.Empty)); + + Assert.Equal("mxc-filesystem-isolation-insufficient", error.Code); + } + + [Fact] + public void LimitedContainmentAcceptsOnlyAppContainerDacl() + { + var accepted = MxcIsolationTierGuard.ValidateProbeResult( + 0, + """{"tier":"appcontainer-dacl","needsDaclAugmentation":true}""", + string.Empty, + "limited-dacl", + "appcontainer-dacl"); + Assert.Equal("appcontainer-dacl", accepted); + + var error = Assert.Throws(() => + MxcIsolationTierGuard.ValidateProbeResult( + 0, + """{"tier":"base-container","needsDaclAugmentation":false}""", + string.Empty, + "limited-dacl", + "appcontainer-dacl")); + Assert.Equal("mxc-filesystem-isolation-drift", error.Code); + } + + [Fact] + public void LimitedAmbientAssessmentBlocksSensitiveAccessAndBindsAttestation() + { + var deniedEntries = new[] + { + new AmbientAccessEntry("desktop", "denied", Path.Combine(_root, "Desktop")), + new AmbientAccessEntry("documents", "denied", Path.Combine(_root, "Documents")), + new AmbientAccessEntry("downloads", "read-write", Path.Combine(_root, "Downloads")), + new AmbientAccessEntry("microclaw-state", "denied", Path.Combine(_root, "MicroClaw")), + new AmbientAccessEntry("openclaw-state", "denied", Path.Combine(_root, "OpenClaw")), + new AmbientAccessEntry( + "openclaw-roaming-state", + "denied", + Path.Combine(_root, "OpenClawRoaming")), + new AmbientAccessEntry("ssh", "denied", Path.Combine(_root, ".ssh")), + new AmbientAccessEntry("gnupg", "denied", Path.Combine(_root, ".gnupg")), + new AmbientAccessEntry("aws", "denied", Path.Combine(_root, ".aws")), + new AmbientAccessEntry("azure", "denied", Path.Combine(_root, ".azure")), + new AmbientAccessEntry("kube", "denied", Path.Combine(_root, ".kube")), + new AmbientAccessEntry("gcloud", "denied", Path.Combine(_root, "gcloud")), + new AmbientAccessEntry( + "windows-credentials", + "denied", + Path.Combine(_root, "Credentials")), + new AmbientAccessEntry( + "powershell-history", + "denied", + Path.Combine(_root, "PSReadLine")), + new AmbientAccessEntry("browser-chrome", "denied", Path.Combine(_root, "Chrome")), + new AmbientAccessEntry("browser-edge", "denied", Path.Combine(_root, "Edge")), + new AmbientAccessEntry("browser-brave", "denied", Path.Combine(_root, "Brave")), + new AmbientAccessEntry("browser-firefox", "denied", Path.Combine(_root, "Firefox")), + }; + var policy = new AmbientAccessPolicy( + AmbientAccessAssessmentContract.Version, + AmbientAccessAssessment.ComputeHash(deniedEntries), + deniedEntries); + AmbientAccessAssessment.ValidatePolicy(policy); + + var hostPolicy = new HostPolicy + { + ApprovedRoots = [], + DeniedRoots = [], + WxcExecPath = Path.Combine(Environment.SystemDirectory, "hostname.exe"), + Canvas = CreateCanvasHostPolicy(), + AllowWindowsUi = true, + StrictNoHostFallback = true, + RequestedStrength = "limited-dacl", + EffectiveTier = "appcontainer-dacl", + AmbientAccess = policy, + AmbientAssessmentScratchRoot = _root, + }; + var attestation = hostPolicy.Attestation; + Assert.False(attestation.ExactFilesystemIsolationRequired); + Assert.False(attestation.FolderPolicyIsExclusiveBoundary); + Assert.False(attestation.DurableApprovalsAllowed); + Assert.True(attestation.AmbientAssessmentBound); + + var accessibleEntries = deniedEntries + .Select(entry => + entry.Category == "ssh" ? entry with { Status = "read" } : entry) + .ToArray(); + var error = Assert.Throws(() => + AmbientAccessAssessment.ValidatePolicy( + new AmbientAccessPolicy( + AmbientAccessAssessmentContract.Version, + AmbientAccessAssessment.ComputeHash(accessibleEntries), + accessibleEntries))); + Assert.Equal("ambient-sensitive-access", error.Code); + } + + [Fact] + public void LockedExecutableContentMustMatchExpectedHash() + { + var executable = Path.Combine(_root, "fake-wxc-exec.exe"); + File.WriteAllText(executable, "not the pinned runtime"); + using var lease = ExecutableApprovalLease.Acquire(executable); + + var error = Assert.Throws(() => + lease.VerifySha256(new string('0', 64))); + + Assert.Equal("executable-hash-mismatch", error.Code); } [Fact] @@ -770,15 +1053,9 @@ await WriteActivationLease( Policy([new ApprovedRoot(desktop, FolderAccess.ReadWrite)]), pipeName, approvalsPath, - new ActivationLeaseGuard(leasePath, secret, generation, fingerprint)); - var command = new[] - { - @"C:\Windows\System32\cmd.exe", - "/d", - "/s", - "/c", - @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", - }; + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: NoopIsolationGuard); + var command = ReadinessProbeContract.Probes["execution"]; var commandText = WindowsCommandLine.Join(command); var commandPreview = $"# [declare-access]rw:{desktop}[/declare-access]\n{command[^1]}"; @@ -813,7 +1090,7 @@ await WriteActivationLease( Assert.Equal("rw", approval.GetProperty("declaredAccess")[0].GetProperty("access").GetString()); Assert.Equal(desktop, approval.GetProperty("declaredAccess")[0].GetProperty("path").GetString(), ignoreCase: true); Assert.Equal( - @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", + command[^1], approval.GetProperty("arguments").EnumerateArray().Last().GetString()); Assert.False(File.Exists(approvalsPath)); } @@ -845,8 +1122,9 @@ await WriteActivationLease( Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), pipeName, approvalsPath, - new ActivationLeaseGuard(leasePath, secret, generation, fingerprint)); - var command = ReadinessProbeContract.Probes["hostname"]; + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: NoopIsolationGuard); + var command = ReadinessProbeContract.Probes["execution"]; var commandText = WindowsCommandLine.Join(command); var response = await capability.ExecuteAsync( @@ -896,7 +1174,7 @@ await WriteActivationLease( 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - var command = ReadinessProbeContract.Probes["hostname"]; + var command = ReadinessProbeContract.Probes["execution"]; var commandText = WindowsCommandLine.Join(command); var capability = new BundledSystemCapability( Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), @@ -908,7 +1186,8 @@ await WriteActivationLease( generation, fingerprint, nodeId, - transitionId)); + transitionId), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -921,7 +1200,7 @@ await WriteActivationLease( rawCommand = commandText, agentId = ReadinessProbeContract.AgentId, sessionKey = ReadinessProbeContract.SessionKey(transitionId), - probeKind = "hostname", + probeKind = "execution", })), }, TestContext.Current.CancellationToken); @@ -932,6 +1211,58 @@ await WriteActivationLease( Assert.False(File.Exists(approvalsPath)); } + [Fact] + public async Task ReadinessPrepareCompletesAmbientGateBeforeReturningScratchPlan() + { + var proofSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var transitionId = Guid.NewGuid().ToString(); + var ambientCompleted = false; + var command = ReadinessProbeContract.Probes["execution"]; + var capability = new BundledSystemCapability( + Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), + "unused-readiness-pipe", + Path.Combine(_root, "unused-readiness-approvals.json"), + new ActivationLeaseGuard( + Path.Combine(_root, "unused-readiness-lease.json"), + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + "generation", + new string('a', 64)), + readinessProof: new ReadinessProofVerifier( + proofSecret, + "generation", + new string('a', 64), + new string('b', 64), + transitionId), + isolationGuard: NoopIsolationGuard, + ambientGuard: async cancellationToken => + { + await Task.Delay(25, cancellationToken); + ambientCompleted = true; + }); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = Parse(JsonSerializer.Serialize(new + { + command, + cwd = (string?)null, + agentId = ReadinessProbeContract.AgentId, + sessionKey = ReadinessProbeContract.SessionKey(transitionId), + })), + }, + TestContext.Current.CancellationToken); + + Assert.True(response.Ok, response.Error); + Assert.True(ambientCompleted); + var payload = JsonSerializer.SerializeToElement(response.Payload); + Assert.Equal( + CwdPolicyContract.ScratchBinding, + payload.GetProperty("plan").GetProperty("cwdBinding").GetString()); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("plan").GetProperty("cwd").ValueKind); + } + [Fact] public async Task ActiveDirectInvokeWithoutGatewayProofFailsBeforeNodePrompt() { @@ -967,7 +1298,8 @@ await WriteActivationLease( proofSecret, generation, fingerprint, - nodeId)); + nodeId), + isolationGuard: NoopIsolationGuard); var response = await capability.ExecuteAsync( new NodeInvokeRequest @@ -1011,6 +1343,60 @@ public async Task RunPrepareRejectsDeclarationAfterExecutableContent() Assert.Contains("declare-access-position-invalid", response.Error); } + [Fact] + public async Task IsolationTierDegradationAfterPrepareStopsBeforeApprovalOrExecution() + { + var leasePath = Path.Combine(_root, "degraded-tier-lease.json"); + var approvalsPath = Path.Combine(_root, "degraded-tier-approvals.json"); + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + const string generation = "degraded-tier-generation"; + const string fingerprint = "degraded-tier-policy"; + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Diagnostic, + generation, + fingerprint, + DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds()); + var strongTier = true; + Task Guard(CancellationToken _) + { + if (!strongTier) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + "MXC isolation degraded."); + return Task.CompletedTask; + } + var capability = new BundledSystemCapability( + Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]), + "missing-approval-pipe-" + Guid.NewGuid().ToString("N"), + approvalsPath, + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint), + isolationGuard: Guard); + var command = new[] { Path.Combine(Environment.SystemDirectory, "hostname.exe") }; + var args = Parse(JsonSerializer.Serialize(new + { + command, + rawCommand = WindowsCommandLine.Join(command), + agentId = "main", + sessionKey = "agent:main:degraded-tier", + })); + + var prepared = await capability.ExecuteAsync( + new NodeInvokeRequest { Command = "system.run.prepare", Args = args }, + TestContext.Current.CancellationToken); + Assert.True(prepared.Ok, prepared.Error); + + strongTier = false; + var response = await capability.ExecuteAsync( + new NodeInvokeRequest { Command = "system.run", Args = args }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("mxc-filesystem-isolation-insufficient", response.Error); + Assert.False(File.Exists(approvalsPath)); + } + [Fact] public async Task RunReplayRejectsDeclarationAfterExecutableContent() { @@ -1114,10 +1500,38 @@ private static HostPolicy Policy(IReadOnlyList roots, IReadOnlyLis ApprovedRoots = roots, DeniedRoots = denied ?? [], WxcExecPath = Path.Combine(Environment.SystemDirectory, "hostname.exe"), + Canvas = CreateCanvasHostPolicy(), + NetworkAllowed = true, AllowWindowsUi = true, StrictNoHostFallback = true, }; + private static CanvasHostPolicy CreateCanvasHostPolicy() => + new( + Path.Combine(Environment.SystemDirectory, "hostname.exe"), + new string('0', 64), + new string('1', 64), + CanvasPhase1Policy.Commands, + CanvasPhase1Policy.Actions); + + private static object CreateCanvasPolicyInput(string hostPath) => + new + { + enabled = true, + contract = CanvasPhase1Policy.Contract, + hostPath, + hostSha256 = new string('0', 64), + hostTreeSha256 = new string('1', 64), + renderer = CanvasPhase1Policy.Renderer, + commands = CanvasPhase1Policy.Commands, + actions = CanvasPhase1Policy.Actions, + allowEval = false, + allowNavigation = false, + allowLocalFiles = false, + allowRemoteMedia = false, + snapshotSurfaceOnly = true, + }; + private BundledSystemCapability Capability(HostPolicy policy, string uiLocale = "en-US") => new( policy, @@ -1128,7 +1542,8 @@ private BundledSystemCapability Capability(HostPolicy policy, string uiLocale = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), "unused-generation", "unused-fingerprint"), - uiLocale); + uiLocale, + isolationGuard: NoopIsolationGuard); private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); diff --git a/windows-node-host.Tests/ReadinessProofTests.cs b/windows-node-host.Tests/ReadinessProofTests.cs index 786b55d..262c240 100644 --- a/windows-node-host.Tests/ReadinessProofTests.cs +++ b/windows-node-host.Tests/ReadinessProofTests.cs @@ -14,7 +14,7 @@ public sealed class ReadinessProofTests Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); private static readonly string PolicyFingerprint = new('a', 64); private static readonly string NodeId = new('b', 64); - private static readonly string[] Argv = ReadinessProbeContract.Probes["hostname"]; + private static readonly string[] Argv = ReadinessProbeContract.Probes["execution"]; private static readonly DurableApproval CurrentIdentity = new( DurableApprovalIdentity.SchemaVersion, Argv[0], @@ -35,16 +35,33 @@ public void AcceptsExactProbeOnceAndRejectsReplay() var replay = Assert.Throws( () => verifier.ValidateAndConsume(args, CurrentIdentity)); - Assert.Equal("hostname", validated.ProbeKind); + Assert.Equal("execution", validated.ProbeKind); Assert.Equal( - "1cf9d0658d325540aba0d944d8d00937b2c86626a6a814cbc9017a2ab63c2c45", + "35fc1fc5eb08a374a0247f7dde073db0ca0578d26158bbd6fdc5110ac6e50bda", validated.PlanSha256); Assert.Equal( - "7c964f4e5c2c0f169eb295185edf317f609cbdfe105b29ec29980bebb577744d", + "223e2c971807546fe05478ebf9e28790b3b6339a92504e7f9fd6a559aab167c7", proof.Signature); Assert.Equal("readiness-proof-replayed", replay.Code); } + [Fact] + public void FreshProofsPermitRepeatedExecutionProbeButEachNonceRemainsOneUse() + { + var verifier = Verifier(); + var first = Sign(UnsignedProof(Args())); + var second = Sign(UnsignedProof(Args()) with + { + Nonce = "12345678-1234-4234-8234-123456789abe", + }); + + Assert.Equal("execution", verifier.ValidateAndConsume(Args(first), CurrentIdentity).ProbeKind); + Assert.Equal("execution", verifier.ValidateAndConsume(Args(second), CurrentIdentity).ProbeKind); + AssertCode( + "readiness-proof-replayed", + () => verifier.ValidateAndConsume(Args(second), CurrentIdentity)); + } + [Fact] public async Task ConcurrentConsumptionAllowsExactlyOneProbe() { @@ -72,6 +89,28 @@ await Task.WhenAll( Assert.Equal(23, results.Count(result => result == "readiness-proof-replayed")); } + [Fact] + public void RevalidationRejectsProofThatExpiresBeforeLaunch() + { + var now = IssuedAtUnixMs; + var verifier = new ReadinessProofVerifier( + Secret, + Generation, + PolicyFingerprint, + NodeId, + TransitionId, + () => now); + var args = Args(); + var validated = verifier.ValidateAndConsume( + Args(Sign(UnsignedProof(args))), + CurrentIdentity); + + now = validated.ExpiresAtUnixMs; + var error = Assert.Throws(() => verifier.Revalidate(validated)); + + Assert.Equal("readiness-proof-stale", error.Code); + } + [Theory] [InlineData("transition", "readiness-proof-transition-mismatch")] [InlineData("generation", "readiness-proof-generation-mismatch")] @@ -119,7 +158,7 @@ public void RejectsExpiredAlteredAndDeclarationBearingProofs() CurrentIdentity)); AssertCode( "readiness-proof-kind-mismatch", - () => Verifier().ValidateAndConsume(Args(proof, probeKind: "powershell"), CurrentIdentity)); + () => Verifier().ValidateAndConsume(Args(proof, probeKind: "hostname"), CurrentIdentity)); var declaredIdentity = CurrentIdentity with { @@ -158,7 +197,7 @@ private static ReadinessProofVerifier Verifier() => private static JsonElement Args( object? proof = null, - string probeKind = "hostname", + string probeKind = "execution", string? cwd = null, string? agentId = ReadinessProbeContract.AgentId, string? rawCommand = null) @@ -197,7 +236,7 @@ private static ReadinessProofRecord UnsignedProof(JsonElement args) => Generation, PolicyFingerprint, NodeId, - "hostname", + "execution", ApprovalProofVerifier.ComputePlanSha256(args, CurrentIdentity), IssuedAtUnixMs, IssuedAtUnixMs + 15_000, @@ -211,4 +250,39 @@ private static void AssertCode(string expected, Action action) var error = Assert.Throws(action); Assert.Equal(expected, error.Code); } + + [Fact] + public void ReadinessPreparationIsFreshBoundAndOneUse() + { + var now = IssuedAtUnixMs; + var guard = new ReadinessPreparationGuard(() => now); + guard.Record("execution", CurrentIdentity); + now += ReadinessPreparationGuard.MaximumAgeMs - 1; + + Assert.Equal( + ReadinessPreparationGuard.MaximumAgeMs - 1, + guard.ValidateAndConsume("execution", CurrentIdentity)); + AssertCode( + "readiness-preparation-required", + () => guard.ValidateAndConsume("execution", CurrentIdentity)); + } + + [Fact] + public void ReadinessPreparationRejectsStaleOrChangedLaunch() + { + var now = IssuedAtUnixMs; + var guard = new ReadinessPreparationGuard(() => now); + guard.Record("execution", CurrentIdentity); + now += ReadinessPreparationGuard.MaximumAgeMs + 1; + AssertCode( + "readiness-preparation-stale", + () => guard.ValidateAndConsume("execution", CurrentIdentity)); + + guard.Record("execution", CurrentIdentity); + AssertCode( + "readiness-preparation-mismatch", + () => guard.ValidateAndConsume( + "execution", + CurrentIdentity with { ExecutableSha256 = new string('d', 64) })); + } } diff --git a/windows-node-host/ActivationLease.cs b/windows-node-host/ActivationLease.cs index 45e4c78..e3d72c1 100644 --- a/windows-node-host/ActivationLease.cs +++ b/windows-node-host/ActivationLease.cs @@ -40,6 +40,49 @@ public sealed class ActivationLeaseGuard( private readonly byte[] _secret = DecodeSecret(secretBase64); public ValidatedActivationLease Validate(IReadOnlyList argv) + { + var lease = ValidateCore(); + if (lease.Mode is ActivationLeaseMode.Diagnostic + && !ReadinessProbeContract.Probes.Values.Any(expected => + ReadinessProbeContract.Matches(argv, expected))) + { + throw new HostPolicyException( + "activation-lease-diagnostic-scope", + "The diagnostic activation lease permits only MicroClaw's fixed contained smokes."); + } + + return lease; + } + + public ValidatedActivationLease ValidateActive() + { + var lease = ValidateCore(); + if (lease.Mode is not ActivationLeaseMode.Active) + throw new HostPolicyException( + "activation-lease-active-required", + "This capability is available only while MXC is Active."); + return lease; + } + + public void RevalidateActive(ValidatedActivationLease expected) + { + var current = ValidateActive(); + if (current != expected) + throw new HostPolicyException( + "activation-lease-changed", + "The MicroClaw activation lease changed before capability execution."); + } + + public void Revalidate(ValidatedActivationLease expected, IReadOnlyList argv) + { + var current = Validate(argv); + if (current != expected) + throw new HostPolicyException( + "activation-lease-changed", + "The MicroClaw activation lease changed before process launch."); + } + + private ValidatedActivationLease ValidateCore() { ActivationLeaseRecord lease; try @@ -108,15 +151,6 @@ public ValidatedActivationLease Validate(IReadOnlyList argv) "activation-lease-signature", "The activation lease signature is invalid."); - if (lease.Mode is ActivationLeaseMode.Diagnostic - && !ReadinessProbeContract.Probes.Values.Any(expected => - ReadinessProbeContract.Matches(argv, expected))) - { - throw new HostPolicyException( - "activation-lease-diagnostic-scope", - "The diagnostic activation lease permits only MicroClaw's fixed contained smokes."); - } - return new ValidatedActivationLease( lease.Mode, lease.GatewayGeneration, @@ -125,15 +159,6 @@ public ValidatedActivationLease Validate(IReadOnlyList argv) lease.Signature); } - public void Revalidate(ValidatedActivationLease expected, IReadOnlyList argv) - { - var current = Validate(argv); - if (current != expected) - throw new HostPolicyException( - "activation-lease-changed", - "The MicroClaw activation lease changed before process launch."); - } - public static string ComputeSignature( string secretBase64, ActivationLeaseMode mode, diff --git a/windows-node-host/AmbientAccessAssessment.cs b/windows-node-host/AmbientAccessAssessment.cs new file mode 100644 index 0000000..a9c2b98 --- /dev/null +++ b/windows-node-host/AmbientAccessAssessment.cs @@ -0,0 +1,312 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared.Mxc; + +namespace MicroClaw.WindowsNodeHost; + +public static class AmbientAccessAssessmentContract +{ + public const string Version = "microclaw.windows-node-mxc-ambient-access.v1"; +} + +public sealed record AmbientAccessTarget(string Category, string Path); + +public sealed record AmbientAccessEntry(string Category, string Status, string Path); + +public sealed record AmbientAccessAssessmentResult( + string Contract, + string Hash, + string CheckedAt, + IReadOnlyList Entries); + +public sealed record AmbientAccessAssessmentRequest( + string WxcExecPath, + string ScratchRoot, + IReadOnlyList Targets); + +public sealed record AmbientAccessPolicy( + string Contract, + string Hash, + IReadOnlyList Entries); + +internal static class AmbientAccessAssessment +{ + private static readonly HashSet RequiredCategories = + new(StringComparer.Ordinal) + { + "desktop", + "documents", + "downloads", + "microclaw-state", + "openclaw-state", + "openclaw-roaming-state", + "ssh", + "gnupg", + "aws", + "azure", + "kube", + "gcloud", + "windows-credentials", + "powershell-history", + "browser-chrome", + "browser-edge", + "browser-brave", + "browser-firefox", + }; + + private static readonly HashSet SensitiveCategories = + new(StringComparer.Ordinal) + { + "microclaw-state", + "openclaw-state", + "openclaw-roaming-state", + "ssh", + "gnupg", + "aws", + "azure", + "kube", + "gcloud", + "windows-credentials", + "powershell-history", + "browser-chrome", + "browser-edge", + "browser-brave", + "browser-firefox", + }; + + public static async Task RunAsync( + AmbientAccessAssessmentRequest request, + CancellationToken cancellationToken = default) + { + if (request.Targets.Count == 0) + throw new HostPolicyException( + "ambient-assessment-targets-missing", + "The Limited containment ambient-access assessment has no targets."); + var wxcExecPath = WindowsPathCanonicalizer.CanonicalizeFile(request.WxcExecPath); + var scratchRoot = WindowsPathCanonicalizer.CanonicalizeDirectory(request.ScratchRoot); + await MxcIsolationTierGuard.EnsureFilesystemIsolationAsync( + wxcExecPath, + "limited-dacl", + "appcontainer-dacl", + cancellationToken); + + var entries = new List(); + var categories = new HashSet(StringComparer.Ordinal); + foreach (var target in request.Targets.OrderBy(entry => entry.Category, StringComparer.Ordinal)) + { + if (!categories.Add(target.Category) + || string.IsNullOrWhiteSpace(target.Category) + || target.Category.Any(ch => !char.IsAsciiLetterOrDigit(ch) && ch != '-')) + throw new HostPolicyException( + "ambient-assessment-target-invalid", + "The Limited containment ambient-access assessment target is invalid."); + var targetPath = NormalizeAssessmentPath(target.Path); + var status = await AssessTargetAsync( + wxcExecPath, + scratchRoot, + targetPath, + cancellationToken); + entries.Add(new AmbientAccessEntry(target.Category, status, targetPath)); + } + if (!categories.SetEquals(RequiredCategories)) + throw new HostPolicyException( + "ambient-assessment-categories-invalid", + "The Limited containment ambient-access assessment must cover every required path category."); + + return new AmbientAccessAssessmentResult( + AmbientAccessAssessmentContract.Version, + ComputeHash(entries), + DateTimeOffset.UtcNow.ToString("O"), + entries); + } + + public static async Task RevalidateAsync( + HostPolicy policy, + CancellationToken cancellationToken = default) + { + if (!string.Equals(policy.RequestedStrength, "limited-dacl", StringComparison.Ordinal)) + return; + var expected = policy.AmbientAccess + ?? throw new HostPolicyException( + "ambient-assessment-required", + "Limited containment requires a current ambient-access assessment."); + var current = await RunAsync( + new AmbientAccessAssessmentRequest( + policy.WxcExecPath, + policy.AmbientAssessmentScratchRoot, + expected.Entries.Select(entry => + new AmbientAccessTarget(entry.Category, entry.Path)).ToArray()), + cancellationToken); + if (!string.Equals(current.Hash, expected.Hash, StringComparison.Ordinal) + || !current.Entries.Select(entry => (entry.Category, entry.Status)) + .SequenceEqual( + expected.Entries + .OrderBy(entry => entry.Category, StringComparer.Ordinal) + .Select(entry => (entry.Category, entry.Status)))) + throw new HostPolicyException( + "ambient-assessment-drift", + "Limited containment ambient filesystem access changed; a fresh explicit activation is required."); + EnsureSensitiveCategoriesDenied(current.Entries); + } + + public static string ComputeHash(IEnumerable entries) + { + var fields = new List { AmbientAccessAssessmentContract.Version }; + foreach (var entry in entries.OrderBy(entry => entry.Category, StringComparer.Ordinal)) + { + fields.Add(EncodeField("category", entry.Category)); + fields.Add(EncodeField("status", entry.Status)); + } + return Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', fields)))) + .ToLowerInvariant(); + } + + public static void ValidatePolicy(AmbientAccessPolicy policy) + { + if (!string.Equals(policy.Contract, AmbientAccessAssessmentContract.Version, StringComparison.Ordinal) + || !IsSha256(policy.Hash) + || policy.Entries.Count == 0 + || policy.Entries.Any(entry => + entry.Status is not ("denied" or "read" or "read-write") + || string.IsNullOrWhiteSpace(entry.Category) + || string.IsNullOrWhiteSpace(entry.Path)) + || policy.Entries.Select(entry => entry.Category).Distinct(StringComparer.Ordinal).Count() + != policy.Entries.Count + || !policy.Entries.Select(entry => entry.Category).ToHashSet(StringComparer.Ordinal) + .SetEquals(RequiredCategories) + || !string.Equals(ComputeHash(policy.Entries), policy.Hash, StringComparison.Ordinal)) + throw new HostPolicyException( + "ambient-assessment-invalid", + "The Limited containment ambient-access assessment is invalid."); + EnsureSensitiveCategoriesDenied(policy.Entries); + } + + private static async Task AssessTargetAsync( + string wxcExecPath, + string scratchRoot, + string targetPath, + CancellationToken cancellationToken) + { + var encodedPath = Convert.ToBase64String(Encoding.Unicode.GetBytes(targetPath)); + var sentinelName = $".microclaw-ambient-{Guid.NewGuid():N}.tmp"; + var script = + "$ErrorActionPreference='Stop';" + + $"$p=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{encodedPath}'));" + + "if (-not [IO.Directory]::Exists($p)) {" + + "$q=[IO.DirectoryInfo]::new($p).Parent; while ($null -ne $q -and -not $q.Exists) { $q=$q.Parent };" + + "if ($null -eq $q) { [Console]::Out.Write('denied'); exit 0 };" + + $"$s=[IO.Path]::Combine($q.FullName,'{sentinelName}.dir');" + + "try { [IO.Directory]::CreateDirectory($s) | Out-Null; [IO.Directory]::Delete($s); [Console]::Out.Write('read-write') }" + + "catch { try { if ([IO.Directory]::Exists($s)) { [IO.Directory]::Delete($s) } } catch {}; [Console]::Out.Write('denied') };" + + "exit 0 };" + + "try { $e=[IO.Directory]::EnumerateFileSystemEntries($p).GetEnumerator(); try { $null=$e.MoveNext() } finally { if ($e -is [IDisposable]) { $e.Dispose() } } }" + + "catch { [Console]::Out.Write('read'); exit 0 };" + + $"$s=[IO.Path]::Combine($p,'{sentinelName}');" + + "try { $f=[IO.File]::Open($s,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); $f.Dispose(); [IO.File]::Delete($s); [Console]::Out.Write('read-write') }" + + "catch { try { if ([IO.File]::Exists($s)) { [IO.File]::Delete($s) } } catch {}; [Console]::Out.Write('read') }"; + var powershell = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var scratch = Path.Combine(scratchRoot, $"ambient-{Guid.NewGuid():N}"); + Directory.CreateDirectory(scratch); + try + { + var config = new MxcConfig + { + Version = "0.7.0-alpha", + ContainerId = Guid.NewGuid().ToString("N"), + Process = new MxcProcess + { + CommandLine = WindowsCommandLine.Join( + [powershell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script]), + Cwd = scratch, + TimeoutMs = 30_000, + }, + ProcessContainer = new MxcProcessContainer + { + LeastPrivilege = false, + Capabilities = [], + Ui = new MxcBaseProcessUi + { + Isolation = "desktop", + DesktopSystemControl = false, + SystemSettings = "none", + Ime = false, + }, + }, + Filesystem = new MxcFilesystem + { + ReadonlyPaths = [], + ReadwritePaths = [scratch], + }, + Network = new MxcNetwork + { + DefaultPolicy = "block", + EnforcementMode = "capabilities", + }, + Ui = new MxcUi { Disable = false, Clipboard = "none", Injection = false }, + Lifecycle = new MxcLifecycle { DestroyOnExit = true, PreservePolicy = false }, + }; + var configPath = Path.Combine(scratch, "ambient-config.json"); + await File.WriteAllTextAsync( + configPath, + BundledSystemCapability.SerializeMxcConfig(config, allowDaclMutation: true), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); + var result = await new MxcExecutor(wxcExecPath, 4096, 4096) + .RunWithConfigFileAsync(configPath, cancellationToken, workingDirectory: scratch); + var status = result.Output?.Trim(); + if (result.ExitCode != 0 + || result.TimedOut + || status is not ("denied" or "read" or "read-write")) + throw new HostPolicyException( + "ambient-assessment-failed", + "The Limited containment ambient-access assessment did not complete safely."); + return status; + } + finally + { + try { Directory.Delete(scratch, recursive: true); } catch { } + } + } + + private static void EnsureSensitiveCategoriesDenied(IEnumerable entries) + { + var accessible = entries + .Where(entry => + SensitiveCategories.Contains(entry.Category) + && !string.Equals(entry.Status, "denied", StringComparison.Ordinal)) + .Select(entry => entry.Category) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + if (accessible.Length > 0) + throw new HostPolicyException( + "ambient-sensitive-access", + $"Limited containment can access sensitive path categories: {string.Join(", ", accessible)}."); + } + + private static string NormalizeAssessmentPath(string value) + { + if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value)) + throw new HostPolicyException( + "ambient-assessment-target-invalid", + "The Limited containment ambient-access assessment target is invalid."); + var normalized = Path.GetFullPath(value); + if (normalized.Length < 3 || normalized[1] != ':' || normalized[2] != '\\') + throw new HostPolicyException( + "ambient-assessment-target-invalid", + "The Limited containment ambient-access assessment target must be a local drive path."); + return normalized.TrimEnd('\\'); + } + + private static string EncodeField(string name, string value) => + $"{name}={Encoding.UTF8.GetByteCount(value)}:{value}"; + + private static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(ch => char.IsAsciiHexDigit(ch)); +} diff --git a/windows-node-host/ApprovalProof.cs b/windows-node-host/ApprovalProof.cs index d85bb4d..dddd03d 100644 --- a/windows-node-host/ApprovalProof.cs +++ b/windows-node-host/ApprovalProof.cs @@ -173,6 +173,14 @@ public ValidatedApprovalProof ValidateAndConsume(JsonElement args, DurableApprov proof.ExpiresAtUnixMs); } + public void Revalidate(ValidatedApprovalProof proof) + { + if (proof.ExpiresAtUnixMs <= _nowUnixMs()) + throw new HostPolicyException( + "approval-proof-stale", + "The Gateway approval proof expired before process launch."); + } + public static string ComputePlanSha256(JsonElement args, DurableApproval currentIdentity) { if (!args.TryGetProperty("systemRunPlan", out var plan) diff --git a/windows-node-host/Approvals.cs b/windows-node-host/Approvals.cs index bb2ad18..c5783d0 100644 --- a/windows-node-host/Approvals.cs +++ b/windows-node-host/Approvals.cs @@ -151,5 +151,25 @@ public DurableApproval Capture( declaredAccess ?? [], _stream); + public void VerifySha256(string expectedSha256) + { + _stream.Position = 0; + var actualSha256 = Convert.ToHexString(SHA256.HashData(_stream)).ToLowerInvariant(); + _stream.Position = 0; + if (!Regexes.Sha256().IsMatch(expectedSha256) + || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(actualSha256), + Encoding.ASCII.GetBytes(expectedSha256.ToLowerInvariant()))) + throw new HostPolicyException( + "executable-hash-mismatch", + "The locked executable does not match the pinned content hash."); + } + public void Dispose() => _stream.Dispose(); } + +internal static partial class Regexes +{ + [System.Text.RegularExpressions.GeneratedRegex("^[a-f0-9]{64}$")] + public static partial System.Text.RegularExpressions.Regex Sha256(); +} diff --git a/windows-node-host/BundledCanvasCapability.cs b/windows-node-host/BundledCanvasCapability.cs new file mode 100644 index 0000000..f964d00 --- /dev/null +++ b/windows-node-host/BundledCanvasCapability.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using OpenClaw.Shared; + +namespace MicroClaw.WindowsNodeHost; + +internal sealed class BundledCanvasCapability( + CanvasHostBroker broker, + ActivationLeaseGuard activationLease) : INodeCapability +{ + public string Category => "canvas"; + public IReadOnlyList Commands => CanvasPhase1Policy.Commands; + public bool CanHandle(string command) => + CanvasPhase1Policy.Commands.Contains(command, StringComparer.Ordinal); + public Task ExecuteAsync(NodeInvokeRequest request) => + ExecuteAsync(request, CancellationToken.None); + + public async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + try + { + if (!CanHandle(request.Command)) + return Error("canvas-command-denied: Canvas command is outside the Phase 1 allowlist."); + var lease = activationLease.ValidateActive(); + var payload = await broker.ExecuteAsync( + request.Command, + request.Args, + lease, + activationLease, + cancellationToken); + return Success(payload ?? new { }); + } + catch (HostPolicyException ex) + { + return Error($"{ex.Code}: {ex.Message}"); + } + catch (OperationCanceledException) + { + return Error("canvas-cancelled: The Canvas invocation was cancelled."); + } + catch (Exception ex) + { + return Error($"canvas-failure: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static NodeInvokeResponse Success(object payload) => new() { Ok = true, Payload = payload }; + private static NodeInvokeResponse Error(string error) => new() { Ok = false, Error = error }; +} diff --git a/windows-node-host/BundledSystemCapability.cs b/windows-node-host/BundledSystemCapability.cs index f654a1d..ca03c79 100644 --- a/windows-node-host/BundledSystemCapability.cs +++ b/windows-node-host/BundledSystemCapability.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using OpenClaw.Shared; @@ -17,9 +18,18 @@ internal sealed class BundledSystemCapability( ActivationLeaseGuard activationLease, string uiLocale = "en-US", ApprovalProofVerifier? approvalProof = null, - ReadinessProofVerifier? readinessProof = null) : INodeCapability + ReadinessProofVerifier? readinessProof = null, + Func? isolationGuard = null, + Func? ambientGuard = null, + ReadinessPreparationGuard? readinessPreparation = null) : INodeCapability { private readonly SemaphoreSlim _runGate = new(1, 1); + private readonly ReadinessPreparationGuard _readinessPreparation = + readinessPreparation ?? new ReadinessPreparationGuard(); + private static readonly JsonSerializerOptions MxcConfigJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; private static readonly string[] SupportedCommands = [ "system.run", @@ -44,12 +54,14 @@ public async Task ExecuteAsync( return request.Command switch { "system.run.cwd-policy" => Success( - CwdPolicyAttestation.Current with + policy.Attestation with { - DurableApprovalsPresent = DurableApprovalIdentity.Load(approvalsPath).Count > 0, + DurableApprovalsPresent = + policy.DurableApprovalsAllowed + && DurableApprovalIdentity.Load(approvalsPath).Count > 0, }), "system.which" => Success(new { bins = ResolveBins(request.Args) }), - "system.run.prepare" => Success(Prepare(request)), + "system.run.prepare" => Success(await PrepareSerializedAsync(request, cancellationToken)), "system.run" => await RunAsync(request, cancellationToken), ReadinessProbeContract.Command => await RunReadinessAsync(request, cancellationToken), _ => Error("Unsupported bundled node command."), @@ -69,44 +81,81 @@ CwdPolicyAttestation.Current with } } - private object Prepare(NodeInvokeRequest request) + private async Task PrepareSerializedAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) { - var run = ParseRun(request.Args); - var cwd = policy.ResolveCwd(run.Cwd); - var commandText = WindowsCommandLine.Join(run.Argv); - var commandPreview = BuildApprovalCommandPreview(run, commandText); - var executable = ResolveExecutable(run.Argv[0]) - ?? throw new HostPolicyException("executable-not-found", "The requested executable was not found."); - var exactArgs = run.Argv.Skip(1).ToArray(); - var declaredApprovalAccess = run.DeclaredAccess - .Select(entry => new DurableApprovalAccess(entry.Access, entry.Path)) - .ToArray(); - using var executableLease = ExecutableApprovalLease.Acquire(executable); - var executableIdentity = executableLease.Capture( - exactArgs, - cwd.ApprovalBinding, - declaredApprovalAccess); - return new + await _runGate.WaitAsync(cancellationToken); + try { - cmdText = commandText, - plan = new + await EnsureFilesystemIsolationAsync(cancellationToken); + var run = ParseRun(request.Args); + var cwd = policy.ResolveCwd(run.Cwd); + var commandText = WindowsCommandLine.Join(run.Argv); + var commandPreview = BuildApprovalCommandPreview(run, commandText); + var executable = ResolveExecutable(run.Argv[0]) + ?? throw new HostPolicyException("executable-not-found", "The requested executable was not found."); + var exactArgs = run.Argv.Skip(1).ToArray(); + var declaredApprovalAccess = run.DeclaredAccess + .Select(entry => new DurableApprovalAccess(entry.Access, entry.Path)) + .ToArray(); + using var executableLease = ExecutableApprovalLease.Acquire(executable); + var executableIdentity = executableLease.Capture( + exactArgs, + cwd.ApprovalBinding, + declaredApprovalAccess); + var readinessProbeKind = ReadinessProbeContract.MatchKind(run.Argv); + var readinessPreparation = + readinessProbeKind is not null + && string.Equals(run.AgentId, ReadinessProbeContract.AgentId, StringComparison.Ordinal) + && readinessProof is not null + && string.Equals( + run.SessionKey, + ReadinessProbeContract.SessionKey(readinessProof.TransitionId), + StringComparison.Ordinal); + if (readinessPreparation) + { + if (!string.Equals(cwd.ApprovalBinding, CwdPolicyContract.ScratchBinding, StringComparison.Ordinal) + || run.DeclaredAccess.Count != 0 + || commandPreview is not null) + throw new HostPolicyException( + "readiness-preparation-scope-invalid", + "The readiness preparation permits only the exact built-in probe in isolated scratch."); + await EnsureAmbientAccessAsync(cancellationToken); + cwd = policy.RevalidateCwd(run.Cwd, cwd.ApprovalBinding); + executableIdentity = executableLease.Capture( + exactArgs, + cwd.ApprovalBinding, + declaredApprovalAccess); + _readinessPreparation.Record(readinessProbeKind!, executableIdentity); + } + return new { - argv = run.Argv, - cwd = string.IsNullOrEmpty(cwd.LaunchPath) ? null : cwd.LaunchPath, - commandText, - commandPreview, - agentId = run.AgentId, - sessionKey = run.SessionKey, - executablePath = executableIdentity.ExecutablePath, - executableSha256 = executableIdentity.ExecutableSha256, + cmdText = commandText, + plan = new + { + argv = run.Argv, + cwd = string.IsNullOrEmpty(cwd.LaunchPath) ? null : cwd.LaunchPath, + commandText, + commandPreview, + agentId = run.AgentId, + sessionKey = run.SessionKey, + executablePath = executableIdentity.ExecutablePath, + executableSha256 = executableIdentity.ExecutableSha256, + cwdBinding = cwd.ApprovalBinding, + declaredAccess = run.DeclaredAccess, + }, cwdBinding = cwd.ApprovalBinding, + cwdAccess = cwd.Access.ToString(), + matchedRoot = cwd.MatchedRoot, + contract = CwdPolicyContract.Version, declaredAccess = run.DeclaredAccess, - }, - cwdBinding = cwd.ApprovalBinding, - cwdAccess = cwd.Access.ToString(), - contract = CwdPolicyContract.Version, - declaredAccess = run.DeclaredAccess, - }; + }; + } + finally + { + _runGate.Release(); + } } private static string? BuildApprovalCommandPreview(RunRequest run, string commandText) @@ -156,6 +205,9 @@ private async Task RunExclusiveAsync( bool readiness, CancellationToken cancellationToken) { + await EnsureFilesystemIsolationAsync(cancellationToken); + if (!readiness) + await EnsureAmbientAccessAsync(cancellationToken); var request = ParseRun(args); var cwd = policy.ResolveCwd(request.Cwd); var validatedActivation = activationLease.Validate(request.Argv); @@ -166,6 +218,11 @@ private async Task RunExclusiveAsync( var declaredApprovalAccess = request.DeclaredAccess .Select(entry => new DurableApprovalAccess(entry.Access, entry.Path)) .ToArray(); + var executableDirectory = Path.GetDirectoryName(executable) + ?? throw new HostPolicyException( + "executable-path-invalid", + "The requested executable has no parent directory."); + using var executableDirectoryLease = DirectoryPathLease.Acquire([executableDirectory]); using var executableLease = ExecutableApprovalLease.Acquire(executable); executable = executableLease.CanonicalPath; var approvedIdentity = executableLease.Capture( @@ -173,6 +230,7 @@ private async Task RunExclusiveAsync( cwd.ApprovalBinding, declaredApprovalAccess); ValidatedReadinessProof? validatedReadiness = null; + ValidatedApprovalProof? validatedApproval = null; if (readiness) { if (validatedActivation.Mode is not ActivationLeaseMode.Diagnostic) @@ -193,10 +251,11 @@ private async Task RunExclusiveAsync( throw new HostPolicyException( "approval-proof-unavailable", "The active Gateway approval proof verifier is unavailable."); - approvalProof.ValidateAndConsume(args, approvedIdentity); + validatedApproval = approvalProof.ValidateAndConsume(args, approvedIdentity); } - var durable = !readiness && !activeGatewayApproval && DurableApprovalIdentity.Load(approvalsPath) + var durable = policy.DurableApprovalsAllowed + && !readiness && !activeGatewayApproval && DurableApprovalIdentity.Load(approvalsPath) .Any(entry => DurableApprovalIdentity.Matches(entry, approvedIdentity)); var requireDurableRecheck = durable; if (!readiness && !activeGatewayApproval && !durable) @@ -213,7 +272,7 @@ private async Task RunExclusiveAsync( cancellationToken); if (decision is ApprovalDecision.Deny) return Error("approval-denied: The operator denied this command."); - if (decision is ApprovalDecision.AllowAlways) + if (decision is ApprovalDecision.AllowAlways && policy.DurableApprovalsAllowed) { DurableApprovalFile.Add(approvalsPath, approvedIdentity); requireDurableRecheck = true; @@ -241,9 +300,29 @@ private async Task RunExclusiveAsync( policy.ApprovedRoots.Select(root => root.Path) .Append(currentCwd.LaunchPath) .Append(scratch)); + var wxcDirectory = Path.GetDirectoryName(policy.WxcExecPath) + ?? throw new HostPolicyException( + "mxc-path-invalid", + "The pinned MXC runtime path has no parent directory."); + using var wxcDirectoryLease = DirectoryPathLease.Acquire([wxcDirectory]); + using var wxcExecutableLease = ExecutableApprovalLease.Acquire(policy.WxcExecPath); + await EnsureFilesystemIsolationAsync( + cancellationToken, + wxcExecutableLease); + if (!readiness) + await EnsureAmbientAccessAsync(cancellationToken); currentCwd = policy.RevalidateCwd(request.Cwd, cwd.ApprovalBinding); - activationLease.Revalidate(validatedActivation, request.Argv); + currentIdentity = executableLease.Capture( + exactArgs, + currentCwd.ApprovalBinding, + declaredApprovalAccess); + if (!DurableApprovalIdentity.Matches(approvedIdentity, currentIdentity)) + throw new HostPolicyException( + "approval-changed-before-launch", + "The executable, arguments, CWD, or declared access changed before launch."); var launchCwd = string.IsNullOrEmpty(currentCwd.LaunchPath) ? scratch : currentCwd.LaunchPath; + Console.Error.WriteLine( + $"[filesystem-policy] launchLocation={(currentCwd.MatchedRoot is null ? "isolated-scratch" : "selected-grant")} effectiveAccess={currentCwd.Access} containmentStrength={policy.RequestedStrength}"); var config = BuildMxcConfig( policy, currentExecutable, @@ -251,6 +330,17 @@ private async Task RunExclusiveAsync( launchCwd, scratch, request.TimeoutMs); + var configPath = Path.Combine(scratch, "mxc-config.json"); + await File.WriteAllTextAsync( + configPath, + SerializeMxcConfig( + config, + allowDaclMutation: string.Equals( + policy.RequestedStrength, + "limited-dacl", + StringComparison.Ordinal)), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); using var environment = ProcessEnvironmentOverride.Apply( new Dictionary { @@ -258,11 +348,40 @@ private async Task RunExclusiveAsync( ["TMP"] = scratch, ["TMPDIR"] = scratch, }); + currentCwd = policy.RevalidateCwd(request.Cwd, cwd.ApprovalBinding); + activationLease.Revalidate(validatedActivation, request.Argv); + currentIdentity = executableLease.Capture( + exactArgs, + currentCwd.ApprovalBinding, + declaredApprovalAccess); + if (!DurableApprovalIdentity.Matches(approvedIdentity, currentIdentity)) + throw new HostPolicyException( + "approval-changed-before-launch", + "The executable, arguments, CWD, or declared access changed at process launch."); + if (validatedApproval is not null) + approvalProof!.Revalidate(validatedApproval); + if (validatedReadiness is not null) + { + readinessProof!.Revalidate(validatedReadiness); + var preparationToLaunchMs = _readinessPreparation.ValidateAndConsume( + validatedReadiness.ProbeKind, + currentIdentity); + Console.Error.WriteLine( + $"[readiness-probe] launch prepared kind={validatedReadiness.ProbeKind} preparationToLaunchMs={preparationToLaunchMs}"); + } + if (requireDurableRecheck && !DurableApprovalIdentity.Load(approvalsPath) + .Any(entry => DurableApprovalIdentity.Matches(entry, currentIdentity))) + throw new HostPolicyException( + "approval-changed-before-launch", + "The durable approval is no longer current."); var result = await new MxcExecutor( - policy.WxcExecPath, + wxcExecutableLease.CanonicalPath, 4 * 1024 * 1024, 4 * 1024 * 1024) - .RunAsync(config, cancellationToken, workingDirectory: launchCwd); + .RunWithConfigFileAsync( + configPath, + cancellationToken, + workingDirectory: launchCwd); if (validatedReadiness is not null) Console.Error.WriteLine( $"[readiness-probe] completed internal fixed probe kind={validatedReadiness.ProbeKind} exitCode={result.ExitCode} timedOut={result.TimedOut}"); @@ -274,6 +393,8 @@ private async Task RunExclusiveAsync( timedOut = result.TimedOut, durationMs = result.DurationMs, containment = "mxc", + containmentStrength = policy.RequestedStrength, + effectiveTier = policy.EffectiveTier, }); } @@ -283,6 +404,36 @@ private async Task RunExclusiveAsync( } } + private Task EnsureAmbientAccessAsync(CancellationToken cancellationToken) => + ambientGuard?.Invoke(cancellationToken) + ?? AmbientAccessAssessment.RevalidateAsync(policy, cancellationToken); + + internal static string SerializeMxcConfig(MxcConfig config, bool allowDaclMutation = false) + { + var root = JsonSerializer.SerializeToNode(config, MxcConfigJsonOptions)?.AsObject() + ?? throw new HostPolicyException( + "mxc-config-serialization-failed", + "The MXC launch policy could not be serialized."); + root["fallback"] = new JsonObject { ["allowDaclMutation"] = allowDaclMutation }; + return root.ToJsonString(MxcConfigJsonOptions); + } + + private Task EnsureFilesystemIsolationAsync( + CancellationToken cancellationToken, + ExecutableApprovalLease? wxcExecutableLease = null) => + isolationGuard?.Invoke(cancellationToken) + ?? (wxcExecutableLease is null + ? MxcIsolationTierGuard.EnsureFilesystemIsolationAsync( + policy.WxcExecPath, + policy.RequestedStrength, + policy.EffectiveTier, + cancellationToken) + : MxcIsolationTierGuard.EnsureFilesystemIsolationAsync( + wxcExecutableLease, + policy.RequestedStrength, + policy.EffectiveTier, + cancellationToken)); + internal static MxcConfig BuildMxcConfig( HostPolicy policy, string executable, @@ -291,6 +442,12 @@ internal static MxcConfig BuildMxcConfig( string scratch, int timeoutMs) { + if (policy.ApprovedRoots.Any(root => policy.DeniedRoots.Any(denied => + WindowsPathCanonicalizer.IsEqualOrNested(root.Path, denied) + || WindowsPathCanonicalizer.IsEqualOrNested(denied, root.Path)))) + throw new HostPolicyException( + "approved-root-overlaps-sensitive-root", + "Denied roots override approved filesystem roots."); var readOnly = policy.ApprovedRoots .Where(root => root.Access == FolderAccess.ReadOnly) .Select(root => root.Path) @@ -313,7 +470,7 @@ internal static MxcConfig BuildMxcConfig( ProcessContainer = new MxcProcessContainer { LeastPrivilege = false, - Capabilities = [], + Capabilities = policy.NetworkAllowed ? ["internetClient"] : [], Ui = new MxcBaseProcessUi { Isolation = "desktop", @@ -329,7 +486,7 @@ internal static MxcConfig BuildMxcConfig( }, Network = new MxcNetwork { - DefaultPolicy = "block", + DefaultPolicy = policy.NetworkAllowed ? "allow" : "block", EnforcementMode = "capabilities", }, Ui = new MxcUi { Disable = false, Clipboard = "none", Injection = false }, diff --git a/windows-node-host/CanvasHostBroker.cs b/windows-node-host/CanvasHostBroker.cs new file mode 100644 index 0000000..f3758ff --- /dev/null +++ b/windows-node-host/CanvasHostBroker.cs @@ -0,0 +1,363 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace MicroClaw.WindowsNodeHost; + +internal sealed class CanvasHostBroker : IDisposable +{ + private readonly HostPolicy _policy; + private readonly string _leasePath; + private readonly string _leaseSecret; + private readonly string _generation; + private readonly string _policyFingerprint; + private readonly string _nodeId; + private readonly string _uiLocale; + private readonly Func> _deliverAction; + private readonly ActivationLeaseGuard _activationLease; + private readonly string _commandPipeName = $"microclaw-canvas-command-{Guid.NewGuid():N}"; + private readonly string _actionPipeName = $"microclaw-canvas-action-{Guid.NewGuid():N}"; + private readonly string _ipcToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + private readonly SemaphoreSlim _commandGate = new(1, 1); + private readonly CancellationTokenSource _shutdown = new(); + private Process? _process; + private Task? _actionServer; + + public CanvasHostBroker( + HostPolicy policy, + string leasePath, + string leaseSecret, + string generation, + string policyFingerprint, + string nodeId, + string uiLocale, + ActivationLeaseGuard activationLease, + Func> deliverAction) + { + _policy = policy; + _leasePath = leasePath; + _leaseSecret = leaseSecret; + _generation = generation; + _policyFingerprint = policyFingerprint; + _nodeId = nodeId; + _uiLocale = uiLocale; + _activationLease = activationLease; + _deliverAction = deliverAction; + } + + public async Task ExecuteAsync( + string command, + JsonElement args, + ValidatedActivationLease lease, + ActivationLeaseGuard leaseGuard, + CancellationToken cancellationToken) + { + await _commandGate.WaitAsync(cancellationToken); + try + { + CanvasPhase1Policy.ValidateCommand(command, args); + leaseGuard.RevalidateActive(lease); + await EnsureHostAsync(cancellationToken); + leaseGuard.RevalidateActive(lease); + using var pipe = new NamedPipeClientStream( + ".", + _commandPipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous, + System.Security.Principal.TokenImpersonationLevel.Identification); + await pipe.ConnectAsync(5_000, cancellationToken); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 64 * 1024, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 64 * 1024, leaveOpen: true) + { + AutoFlush = true, + }; + await writer.WriteLineAsync(JsonSerializer.Serialize(new + { + contract = CanvasPhase1Policy.Contract, + token = _ipcToken, + gatewayGeneration = _generation, + policyFingerprint = _policyFingerprint, + nodeId = _nodeId, + command, + args, + })); + var line = await reader.ReadLineAsync(cancellationToken) + ?? throw new HostPolicyException( + "canvas-host-disconnected", + "The managed Canvas host disconnected before returning a result."); + var response = JsonNode.Parse(line)?.AsObject() + ?? throw new HostPolicyException("canvas-host-response", "The Canvas host response is invalid."); + if (response["ok"]?.GetValue() != true) + { + throw new HostPolicyException( + "canvas-command-rejected", + response["error"]?.GetValue() ?? "The Canvas command was rejected."); + } + leaseGuard.RevalidateActive(lease); + return response["payload"]?.DeepClone(); + } + finally + { + _commandGate.Release(); + } + } + + private async Task EnsureHostAsync(CancellationToken cancellationToken) + { + if (_process is { HasExited: false }) return; + VerifyCanvasHost(); + _process?.Dispose(); + _process = Process.Start(new ProcessStartInfo + { + FileName = _policy.Canvas.HostPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(_policy.Canvas.HostPath)!, + }) ?? throw new HostPolicyException("canvas-host-start", "The managed Canvas host did not start."); + _process.EnableRaisingEvents = true; + _process.ErrorDataReceived += (_, eventArgs) => + { + if (!string.IsNullOrWhiteSpace(eventArgs.Data)) + Console.Error.WriteLine($"[canvas-host] {eventArgs.Data}"); + }; + _process.BeginErrorReadLine(); + await _process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new + { + contract = CanvasPhase1Policy.Contract, + commandPipeName = _commandPipeName, + actionPipeName = _actionPipeName, + ipcToken = _ipcToken, + activationLeasePath = _leasePath, + activationLeaseSecret = _leaseSecret, + gatewayGeneration = _generation, + policyFingerprint = _policyFingerprint, + nodeId = _nodeId, + parentProcessId = Environment.ProcessId, + parentProcessStartTimeUtcTicks = Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks, + uiLocale = _uiLocale, + })); + await _process.StandardInput.FlushAsync(cancellationToken); + _actionServer ??= RunActionServerAsync(_shutdown.Token); + } + + private void VerifyCanvasHost() + { + using var stream = new FileStream( + _policy.Canvas.HostPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + if (!string.Equals(hash, _policy.Canvas.HostSha256, StringComparison.Ordinal)) + throw new HostPolicyException( + "canvas-host-hash", + "The managed Canvas host does not match the fingerprinted executable."); + var treeHash = ComputeDirectoryTreeHash(Path.GetDirectoryName(_policy.Canvas.HostPath)!); + if (!string.Equals(treeHash, _policy.Canvas.HostTreeSha256, StringComparison.Ordinal)) + throw new HostPolicyException( + "canvas-host-tree-hash", + "The managed Canvas host files do not match the fingerprinted resource tree."); + } + + internal static string ComputeDirectoryTreeHash(string root) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var file in EnumerateFiles(root).OrderBy( + file => Path.GetRelativePath(root, file).Replace('\\', '/'), + StringComparer.Ordinal)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + var info = new FileInfo(file); + hash.AppendData(Encoding.UTF8.GetBytes($"{relative}\0{info.Length}\0")); + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + var buffer = new byte[128 * 1024]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + hash.AppendData(buffer.AsSpan(0, read)); + hash.AppendData([0]); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static IEnumerable EnumerateFiles(string root) + { + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var entry in new DirectoryInfo(directory).EnumerateFileSystemInfos()) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + throw new HostPolicyException( + "canvas-host-tree-reparse", + "The managed Canvas host resource tree contains a reparse point."); + if (entry is DirectoryInfo child) pending.Push(child.FullName); + else if (entry is FileInfo file) yield return file.FullName; + } + } + } + + private async Task RunActionServerAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var pipe = new NamedPipeServerStream( + _actionPipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.WaitForConnectionAsync(cancellationToken); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 64 * 1024, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), 64 * 1024, leaveOpen: true) + { + AutoFlush = true, + }; + var response = await HandleActionAsync(await reader.ReadLineAsync(cancellationToken)); + await writer.WriteLineAsync(JsonSerializer.Serialize(response)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[canvas-action] rejected: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + private async Task HandleActionAsync(string? json) + { + try + { + var request = JsonNode.Parse(json ?? string.Empty)?.AsObject() + ?? throw new HostPolicyException("canvas-action-invalid", "Canvas action request is empty."); + ValidateActionIdentity(request); + var activeLease = _activationLease.ValidateActive(); + var action = request["action"]?["action"]?.AsObject() + ?? throw new HostPolicyException("canvas-action-invalid", "Canvas action envelope is invalid."); + var name = action["name"]?.GetValue() ?? string.Empty; + CanvasPhase1Policy.ValidateAction(name); + var sessionKey = request["sessionKey"]?.GetValue()?.Trim(); + if (string.IsNullOrEmpty(sessionKey) + || sessionKey.Length > 256 + || !sessionKey.All(character => + char.IsLetterOrDigit(character) || character is '_' or '-' or ':' or '.')) + { + throw new HostPolicyException( + "canvas-action-session", + "Canvas action session binding is invalid."); + } + var actionId = request["key"]?.GetValue(); + if (string.IsNullOrWhiteSpace(actionId) || !Guid.TryParse(actionId, out _)) + throw new HostPolicyException("canvas-action-id", "Canvas action id is invalid."); + var contextJson = action["context"]?.ToJsonString(); + var message = FormatAgentMessage( + name, + sessionKey, + action["surfaceId"]?.GetValue() ?? string.Empty, + action["sourceComponentId"]?.GetValue() ?? string.Empty, + Environment.MachineName, + _nodeId, + contextJson); + _activationLease.RevalidateActive(activeLease); + var sent = await _deliverAction(new JsonObject + { + ["message"] = message, + ["sessionKey"] = sessionKey, + ["thinking"] = "low", + ["deliver"] = false, + ["key"] = actionId, + }); + _activationLease.RevalidateActive(activeLease); + if (!sent) + throw new HostPolicyException("canvas-action-gateway", "Gateway rejected the Canvas action."); + return new { ok = true }; + } + catch (HostPolicyException ex) + { + return new { ok = false, error = $"{ex.Code}: {ex.Message}" }; + } + } + + private void ValidateActionIdentity(JsonObject request) + { + byte[] actualToken; + try { actualToken = Convert.FromBase64String(request["token"]?.GetValue() ?? string.Empty); } + catch (FormatException) + { + throw new HostPolicyException("canvas-action-token", "Canvas action token is malformed."); + } + if (!CryptographicOperations.FixedTimeEquals(actualToken, Convert.FromBase64String(_ipcToken)) + || request["contract"]?.GetValue() != CanvasPhase1Policy.Contract + || request["gatewayGeneration"]?.GetValue() != _generation + || request["policyFingerprint"]?.GetValue() != _policyFingerprint + || request["nodeId"]?.GetValue() != _nodeId) + { + throw new HostPolicyException("canvas-action-identity", "Canvas action identity is stale or invalid."); + } + } + + private static string FormatAgentMessage( + string actionName, + string sessionKey, + string surfaceId, + string sourceComponentId, + string host, + string instanceId, + string? contextJson) + { + var parts = new List + { + "CANVAS_A2UI", + $"action={SanitizeTagValue(actionName)}", + $"session={SanitizeTagValue(sessionKey)}", + $"surface={SanitizeTagValue(surfaceId)}", + $"component={SanitizeTagValue(sourceComponentId)}", + $"host={SanitizeTagValue(host)}", + $"instance={SanitizeTagValue(instanceId)}", + "default=update_canvas", + }; + if (!string.IsNullOrWhiteSpace(contextJson)) parts.Add($"ctx={contextJson}"); + return string.Join(" ", parts); + } + + private static string SanitizeTagValue(string? value) + { + var trimmed = (value ?? string.Empty).Trim(); + if (trimmed.Length == 0) return "-"; + return new string(trimmed + .Replace(' ', '_') + .Select(character => + char.IsLetterOrDigit(character) || character is '_' or '-' or '.' or ':' + ? character + : '_') + .ToArray()); + } + + public void Dispose() + { + _shutdown.Cancel(); + try + { + if (_process is { HasExited: false }) _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + _process?.Dispose(); + _shutdown.Dispose(); + _commandGate.Dispose(); + } +} diff --git a/windows-node-host/CanvasPhase1Policy.cs b/windows-node-host/CanvasPhase1Policy.cs new file mode 100644 index 0000000..6eba265 --- /dev/null +++ b/windows-node-host/CanvasPhase1Policy.cs @@ -0,0 +1,202 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MicroClaw.WindowsNodeHost; + +public static class CanvasPhase1Policy +{ + public const string Contract = "microclaw.windows-node-mxc.canvas.v1"; + public const string Renderer = "native-a2ui"; + public static readonly IReadOnlyList Commands = + [ + "canvas.present", + "canvas.hide", + "canvas.snapshot", + "canvas.caps", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "canvas.a2ui.dump", + ]; + public static readonly IReadOnlyList Actions = + [ + "cancel", + "click", + "confirm", + "submit", + ]; + + public static void ValidateCommand(string command, JsonElement args) + { + if (!Commands.Contains(command, StringComparer.Ordinal)) + throw new HostPolicyException( + "canvas-command-denied", + "Canvas command is outside the reviewed Phase 1 allowlist."); + switch (command) + { + case "canvas.present": + ValidatePresent(args); + break; + case "canvas.a2ui.push": + case "canvas.a2ui.pushJSONL": + ValidateInlineJsonl(args); + break; + case "canvas.snapshot": + ValidateSnapshot(args); + break; + } + } + + public static void ValidateAction(string action) + { + if (!Actions.Contains(action, StringComparer.Ordinal)) + throw new HostPolicyException( + "canvas-action-denied", + "Canvas action is outside the reviewed Phase 1 allowlist."); + } + + private static void ValidatePresent(JsonElement args) + { + if (args.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return; + if (args.ValueKind != JsonValueKind.Object) + throw new HostPolicyException( + "canvas-present-invalid", + "Canvas present parameters must be an object."); + if (args.EnumerateObject().Any()) + throw new HostPolicyException( + "canvas-present-trusted-origin-only", + "Phase 1 present opens only the managed native A2UI shell."); + } + + private static void ValidateInlineJsonl(JsonElement args) + { + if (args.ValueKind != JsonValueKind.Object) + throw new HostPolicyException( + "canvas-a2ui-invalid", + "A2UI parameters must be an object."); + if (args.TryGetProperty("jsonlPath", out _)) + throw new HostPolicyException( + "canvas-local-file-denied", + "Phase 1 rejects local A2UI files."); + if (!args.TryGetProperty("jsonl", out var value) || value.ValueKind != JsonValueKind.String) + throw new HostPolicyException("canvas-a2ui-invalid", "Inline jsonl is required."); + var jsonl = value.GetString() ?? string.Empty; + if (Encoding.UTF8.GetByteCount(jsonl) > 4 * 1024 * 1024) + throw new HostPolicyException("canvas-a2ui-too-large", "A2UI JSONL is too large."); + var lines = jsonl.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > 4096) + throw new HostPolicyException("canvas-a2ui-too-many-lines", "A2UI JSONL has too many lines."); + foreach (var line in lines) + { + JsonNode node; + try { node = JsonNode.Parse(line) ?? throw new JsonException(); } + catch (JsonException) + { + throw new HostPolicyException("canvas-a2ui-invalid", "A2UI JSONL contains invalid JSON."); + } + ValidateNode(node); + } + } + + private static void ValidateSnapshot(JsonElement args) + { + if (args.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) return; + if (args.ValueKind != JsonValueKind.Object) + throw new HostPolicyException( + "canvas-snapshot-invalid", + "Canvas snapshot parameters must be an object."); + foreach (var property in args.EnumerateObject()) + { + if (property.Name != "format") + throw new HostPolicyException( + "canvas-snapshot-invalid", + "Canvas snapshot accepts only a format."); + var format = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString()?.ToLowerInvariant() + : null; + if (format is not ("png" or "jpeg" or "jpg")) + throw new HostPolicyException( + "canvas-snapshot-format", + "Canvas snapshot supports png or jpeg only."); + } + } + + private static void ValidateNode(JsonNode node) + { + switch (node) + { + case JsonObject obj: + foreach (var (name, value) in obj) + { + if (value is null) continue; + if (IsMediaProperty(name)) ValidateMediaValue(value); + else ValidateNode(value); + } + break; + case JsonArray array: + foreach (var value in array) + if (value is not null) ValidateNode(value); + break; + } + } + + private static bool IsMediaProperty(string name) => + name.Equals("url", StringComparison.OrdinalIgnoreCase) + || name.Equals("src", StringComparison.OrdinalIgnoreCase) + || name.Equals("uri", StringComparison.OrdinalIgnoreCase); + + private static void ValidateMediaValue(JsonNode node) + { + if (node is JsonValue direct && direct.TryGetValue(out var directText)) + { + ValidateMediaReference(directText); + return; + } + if (node is JsonObject value) + { + if (value["path"] is not null) + throw new HostPolicyException( + "canvas-media-binding-denied", + "Phase 1 Canvas media must use a bounded inline literal."); + if (value["literalString"] is JsonValue literal + && literal.TryGetValue(out var literalText)) + { + ValidateMediaReference(literalText); + return; + } + } + throw new HostPolicyException( + "canvas-media-reference-denied", + "Canvas media reference is not approved."); + } + + private static void ValidateMediaReference(string value) + { + var trimmed = value.Trim(); + if (IsApprovedInlineImage(trimmed)) return; + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + var code = uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + ? "canvas-media-host-not-allowed" + : "canvas-media-scheme-denied"; + throw new HostPolicyException(code, "Canvas media reference is not approved."); + } + throw new HostPolicyException( + "canvas-local-file-denied", + "Local and relative Canvas media references are denied."); + } + + private static bool IsApprovedInlineImage(string value) + { + if (!value.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return false; + var comma = value.IndexOf(','); + if (comma < 0) return false; + var metadata = value[5..comma].Split(';', StringSplitOptions.RemoveEmptyEntries); + if (metadata.Length < 2 || !metadata.Skip(1).Contains("base64", StringComparer.OrdinalIgnoreCase)) + return false; + return metadata[0].Equals("image/png", StringComparison.OrdinalIgnoreCase) + || metadata[0].Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || metadata[0].Equals("image/webp", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/windows-node-host/CwdPolicy.cs b/windows-node-host/CwdPolicy.cs index 57e1c94..0cab107 100644 --- a/windows-node-host/CwdPolicy.cs +++ b/windows-node-host/CwdPolicy.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; namespace MicroClaw.WindowsNodeHost; @@ -33,13 +34,32 @@ public sealed record CwdPolicyAttestation( [property: JsonPropertyName("approvalProofBindsExecutableContent")] bool ApprovalProofBindsExecutableContent, [property: JsonPropertyName("approvalProofBindsActivation")] bool ApprovalProofBindsActivation, [property: JsonPropertyName("approvalProofOneUse")] bool ApprovalProofOneUse, + [property: JsonPropertyName("launchTimeApprovalProofRevalidation")] bool LaunchTimeApprovalProofRevalidation, [property: JsonPropertyName("readinessProofContract")] string ReadinessProofContract, [property: JsonPropertyName("readinessProofBindsTransition")] bool ReadinessProofBindsTransition, [property: JsonPropertyName("readinessProofBindsPreparedPlan")] bool ReadinessProofBindsPreparedPlan, [property: JsonPropertyName("readinessProofExactBuiltInsOnly")] bool ReadinessProofExactBuiltInsOnly, [property: JsonPropertyName("readinessProofOneUse")] bool ReadinessProofOneUse, + [property: JsonPropertyName("launchTimeReadinessProofRevalidation")] bool LaunchTimeReadinessProofRevalidation, + [property: JsonPropertyName("exactFilesystemIsolationRequired")] bool ExactFilesystemIsolationRequired, + [property: JsonPropertyName("degradedIsolationTierRejected")] bool DegradedIsolationTierRejected, [property: JsonPropertyName("durableApprovalStoreProtected")] bool DurableApprovalStoreProtected, - [property: JsonPropertyName("durableApprovalsPresent")] bool DurableApprovalsPresent) + [property: JsonPropertyName("durableApprovalsPresent")] bool DurableApprovalsPresent, + [property: JsonPropertyName("requestedContainmentStrength")] string RequestedContainmentStrength, + [property: JsonPropertyName("folderPolicyIsExclusiveBoundary")] bool FolderPolicyIsExclusiveBoundary, + [property: JsonPropertyName("durableApprovalsAllowed")] bool DurableApprovalsAllowed, + [property: JsonPropertyName("ambientAssessmentBound")] bool AmbientAssessmentBound, + [property: JsonPropertyName("outboundNetworkEnabled")] bool OutboundNetworkEnabled, + [property: JsonPropertyName("canvasContract")] string CanvasContract, + [property: JsonPropertyName("canvasPhase1Enabled")] bool CanvasPhase1Enabled, + [property: JsonPropertyName("canvasExactCommandSet")] bool CanvasExactCommandSet, + [property: JsonPropertyName("canvasExactActionSet")] bool CanvasExactActionSet, + [property: JsonPropertyName("canvasNativeA2uiOnly")] bool CanvasNativeA2uiOnly, + [property: JsonPropertyName("canvasHostTreeBound")] bool CanvasHostTreeBound, + [property: JsonPropertyName("canvasEvalAbsent")] bool CanvasEvalAbsent, + [property: JsonPropertyName("canvasNavigationAbsent")] bool CanvasNavigationAbsent, + [property: JsonPropertyName("canvasLocalFilesAbsent")] bool CanvasLocalFilesAbsent, + [property: JsonPropertyName("canvasSurfaceOnlySnapshot")] bool CanvasSurfaceOnlySnapshot) { public static readonly CwdPolicyAttestation Current = new( CwdPolicyContract.Version, @@ -62,13 +82,46 @@ public sealed record CwdPolicyAttestation( ApprovalProofBindsExecutableContent: true, ApprovalProofBindsActivation: true, ApprovalProofOneUse: true, + LaunchTimeApprovalProofRevalidation: true, ReadinessProofContract: global::MicroClaw.WindowsNodeHost.ReadinessProbeContract.Version, ReadinessProofBindsTransition: true, ReadinessProofBindsPreparedPlan: true, ReadinessProofExactBuiltInsOnly: true, ReadinessProofOneUse: true, + LaunchTimeReadinessProofRevalidation: true, + ExactFilesystemIsolationRequired: true, + DegradedIsolationTierRejected: true, DurableApprovalStoreProtected: true, - DurableApprovalsPresent: false); + DurableApprovalsPresent: false, + RequestedContainmentStrength: "full", + FolderPolicyIsExclusiveBoundary: true, + DurableApprovalsAllowed: true, + AmbientAssessmentBound: false, + OutboundNetworkEnabled: true, + CanvasContract: global::MicroClaw.WindowsNodeHost.CanvasPhase1Policy.Contract, + CanvasPhase1Enabled: true, + CanvasExactCommandSet: true, + CanvasExactActionSet: true, + CanvasNativeA2uiOnly: true, + CanvasHostTreeBound: true, + CanvasEvalAbsent: true, + CanvasNavigationAbsent: true, + CanvasLocalFilesAbsent: true, + CanvasSurfaceOnlySnapshot: true); + + public static CwdPolicyAttestation ForPolicy(HostPolicy policy) => + Current with + { + ExactFilesystemIsolationRequired = + string.Equals(policy.RequestedStrength, "full", StringComparison.Ordinal), + DegradedIsolationTierRejected = + string.Equals(policy.RequestedStrength, "full", StringComparison.Ordinal), + RequestedContainmentStrength = policy.RequestedStrength, + FolderPolicyIsExclusiveBoundary = + string.Equals(policy.RequestedStrength, "full", StringComparison.Ordinal), + DurableApprovalsAllowed = policy.DurableApprovalsAllowed, + AmbientAssessmentBound = policy.AmbientAccess is not null, + }; } public enum FolderAccess @@ -79,7 +132,11 @@ public enum FolderAccess public sealed record ApprovedRoot(string Path, FolderAccess Access); -public sealed record CanonicalCwd(string LaunchPath, string ApprovalBinding, FolderAccess Access); +public sealed record CanonicalCwd( + string LaunchPath, + string ApprovalBinding, + FolderAccess Access, + string? MatchedRoot); public sealed class HostPolicyException(string code, string message) : Exception(message) { @@ -96,7 +153,14 @@ public sealed class HostPolicy public string Clipboard { get; init; } = "none"; public bool InputInjection { get; init; } public bool StrictNoHostFallback { get; init; } - public CwdPolicyAttestation Attestation => CwdPolicyAttestation.Current; + public string RequestedStrength { get; init; } = "full"; + public string EffectiveTier { get; init; } = "base-container"; + public AmbientAccessPolicy? AmbientAccess { get; init; } + public string AmbientAssessmentScratchRoot { get; init; } = Path.GetTempPath(); + public required CanvasHostPolicy Canvas { get; init; } + public bool DurableApprovalsAllowed => + string.Equals(RequestedStrength, "full", StringComparison.Ordinal); + public CwdPolicyAttestation Attestation => CwdPolicyAttestation.ForPolicy(this); public static async Task LoadAsync(string path) { @@ -134,8 +198,64 @@ private static HostPolicy Parse(ReadOnlySpan json) throw new HostPolicyException("host-fallback-enabled", "Strict no-host-fallback must be enabled."); if (!input.AllowWindowsUi) throw new HostPolicyException("windows-ui-disabled", "allowWindowsUi must be enabled for MXC 0.7."); - if (input.NetworkAllowed || input.InputInjection || !string.Equals(input.Clipboard, "none", StringComparison.Ordinal)) - throw new HostPolicyException("unsafe-capability", "Network, clipboard, and input injection must remain disabled."); + if (!input.NetworkAllowed) + throw new HostPolicyException( + "outbound-network-disabled", + "Contained outbound network access must be enabled."); + if (input.InputInjection || !string.Equals(input.Clipboard, "none", StringComparison.Ordinal)) + throw new HostPolicyException( + "unsafe-capability", + "Clipboard and input injection must remain disabled."); + if (input.RequestedStrength is not ("full" or "limited-dacl")) + throw new HostPolicyException( + "containment-strength-invalid", + "The requested containment strength is invalid."); + if (string.IsNullOrWhiteSpace(input.EffectiveTier)) + throw new HostPolicyException( + "containment-tier-missing", + "The effective MXC tier is missing."); + if (input.RequestedStrength == "full" + && input.EffectiveTier is not ("base-container" or "appcontainer-bfs")) + throw new HostPolicyException( + "containment-tier-mismatch", + "Full containment requires base-container or appcontainer-bfs."); + if (input.RequestedStrength == "limited-dacl" + && !string.Equals(input.EffectiveTier, "appcontainer-dacl", StringComparison.Ordinal)) + throw new HostPolicyException( + "containment-tier-mismatch", + "Limited containment requires appcontainer-dacl."); + if (input.RequestedStrength == "limited-dacl") + { + if (input.AmbientAccess is null) + throw new HostPolicyException( + "ambient-assessment-required", + "Limited containment requires an ambient-access assessment."); + AmbientAccessAssessment.ValidatePolicy(input.AmbientAccess); + } + else if (input.AmbientAccess is not null) + { + throw new HostPolicyException( + "ambient-assessment-unexpected", + "Full containment must not carry a Limited-mode ambient-access assessment."); + } + if (input.Canvas is null + || !input.Canvas.Enabled + || !string.Equals(input.Canvas.Contract, CanvasPhase1Policy.Contract, StringComparison.Ordinal) + || !string.Equals(input.Canvas.Renderer, CanvasPhase1Policy.Renderer, StringComparison.Ordinal) + || !input.Canvas.Commands.SequenceEqual(CanvasPhase1Policy.Commands, StringComparer.Ordinal) + || !input.Canvas.Actions.SequenceEqual(CanvasPhase1Policy.Actions, StringComparer.Ordinal) + || input.Canvas.AllowEval + || input.Canvas.AllowNavigation + || input.Canvas.AllowLocalFiles + || input.Canvas.AllowRemoteMedia + || !input.Canvas.SnapshotSurfaceOnly + || !Regex.IsMatch(input.Canvas.HostSha256, "^[a-f0-9]{64}$") + || !Regex.IsMatch(input.Canvas.HostTreeSha256, "^[a-f0-9]{64}$")) + { + throw new HostPolicyException( + "canvas-policy-invalid", + "The Canvas policy does not match the reviewed Phase 1 capability surface."); + } var approved = (input.ApprovedRoots ?? []) .Select(root => new ApprovedRoot( @@ -154,24 +274,44 @@ private static HostPolicy Parse(ReadOnlySpan json) "Approved folders must not contain or be contained by a protected root."); } var wxc = WindowsPathCanonicalizer.CanonicalizeFile(input.WxcExecPath); + var canvasHost = WindowsPathCanonicalizer.CanonicalizeFile(input.Canvas.HostPath); + var ambientScratch = + input.RequestedStrength == "limited-dacl" + ? WindowsPathCanonicalizer.CanonicalizeDirectory( + input.AmbientAssessmentScratchRoot) + : Path.GetFullPath(input.AmbientAssessmentScratchRoot); return new HostPolicy { ApprovedRoots = approved, DeniedRoots = denied, WxcExecPath = wxc, - NetworkAllowed = false, + NetworkAllowed = true, AllowWindowsUi = true, Clipboard = "none", InputInjection = false, StrictNoHostFallback = true, + RequestedStrength = input.RequestedStrength, + EffectiveTier = input.EffectiveTier.ToLowerInvariant(), + AmbientAccess = input.AmbientAccess, + AmbientAssessmentScratchRoot = ambientScratch, + Canvas = new CanvasHostPolicy( + canvasHost, + input.Canvas.HostSha256, + input.Canvas.HostTreeSha256, + input.Canvas.Commands.ToArray(), + input.Canvas.Actions.ToArray()), }; } public CanonicalCwd ResolveCwd(string? requestedCwd) { if (string.IsNullOrWhiteSpace(requestedCwd)) - return new CanonicalCwd(string.Empty, CwdPolicyContract.ScratchBinding, FolderAccess.ReadWrite); + return new CanonicalCwd( + string.Empty, + CwdPolicyContract.ScratchBinding, + FolderAccess.ReadWrite, + MatchedRoot: null); var canonical = WindowsPathCanonicalizer.CanonicalizeDirectory(requestedCwd); if (DeniedRoots.Any(root => WindowsPathCanonicalizer.IsEqualOrNested(canonical, root))) @@ -183,7 +323,7 @@ public CanonicalCwd ResolveCwd(string? requestedCwd) .FirstOrDefault() ?? throw new HostPolicyException("cwd-outside-approved-roots", "The requested working directory is not within an approved folder."); - return new CanonicalCwd(canonical, canonical, grant.Access); + return new CanonicalCwd(canonical, canonical, grant.Access, grant.Path); } public CanonicalCwd RevalidateCwd(string? requestedCwd, string approvalBinding) @@ -204,6 +344,11 @@ private sealed class HostPolicyInput public string Clipboard { get; init; } = "none"; public bool InputInjection { get; init; } public bool StrictNoHostFallback { get; init; } + public string RequestedStrength { get; init; } = "full"; + public string EffectiveTier { get; init; } = "base-container"; + public AmbientAccessPolicy? AmbientAccess { get; init; } + public string AmbientAssessmentScratchRoot { get; init; } = Path.GetTempPath(); + public CanvasHostPolicyInput? Canvas { get; init; } } private sealed class ApprovedRootInput @@ -211,8 +356,32 @@ private sealed class ApprovedRootInput public string Path { get; init; } = string.Empty; public FolderAccess Access { get; init; } } + + private sealed class CanvasHostPolicyInput + { + public bool Enabled { get; init; } + public string Contract { get; init; } = string.Empty; + public string HostPath { get; init; } = string.Empty; + public string HostSha256 { get; init; } = string.Empty; + public string HostTreeSha256 { get; init; } = string.Empty; + public string Renderer { get; init; } = string.Empty; + public List Commands { get; init; } = []; + public List Actions { get; init; } = []; + public bool AllowEval { get; init; } + public bool AllowNavigation { get; init; } + public bool AllowLocalFiles { get; init; } + public bool AllowRemoteMedia { get; init; } + public bool SnapshotSurfaceOnly { get; init; } + } } +public sealed record CanvasHostPolicy( + string HostPath, + string HostSha256, + string HostTreeSha256, + IReadOnlyList Commands, + IReadOnlyList Actions); + public static class WindowsPathCanonicalizer { public static string CanonicalizeDirectory(string path) => Canonicalize(path, expectDirectory: true); diff --git a/windows-node-host/MxcIsolationTierGuard.cs b/windows-node-host/MxcIsolationTierGuard.cs new file mode 100644 index 0000000..97fac43 --- /dev/null +++ b/windows-node-host/MxcIsolationTierGuard.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace MicroClaw.WindowsNodeHost; + +internal static class MxcIsolationTierGuard +{ + private static readonly HashSet ExactFilesystemIsolationTiers = + new(StringComparer.OrdinalIgnoreCase) { "base-container", "appcontainer-bfs" }; + private const string LimitedDaclTier = "appcontainer-dacl"; + private const string WxcExecX64Sha256 = + "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2"; + private const string WxcExecArm64Sha256 = + "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab"; + + public static async Task EnsureExactFilesystemIsolationAsync( + string wxcExecPath, + CancellationToken cancellationToken = default) + => await EnsureFilesystemIsolationAsync( + wxcExecPath, + "full", + expectedTier: null, + cancellationToken); + + public static async Task EnsureFilesystemIsolationAsync( + string wxcExecPath, + string requestedStrength, + string? expectedTier, + CancellationToken cancellationToken = default) + { + var parentDirectory = Path.GetDirectoryName(wxcExecPath) + ?? throw new HostPolicyException( + "mxc-path-invalid", + "The pinned MXC runtime path has no parent directory."); + using var directoryLease = DirectoryPathLease.Acquire([parentDirectory]); + using var executableLease = ExecutableApprovalLease.Acquire(wxcExecPath); + return await EnsureFilesystemIsolationAsync( + executableLease, + requestedStrength, + expectedTier, + cancellationToken); + } + + internal static async Task EnsureExactFilesystemIsolationAsync( + ExecutableApprovalLease executableLease, + CancellationToken cancellationToken = default) + => await EnsureFilesystemIsolationAsync( + executableLease, + "full", + expectedTier: null, + cancellationToken); + + internal static async Task EnsureFilesystemIsolationAsync( + ExecutableApprovalLease executableLease, + string requestedStrength, + string? expectedTier, + CancellationToken cancellationToken = default) + { + executableLease.VerifySha256(ExpectedWxcExecSha256()); + var startInfo = new ProcessStartInfo + { + FileName = executableLease.CanonicalPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("--probe"); + + using var process = new Process { StartInfo = startInfo }; + try + { + if (!process.Start()) + throw new HostPolicyException( + "mxc-probe-launch-failed", + "The pinned MXC runtime probe could not be started."); + } + catch (HostPolicyException) + { + throw; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + throw new HostPolicyException( + "mxc-probe-launch-failed", + $"The pinned MXC runtime probe could not be started: {ex.Message}"); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } + cancellationToken.ThrowIfCancellationRequested(); + throw new HostPolicyException( + "mxc-probe-timeout", + "The pinned MXC runtime probe timed out."); + } + + return ValidateProbeResult( + process.ExitCode, + await stdoutTask, + await stderrTask, + requestedStrength, + expectedTier); + } + + private static string ExpectedWxcExecSha256() => + RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => WxcExecX64Sha256, + Architecture.Arm64 => WxcExecArm64Sha256, + _ => throw new HostPolicyException( + "mxc-architecture-unsupported", + $"The pinned MXC runtime does not support {RuntimeInformation.ProcessArchitecture}."), + }; + + internal static string ValidateProbeResult(int exitCode, string stdout, string stderr) + => ValidateProbeResult(exitCode, stdout, stderr, "full", expectedTier: null); + + internal static string ValidateProbeResult( + int exitCode, + string stdout, + string stderr, + string requestedStrength, + string? expectedTier) + { + if (exitCode != 0) + throw new HostPolicyException( + "mxc-probe-failed", + $"The pinned MXC runtime probe exited with code {exitCode}: {stderr.Trim()}"); + + try + { + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + if (root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String) + throw new HostPolicyException( + "mxc-probe-failed", + error.GetString() ?? "The pinned MXC runtime reported an error."); + if (root.TryGetProperty("supported", out var supported) + && supported.ValueKind == JsonValueKind.False) + throw new HostPolicyException( + "mxc-probe-unsupported", + "The pinned MXC runtime is not supported on this host."); + + var tier = root.TryGetProperty("tier", out var tierElement) + && tierElement.ValueKind == JsonValueKind.String + ? tierElement.GetString()?.Trim() + : null; + var needsDaclAugmentation = + root.TryGetProperty("needsDaclAugmentation", out var dacl) + && dacl.ValueKind == JsonValueKind.True; + if (string.IsNullOrEmpty(tier)) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + "MXC did not attest a filesystem isolation tier."); + if (!string.IsNullOrWhiteSpace(expectedTier) + && !string.Equals(tier, expectedTier, StringComparison.OrdinalIgnoreCase)) + throw new HostPolicyException( + "mxc-filesystem-isolation-drift", + $"MXC isolation tier drifted from {expectedTier} to {tier}."); + if (string.Equals(requestedStrength, "full", StringComparison.Ordinal)) + { + if (needsDaclAugmentation || !ExactFilesystemIsolationTiers.Contains(tier)) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + $"MXC isolation tier {tier} cannot enforce configured filesystem roots exactly; base-container or appcontainer-bfs is required."); + } + else if (string.Equals(requestedStrength, "limited-dacl", StringComparison.Ordinal)) + { + if (!string.Equals(tier, LimitedDaclTier, StringComparison.OrdinalIgnoreCase)) + throw new HostPolicyException( + "mxc-filesystem-isolation-insufficient", + $"Limited containment requires appcontainer-dacl, but MXC attested {tier}."); + } + else + { + throw new HostPolicyException( + "mxc-containment-strength-invalid", + $"Unsupported containment strength: {requestedStrength}."); + } + return tier; + } + catch (HostPolicyException) + { + throw; + } + catch (JsonException) + { + throw new HostPolicyException( + "mxc-probe-invalid", + "The pinned MXC runtime probe returned invalid JSON."); + } + } +} diff --git a/windows-node-host/Program.cs b/windows-node-host/Program.cs index ef3a406..7d78f37 100644 --- a/windows-node-host/Program.cs +++ b/windows-node-host/Program.cs @@ -15,6 +15,37 @@ private static async Task Main(string[] args) return 0; } + if (args is ["--ambient-assess", var requestPath]) + { + try + { + var request = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(requestPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new HostPolicyException( + "ambient-assessment-request-invalid", + "The ambient-access assessment request is empty."); + var result = await AmbientAccessAssessment.RunAsync(request); + Console.WriteLine(JsonSerializer.Serialize(new + { + contract = result.Contract, + hash = result.Hash, + checkedAt = result.CheckedAt, + entries = result.Entries.Select(entry => new + { + category = entry.Category, + status = entry.Status, + }), + })); + return 0; + } + catch (HostPolicyException ex) + { + Console.Error.WriteLine($"{ex.Code}: {ex.Message}"); + return 2; + } + } + if (args is ["--validate-config", var path]) { try @@ -42,7 +73,7 @@ private static async Task Main(string[] args) if (args.Length != 0) { Console.Error.WriteLine( - "This helper is managed by MicroClaw. Supported diagnostics: --attestation, --validate-config ."); + "This helper is managed by MicroClaw. Supported diagnostics: --attestation, --validate-config , --ambient-assess ."); return 2; } @@ -71,6 +102,11 @@ private static async Task Main(string[] args) var policy = await HostPolicy.LoadVerifiedAsync( bootstrap.PolicyPath, bootstrap.PolicyFingerprint); + await MxcIsolationTierGuard.EnsureFilesystemIsolationAsync( + policy.WxcExecPath, + policy.RequestedStrength, + policy.EffectiveTier); + await AmbientAccessAssessment.RevalidateAsync(policy); SecureStateDirectory.Ensure(bootstrap.IdentityDirectory); var approvalDirectory = Path.GetDirectoryName(bootstrap.ApprovalsPath) ?? throw new HostPolicyException( @@ -94,15 +130,26 @@ Task AuthorizeGatewayAsync(CancellationToken _) => "The expected MicroClaw Gateway process does not own the loopback listener.")); client.HandshakeAuthorizationAsync = AuthorizeGatewayAsync; client.ReconnectAuthorizationAsync = AuthorizeGatewayAsync; + var activationLease = new ActivationLeaseGuard( + bootstrap.ActivationLeasePath, + bootstrap.ActivationLeaseSecret, + bootstrap.GatewayGeneration, + bootstrap.PolicyFingerprint); + using var canvasBroker = new CanvasHostBroker( + policy, + bootstrap.ActivationLeasePath, + bootstrap.ActivationLeaseSecret, + bootstrap.GatewayGeneration, + bootstrap.PolicyFingerprint, + bootstrap.NodeId, + bootstrap.UiLocale, + activationLease, + payload => client.SendNodeEventAsync("agent.request", payload)); client.RegisterCapability(new BundledSystemCapability( policy, bootstrap.ApprovalPipeName, bootstrap.ApprovalsPath, - new ActivationLeaseGuard( - bootstrap.ActivationLeasePath, - bootstrap.ActivationLeaseSecret, - bootstrap.GatewayGeneration, - bootstrap.PolicyFingerprint), + activationLease, bootstrap.UiLocale, new ApprovalProofVerifier( bootstrap.ApprovalProofSecret, @@ -115,6 +162,7 @@ Task AuthorizeGatewayAsync(CancellationToken _) => bootstrap.PolicyFingerprint, bootstrap.NodeId, bootstrap.ReadinessTransitionId))); + client.RegisterCapability(new BundledCanvasCapability(canvasBroker, activationLease)); var connect = client.ConnectAsync(); if (await Task.WhenAny(connect, ownerLifetime) == ownerLifetime) return 0; diff --git a/windows-node-host/ReadinessProof.cs b/windows-node-host/ReadinessProof.cs index 61d4c77..5dfec6f 100644 --- a/windows-node-host/ReadinessProof.cs +++ b/windows-node-host/ReadinessProof.cs @@ -16,22 +16,14 @@ internal static class ReadinessProbeContract public static readonly IReadOnlyDictionary Probes = new Dictionary(StringComparer.Ordinal) { - ["hostname"] = - [ - @"C:\Windows\System32\cmd.exe", - "/d", - "/s", - "/c", - @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", - ], - ["powershell"] = + ["execution"] = [ @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", - "[Console]::Out.Write('MICROCLAW_MXC_POWERSHELL_OK')", + "$child=& 'C:\\Windows\\System32\\hostname.exe'; if ([string]::IsNullOrWhiteSpace(($child -join ''))) { exit 41 }; [Console]::Out.Write('MICROCLAW_MXC_EXECUTION_OK')", ], }; @@ -46,6 +38,9 @@ public static bool Matches(IReadOnlyList actual, IReadOnlyList e expected[index], index == 0 ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) .All(matches => matches); + + public static string? MatchKind(IReadOnlyList argv) => + Probes.FirstOrDefault(entry => Matches(argv, entry.Value)).Key; } internal sealed record ReadinessProofRecord( @@ -78,7 +73,8 @@ internal sealed class ReadinessProofVerifier private readonly Func _nowUnixMs; private readonly object _consumeGate = new(); private readonly Dictionary _consumedNonces = new(StringComparer.Ordinal); - private readonly HashSet _consumedProbeKinds = new(StringComparer.Ordinal); + + public string TransitionId => _transitionId; public ReadinessProofVerifier( string secretBase64, @@ -140,13 +136,11 @@ public ValidatedReadinessProof ValidateAndConsume( .Select(entry => entry.Key) .ToArray()) _consumedNonces.Remove(key); - if (_consumedNonces.ContainsKey(proof.Nonce) - || _consumedProbeKinds.Contains(proof.ProbeKind)) + if (_consumedNonces.ContainsKey(proof.Nonce)) throw new HostPolicyException( "readiness-proof-replayed", "This readiness probe authorization has already been consumed."); _consumedNonces.Add(proof.Nonce, proof.ExpiresAtUnixMs); - _consumedProbeKinds.Add(proof.ProbeKind); } return new ValidatedReadinessProof( @@ -157,6 +151,14 @@ public ValidatedReadinessProof ValidateAndConsume( proof.ExpiresAtUnixMs); } + public void Revalidate(ValidatedReadinessProof proof) + { + if (proof.ExpiresAtUnixMs <= _nowUnixMs()) + throw new HostPolicyException( + "readiness-proof-stale", + "The readiness proof expired before process launch."); + } + public static string ComputeSignature(string secretBase64, ReadinessProofRecord proof) => ComputeSignature(DecodeSecret(secretBase64), proof); @@ -337,3 +339,56 @@ private static bool FixedTimeHexEquals(string supplied, string expected) } } } + +internal sealed class ReadinessPreparationGuard +{ + public const int MaximumAgeMs = 30_000; + + private readonly Func _nowUnixMs; + private readonly object _gate = new(); + private PreparedReadiness? _prepared; + + public ReadinessPreparationGuard(Func? nowUnixMs = null) + { + _nowUnixMs = nowUnixMs ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + public void Record(string probeKind, DurableApproval identity) + { + if (!ReadinessProbeContract.Probes.ContainsKey(probeKind)) + throw new HostPolicyException( + "readiness-preparation-invalid", + "The readiness preparation probe kind is invalid."); + lock (_gate) + _prepared = new PreparedReadiness(probeKind, identity, _nowUnixMs()); + } + + public long ValidateAndConsume(string probeKind, DurableApproval identity) + { + lock (_gate) + { + var prepared = _prepared; + _prepared = null; + if (prepared is null) + throw new HostPolicyException( + "readiness-preparation-required", + "A fresh readiness preparation is required before the internal probe."); + var ageMs = _nowUnixMs() - prepared.PreparedAtUnixMs; + if (ageMs < 0 || ageMs > MaximumAgeMs) + throw new HostPolicyException( + "readiness-preparation-stale", + "The readiness preparation expired before process launch."); + if (!string.Equals(prepared.ProbeKind, probeKind, StringComparison.Ordinal) + || !DurableApprovalIdentity.Matches(prepared.Identity, identity)) + throw new HostPolicyException( + "readiness-preparation-mismatch", + "The readiness preparation does not match the launch identity."); + return ageMs; + } + } + + private sealed record PreparedReadiness( + string ProbeKind, + DurableApproval Identity, + long PreparedAtUnixMs); +}