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.
This commit is contained in:
Peter Steinberger
2026-08-20 00:42:41 -07:00
committed by GitHub
parent 966c233dcc
commit 147edf47f3
37 changed files with 250 additions and 196 deletions
+1 -1
View File
@@ -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/<agentId>/agent/auth-profiles.json`.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/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.
+2 -2
View File
@@ -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,
+5 -5
View File
@@ -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 <id>`: scope `auth-profiles.json` target discovery and writes to one agent store
- `--agent <id>`: 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
+14 -3
View File
@@ -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)
+14
View File
@@ -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
@@ -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": {
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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(
@@ -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);
});
});
@@ -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();
});
});
});
@@ -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<typeof inspectPersistedAuthProfileStoreRaw>;
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 {
+4 -1
View File
@@ -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,
+10 -9
View File
@@ -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 () => {
@@ -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");
@@ -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<DaemonServiceAction, string> = {
uninstall: "uninstall the gateway service",
};
const GATEWAY_LAUNCHING_ACTIONS = new Set<DaemonServiceAction>(["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;
}
+3 -13
View File
@@ -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 () => {
+1 -1
View File
@@ -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");
}
@@ -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\`.`,
},
]);
}
@@ -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<ReturnType<LoadTranscriptEvents>> },
): 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 {
-22
View File
@@ -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 } : {}),
};
}
+2 -2
View File
@@ -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<string, AuthProfileStoreTarget>;
scrubbedValues: Set<string>;
}): boolean {
if (params.resolved.entry.configFile !== "auth-profiles.json") {
if (params.resolved.entry.configFile !== "auth-profile-store") {
return false;
}
const { store } = resolveAuthStoreForTarget({
+1 -1
View File
@@ -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");
});
+3 -3
View File
@@ -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<string, SecretProvid
}
function configureCandidateSortKey(candidate: ConfigureCandidate): string {
if (candidate.configFile === "auth-profiles.json") {
if (candidate.configFile === "auth-profile-store") {
const agentId = candidate.agentId ?? "";
return `auth-profiles:${agentId}:${candidate.path}`;
}
@@ -143,7 +143,7 @@ export function buildConfigureCandidatesForScope(params: {
path: entry.path,
pathSegments: [...entry.pathSegments],
label: `${entry.path} (auth profile, agent ${authProfiles.agentId})`,
configFile: `auth-profiles.json` as const,
configFile: `auth-profile-store` as const,
expectedResolvedValue: entry.entry.expectedResolvedValue,
},
resolved.ref ? { existingRef: resolved.ref } : {},
+7 -5
View File
@@ -265,11 +265,11 @@ async function promptOptionalPositiveInt(params: {
}
function configureCandidateKey(candidate: {
configFile: "openclaw.json" | "auth-profiles.json";
configFile: "openclaw.json" | "auth-profile-store";
path: string;
agentId?: string;
}): string {
if (candidate.configFile === "auth-profiles.json") {
if (candidate.configFile === "auth-profile-store") {
return `auth-profiles:${normalizeOptionalString(candidate.agentId) ?? ""}:${candidate.path}`;
}
return `openclaw:${candidate.path}`;
@@ -374,7 +374,7 @@ async function promptNewAuthProfileCandidate(agentId: string): Promise<Configure
path: `profiles.${profileIdTrimmed}.token`,
pathSegments: ["profiles", profileIdTrimmed, "token"],
label: `profiles.${profileIdTrimmed}.token (auth profile, agent ${agentId})`,
configFile: "auth-profiles.json",
configFile: "auth-profile-store",
agentId,
authProfileProvider: providerTrimmed,
expectedResolvedValue: "string",
@@ -385,7 +385,7 @@ async function promptNewAuthProfileCandidate(agentId: string): Promise<Configure
path: `profiles.${profileIdTrimmed}.key`,
pathSegments: ["profiles", profileIdTrimmed, "key"],
label: `profiles.${profileIdTrimmed}.key (auth profile, agent ${agentId})`,
configFile: "auth-profiles.json",
configFile: "auth-profile-store",
agentId,
authProfileProvider: providerTrimmed,
expectedResolvedValue: "string",
@@ -862,7 +862,9 @@ export async function runSecretsConfigureInteractive(
value: configureCandidateKey(candidate),
label: candidate.label,
hint: [
candidate.configFile === "auth-profiles.json" ? "auth-profiles.json" : "openclaw.json",
// Auth profiles live in the agent's SQLite store; naming the retired
// JSON file here sent operators looking for a file that no longer exists.
candidate.configFile === "auth-profile-store" ? "auth profile store" : "openclaw.json",
candidate.isDerived === true ? "derived" : undefined,
]
.filter(Boolean)
@@ -4,7 +4,7 @@ import { unsupportedSecretRefSurfacePolicy } from "./unsupported-surface-policy.
type CredentialMatrixEntry = {
id: string;
configFile: "openclaw.json" | "auth-profiles.json";
configFile: "openclaw.json" | "auth-profile-store";
path: string;
refPath?: string;
when?: { type: "api_key" | "token" };
@@ -276,7 +276,7 @@ describe("exec SecretRef id parity", () => {
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" } : {}),
},
],
});
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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) {
+3 -3
View File
@@ -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[]) {
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
);
}
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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"],
});
+8
View File
@@ -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;
+7 -2
View File
@@ -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",
+12 -4
View File
@@ -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",