diff --git a/extensions/mxc/README.md b/extensions/mxc/README.md index 0194bcdd68a68..84dc1d7552b96 100644 --- a/extensions/mxc/README.md +++ b/extensions/mxc/README.md @@ -46,24 +46,52 @@ readiness behavior to change as MXC host support matures. and out-of-range values fail plugin activation with an actionable error (`Invalid mxc plugin config: `) instead of falling back silently. -| Field | Type | Default | Notes | -| ---------------- | --------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mxcBinaryPath` | `string` | unset | Non-empty override for the `wxc-exec.exe` executor path; see [SDK-only executor discovery](#supported). | -| `containment` | `"process" \| "processcontainer"` | `"process"` | Both currently resolve to Windows ProcessContainer. | -| `network` | `"none" \| "default"` | `"none"` | `"default"` allows outbound network via the `internetClient` capability. | -| `timeoutSeconds` | `number` | unset (baseline default `300` applies) | Must be `>= 1` and `<= 2147000` (the largest Node-safe `setTimeout` delay in whole seconds). Capped to the sandbox policy baseline timeout when both are set. | -| `debug` | `boolean` | `false` | Forwards debug output from the MXC SDK launcher. | -| `mxcPolicyPaths` | `string[]` | unset (built-in baseline only) | Every entry must be a non-empty absolute path. See [Sandbox policy files](#sandbox-policy-files). | +| Field | Type | Default | Notes | +| ---------------- | ------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `securityLevel` | `"Locked Down" \| "Recommended" \| "Unprotected"` | `"Recommended"` | Windows-aligned baseline for network, standard folders, clipboard, and timeout. | +| `mxcBinaryPath` | `string` | unset | Non-empty override for the `wxc-exec.exe` executor path; see [SDK-only executor discovery](#supported). | +| `containment` | `"process" \| "processcontainer"` | `"process"` | Both currently resolve to Windows ProcessContainer. | +| `network` | `"none" \| "default"` | selected preset | Optional restrictive override. `"none"` blocks outbound network; `"default"` retains the preset policy and cannot weaken `Locked Down`. | +| `timeoutSeconds` | `number` | selected preset | Optional preset override from 1 through 2147000 seconds. The sandbox policy baseline can enforce a shorter timeout. | +| `debug` | `boolean` | `false` | Forwards debug output from the MXC SDK launcher. | +| `mxcPolicyPaths` | `string[]` | built-in baseline only | Every entry must be a non-empty absolute path. See [Sandbox policy files](#sandbox-policy-files). | Any other key is rejected. `openclaw.plugin.json` publishes the same schema (enums, `minimum`/`maximum` bounds) so `openclaw config` validation and CLI help stay in sync with plugin runtime validation. +### Security levels + +| Security level | Internet | Gateway identity's Documents / Downloads / Desktop | Clipboard | Timeout | +| -------------- | -------- | -------------------------------------------------- | ---------- | ----------- | +| `Locked Down` | Off | None | None | 30 seconds | +| `Recommended` | On | Read-only | Read | 60 seconds | +| `Unprotected` | On | Read-write | Read-write | 300 seconds | + +Standard-folder grants use the Windows known-folder locations for the identity +running the Gateway and honor folder redirection. The active workspace and MXC +runtime paths remain available independently of the selected preset. When a +writable workspace is inside one of those folders, the workspace grant takes +precedence and MXC omits the overlapping automatic read-only folder grant. + +All presets disable input injection and desktop system control. `Locked Down` +keeps ProcessContainer UI isolation. Clipboard-enabled presets use desktop UI +isolation so their clipboard policy can reach the clipboard in the Gateway's +Windows session; the top-level clipboard setting still independently enforces +read-only or read-write access. + +Explicit `network: "none"` can tighten a clipboard-enabled preset, while +`network: "default"` cannot enable network for `Locked Down`. +`timeoutSeconds` overrides the preset timeout, subject to a shorter policy-file +ceiling. Policy files can also add explicit filesystem paths. + ## Supported - Windows hosts with the MXC executor installed through `@microsoft/mxc-sdk`. - Explicit opt-in after plugin install with `sandbox.backend: "mxc"`. - MXC `process` containment, which resolves to Windows ProcessContainer. +- Windows-aligned `Locked Down`, `Recommended`, and `Unprotected` security + presets. - `workspaceAccess`: - `none`: only the isolated sandbox workdir is mounted, read-only. There is no separate mount for the real agent workspace. @@ -257,8 +285,12 @@ agents may still use them. ## Host readiness -IsoEnvBroker must be available on the host OS. The plugin checks this before -registering the sandbox backend. +The plugin validates that its packaged MXC executor is present before +registering the sandbox backend. The MXC `process` backend probes the available +Windows containment tier at execution time and falls back from BaseContainer to +its supported AppContainer path when needed. It does not require the legacy +`IsoEnvBroker` service name; current SF2 builds expose the separate +`IsolationSession` services instead. Host preparation is advisory. If directory listing inside the sandbox fails with `Access is denied`, run this once from an elevated prompt: diff --git a/extensions/mxc/index.test.ts b/extensions/mxc/index.test.ts index c2288842ca809..198ed8e758cb2 100644 --- a/extensions/mxc/index.test.ts +++ b/extensions/mxc/index.test.ts @@ -21,4 +21,20 @@ describe("mxc plugin entry", () => { it("wires the runtime config schema into the plugin entry and manifest", () => { expect(plugin.configSchema?.jsonSchema).toEqual(manifest.configSchema); }); + + it("publishes the security preset contract", () => { + expect(manifest.configSchema.properties.securityLevel).toMatchObject({ + enum: ["Locked Down", "Recommended", "Unprotected"], + default: "Recommended", + }); + expect(manifest.configContracts.dangerousFlags).toContainEqual({ + path: "securityLevel", + equals: "Unprotected", + }); + expect(manifest.uiHints.securityLevel).toMatchObject({ + label: "Default sandbox security", + }); + expect(manifest.uiHints.securityLevel).not.toHaveProperty("advanced"); + expect(manifest.uiHints.network.advanced).toBe(true); + }); }); diff --git a/extensions/mxc/openclaw.plugin.json b/extensions/mxc/openclaw.plugin.json index 4102b12d60ccb..83c26503e199b 100644 --- a/extensions/mxc/openclaw.plugin.json +++ b/extensions/mxc/openclaw.plugin.json @@ -10,6 +10,12 @@ "type": "object", "additionalProperties": false, "properties": { + "securityLevel": { + "type": "string", + "enum": ["Locked Down", "Recommended", "Unprotected"], + "default": "Recommended", + "description": "Windows-aligned baseline for network, standard folders, clipboard, and timeout." + }, "mxcBinaryPath": { "type": "string", "minLength": 1, @@ -23,13 +29,13 @@ "network": { "type": "string", "enum": ["none", "default"], - "description": "Outbound network policy. 'none' blocks all network; 'default' allows outbound access via the internetClient capability." + "description": "Optional restrictive preset override. 'none' blocks all network; 'default' retains the selected preset's outbound policy." }, "timeoutSeconds": { "type": "number", "minimum": 1, "maximum": 2147000, - "description": "Per-command execution timeout in seconds. Capped to the sandbox policy baseline timeout when both are set." + "description": "Optional preset timeout override in seconds. Capped to the sandbox policy baseline timeout when both are set." }, "debug": { "type": "boolean", @@ -46,9 +52,16 @@ } }, "configContracts": { - "dangerousFlags": [{ "path": "network", "equals": "default" }] + "dangerousFlags": [ + { "path": "securityLevel", "equals": "Unprotected" }, + { "path": "network", "equals": "default" } + ] }, "uiHints": { + "securityLevel": { + "label": "Default sandbox security", + "help": "Recommended is intended for normal OpenClaw use. Locked Down disables internet and clipboard and adds no standard-folder grants. Unprotected grants broader filesystem and clipboard access but does not disable MXC containment." + }, "mxcBinaryPath": { "label": "MXC Executor Path", "help": "Optional absolute path to the MXC executor (wxc-exec.exe). Leave unset to auto-discover from the installed @microsoft/mxc-sdk.", @@ -60,11 +73,12 @@ }, "network": { "label": "Network Policy", - "help": "'none' blocks all outbound network; 'default' allows outbound access via the internetClient capability, which weakens sandbox isolation." + "help": "Optional restrictive override for the selected security preset. 'none' blocks all outbound network; 'default' retains the preset policy and cannot enable network for Locked Down.", + "advanced": true }, "timeoutSeconds": { - "label": "Command Timeout (seconds)", - "help": "Per-command execution timeout, from 1 to 2147000 seconds. Capped to the sandbox policy baseline timeout when both are set.", + "label": "Command Timeout Override (seconds)", + "help": "Optional override for the selected security preset, from 1 to 2147000 seconds. Capped to the sandbox policy baseline timeout when both are set.", "advanced": true }, "debug": { diff --git a/extensions/mxc/src/config.ts b/extensions/mxc/src/config.ts index 6f0319c8a4849..1f0052f511e93 100644 --- a/extensions/mxc/src/config.ts +++ b/extensions/mxc/src/config.ts @@ -6,6 +6,12 @@ import { } from "openclaw/plugin-sdk/extension-shared"; import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime"; import { z } from "zod"; +import { + DEFAULT_MXC_SECURITY_LEVEL, + getMxcSecurityPreset, + MXC_SECURITY_LEVELS, + type MxcSecurityLevel, +} from "./security-level.js"; const MXC_CONTAINMENTS = ["process", "processcontainer"] as const; const MXC_NETWORK_MODES = ["none", "default"] as const; @@ -16,23 +22,27 @@ type MxcNetworkMode = (typeof MXC_NETWORK_MODES)[number]; export type MxcConfig = { mxcBinaryPath?: string; + securityLevel: MxcSecurityLevel; containment: MxcContainment; network: MxcNetworkMode; timeoutSeconds: number; - timeoutSecondsConfigured?: boolean; debug: boolean; mxcPolicyPaths?: string[]; }; const DEFAULT_CONTAINMENT: MxcContainment = "process"; -const DEFAULT_NETWORK: MxcNetworkMode = "none"; -const DEFAULT_TIMEOUT_SECONDS = 120; const DEFAULT_DEBUG = false; const nonEmptyTrimmedString = (message: string) => z.string({ error: message }).trim().min(1, { error: message }); const MxcPluginConfigSchema = z.strictObject({ + securityLevel: z + .enum(MXC_SECURITY_LEVELS, { + error: `securityLevel must be one of ${MXC_SECURITY_LEVELS.join(", ")}`, + }) + .describe("Windows-aligned baseline for network, standard folders, clipboard, and timeout.") + .default(DEFAULT_MXC_SECURITY_LEVEL), mxcBinaryPath: nonEmptyTrimmedString("mxcBinaryPath must be a non-empty string") .describe( "Absolute path to the MXC executor (wxc-exec.exe). When unset, the executor is discovered from the installed @microsoft/mxc-sdk.", @@ -51,7 +61,7 @@ const MxcPluginConfigSchema = z.strictObject({ error: `network must be one of ${MXC_NETWORK_MODES.join(", ")}`, }) .describe( - "Outbound network policy. 'none' blocks all network; 'default' allows outbound access via the internetClient capability.", + "Optional restrictive preset override. 'none' blocks all network; 'default' retains the selected preset's outbound policy.", ) .optional(), timeoutSeconds: z @@ -63,7 +73,7 @@ const MxcPluginConfigSchema = z.strictObject({ error: `timeoutSeconds must be a number <= ${MAX_TIMER_TIMEOUT_SECONDS}`, }) .describe( - "Per-command execution timeout in seconds. Capped to the sandbox policy baseline timeout when both are set.", + "Optional preset timeout override in seconds. Capped to the sandbox policy baseline timeout when both are set.", ) .optional(), debug: z @@ -102,11 +112,13 @@ export function createMxcPluginConfigSchema(): OpenClawPluginConfigSchema { export function resolveConfig(value: unknown): MxcConfig { if (value === undefined) { + const preset = getMxcSecurityPreset(DEFAULT_MXC_SECURITY_LEVEL); return { mxcBinaryPath: undefined, + securityLevel: DEFAULT_MXC_SECURITY_LEVEL, containment: DEFAULT_CONTAINMENT, - network: DEFAULT_NETWORK, - timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, + network: preset.networkEnabled ? "default" : "none", + timeoutSeconds: preset.timeoutSeconds, debug: DEFAULT_DEBUG, }; } @@ -118,19 +130,18 @@ export function resolveConfig(value: unknown): MxcConfig { } const config = parsed.data; + const securityLevel = config.securityLevel ?? DEFAULT_MXC_SECURITY_LEVEL; + const preset = getMxcSecurityPreset(securityLevel); + const presetNetwork: MxcNetworkMode = preset.networkEnabled ? "default" : "none"; const resolved: MxcConfig = { mxcBinaryPath: config.mxcBinaryPath, + securityLevel, containment: config.containment ?? DEFAULT_CONTAINMENT, - network: config.network ?? DEFAULT_NETWORK, - timeoutSeconds: config.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, + network: presetNetwork === "none" ? "none" : (config.network ?? presetNetwork), + timeoutSeconds: config.timeoutSeconds ?? preset.timeoutSeconds, debug: config.debug ?? DEFAULT_DEBUG, mxcPolicyPaths: resolveMxcPolicyPaths(config.mxcPolicyPaths), }; - - if (config.timeoutSeconds !== undefined) { - resolved.timeoutSecondsConfigured = true; - } - return resolved; } diff --git a/extensions/mxc/src/mxc-backend.ts b/extensions/mxc/src/mxc-backend.ts index 7f3e6283b5325..2ee899f27fb7c 100644 --- a/extensions/mxc/src/mxc-backend.ts +++ b/extensions/mxc/src/mxc-backend.ts @@ -276,9 +276,9 @@ export function createMxcSandboxBackendHandle(params: { // Shell commands use a restrictive policy (no network, 30s timeout) const restrictiveConfig: MxcConfig = { ...params.config, + securityLevel: "Locked Down", network: "none", timeoutSeconds: 30, - timeoutSecondsConfigured: true, }; const effectiveWorkdir = path.resolve(params.workdir); const workspaceAccess = params.workspaceAccess ?? "rw"; diff --git a/extensions/mxc/src/mxc-container-config.ts b/extensions/mxc/src/mxc-container-config.ts index d9e6b700a76e9..4d52f2d75d56c 100644 --- a/extensions/mxc/src/mxc-container-config.ts +++ b/extensions/mxc/src/mxc-container-config.ts @@ -10,8 +10,10 @@ import type { LoadedSandboxBaselinePolicy, SandboxConfiguredPathEntry, } from "./sandbox-policy-loader.js"; +import { getMxcSecurityPreset, type MxcStandardFolderAccess } from "./security-level.js"; import { buildCommandLine } from "./windows-command.js"; import { normalizeWindowsProcessEnvRecord } from "./windows-env.js"; +import { resolveWindowsStandardFolders } from "./windows-known-folders.js"; import { resolveMxcReadOnlySkillMounts, type MxcReadOnlySkillMount, @@ -107,10 +109,12 @@ export function buildMxcContainerConfig(params: { env: Record; }): ContainerConfig { const networkAllowed = params.config.network === "default"; + const preset = getMxcSecurityPreset(params.config.securityLevel); const filesystem = buildFilesystemConfig({ baseline: params.baseline, context: params.baselineContext, sandboxTempDir: params.sandboxTempDir, + standardFolderAccess: preset.standardFolderAccess, workspace: params.workspace, }); @@ -133,8 +137,8 @@ export function buildMxcContainerConfig(params: { }, filesystem, ui: { - disable: true, - clipboard: "none", + disable: false, + clipboard: preset.clipboard, injection: false, }, network: { @@ -146,7 +150,7 @@ export function buildMxcContainerConfig(params: { leastPrivilege: true, capabilities: networkAllowed ? ["internetClient"] : [], ui: { - isolation: "container", + isolation: preset.clipboard === "none" ? "container" : "desktop", desktopSystemControl: false, systemSettings: "none", ime: false, @@ -159,9 +163,18 @@ function buildFilesystemConfig(params: { baseline: LoadedSandboxBaselinePolicy; context: BaselineApplicationContext; sandboxTempDir: string; + standardFolderAccess: MxcStandardFolderAccess; workspace: MxcWorkspaceContext; }): MxcFilesystemConfig { - const readwritePathSpecs = resolveWorkspaceReadwritePathSpecs(params.workspace); + const standardFolders = Object.values(resolveWindowsStandardFolders()).filter( + (folder): folder is string => typeof folder === "string", + ); + const readwritePathSpecs = [ + ...resolveWorkspaceReadwritePathSpecs(params.workspace), + ...(params.standardFolderAccess === "readwrite" + ? standardFolders.map(optionalFilesystemPath) + : []), + ]; const readonlyPathSpecs = [ ...resolveWorkspaceReadonlyPathSpecs(params.workspace), ...resolveBaselineReadonlyPathSpecs(params.baseline, params.context), @@ -181,16 +194,27 @@ function buildFilesystemConfig(params: { ); } + const readwritePaths = resolveExistingFilesystemPaths(readwritePathSpecs, "readwrite"); + if (params.standardFolderAccess === "readonly") { + readonlyPathSpecs.push( + ...standardFolders + .filter( + (standardFolder) => + !readwritePaths.some((readwritePath) => pathsOverlap(readwritePath, standardFolder)), + ) + .map(optionalFilesystemPath), + ); + } + const protectedSkillPolicyPaths = resolveMxcProtectedSkillPolicyPaths(params.workspace); // ProcessContainer writable-parent grants override nested read-only grants. // Fail closed instead of claiming protected skill overlays are enforceable. assertNoMxcReadwriteReadonlyOverlap({ - readwritePaths: resolveExistingFilesystemPaths(readwritePathSpecs, "readwrite"), + readwritePaths, readonlyPaths: protectedSkillPolicyPaths, }); const readonlyPaths = resolveExistingFilesystemPaths(readonlyPathSpecs, "read-only"); - const readwritePaths = resolveExistingFilesystemPaths(readwritePathSpecs, "readwrite"); assertNoMxcReadwriteReadonlyOverlap({ readwritePaths, readonlyPaths }); return { @@ -364,10 +388,9 @@ function resolveProcessTimeoutSeconds( config: MxcConfig, baseline: LoadedSandboxBaselinePolicy, ): number { - if (config.timeoutSecondsConfigured === true) { - return Math.min(config.timeoutSeconds, baseline.process.timeoutSeconds); - } - return baseline.process.timeoutSeconds; + return baseline.process.timeoutSecondsConfigured + ? Math.min(config.timeoutSeconds, baseline.process.timeoutSeconds) + : config.timeoutSeconds; } function assertNoMxcReadwriteReadonlyOverlap(params: { diff --git a/extensions/mxc/src/plugin.ts b/extensions/mxc/src/plugin.ts index 4f7be2e0adc3d..7820ae9c5bad8 100644 --- a/extensions/mxc/src/plugin.ts +++ b/extensions/mxc/src/plugin.ts @@ -4,7 +4,7 @@ import { resolveMxcBinaryPath } from "./binary-resolver.js"; import { resolveConfig } from "./config.js"; import { createMxcSandboxBackendFactory } from "./mxc-backend-factory.js"; import { mxcSandboxBackendManager } from "./mxc-backend.js"; -import { assertMxcReadiness, warnMxcHostPrepIfNeeded } from "./readiness.js"; +import { warnMxcHostPrepIfNeeded } from "./readiness.js"; export function registerMxcPlugin(api: OpenClawPluginApi): void { if (api.registrationMode !== "full") { @@ -20,8 +20,6 @@ export function registerMxcPlugin(api: OpenClawPluginApi): void { return; } - // IsoEnvBroker availability is the ProcessContainer readiness signal for this plugin. - // Binary and host readiness checks fail load with actionable remediation. try { resolveMxcBinaryPath(config.mxcBinaryPath); } catch (err) { @@ -31,7 +29,6 @@ export function registerMxcPlugin(api: OpenClawPluginApi): void { { cause: err }, ); } - assertMxcReadiness(); // Advisory: warn (don't block) when the system drive lacks AppContainer // directory-access ACEs, which only degrades in-sandbox directory listing. diff --git a/extensions/mxc/src/readiness.ts b/extensions/mxc/src/readiness.ts index eeaa045334f08..0eb86a66fe7e9 100644 --- a/extensions/mxc/src/readiness.ts +++ b/extensions/mxc/src/readiness.ts @@ -12,28 +12,6 @@ function resolveWindowsSystemExecutable(name: string): string { return path.win32.join(systemRoot || "C:\\Windows", "System32", name); } -// The IsoEnvBroker service is demand-started, so it does not need to be RUNNING -// at plugin load: we only require that it is installed. `sc.exe query` exits -// non-zero (1060) when the service is absent, which surfaces as a thrown error; -// a successful query means the service exists and Windows will start it on use. -function assertWindowsIsoEnvBrokerInstalled(deps: ReadinessDeps): void { - try { - deps.execFileSync(resolveWindowsSystemExecutable("sc.exe"), ["query", "IsoEnvBroker"], { - encoding: "utf-8", - stdio: "pipe", - timeout: 5_000, - windowsHide: true, - }); - } catch (error) { - const detail = error instanceof Error && error.message ? `: ${error.message.trim()}` : ""; - throw new Error( - `[mxc] MXC Windows ProcessContainer sandbox is not ready: IsoEnvBroker service is not installed${detail}. ` + - `Install the IsoEnvBroker service before enabling MXC sandbox execution.`, - { cause: error }, - ); - } -} - // AppContainer processes need directory-traversal/list rights on the system // drive root (C:\) to enumerate directories inside the sandbox. // `wxc-host-prep prepare-system-drive` adds ACEs for the well-known @@ -93,17 +71,3 @@ export function warnMxcHostPrepIfNeeded( warn(systemDrivePrepWarning(process.env.SystemDrive || "C:")); } } - -export function assertMxcReadiness( - params: { - platform?: NodeJS.Platform; - deps?: Partial; - } = {}, -): void { - const platform = params.platform ?? process.platform; - if (platform !== "win32") { - return; - } - const deps = { ...DEFAULT_DEPS, ...params.deps }; - assertWindowsIsoEnvBrokerInstalled(deps); -} diff --git a/extensions/mxc/src/sandbox-policy-loader.ts b/extensions/mxc/src/sandbox-policy-loader.ts index 9f03e69136e2f..c8e170adbeab3 100644 --- a/extensions/mxc/src/sandbox-policy-loader.ts +++ b/extensions/mxc/src/sandbox-policy-loader.ts @@ -149,7 +149,7 @@ function parseSandboxPolicyLayer(value: unknown, sourceLabel: string): SandboxPo } function mergeSandboxPolicyLayers(sources: readonly SandboxPolicySource[]): SandboxPolicyLayer { - const timeoutCandidates = [DEFAULT_SANDBOX_BASELINE.process.timeoutSeconds]; + const timeoutCandidates: number[] = []; const filesystem: BaselineFilesystemPolicyInput = { restrictToProjectDir: DEFAULT_SANDBOX_BASELINE.filesystem.restrictToProjectDir, additionalReadonlyPaths: [], @@ -187,9 +187,13 @@ function mergeSandboxPolicyLayers(sources: readonly SandboxPolicySource[]): Sand (entry) => entry.path, ), }, - process: { - timeoutSeconds: Math.min(...timeoutCandidates), - }, + ...(timeoutCandidates.length > 0 + ? { + process: { + timeoutSeconds: Math.min(...timeoutCandidates), + }, + } + : {}), configuredPaths: { readonlyPaths: [...configuredPathMaps.readonlyPaths.values()], readwritePaths: [...configuredPathMaps.readwritePaths.values()], diff --git a/extensions/mxc/src/security-level.ts b/extensions/mxc/src/security-level.ts new file mode 100644 index 0000000000000..79c33e441edd7 --- /dev/null +++ b/extensions/mxc/src/security-level.ts @@ -0,0 +1,39 @@ +export const MXC_SECURITY_LEVELS = ["Locked Down", "Recommended", "Unprotected"] as const; + +export type MxcSecurityLevel = (typeof MXC_SECURITY_LEVELS)[number]; +export type MxcClipboardAccess = "none" | "read" | "all"; +export type MxcStandardFolderAccess = "none" | "readonly" | "readwrite"; + +export const DEFAULT_MXC_SECURITY_LEVEL: MxcSecurityLevel = "Recommended"; + +export type MxcSecurityPreset = { + networkEnabled: boolean; + standardFolderAccess: MxcStandardFolderAccess; + clipboard: MxcClipboardAccess; + timeoutSeconds: number; +}; + +const PRESETS: Record = { + "Locked Down": { + networkEnabled: false, + standardFolderAccess: "none", + clipboard: "none", + timeoutSeconds: 30, + }, + Recommended: { + networkEnabled: true, + standardFolderAccess: "readonly", + clipboard: "read", + timeoutSeconds: 60, + }, + Unprotected: { + networkEnabled: true, + standardFolderAccess: "readwrite", + clipboard: "all", + timeoutSeconds: 300, + }, +}; + +export function getMxcSecurityPreset(level: MxcSecurityLevel): MxcSecurityPreset { + return PRESETS[level]; +} diff --git a/extensions/mxc/src/windows-known-folders.ts b/extensions/mxc/src/windows-known-folders.ts new file mode 100644 index 0000000000000..037dfe9cff193 --- /dev/null +++ b/extensions/mxc/src/windows-known-folders.ts @@ -0,0 +1,128 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +export type WindowsStandardFolders = { + documents?: string; + downloads?: string; + desktop?: string; +}; + +type ResolveWindowsStandardFoldersOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + queryRegistryValue?: (valueName: string) => string; + warn?: (message: string) => void; +}; + +const USER_SHELL_FOLDERS_KEY = + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders"; +const DOWNLOADS_VALUE = "{374DE290-123F-4565-9164-39C4925E467B}"; +const CACHE_RETRY_MS = 60_000; + +let cachedFolders: WindowsStandardFolders | undefined; +let cachedAt = 0; + +function queryUserShellFolder(valueName: string): string { + return execFileSync("reg.exe", ["query", USER_SHELL_FOLDERS_KEY, "/v", valueName], { + encoding: "utf8", + windowsHide: true, + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function expandEnvironmentVariables(value: string, env: NodeJS.ProcessEnv): string | undefined { + let unresolved = false; + const expanded = value.replace(/%([^%]+)%/g, (_match, name: string) => { + const entry = Object.entries(env).find( + ([key]) => key.toLowerCase() === name.toLowerCase(), + )?.[1]; + if (!entry) { + unresolved = true; + return ""; + } + return entry; + }); + return unresolved ? undefined : expanded; +} + +function parseRegistryPath( + output: string, + valueName: string, + env: NodeJS.ProcessEnv, +): string | undefined { + const match = output.match( + new RegExp(`^\\s*${escapeRegExp(valueName)}\\s+REG_(?:EXPAND_)?SZ\\s+(.+?)\\s*$`, "imu"), + ); + const registryPath = match?.[1]; + if (!registryPath) { + return undefined; + } + const expanded = expandEnvironmentVariables(registryPath, env); + return expanded && path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : undefined; +} + +function resolveFolder( + label: string, + valueName: string, + options: Required< + Pick + >, +): string | undefined { + try { + const resolved = parseRegistryPath( + options.queryRegistryValue(valueName), + valueName, + options.env, + ); + if (!resolved) { + options.warn(`[mxc] Could not resolve the ${label} known folder; omitting its preset grant.`); + } + return resolved; + } catch { + options.warn(`[mxc] Could not resolve the ${label} known folder; omitting its preset grant.`); + return undefined; + } +} + +export function resolveWindowsStandardFolders( + options: ResolveWindowsStandardFoldersOptions = {}, +): WindowsStandardFolders { + const useCache = + options.platform === undefined && + options.env === undefined && + options.queryRegistryValue === undefined && + options.warn === undefined; + if ( + useCache && + cachedFolders && + (Object.values(cachedFolders).every((folder) => folder !== undefined) || + Date.now() - cachedAt < CACHE_RETRY_MS) + ) { + return cachedFolders; + } + + const platform = options.platform ?? process.platform; + if (platform !== "win32") { + return {}; + } + + const resolverOptions = { + env: options.env ?? process.env, + queryRegistryValue: options.queryRegistryValue ?? queryUserShellFolder, + warn: options.warn ?? console.warn, + }; + const resolved = { + documents: resolveFolder("Documents", "Personal", resolverOptions), + downloads: resolveFolder("Downloads", DOWNLOADS_VALUE, resolverOptions), + desktop: resolveFolder("Desktop", "Desktop", resolverOptions), + }; + + if (useCache) { + cachedFolders = resolved; + cachedAt = Date.now(); + } + return resolved; +} diff --git a/extensions/mxc/test/config.test.ts b/extensions/mxc/test/config.test.ts index 21715e76d0fa6..0b554a5bcdeae 100644 --- a/extensions/mxc/test/config.test.ts +++ b/extensions/mxc/test/config.test.ts @@ -6,26 +6,28 @@ describe("resolveConfig", () => { test("uses defaults only when config is omitted", () => { expect(resolveConfig(undefined)).toEqual({ mxcBinaryPath: undefined, + securityLevel: "Recommended", containment: "process", - network: "none", - timeoutSeconds: 120, + network: "default", + timeoutSeconds: 60, debug: false, }); const config = resolveConfig({}); expect(config).toEqual({ mxcBinaryPath: undefined, + securityLevel: "Recommended", containment: "process", - network: "none", - timeoutSeconds: 120, + network: "default", + timeoutSeconds: 60, debug: false, mxcPolicyPaths: undefined, }); - expect(config).not.toHaveProperty("timeoutSecondsConfigured"); }); - test("applies valid overrides and preserves explicit timeout configuration", () => { + test("applies valid overrides", () => { const config = resolveConfig({ + securityLevel: "Unprotected", mxcBinaryPath: " C:\\custom\\wxc-exec.exe ", containment: "processcontainer", network: "default", @@ -39,10 +41,10 @@ describe("resolveConfig", () => { expect(config).toEqual({ mxcBinaryPath: "C:\\custom\\wxc-exec.exe", + securityLevel: "Unprotected", containment: "processcontainer", network: "default", timeoutSeconds: 60, - timeoutSecondsConfigured: true, debug: true, mxcPolicyPaths: [ "C:\\ProgramData\\openclaw\\mxc-machine-policy.json", @@ -51,6 +53,29 @@ describe("resolveConfig", () => { }); }); + test("does not allow the network override to weaken Locked Down", () => { + expect(resolveConfig({ securityLevel: "Locked Down", network: "default" }).network).toBe( + "none", + ); + expect(resolveConfig({ securityLevel: "Recommended", network: "none" }).network).toBe("none"); + }); + + test.each([ + ["Locked Down", "none", 30], + ["Recommended", "default", 60], + ["Unprotected", "default", 300], + ] as const)("maps the %s preset defaults", (securityLevel, network, timeoutSeconds) => { + expect(resolveConfig({ securityLevel })).toEqual({ + mxcBinaryPath: undefined, + securityLevel, + containment: "process", + network, + timeoutSeconds, + debug: false, + mxcPolicyPaths: undefined, + }); + }); + test("rejects invalid root values and unknown keys", () => { expect(() => resolveConfig(null)).toThrow(/Invalid mxc plugin config/u); expect(() => resolveConfig("bad")).toThrow(/Invalid mxc plugin config/u); @@ -73,11 +98,12 @@ describe("resolveConfig", () => { test("rejects malformed enums and types instead of silently falling back", () => { expect(() => resolveConfig({ network: "allow-all" })).toThrow(/network/u); + expect(() => resolveConfig({ securityLevel: "Balanced" })).toThrow(/securityLevel/u); expect(() => resolveConfig({ debug: "true" })).toThrow(/debug/u); expect(() => resolveConfig({ mxcBinaryPath: " " })).toThrow(/mxcBinaryPath/u); }); - test("enforces timeout bounds and only marks configured timeouts when supplied", () => { + test("enforces timeout bounds", () => { expect(() => resolveConfig({ timeoutSeconds: 0 })).toThrow(/>= 1/u); expect(() => resolveConfig({ timeoutSeconds: -5 })).toThrow(/>= 1/u); expect(() => resolveConfig({ timeoutSeconds: "fast" })).toThrow(/timeoutSeconds/u); @@ -87,7 +113,6 @@ describe("resolveConfig", () => { const config = resolveConfig({ timeoutSeconds: MAX_TIMER_TIMEOUT_SECONDS }); expect(config.timeoutSeconds).toBe(MAX_TIMER_TIMEOUT_SECONDS); - expect(config.timeoutSecondsConfigured).toBe(true); }); test("trims and validates mxcPolicyPaths as absolute paths", () => { @@ -101,16 +126,23 @@ describe("resolveConfig", () => { }); describe("createMxcPluginConfigSchema", () => { - test("publishes the same timeout cap in the plugin schema", () => { + test("publishes preset and timeout contracts in the plugin schema", () => { const jsonSchema = createMxcPluginConfigSchema().jsonSchema as { - properties?: { timeoutSeconds?: unknown }; + properties?: { securityLevel?: unknown; timeoutSeconds?: unknown }; }; + expect(jsonSchema.properties?.securityLevel).toEqual({ + type: "string", + enum: ["Locked Down", "Recommended", "Unprotected"], + default: "Recommended", + description: + "Windows-aligned baseline for network, standard folders, clipboard, and timeout.", + }); expect(jsonSchema.properties?.timeoutSeconds).toEqual({ type: "number", minimum: 1, maximum: MAX_TIMER_TIMEOUT_SECONDS, description: - "Per-command execution timeout in seconds. Capped to the sandbox policy baseline timeout when both are set.", + "Optional preset timeout override in seconds. Capped to the sandbox policy baseline timeout when both are set.", }); }); }); diff --git a/extensions/mxc/test/mxc-backend.test.ts b/extensions/mxc/test/mxc-backend.test.ts index af81f8673f9dc..848d870439e1b 100644 --- a/extensions/mxc/test/mxc-backend.test.ts +++ b/extensions/mxc/test/mxc-backend.test.ts @@ -18,11 +18,14 @@ import { resolveConfig, type MxcConfig } from "../src/config.js"; import { createMxcSandboxBackendFactory } from "../src/mxc-backend-factory.js"; import { createMxcSandboxBackendHandle, mxcSandboxBackendManager } from "../src/mxc-backend.js"; -const { spawnCommandMock, execFileSyncMock, mockedHomeDir } = vi.hoisted(() => ({ - spawnCommandMock: vi.fn(), - execFileSyncMock: vi.fn(), - mockedHomeDir: { value: undefined as string | undefined }, -})); +const { spawnCommandMock, execFileSyncMock, mockedHomeDir, standardFoldersMock } = vi.hoisted( + () => ({ + spawnCommandMock: vi.fn(), + execFileSyncMock: vi.fn(), + mockedHomeDir: { value: undefined as string | undefined }, + standardFoldersMock: vi.fn(() => ({})), + }), +); vi.mock("node:os", async (importOriginal) => { const actual = await importOriginal(); @@ -44,11 +47,15 @@ vi.mock("../src/binary-resolver.js", () => ({ resolveMxcBinaryPath: (configuredPath?: string) => configuredPath ?? "mxc-test-binary", })); +vi.mock("../src/windows-known-folders.js", () => ({ + resolveWindowsStandardFolders: standardFoldersMock, +})); + const baseConfig: MxcConfig = { + securityLevel: "Recommended", containment: "process", network: "none", timeoutSeconds: 120, - timeoutSecondsConfigured: true, debug: false, }; @@ -231,6 +238,8 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), }); + standardFoldersMock.mockReset(); + standardFoldersMock.mockReturnValue({}); mockedHomeDir.value = mkdtempSync(path.join(tmpdir(), "mxc-test-home-")); testDirs.push(mockedHomeDir.value); baseParams.workdir = mkdtempSync(path.join(tmpdir(), "mxc-test-workspace-")); @@ -275,15 +284,15 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests leastPrivilege: true, capabilities: [], ui: { - isolation: "container", + isolation: "desktop", desktopSystemControl: false, systemSettings: "none", ime: false, }, }); expect(ui).toEqual({ - disable: true, - clipboard: "none", + disable: false, + clipboard: "read", injection: false, }); expect(processConfig.commandLine).toBe(`${expectedShell} /d /s /c "echo hello"`); @@ -388,7 +397,7 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests const network = objectField(cfg, "network"); const env = stringArrayField(processConfig, "env"); expect(cfg.containment).toBe("process"); - expect(processContainer.ui).toMatchObject({ isolation: "container" }); + expect(processContainer.ui).toMatchObject({ isolation: "desktop" }); expect(processContainer.leastPrivilege).toBe(true); expect(processContainer.capabilities).toEqual([]); expect(network.enforcementMode).toBe("capabilities"); @@ -446,6 +455,112 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests expect(processContainer.capabilities).toEqual(["internetClient"]); }); + test.each([ + { + securityLevel: "Locked Down" as const, + networkPolicy: "block", + clipboard: "none", + isolation: "container", + timeout: 30_000, + folderAccess: "none", + }, + { + securityLevel: "Recommended" as const, + networkPolicy: "allow", + clipboard: "read", + isolation: "desktop", + timeout: 60_000, + folderAccess: "readonly", + }, + { + securityLevel: "Unprotected" as const, + networkPolicy: "allow", + clipboard: "all", + isolation: "desktop", + timeout: 300_000, + folderAccess: "readwrite", + }, + ])( + "maps the $securityLevel preset into the final MXC payload", + async ({ securityLevel, networkPolicy, clipboard, isolation, timeout, folderAccess }) => { + const standardFolders = { + documents: path.join(mockedHomeDir.value ?? "", "Documents"), + downloads: path.join(mockedHomeDir.value ?? "", "Downloads"), + desktop: path.join(mockedHomeDir.value ?? "", "Desktop"), + }; + for (const folder of Object.values(standardFolders)) { + mkdirSync(folder, { recursive: true }); + } + standardFoldersMock.mockReturnValue(standardFolders); + + const handle = createMxcSandboxBackendHandle({ + ...baseParams, + config: resolveConfig({ securityLevel }), + }); + const spec = await handle.buildExecSpec({ + command: "echo hello", + env: {}, + usePty: false, + }); + const cfg = decodeContainerConfig(spec.argv); + const filesystem = objectField(cfg, "filesystem"); + const network = objectField(cfg, "network"); + const processConfig = objectField(cfg, "process"); + const processContainer = objectField(cfg, "processContainer"); + const ui = objectField(cfg, "ui"); + const standardFolderPaths = Object.values(standardFolders).map((folder) => + path.resolve(folder), + ); + + expect(network.defaultPolicy).toBe(networkPolicy); + expect(processConfig.timeout).toBe(timeout); + expect(ui).toMatchObject({ disable: false, clipboard, injection: false }); + expect(processContainer.ui).toMatchObject({ isolation }); + expect( + stringArrayField(filesystem, "readonlyPaths").filter((entry) => + standardFolderPaths.includes(entry), + ), + ).toEqual(folderAccess === "readonly" ? standardFolderPaths : []); + expect( + stringArrayField(filesystem, "readwritePaths").filter((entry) => + standardFolderPaths.includes(entry), + ), + ).toEqual(folderAccess === "readwrite" ? standardFolderPaths : []); + + await handle.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: spec.finalizeToken, + }); + }, + ); + + test("writable workspaces take precedence over automatic read-only standard-folder grants", async () => { + const documents = path.join(mockedHomeDir.value ?? "", "Documents"); + const downloads = path.join(mockedHomeDir.value ?? "", "Downloads"); + const desktop = path.join(mockedHomeDir.value ?? "", "Desktop"); + const workspace = path.join(documents, "OpenClaw"); + for (const folder of [workspace, downloads, desktop]) { + mkdirSync(folder, { recursive: true }); + } + standardFoldersMock.mockReturnValue({ documents, downloads, desktop }); + + const handle = createMxcSandboxBackendHandle({ + ...baseParams, + workdir: workspace, + config: resolveConfig({ securityLevel: "Recommended" }), + }); + const spec = await handle.buildExecSpec({ command: "echo hello", env: {}, usePty: false }); + const filesystem = objectField(decodeContainerConfig(spec.argv), "filesystem"); + + expect(stringArrayField(filesystem, "readwritePaths")).toContain(path.resolve(workspace)); + expect(stringArrayField(filesystem, "readonlyPaths")).not.toContain(path.resolve(documents)); + expect(stringArrayField(filesystem, "readonlyPaths")).toEqual( + expect.arrayContaining([path.resolve(downloads), path.resolve(desktop)]), + ); + }); + test("Windows process containment caps long AppContainer names", async () => { const handle = createMxcSandboxBackendHandle({ ...baseParams, @@ -1118,7 +1233,7 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests expect(env).toContain("CUSTOM_VAR=value"); }); - test("timeout falls back to the sandbox baseline when config uses defaults", async () => { + test("sandbox policy can shorten the preset timeout", async () => { const handle = createMxcSandboxBackendHandle({ ...baseParams, config: sandboxPolicyConfig( @@ -1135,7 +1250,7 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests expect(processConfig.timeout).toBe(45_000); }); - test("timeout falls back to the built-in baseline when no policy paths are configured", async () => { + test("the selected preset supplies the timeout when no policy path shortens it", async () => { const handle = createMxcSandboxBackendHandle({ ...baseParams, config: resolveConfig({}), @@ -1143,7 +1258,18 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests const spec = await handle.buildExecSpec({ command: "echo hello", env: {}, usePty: false }); const processConfig = objectField(decodeContainerConfig(spec.argv), "process"); - expect(processConfig.timeout).toBe(300_000); + expect(processConfig.timeout).toBe(60_000); + }); + + test("an explicit timeout can exceed the built-in baseline without a policy ceiling", async () => { + const handle = createMxcSandboxBackendHandle({ + ...baseParams, + config: resolveConfig({ timeoutSeconds: 600 }), + }); + const spec = await handle.buildExecSpec({ command: "echo hello", env: {}, usePty: false }); + + const processConfig = objectField(decodeContainerConfig(spec.argv), "process"); + expect(processConfig.timeout).toBe(600_000); }); test("timeout policy caps explicit config timeouts", async () => { @@ -1152,15 +1278,15 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests config: sandboxPolicyConfig( { filesystem: {}, - process: { timeoutSeconds: 45 }, + process: { timeoutSeconds: 600 }, }, - { ...baseConfig, timeoutSeconds: 120, timeoutSecondsConfigured: true }, + { ...baseConfig, timeoutSeconds: 900 }, ), }); const spec = await handle.buildExecSpec({ command: "echo hello", env: {}, usePty: false }); const processConfig = objectField(decodeContainerConfig(spec.argv), "process"); - expect(processConfig.timeout).toBe(45_000); + expect(processConfig.timeout).toBe(600_000); }); test("rejects per-command workdirs outside the sandbox workspace", async () => { @@ -1271,9 +1397,11 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests }); test("runShellCommand uses the inline Windows command line when no args are passed", async () => { + let containerConfig: Record | undefined; let processConfig: Record | undefined; spawnCommandMock.mockImplementationOnce(async (argv: string[]) => { - processConfig = objectField(decodeContainerConfig(argv), "process"); + containerConfig = decodeContainerConfig(argv); + processConfig = objectField(containerConfig, "process"); return { code: 0, signal: null, @@ -1293,6 +1421,14 @@ describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests const expectedShell = process.env.ComSpec?.trim() || "cmd.exe"; expect(processConfig?.commandLine).toBe(`${expectedShell} /d /s /c "echo hello"`); expect(String(processConfig?.commandLine)).not.toContain(".openclaw-mxc-cmd-"); + expect(objectField(containerConfig ?? {}, "ui")).toMatchObject({ + clipboard: "none", + }); + expect(objectField(objectField(containerConfig ?? {}, "processContainer"), "ui")).toMatchObject( + { + isolation: "container", + }, + ); }); test("runShellCommand timeout is capped by sandbox policy", async () => { diff --git a/extensions/mxc/test/plugin.test.ts b/extensions/mxc/test/plugin.test.ts index f4ce9ee0df0be..725167864576d 100644 --- a/extensions/mxc/test/plugin.test.ts +++ b/extensions/mxc/test/plugin.test.ts @@ -119,7 +119,7 @@ describe("registerMxcPlugin", () => { let warnSpy: ReturnType; beforeEach(() => { - assertMxcReadinessMock.mockClear(); + assertMxcReadinessMock.mockReset(); warnMxcHostPrepIfNeededMock.mockClear(); createMxcSandboxBackendFactoryMock.mockClear(); resolveMxcBinaryPathMock.mockReset(); @@ -147,7 +147,6 @@ describe("registerMxcPlugin", () => { "[mxc] Sandbox backend is Windows-only and not available on darwin. Plugin will be dormant.", ); expect(resolveMxcBinaryPathMock).not.toHaveBeenCalled(); - expect(assertMxcReadinessMock).not.toHaveBeenCalled(); expect(readBackend()).toEqual(original); expect(lifecycles).toEqual([]); expect(registerService).not.toHaveBeenCalled(); @@ -166,7 +165,6 @@ describe("registerMxcPlugin", () => { expect(warnSpy).not.toHaveBeenCalled(); expect(resolveMxcBinaryPathMock).not.toHaveBeenCalled(); - expect(assertMxcReadinessMock).not.toHaveBeenCalled(); expect(warnMxcHostPrepIfNeededMock).not.toHaveBeenCalled(); expect(createMxcSandboxBackendFactoryMock).not.toHaveBeenCalled(); expect(readBackend()).toEqual(original); @@ -184,7 +182,6 @@ describe("registerMxcPlugin", () => { registerMxcPlugin(api); expect(resolveMxcBinaryPathMock).toHaveBeenCalledWith(undefined); - expect(assertMxcReadinessMock).toHaveBeenCalledWith(); expect(warnMxcHostPrepIfNeededMock).toHaveBeenCalledWith(); expect(createMxcSandboxBackendFactoryMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -203,6 +200,21 @@ describe("registerMxcPlugin", () => { }, ); + test("registers without the legacy IsoEnvBroker service gate", () => { + assertMxcReadinessMock.mockImplementation(() => { + throw new Error("IsoEnvBroker service is not installed"); + }); + const { api } = createApi(); + + expect(() => registerMxcPlugin(api)).not.toThrow(); + expect(assertMxcReadinessMock).not.toHaveBeenCalled(); + expect(readBackend()).toEqual({ + factory: expect.any(Function), + manager: mxcSandboxBackendManagerMock, + resolveWorkdir: null, + }); + }); + test.each(["disable", "restart", "reset", "delete"] as const)( "preserves backend hooks during scoped %s cleanup", async (reason) => { @@ -283,7 +295,6 @@ describe("registerMxcPlugin", () => { ); expect(warnSpy).not.toHaveBeenCalled(); - expect(assertMxcReadinessMock).not.toHaveBeenCalled(); expect(readBackend()).toEqual(original); expect(lifecycles).toEqual([]); expect(registerService).not.toHaveBeenCalled(); diff --git a/extensions/mxc/test/readiness.test.ts b/extensions/mxc/test/readiness.test.ts index 16c3c39d8ea0e..1f2ec0b6e070e 100644 --- a/extensions/mxc/test/readiness.test.ts +++ b/extensions/mxc/test/readiness.test.ts @@ -1,30 +1,18 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { assertMxcReadiness, warnMxcHostPrepIfNeeded } from "../src/readiness.js"; +import { warnMxcHostPrepIfNeeded } from "../src/readiness.js"; const SYSTEM32 = path.win32.join( process.env.SystemRoot || process.env.WINDIR || "C:\\Windows", "System32", ); const ICACLS = path.win32.join(SYSTEM32, "icacls.exe"); -const SC_EXE = path.win32.join(SYSTEM32, "sc.exe"); -function depsFor(params: { - isoEnvBroker: "missing" | "running" | "stopped"; - systemDriveAcl?: string; -}) { +function depsFor(params: { systemDriveAcl?: string }) { const systemDriveAcl = params.systemDriveAcl ?? "C:\\ BUILTIN\\Administrators:(OI)(CI)(F)\n S-1-15-2-1:(R)\n"; const exec = vi.fn((command: string) => { - if (command === SC_EXE) { - if (params.isoEnvBroker === "missing") { - throw new Error("The specified service does not exist as an installed service."); - } - return params.isoEnvBroker === "stopped" - ? "STATE : 1 STOPPED" - : "STATE : 4 RUNNING"; - } if (command === ICACLS) { return systemDriveAcl; } @@ -33,53 +21,10 @@ function depsFor(params: { return { execFileSync: exec }; } -describe("assertMxcReadiness", () => { - test("is a no-op on non-Windows platforms", () => { - const deps = depsFor({ isoEnvBroker: "missing" }); - - expect(() => assertMxcReadiness({ platform: "linux", deps })).not.toThrow(); - expect(deps.execFileSync).not.toHaveBeenCalled(); - }); - - test("accepts an installed IsoEnvBroker (system-drive prep is advisory, not gated)", () => { - const deps = depsFor({ isoEnvBroker: "running" }); - - expect(() => assertMxcReadiness({ platform: "win32", deps })).not.toThrow(); - expect(deps.execFileSync).toHaveBeenCalledWith( - SC_EXE, - ["query", "IsoEnvBroker"], - expect.any(Object), - ); - }); - - test("accepts an installed but stopped (demand-started) IsoEnvBroker", () => { - const deps = depsFor({ isoEnvBroker: "stopped" }); - - expect(() => assertMxcReadiness({ platform: "win32", deps })).not.toThrow(); - }); - - test("rejects Windows hosts when IsoEnvBroker is not installed", () => { - const deps = depsFor({ isoEnvBroker: "missing" }); - - expect(() => assertMxcReadiness({ platform: "win32", deps })).toThrow( - /IsoEnvBroker service is not installed/u, - ); - }); - - test("does NOT throw when system drive lacks AppContainer ACEs (advisory only)", () => { - const deps = depsFor({ - isoEnvBroker: "running", - systemDriveAcl: "C:\\ BUILTIN\\Administrators:(OI)(CI)(F)\n", - }); - - expect(() => assertMxcReadiness({ platform: "win32", deps })).not.toThrow(); - }); -}); - describe("warnMxcHostPrepIfNeeded", () => { test("is a no-op on non-Windows platforms", () => { const warn = vi.fn(); - const deps = depsFor({ isoEnvBroker: "running" }); + const deps = depsFor({}); warnMxcHostPrepIfNeeded({ platform: "linux", deps, warn }); expect(warn).not.toHaveBeenCalled(); @@ -88,7 +33,6 @@ describe("warnMxcHostPrepIfNeeded", () => { test("warns when the system drive lacks AppContainer ACEs", () => { const warn = vi.fn(); const deps = depsFor({ - isoEnvBroker: "running", systemDriveAcl: "C:\\ BUILTIN\\Administrators:(OI)(CI)(F)\n", }); @@ -99,7 +43,7 @@ describe("warnMxcHostPrepIfNeeded", () => { test("stays silent when the system drive is prepared (SID form)", () => { const warn = vi.fn(); - const deps = depsFor({ isoEnvBroker: "running" }); + const deps = depsFor({}); warnMxcHostPrepIfNeeded({ platform: "win32", deps, warn }); expect(warn).not.toHaveBeenCalled(); @@ -108,7 +52,6 @@ describe("warnMxcHostPrepIfNeeded", () => { test("stays silent when the system drive is prepared (display-name form)", () => { const warn = vi.fn(); const deps = depsFor({ - isoEnvBroker: "running", systemDriveAcl: "C:\\ APPLICATION PACKAGES:(R)\n BUILTIN\\Administrators:(F)\n", }); diff --git a/extensions/mxc/test/windows-known-folders.test.ts b/extensions/mxc/test/windows-known-folders.test.ts new file mode 100644 index 0000000000000..9283ced92d239 --- /dev/null +++ b/extensions/mxc/test/windows-known-folders.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from "vitest"; +import { resolveWindowsStandardFolders } from "../src/windows-known-folders.js"; + +const DOWNLOADS_VALUE = "{374DE290-123F-4565-9164-39C4925E467B}"; + +function registryOutput(valueName: string, value: string): string { + return [ + "", + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", + ` ${valueName} REG_EXPAND_SZ ${value}`, + "", + ].join("\r\n"); +} + +describe("resolveWindowsStandardFolders", () => { + test("resolves redirected folders for the current Windows identity", () => { + const values: Record = { + Personal: "%OneDrive%\\Documents", + [DOWNLOADS_VALUE]: "D:\\Downloads", + Desktop: "%USERPROFILE%\\Desktop", + }; + + expect( + resolveWindowsStandardFolders({ + platform: "win32", + env: { + OneDrive: "C:\\Users\\Agent\\OneDrive - Microsoft", + USERPROFILE: "C:\\Users\\Agent", + }, + queryRegistryValue: (valueName) => { + const value = values[valueName]; + if (!value) { + throw new Error(`missing fixture for ${valueName}`); + } + return registryOutput(valueName, value); + }, + warn: vi.fn(), + }), + ).toEqual({ + documents: "C:\\Users\\Agent\\OneDrive - Microsoft\\Documents", + downloads: "D:\\Downloads", + desktop: "C:\\Users\\Agent\\Desktop", + }); + }); + + test("omits unresolved or malformed folders with a warning", () => { + const warn = vi.fn(); + + expect( + resolveWindowsStandardFolders({ + platform: "win32", + env: { USERPROFILE: "C:\\Users\\Agent" }, + queryRegistryValue: (valueName) => { + if (valueName === "Personal") { + return registryOutput(valueName, "%MISSING%\\Documents"); + } + if (valueName === DOWNLOADS_VALUE) { + return registryOutput(valueName, "Downloads"); + } + return ` ${valueName} REG_BINARY 00`; + }, + warn, + }), + ).toEqual({ + documents: undefined, + downloads: undefined, + desktop: undefined, + }); + expect(warn).toHaveBeenCalledTimes(3); + }); + + test("returns no standard-folder grants outside Windows", () => { + expect(resolveWindowsStandardFolders({ platform: "linux" })).toEqual({}); + }); +});