Skip to content

Commit 4e7a833

Browse files
joshavantsteipete
authored andcommitted
feat(security): add provider-based external secrets management
1 parent bb60cab commit 4e7a833

35 files changed

Lines changed: 1768 additions & 658 deletions

src/agents/auth-profiles.runtime-snapshot-save.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("auth profile runtime snapshot persistence", () => {
2525
"openai:default": {
2626
type: "api_key",
2727
provider: "openai",
28-
keyRef: { source: "env", id: "OPENAI_API_KEY" },
28+
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
2929
},
3030
},
3131
},
@@ -46,7 +46,7 @@ describe("auth profile runtime snapshot persistence", () => {
4646
expect(runtimeStore.profiles["openai:default"]).toMatchObject({
4747
type: "api_key",
4848
key: "sk-runtime-openai",
49-
keyRef: { source: "env", id: "OPENAI_API_KEY" },
49+
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
5050
});
5151

5252
await markAuthProfileUsed({
@@ -61,6 +61,7 @@ describe("auth profile runtime snapshot persistence", () => {
6161
expect(persisted.profiles["openai:default"]?.key).toBeUndefined();
6262
expect(persisted.profiles["openai:default"]?.keyRef).toEqual({
6363
source: "env",
64+
provider: "default",
6465
id: "OPENAI_API_KEY",
6566
});
6667
} finally {

src/agents/auth-profiles.store.save.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ describe("saveAuthProfileStore", () => {
1717
type: "api_key",
1818
provider: "openai",
1919
key: "sk-runtime-value",
20-
keyRef: { source: "env", id: "OPENAI_API_KEY" },
20+
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
2121
},
2222
"github-copilot:default": {
2323
type: "token",
2424
provider: "github-copilot",
2525
token: "gh-runtime-token",
26-
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
26+
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
2727
},
2828
"anthropic:default": {
2929
type: "api_key",
@@ -45,12 +45,14 @@ describe("saveAuthProfileStore", () => {
4545
expect(parsed.profiles["openai:default"]?.key).toBeUndefined();
4646
expect(parsed.profiles["openai:default"]?.keyRef).toEqual({
4747
source: "env",
48+
provider: "default",
4849
id: "OPENAI_API_KEY",
4950
});
5051

5152
expect(parsed.profiles["github-copilot:default"]?.token).toBeUndefined();
5253
expect(parsed.profiles["github-copilot:default"]?.tokenRef).toEqual({
5354
source: "env",
55+
provider: "default",
5456
id: "GITHUB_TOKEN",
5557
});
5658

src/agents/auth-profiles/oauth.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe("resolveApiKeyForProfile secret refs", () => {
183183
[profileId]: {
184184
type: "api_key",
185185
provider: "openai",
186-
keyRef: { source: "env", id: "OPENAI_API_KEY" },
186+
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
187187
},
188188
},
189189
},
@@ -217,7 +217,7 @@ describe("resolveApiKeyForProfile secret refs", () => {
217217
type: "token",
218218
provider: "github-copilot",
219219
token: "",
220-
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
220+
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
221221
},
222222
},
223223
},
@@ -236,4 +236,70 @@ describe("resolveApiKeyForProfile secret refs", () => {
236236
}
237237
}
238238
});
239+
240+
it("resolves inline ${ENV} api_key values", async () => {
241+
const profileId = "openai:inline-env";
242+
const previous = process.env.OPENAI_API_KEY;
243+
process.env.OPENAI_API_KEY = "sk-openai-inline";
244+
try {
245+
const result = await resolveApiKeyForProfile({
246+
cfg: cfgFor(profileId, "openai", "api_key"),
247+
store: {
248+
version: 1,
249+
profiles: {
250+
[profileId]: {
251+
type: "api_key",
252+
provider: "openai",
253+
key: "${OPENAI_API_KEY}",
254+
},
255+
},
256+
},
257+
profileId,
258+
});
259+
expect(result).toEqual({
260+
apiKey: "sk-openai-inline",
261+
provider: "openai",
262+
email: undefined,
263+
});
264+
} finally {
265+
if (previous === undefined) {
266+
delete process.env.OPENAI_API_KEY;
267+
} else {
268+
process.env.OPENAI_API_KEY = previous;
269+
}
270+
}
271+
});
272+
273+
it("resolves inline ${ENV} token values", async () => {
274+
const profileId = "github-copilot:inline-env";
275+
const previous = process.env.GITHUB_TOKEN;
276+
process.env.GITHUB_TOKEN = "gh-inline-token";
277+
try {
278+
const result = await resolveApiKeyForProfile({
279+
cfg: cfgFor(profileId, "github-copilot", "token"),
280+
store: {
281+
version: 1,
282+
profiles: {
283+
[profileId]: {
284+
type: "token",
285+
provider: "github-copilot",
286+
token: "${GITHUB_TOKEN}",
287+
},
288+
},
289+
},
290+
profileId,
291+
});
292+
expect(result).toEqual({
293+
apiKey: "gh-inline-token",
294+
provider: "github-copilot",
295+
email: undefined,
296+
});
297+
} finally {
298+
if (previous === undefined) {
299+
delete process.env.GITHUB_TOKEN;
300+
} else {
301+
process.env.GITHUB_TOKEN = previous;
302+
}
303+
}
304+
});
239305
});

src/agents/auth-profiles/oauth.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type OAuthProvider,
66
} from "@mariozechner/pi-ai";
77
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
8-
import { isSecretRef } from "../../config/types.secrets.js";
8+
import { coerceSecretRef } from "../../config/types.secrets.js";
99
import { withFileLock } from "../../infra/file-lock.js";
1010
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
1111
import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
@@ -257,14 +257,34 @@ export async function resolveApiKeyForProfile(
257257
return null;
258258
}
259259

260-
const refResolveCache: SecretRefResolveCache = { fileSecretsPromise: null };
260+
const refResolveCache: SecretRefResolveCache = {};
261261
const configForRefResolution = cfg ?? loadConfig();
262+
const refDefaults = configForRefResolution.secrets?.defaults;
262263

263264
if (cred.type === "api_key") {
264265
let key = cred.key?.trim();
265-
if (!key && isSecretRef(cred.keyRef)) {
266+
if (key) {
267+
const inlineRef = coerceSecretRef(key, refDefaults);
268+
if (inlineRef) {
269+
try {
270+
key = await resolveSecretRefString(inlineRef, {
271+
config: configForRefResolution,
272+
env: process.env,
273+
cache: refResolveCache,
274+
});
275+
} catch (err) {
276+
log.debug("failed to resolve inline auth profile api_key ref", {
277+
profileId,
278+
provider: cred.provider,
279+
error: err instanceof Error ? err.message : String(err),
280+
});
281+
}
282+
}
283+
}
284+
const keyRef = coerceSecretRef(cred.keyRef, refDefaults);
285+
if (!key && keyRef) {
266286
try {
267-
key = await resolveSecretRefString(cred.keyRef, {
287+
key = await resolveSecretRefString(keyRef, {
268288
config: configForRefResolution,
269289
env: process.env,
270290
cache: refResolveCache,
@@ -284,9 +304,28 @@ export async function resolveApiKeyForProfile(
284304
}
285305
if (cred.type === "token") {
286306
let token = cred.token?.trim();
287-
if (!token && isSecretRef(cred.tokenRef)) {
307+
if (token) {
308+
const inlineRef = coerceSecretRef(token, refDefaults);
309+
if (inlineRef) {
310+
try {
311+
token = await resolveSecretRefString(inlineRef, {
312+
config: configForRefResolution,
313+
env: process.env,
314+
cache: refResolveCache,
315+
});
316+
} catch (err) {
317+
log.debug("failed to resolve inline auth profile token ref", {
318+
profileId,
319+
provider: cred.provider,
320+
error: err instanceof Error ? err.message : String(err),
321+
});
322+
}
323+
}
324+
}
325+
const tokenRef = coerceSecretRef(cred.tokenRef, refDefaults);
326+
if (!token && tokenRef) {
288327
try {
289-
token = await resolveSecretRefString(cred.tokenRef, {
328+
token = await resolveSecretRefString(tokenRef, {
290329
config: configForRefResolution,
291330
env: process.env,
292331
cache: refResolveCache,

src/agents/model-auth-label.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe("resolveModelAuthLabel", () => {
3232
"github-copilot:default": {
3333
type: "token",
3434
provider: "github-copilot",
35-
tokenRef: { source: "env", id: "GITHUB_TOKEN" },
35+
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
3636
},
3737
},
3838
} as never);

src/cli/secrets-cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function registerSecretsCli(program: Command) {
9595

9696
secrets
9797
.command("migrate")
98-
.description("Migrate plaintext secrets to file-backed SecretRefs (sops)")
98+
.description("Migrate plaintext secrets to file-backed SecretRefs")
9999
.option("--write", "Apply migration changes (default is dry-run)", false)
100100
.option("--rollback <backup-id>", "Rollback a previous migration backup id")
101101
.option("--no-scrub-env", "Keep matching plaintext values in ~/.openclaw/.env")

src/commands/auth-choice.apply-helpers.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,18 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
177177
});
178178

179179
expect(result).toBe("env-key");
180-
expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref");
180+
expect(setCredential).toHaveBeenCalledWith(
181+
{ source: "env", provider: "default", id: "MINIMAX_API_KEY" },
182+
"ref",
183+
);
181184
expect(text).not.toHaveBeenCalled();
182185
});
183186

184-
it("re-prompts after sops ref validation failure and succeeds with env ref", async () => {
187+
it("re-prompts after provider ref validation failure and succeeds with env ref", async () => {
185188
process.env.MINIMAX_API_KEY = "env-key";
186189
delete process.env.MINIMAX_OAUTH_TOKEN;
187190

188-
const selectValues: Array<"file" | "env"> = ["file", "env"];
191+
const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"];
189192
const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"];
190193
const text = vi
191194
.fn<WizardPrompter["text"]>()
@@ -195,7 +198,17 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
195198
const setCredential = vi.fn(async () => undefined);
196199

197200
const result = await ensureApiKeyFromEnvOrPrompt({
198-
config: {},
201+
config: {
202+
secrets: {
203+
providers: {
204+
filemain: {
205+
source: "file",
206+
path: "/tmp/does-not-exist-secrets.json",
207+
mode: "jsonPointer",
208+
},
209+
},
210+
},
211+
},
199212
provider: "minimax",
200213
envLabel: "MINIMAX_API_KEY",
201214
promptMessage: "Enter key",
@@ -207,9 +220,12 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
207220
});
208221

209222
expect(result).toBe("env-key");
210-
expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref");
223+
expect(setCredential).toHaveBeenCalledWith(
224+
{ source: "env", provider: "default", id: "MINIMAX_API_KEY" },
225+
"ref",
226+
);
211227
expect(note).toHaveBeenCalledWith(
212-
expect.stringContaining("Could not validate this encrypted file reference."),
228+
expect.stringContaining("Could not validate provider reference"),
213229
"Reference check failed",
214230
);
215231
});

0 commit comments

Comments
 (0)