mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
feat:upgrade OpenClaw to 2026.6.5 and fix OAuth provider detection for auth-profile-only configs. (#1109)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+256
-960
File diff suppressed because it is too large
Load Diff
+28
-5
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user