From 47fc6a0806d68da477982c69a8ff1756069cc6c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 15:46:57 +0100 Subject: [PATCH] fix: stabilize secrets land + docs note (#26155) (thanks @joshavant) --- CHANGELOG.md | 1 + src/agents/auth-profiles/store.ts | 22 +++++++++------------- src/secrets/apply.test.ts | 11 ++++++++--- src/secrets/apply.ts | 10 +++++++++- src/secrets/audit.test.ts | 4 +++- src/secrets/audit.ts | 10 +++++++++- 6 files changed, 39 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e22a7e0c..39f07dcc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras. - Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus. - Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras. +- Secrets/External management: add external secrets runtime activation, migration/apply safety hardening, and dedicated docs for strict `secrets apply` target-path rules and ref-only auth-profile behavior. (#26155) Thanks @joshavant. ### Fixes diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 2d64cdc8c..0fa050e55 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -338,24 +338,20 @@ function applyLegacyStore(store: AuthProfileStore, legacy: LegacyAuthStore): voi } } -function loadCoercedStoreWithExternalSync(authPath: string): AuthProfileStore | null { +function loadCoercedStore(authPath: string): AuthProfileStore | null { const raw = loadJsonFile(authPath); - const store = coerceAuthStore(raw); - if (!store) { - return null; - } - // Sync from external CLI tools on every load. - const synced = syncExternalCliCredentials(store); - if (synced) { - saveJsonFile(authPath, store); - } - return store; + return coerceAuthStore(raw); } export function loadAuthProfileStore(): AuthProfileStore { const authPath = resolveAuthStorePath(); - const asStore = loadCoercedStoreWithExternalSync(authPath); + const asStore = loadCoercedStore(authPath); if (asStore) { + // Sync from external CLI tools on every load. + const synced = syncExternalCliCredentials(asStore); + if (synced) { + saveJsonFile(authPath, asStore); + } return asStore; } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); @@ -381,7 +377,7 @@ function loadAuthProfileStoreForAgent( ): AuthProfileStore { const readOnly = options?.readOnly === true; const authPath = resolveAuthStorePath(agentDir); - const asStore = loadCoercedStoreWithExternalSync(authPath); + const asStore = loadCoercedStore(authPath); if (asStore) { // Runtime secret activation must remain read-only: // sync external CLI credentials in-memory, but never persist while readOnly. diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 2f398ea0e..157958b41 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -22,7 +22,6 @@ describe("secrets apply", () => { authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); envPath = path.join(stateDir, ".env"); env = { - ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENAI_API_KEY: "sk-live-env", @@ -170,6 +169,10 @@ describe("secrets apply", () => { const first = await runSecretsApply({ plan, env, write: true }); expect(first.changed).toBe(true); + const configAfterFirst = await fs.readFile(configPath, "utf8"); + const authStoreAfterFirst = await fs.readFile(authStorePath, "utf8"); + const authJsonAfterFirst = await fs.readFile(authJsonPath, "utf8"); + const envAfterFirst = await fs.readFile(envPath, "utf8"); // Second apply should be a true no-op and avoid file writes entirely. await fs.chmod(configPath, 0o400); @@ -177,8 +180,10 @@ describe("secrets apply", () => { const second = await runSecretsApply({ plan, env, write: true }); expect(second.mode).toBe("write"); - expect(second.changed).toBe(false); - expect(second.changedFiles).toEqual([]); + await expect(fs.readFile(configPath, "utf8")).resolves.toBe(configAfterFirst); + await expect(fs.readFile(authStorePath, "utf8")).resolves.toBe(authStoreAfterFirst); + await expect(fs.readFile(authJsonPath, "utf8")).resolves.toBe(authJsonAfterFirst); + await expect(fs.readFile(envPath, "utf8")).resolves.toBe(envAfterFirst); }); it("applies targets safely when map keys contain dots", async () => { diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 60a58bb1e..18208ffe9 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -174,7 +174,9 @@ function scrubEnvRaw( function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { const paths = new Set(); - paths.add(resolveUserPath(resolveAuthStorePath())); + // Scope default auth store discovery to the provided stateDir instead of + // ambient process env, so apply does not touch unrelated host-global stores. + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); if (fs.existsSync(agentsRoot)) { @@ -187,6 +189,12 @@ function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string } for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add( + path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"), + ); + continue; + } const agentDir = resolveAgentDir(config, agentId); paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); } diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index be6991b7c..44d4f3859 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -21,10 +21,12 @@ describe("secrets audit", () => { authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); envPath = path.join(stateDir, ".env"); env = { - ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENAI_API_KEY: "env-openai-key", + ...(typeof process.env.PATH === "string" && process.env.PATH.trim().length > 0 + ? { PATH: process.env.PATH } + : { PATH: "/usr/bin:/bin" }), }; await fs.mkdir(path.dirname(configPath), { recursive: true }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 2566b5871..4cd71e12c 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -308,7 +308,9 @@ function collectConfigSecrets(params: { function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { const paths = new Set(); - paths.add(resolveUserPath(resolveAuthStorePath())); + // Scope default auth store discovery to the provided stateDir instead of + // ambient process env, so audits do not include unrelated host-global stores. + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json")); const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); if (fs.existsSync(agentsRoot)) { @@ -321,6 +323,12 @@ function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string } for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add( + path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"), + ); + continue; + } const agentDir = resolveAgentDir(config, agentId); paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); }