mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
966c233dcc
commit
147edf47f3
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user