feat:upgrade OpenClaw to 2026.6.5 and fix OAuth provider detection for auth-profile-only configs. (#1109)

This commit is contained in:
paisley
2026-06-10 17:52:05 +08:00
committed by GitHub
parent 07c5c4cfcc
commit bf038f5ca4
11 changed files with 957 additions and 993 deletions
@@ -7,6 +7,7 @@ import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider
import {
ensureAnthropicMessagesModelMaxTokens,
ensureOpenClawProviderAgentRuntimePins,
migrateAllAgentAuthProfilesToSqlite,
pruneInvalidApiProviderEntries,
removeProviderFromOpenClaw,
removeProviderKeyFromOpenClaw,
@@ -210,6 +211,7 @@ export async function syncProviderApiKeyToRuntime(
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
await migrateAllAgentAuthProfilesToSqlite();
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
+297
View File
@@ -0,0 +1,297 @@
/**
* OpenClaw 2026.6+ persists agent auth in openclaw-agent.sqlite.
* ClawX historically wrote auth-profiles.json only; gateway runtime reads SQLite.
*/
import { chmodSync, existsSync, mkdirSync } from 'fs';
import { access, readFile } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { DatabaseSync } from 'node:sqlite';
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
const AUTH_SQLITE_FILENAME = 'openclaw-agent.sqlite';
const PRIMARY_ROW_KEY = 'primary';
const SCHEMA_VERSION = 1;
const OPENCLAW_AGENT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS schema_meta (
meta_key TEXT NOT NULL PRIMARY KEY,
role TEXT NOT NULL,
schema_version INTEGER NOT NULL,
agent_id TEXT,
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cache_entries (
scope TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT,
blob BLOB,
expires_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
CREATE INDEX IF NOT EXISTS idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agent_cache_updated
ON cache_entries(scope, updated_at DESC, key);
CREATE TABLE IF NOT EXISTS auth_profile_store (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_profile_state (
state_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`;
export interface PersistedAuthProfileCredential {
type: string;
provider: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
email?: string;
projectId?: string;
[extra: string]: unknown;
}
export interface PersistedAuthProfilesStore {
version: number;
profiles: Record<string, PersistedAuthProfileCredential>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, unknown>;
}
function getAgentAuthDir(agentId: string): string {
return join(homedir(), '.openclaw', 'agents', agentId, 'agent');
}
export function getAuthProfilesJsonPath(agentId: string): string {
return join(getAgentAuthDir(agentId), AUTH_PROFILE_FILENAME);
}
export function getAuthProfilesSqlitePath(agentId: string): string {
return join(getAgentAuthDir(agentId), AUTH_SQLITE_FILENAME);
}
function ensureAgentAuthDir(agentId: string): void {
const dir = getAgentAuthDir(agentId);
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
function ensureDatabaseSchema(db: DatabaseSync, agentId: string): void {
db.exec(OPENCLAW_AGENT_SCHEMA_SQL);
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
const now = Date.now();
db.prepare(`
INSERT INTO schema_meta (
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
) VALUES (?, 'agent', ?, ?, NULL, ?, ?)
ON CONFLICT(meta_key) DO UPDATE SET
role = excluded.role,
schema_version = excluded.schema_version,
agent_id = excluded.agent_id,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, SCHEMA_VERSION, agentId, now, now);
}
function tightenDatabasePermissions(sqlitePath: string): void {
try {
if (process.platform !== 'win32') {
chmodSync(sqlitePath, 0o600);
for (const suffix of ['-wal', '-shm']) {
const sidecar = `${sqlitePath}${suffix}`;
if (existsSync(sidecar)) {
chmodSync(sidecar, 0o600);
}
}
}
} catch {
// Best-effort; Windows ACLs differ from POSIX modes.
}
}
function parseJsonCell(raw: string | null | undefined): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function coerceAuthProfilesStore(raw: Record<string, unknown> | null): PersistedAuthProfilesStore | null {
if (!raw || typeof raw !== 'object') return null;
const profiles = raw.profiles;
if (!profiles || typeof profiles !== 'object') return null;
const version = typeof raw.version === 'number' ? raw.version : 1;
const store: PersistedAuthProfilesStore = {
version,
profiles: profiles as Record<string, PersistedAuthProfileCredential>,
};
if (raw.order && typeof raw.order === 'object') {
store.order = raw.order as Record<string, string[]>;
}
if (raw.lastGood && typeof raw.lastGood === 'object') {
store.lastGood = raw.lastGood as Record<string, string>;
}
if (raw.usageStats && typeof raw.usageStats === 'object') {
store.usageStats = raw.usageStats as Record<string, unknown>;
}
return store;
}
function buildSecretsPayload(store: PersistedAuthProfilesStore): Record<string, unknown> {
return {
version: store.version ?? 1,
profiles: store.profiles,
};
}
function buildStatePayload(store: PersistedAuthProfilesStore): Record<string, unknown> | null {
if (!store.order && !store.lastGood && !store.usageStats) {
return null;
}
return {
version: 1,
...(store.order ? { order: store.order } : {}),
...(store.lastGood ? { lastGood: store.lastGood } : {}),
...(store.usageStats ? { usageStats: store.usageStats } : {}),
};
}
function mergeStoreAndState(
secrets: Record<string, unknown> | null,
state: Record<string, unknown> | null,
): PersistedAuthProfilesStore | null {
const base = coerceAuthProfilesStore(secrets);
if (!base) return null;
if (!state) return base;
if (state.order && typeof state.order === 'object') {
base.order = state.order as Record<string, string[]>;
}
if (state.lastGood && typeof state.lastGood === 'object') {
base.lastGood = state.lastGood as Record<string, string>;
}
if (state.usageStats && typeof state.usageStats === 'object') {
base.usageStats = state.usageStats as Record<string, unknown>;
}
return base;
}
function hasPersistedProfiles(store: PersistedAuthProfilesStore | null | undefined): boolean {
return !!store && Object.keys(store.profiles).length > 0;
}
function openAgentDatabase(agentId: string, sqlitePath: string): DatabaseSync {
ensureAgentAuthDir(agentId);
const db = new DatabaseSync(sqlitePath);
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA busy_timeout = 5000;');
db.exec('PRAGMA foreign_keys = ON;');
ensureDatabaseSchema(db, agentId);
return db;
}
export function readAuthProfilesFromSqlite(agentId: string): PersistedAuthProfilesStore | null {
const sqlitePath = getAuthProfilesSqlitePath(agentId);
if (!existsSync(sqlitePath)) {
return null;
}
const db = new DatabaseSync(sqlitePath, { readOnly: true });
try {
const storeRow = db.prepare(
'SELECT store_json FROM auth_profile_store WHERE store_key = ?',
).get(PRIMARY_ROW_KEY) as { store_json?: string } | undefined;
const stateRow = db.prepare(
'SELECT state_json FROM auth_profile_state WHERE state_key = ?',
).get(PRIMARY_ROW_KEY) as { state_json?: string } | undefined;
return mergeStoreAndState(
parseJsonCell(storeRow?.store_json),
parseJsonCell(stateRow?.state_json),
);
} catch (error) {
console.warn(`Failed to read auth profiles from SQLite (${sqlitePath}):`, error);
return null;
} finally {
db.close();
}
}
export function writeAuthProfilesToSqlite(
store: PersistedAuthProfilesStore,
agentId: string,
): void {
const sqlitePath = getAuthProfilesSqlitePath(agentId);
const db = openAgentDatabase(agentId, sqlitePath);
try {
const now = Date.now();
const secretsPayload = JSON.stringify(buildSecretsPayload(store));
db.prepare(`
INSERT INTO auth_profile_store (store_key, store_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(store_key) DO UPDATE SET
store_json = excluded.store_json,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, secretsPayload, now);
const statePayload = buildStatePayload(store);
if (statePayload) {
db.prepare(`
INSERT INTO auth_profile_state (state_key, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, JSON.stringify(statePayload), now);
} else {
db.prepare('DELETE FROM auth_profile_state WHERE state_key = ?').run(PRIMARY_ROW_KEY);
}
} finally {
db.close();
tightenDatabasePermissions(sqlitePath);
}
}
export async function readAuthProfilesJson(agentId: string): Promise<PersistedAuthProfilesStore | null> {
const jsonPath = getAuthProfilesJsonPath(agentId);
try {
await access(jsonPath, constants.F_OK);
const raw = JSON.parse(await readFile(jsonPath, 'utf-8')) as Record<string, unknown>;
return coerceAuthProfilesStore(raw);
} catch {
return null;
}
}
export async function migrateAuthProfilesJsonToSqliteIfNeeded(agentId: string): Promise<boolean> {
const sqliteStore = readAuthProfilesFromSqlite(agentId);
if (hasPersistedProfiles(sqliteStore)) {
return false;
}
const jsonStore = await readAuthProfilesJson(agentId);
if (!hasPersistedProfiles(jsonStore)) {
return false;
}
writeAuthProfilesToSqlite(jsonStore!, agentId);
console.log(
`[auth-sync] Migrated auth-profiles.json to SQLite for agent "${agentId}"`,
);
return true;
}
+68 -23
View File
@@ -1,7 +1,8 @@
/**
* OpenClaw Auth Profiles Utility
* Writes API keys to configured OpenClaw agent auth-profiles.json files
* so the OpenClaw Gateway can load them for AI provider calls.
* Writes API keys to OpenClaw agent auth storage (SQLite primary since 2026.6+,
* with auth-profiles.json kept for migration compatibility) so the Gateway can
* load them for AI provider calls.
*
* All file I/O is asynchronous (fs/promises) to avoid blocking the
* Electron main thread. On Windows + NTFS + Defender the synchronous
@@ -39,6 +40,13 @@ import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from './openclaw-image-relay-constants';
import {
migrateAuthProfilesJsonToSqliteIfNeeded,
readAuthProfilesFromSqlite,
readAuthProfilesJson,
writeAuthProfilesToSqlite,
type PersistedAuthProfilesStore,
} from './openclaw-auth-sqlite';
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
@@ -324,12 +332,7 @@ interface OAuthProfileEntry {
projectId?: string;
}
interface AuthProfilesStore {
version: number;
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
}
type AuthProfilesStore = PersistedAuthProfilesStore;
function removeProfilesForProvider(store: AuthProfilesStore, provider: string): boolean {
const removedProfileIds = new Set<string>();
@@ -414,22 +417,42 @@ function getAuthProfilesPath(agentId = 'main'): string {
}
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
const filePath = getAuthProfilesPath(agentId);
try {
const data = await readJsonFile<AuthProfilesStore>(filePath);
if (data?.version && data.profiles && typeof data.profiles === 'object') {
return data;
}
} catch (error) {
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
const sqliteStore = readAuthProfilesFromSqlite(agentId);
if (sqliteStore?.profiles && Object.keys(sqliteStore.profiles).length > 0) {
return sqliteStore;
}
const jsonStore = await readAuthProfilesJson(agentId);
if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) {
try {
writeAuthProfilesToSqlite(jsonStore, agentId);
console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`);
} catch (error) {
console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error);
}
return jsonStore;
}
return { version: AUTH_STORE_VERSION, profiles: {} };
}
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
writeAuthProfilesToSqlite(store, agentId);
await writeJsonFile(getAuthProfilesPath(agentId), store);
}
/** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */
export async function migrateAllAgentAuthProfilesToSqlite(): Promise<void> {
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
try {
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
} catch (error) {
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
}
}
}
function getApiKeyFromAuthProfilesStore(
store: AuthProfilesStore,
provider: string,
@@ -622,6 +645,7 @@ function normalizeAuthProfileProviderKey(provider: string): string {
function addProvidersFromProfileEntries(
profiles: Record<string, unknown> | undefined,
target: Set<string>,
options?: { includeRawKeys?: boolean },
): void {
if (!profiles || typeof profiles !== 'object') {
return;
@@ -632,17 +656,28 @@ function addProvidersFromProfileEntries(
? ((profile as Record<string, unknown>).provider as string)
: undefined;
if (!provider) continue;
target.add(normalizeAuthProfileProviderKey(provider));
const normalized = normalizeAuthProfileProviderKey(provider);
target.add(normalized);
// The raw runtime key (e.g. "openai-codex") matters for active-provider
// checks: filterActiveProviderKeysForUi() and the OAuth account matching
// in ProviderService.listAccounts() both key off it. Newer OpenClaw
// versions no longer keep explicit models.providers/plugins entries for
// these providers, so the auth profile is the only remaining signal.
if (options?.includeRawKeys && provider !== normalized) {
target.add(provider);
}
}
}
async function getProvidersFromAuthProfileStores(): Promise<Set<string>> {
async function getProvidersFromAuthProfileStores(
options?: { includeRawKeys?: boolean },
): Promise<Set<string>> {
const providers = new Set<string>();
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
const store = await readAuthProfiles(agentId);
addProvidersFromProfileEntries(store.profiles, providers);
addProvidersFromProfileEntries(store.profiles, providers, options);
}
return providers;
@@ -675,9 +710,13 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
}
const auth = config.auth as Record<string, unknown> | undefined;
addProvidersFromProfileEntries(auth?.profiles as Record<string, unknown> | undefined, activeProviders);
addProvidersFromProfileEntries(
auth?.profiles as Record<string, unknown> | undefined,
activeProviders,
{ includeRawKeys: true },
);
const authProfileProviders = await getProvidersFromAuthProfileStores();
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
@@ -1952,10 +1991,16 @@ export async function getActiveOpenClawProviders(): Promise<Set<string>> {
// 4. auth.profiles — OAuth/device-token based providers may exist only in
// auth-profiles without explicit models.providers entries yet.
// Raw keys (e.g. "openai-codex") are included so downstream logic can
// distinguish OAuth runtime providers from their UI alias ("openai").
const auth = config.auth as Record<string, unknown> | undefined;
addProvidersFromProfileEntries(auth?.profiles as Record<string, unknown> | undefined, activeProviders);
addProvidersFromProfileEntries(
auth?.profiles as Record<string, unknown> | undefined,
activeProviders,
{ includeRawKeys: true },
);
const authProfileProviders = await getProvidersFromAuthProfileStores();
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
@@ -0,0 +1,34 @@
---
id: fix-first-chat-no-response-fallback-poll
title: Restore fallback transcript polling so missing streamed events do not fail the first chat
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Prevent the false "The model did not respond within 120 seconds" / "No response received from the model" errors on the first chat after Gateway startup when streamed chat/runtime events never reach the renderer, by polling chat.history as a fallback progress source during active sends.
touchedAreas:
- harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
- src/stores/chat.ts
- tests/unit/chat-store-history-retry.test.ts
expectedUserBehavior:
- When a send receives no streamed chat/runtime events (e.g. first run after Gateway startup or a silent WS drop), the renderer polls chat.history and surfaces transcript progress instead of firing the 120s/130s no-response safety errors.
- When the transcript shows a conclusive assistant reply, the run closes normally (sending cleared, reply rendered) without any error banner.
- While streamed events are fresh, the fallback poll issues no extra chat.history RPCs, so healthy streamed runs are unaffected.
- Renderer continues to use the existing gateway rpc Main-process boundary for chat.history polling.
requiredProfiles:
- fast
- comms
requiredRules:
- gateway-readiness-policy
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
requiredTests:
- pnpm exec vitest run tests/unit/chat-store-history-retry.test.ts
- pnpm run typecheck
acceptance:
- The active sendMessage path arms a fallback transcript poll that only issues chat.history RPCs after sustained streamed-event silence.
- Streamed chat events no longer permanently clear the fallback poll timer; the poll self-throttles via event freshness instead.
- A run whose transcript contains a final assistant reply closes without emitting the no-response safety errors even when zero streamed events arrive.
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client invocation path.
docs:
required: false
---
+4 -4
View File
@@ -102,9 +102,9 @@
"@larksuite/openclaw-lark": "2026.5.20",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@monaco-editor/react": "^4.7.0",
"@openclaw/discord": "2026.5.20",
"@openclaw/qqbot": "2026.5.20",
"@openclaw/whatsapp": "2026.5.20",
"@openclaw/discord": "2026.6.5",
"@openclaw/qqbot": "2026.6.5",
"@openclaw/whatsapp": "2026.6.5",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -158,7 +158,7 @@
"monaco-editor": "^0.55.1",
"mpg123-decoder": "^1.0.3",
"ms": "^2.1.3",
"openclaw": "2026.5.20",
"openclaw": "2026.6.5",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",
"playwright-core": "1.59.1",
+256 -960
View File
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -135,6 +135,12 @@ const ERROR_RECOVERY_DELAY_MS = 12_000;
const LLM_IDLE_HINT_MS = 120_000;
/** Wait past one LLM idle window before declaring a hard no-response failure. */
const NO_RESPONSE_SAFETY_TIMEOUT_MS = 130_000;
/** Delay before the first fallback transcript poll after a send. */
const HISTORY_POLL_START_DELAY_MS = 3_000;
/** Interval between fallback transcript poll ticks during an active send. */
const HISTORY_POLL_INTERVAL_MS = 5_000;
/** Only issue the fallback poll RPC after this much streamed-event silence. */
const HISTORY_POLL_EVENT_SILENCE_MS = 10_000;
type PendingOptimisticUserMessage = {
message: RawMessage;
@@ -3659,6 +3665,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
clearHistoryPoll();
clearErrorRecoveryTimer();
// Fallback transcript poll: streamed runtime events are the primary
// active-run path, but when they go missing entirely (first run right
// after gateway startup, silent WS drops, event-normalization gaps) the
// safety timeout above would fire a false "No response received" error
// even though the gateway is making progress. Polling chat.history keeps
// progress detection honest in that case. The RPC is skipped while
// streamed events are fresh, so healthy runs issue no extra requests.
const pollHistoryFallback = () => {
_historyPollTimer = null;
const state = get();
if (!state.sending || state.currentSessionKey !== currentSessionKey) return;
if (Date.now() - _lastChatEventAt >= HISTORY_POLL_EVENT_SILENCE_MS) {
void state.loadHistory(true);
}
_historyPollTimer = setTimeout(pollHistoryFallback, HISTORY_POLL_INTERVAL_MS);
};
_historyPollTimer = setTimeout(pollHistoryFallback, HISTORY_POLL_START_DELAY_MS);
const checkStuck = () => {
const state = get();
if (!state.sending) return;
@@ -3912,14 +3936,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
}
// Only pause the history poll when we receive actual streaming data.
// The gateway sends "agent" events with { phase, startedAt } that carry
// no message — these must NOT kill the poll, since the poll is our only
// way to track progress when the gateway doesn't stream intermediate turns.
// Streaming data pauses the fallback transcript poll implicitly: each
// event refreshes _lastChatEventAt, so the poll skips its RPC while the
// stream is healthy. Do NOT clear the poll timer here — it must stay
// armed to recover progress tracking if the stream stalls mid-run.
const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final'
|| resolvedState === 'error' || resolvedState === 'aborted';
if (hasUsefulData) {
clearHistoryPoll();
// Adopt run started from another client only for user-initiated turns.
// Background :main heartbeat runs must not surface "Thinking..." in the UI.
const { sending } = get();
+133
View File
@@ -1529,6 +1529,139 @@ describe('useChatStore startup history retry', () => {
await sendPromise;
});
// Regression for the "first chat after gateway start" bug: the gateway
// accepted chat.send but no streamed chat/runtime events ever reached the
// renderer. Without the fallback transcript poll the safety timers fired
// "The model did not respond within 120 seconds" and then "No response
// received from the model" even though the transcript already contained
// the assistant reply.
it('recovers via the fallback transcript poll when no streamed events arrive', async () => {
let chatHistoryCalls = 0;
let transcript: Array<Record<string, unknown>> = [];
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'config.get') return {};
if (method === 'chat.send') {
// Seed the transcript as the gateway would, but never emit events.
const nowSec = Date.now() / 1000;
transcript = [
{ id: 'user-first', role: 'user', content: '明天呢', timestamp: nowSec },
{
id: 'assistant-first',
role: 'assistant',
content: [{ type: 'text', text: '明天晴。' }],
stopReason: 'endTurn',
timestamp: nowSec + 1,
},
];
return { runId: 'run-first-chat' };
}
if (method === 'chat.history') {
chatHistoryCalls += 1;
return { messages: transcript };
}
return { messages: [] };
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:session-first-chat',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:session-first-chat' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
runError: null,
loading: false,
thinkingLevel: null,
});
await useChatStore.getState().sendMessage('明天呢');
// While streamed events are still considered fresh the poll stays silent.
await vi.advanceTimersByTimeAsync(8_000);
expect(chatHistoryCalls).toBe(0);
// After enough event silence the fallback poll reads the transcript,
// detects the finished reply, and closes the run without errors.
await vi.advanceTimersByTimeAsync(10_000);
expect(chatHistoryCalls).toBeGreaterThan(0);
await vi.waitFor(() => {
expect(useChatStore.getState().sending).toBe(false);
});
expect(useChatStore.getState().messages.map((message) => message.id)).toEqual([
'user-first',
'assistant-first',
]);
// The 120s idle hint and the 130s hard failure must never fire.
await vi.advanceTimersByTimeAsync(140_000);
expect(useChatStore.getState().error).toBeNull();
expect(useChatStore.getState().runError).toBeNull();
});
it('keeps the fallback poll silent while streamed events are fresh', async () => {
let chatHistoryCalls = 0;
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'config.get') return {};
if (method === 'chat.send') {
return { runId: 'run-streamed' };
}
if (method === 'chat.history') {
chatHistoryCalls += 1;
return { messages: [] };
}
return { messages: [] };
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:session-streamed',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:session-streamed' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
runError: null,
loading: false,
thinkingLevel: null,
});
await useChatStore.getState().sendMessage('streamed run');
// Streamed deltas keep refreshing the event timestamp; the fallback poll
// must not issue any chat.history RPCs while the stream is healthy.
for (let i = 0; i < 6; i += 1) {
useChatStore.getState().handleChatEvent({
state: 'delta',
runId: 'run-streamed',
sessionKey: 'agent:main:session-streamed',
message: { role: 'assistant', content: [{ type: 'text', text: `chunk ${i}` }] },
});
await vi.advanceTimersByTimeAsync(5_000);
}
expect(chatHistoryCalls).toBe(0);
expect(useChatStore.getState().sending).toBe(true);
});
it('does not treat prior-turn assistant history as progress for a new send', async () => {
let resolveSend: ((value: { runId: string }) => void) | undefined;
gatewayRpcMock.mockImplementation((method: string) => {
+92
View File
@@ -0,0 +1,92 @@
import { existsSync } from 'fs';
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { testHome } = vi.hoisted(() => ({
testHome: `/tmp/clawx-auth-sqlite-${Math.random().toString(36).slice(2)}`,
}));
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os');
const mocked = {
...actual,
homedir: () => testHome,
};
return {
...mocked,
default: mocked,
};
});
async function writeJsonStore(agentId: string, store: Record<string, unknown>): Promise<void> {
const dir = join(testHome, '.openclaw', 'agents', agentId, 'agent');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'auth-profiles.json'), JSON.stringify(store, null, 2), 'utf8');
}
describe('openclaw-auth-sqlite', () => {
beforeEach(async () => {
vi.resetModules();
await rm(testHome, { recursive: true, force: true });
});
it('migrates auth-profiles.json into openclaw-agent.sqlite when sqlite is empty', async () => {
await writeJsonStore('main', {
version: 1,
profiles: {
'custom-customc7:default': {
type: 'api_key',
provider: 'custom-customc7',
key: 'sk-test-key',
},
},
order: { 'custom-customc7': ['custom-customc7:default'] },
lastGood: { 'custom-customc7': 'custom-customc7:default' },
});
const {
migrateAuthProfilesJsonToSqliteIfNeeded,
readAuthProfilesFromSqlite,
getAuthProfilesSqlitePath,
} = await import('@electron/utils/openclaw-auth-sqlite');
const migrated = await migrateAuthProfilesJsonToSqliteIfNeeded('main');
expect(migrated).toBe(true);
expect(existsSync(getAuthProfilesSqlitePath('main'))).toBe(true);
const sqliteStore = readAuthProfilesFromSqlite('main');
expect(sqliteStore?.profiles['custom-customc7:default']).toMatchObject({
type: 'api_key',
provider: 'custom-customc7',
key: 'sk-test-key',
});
expect(sqliteStore?.order?.['custom-customc7']).toEqual(['custom-customc7:default']);
expect(sqliteStore?.lastGood?.['custom-customc7']).toBe('custom-customc7:default');
});
it('saveProviderKeyToOpenClaw writes credentials readable from sqlite', async () => {
const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth');
const {
readAuthProfilesFromSqlite,
getAuthProfilesSqlitePath,
} = await import('@electron/utils/openclaw-auth-sqlite');
await saveProviderKeyToOpenClaw('custom-customc7', 'sk-runtime-key', 'main');
expect(existsSync(getAuthProfilesSqlitePath('main'))).toBe(true);
const sqliteStore = readAuthProfilesFromSqlite('main');
expect(sqliteStore?.profiles['custom-customc7:default']).toMatchObject({
type: 'api_key',
provider: 'custom-customc7',
key: 'sk-runtime-key',
});
const json = JSON.parse(
await readFile(join(testHome, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json'), 'utf8'),
) as Record<string, unknown>;
expect((json.profiles as Record<string, unknown>)['custom-customc7:default']).toMatchObject({
key: 'sk-runtime-key',
});
});
});
+5 -1
View File
@@ -1219,8 +1219,12 @@ describe('auth-backed provider discovery', () => {
const { getActiveOpenClawProviders } = await import('@electron/utils/openclaw-auth');
// Raw runtime keys (openai-codex / google-gemini-cli) are kept alongside
// their normalized UI aliases: newer OpenClaw versions no longer write
// explicit models.providers / plugins entries for OAuth CLI providers, so
// the auth profile is the only signal that the runtime provider is active.
await expect(getActiveOpenClawProviders()).resolves.toEqual(
new Set(['openai', 'anthropic', 'google']),
new Set(['openai', 'openai-codex', 'anthropic', 'google', 'google-gemini-cli']),
);
});
@@ -217,6 +217,44 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('openai');
});
it('hides the bare openai slot when openai-codex is active only via auth profile (no openclaw.json entries)', async () => {
// Regression: newer OpenClaw versions drop the explicit models.providers
// "openai-codex" entry and the "openai-codex-auth" plugin entry, leaving
// the OAuth auth profile as the only active signal. The bare "openai"
// slot must still be hidden and the stale seeded api_key account removed.
mocks.listProviderAccounts.mockResolvedValue([
makeAccount({
id: 'openai-oauth-1',
vendorId: 'openai' as ProviderAccount['vendorId'],
authMode: 'oauth_browser',
label: 'OpenAI Codex',
}),
makeAccount({
id: 'openai',
vendorId: 'openai' as ProviderAccount['vendorId'],
authMode: 'api_key',
label: 'OpenAI',
}),
]);
mocks.getApiKey.mockResolvedValue(null);
mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null);
// Active set as produced by getActiveOpenClawProviders() when only the
// openai-codex OAuth profile exists in the auth store.
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openai', 'openai-codex']));
mocks.getOpenClawProvidersConfig.mockResolvedValue({
providers: { openai: {} },
defaultModel: undefined,
});
const result = await service.listAccounts();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('openai-oauth-1');
expect(result[0].authMode).toBe('oauth_browser');
expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('openai');
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
});
it('matches OpenAI browser OAuth accounts to the openai-codex runtime key', async () => {
mocks.listProviderAccounts.mockResolvedValue([
makeAccount({