mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
fix(auth): create a fresh install with canonical shared-auth ownership (#126783)
* fix(auth): create a fresh install with canonical shared-auth ownership
A brand-new install was born in the retired shape. `parseSharedAuthStoreOwnership(undefined)` returns
`legacy-main`, which is the correct compat answer for an existing install whose profiles really do
live in the main agent database -- but a new install has no ownership row and no legacy data either,
so onboarding wrote its first credential into `agents/main/agent/openclaw-agent.sqlite` and the
operator's very first `openclaw doctor` told them to run a migration for state OpenClaw had created
seconds earlier. The main agent also stayed undeletable until they did.
Record `auth.sharedStore = {"location":"state-db"}` when the shared store is first written and the
legacy source provably holds nothing: no `auth_profile_store` row, no `auth_profile_state` row, and
no unfinished cleanup ledger entry. Any legacy row, or any inspection error, leaves ownership alone
so doctor keeps owning the relocation. The check is memoized per ownership generation with a WeakSet
keyed on the process-stable ownership object, so a legacy root is inspected once per process and
doctor's committed flip naturally invalidates it.
The legacy row inspection moves out of `state-migrations.shared-auth-store.ts` into the auth-profiles
owner so doctor and runtime share one contract instead of runtime importing migration code. Explicit
main-agent credential writes now follow the shared target, which is a no-op on legacy roots where
both routes already resolve to the same file.
No SQLite schema change; the ownership row is data. Existing installs take exactly the path they take
today.
* fix(auth): preserve JSON-era shared credentials
* docs(auth): explain why doctor names the main agent dir during a shared JSON import
* test(auth): assert shared-owner runtime reads
* test(doctor): read migrated catalog credentials through the shared owner
A fresh root records state-db shared ownership, so the model-catalog credential migration persists into the shared store rather than the agent file. The assertion read the agent file directly and saw an empty store while all three credentials were present and correct in state/openclaw.sqlite. Read through the owner for that state root instead of pinning storage layout; the credential contents are still asserted exactly.
This commit is contained in:
committed by
GitHub
parent
1d3d65e924
commit
a8a9f284fb
@@ -3347,7 +3347,7 @@ src/infra/state-migrations.meeting-transcripts.ts 2
|
||||
src/infra/state-migrations.node-host.ts 2
|
||||
src/infra/state-migrations.runtime-state.ts 7
|
||||
src/infra/state-migrations.session-store.ts 12
|
||||
src/infra/state-migrations.shared-auth-store.ts 2
|
||||
src/infra/state-migrations.shared-auth-store.ts 1
|
||||
src/infra/state-migrations.source-snapshot.ts 1
|
||||
src/infra/state-migrations.state-dir.ts 1
|
||||
src/infra/state-migrations.storage.ts 21
|
||||
|
||||
@@ -11,6 +11,10 @@ import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as kyselySync from "../infra/kysely-sync.js";
|
||||
import * as nodeSqlite from "../infra/node-sqlite.js";
|
||||
import {
|
||||
detectSharedAuthStoreMigration,
|
||||
migrateSharedAuthStore,
|
||||
} from "../infra/state-migrations.shared-auth-store.js";
|
||||
import { writeConfigMachineState } from "../state/config-machine-state.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
@@ -30,13 +34,19 @@ import {
|
||||
inspectPersistedAuthProfileStateRaw,
|
||||
inspectPersistedAuthProfileStoreRaw,
|
||||
resolveAuthProfileDatabasePath,
|
||||
writePersistedAuthProfileStateRaw,
|
||||
writePersistedAuthProfileStoreRaw,
|
||||
} from "./auth-profiles/sqlite.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getRuntimeAuthProfileStoreSnapshotRevision,
|
||||
saveAuthProfileStore,
|
||||
} from "./auth-profiles/store.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
|
||||
import type { ApiKeyCredential, AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
|
||||
import {
|
||||
persistAuthProfileBatch,
|
||||
upsertAuthProfileWithLockOrThrow,
|
||||
} from "./auth-profiles/upsert-with-lock.js";
|
||||
|
||||
type RuntimeOnlyOverlay = {
|
||||
profileId: string;
|
||||
@@ -59,20 +69,23 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveExternalAuthProfilesWithPlugins: () => [],
|
||||
}));
|
||||
|
||||
function apiKeyCredential(key: string): ApiKeyCredential {
|
||||
return { type: "api_key", provider: "openai", key };
|
||||
}
|
||||
|
||||
function apiKeyStore(key: string): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key,
|
||||
},
|
||||
"openai:default": apiKeyCredential(key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void | Promise<void>) {
|
||||
async function withAgentDirEnv(
|
||||
prefix: string,
|
||||
run: (agentDir: string, stateDir: string) => void | Promise<void>,
|
||||
) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const agentDir = path.join(root, "agents", "main", "agent");
|
||||
try {
|
||||
@@ -82,7 +95,7 @@ async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void |
|
||||
OPENCLAW_STATE_DIR: root,
|
||||
OPENCLAW_AGENT_DIR: agentDir,
|
||||
},
|
||||
async () => await run(agentDir),
|
||||
async () => await run(agentDir, root),
|
||||
);
|
||||
} finally {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
@@ -127,11 +140,22 @@ describe("auth profile sqlite store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the relocated shared store through the shared-state adapter", async () => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-state-", () => {
|
||||
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
|
||||
saveAuthProfileStore({
|
||||
...apiKeyStore("sk-shared"),
|
||||
it.each([
|
||||
{ label: "pre-recorded ownership", recordOwnership: true },
|
||||
{ label: "fresh ownership", recordOwnership: false },
|
||||
])("persists the shared store through the shared-state adapter with $label", async (testCase) => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-state-", async (agentDir) => {
|
||||
if (testCase.recordOwnership) {
|
||||
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
|
||||
}
|
||||
await persistAuthProfileBatch({
|
||||
agentDir,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential("sk-shared"),
|
||||
},
|
||||
],
|
||||
order: { openai: ["openai:default"] },
|
||||
});
|
||||
|
||||
@@ -150,7 +174,218 @@ describe("auth profile sqlite store", () => {
|
||||
.prepare("SELECT store_key FROM auth_profile_state WHERE store_key = 'shared'")
|
||||
.get(),
|
||||
).toEqual({ store_key: "shared" });
|
||||
expect(
|
||||
database
|
||||
.prepare(
|
||||
"SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ value_json: JSON.stringify({ location: "state-db" }) });
|
||||
database.close();
|
||||
expect(fs.existsSync(resolveAuthProfileDatabasePath(agentDir))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "credential row",
|
||||
seed: (agentDir: string) =>
|
||||
writePersistedAuthProfileStoreRaw(apiKeyStore("sk-legacy"), agentDir),
|
||||
},
|
||||
{
|
||||
label: "runtime-state row",
|
||||
seed: (agentDir: string) =>
|
||||
writePersistedAuthProfileStateRaw(
|
||||
{ version: 1, order: { openai: ["openai:legacy"] } },
|
||||
agentDir,
|
||||
),
|
||||
},
|
||||
])("keeps legacy ownership when the main agent has a $label", async (testCase) => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-legacy-", async (agentDir) => {
|
||||
testCase.seed(agentDir);
|
||||
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
agentDir,
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential("sk-updated"),
|
||||
});
|
||||
|
||||
const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath());
|
||||
expect(
|
||||
sharedDatabase
|
||||
.prepare(
|
||||
"SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'",
|
||||
)
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
sharedDatabase
|
||||
.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'")
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
sharedDatabase.close();
|
||||
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable");
|
||||
});
|
||||
});
|
||||
|
||||
it("memoizes legacy inspection and follows Doctor's ownership flip", async () => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-memo-", async (agentDir, stateDir) => {
|
||||
const sourcePath = resolveAuthProfileDatabasePath(agentDir);
|
||||
writePersistedAuthProfileStoreRaw(apiKeyStore("sk-legacy"), agentDir);
|
||||
const realLstat = fs.lstatSync;
|
||||
let sourceInspections = 0;
|
||||
const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((pathname, options) => {
|
||||
if (path.resolve(String(pathname)) === path.resolve(sourcePath)) {
|
||||
sourceInspections += 1;
|
||||
}
|
||||
return realLstat(pathname, options as never);
|
||||
});
|
||||
|
||||
try {
|
||||
for (const key of ["sk-first", "sk-second"]) {
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
agentDir,
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential(key),
|
||||
});
|
||||
}
|
||||
expect(sourceInspections).toBe(1);
|
||||
|
||||
const detected = detectSharedAuthStoreMigration({
|
||||
stateDir,
|
||||
doctorOnlyStateMigrations: true,
|
||||
});
|
||||
await migrateSharedAuthStore({ detected, stateDir });
|
||||
const inspectionsAfterDoctor = sourceInspections;
|
||||
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
agentDir,
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential("sk-after-doctor"),
|
||||
});
|
||||
|
||||
expect(sourceInspections).toBe(inspectionsAfterDoctor);
|
||||
expect(ensureAuthProfileStore(undefined, { syncExternalCli: false })).toMatchObject({
|
||||
profiles: { "openai:default": { key: "sk-after-doctor" } },
|
||||
});
|
||||
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("missing");
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy ownership while shared-auth cleanup is pending", async () => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-pending-", async (agentDir) => {
|
||||
const sourcePath = resolveAuthProfileDatabasePath(agentDir);
|
||||
writeConfigMachineState("test.seed", true);
|
||||
const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath());
|
||||
sharedDatabase
|
||||
.prepare(
|
||||
`INSERT INTO migration_runs (id, started_at, finished_at, status, report_json)
|
||||
VALUES ('shared-auth-pending', 1, NULL, 'copied', '{}')`,
|
||||
)
|
||||
.run();
|
||||
sharedDatabase
|
||||
.prepare(
|
||||
`INSERT INTO migration_sources
|
||||
(source_key, migration_kind, source_path, target_table, source_sha256,
|
||||
source_size_bytes, source_record_count, last_run_id, status, imported_at,
|
||||
removed_source, report_json)
|
||||
VALUES ('shared-auth-pending:store', 'shared-auth-store-state-db', ?,
|
||||
'auth_profile_stores', NULL, NULL, NULL, 'shared-auth-pending',
|
||||
'copied', 1, 0, '{}')`,
|
||||
)
|
||||
.run(sourcePath);
|
||||
sharedDatabase.close();
|
||||
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
agentDir,
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential("sk-after-crash"),
|
||||
});
|
||||
|
||||
const after = new DatabaseSync(resolveOpenClawStateSqlitePath());
|
||||
expect(
|
||||
after
|
||||
.prepare(
|
||||
"SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'",
|
||||
)
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
after.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'").get(),
|
||||
).toBeUndefined();
|
||||
after.close();
|
||||
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy ownership when the main-agent source is unreadable", async () => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-unreadable-", async (agentDir) => {
|
||||
const sourcePath = resolveAuthProfileDatabasePath(agentDir);
|
||||
const realLstat = fs.lstatSync;
|
||||
const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((pathname, options) => {
|
||||
if (path.resolve(String(pathname)) === path.resolve(sourcePath)) {
|
||||
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
}
|
||||
return realLstat(pathname, options as never);
|
||||
});
|
||||
|
||||
try {
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
agentDir,
|
||||
profileId: "openai:default",
|
||||
credential: apiKeyCredential("sk-unreadable"),
|
||||
});
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
}
|
||||
|
||||
const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath());
|
||||
expect(
|
||||
sharedDatabase
|
||||
.prepare(
|
||||
"SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'",
|
||||
)
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
sharedDatabase.close();
|
||||
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy ownership when a retired-file probe fails", async () => {
|
||||
await withAgentDirEnv("openclaw-auth-shared-file-probe-error-", async (agentDir) => {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const realExistsSync = fs.existsSync.bind(fs);
|
||||
let authPathProbes = 0;
|
||||
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((pathname) => {
|
||||
if (path.resolve(String(pathname)) === path.resolve(authPath)) {
|
||||
authPathProbes += 1;
|
||||
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
}
|
||||
return realExistsSync(pathname);
|
||||
});
|
||||
|
||||
try {
|
||||
writePersistedAuthProfileStoreRaw(apiKeyStore("sk-first"));
|
||||
writePersistedAuthProfileStoreRaw(apiKeyStore("sk-second"));
|
||||
expect(authPathProbes).toBe(1);
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
}
|
||||
|
||||
const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath());
|
||||
expect(
|
||||
sharedDatabase
|
||||
.prepare(
|
||||
"SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'",
|
||||
)
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
sharedDatabase.close();
|
||||
expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,98 +1,33 @@
|
||||
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 {
|
||||
listLegacyAuthProfileSources,
|
||||
type LegacyAuthProfileSource,
|
||||
type LegacyAuthProfileSourceKind,
|
||||
} from "./legacy-source-files.js";
|
||||
import { resolveSharedAuthStorePath } from "./path-resolve.js";
|
||||
import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js";
|
||||
import { inspectPersistedAuthProfileStoreRaw, resolveAuthProfileDatabasePath } from "./sqlite.js";
|
||||
|
||||
export {
|
||||
listLegacyAuthProfileArchives,
|
||||
listLegacyAuthProfileSources,
|
||||
resolveLegacyOAuthPath,
|
||||
} from "./legacy-source-files.js";
|
||||
|
||||
const AUTH_PROFILE_MIGRATION_REQUIRED_CODE = "AUTH_PROFILE_MIGRATION_REQUIRED" 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";
|
||||
|
||||
type LegacyAuthProfileSource = {
|
||||
kind: LegacyAuthProfileSourceKind;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function isCredentialSource(source: LegacyAuthProfileSource): boolean {
|
||||
return source.kind !== "auth-state";
|
||||
}
|
||||
|
||||
export function resolveLegacyOAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveOAuthDir(env), "oauth.json");
|
||||
}
|
||||
|
||||
function resolveAuthProfileOwnerPath(agentDir?: string): string {
|
||||
return agentDir ? resolveAuthProfileDatabasePath(agentDir) : resolveSharedAuthStorePath();
|
||||
}
|
||||
|
||||
function resolveLegacySourceAgentDir(
|
||||
agentDir: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return agentDir
|
||||
? path.dirname(resolveAuthProfileOwnerPath(agentDir))
|
||||
: resolveSharedMainAuthAgentDir(env);
|
||||
}
|
||||
|
||||
/** Detects retired auth files by name only; runtime code must never read their contents. */
|
||||
export function listLegacyAuthProfileSources(params: {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): LegacyAuthProfileSource[] {
|
||||
const agentDir = resolveLegacySourceAgentDir(params.agentDir, params.env);
|
||||
const candidates: LegacyAuthProfileSource[] = [
|
||||
{ kind: "auth-profiles", path: path.join(agentDir, "auth-profiles.json") },
|
||||
{ kind: "auth-state", path: path.join(agentDir, "auth-state.json") },
|
||||
{ kind: "legacy-auth", path: path.join(agentDir, "auth.json") },
|
||||
];
|
||||
const sharedMainDir = resolveSharedMainAuthAgentDir(params.env);
|
||||
if (path.resolve(agentDir) === path.resolve(sharedMainDir)) {
|
||||
candidates.push({ kind: "legacy-oauth", path: resolveLegacyOAuthPath(params.env) });
|
||||
}
|
||||
return candidates.filter((candidate) => fs.existsSync(candidate.path));
|
||||
}
|
||||
|
||||
export function listLegacyAuthProfileArchives(params: {
|
||||
agentDirs: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): LegacyAuthProfileSource[] {
|
||||
const candidates = new Map<string, LegacyAuthProfileSourceKind>();
|
||||
for (const agentDir of params.agentDirs) {
|
||||
candidates.set(path.join(agentDir, "auth-profiles.json"), "auth-profiles");
|
||||
candidates.set(path.join(agentDir, "auth-state.json"), "auth-state");
|
||||
candidates.set(path.join(agentDir, "auth.json"), "legacy-auth");
|
||||
}
|
||||
candidates.set(resolveLegacyOAuthPath(params.env), "legacy-oauth");
|
||||
const archives: LegacyAuthProfileSource[] = [];
|
||||
for (const [sourcePath, kind] of candidates) {
|
||||
const directory = path.dirname(sourcePath);
|
||||
const baseName = path.basename(sourcePath);
|
||||
const migratedPrefix = `${baseName}.migrated-`;
|
||||
const priorImportPrefix = `${baseName}.sqlite-import.`;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(directory);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.startsWith(migratedPrefix) ||
|
||||
(entry.startsWith(priorImportPrefix) && entry.endsWith(".bak"))
|
||||
) {
|
||||
archives.push({ kind, path: path.join(directory, entry) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return archives;
|
||||
}
|
||||
|
||||
export function hasLegacyAuthProfileCredentialSource(agentDir?: string): boolean {
|
||||
return listLegacyAuthProfileSources({ agentDir }).some(isCredentialSource);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveOAuthDir } from "../../config/paths.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js";
|
||||
|
||||
export type LegacyAuthProfileSourceKind =
|
||||
| "auth-profiles"
|
||||
| "auth-state"
|
||||
| "legacy-auth"
|
||||
| "legacy-oauth";
|
||||
|
||||
export type LegacyAuthProfileSource = {
|
||||
kind: LegacyAuthProfileSourceKind;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function resolveLegacyOAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveOAuthDir(env), "oauth.json");
|
||||
}
|
||||
|
||||
function resolveLegacySourceAgentDir(
|
||||
agentDir: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return agentDir ? resolveUserPath(agentDir) : resolveSharedMainAuthAgentDir(env);
|
||||
}
|
||||
|
||||
/** Detects retired auth files by name only; runtime code must never read their contents. */
|
||||
export function listLegacyAuthProfileSources(params: {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): LegacyAuthProfileSource[] {
|
||||
const agentDir = resolveLegacySourceAgentDir(params.agentDir, params.env);
|
||||
const candidates: LegacyAuthProfileSource[] = [
|
||||
{ kind: "auth-profiles", path: path.join(agentDir, "auth-profiles.json") },
|
||||
{ kind: "auth-state", path: path.join(agentDir, "auth-state.json") },
|
||||
{ kind: "legacy-auth", path: path.join(agentDir, "auth.json") },
|
||||
];
|
||||
const sharedMainDir = resolveSharedMainAuthAgentDir(params.env);
|
||||
if (path.resolve(agentDir) === path.resolve(sharedMainDir)) {
|
||||
candidates.push({ kind: "legacy-oauth", path: resolveLegacyOAuthPath(params.env) });
|
||||
}
|
||||
return candidates.filter((candidate) => fs.existsSync(candidate.path));
|
||||
}
|
||||
|
||||
export function listLegacyAuthProfileArchives(params: {
|
||||
agentDirs: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): LegacyAuthProfileSource[] {
|
||||
const candidates = new Map<string, LegacyAuthProfileSourceKind>();
|
||||
for (const agentDir of params.agentDirs) {
|
||||
candidates.set(path.join(agentDir, "auth-profiles.json"), "auth-profiles");
|
||||
candidates.set(path.join(agentDir, "auth-state.json"), "auth-state");
|
||||
candidates.set(path.join(agentDir, "auth.json"), "legacy-auth");
|
||||
}
|
||||
candidates.set(resolveLegacyOAuthPath(params.env), "legacy-oauth");
|
||||
const archives: LegacyAuthProfileSource[] = [];
|
||||
for (const [sourcePath, kind] of candidates) {
|
||||
const directory = path.dirname(sourcePath);
|
||||
const baseName = path.basename(sourcePath);
|
||||
const migratedPrefix = `${baseName}.migrated-`;
|
||||
const priorImportPrefix = `${baseName}.sqlite-import.`;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(directory);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.startsWith(migratedPrefix) ||
|
||||
(entry.startsWith(priorImportPrefix) && entry.endsWith(".bak"))
|
||||
) {
|
||||
archives.push({ kind, path: path.join(directory, entry) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return archives;
|
||||
}
|
||||
@@ -962,7 +962,8 @@ describe("promoteAuthProfileInOrder", () => {
|
||||
it("normalizes copied secrets when using the locked upsert path", async () => {
|
||||
await withAuthProfileTestState(
|
||||
"openclaw-auth-profile-upsert-",
|
||||
async ({ agentDir }) => {
|
||||
async ({ agentDirFor }) => {
|
||||
const agentDir = agentDirFor("work");
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
await upsertAuthProfileWithLock({
|
||||
|
||||
@@ -198,6 +198,7 @@ export function upsertAuthProfile(params: {
|
||||
store.profiles[params.profileId] = credential;
|
||||
saveAuthProfileStore(store, params.agentDir, {
|
||||
filterExternalAuthProfiles: false,
|
||||
sharedStoreWrite: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { hasErrnoCode } from "../../infra/errno.js";
|
||||
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js";
|
||||
import { writeConfigMachineState } from "../../state/config-machine-state.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import { withExistingOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js";
|
||||
import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { listLegacyAuthProfileSources } from "./legacy-source-files.js";
|
||||
import {
|
||||
noteCommittedSharedAuthStoreOwnership,
|
||||
resolveSharedAuthStoreOwnership,
|
||||
SHARED_AUTH_STORE_STATE_KEY,
|
||||
type SharedAuthStoreOwnership,
|
||||
} from "./path-resolve.js";
|
||||
import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js";
|
||||
|
||||
const PRIMARY_ROW_KEY = "primary";
|
||||
const SHARED_AUTH_STORE_MIGRATION_KIND = "shared-auth-store-state-db";
|
||||
|
||||
// Ownership objects are process-stable per state root. Doctor replaces the cached object
|
||||
// after relocation, so legacy inspection is memoized only for that ownership generation.
|
||||
const inspectedLegacySharedAuthOwnerships = new WeakSet<SharedAuthStoreOwnership>();
|
||||
|
||||
type SourceAuthDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
"auth_profile_store" | "auth_profile_state"
|
||||
>;
|
||||
type SharedAuthMigrationDatabase = Pick<OpenClawStateKyselyDatabase, "migration_sources">;
|
||||
|
||||
export type SharedAuthLegacyStoreRow = { store_json: string; updated_at: number };
|
||||
export type SharedAuthLegacyStateRow = { state_json: string; updated_at: number };
|
||||
export type SharedAuthLegacyRows = {
|
||||
store: SharedAuthLegacyStoreRow | null;
|
||||
state: SharedAuthLegacyStateRow | null;
|
||||
};
|
||||
|
||||
export class SharedAuthStoreSourceInspectionError extends Error {
|
||||
readonly code = "SHARED_AUTH_STORE_SOURCE_UNREADABLE" as const;
|
||||
readonly action = "openclaw doctor --fix" as const;
|
||||
readonly sourcePath: string;
|
||||
|
||||
constructor(sourcePath: string, operation: string, cause: unknown) {
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Cannot ${operation} legacy shared auth database ${sourcePath}: ${detail}`, { cause });
|
||||
this.name = "SharedAuthStoreSourceInspectionError";
|
||||
this.sourcePath = sourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectSharedAuthLegacySourceFile(
|
||||
sourcePath: string,
|
||||
): { status: "missing" } | { status: "present"; size: number } {
|
||||
let entry: fs.Stats;
|
||||
try {
|
||||
entry = fs.lstatSync(sourcePath);
|
||||
} catch (error) {
|
||||
if (hasErrnoCode(error, "ENOENT")) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "inspect", error);
|
||||
}
|
||||
let target = entry;
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
target = fs.statSync(sourcePath);
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "resolve", error);
|
||||
}
|
||||
}
|
||||
if (!target.isFile()) {
|
||||
throw new SharedAuthStoreSourceInspectionError(
|
||||
sourcePath,
|
||||
"open",
|
||||
new Error("path is not a regular file"),
|
||||
);
|
||||
}
|
||||
return { status: "present", size: target.size };
|
||||
}
|
||||
|
||||
export function readSharedAuthLegacyRowsFromDatabase(database: DatabaseSync): SharedAuthLegacyRows {
|
||||
const db = getNodeSqliteKysely<SourceAuthDatabase>(database);
|
||||
const store = tableExists(database, "auth_profile_store")
|
||||
? (executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("auth_profile_store")
|
||||
.select(["store_json", "updated_at"])
|
||||
.where("store_key", "=", PRIMARY_ROW_KEY),
|
||||
) ?? null)
|
||||
: null;
|
||||
const state = tableExists(database, "auth_profile_state")
|
||||
? (executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("auth_profile_state")
|
||||
.select(["state_json", "updated_at"])
|
||||
.where("state_key", "=", PRIMARY_ROW_KEY),
|
||||
) ?? null)
|
||||
: null;
|
||||
return { store, state };
|
||||
}
|
||||
|
||||
export function inspectSharedAuthLegacyRowsReadOnly(sourcePath: string): SharedAuthLegacyRows {
|
||||
if (inspectSharedAuthLegacySourceFile(sourcePath).status === "missing") {
|
||||
return { store: null, state: null };
|
||||
}
|
||||
let database: DatabaseSync;
|
||||
try {
|
||||
database = openNodeSqliteDatabase(sourcePath, { readOnly: true });
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "open", error);
|
||||
}
|
||||
try {
|
||||
return readSharedAuthLegacyRowsFromDatabase(database);
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "read", error);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPendingSharedAuthCleanup(env: NodeJS.ProcessEnv, sourcePath: string): boolean {
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(
|
||||
({ db: database }) => {
|
||||
const db = getNodeSqliteKysely<SharedAuthMigrationDatabase>(database);
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("migration_sources")
|
||||
.select("source_key")
|
||||
.where("migration_kind", "=", SHARED_AUTH_STORE_MIGRATION_KIND)
|
||||
.where("source_path", "=", sourcePath)
|
||||
.where("removed_source", "=", 0)
|
||||
.limit(1),
|
||||
);
|
||||
return Boolean(row);
|
||||
},
|
||||
{ env },
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function initializeFreshSharedAuthStore(env: NodeJS.ProcessEnv): void {
|
||||
const ownership = resolveSharedAuthStoreOwnership(env);
|
||||
if (ownership.location === "state-db" || inspectedLegacySharedAuthOwnerships.has(ownership)) {
|
||||
return;
|
||||
}
|
||||
const sourcePath = path.join(resolveSharedMainAuthAgentDir(env), "openclaw-agent.sqlite");
|
||||
try {
|
||||
if (listLegacyAuthProfileSources({ env }).length > 0) {
|
||||
inspectedLegacySharedAuthOwnerships.add(ownership);
|
||||
return;
|
||||
}
|
||||
const rows = inspectSharedAuthLegacyRowsReadOnly(sourcePath);
|
||||
if (rows.store || rows.state || hasPendingSharedAuthCleanup(env, sourcePath)) {
|
||||
inspectedLegacySharedAuthOwnerships.add(ownership);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Doctor owns unreadable or partially migrated legacy state; never infer past it.
|
||||
inspectedLegacySharedAuthOwnerships.add(ownership);
|
||||
return;
|
||||
}
|
||||
writeConfigMachineState(SHARED_AUTH_STORE_STATE_KEY, { location: "state-db" }, { env });
|
||||
noteCommittedSharedAuthStoreOwnership({ location: "state-db" }, env);
|
||||
}
|
||||
|
||||
export function prepareFreshSharedAuthStoreWrite(params: {
|
||||
agentDir: string | undefined;
|
||||
allowExplicitMain: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
// A main-agent credential is shared; explicit main writes must follow the shared target.
|
||||
// On legacy roots both routes already resolve to the same file, so redirecting is a no-op.
|
||||
const isSharedWrite =
|
||||
params.agentDir === undefined ||
|
||||
(params.allowExplicitMain &&
|
||||
path.resolve(resolveUserPath(params.agentDir, params.env)) ===
|
||||
path.resolve(resolveSharedMainAuthAgentDir(params.env)));
|
||||
if (isSharedWrite) {
|
||||
initializeFreshSharedAuthStore(params.env);
|
||||
}
|
||||
return isSharedWrite;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveRegisteredAgentIdForDir } from "../agent-dir-registry.js";
|
||||
import { resolveSharedAuthStoreOwnership, resolveSharedAuthStorePath } from "./path-resolve.js";
|
||||
import { prepareFreshSharedAuthStoreWrite } from "./shared-store-bootstrap.js";
|
||||
|
||||
type AgentAuthProfileDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
@@ -166,11 +167,13 @@ function resolveAuthProfileDatabaseKind(
|
||||
agentDir: string | undefined,
|
||||
database?: Pick<AuthProfileDatabase, "db">,
|
||||
): AuthProfileDatabaseTarget["kind"] {
|
||||
return agentDir !== undefined
|
||||
? "agent"
|
||||
: database && !("agentId" in database)
|
||||
? "shared-state"
|
||||
: resolveAuthProfileDatabaseOptions(agentDir).kind;
|
||||
if (database && "agentId" in database) {
|
||||
return "agent";
|
||||
}
|
||||
if (database && "path" in database) {
|
||||
return "shared-state";
|
||||
}
|
||||
return resolveAuthProfileDatabaseOptions(agentDir).kind;
|
||||
}
|
||||
|
||||
function inspectAuthProfileTable(
|
||||
@@ -658,12 +661,24 @@ export function writePersistedAuthProfileStateRaw(
|
||||
export function runAuthProfileWriteTransaction<T>(
|
||||
agentDir: string | undefined,
|
||||
operation: (database: AuthProfileDatabase) => T,
|
||||
options: { env?: NodeJS.ProcessEnv; stateDir?: string } = {},
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sharedStoreWrite?: boolean;
|
||||
stateDir?: string;
|
||||
} = {},
|
||||
): T {
|
||||
const env =
|
||||
options.env ??
|
||||
(options.stateDir ? { ...process.env, OPENCLAW_STATE_DIR: options.stateDir } : process.env);
|
||||
const databaseTarget = resolveAuthProfileDatabaseOptions(agentDir, env);
|
||||
const sharedStoreWrite = prepareFreshSharedAuthStoreWrite({
|
||||
agentDir,
|
||||
allowExplicitMain: options.sharedStoreWrite === true,
|
||||
env,
|
||||
});
|
||||
const databaseTarget = resolveAuthProfileDatabaseOptions(
|
||||
sharedStoreWrite ? undefined : agentDir,
|
||||
env,
|
||||
);
|
||||
if (databaseTarget.kind === "agent") {
|
||||
return runOpenClawAgentWriteTransaction(operation, databaseTarget);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ type SaveAuthProfileStoreOptions = {
|
||||
preserveOrderProfileIds?: Iterable<string>;
|
||||
preserveStateProfileIds?: Iterable<string>;
|
||||
pruneOrderProfileIds?: Iterable<string>;
|
||||
sharedStoreWrite?: boolean;
|
||||
syncExternalCli?: boolean;
|
||||
};
|
||||
|
||||
@@ -864,6 +865,7 @@ function mergeRuntimeExternalProfileState(params: {
|
||||
/** Apply an auth store update inside the SQLite write lock. */
|
||||
export async function updateAuthProfileStoreWithLock(params: {
|
||||
agentDir?: string;
|
||||
sharedStoreWrite?: boolean;
|
||||
stateDir?: string;
|
||||
saveOptions?: SaveAuthProfileStoreOptions;
|
||||
updater: (store: AuthProfileStore) => boolean;
|
||||
@@ -891,7 +893,7 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
}
|
||||
return loadedStore;
|
||||
},
|
||||
{ stateDir: params.stateDir },
|
||||
{ sharedStoreWrite: params.sharedStoreWrite, stateDir: params.stateDir },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -1315,25 +1317,30 @@ function saveAuthProfileStoreInTransaction(
|
||||
database: AuthProfileDatabase,
|
||||
publishFromSuppliedStore = false,
|
||||
): () => void {
|
||||
const savedAuthPath = agentDir ? resolveAgentAuthPath(agentDir) : database.path;
|
||||
const mainAuthPath = agentDir ? resolveSharedAuthPath() : database.path;
|
||||
// Shared-state rows are global: never scope their persistence or runtime snapshots to an
|
||||
// agent, or shared credentials are published and cached as agent-local state.
|
||||
const persistenceAgentDir = "agentId" in database ? agentDir : undefined;
|
||||
const savedAuthPath = persistenceAgentDir
|
||||
? resolveAgentAuthPath(persistenceAgentDir)
|
||||
: database.path;
|
||||
const mainAuthPath = persistenceAgentDir ? resolveSharedAuthPath() : database.path;
|
||||
const savesMainStore = savedAuthPath === mainAuthPath;
|
||||
const loadedPersistedStores = loadPersistedAuthProfileStores(agentDir, database);
|
||||
const loadedPersistedStores = loadPersistedAuthProfileStores(persistenceAgentDir, database);
|
||||
const persistedStores: PersistedAuthProfileStores = {
|
||||
...loadedPersistedStores,
|
||||
localStore: loadedPersistedStores.localStore ?? {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
...loadPersistedAuthProfileState(agentDir, database),
|
||||
...loadPersistedAuthProfileState(persistenceAgentDir, database),
|
||||
},
|
||||
};
|
||||
const localStore = buildLocalAuthProfileStoreForSave({
|
||||
store,
|
||||
agentDir,
|
||||
agentDir: persistenceAgentDir,
|
||||
options,
|
||||
persistedStores,
|
||||
});
|
||||
const existingRaw = readPersistedAuthProfileStoreRaw(agentDir, database);
|
||||
const existingRaw = readPersistedAuthProfileStoreRaw(persistenceAgentDir, database);
|
||||
const payload = preserveLegacyOAuthRefsOnSave({
|
||||
payload: buildPersistedAuthProfileSecretsStore(localStore),
|
||||
existingRaw,
|
||||
@@ -1352,20 +1359,25 @@ function saveAuthProfileStoreInTransaction(
|
||||
const credentialsChanged = !isDeepStrictEqual(existingRaw, payload);
|
||||
const statePayload = buildPersistedAuthProfileState(localStore);
|
||||
const stateChanged = !isDeepStrictEqual(
|
||||
readPersistedAuthProfileStateRaw(agentDir, database),
|
||||
readPersistedAuthProfileStateRaw(persistenceAgentDir, database),
|
||||
statePayload,
|
||||
);
|
||||
const suppliedRuntimeStore = publishFromSuppliedStore
|
||||
? markRuntimePersistedProfiles(
|
||||
buildRuntimeAuthProfileStoreForSave({ store, agentDir, options, persistedStores }),
|
||||
buildRuntimeAuthProfileStoreForSave({
|
||||
store,
|
||||
agentDir: persistenceAgentDir,
|
||||
options,
|
||||
persistedStores,
|
||||
}),
|
||||
localStore,
|
||||
)
|
||||
: undefined;
|
||||
if (credentialsChanged) {
|
||||
writePersistedAuthProfileStoreRaw(payload, agentDir, database);
|
||||
writePersistedAuthProfileStoreRaw(payload, persistenceAgentDir, database);
|
||||
}
|
||||
if (stateChanged) {
|
||||
writePersistedAuthProfileStateRaw(statePayload, agentDir, database);
|
||||
writePersistedAuthProfileStateRaw(statePayload, persistenceAgentDir, database);
|
||||
}
|
||||
const publishRuntimeSnapshots = () => {
|
||||
// Main-store publication invalidates derived stores. Capture the latest
|
||||
@@ -1376,7 +1388,7 @@ function saveAuthProfileStoreInTransaction(
|
||||
)
|
||||
: [];
|
||||
if (credentialsChanged || stateChanged) {
|
||||
noteRuntimeAuthProfileStorePersistedMutation(agentDir, {
|
||||
noteRuntimeAuthProfileStorePersistedMutation(persistenceAgentDir, {
|
||||
credentialsChanged,
|
||||
profileSetChanged,
|
||||
stateChanged,
|
||||
@@ -1384,7 +1396,7 @@ function saveAuthProfileStoreInTransaction(
|
||||
});
|
||||
}
|
||||
if (suppliedRuntimeStore) {
|
||||
const existing = getRuntimeAuthProfileStoreSnapshot(agentDir);
|
||||
const existing = getRuntimeAuthProfileStoreSnapshot(persistenceAgentDir);
|
||||
if (existing) {
|
||||
const materialized = preserveResolvedSecretBackedCredentials({
|
||||
next: suppliedRuntimeStore,
|
||||
@@ -1392,7 +1404,7 @@ function saveAuthProfileStoreInTransaction(
|
||||
});
|
||||
setRuntimeAuthProfileStoreSnapshot(
|
||||
mergeRuntimeExternalProfileReferences({ next: materialized, existing }),
|
||||
agentDir,
|
||||
persistenceAgentDir,
|
||||
);
|
||||
}
|
||||
if (savesMainStore && (credentialsChanged || stateChanged)) {
|
||||
@@ -1411,7 +1423,7 @@ function saveAuthProfileStoreInTransaction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
refreshRuntimeAuthProfileStoreSnapshot(agentDir);
|
||||
refreshRuntimeAuthProfileStoreSnapshot(persistenceAgentDir);
|
||||
for (const derived of derivedSnapshots) {
|
||||
const refreshed = loadAuthProfileStoreWithoutExternalProfiles(derived.agentDir);
|
||||
const materialized = preserveResolvedSecretBackedCredentials({
|
||||
@@ -1454,14 +1466,18 @@ export function saveAuthProfileStore(
|
||||
return;
|
||||
}
|
||||
let publishRuntimeSnapshots: (() => void) | undefined;
|
||||
runAuthProfileWriteTransaction(effectiveAgentDir, (transactionDatabase) => {
|
||||
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
store,
|
||||
effectiveAgentDir,
|
||||
options,
|
||||
transactionDatabase,
|
||||
);
|
||||
});
|
||||
runAuthProfileWriteTransaction(
|
||||
effectiveAgentDir,
|
||||
(transactionDatabase) => {
|
||||
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
|
||||
store,
|
||||
effectiveAgentDir,
|
||||
options,
|
||||
transactionDatabase,
|
||||
);
|
||||
},
|
||||
{ sharedStoreWrite: options?.sharedStoreWrite },
|
||||
);
|
||||
publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,7 @@ async function withAgentDir(run: (agentDir: string) => Promise<void>): Promise<v
|
||||
const agentDir = path.join(root, "agents", "work", "agent");
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_STATE_DIR: root, OPENCLAW_AGENT_DIR: agentDir },
|
||||
async () => await run(agentDir),
|
||||
);
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => await run(agentDir));
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function persistAuthProfileBatch(
|
||||
);
|
||||
}
|
||||
},
|
||||
{ stateDir: params.stateDir },
|
||||
{ sharedStoreWrite: true, stateDir: params.stateDir },
|
||||
);
|
||||
|
||||
let rolledBack = false;
|
||||
@@ -150,7 +150,7 @@ export async function persistAuthProfileBatch(
|
||||
writePersistedAuthProfileStateRaw(null, params.agentDir, database);
|
||||
}
|
||||
},
|
||||
{ stateDir: params.stateDir },
|
||||
{ sharedStoreWrite: true, stateDir: params.stateDir },
|
||||
);
|
||||
rolledBack = true;
|
||||
},
|
||||
@@ -167,6 +167,7 @@ export async function upsertAuthProfileWithLock(params: {
|
||||
const credential = normalizeAuthProfileCredential(params.credential);
|
||||
return await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
sharedStoreWrite: true,
|
||||
stateDir: params.stateDir,
|
||||
saveOptions: {
|
||||
filterExternalAuthProfiles: false,
|
||||
|
||||
@@ -185,6 +185,80 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
|
||||
it("keeps JSON-era ownership through shared writes until Doctor imports the credential", async () => {
|
||||
const state = await makeTestState();
|
||||
const authPath = await writeLegacyAuthProfilesJson(state, {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:json-era": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-json-era",
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyDatabasePath = path.join(state.agentDir(), "openclaw-agent.sqlite");
|
||||
expect(fs.existsSync(legacyDatabasePath)).toBe(false);
|
||||
|
||||
const realExistsSync = fs.existsSync.bind(fs);
|
||||
let legacyJsonProbes = 0;
|
||||
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((pathname) => {
|
||||
if (path.resolve(String(pathname)) === path.resolve(authPath)) {
|
||||
legacyJsonProbes += 1;
|
||||
}
|
||||
return realExistsSync(pathname);
|
||||
});
|
||||
try {
|
||||
for (const key of ["sk-first-write", "sk-second-write"]) {
|
||||
writePersistedAuthProfileStoreRaw({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:written": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(legacyJsonProbes).toBe(1);
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
}
|
||||
|
||||
const beforeDoctor = openOpenClawStateDatabase({ env: state.env });
|
||||
expect(
|
||||
beforeDoctor.db
|
||||
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
|
||||
.get("auth.sharedStore"),
|
||||
).toBeUndefined();
|
||||
expect(fs.existsSync(legacyDatabasePath)).toBe(true);
|
||||
|
||||
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
|
||||
cfg: {},
|
||||
prompter: makePrompter(true),
|
||||
env: state.env,
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toEqual([expect.stringContaining("Migrated auth profile JSON")]);
|
||||
expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toMatchObject({
|
||||
"openai:json-era": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-json-era",
|
||||
},
|
||||
"anthropic:written": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-second-write",
|
||||
},
|
||||
});
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
expectMigratedArchive(authPath);
|
||||
});
|
||||
|
||||
it("migrates the inherited auth owner after it leaves the explicit roster", async () => {
|
||||
const state = await makeTestState();
|
||||
const authPath = await writeLegacyAuthProfilesJson(
|
||||
|
||||
@@ -989,6 +989,13 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
||||
const sharedStateTarget =
|
||||
candidate.agentDir === undefined &&
|
||||
resolveSharedAuthStoreOwnership(env).location === "state-db";
|
||||
// A shared candidate on a legacy root names the main agent dir explicitly: it resolves to the
|
||||
// same database, but an undefined agent dir would enter the shared-write bootstrap and could
|
||||
// record state-db ownership midway through this import. Doctor stays the only owner of that flip.
|
||||
const transactionAgentDir =
|
||||
sharedStateTarget || candidate.agentDir !== undefined
|
||||
? candidate.agentDir
|
||||
: resolveSharedMainAuthAgentDir(env);
|
||||
let sourceReceipts = candidateSourcePaths.filter(fs.existsSync).map((pathname) =>
|
||||
prepareAuthProfileSourceReceipt({
|
||||
pathname,
|
||||
@@ -1160,7 +1167,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
||||
try {
|
||||
assertAuthProfileMigrationSourcesUnchanged(candidate, sourceReceipts);
|
||||
verifiedStore = runAuthProfileWriteTransaction(
|
||||
candidate.agentDir,
|
||||
transactionAgentDir,
|
||||
(database) => {
|
||||
const authoritative = loadAuthProfileMigrationTargetStore(
|
||||
candidate.agentDir,
|
||||
|
||||
@@ -108,7 +108,22 @@ describe("doctor model catalog credential migration", () => {
|
||||
expect(first.migrated).toBe(3);
|
||||
expect(first.warnings).toEqual([]);
|
||||
expect(cfg.models?.providers?.configured?.apiKey).toBe("configured-secret");
|
||||
expect(loadPersistedAuthProfileStore(agentDir)?.profiles).toMatchObject({
|
||||
// A fresh root records state-db shared ownership, so the migrated credentials persist in the
|
||||
// shared store rather than the agent file. Read through the owner for this state root instead of
|
||||
// pinning the storage layout.
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = state.stateDir;
|
||||
let migratedProfiles: Record<string, unknown>;
|
||||
try {
|
||||
migratedProfiles = loadPersistedAuthProfileStore(undefined)?.profiles ?? {};
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
}
|
||||
expect(migratedProfiles).toMatchObject({
|
||||
"configured:default": {
|
||||
type: "api_key",
|
||||
provider: "configured",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readAuthProfilesForAgent,
|
||||
setupAuthTestEnv,
|
||||
} from "../../test/helpers/auth-wizard.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import type { OAuthCredentials } from "../llm/utils/oauth/types.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
@@ -62,6 +63,13 @@ function expectFields(value: unknown, expected: Record<string, unknown>, label =
|
||||
return record;
|
||||
}
|
||||
|
||||
function readEffectiveAuthProfiles(agentDir: string) {
|
||||
return ensureAuthProfileStore(agentDir, {
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("writeOAuthCredentials", () => {
|
||||
const lifecycle = createAuthTestLifecycle([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
@@ -125,19 +133,23 @@ describe("writeOAuthCredentials", () => {
|
||||
});
|
||||
|
||||
for (const dir of [mainAgentDir, kidAgentDir]) {
|
||||
const persistedStore = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||
}>(dir);
|
||||
expectFields(persistedStore.profiles?.["openai:default"], {
|
||||
const effectiveStore = readEffectiveAuthProfiles(dir);
|
||||
expectFields(effectiveStore.profiles?.["openai:default"], {
|
||||
refresh: "refresh-sync",
|
||||
access: "access-sync",
|
||||
type: "oauth",
|
||||
});
|
||||
}
|
||||
const inheritedSiblingStore = await readAuthProfilesForAgent<{
|
||||
const inheritedSiblingStore = readEffectiveAuthProfiles(workerAgentDir);
|
||||
expectFields(inheritedSiblingStore.profiles?.["openai:default"], {
|
||||
refresh: "refresh-sync",
|
||||
access: "access-sync",
|
||||
type: "oauth",
|
||||
});
|
||||
const persistedSiblingStore = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||
}>(workerAgentDir);
|
||||
expect(inheritedSiblingStore.profiles).toEqual({});
|
||||
expect(persistedSiblingStore.profiles).toEqual({});
|
||||
});
|
||||
|
||||
it("writes OAuth credentials only to target dir by default", async () => {
|
||||
@@ -160,9 +172,7 @@ describe("writeOAuthCredentials", () => {
|
||||
|
||||
await writeOAuthCredentials("openai", creds, kidAgentDir);
|
||||
|
||||
const kidParsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||
}>(kidAgentDir);
|
||||
const kidParsed = readEffectiveAuthProfiles(kidAgentDir);
|
||||
expectFields(kidParsed.profiles?.["openai:default"], {
|
||||
access: "access-kid",
|
||||
type: "oauth",
|
||||
@@ -239,16 +249,20 @@ describe("upsertApiKeyProfile secret refs", () => {
|
||||
agentDir: string,
|
||||
profileId: string,
|
||||
): Promise<AuthProfileEntry | undefined> {
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, AuthProfileEntry>;
|
||||
}>(agentDir);
|
||||
return parsed.profiles?.[profileId];
|
||||
const parsed = readEffectiveAuthProfiles(agentDir);
|
||||
const profile = parsed.profiles[profileId];
|
||||
if (!profile || profile.type !== "api_key") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(profile.key !== undefined ? { key: profile.key } : {}),
|
||||
...(profile.keyRef !== undefined ? { keyRef: profile.keyRef } : {}),
|
||||
...(profile.metadata !== undefined ? { metadata: profile.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readProfileIds(agentDir: string): Promise<string[]> {
|
||||
const parsed = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, AuthProfileEntry>;
|
||||
}>(agentDir);
|
||||
const parsed = readEffectiveAuthProfiles(agentDir);
|
||||
return Object.keys(parsed.profiles ?? {}).toSorted();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
SHARED_AUTH_STORE_STATE_KEY,
|
||||
} from "../agents/auth-profiles/path-resolve.js";
|
||||
import { resolveSharedMainAuthAgentDir } from "../agents/auth-profiles/shared-main-dir.js";
|
||||
import {
|
||||
hasPendingSharedAuthCleanup,
|
||||
inspectSharedAuthLegacyRowsReadOnly,
|
||||
inspectSharedAuthLegacySourceFile,
|
||||
readSharedAuthLegacyRowsFromDatabase,
|
||||
SharedAuthStoreSourceInspectionError,
|
||||
type SharedAuthLegacyRows as AuthRows,
|
||||
type SharedAuthLegacyStateRow as StateRow,
|
||||
type SharedAuthLegacyStoreRow as StoreRow,
|
||||
} from "../agents/auth-profiles/shared-store-bootstrap.js";
|
||||
import {
|
||||
closeAuthProfileReadPool,
|
||||
resolveAuthProfileDatabaseOwnerId,
|
||||
@@ -18,8 +28,6 @@ import {
|
||||
closeOpenClawAgentDatabaseByPath,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
|
||||
import { tableExists as sqliteTableExists } from "../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
@@ -27,7 +35,6 @@ import {
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "./kysely-sync.js";
|
||||
import { openNodeSqliteDatabase } from "./node-sqlite.js";
|
||||
import { withLegacyMigrationStateLock } from "./state-migrations.lock.js";
|
||||
import type { SharedAuthStoreMigrationDetection } from "./state-migrations.shared-auth-store.types.js";
|
||||
import type { MigrationMessages } from "./state-migrations.types.js";
|
||||
@@ -50,24 +57,8 @@ type SharedAuthMigrationDatabase = Pick<
|
||||
| "migration_sources"
|
||||
>;
|
||||
|
||||
type StoreRow = { store_json: string; updated_at: number };
|
||||
type StateRow = { state_json: string; updated_at: number };
|
||||
type AuthRows = { store: StoreRow | null; state: StateRow | null };
|
||||
type MigrationStage = "copied" | "ownership-flipped" | "completed";
|
||||
|
||||
class SharedAuthStoreSourceInspectionError extends Error {
|
||||
readonly code = "SHARED_AUTH_STORE_SOURCE_UNREADABLE" as const;
|
||||
readonly action = "openclaw doctor --fix" as const;
|
||||
readonly sourcePath: string;
|
||||
|
||||
constructor(sourcePath: string, operation: string, cause: unknown) {
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Cannot ${operation} legacy shared auth database ${sourcePath}: ${detail}`, { cause });
|
||||
this.name = "SharedAuthStoreSourceInspectionError";
|
||||
this.sourcePath = sourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceMigrationKey(sourcePath: string, sourceTable: string): string {
|
||||
return `shared-auth-store:${createHash("sha256")
|
||||
.update(path.resolve(sourcePath))
|
||||
@@ -76,90 +67,17 @@ function sourceMigrationKey(sourcePath: string, sourceTable: string): string {
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function inspectSourceFile(
|
||||
sourcePath: string,
|
||||
): { status: "missing" } | { status: "present"; size: number } {
|
||||
let entry: fs.Stats;
|
||||
try {
|
||||
entry = fs.lstatSync(sourcePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "inspect", error);
|
||||
}
|
||||
let target = entry;
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
target = fs.statSync(sourcePath);
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "resolve", error);
|
||||
}
|
||||
}
|
||||
if (!target.isFile()) {
|
||||
throw new SharedAuthStoreSourceInspectionError(
|
||||
sourcePath,
|
||||
"open",
|
||||
new Error("path is not a regular file"),
|
||||
);
|
||||
}
|
||||
return { status: "present", size: target.size };
|
||||
}
|
||||
|
||||
function readSourceRowsFromDatabase(database: DatabaseSync): AuthRows {
|
||||
const db = getNodeSqliteKysely<SourceAuthDatabase>(database);
|
||||
const store = sqliteTableExists(database, "auth_profile_store")
|
||||
? (executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("auth_profile_store")
|
||||
.select(["store_json", "updated_at"])
|
||||
.where("store_key", "=", SOURCE_STORE_KEY),
|
||||
) ?? null)
|
||||
: null;
|
||||
const state = sqliteTableExists(database, "auth_profile_state")
|
||||
? (executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("auth_profile_state")
|
||||
.select(["state_json", "updated_at"])
|
||||
.where("state_key", "=", SOURCE_STORE_KEY),
|
||||
) ?? null)
|
||||
: null;
|
||||
return { store, state };
|
||||
}
|
||||
|
||||
function inspectSourceRowsReadOnly(sourcePath: string): AuthRows {
|
||||
const source = inspectSourceFile(sourcePath);
|
||||
if (source.status === "missing") {
|
||||
return { store: null, state: null };
|
||||
}
|
||||
let database: DatabaseSync;
|
||||
try {
|
||||
database = openNodeSqliteDatabase(sourcePath, { readOnly: true });
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "open", error);
|
||||
}
|
||||
try {
|
||||
return readSourceRowsFromDatabase(database);
|
||||
} catch (error) {
|
||||
throw new SharedAuthStoreSourceInspectionError(sourcePath, "read", error);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readSourceSnapshot(params: { env: NodeJS.ProcessEnv; sourcePath: string }): {
|
||||
rows: AuthRows;
|
||||
size: number | null;
|
||||
} {
|
||||
const source = inspectSourceFile(params.sourcePath);
|
||||
const source = inspectSharedAuthLegacySourceFile(params.sourcePath);
|
||||
if (source.status === "missing") {
|
||||
return { rows: { store: null, state: null }, size: null };
|
||||
}
|
||||
try {
|
||||
const rows = runOpenClawAgentWriteTransaction(
|
||||
({ db }) => readSourceRowsFromDatabase(db),
|
||||
({ db }) => readSharedAuthLegacyRowsFromDatabase(db),
|
||||
{
|
||||
agentId: resolveAuthProfileDatabaseOwnerId(path.dirname(params.sourcePath)),
|
||||
path: params.sourcePath,
|
||||
@@ -467,14 +385,14 @@ function flipOwnership(params: {
|
||||
}
|
||||
|
||||
function cleanupSourceRows(params: { env: NodeJS.ProcessEnv; sourcePath: string }): boolean {
|
||||
if (inspectSourceFile(params.sourcePath).status === "missing") {
|
||||
if (inspectSharedAuthLegacySourceFile(params.sourcePath).status === "missing") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const removed = runOpenClawAgentWriteTransaction(
|
||||
({ db: database }) => {
|
||||
const db = getNodeSqliteKysely<SourceAuthDatabase>(database);
|
||||
const before = readSourceRowsFromDatabase(database);
|
||||
const before = readSharedAuthLegacyRowsFromDatabase(database);
|
||||
executeSqliteQuerySync(
|
||||
database,
|
||||
db.deleteFrom("auth_profile_store").where("store_key", "=", SOURCE_STORE_KEY),
|
||||
@@ -483,7 +401,7 @@ function cleanupSourceRows(params: { env: NodeJS.ProcessEnv; sourcePath: string
|
||||
database,
|
||||
db.deleteFrom("auth_profile_state").where("state_key", "=", SOURCE_STORE_KEY),
|
||||
);
|
||||
const after = readSourceRowsFromDatabase(database);
|
||||
const after = readSharedAuthLegacyRowsFromDatabase(database);
|
||||
if (after.store || after.state) {
|
||||
throw new Error("legacy shared auth rows remain after cleanup");
|
||||
}
|
||||
@@ -521,28 +439,6 @@ function finalizeMigration(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function hasPendingCleanup(env: NodeJS.ProcessEnv, sourcePath: string): boolean {
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(
|
||||
({ db: database }) => {
|
||||
const db = getNodeSqliteKysely<SharedAuthMigrationDatabase>(database);
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
database,
|
||||
db
|
||||
.selectFrom("migration_sources")
|
||||
.select("source_key")
|
||||
.where("migration_kind", "=", MIGRATION_KIND)
|
||||
.where("source_path", "=", sourcePath)
|
||||
.where("removed_source", "=", 0)
|
||||
.limit(1),
|
||||
);
|
||||
return Boolean(row);
|
||||
},
|
||||
{ env },
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Detect relocation or unfinished cleanup only in the explicit Doctor repair path. */
|
||||
export function detectSharedAuthStoreMigration(params: {
|
||||
stateDir: string;
|
||||
@@ -554,14 +450,14 @@ export function detectSharedAuthStoreMigration(params: {
|
||||
return { sourcePath, hasLegacy: false };
|
||||
}
|
||||
const ownership = resolveSharedAuthStoreOwnership(env);
|
||||
const sourceRows = inspectSourceRowsReadOnly(sourcePath);
|
||||
const sourceRows = inspectSharedAuthLegacyRowsReadOnly(sourcePath);
|
||||
return {
|
||||
sourcePath,
|
||||
hasLegacy:
|
||||
ownership.location === "legacy-main" ||
|
||||
sourceRows.store !== null ||
|
||||
sourceRows.state !== null ||
|
||||
hasPendingCleanup(env, sourcePath),
|
||||
hasPendingSharedAuthCleanup(env, sourcePath),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user