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",