Skip to content

Commit 6459b7b

Browse files
committed
fix(secrets): narrow web search credential deferral
1 parent 8ff84bf commit 6459b7b

5 files changed

Lines changed: 83 additions & 16 deletions

src/plugins/config-contracts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export { collectPluginConfigContractMatches } from "./config-contract-matches.js
1212
type PluginConfigContractMetadata = {
1313
/** Runtime origin that supplied the contract metadata. */
1414
origin: PluginOrigin;
15-
/** Capability contracts that determine runtime ownership of config subtrees. */
15+
/** Capability contracts that determine runtime ownership of exact secret paths. */
1616
contracts?: PluginManifestContracts;
1717
/** Manifest-declared config contract paths used by secret/security/config scanners. */
1818
configContracts: PluginManifestConfigContracts;

src/secrets/runtime-config-collectors-plugins.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,4 +570,54 @@ describe("collectPluginConfigAssignments", () => {
570570
"plugins.entries.other.config.service.tokens.secondary",
571571
]);
572572
});
573+
574+
it("defers only the exact web-search credential path for active provider plugins", () => {
575+
loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({
576+
plugins: [
577+
{
578+
id: "custom-search",
579+
origin: "config",
580+
providers: [],
581+
legacyPluginIds: [],
582+
contracts: { webSearchProviders: ["custom"] },
583+
configContracts: {
584+
secretInputs: {
585+
paths: [
586+
{ path: "webSearch.apiKey", expected: "string" },
587+
{ path: "webSearch.headers.authorization", expected: "string" },
588+
],
589+
},
590+
},
591+
},
592+
],
593+
diagnostics: [],
594+
});
595+
const config = asConfig({
596+
plugins: {
597+
entries: {
598+
"custom-search": {
599+
enabled: true,
600+
config: {
601+
webSearch: {
602+
apiKey: envRef("A"),
603+
headers: { authorization: envRef("B") },
604+
},
605+
},
606+
},
607+
},
608+
},
609+
});
610+
const context = makeContext(config);
611+
612+
collectPluginConfigAssignments({
613+
config,
614+
defaults: undefined,
615+
context,
616+
loadablePluginOrigins: loadablePluginOrigins([["custom-search", "config"]]),
617+
});
618+
619+
expect(context.assignments.map((assignment) => assignment.path)).toEqual([
620+
"plugins.entries.custom-search.config.webSearch.headers.authorization",
621+
]);
622+
});
573623
});

src/secrets/runtime-config-collectors-plugins.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type SecretDefaults,
1616
} from "./runtime-shared.js";
1717
import { isRecord } from "./shared.js";
18+
import { listWebProviderSecretConfigPaths } from "./web-provider-secret-configs.js";
1819

1920
function parsePluginConfigArrayIndex(segment: string): number | undefined {
2021
return parseConfigPathArrayIndex(segment);
@@ -124,9 +125,10 @@ export function collectPluginConfigAssignments(params: {
124125
pluginConfig,
125126
secretPaths: secretInputs.paths,
126127
active: enableState.enabled,
127-
runtimeOwnedPathPrefixes: secretInputs.contracts?.webSearchProviders?.length
128-
? ["webSearch."]
129-
: [],
128+
runtimeOwnedPaths: listWebProviderSecretConfigPaths({
129+
contracts: secretInputs.contracts,
130+
contract: "webSearchProviders",
131+
}),
130132
inactiveReason: enableState.reason ?? "plugin is disabled.",
131133
defaults: params.defaults,
132134
context: params.context,
@@ -139,17 +141,14 @@ function collectConfiguredPluginSecretAssignments(params: {
139141
pluginConfig: Record<string, unknown>;
140142
secretPaths: ReadonlyArray<{ path: string; expected?: "string" }>;
141143
active: boolean;
142-
runtimeOwnedPathPrefixes?: readonly string[];
144+
runtimeOwnedPaths?: readonly string[];
143145
inactiveReason: string;
144146
defaults: SecretDefaults | undefined;
145147
context: ResolverContext;
146148
}): void {
147149
const seenPaths = new Set<string>();
148150
for (const secretPath of params.secretPaths) {
149-
if (
150-
params.active &&
151-
params.runtimeOwnedPathPrefixes?.some((prefix) => secretPath.path.startsWith(prefix))
152-
) {
151+
if (params.active && params.runtimeOwnedPaths?.includes(secretPath.path)) {
153152
// Web provider selection owns these credentials. Resolving them here would make an
154153
// inactive or non-selected provider block the whole runtime snapshot.
155154
continue;

src/secrets/target-registry-data.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
44
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
55
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
66
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
7+
import {
8+
WEB_PROVIDER_SECRET_CONFIGS,
9+
type WebProviderSecretConfig,
10+
} from "./web-provider-secret-configs.js";
711

812
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
913
const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret
1014

11-
const WEB_PROVIDER_SECRET_CONFIGS = [
12-
{ contract: "webSearchProviders", configPath: "webSearch.apiKey" },
13-
{ contract: "webFetchProviders", configPath: "webFetch.apiKey" },
14-
] as const;
15-
16-
type WebProviderSecretConfig = (typeof WEB_PROVIDER_SECRET_CONFIGS)[number];
17-
1815
function createPluginOpenClawConfigSecretTargetEntry(
1916
pluginId: string,
2017
configPath: string,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { PluginManifestContracts } from "../plugins/manifest.js";
2+
3+
export const WEB_PROVIDER_SECRET_CONFIGS = [
4+
{ contract: "webSearchProviders", configPath: "webSearch.apiKey" },
5+
{ contract: "webFetchProviders", configPath: "webFetch.apiKey" },
6+
] as const;
7+
8+
export type WebProviderSecretConfig = (typeof WEB_PROVIDER_SECRET_CONFIGS)[number];
9+
10+
/** Lists exact provider-owned config paths without loading provider runtime. */
11+
export function listWebProviderSecretConfigPaths(params: {
12+
contracts: PluginManifestContracts | undefined;
13+
contract: WebProviderSecretConfig["contract"];
14+
}): string[] {
15+
if ((params.contracts?.[params.contract]?.length ?? 0) === 0) {
16+
return [];
17+
}
18+
return WEB_PROVIDER_SECRET_CONFIGS.filter((config) => config.contract === params.contract).map(
19+
(config) => config.configPath,
20+
);
21+
}

0 commit comments

Comments
 (0)