Skip to content

Commit 074a639

Browse files
committed
fix doctor channel SecretRef preview
1 parent 575cae5 commit 074a639

10 files changed

Lines changed: 530 additions & 15 deletions

src/cli/command-config-resolution.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export async function resolveCommandConfigWithSecrets<TConfig extends OpenClawCo
1616
allowedPaths?: Set<string>;
1717
forcedActivePaths?: Set<string>;
1818
optionalActivePaths?: Set<string>;
19+
allowLocalExecSecretRefs?: boolean;
20+
scrubUnresolvedSecretRefs?: boolean;
1921
runtime?: RuntimeEnv;
2022
autoEnable?: boolean;
2123
env?: NodeJS.ProcessEnv;
@@ -32,6 +34,12 @@ export async function resolveCommandConfigWithSecrets<TConfig extends OpenClawCo
3234
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
3335
...(params.forcedActivePaths ? { forcedActivePaths: params.forcedActivePaths } : {}),
3436
...(params.optionalActivePaths ? { optionalActivePaths: params.optionalActivePaths } : {}),
37+
...(params.allowLocalExecSecretRefs !== undefined
38+
? { allowLocalExecSecretRefs: params.allowLocalExecSecretRefs }
39+
: {}),
40+
...(params.scrubUnresolvedSecretRefs !== undefined
41+
? { scrubUnresolvedSecretRefs: params.scrubUnresolvedSecretRefs }
42+
: {}),
3543
});
3644
if (params.runtime) {
3745
for (const entry of diagnostics) {

src/cli/command-secret-gateway.test.ts

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// Command secret gateway tests cover secret resolution for gateway-backed CLI commands.
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
36
import type { OpenClawConfig } from "../config/config.js";
47
import { withEnvAsync } from "../test-utils/env.js";
58
import {
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
1821
}));
1922

2023
const { callGateway } = mocks;
24+
const tempRoots = new Set<string>();
2125

2226
vi.mock("../gateway/call.js", () => ({
2327
callGateway: mocks.callGateway,
@@ -37,6 +41,13 @@ beforeEach(() => {
3741
commandSecretGatewayTesting.resetDepsForTest();
3842
});
3943

44+
afterEach(async () => {
45+
for (const root of tempRoots) {
46+
await fs.rm(root, { recursive: true, force: true });
47+
}
48+
tempRoots.clear();
49+
});
50+
4051
describe("resolveCommandSecretRefsViaGateway", () => {
4152
function makeTalkProviderApiKeySecretRefConfig(envKey: string): OpenClawConfig {
4253
return buildTalkTestProviderConfig({ source: "env", provider: "default", id: envKey });
@@ -85,6 +96,56 @@ describe("resolveCommandSecretRefsViaGateway", () => {
8596
).toBe(true);
8697
}
8798

99+
async function createExecProviderConfig(refId: string): Promise<{
100+
config: OpenClawConfig;
101+
markerPath: string;
102+
}> {
103+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-secret-exec-"));
104+
tempRoots.add(root);
105+
const markerPath = path.join(root, "executed");
106+
const resolverScript = [
107+
"const fs = require('node:fs');",
108+
"let stdin = '';",
109+
"process.stdin.on('data', (chunk) => { stdin += chunk; });",
110+
"process.stdin.on('end', () => {",
111+
" const request = JSON.parse(stdin);",
112+
" fs.writeFileSync(process.env.OPENCLAW_EXEC_MARKER, 'executed');",
113+
" const values = Object.fromEntries(request.ids.map((id) => [id, 'exec-local-key']));",
114+
" process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
115+
"});",
116+
].join("\n");
117+
return {
118+
markerPath,
119+
config: {
120+
...buildTalkTestProviderConfig({
121+
source: "exec",
122+
provider: "default",
123+
id: refId,
124+
}),
125+
secrets: {
126+
providers: {
127+
default: {
128+
source: "exec",
129+
command: process.execPath,
130+
args: ["-e", resolverScript],
131+
env: { OPENCLAW_EXEC_MARKER: markerPath },
132+
allowInsecurePath: true,
133+
allowSymlinkCommand: true,
134+
jsonOnly: true,
135+
},
136+
},
137+
},
138+
} as OpenClawConfig,
139+
};
140+
}
141+
142+
async function markerExists(markerPath: string): Promise<boolean> {
143+
return await fs.access(markerPath).then(
144+
() => true,
145+
() => false,
146+
);
147+
}
148+
88149
function readPath(root: unknown, pathSegments: readonly string[]): unknown {
89150
let cursor = root;
90151
for (const segment of pathSegments) {
@@ -560,6 +621,133 @@ describe("resolveCommandSecretRefsViaGateway", () => {
560621
});
561622
});
562623

624+
it("keeps local exec SecretRef fallback enabled by default", async () => {
625+
const { config, markerPath } = await createExecProviderConfig("talk/providers/api-key");
626+
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
627+
628+
const result = await resolveCommandSecretRefsViaGateway({
629+
config,
630+
commandName: "memory status",
631+
targetIds: new Set(["talk.providers.*.apiKey"]),
632+
mode: "read_only_status",
633+
});
634+
635+
expect(await markerExists(markerPath)).toBe(true);
636+
expect(readTalkProviderApiKey(result.resolvedConfig)).toBe("exec-local-key");
637+
expect(result.targetStatesByPath[TALK_TEST_PROVIDER_API_KEY_PATH]).toBe("resolved_local");
638+
expectGatewayUnavailableLocalFallbackDiagnostics(result);
639+
});
640+
641+
it("skips local exec SecretRef fallback when the caller disallows exec providers", async () => {
642+
const { config, markerPath } = await createExecProviderConfig("talk/providers/api-key");
643+
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
644+
645+
const result = await resolveCommandSecretRefsViaGateway({
646+
config,
647+
commandName: "doctor preview",
648+
targetIds: new Set(["talk.providers.*.apiKey"]),
649+
mode: "read_only_status",
650+
allowLocalExecSecretRefs: false,
651+
});
652+
653+
expect(await markerExists(markerPath)).toBe(false);
654+
expect(readTalkProviderApiKey(result.resolvedConfig)).toBeUndefined();
655+
expect(result.targetStatesByPath[TALK_TEST_PROVIDER_API_KEY_PATH]).toBe("unresolved");
656+
expect(result.diagnostics).toContain(
657+
`doctor preview: ${TALK_TEST_PROVIDER_API_KEY_PATH} is unavailable in this command path; continuing with degraded read-only config.`,
658+
);
659+
expect(
660+
result.diagnostics.some((entry) =>
661+
entry.includes(
662+
"doctor preview: skipped local exec SecretRef resolution for talk.providers.acme-speech.apiKey",
663+
),
664+
),
665+
).toBe(true);
666+
expect(
667+
result.diagnostics.some((entry) =>
668+
entry.includes("attempted local command-secret resolution"),
669+
),
670+
).toBe(true);
671+
});
672+
673+
it("can preserve unresolved SecretRefs when local exec fallback is disabled", async () => {
674+
const { config, markerPath } = await createExecProviderConfig("talk/providers/api-key");
675+
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
676+
677+
const result = await resolveCommandSecretRefsViaGateway({
678+
config,
679+
commandName: "doctor preview",
680+
targetIds: new Set(["talk.providers.*.apiKey"]),
681+
mode: "read_only_status",
682+
allowLocalExecSecretRefs: false,
683+
scrubUnresolvedSecretRefs: false,
684+
});
685+
686+
expect(await markerExists(markerPath)).toBe(false);
687+
expect(readTalkProviderApiKey(result.resolvedConfig)).toEqual({
688+
source: "exec",
689+
provider: "default",
690+
id: "talk/providers/api-key",
691+
});
692+
expect(result.targetStatesByPath[TALK_TEST_PROVIDER_API_KEY_PATH]).toBe("unresolved");
693+
expect(result.hadUnresolvedTargets).toBe(true);
694+
});
695+
696+
it("skips gateway resolution when gateway credentials would execute exec SecretRefs", async () => {
697+
await withEnvAsync(
698+
{
699+
OPENCLAW_GATEWAY_PASSWORD: undefined,
700+
OPENCLAW_GATEWAY_TOKEN: undefined,
701+
TALK_API_KEY: "local-fallback-key",
702+
},
703+
async () => {
704+
const result = await resolveCommandSecretRefsViaGateway({
705+
config: {
706+
...buildTalkTestProviderConfig({
707+
source: "env",
708+
provider: "env",
709+
id: "TALK_API_KEY",
710+
}),
711+
gateway: {
712+
auth: {
713+
mode: "token",
714+
token: { source: "exec", provider: "vault", id: "gateway/token" },
715+
},
716+
},
717+
secrets: {
718+
providers: {
719+
env: { source: "env" },
720+
vault: {
721+
source: "exec",
722+
command: process.execPath,
723+
args: ["-e", 'process.stdout.write(\'{"values":{"gateway/token":"x"}}\')'],
724+
allowInsecurePath: true,
725+
allowSymlinkCommand: true,
726+
jsonOnly: true,
727+
},
728+
},
729+
},
730+
} as OpenClawConfig,
731+
commandName: "doctor preview",
732+
targetIds: new Set(["talk.providers.*.apiKey"]),
733+
mode: "read_only_status",
734+
allowLocalExecSecretRefs: false,
735+
});
736+
737+
expect(callGateway).not.toHaveBeenCalled();
738+
expect(readTalkProviderApiKey(result.resolvedConfig)).toBe("local-fallback-key");
739+
expect(result.targetStatesByPath[TALK_TEST_PROVIDER_API_KEY_PATH]).toBe("resolved_local");
740+
expect(
741+
result.diagnostics.some((entry) =>
742+
entry.includes(
743+
"doctor preview: skipped gateway secrets.resolve because gateway credentials use exec SecretRefs at gateway.auth.token",
744+
),
745+
),
746+
).toBe(true);
747+
},
748+
);
749+
});
750+
563751
it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => {
564752
const restoreDeps = setGoogleWebSearchTargetDeps();
565753
const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK";

0 commit comments

Comments
 (0)