diff --git a/config/knip.all-exports.config.ts b/config/knip.all-exports.config.ts index b9851d7a8d26..43fe2929fb82 100644 --- a/config/knip.all-exports.config.ts +++ b/config/knip.all-exports.config.ts @@ -56,6 +56,8 @@ const ROOT_TEST_ENTRY_GLOBS = [ // Invoked directly by the Docker image-auth scenario. "test/e2e/qa-lab/runtime/openai-image-auth-docker-client.ts!", "test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts!", + // Loaded through NODE_OPTIONS by the Doctor SecretRef E2E; no static import edge exists. + "test/fixtures/windows-acl-tools-unavailable.mjs!", // QA scenario YAML dispatches these scripts/tests by path rather than import. ...QA_SCENARIO_EXECUTION_ENTRIES, // Invoked directly by the sandbox bind-conflict E2E verification script. diff --git a/package.json b/package.json index 920a9e68744d..bafb94903058 100644 --- a/package.json +++ b/package.json @@ -1905,7 +1905,7 @@ "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", - "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "test:windows:schtasks:integration": "node scripts/run-with-env.mjs CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index b45259422250..5617e996eaf4 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -53,6 +53,10 @@ const WINDOWS_SCOPE_RE = /^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive(?:\.worker(?:\.test)?)?|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:-(?:command|lifecycle)\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:android-(?:app-i18n|pin-version)\.ts|ci-run-timings\.mjs|e2e\/lib\/package-compat\.mjs|generate-bundled-channel-config-metadata\.ts|install\.ps1|openclaw-cross-os-release-checks\.ts|plan-release-workflow-matrix\.mjs|run-additional-boundary-checks\.mjs|verify-docker-attestations\.mjs|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)|lib\/(?:direct-run\.mjs|format-generated-module\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; const WINDOWS_TEST_SCOPE_RE = /^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive\.worker|store\.session-lifecycle-mutation)\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/(?:exec-allowlist-pattern|fs-safe-remove|ssh-client\.windows|update-managed-service-handoff-(?:command|lifecycle)|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; +const WINDOWS_SECRETREF_SCOPE_RE = + /^(?:src\/commands\/doctor-gateway-auth-token(?:\.windows\.test)?\.ts|src\/flows\/(?:doctor-core-checks|doctor-health-contributions)\.ts|src\/gateway\/(?:auth-token-resolution|resolve-configured-secret-input-string)\.ts|src\/infra\/(?:fs-safe|fs-safe-defaults|permissions)\.ts|src\/secrets\/(?:resolve|resolve-errors|test-node-command\.test-support)\.ts|src\/security\/audit-fs\.ts|src\/test-utils\/vitest-spies\.ts|test\/e2e\/qa-lab\/runtime\/doctor-auth-secretref-checks\.e2e\.test\.ts|test\/fixtures\/windows-acl-tools-unavailable\.mjs)$/; +const WINDOWS_SECRETREF_TEST_SCOPE_RE = + /^(?:src\/commands\/doctor-gateway-auth-token\.windows\.test\.ts|src\/secrets\/test-node-command\.test-support\.ts|test\/e2e\/qa-lab\/runtime\/doctor-auth-secretref-checks\.e2e\.test\.ts|test\/fixtures\/windows-acl-tools-unavailable\.mjs)$/; const WINDOWS_DAEMON_SCOPE_RE = /^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/; const CONTROL_UI_I18N_SCOPE_RE = @@ -158,8 +162,12 @@ export function detectChangedScope(changedPaths) { if ( (WINDOWS_SCOPE_RE.test(path) || WINDOWS_SQLITE_SCOPE_RE.test(path) || + WINDOWS_SECRETREF_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) && - (!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path)) + (!facts.isTestOnly || + WINDOWS_TEST_SCOPE_RE.test(path) || + WINDOWS_SECRETREF_TEST_SCOPE_RE.test(path) || + WINDOWS_DAEMON_SCOPE_RE.test(path)) ) { runWindows = true; } diff --git a/src/commands/doctor-gateway-auth-token.windows.test.ts b/src/commands/doctor-gateway-auth-token.windows.test.ts new file mode 100644 index 000000000000..9bed03b503bc --- /dev/null +++ b/src/commands/doctor-gateway-auth-token.windows.test.ts @@ -0,0 +1,64 @@ +// Windows Doctor service-token tests cover detailed SecretRef diagnostic rendering. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const mocks = vi.hoisted(() => ({ + resolveGatewayAuthToken: vi.fn(), +})); + +vi.mock("../gateway/auth-token-resolution.js", () => ({ + resolveGatewayAuthToken: mocks.resolveGatewayAuthToken, +})); + +const { resolveGatewayAuthTokenForService } = await import("./doctor-gateway-auth-token.js"); + +describe("resolveGatewayAuthTokenForService Windows diagnostics", () => { + beforeEach(() => { + mocks.resolveGatewayAuthToken.mockReset(); + }); + + it("preserves the detailed path-free ACL recovery from the canonical resolver", async () => { + const privateCommand = String.raw`C:\private\gateway-token-provider.cmd`; + const recovery = + "Windows path security could not be verified. Restore Windows path security verification, or use an existing provider command whose owner and ACLs OpenClaw can verify."; + const cfg: OpenClawConfig = { + gateway: { + auth: { + token: { + source: "exec", + provider: "execmain", + id: "gateway/token", + }, + }, + }, + secrets: { + providers: { + execmain: { + source: "exec", + command: privateCommand, + }, + }, + }, + }; + const env = {} as NodeJS.ProcessEnv; + mocks.resolveGatewayAuthToken.mockResolvedValue({ + secretRefConfigured: true, + unresolvedRefReason: recovery, + }); + + const resolved = await resolveGatewayAuthTokenForService(cfg, env, { + allowExecSecretRefs: true, + }); + + expect(resolved).toEqual({ + unavailableReason: `gateway.auth.token SecretRef is configured but unresolved (${recovery}).`, + }); + expect(resolved.unavailableReason).not.toContain(privateCommand); + expect(mocks.resolveGatewayAuthToken).toHaveBeenCalledWith({ + cfg, + env, + unresolvedReasonStyle: "detailed", + envFallback: "always", + }); + }); +}); diff --git a/src/gateway/resolve-configured-secret-input-string.test.ts b/src/gateway/resolve-configured-secret-input-string.test.ts index 89d1f748b813..d1ee43aa5040 100644 --- a/src/gateway/resolve-configured-secret-input-string.test.ts +++ b/src/gateway/resolve-configured-secret-input-string.test.ts @@ -1,13 +1,27 @@ /** * Tests configured secret input resolution for gateway method parameters. */ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; +import { withMockedWindowsAclVerificationUnavailable } from "../test-utils/vitest-spies.js"; import { resolveConfiguredSecretInputWithFallback, resolveRequiredConfiguredSecretRefInputString, } from "./resolve-configured-secret-input-string.js"; +let fixtureRoot = ""; + +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-secret-input-")); +}); + +afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); +}); + function createConfig(value: unknown): OpenClawConfig { return { gateway: { @@ -23,6 +37,26 @@ function createConfig(value: unknown): OpenClawConfig { } as OpenClawConfig; } +async function createWindowsAclUnavailableConfig() { + const filePath = path.join(fixtureRoot, "gateway-token.txt"); + await fs.writeFile(filePath, "secret-token", { mode: 0o600 }); + return { + filePath, + config: { + gateway: { + auth: { + token: { source: "file", provider: "filemain", id: "value" }, + }, + }, + secrets: { + providers: { + filemain: { source: "file", path: filePath, mode: "singleValue" }, + }, + }, + } as OpenClawConfig, + }; +} + describe("resolveConfiguredSecretInputWithFallback", () => { it("returns plaintext config value when present", async () => { const resolved = await resolveConfiguredSecretInputWithFallback({ @@ -147,6 +181,60 @@ describe("resolveConfiguredSecretInputWithFallback", () => { expect(resolved.unresolvedRefReason).toContain("gateway.auth.token SecretRef is unresolved"); expect(resolved.unresolvedRefReason).toContain("MISSING_GATEWAY_TOKEN"); }); + + it("keeps generic Windows ACL failures byte-compatible", async () => { + await withMockedWindowsAclVerificationUnavailable( + path.join(fixtureRoot, "missing-windows-system-root"), + async () => { + const { config } = await createWindowsAclUnavailableConfig(); + const resolved = await resolveConfiguredSecretInputWithFallback({ + config, + env: {} as NodeJS.ProcessEnv, + value: config.gateway?.auth?.token, + path: "gateway.auth.token", + }); + + expect(resolved.unresolvedRefReason).toBe( + "gateway.auth.token SecretRef is unresolved (file:filemain:value).", + ); + }, + ); + }); + + it("adds only the sanitized Windows ACL diagnostic in detailed mode", async () => { + await withMockedWindowsAclVerificationUnavailable( + path.join(fixtureRoot, "missing-windows-system-root"), + async () => { + const { config, filePath } = await createWindowsAclUnavailableConfig(); + const resolved = await resolveConfiguredSecretInputWithFallback({ + config, + env: {} as NodeJS.ProcessEnv, + value: config.gateway?.auth?.token, + path: "gateway.auth.token", + unresolvedReasonStyle: "detailed", + }); + + expect(resolved.unresolvedRefReason).toBe( + "gateway.auth.token SecretRef is unresolved (file:filemain:value). Windows path security could not be verified. Restore Windows path security verification, or use an existing secret file whose owner and ACLs OpenClaw can verify.", + ); + expect(resolved.unresolvedRefReason).not.toContain(filePath); + }, + ); + }); + + it("keeps unrelated detailed provider failures unchanged", async () => { + const resolved = await resolveConfiguredSecretInputWithFallback({ + config: createConfig("${MISSING_GATEWAY_TOKEN}"), + env: {} as NodeJS.ProcessEnv, + value: "${MISSING_GATEWAY_TOKEN}", + path: "gateway.auth.token", + unresolvedReasonStyle: "detailed", + }); + + expect(resolved.unresolvedRefReason).toBe( + "gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).", + ); + }); }); describe("resolveRequiredConfiguredSecretRefInputString", () => { diff --git a/src/gateway/resolve-configured-secret-input-string.ts b/src/gateway/resolve-configured-secret-input-string.ts index a27819fac919..b1c47e4c708a 100644 --- a/src/gateway/resolve-configured-secret-input-string.ts +++ b/src/gateway/resolve-configured-secret-input-string.ts @@ -5,6 +5,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { secretRefKey } from "../secrets/ref-contract.js"; +import { + describeSecretResolutionOperatorDiagnostic, + describeSecretResolutionOperatorRecovery, +} from "../secrets/resolve-errors.js"; import { resolveSecretRefValues } from "../secrets/resolve.js"; export type SecretInputUnresolvedReasonStyle = "generic" | "detailed"; // pragma: allowlist secret @@ -78,14 +82,22 @@ export async function resolveConfiguredSecretInputString(params: { }; } return { value: trimmed }; - } catch { + } catch (error) { + const operatorDiagnostic = + style === "detailed" ? describeSecretResolutionOperatorDiagnostic(error) : undefined; + const operatorRecovery = + style === "detailed" ? describeSecretResolutionOperatorRecovery(error) : undefined; + const unresolvedReason = buildUnresolvedReason({ + path: params.path, + style, + kind: "unresolved", + refLabel, + }); + const operatorDetail = [operatorDiagnostic, operatorRecovery].filter(Boolean).join(". "); return { - unresolvedRefReason: buildUnresolvedReason({ - path: params.path, - style, - kind: "unresolved", - refLabel, - }), + unresolvedRefReason: operatorDetail + ? `${unresolvedReason} ${operatorDetail}.` + : unresolvedReason, }; } } diff --git a/src/scripts/ci-changed-scope.windows.test.ts b/src/scripts/ci-changed-scope.windows.test.ts index 611c2acf01a1..1b2aca9a4be1 100644 --- a/src/scripts/ci-changed-scope.windows.test.ts +++ b/src/scripts/ci-changed-scope.windows.test.ts @@ -114,4 +114,39 @@ describe("detectChangedScope Windows routing", () => { }); } }); + + it("routes SecretRef path-security changes and native fixtures to Windows", () => { + for (const secretRefPath of [ + "src/commands/doctor-gateway-auth-token.ts", + "src/commands/doctor-gateway-auth-token.windows.test.ts", + "src/flows/doctor-core-checks.ts", + "src/flows/doctor-health-contributions.ts", + "src/gateway/auth-token-resolution.ts", + "src/gateway/resolve-configured-secret-input-string.ts", + "src/infra/fs-safe.ts", + "src/infra/fs-safe-defaults.ts", + "src/infra/permissions.ts", + "src/secrets/resolve-errors.ts", + "src/secrets/resolve.ts", + "src/secrets/test-node-command.test-support.ts", + "src/security/audit-fs.ts", + "src/test-utils/vitest-spies.ts", + "test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts", + "test/fixtures/windows-acl-tools-unavailable.mjs", + ]) { + expect(detectChangedScope([secretRefPath]), secretRefPath).toMatchObject({ + runNode: true, + runWindows: true, + }); + } + }); + + it("does not route SecretRef unit tests omitted from the Windows shard", () => { + for (const testPath of [ + "src/gateway/resolve-configured-secret-input-string.test.ts", + "src/secrets/resolve.test.ts", + ]) { + expect(detectChangedScope([testPath]).runWindows, testPath).toBe(false); + } + }); }); diff --git a/src/secrets/resolve-errors.ts b/src/secrets/resolve-errors.ts index 27f1605aa5be..17755fbc6eb8 100644 --- a/src/secrets/resolve-errors.ts +++ b/src/secrets/resolve-errors.ts @@ -9,6 +9,7 @@ type SecretRefResolutionCode = | "SECRET_REF_PROVIDER_CONTRACT"; type SecretProviderResolutionCode = + | "SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE" | "SECRET_PROVIDER_INVALID" | "SECRET_PROVIDER_NOT_CONFIGURED" | "SECRET_PROVIDER_UNAVAILABLE"; @@ -86,7 +87,10 @@ export function describeSecretResolutionError( value: unknown, ): SecretResolutionFailureReason | undefined { if (value instanceof SecretProviderResolutionError) { - return value.code === "SECRET_PROVIDER_UNAVAILABLE" ? "secret provider failed" : undefined; + return value.code === "SECRET_PROVIDER_UNAVAILABLE" || + value.code === "SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE" + ? "secret provider failed" + : undefined; } if (!(value instanceof SecretRefResolutionError)) { return undefined; @@ -106,6 +110,30 @@ export function describeSecretResolutionError( return undefined; } +/** Sanitized provider detail suitable for operator-facing diagnostics. */ +export function describeSecretResolutionOperatorDiagnostic(value: unknown): string | undefined { + if ( + value instanceof SecretProviderResolutionError && + value.code === "SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE" + ) { + return "Windows path security could not be verified"; + } + return undefined; +} + +/** Sanitized recovery action suitable for operator-facing diagnostics. */ +export function describeSecretResolutionOperatorRecovery(value: unknown): string | undefined { + if ( + !(value instanceof SecretProviderResolutionError) || + value.code !== "SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE" + ) { + return undefined; + } + return value.source === "exec" + ? "Restore Windows path security verification, or use an existing provider command whose owner and ACLs OpenClaw can verify" + : "Restore Windows path security verification, or use an existing secret file whose owner and ACLs OpenClaw can verify"; +} + export function providerResolutionError(params: { code?: SecretProviderResolutionCode; source: SecretRefSource; diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 2b1e6cdfc72d..7ea5c6d3bf68 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -12,9 +12,18 @@ import { writeForkingNoOutputScript, } from "../test-utils/process-tree.js"; import { INVALID_EXEC_SECRET_REF_IDS } from "../test-utils/secret-ref-test-vectors.js"; -import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; +import { + withMockedWindowsAclVerificationUnavailable, + withMockedWindowsPlatform, +} from "../test-utils/vitest-spies.js"; +import { + describeSecretResolutionError, + describeSecretResolutionOperatorDiagnostic, + describeSecretResolutionOperatorRecovery, +} from "./resolve-errors.js"; import { isMissingSecretRefResolutionError, + isProviderScopedSecretResolutionError, resolveSecretRefString, resolveSecretRefValue, resolveSecretRefValues, @@ -793,13 +802,14 @@ describe("secret ref resolver", () => { }); it("fails closed on Windows when file provider ACL source is unknown", async () => { - await withMockedWindowsPlatform(async () => { - const dir = await createCaseDir("win-acl"); - const filePath = path.join(dir, "secrets.json"); - await writeSecureFile(filePath, '{"token":"abc123"}'); + const dir = await createCaseDir("win-acl"); + await withMockedWindowsAclVerificationUnavailable( + path.join(dir, "missing-windows-system-root"), + async () => { + const filePath = path.join(dir, "secrets.json"); + await writeSecureFile(filePath, '{"token":"abc123"}'); - await expect( - resolveSecretRefString( + const error = await resolveSecretRefString( { source: "file", provider: "filemain", id: "/token" }, { config: { @@ -810,16 +820,135 @@ describe("secret ref resolver", () => { }, }, }, - ), - ).rejects.toThrow(/ACL verification unavailable on Windows/); + ).catch((caught: unknown) => caught); + + expect(isProviderScopedSecretResolutionError(error)).toBe(true); + if (!isProviderScopedSecretResolutionError(error)) { + return; + } + expect(error.code).toBe("SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE"); + expect(describeSecretResolutionError(error)).toBe("secret provider failed"); + expect(describeSecretResolutionOperatorDiagnostic(error)).toBe( + "Windows path security could not be verified", + ); + expect(describeSecretResolutionOperatorRecovery(error)).toBe( + "Restore Windows path security verification, or use an existing secret file whose owner and ACLs OpenClaw can verify", + ); + }, + ); + }); + + it("keeps a missing Windows file provider path as a generic failure", async () => { + await withMockedWindowsPlatform(async () => { + const dir = await createCaseDir("win-file-missing"); + const filePath = path.join(dir, "missing.json"); + const error = await resolveSecretRefString( + { source: "file", provider: "filemain", id: "/token" }, + { + config: { + secrets: { + providers: { + filemain: createFileProviderConfig(filePath), + }, + }, + }, + }, + ).catch((caught: unknown) => caught); + + expect(isProviderScopedSecretResolutionError(error)).toBe(true); + if (!isProviderScopedSecretResolutionError(error)) { + return; + } + expect(error.code).toBe("SECRET_PROVIDER_UNAVAILABLE"); + expect(describeSecretResolutionError(error)).toBe("secret provider failed"); + expect(describeSecretResolutionOperatorDiagnostic(error)).toBeUndefined(); + expect(describeSecretResolutionOperatorRecovery(error)).toBeUndefined(); }); }); it("fails closed on Windows when exec provider ACL source is unknown", async () => { + const dir = await createCaseDir("win-exec-acl"); + await withMockedWindowsAclVerificationUnavailable( + path.join(dir, "missing-windows-system-root"), + async () => { + const markerPath = path.join(dir, "executed"); + const commandPath = path.join(dir, "resolver.sh"); + await writeSecureFile( + commandPath, + ["#!/bin/sh", `touch ${JSON.stringify(markerPath)}`].join("\n"), + 0o700, + ); + + const error = await resolveExecSecret(commandPath).catch((caught: unknown) => caught); + + expect(isProviderScopedSecretResolutionError(error)).toBe(true); + if (!isProviderScopedSecretResolutionError(error)) { + return; + } + expect(error.code).toBe("SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE"); + expect(describeSecretResolutionError(error)).toBe("secret provider failed"); + expect(describeSecretResolutionOperatorDiagnostic(error)).toBe( + "Windows path security could not be verified", + ); + expect(describeSecretResolutionOperatorRecovery(error)).toBe( + "Restore Windows path security verification, or use an existing provider command whose owner and ACLs OpenClaw can verify", + ); + await expect(fs.access(markerPath)).rejects.toThrow(); + }, + ); + }); + + it("keeps a missing Windows exec provider path as a generic failure", async () => { await withMockedWindowsPlatform(async () => { - await expect(resolveExecSecret(execProtocolV1ScriptPath)).rejects.toThrow( - /Move the command to a path whose ACLs OpenClaw can verify; there is no provider-level bypass/, + const dir = await createCaseDir("win-exec-missing"); + const commandPath = path.join(dir, "missing-resolver"); + const error = await resolveExecSecret(commandPath).catch((caught: unknown) => caught); + + expect(isProviderScopedSecretResolutionError(error)).toBe(true); + if (!isProviderScopedSecretResolutionError(error)) { + return; + } + expect(error.code).toBe("SECRET_PROVIDER_UNAVAILABLE"); + expect(describeSecretResolutionError(error)).toBe("secret provider failed"); + expect(describeSecretResolutionOperatorDiagnostic(error)).toBeUndefined(); + expect(describeSecretResolutionOperatorRecovery(error)).toBeUndefined(); + }); + }); + + it("keeps a failed Windows exec permission restat as a generic failure", async () => { + await withMockedWindowsPlatform(async () => { + const dir = await createCaseDir("win-exec-restat"); + const markerPath = path.join(dir, "executed"); + const commandPath = path.join(dir, "resolver.sh"); + await writeSecureFile( + commandPath, + ["#!/bin/sh", `touch ${JSON.stringify(markerPath)}`].join("\n"), + 0o700, ); + + const originalLstat = fs.lstat.bind(fs); + let commandPathStats = 0; + const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => { + if (String(args[0]) === commandPath && ++commandPathStats === 2) { + throw Object.assign(new Error("provider command disappeared"), { code: "ENOENT" }); + } + return await originalLstat(...args); + }); + try { + const error = await resolveExecSecret(commandPath).catch((caught: unknown) => caught); + + expect(isProviderScopedSecretResolutionError(error)).toBe(true); + if (!isProviderScopedSecretResolutionError(error)) { + return; + } + expect(error.code).toBe("SECRET_PROVIDER_UNAVAILABLE"); + expect(describeSecretResolutionError(error)).toBe("secret provider failed"); + expect(describeSecretResolutionOperatorDiagnostic(error)).toBeUndefined(); + expect(describeSecretResolutionOperatorRecovery(error)).toBeUndefined(); + await expect(fs.access(markerPath)).rejects.toThrow(); + } finally { + lstatSpy.mockRestore(); + } }); }); }); diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 00aee3159176..83f20c7404f1 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -99,7 +99,17 @@ function throwUnknownProviderResolutionError(params: { if (isSecretResolutionError(params.err)) { throw params.err; } + // fs-safe 0.5 exposes one fail-closed receipt for failed permission inspection, + // unavailable ACL data, and indeterminate owner trust. Keep the diagnostic path-free. + const isWindowsPathSecurityFailure = + process.platform === "win32" && + (params.source === "file" || params.source === "exec") && + params.err instanceof FsSafeError && + params.err.code === "permission-unverified"; throw providerResolutionError({ + ...(isWindowsPathSecurityFailure + ? { code: "SECRET_PROVIDER_PATH_SECURITY_UNVERIFIABLE" as const } + : {}), source: params.source, provider: params.provider, message: formatErrorMessage(params.err), @@ -245,7 +255,8 @@ async function assertSecurePath(params: { } if (process.platform === "win32" && perms.source === "unknown") { - throw new Error( + throw new FsSafeError( + "permission-unverified", `${params.label} ACL verification unavailable on Windows for ${effectivePath}. Move the command to a path whose ACLs OpenClaw can verify; there is no provider-level bypass.`, ); } diff --git a/src/test-utils/vitest-spies.ts b/src/test-utils/vitest-spies.ts index 4b4fffbc4b38..3c4555aca40f 100644 --- a/src/test-utils/vitest-spies.ts +++ b/src/test-utils/vitest-spies.ts @@ -60,3 +60,95 @@ export function withMockedWindowsPlatform(run: () => T): T; export function withMockedWindowsPlatform(run: () => T | Promise): T | Promise { return withMockedPlatform("win32", run); } + +const WINDOWS_ACL_ENV_KEYS = new Set([ + "fs_safe_native_mode", + "openclaw_fs_safe_native_mode", + "systemroot", + "windir", +]); +const NODE_OPTIONS_ENV_KEY = "node_options"; + +function takeWindowsAclEnvSnapshot(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(env).filter(([key]) => WINDOWS_ACL_ENV_KEYS.has(key.toLowerCase())), + ); +} + +function clearWindowsAclEnv(env: NodeJS.ProcessEnv): void { + for (const key of Object.keys(env)) { + if (WINDOWS_ACL_ENV_KEYS.has(key.toLowerCase())) { + delete env[key]; + } + } +} + +function forceFsSafeNativeFallback(env: NodeJS.ProcessEnv): void { + for (const key of Object.keys(env)) { + const normalized = key.toLowerCase(); + if (normalized === "fs_safe_native_mode" || normalized === "openclaw_fs_safe_native_mode") { + delete env[key]; + } + } + env.FS_SAFE_NATIVE_MODE = "off"; + env.OPENCLAW_FS_SAFE_NATIVE_MODE = "off"; +} + +function forceWindowsAclVerificationUnavailable( + env: NodeJS.ProcessEnv, + missingSystemRoot: string, +): void { + clearWindowsAclEnv(env); + // Disable the optional native backend and make both Windows ACL tool paths + // unavailable, so permission checks exercise the real fail-closed result. + forceFsSafeNativeFallback(env); + env.SystemRoot = missingSystemRoot; + env.WINDIR = missingSystemRoot; +} + +export function forceNativeWindowsAclToolsUnavailable( + env: NodeJS.ProcessEnv, + preloadUrl: string, +): void { + forceFsSafeNativeFallback(env); + let existingNodeOptions: string | undefined; + for (const key of Object.keys(env)) { + if (key.toLowerCase() !== NODE_OPTIONS_ENV_KEY) { + continue; + } + existingNodeOptions ??= env[key]; + delete env[key]; + } + env.NODE_OPTIONS = [existingNodeOptions, `--import=${preloadUrl}`].filter(Boolean).join(" "); +} + +export function withMockedWindowsAclVerificationUnavailable( + missingSystemRoot: string, + run: () => Promise, +): Promise; +export function withMockedWindowsAclVerificationUnavailable( + missingSystemRoot: string, + run: () => T, +): T; +export function withMockedWindowsAclVerificationUnavailable( + missingSystemRoot: string, + run: () => T | Promise, +): T | Promise { + const snapshot = takeWindowsAclEnvSnapshot(process.env); + forceWindowsAclVerificationUnavailable(process.env, missingSystemRoot); + const restore = () => { + clearWindowsAclEnv(process.env); + Object.assign(process.env, snapshot); + }; + try { + const result = withMockedWindowsPlatform(run); + if (isPromiseLike(result)) { + return result.finally(restore); + } + restore(); + return result; + } catch (error) { + restore(); + throw error; + } +} diff --git a/test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts b/test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts index 5f5e9986a74b..cf0703e76fa4 100644 --- a/test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts @@ -1,10 +1,14 @@ // QA Lab product proof for doctor gateway auth and SecretRef behavior. +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { stripAnsiSequences } from "../../../../packages/terminal-core/src/ansi.js"; import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js"; import { withSecureTestNodeCommand } from "../../../../src/secrets/test-node-command.test-support.js"; +import { forceNativeWindowsAclToolsUnavailable } from "../../../../src/test-utils/vitest-spies.js"; import { createOpenClawTestInstance, type OpenClawTestInstance, @@ -12,6 +16,7 @@ import { let instance: OpenClawTestInstance | undefined; type GatewayToken = NonNullable["auth"]>["token"]; +const execFileAsync = promisify(execFile); afterEach(async () => { await instance?.cleanup(); @@ -23,7 +28,7 @@ function outputOf(result: { stderr: string; stdout: string }): string { } function normalizedOutputOf(result: { stderr: string; stdout: string }): string { - return stripAnsiSequences(outputOf(result)).replace(/\s+/g, " ").trim(); + return stripAnsiSequences(outputOf(result)).replaceAll("│", " ").replace(/\s+/g, " ").trim(); } async function writeConfig(config: OpenClawConfig): Promise { @@ -45,6 +50,24 @@ function localGatewayConfig(token?: GatewayToken): OpenClawConfig { }; } +async function expectAclFixturePreservesExecFileContract(preloadUrl: string): Promise { + const probe = [ + 'import { execFile } from "node:child_process";', + 'import { promisify } from "node:util";', + 'const promise = promisify(execFile)(process.execPath, ["--version"], { encoding: "utf8" });', + 'if (!promise.child || typeof promise.child.kill !== "function") process.exit(2);', + "const result = await promise;", + 'if (!result || typeof result.stdout !== "string" || typeof result.stderr !== "string") process.exit(3);', + 'process.stdout.write("ok");', + ].join(""); + const result = await execFileAsync( + process.execPath, + [`--import=${preloadUrl}`, "--input-type=module", "--eval", probe], + { encoding: "utf8" }, + ); + expect(result).toEqual({ stdout: "ok", stderr: "" }); +} + describe("doctor auth and SecretRef product proof", () => { it( "preserves SecretRef ownership while proving resolution, fallback, exec gating, and token generation", @@ -103,6 +126,50 @@ describe("doctor auth and SecretRef product proof", () => { }; expect(unresolvedConfig.gateway?.auth?.token).toEqual(unresolvedRef); + const aclFixtureUrl = pathToFileURL( + path.resolve("test/fixtures/windows-acl-tools-unavailable.mjs"), + ).href; + await expectAclFixturePreservesExecFileContract(aclFixtureUrl); + if (process.platform === "win32") { + forceNativeWindowsAclToolsUnavailable(instance.env, aclFixtureUrl); + } + + const filePath = path.join(instance.stateDir, "doctor-file-secretref.json"); + const fileSecret = "qa-file-token"; + await fs.writeFile(filePath, JSON.stringify({ gateway: { token: fileSecret } }), { + mode: 0o600, + }); + await writeConfig({ + ...localGatewayConfig({ + source: "file", + provider: "filemain", + id: "/gateway/token", + }), + secrets: { + providers: { + filemain: { + source: "file", + path: filePath, + }, + }, + }, + }); + const fileResult = await instance.cli( + ["doctor", "--non-interactive", "--no-workspace-suggestions"], + { timeoutMs: 120_000 }, + ); + expect(fileResult.code).toBe(0); + const fileOutput = normalizedOutputOf(fileResult); + if (process.platform === "win32") { + expect(fileOutput).toMatch( + /Gateway token SecretRef could not be resolved: .*Windows path security could not be verified\. Restore Windows path security verification, or use an existing secret file whose owner and ACLs OpenClaw can verify\./, + ); + expect(fileOutput).not.toContain(filePath); + } else { + expect(fileOutput).not.toContain("Gateway token SecretRef could not be resolved"); + } + expect(fileOutput).not.toContain(fileSecret); + const execMarker = path.join(instance.stateDir, "doctor-exec-secretref.marker"); const execScript = [ "const fs = require('node:fs');", @@ -146,8 +213,10 @@ describe("doctor auth and SecretRef product proof", () => { const execAllowedOutput = normalizedOutputOf(execAllowed); if (process.platform === "win32") { expect(execAllowedOutput).toMatch( - /Gateway token SecretRef could not be resolved: .*ACL verification unavailable on Windows/, + /Gateway token SecretRef could not be resolved: .*Windows path security could not be verified\. Restore Windows path security verification, or use an existing provider command whose owner and ACLs OpenClaw can verify\./, ); + expect(execAllowedOutput).not.toContain(command); + expect(execAllowedOutput).not.toContain(execMarker); await expect(fs.access(execMarker)).rejects.toThrow(); } else { await expect(fs.readFile(execMarker, "utf8")).resolves.toBe("executed"); @@ -183,6 +252,8 @@ describe("doctor auth and SecretRef product proof", () => { execRefGated: true, execRefAllowed: process.platform !== "win32", execRefWindowsAclBlocked: process.platform === "win32", + fileRefAllowed: process.platform !== "win32", + fileRefWindowsAclBlocked: process.platform === "win32", generatedTokenPersisted: true, })}`, ); diff --git a/test/fixtures/windows-acl-tools-unavailable.mjs b/test/fixtures/windows-acl-tools-unavailable.mjs new file mode 100644 index 000000000000..d89fc8a8a0a2 --- /dev/null +++ b/test/fixtures/windows-acl-tools-unavailable.mjs @@ -0,0 +1,41 @@ +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const WINDOWS_ACL_TOOLS = new Set(["icacls.exe", "powershell.exe", "whoami.exe"]); +const realExecFile = childProcess.execFile; +const missingToolPath = fileURLToPath(new URL("missing-windows-acl-tool.exe", import.meta.url)); + +function fixtureExecFile(command, ...args) { + if (!WINDOWS_ACL_TOOLS.has(path.win32.basename(String(command)).toLowerCase())) { + return realExecFile.call(this, command, ...args); + } + return realExecFile.call(this, missingToolPath, ...args); +} + +Object.defineProperty(fixtureExecFile, promisify.custom, { + value: function promisifiedExecFile(...args) { + let child; + const promise = new Promise((resolve, reject) => { + child = fixtureExecFile(...args, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); + promise.child = child; + return promise; + }, + configurable: false, + enumerable: false, + writable: false, +}); + +childProcess.execFile = fixtureExecFile; +syncBuiltinESMExports(); diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 8635c7011dba..c3ac11da71fd 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -197,6 +197,18 @@ describe("package scripts", () => { ); }); + it("runs Doctor SecretRef ACL coverage in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts", + ); + }); + + it("runs the Doctor managed-service SecretRef renderer in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "src/commands/doctor-gateway-auth-token.windows.test.ts", + ); + }); + it("runs legacy session importer atomicity coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "src/infra/state-migrations.legacy-session-store.test.ts",