From 147edf47f37dc417c6abc3a02d5dc8ee08c2b2ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 00:42:41 -0700 Subject: [PATCH] fix(auth): keep a retired auth JSON from stranding a migrated store (#126562) * fix(auth): keep a retired auth JSON from stranding a migrated store Runtime failed closed with AUTH_PROFILE_MIGRATION_REQUIRED whenever a retired credential file was present, even when the canonical SQLite store already held the agent's profiles. One leftover auth.json therefore made a fully migrated install unusable, and the gateway lifecycle preflight refused start/restart on top of it, so every channel and provider stayed offline until Doctor ran. A legacy file is now only fatal when the canonical store cannot serve credentials. Doctor's importer never overwrites a usable stored credential, so a file sitting beside a populated store is unarchived bytes, not pending migration: runtime logs a one-time warning and keeps serving. An empty store with a credential file still fails closed and never falls through to environment auth. Startup degrades that owner to configured-unavailable instead of refusing to boot, which lets the lifecycle preflight go away. * refactor(secrets): retire the auth-profiles.json vocabulary Auth profiles moved to SQLite, but operator-facing surfaces still named the retired JSON file. The duplicate-agentDir error told operators to copy auth-profiles.json to share credentials, which does nothing and lands the second agent in a migration-required state; `openclaw migrate plan codex` reported a target file that is never created; and the secrets picker labelled candidates with a filename that no longer exists. Renames the SecretTargetConfigFile discriminator to "auth-profile-store" and corrects the operator-facing text, the migrate plan target, and the docs that described the file as a live target. Genuine legacy-filename uses in doctor, the security fixer, and migration fixtures are unchanged. Also deletes resolveSecretPlanTargetByPath and ResolvedSecretPlanTarget from the plugin SDK. They have no callers in core, plugins, or tests, and the symbols are absent from the latest stable tag, so they carry no compatibility obligation and are removed rather than deprecated. Their inline parameter type was the only thing putting the retired filename on the public SDK surface. * improve(wizard): warn about device-code phishing The device-code prompt only warned against sharing the code, and only when an expiry was known. Device-code phishing works the other way around: the attacker starts the login and gets the victim to enter the attacker's code. Codes delivered over a chat channel are the risky case and carry no expiry hint, so the warning is now unconditional and covers received codes, matching the Codex CLI prompt. Also documents the Codex auth handoff: a subscription profile is installed as in-memory external auth rather than persisted, and token refresh is inverted so the refresh token stays in OpenClaw's store. * fix(test): make transcript read-failure injection order-independent server.sessions.compaction-read-errors.test.ts injected its failures with mockRejectedValueOnce, which fails the NEXT call to loadTranscriptEvents globally. Under --isolate=false a shard shares one worker, so any sibling transcript read could consume the one-shot rejection before the compaction RPC issued its own; compaction then ran against the real reader and returned ok, failing three assertions. This shard was already red on main; a prior repair fixed the mock's initialization order but left the call-order dependency. Key the injection on the seeded sessionId instead, so unrelated readers cannot consume it and the re-read case counts only its own session's reads. Also updates two expectations invalidated by this branch: the duplicate-agentDir remediation text, and the plugin SDK export ratchet, shrunk by the two retired secret-plan exports. --- AGENTS.md | 2 +- docs/cli/policy.md | 4 +- docs/cli/secrets.md | 10 +-- docs/concepts/oauth.md | 17 +++- docs/plugins/codex-harness-reference.md | 14 ++++ ...tref-user-supplied-credentials-matrix.json | 4 +- extensions/codex/src/migration/auth.ts | 4 +- scripts/plugin-sdk-surface-report.mts | 6 +- src/agents/auth-profiles.sqlite-store.test.ts | 11 ++- .../legacy-source-diagnostic.test.ts | 45 ++++++++--- .../auth-profiles/legacy-source-diagnostic.ts | 78 +++++++++++++------ src/agents/auth-profiles/store.ts | 5 +- src/agents/sessions/auth-storage.test.ts | 19 ++--- .../lifecycle-action-preflight.test.ts | 22 ++---- .../daemon-cli/lifecycle-action-preflight.ts | 43 +--------- .../doctor-auth-flat-profiles.test.ts | 16 +--- src/config/agent-dirs.ts | 2 +- ...ig.multi-agent-agentdir-validation.test.ts | 2 +- ...er.sessions.compaction-read-errors.test.ts | 31 +++++++- src/plugin-sdk/secret-ref-runtime.ts | 22 ------ src/secrets/apply.ts | 4 +- src/secrets/configure-plan.test.ts | 2 +- src/secrets/configure-plan.ts | 6 +- src/secrets/configure.ts | 12 +-- src/secrets/credential-matrix.test-support.ts | 2 +- src/secrets/exec-secret-ref-id-parity.test.ts | 4 +- src/secrets/plan.ts | 2 +- src/secrets/plugin-setup-plan.ts | 2 +- src/secrets/runtime.coverage.test.ts | 6 +- src/secrets/target-registry-data.ts | 4 +- src/secrets/target-registry-query.ts | 6 +- src/secrets/target-registry-types.ts | 2 +- src/secrets/target-registry.docs.test.ts | 2 +- src/secrets/target-registry.test.ts | 2 +- src/wizard/prompts.ts | 8 ++ src/wizard/session.test.ts | 9 ++- src/wizard/session.ts | 16 +++- 37 files changed, 250 insertions(+), 196 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index adde206e634f..7fcee3472067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,7 +331,7 @@ Mechanics only; policy lives above. ## Security / Release - Never commit real phone numbers, videos, credentials, live config. -- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents//agent/auth-profiles.json`. +- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents//agent/openclaw-agent.sqlite` (`auth_profile_store`). - SecretRef failures isolate to the smallest known owning surface; unknown ownership fails closed. Gateway starts degraded (exact owner marked configured-unavailable, typed redacted diagnostic, no implicit credential fallback) rather than refusing startup, except for its own ingress protection or structurally invalid config. Doctor and status list every degraded owner. Full doctrine: `docs/gateway/secrets.md`. - Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only. - Release/package guards: no hard-coded retired-package denylists; use generic artifact/dependency checks or fix build source. diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 3a19e9b6e031..c2f805aa7336 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -14,7 +14,7 @@ conformance layer over existing OpenClaw settings, not a second configuration system. You author requirements in `policy.jsonc`; OpenClaw observes the active workspace as evidence; policy reports drift through `doctor --lint`. Policy does not enforce tool calls or rewrite runtime behavior at request time, and it -does not attest per-agent credential stores such as `auth-profiles.json`. +does not attest per-agent credential stores such as `openclaw-agent.sqlite`. Policy checks configured channels, MCP servers, model providers, network SSRF posture, ingress/channel access, Gateway exposure and node command posture, @@ -199,7 +199,7 @@ Cross-cutting notes not obvious from the rule tables below: unobservable evidence, not a synthetic pass. - Secret and auth-profile evidence records provider/source posture and SecretRef metadata only, never raw values. Policy does not read or attest - per-agent credential stores such as `auth-profiles.json`. + per-agent credential stores such as `openclaw-agent.sqlite`. - Data-handling evidence is config-level posture (telemetry capture toggle, session maintenance mode, transcript-indexing setting) plus the always-on log redaction invariant. It does not inspect logs, telemetry exports, diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index 73093cc79bb1..e8b4979bd1ef 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -153,7 +153,7 @@ Scans OpenClaw state for: - plaintext secret storage - unresolved refs -- precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs) +- precedence drift (auth profile store credentials shadowing `openclaw.json` refs) - store residue (a team store value duplicated by plaintext in `openclaw.json`) - generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers) - legacy residues (legacy auth store entries, OAuth reminders) @@ -196,7 +196,7 @@ Flags: - `--providers-only`: configure `secrets.providers` only, skip credential mapping - `--skip-provider-setup`: skip provider setup, map credentials to existing providers -- `--agent `: scope `auth-profiles.json` target discovery and writes to one agent store +- `--agent `: scope auth profile target discovery and writes to one agent store - `--allow-exec`: allow exec SecretRef checks during preflight/apply (may execute provider commands) `--providers-only` and `--skip-provider-setup` cannot be combined. @@ -204,8 +204,8 @@ Flags: Notes: - Requires an interactive TTY. -- Targets secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for the selected agent scope; canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface). -- Supports creating new `auth-profiles.json` mappings directly in the picker flow. +- Targets secret-bearing fields in `openclaw.json` plus the selected agent's auth profile store; canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface). +- Supports creating new auth profile mappings directly in the picker flow. - Runs preflight resolution before apply. - Generated plans default to scrub options enabled (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson`). Apply is one-way for scrubbed plaintext values. - `--plan-out` refuses to create a plan whose UTF-8 serialized form exceeds 16 MiB (16,777,216 bytes), matching the `apply --from` input limit. @@ -234,7 +234,7 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json What `apply` may update: - `openclaw.json` (SecretRef targets + provider upserts/deletes) -- `auth-profiles.json` (provider-target scrubbing) +- auth profile store (provider-target scrubbing) - legacy `auth.json` residues - `.env` files in the effective state and active-config directories, for known secret keys whose values were migrated diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index e62522c390bc..c38ed525c1b8 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -72,9 +72,20 @@ Older installations may still contain `auth-profiles.json`, `auth-state.json`, per-agent `auth.json`, or shared `credentials/oauth.json`. Run `openclaw doctor --fix` once after upgrading. Doctor imports verified values, records a migration receipt, and renames the original file to a timestamped -archive. Runtime never reads these retired files and reports -`AUTH_PROFILE_MIGRATION_REQUIRED` when a legacy credential source has not been -migrated. +archive. + +Runtime never reads these retired files. What happens when one is still present +depends on whether the SQLite store can already serve credentials for that +agent: + +- The store holds profiles: the retired file is leftover bytes. Runtime logs a + one-time warning naming the file and keeps working; Doctor archives it on the + next `--fix`. Doctor never overwrites a usable stored credential with imported + values, so the file cannot resurrect a stale token. +- The store is empty: the credentials still live only in that file, so runtime + fails closed for that agent with `AUTH_PROFILE_MIGRATION_REQUIRED` rather than + falling through to environment auth. Gateway startup degrades this owner to + configured-unavailable instead of refusing to start. The database and migration sources respect `$OPENCLAW_STATE_DIR`. Full reference: [/gateway/configuration-reference#auth-storage](/gateway/configuration-reference#auth-storage) diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 9b870557b741..c20905daffc5 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -496,6 +496,20 @@ this mode. Import that file explicitly as described below. Set `appServer.homeScope: "user"` only when the app-server should instead own and use the operator's native Codex account. +No credential file is written in this mode, in either home. A subscription +profile is handed over as an `account/login/start` request of type +`chatgptAuthTokens`, which Codex installs as in-memory external auth rather +than persisting; the ephemeral credential store covers the API-key login, +which would otherwise write `CODEX_HOME/auth.json`. + +Token refresh is inverted so the long-lived secret never leaves OpenClaw. Codex +holds only a short-lived access token, and on an unauthorized response it sends +an `account/chatgptAuthTokens/refresh` request back to OpenClaw over the same +connection. OpenClaw refreshes against its own auth profile store and returns a +fresh access token, so the refresh token stays in SQLite. A refresh that does +not answer within the app-server's timeout fails that turn rather than falling +back to another credential. + When OpenClaw sees a ChatGPT subscription-style Codex auth profile (OAuth or token credential type), it removes `CODEX_API_KEY` and `OPENAI_API_KEY` from the spawned Codex child process. That keeps Gateway-level API keys available diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 1f24885f7356..0143eae6cda5 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -30,7 +30,7 @@ }, { "id": "auth-profiles.api_key.key", - "configFile": "auth-profiles.json", + "configFile": "auth-profile-store", "path": "profiles.*.key", "refPath": "profiles.*.keyRef", "when": { @@ -42,7 +42,7 @@ }, { "id": "auth-profiles.token.token", - "configFile": "auth-profiles.json", + "configFile": "auth-profile-store", "path": "profiles.*.token", "refPath": "profiles.*.tokenRef", "when": { diff --git a/extensions/codex/src/migration/auth.ts b/extensions/codex/src/migration/auth.ts index f57c087a9f72..8d278dd2b672 100644 --- a/extensions/codex/src/migration/auth.ts +++ b/extensions/codex/src/migration/auth.ts @@ -430,7 +430,9 @@ export async function buildCodexAuthItems(params: { kind: "auth", action: skipped ? "skip" : "create", source: params.source.authPath, - target: `${params.targets.agentDir}/auth-profiles.json#${profileId}`, + // Credentials land in the agent's SQLite auth profile store; naming the + // retired JSON file here promised operators a file that is never created. + target: `${params.targets.agentDir}/openclaw-agent.sqlite#auth_profile_store:${profileId}`, status: skipped ? "skipped" : conflict ? "conflict" : "planned", sensitive: true, reason: skipped diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 13506c829d99..218fa3454d81 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -306,7 +306,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -1: remove the test-only channel activity reset export. // +1: named bounded structured-input surface for native harness protocol adapters. // +1: OpenAI-compatible video execution in the existing media-understanding owner. - 4337, + // -2: retire the uncalled secret-plan target resolver and its result type. + 4335, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -394,7 +395,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: shared harness visible-source-reply guidance. // -1: remove the test-only channel activity reset export. // +1: OpenAI-compatible video execution in the existing media-understanding owner. - 2578, + // -1: retire the uncalled secret-plan target resolver. + 2577, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/auth-profiles.sqlite-store.test.ts b/src/agents/auth-profiles.sqlite-store.test.ts index 12cd1402642d..96a74e6c3169 100644 --- a/src/agents/auth-profiles.sqlite-store.test.ts +++ b/src/agents/auth-profiles.sqlite-store.test.ts @@ -168,7 +168,7 @@ describe("auth profile sqlite store", () => { }); }); - it("fails closed when a credential source appears during a successful SQLite read", async () => { + it("keeps serving SQLite credentials when a credential source appears during the read", async () => { await withAgentDirEnv("openclaw-auth-sqlite-late-legacy-", (agentDir) => { saveAuthProfileStore(apiKeyStore("not-a-real"), agentDir); const legacyPath = path.join(agentDir, "auth.json"); @@ -186,12 +186,15 @@ describe("auth profile sqlite store", () => { return existsSync(pathname); }); try { - expect(() => ensureAuthProfileStore(agentDir, { syncExternalCli: false })).toThrow( - "requires legacy credential migration", - ); + // The migrated store already owns these credentials, so a retired file + // appearing beside it is unarchived bytes rather than pending migration. + expect( + ensureAuthProfileStore(agentDir, { syncExternalCli: false }).profiles["openai:default"], + ).toMatchObject({ type: "api_key", provider: "openai", key: "not-a-real" }); } finally { existsSpy.mockRestore(); } + // Runtime never reads or removes it; Doctor still owns the archive step. expect(fs.existsSync(legacyPath)).toBe(true); }); }); diff --git a/src/agents/auth-profiles/legacy-source-diagnostic.test.ts b/src/agents/auth-profiles/legacy-source-diagnostic.test.ts index da8155cedcf6..af9099e240e1 100644 --- a/src/agents/auth-profiles/legacy-source-diagnostic.test.ts +++ b/src/agents/auth-profiles/legacy-source-diagnostic.test.ts @@ -5,35 +5,60 @@ import { withTestDir } from "../../test-helpers/temp-dir.js"; import { assertAuthProfileMigrationReady, clearAuthProfileMigrationDiagnostics, - listAuthProfileStoresRequiringMigration, } from "./legacy-source-diagnostic.js"; -import { resolveAuthProfileDatabasePath } from "./sqlite.js"; +import { writePersistedAuthProfileStoreRaw } from "./sqlite.js"; afterEach(() => { clearAuthProfileMigrationDiagnostics(); }); -describe("listAuthProfileStoresRequiringMigration", () => { +describe("assertAuthProfileMigrationReady", () => { it("reports only credential sources without marking runtime migration state", async () => { await withTestDir({ prefix: "openclaw-auth-migration-diagnostic-" }, async (root) => { const credentialAgentDir = path.join(root, "credential-agent"); const authStateAgentDir = path.join(root, "auth-state-agent"); - const env = { OPENCLAW_STATE_DIR: path.join(root, "state") }; await fs.mkdir(credentialAgentDir, { recursive: true }); await fs.mkdir(authStateAgentDir, { recursive: true }); const credentialPath = path.join(credentialAgentDir, "auth-profiles.json"); await fs.writeFile(credentialPath, "{}\n"); await fs.writeFile(path.join(authStateAgentDir, "auth-state.json"), "{}\n"); - expect( - listAuthProfileStoresRequiringMigration({ - agentDirs: [authStateAgentDir, credentialAgentDir, credentialAgentDir], - env, - }), - ).toEqual([resolveAuthProfileDatabasePath(credentialAgentDir)]); + // An auth-state file carries no credentials, so it never blocks its owner. + expect(() => assertAuthProfileMigrationReady(authStateAgentDir)).not.toThrow(); + expect(() => assertAuthProfileMigrationReady(credentialAgentDir)).toThrow( + "requires legacy credential migration", + ); + clearAuthProfileMigrationDiagnostics(); await fs.rm(credentialPath); expect(() => assertAuthProfileMigrationReady(credentialAgentDir)).not.toThrow(); }); }); + + it("clears the requirement once the canonical store holds credentials", async () => { + await withTestDir({ prefix: "openclaw-auth-migration-migrated-" }, async (root) => { + const agentDir = path.join(root, "migrated-agent"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile(path.join(agentDir, "auth.json"), '{"openai":{"key":"not-a-real"}}\n'); + + // Unmigrated: the credentials exist only in the retired file. + expect(() => assertAuthProfileMigrationReady(agentDir)).toThrow( + "requires legacy credential migration", + ); + clearAuthProfileMigrationDiagnostics(); + + writePersistedAuthProfileStoreRaw( + { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key: "not-a-real" }, + }, + }, + agentDir, + ); + + // Migrated: the same leftover file must not strand a working store. + expect(() => assertAuthProfileMigrationReady(agentDir)).not.toThrow(); + }); + }); }); diff --git a/src/agents/auth-profiles/legacy-source-diagnostic.ts b/src/agents/auth-profiles/legacy-source-diagnostic.ts index c4a5af221819..3e316a8ef63d 100644 --- a/src/agents/auth-profiles/legacy-source-diagnostic.ts +++ b/src/agents/auth-profiles/legacy-source-diagnostic.ts @@ -1,14 +1,15 @@ import fs from "node:fs"; import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveOAuthDir } from "../../config/paths.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSharedAuthStorePath } from "./path-resolve.js"; import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js"; -import { resolveAuthProfileDatabasePath } from "./sqlite.js"; +import { inspectPersistedAuthProfileStoreRaw, resolveAuthProfileDatabasePath } from "./sqlite.js"; const AUTH_PROFILE_MIGRATION_REQUIRED_CODE = "AUTH_PROFILE_MIGRATION_REQUIRED" as const; -export const AUTH_PROFILE_MIGRATION_COMMAND = "openclaw doctor --fix" as const; +const AUTH_PROFILE_MIGRATION_COMMAND = "openclaw doctor --fix" as const; const log = createSubsystemLogger("auth-profiles/persistence"); type LegacyAuthProfileSourceKind = "auth-profiles" | "auth-state" | "legacy-auth" | "legacy-oauth"; @@ -96,18 +97,49 @@ export function hasLegacyAuthProfileCredentialSource(agentDir?: string): boolean return listLegacyAuthProfileSources({ agentDir }).some(isCredentialSource); } +/** + * True when the canonical SQLite store already holds credentials for this owner. + * A retired JSON file sitting next to a populated store is leftover bytes Doctor + * has not archived yet, not unmigrated credentials: failing runtime closed there + * would strand a working store over a file nothing reads. + */ +function hasMigratedAuthProfileCredentials(agentDir?: string): boolean { + let inspection: ReturnType; + try { + inspection = inspectPersistedAuthProfileStoreRaw(agentDir); + } catch { + // An unreadable store is handled by its own canonical error; treat it as + // "cannot serve credentials" so the legacy source stays fail-closed. + return false; + } + if (inspection.status !== "readable") { + return false; + } + const profiles = isRecord(inspection.raw) ? inspection.raw.profiles : undefined; + return isRecord(profiles) && Object.keys(profiles).length > 0; +} + function listStartupLegacyAuthProfileSources(params: { agentDirs: readonly string[]; env?: NodeJS.ProcessEnv; }): Array<{ agentDir: string; sources: LegacyAuthProfileSource[]; - credentialSources: LegacyAuthProfileSource[]; + /** Credential files that are not yet represented by the canonical store. */ + unmigratedCredentialSources: LegacyAuthProfileSource[]; }> { const sharedMainDir = resolveSharedMainAuthAgentDir(params.env); return [...new Set([...params.agentDirs, sharedMainDir])].map((agentDir) => { const sources = listLegacyAuthProfileSources({ agentDir, env: params.env }); - return { agentDir, sources, credentialSources: sources.filter(isCredentialSource) }; + const credentialSources = sources.filter(isCredentialSource); + return { + agentDir, + sources, + unmigratedCredentialSources: + credentialSources.length > 0 && hasMigratedAuthProfileCredentials(agentDir) + ? [] + : credentialSources, + }; }); } @@ -116,31 +148,22 @@ export function hasLegacyAuthProfileSourcesForStartup(params: { env?: NodeJS.ProcessEnv; }): boolean { let detected = false; - for (const { agentDir, sources, credentialSources } of listStartupLegacyAuthProfileSources( - params, - )) { + for (const { + agentDir, + sources, + unmigratedCredentialSources, + } of listStartupLegacyAuthProfileSources(params)) { detected ||= sources.length > 0; - if (credentialSources.length > 0) { + if (unmigratedCredentialSources.length > 0) { markAuthProfileMigrationRequired( agentDir, - new AuthProfileMigrationRequiredError({ agentDir, sources: credentialSources }), + new AuthProfileMigrationRequiredError({ agentDir, sources: unmigratedCredentialSources }), ); } } return detected; } -/** Agent auth stores whose retired credential files make gateway startup fail until Doctor migrates them. */ -export function listAuthProfileStoresRequiringMigration(params: { - agentDirs: readonly string[]; - env?: NodeJS.ProcessEnv; -}): string[] { - const owners = listStartupLegacyAuthProfileSources(params) - .filter(({ credentialSources }) => credentialSources.length > 0) - .map(({ agentDir }) => shortenHomePath(resolveAuthProfileDatabasePath(agentDir))); - return [...new Set(owners)].toSorted(); -} - export class AuthProfileMigrationRequiredError extends Error { readonly code = AUTH_PROFILE_MIGRATION_REQUIRED_CODE; readonly action = AUTH_PROFILE_MIGRATION_COMMAND; @@ -218,11 +241,18 @@ export function assertAuthProfileMigrationReady(agentDir?: string): void { // Older shipped processes and restores can recreate these three fixed files // after startup, so this credential boundary deliberately rechecks their names. const sources = listLegacyAuthProfileSources({ agentDir }).filter(isCredentialSource); - if (sources.length > 0) { - const migrationError = new AuthProfileMigrationRequiredError({ agentDir, sources }); - markAuthProfileMigrationRequired(agentDir, migrationError); - throw migrationError; + if (sources.length === 0) { + return; } + // The store read only happens once a retired file actually exists, so the + // healthy majority keeps the plain name check on this hot path. + if (hasMigratedAuthProfileCredentials(agentDir)) { + warnLegacyAuthProfileSourcesIgnored({ agentDir, sources }); + return; + } + const migrationError = new AuthProfileMigrationRequiredError({ agentDir, sources }); + markAuthProfileMigrationRequired(agentDir, migrationError); + throw migrationError; } export function clearAuthProfileMigrationDiagnostics(): void { diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index d4b12f6278d8..6399fc8105cc 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -937,7 +937,10 @@ function loadAuthProfileStoreForAgent( if (asStore) { const legacySources = listLegacyAuthProfileSources({ agentDir: effectiveAgentDir }); const credentialSources = legacySources.filter((source) => source.kind !== "auth-state"); - if (credentialSources.length > 0) { + // A populated canonical store already owns this agent's credentials, so a + // retired file beside it is unarchived bytes rather than pending migration. + // Only an empty store means the credentials still live solely in that file. + if (credentialSources.length > 0 && Object.keys(asStore.profiles).length === 0) { const migrationError = new AuthProfileMigrationRequiredError({ agentDir: effectiveAgentDir, sources: credentialSources, diff --git a/src/agents/sessions/auth-storage.test.ts b/src/agents/sessions/auth-storage.test.ts index 238de9172130..000f57795b42 100644 --- a/src/agents/sessions/auth-storage.test.ts +++ b/src/agents/sessions/auth-storage.test.ts @@ -246,12 +246,13 @@ describe("SQLite auth storage", () => { }, }); - await expect(storage.getApiKey("test-oauth")).rejects.toThrow( - "requires legacy credential migration", - ); - expect(loadPersistedAuthProfileStore(agentDir)?.profiles["test-oauth:default"]).toMatchObject({ - expires: 1, - }); + // The store already owns this profile, so a retired file appearing mid-call is + // leftover bytes: discarding a completed refresh over it would strand the agent + // on an expired token. Doctor never overwrites a usable stored credential. + await expect(storage.getApiKey("test-oauth")).resolves.toBe("not-a-real"); + expect( + loadPersistedAuthProfileStore(agentDir)?.profiles["test-oauth:default"], + ).not.toMatchObject({ expires: 1 }); }); it("keeps AuthStorage.create(path) as a named SQLite-backed deprecation", () => { @@ -291,9 +292,9 @@ describe("SQLite auth storage", () => { }); expect(fs.existsSync(legacyPath)).toBe(false); fs.writeFileSync(legacyPath, '{"openai":{"key":"fake-late"}}\n'); - await expect(storage.getApiKey("openai")).rejects.toThrow( - "requires legacy credential migration", - ); + // Never read the retired file, but keep serving the migrated store beside it. + await expect(storage.getApiKey("openai")).resolves.toBe("fake-openai-key"); + expect(fs.existsSync(legacyPath)).toBe(true); }); it("blocks ambient fallback when the compatibility backend cannot materialize SQLite refs", async () => { diff --git a/src/cli/daemon-cli/lifecycle-action-preflight.test.ts b/src/cli/daemon-cli/lifecycle-action-preflight.test.ts index 11e36a85c18e..a36e5a10910e 100644 --- a/src/cli/daemon-cli/lifecycle-action-preflight.test.ts +++ b/src/cli/daemon-cli/lifecycle-action-preflight.test.ts @@ -31,23 +31,11 @@ async function withIsolatedLifecycleState( } describe("getServiceActionPreflightFailure", () => { - it.each(["start", "restart"] as const)( - "blocks %s when a legacy credential file exists", - async (action) => { - await withIsolatedLifecycleState(async ({ agentDir }) => { - await fs.writeFile(path.join(agentDir, "auth-profiles.json"), "{}\n"); - - await expect(getServiceActionPreflightFailure(action)).resolves.toEqual({ - message: - "Auth profile store ~/state/agents/main/agent/openclaw-agent.sqlite requires legacy credential migration.", - hints: ["Run `openclaw doctor --fix`, then retry this command."], - }); - }); - }, - ); - - it.each(["stop", "uninstall"] as const)( - "allows %s with the same pending migration", + // A retired credential file no longer blocks the service: the Gateway boots and + // marks that auth owner configured-unavailable, so one stale file cannot keep + // every other channel and provider offline. + it.each(["start", "restart", "stop", "uninstall"] as const)( + "allows %s when a legacy credential file exists", async (action) => { await withIsolatedLifecycleState(async ({ agentDir }) => { await fs.writeFile(path.join(agentDir, "auth-profiles.json"), "{}\n"); diff --git a/src/cli/daemon-cli/lifecycle-action-preflight.ts b/src/cli/daemon-cli/lifecycle-action-preflight.ts index c66e6826ad78..8b86463edca6 100644 --- a/src/cli/daemon-cli/lifecycle-action-preflight.ts +++ b/src/cli/daemon-cli/lifecycle-action-preflight.ts @@ -1,13 +1,8 @@ -import { - AUTH_PROFILE_MIGRATION_COMMAND, - listAuthProfileStoresRequiringMigration, -} from "../../agents/auth-profiles/legacy-source-diagnostic.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js"; import { renderConfigValidationIssueLines } from "../../config/issue-location.js"; import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../../config/recovery-policy.js"; import type { ConfigFileSnapshot } from "../../config/types.openclaw.js"; -import { collectCandidateAgentDirs } from "../../secrets/runtime-fast-path.js"; import { formatPluginPackagingRuntimeOutputRecoveryHint } from "../config-recovery-hints.js"; /** Service lifecycle actions; only start/restart bring the gateway up. */ @@ -25,46 +20,10 @@ const ACTION_PROSE: Record = { uninstall: "uninstall the gateway service", }; -const GATEWAY_LAUNCHING_ACTIONS = new Set(["start", "restart"]); - function formatPluginPackagingRuntimeOutputRecoveryHints(): string[] { return formatPluginPackagingRuntimeOutputRecoveryHint().split("\n"); } -/** - * Retired credential files make the gateway throw AuthProfileMigrationRequiredError during boot. - * Blocking here keeps the running service up instead of taking it down into a known-fatal state. - * Only launching actions are gated; stop/uninstall never read the auth store. - */ -function resolveAuthProfileMigrationBlock( - action: DaemonServiceAction, - snapshot: ConfigFileSnapshot, -): ServiceActionPreflightFailure | null { - if (!GATEWAY_LAUNCHING_ACTIONS.has(action) || !snapshot.valid) { - return null; - } - let stores: string[]; - try { - stores = listAuthProfileStoresRequiringMigration({ - agentDirs: collectCandidateAgentDirs(snapshot.runtimeConfig, process.env), - env: process.env, - }); - } catch { - // A preflight must never be the reason a lifecycle command fails. - return null; - } - if (stores.length === 0) { - return null; - } - return { - message: - stores.length === 1 - ? `Auth profile store ${stores[0]} requires legacy credential migration.` - : `Auth profile stores ${stores.join(", ")} require legacy credential migration.`, - hints: [`Run \`${AUTH_PROFILE_MIGRATION_COMMAND}\`, then retry this command.`], - }; -} - /** Best-effort validation before a service action mutates runtime state. */ export async function getServiceActionPreflightFailure( action: DaemonServiceAction, @@ -92,5 +51,5 @@ export async function getServiceActionPreflightFailure( if (futureBlock) { return { message: futureBlock.message, hints: futureBlock.hints }; } - return resolveAuthProfileMigrationBlock(action, snapshot); + return null; } diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index 6013d847cd9f..1fdcab4493c3 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { listAuthProfileStoresRequiringMigration } from "../agents/auth-profiles/legacy-source-diagnostic.js"; +import { assertAuthProfileMigrationReady } from "../agents/auth-profiles/legacy-source-diagnostic.js"; import { resolveAuthProfileEligibility } from "../agents/auth-profiles/order.js"; import { loadPersistedAuthProfileStore, @@ -780,12 +780,7 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { ).toEqual({ eligible: false, reasonCode: "unresolved_ref" }); expect(fs.existsSync(authPath)).toBe(false); expectMigratedArchive(authPath); - expect( - listAuthProfileStoresRequiringMigration({ - agentDirs: [state.agentDir()], - env: state.env, - }), - ).toEqual([]); + expect(() => assertAuthProfileMigrationReady(state.agentDir())).not.toThrow(); }); it("imports valid profiles when one legacy OAuth sidecar ref is unresolved", async () => { @@ -844,12 +839,7 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { }); expect(fs.existsSync(authPath)).toBe(false); expectMigratedArchive(authPath); - expect( - listAuthProfileStoresRequiringMigration({ - agentDirs: [state.agentDir()], - env: state.env, - }), - ).toEqual([]); + expect(() => assertAuthProfileMigrationReady(state.agentDir())).not.toThrow(); }); it("keeps existing SQLite credentials when importing stale JSON", async () => { diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index 87f5129a7828..4873b51fd86a 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -144,7 +144,7 @@ export function formatDuplicateAgentDirError(dups: DuplicateAgentDir[]): string ...dups.map((d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`), "", "Fix: remove the shared agents.entries.*.agentDir override (or give each agent its own directory).", - "If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.", + "Auth profiles live in each agent's SQLite store, so a shared agentDir is not how credentials are shared: give each agent its own directory and either leave its store empty to inherit the main agent's profiles, or log it in with `openclaw models auth login`.", ]; return lines.join("\n"); } diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index 1cc64445c420..e85aa528edb7 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -26,7 +26,7 @@ Conflicts: - ${shared}: "a", "b" Fix: remove the shared agents.entries.*.agentDir override (or give each agent its own directory). -If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.`, +Auth profiles live in each agent's SQLite store, so a shared agentDir is not how credentials are shared: give each agent its own directory and either leave its store empty to inherit the main agent's profiles, or log it in with \`openclaw models auth login\`.`, }, ]); } diff --git a/src/gateway/server.sessions.compaction-read-errors.test.ts b/src/gateway/server.sessions.compaction-read-errors.test.ts index 47e463f7a9a3..2f1cbbce4f30 100644 --- a/src/gateway/server.sessions.compaction-read-errors.test.ts +++ b/src/gateway/server.sessions.compaction-read-errors.test.ts @@ -97,10 +97,35 @@ async function seedCompactionSession(params: { const transcriptReadError = () => new Error("SQLITE_IOERR: failed to read session transcript storage"); +/** + * Injects the read failure for one seeded session instead of the next global call. + * `--isolate=false` shares a worker, so any sibling transcript read can consume a + * `*Once` mock before the compaction RPC issues its own and the failure silently + * disappears. Keying on sessionId makes the injection independent of call order. + */ +function failTranscriptReadsForSession( + sessionId: string, + options?: { succeedFirstWith: Awaited> }, +): void { + let sessionReads = 0; + transcriptReads.load.mockImplementation(async (scope, ...rest) => { + if (scope.sessionId !== sessionId) { + return await ( + await actualTranscriptReader() + )(scope, ...rest); + } + sessionReads += 1; + if (options && sessionReads === 1) { + return options.succeedFirstWith; + } + throw transcriptReadError(); + }); +} + test("sessions.compact reports initial transcript read failures as unavailable", async () => { const { storePath } = await createSessionStoreDir(); await seedCompactionSession({ sessionId: "sess-read-failure", storePath }); - transcriptReads.load.mockRejectedValueOnce(transcriptReadError()); + failTranscriptReadsForSession("sess-read-failure"); const { ws } = await openClient(); try { @@ -124,7 +149,7 @@ test("sessions.compact reports model compaction transcript re-read failures as u nativeHarness: true, }); const events = await (await actualTranscriptReader())(scope); - transcriptReads.load.mockResolvedValueOnce(events).mockRejectedValueOnce(transcriptReadError()); + failTranscriptReadsForSession("sess-model-read-failure", { succeedFirstWith: events }); const { ws } = await openClient(); try { @@ -143,7 +168,7 @@ test("sessions.compact reports model compaction transcript re-read failures as u test("sessions.compact maxLines reports transcript preflight read failures as unavailable", async () => { const { storePath } = await createSessionStoreDir(); await seedCompactionSession({ sessionId: "sess-max-lines-read-failure", storePath }); - transcriptReads.load.mockRejectedValueOnce(transcriptReadError()); + failTranscriptReadsForSession("sess-max-lines-read-failure"); const { ws } = await openClient(); try { diff --git a/src/plugin-sdk/secret-ref-runtime.ts b/src/plugin-sdk/secret-ref-runtime.ts index 49d3bb1796d1..f522da3fdd5b 100644 --- a/src/plugin-sdk/secret-ref-runtime.ts +++ b/src/plugin-sdk/secret-ref-runtime.ts @@ -15,7 +15,6 @@ import { parsePluginSecretTargetSpecifier, } from "../secrets/plugin-setup-plan.js"; import { createPrivateWindowsPlanFile } from "../secrets/private-plan-file.js"; -import { resolveSecretPlanTargetByPathCore } from "../secrets/target-registry-query.js"; import { resolveTrustedExecutablePath, resolveTrustedPlanDirectoryPath, @@ -443,24 +442,3 @@ export const pluginSecretRefSetup = { resolveTrustedExecutablePath, writePlanFile: writeSecretPlanFile, }; - -export type ResolvedSecretPlanTarget = { - targetType: string; - providerId?: string; - accountId?: string; -}; - -export function resolveSecretPlanTargetByPath(params: { - configFile: "openclaw.json" | "auth-profiles.json"; - pathSegments: string[]; -}): ResolvedSecretPlanTarget | null { - const resolved = resolveSecretPlanTargetByPathCore(params); - if (!resolved) { - return null; - } - return { - targetType: resolved.entry.targetType, - ...(resolved.providerId ? { providerId: resolved.providerId } : {}), - ...(resolved.accountId ? { accountId: resolved.accountId } : {}), - }; -} diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 00b8993db73b..af6c68c64c83 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -385,7 +385,7 @@ function applyConfigTargetMutations(params: { let configChanged = false; for (const { target, resolved } of resolvedTargets) { - if (resolved.entry.configFile === "auth-profiles.json") { + if (resolved.entry.configFile === "auth-profile-store") { const authStoreChanged = applyAuthProfileTargetMutation({ target, resolved, @@ -655,7 +655,7 @@ function applyAuthProfileTargetMutation(params: { authStoreTargetByPath: Map; scrubbedValues: Set; }): boolean { - if (params.resolved.entry.configFile !== "auth-profiles.json") { + if (params.resolved.entry.configFile !== "auth-profile-store") { return false; } const { store } = resolveAuthStoreForTarget({ diff --git a/src/secrets/configure-plan.test.ts b/src/secrets/configure-plan.test.ts index 8d41ff2fad23..f2b0aa2b3c0d 100644 --- a/src/secrets/configure-plan.test.ts +++ b/src/secrets/configure-plan.test.ts @@ -86,7 +86,7 @@ describe("secrets configure plan helpers", () => { ); expect(openaiCandidate?.type).toBe("auth-profiles.api_key.key"); expect(openaiCandidate?.agentId).toBe("main"); - expect(openaiCandidate?.configFile).toBe("auth-profiles.json"); + expect(openaiCandidate?.configFile).toBe("auth-profile-store"); expect(openaiCandidate?.authProfileProvider).toBe("openai"); }); diff --git a/src/secrets/configure-plan.ts b/src/secrets/configure-plan.ts index 7be50418b43b..609a4e0c26b5 100644 --- a/src/secrets/configure-plan.ts +++ b/src/secrets/configure-plan.ts @@ -21,7 +21,7 @@ export type ConfigureCandidate = { path: string; pathSegments: string[]; label: string; - configFile: "openclaw.json" | "auth-profiles.json"; + configFile: "openclaw.json" | "auth-profile-store"; expectedResolvedValue: "string" | "string-or-object"; existingRef?: SecretRef; isDerived?: boolean; @@ -50,7 +50,7 @@ function getSecretProviders(config: OpenClawConfig): Record { function planAcceptsExecRefForSample(params: { type: string; - configFile: "openclaw.json" | "auth-profiles.json"; + configFile: "openclaw.json" | "auth-profile-store"; pathSegments: string[]; id: string; }): boolean { @@ -291,7 +291,7 @@ describe("exec SecretRef id parity", () => { path: params.pathSegments.join("."), pathSegments: params.pathSegments, ref: { source: "exec", provider: "vault", id: params.id }, - ...(params.configFile === "auth-profiles.json" ? { agentId: "main" } : {}), + ...(params.configFile === "auth-profile-store" ? { agentId: "main" } : {}), }, ], }); diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 9003b27942be..150f290881af 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -143,7 +143,7 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { ) { return false; } - if (resolved.entry.configFile === "auth-profiles.json") { + if (resolved.entry.configFile === "auth-profile-store") { if (typeof candidate.agentId !== "string" || candidate.agentId.trim().length === 0) { return false; } diff --git a/src/secrets/plugin-setup-plan.ts b/src/secrets/plugin-setup-plan.ts index ecf780e068af..90ecca800514 100644 --- a/src/secrets/plugin-setup-plan.ts +++ b/src/secrets/plugin-setup-plan.ts @@ -99,7 +99,7 @@ function createPluginConfigSecretTarget(params: { throw new Error(`Invalid --target config path: ${params.path}`); } const resolved = resolveSecretPlanTargetByPathCore({ - configFile: params.agentId ? "auth-profiles.json" : "openclaw.json", + configFile: params.agentId ? "auth-profile-store" : "openclaw.json", pathSegments, }); if (!resolved) { diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index c890d1b05b89..9c412bc736fa 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -236,7 +236,7 @@ vi.mock("../plugins/web-provider-public-artifacts.explicit.js", () => ({ type SecretRegistryEntry = { id: string; - configFile: "openclaw.json" | "auth-profiles.json"; + configFile: "openclaw.json" | "auth-profile-store"; pathPattern: string; refPathPattern?: string; secretShape: "secret_input" | "sibling_ref"; @@ -247,7 +247,7 @@ type SecretRegistryEntry = { type SecretRefCredentialMatrix = { entries: Array<{ id: string; - configFile: "openclaw.json" | "auth-profiles.json"; + configFile: "openclaw.json" | "auth-profile-store"; path: string; refPath?: string; secretShape: SecretRegistryEntry["secretShape"]; @@ -880,7 +880,7 @@ const OPENCLAW_PLUGIN_COVERAGE_BATCHES = buildCoverageBatches( collectOpenClawCoverageEntries({ includePluginEntries: true }), ); const AUTH_PROFILE_COVERAGE_BATCHES = buildCoverageBatches( - COVERAGE_REGISTRY_ENTRIES.filter((entry) => entry.configFile === "auth-profiles.json"), + COVERAGE_REGISTRY_ENTRIES.filter((entry) => entry.configFile === "auth-profile-store"), ); function toCoverageBatchCase(batch: SecretRegistryEntry[]) { diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 90d2afc3c8d9..7656d5bbea90 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -107,7 +107,7 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ { id: "auth-profiles.api_key.key", targetType: "auth-profiles.api_key.key", - configFile: "auth-profiles.json", + configFile: "auth-profile-store", pathPattern: "profiles.*.key", refPathPattern: "profiles.*.keyRef", secretShape: SIBLING_REF_SHAPE, @@ -120,7 +120,7 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ { id: "auth-profiles.token.token", targetType: "auth-profiles.token.token", - configFile: "auth-profiles.json", + configFile: "auth-profile-store", pathPattern: "profiles.*.token", refPathPattern: "profiles.*.tokenRef", secretShape: SIBLING_REF_SHAPE, diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 76e265c3f250..01f7300e6a6e 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -89,7 +89,7 @@ function compileSecretTargetRegistryState(registry: SecretTargetRegistryEntry[]) (entry) => entry.configFile === "openclaw.json", ); const authProfilesCompiledSecretTargets = compiledSecretTargetRegistry.filter( - (entry) => entry.configFile === "auth-profiles.json", + (entry) => entry.configFile === "auth-profile-store", ); return { authProfilesCompiledSecretTargets, @@ -144,7 +144,7 @@ function getCompiledCoreAuthProfileTargetState() { return compiledCoreAuthProfileTargetState; } const entries = getCoreSecretTargetRegistry() - .filter((entry) => entry.configFile === "auth-profiles.json") + .filter((entry) => entry.configFile === "auth-profile-store") .map(compileTargetRegistryEntry); compiledCoreAuthProfileTargetState = { entries, @@ -582,7 +582,7 @@ export function discoverAuthProfileSecretTargets( */ export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] { return getCoreSecretTargetRegistry().filter( - (entry) => entry.configFile === "auth-profiles.json" && entry.includeInAudit, + (entry) => entry.configFile === "auth-profile-store" && entry.includeInAudit, ); } diff --git a/src/secrets/target-registry-types.ts b/src/secrets/target-registry-types.ts index 70c9e6ffd284..9431efdfd360 100644 --- a/src/secrets/target-registry-types.ts +++ b/src/secrets/target-registry-types.ts @@ -1,5 +1,5 @@ /** Config document that owns a registered secret-bearing target. */ -export type SecretTargetConfigFile = "openclaw.json" | "auth-profiles.json"; // pragma: allowlist secret +export type SecretTargetConfigFile = "openclaw.json" | "auth-profile-store"; // pragma: allowlist secret /** Storage shape used by a target: inline SecretInput or a sibling `*Ref` field. */ export type SecretTargetShape = "secret_input" | "sibling_ref"; // pragma: allowlist secret /** Resolved value shape accepted by runtime and apply validation. */ diff --git a/src/secrets/target-registry.docs.test.ts b/src/secrets/target-registry.docs.test.ts index 1a4fed089bc1..58bcfb1986cd 100644 --- a/src/secrets/target-registry.docs.test.ts +++ b/src/secrets/target-registry.docs.test.ts @@ -99,7 +99,7 @@ describe("secret target registry docs", () => { const supportedFromMatrix = new Set( matrix.entries.map((entry) => - entry.configFile === "auth-profiles.json" && entry.refPath ? entry.refPath : entry.path, + entry.configFile === "auth-profile-store" && entry.refPath ? entry.refPath : entry.path, ), ); const unsupportedFromMatrix = new Set(matrix.excludedMutableOrRuntimeManaged); diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 78627e9f1562..e12d641664ef 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -60,7 +60,7 @@ describe("secret target registry", () => { pathSegments: ["models", "providers", "openai", "apiKey"], }); const authProfileTarget = resolveSecretPlanTargetByPathCore({ - configFile: "auth-profiles.json", + configFile: "auth-profile-store", pathSegments: ["profiles", "openai:default", "key"], }); diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 6b64fb4f0ae8..52acf6e9b909 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -50,6 +50,14 @@ export type WizardProgress = { stop: (message?: string) => void; }; +/** + * Device-code phishing gets the victim to enter the attacker's code, so warning + * only against sharing the code misses the actual attack. Wording tracks the + * Codex CLI prompt so operators see one story across both tools. + */ +export const DEVICE_CODE_PHISHING_WARNING = + "Continue only if you started this sign-in yourself. If a website or another person gave you this code, cancel."; + type WizardDeviceCodeParams = { title: string; code: string; diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 738378f4fda0..9c2e54bb8a3e 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -1,6 +1,7 @@ // Wizard session tests cover session creation and state transitions. import { describe, expect, test, vi } from "vitest"; +import { DEVICE_CODE_PHISHING_WARNING } from "./prompts.js"; import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "./session.js"; function noteRunner() { @@ -138,8 +139,12 @@ describe("WizardSession", () => { expect(first.step).toMatchObject({ type: "note", title: "Provider sign-in", - message: - "Enter this one-time code in your browser.\nCode: ABCD-1234\nCode expires in 15 minutes. Never share it.", + message: [ + "Enter this one-time code in your browser.", + "Code: ABCD-1234", + "Code expires in 15 minutes.", + DEVICE_CODE_PHISHING_WARNING, + ].join("\n"), externalUrl: "https://provider.example/device", deviceCode: { code: "ABCD-1234", diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 567569a23c14..ebe7a93e5f3a 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto"; import type { WizardStep as ProtocolWizardStep } from "../../packages/gateway-protocol/src/index.js"; import { createDeferredCore, type Deferred } from "../shared/deferred.js"; -import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js"; +import { + DEVICE_CODE_PHISHING_WARNING, + WizardCancelledError, + type WizardProgress, + type WizardPrompter, +} from "./prompts.js"; // WizardSession exposes interactive setup as a step/answer protocol for remote // clients while reusing the same WizardPrompter contract as the local CLI. @@ -109,9 +114,12 @@ class WizardSessionPrompter implements WizardPrompter { const fallbackMessage = [ params.message ?? "Enter this one-time code on the provider's sign-in page.", `Code: ${params.code}`, - ...(params.expiresInMinutes - ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] - : []), + ...(params.expiresInMinutes ? [`Code expires in ${params.expiresInMinutes} minutes.`] : []), + // Device-code phishing works by getting the victim to enter the attacker's + // code, so the warning has to cover received codes, not just shared ones. + // Unconditional: codes delivered over a chat channel are the risky case and + // carry no expiry hint. Matches the Codex CLI prompt. + DEVICE_CODE_PHISHING_WARNING, ].join("\n"); await this.prompt({ type: "note",