fix(secrets): preserve Windows ACL diagnostics (#120211)

* fix(secrets): preserve Windows ACL diagnostics

* fix(secrets): make Windows path security proof deterministic

* test(secrets): isolate Windows ACL tool failures

* test(secrets): preserve ACL preload process contract

* test(ci): route Doctor ACL proof to Windows

* test(qa): normalize Clack note borders

* test(ci): register Windows ACL preload for deadcode checks
This commit is contained in:
Vincent Koc
2026-08-09 03:48:42 +08:00
committed by GitHub
parent 1889764369
commit 38039f5ea8
14 changed files with 618 additions and 25 deletions
+2
View File
@@ -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.
+1 -1
View File
@@ -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",
+9 -1
View File
@@ -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;
}
@@ -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",
});
});
});
@@ -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", () => {
@@ -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,
};
}
}
@@ -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);
}
});
});
+29 -1
View File
@@ -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;
+140 -11
View File
@@ -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();
}
});
});
});
+12 -1
View File
@@ -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.`,
);
}
+92
View File
@@ -60,3 +60,95 @@ export function withMockedWindowsPlatform<T>(run: () => T): T;
export function withMockedWindowsPlatform<T>(run: () => T | Promise<T>): T | Promise<T> {
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<T>(
missingSystemRoot: string,
run: () => Promise<T>,
): Promise<T>;
export function withMockedWindowsAclVerificationUnavailable<T>(
missingSystemRoot: string,
run: () => T,
): T;
export function withMockedWindowsAclVerificationUnavailable<T>(
missingSystemRoot: string,
run: () => T | Promise<T>,
): T | Promise<T> {
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;
}
}
@@ -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<NonNullable<OpenClawConfig["gateway"]>["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<void> {
@@ -45,6 +50,24 @@ function localGatewayConfig(token?: GatewayToken): OpenClawConfig {
};
}
async function expectAclFixturePreservesExecFileContract(preloadUrl: string): Promise<void> {
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,
})}`,
);
+41
View File
@@ -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();
+12
View File
@@ -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",