Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 137 additions & 41 deletions electron/utils/openclaw-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* equivalents could stall for 500 ms – 2 s+ per call, causing "Not
* Responding" hangs.
*/
import { access, mkdir, readFile, writeFile } from 'fs/promises';
import { access, mkdir, open, readFile, rm, writeFile } from 'fs/promises';
import { constants, readdirSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
Expand Down Expand Up @@ -193,6 +193,94 @@
await writeJsonFile(getAuthProfilesPath(agentId), store);
}

/**
* Check whether a process ID is still running.
* Returns false if the process is gone (ESRCH) or we get a definitive
* "no such process" signal; returns true on EPERM (process exists but
* we cannot signal it) or any other unexpected error (conservative).
*/
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}

/**
* Run `fn` while holding the `.lock` sidecar file that the OpenClaw gateway
* uses to guard concurrent writes to auth-profiles.json. Both sides must
* participate in this protocol so their reads and writes are serialised.
*
* Protocol (mirrors node_modules/openclaw/dist/file-lock-B4wypLkV.js):
* lock path = `${auth-profiles.json}.lock`
* acquire = fs.open(lockPath, 'wx') — atomic EEXIST on collision
* lock body = JSON { pid, createdAt } — used for stale detection
* stale = PID no longer alive OR lock file older than 30 s
* release = fs.rm(lockPath, { force: true })
*/
async function withAuthProfilesLock<T>(agentId: string, fn: () => Promise<T>): Promise<T> {
const authPath = getAuthProfilesPath(agentId);
const lockPath = `${authPath}.lock`;
const staleMs = 30_000;
const maxAttempts = 10;
const baseDelayMs = 50;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
let handle: Awaited<ReturnType<typeof open>> | null = null;
try {
// Ensure parent dir exists before trying to create the lock file
await ensureDir(join(authPath, '..'));
handle = await open(lockPath, 'wx');
await handle.writeFile(
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
'utf-8',
);
await handle.close();
handle = null;

try {
return await fn();
} finally {
await rm(lockPath, { force: true }).catch(() => {});
}
} catch (err: unknown) {
if (handle) {
await handle.close().catch(() => {});
handle = null;

Check failure on line 251 in electron/utils/openclaw-auth.ts

View workflow job for this annotation

GitHub Actions / check

The value assigned to 'handle' is not used in subsequent statements
}
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;

// Lock file already exists — check if it is stale
const isStale = await (async (): Promise<boolean> => {
try {
const raw = await readFile(lockPath, 'utf-8');
const parsed = JSON.parse(raw) as { pid?: number; createdAt?: string };
if (typeof parsed.pid === 'number' && !isPidAlive(parsed.pid)) return true;
if (typeof parsed.createdAt === 'string') {
const age = Date.now() - Date.parse(parsed.createdAt);
if (!Number.isFinite(age) || age > staleMs) return true;
}
return false;
} catch {
return true; // unreadable / malformed lock → treat as stale
Comment on lines +266 to +267
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't delete lock files on transient parse/read failures

When open(lockPath, 'wx') succeeds, the lock file exists before its JSON body is fully written; a concurrent writer that gets EEXIST can read an empty/partial file, hit this catch, mark it stale, and rm a still-live lock. On POSIX this unlink can succeed even while the first process still holds the fd, allowing both processes into the critical section and reintroducing lost updates to auth-profiles.json under contention.

Useful? React with 👍 / 👎.

}
})();

if (isStale) {
await rm(lockPath, { force: true }).catch(() => {});
continue; // retry immediately after clearing stale lock
}

if (attempt >= maxAttempts - 1) break;
await new Promise<void>((resolve) => setTimeout(resolve, baseDelayMs * (attempt + 1)));
}
}

throw new Error(`auth-profiles lock timeout for agent "${agentId}"`);
}

// ── Agent Discovery ──────────────────────────────────────────────

async function discoverAgentIds(): Promise<string[]> {
Expand Down Expand Up @@ -376,29 +464,31 @@
if (agentIds.length === 0) agentIds.push('main');

for (const id of agentIds) {
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;

store.profiles[profileId] = {
type: 'oauth',
provider,
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
};

if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}
await withAuthProfilesLock(id, async () => {
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;

store.profiles[profileId] = {
type: 'oauth',
provider,
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
};

if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}

if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;
if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;

await writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
});
}
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
Expand Down Expand Up @@ -445,21 +535,23 @@
if (agentIds.length === 0) agentIds.push('main');

for (const id of agentIds) {
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;
await withAuthProfilesLock(id, async () => {
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;

store.profiles[profileId] = { type: 'api_key', provider, key: apiKey };
store.profiles[profileId] = { type: 'api_key', provider, key: apiKey };

if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}
if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}

if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;
if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;

await writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
});
}
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
Expand All @@ -475,10 +567,12 @@
if (agentIds.length === 0) agentIds.push('main');

for (const id of agentIds) {
const store = await readAuthProfiles(id);
if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) {
await writeAuthProfiles(store, id);
}
await withAuthProfilesLock(id, async () => {
const store = await readAuthProfiles(id);
if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) {
await writeAuthProfiles(store, id);
}
});
}
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
Expand All @@ -491,10 +585,12 @@
const agentIds = await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = await readAuthProfiles(id);
if (removeProfilesForProvider(store, provider)) {
await writeAuthProfiles(store, id);
}
await withAuthProfilesLock(id, async () => {
const store = await readAuthProfiles(id);
if (removeProfilesForProvider(store, provider)) {
await writeAuthProfiles(store, id);
}
});
}

// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/openclaw-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,67 @@ describe('auth-backed provider discovery', () => {
await expect(getActiveOpenClawProviders()).resolves.toEqual(new Set());
});
});

describe('auth-profiles file-lock protocol', () => {
beforeEach(async () => {
vi.resetModules();
vi.restoreAllMocks();
await rm(testHome, { recursive: true, force: true });
await rm(testUserData, { recursive: true, force: true });
});

it('removes the .lock sidecar file after a successful write', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth');
await saveProviderKeyToOpenClaw('moonshot', 'sk-moon', 'main');

const lockPath = join(testHome, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json.lock');
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();

logSpy.mockRestore();
});

it('clears a stale lock (dead PID) and proceeds normally', async () => {
const agentDir = join(testHome, '.openclaw', 'agents', 'main', 'agent');
await mkdir(agentDir, { recursive: true });
const lockPath = join(agentDir, 'auth-profiles.json.lock');

// Plant a lock file referencing a PID that cannot possibly be alive
await writeFile(
lockPath,
JSON.stringify({ pid: 999_999_999, createdAt: new Date().toISOString() }),
'utf8',
);

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth');
await saveProviderKeyToOpenClaw('anthropic', 'sk-ant', 'main');

const store = await readAuthProfiles('main');
expect((store.profiles as Record<string, { key: string }>)['anthropic:default'].key).toBe('sk-ant');

// Stale lock should have been removed
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();

logSpy.mockRestore();
});

it('concurrent writes to the same agent are serialised — no key is lost', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth');

// Two simultaneous saves; without locking the second read sees an empty
// store and overwrites the first key when it writes back.
await Promise.all([
saveProviderKeyToOpenClaw('openai', 'sk-openai', 'main'),
saveProviderKeyToOpenClaw('anthropic', 'sk-anthropic', 'main'),
]);

const store = await readAuthProfiles('main');
const profiles = store.profiles as Record<string, { key: string }>;
expect(profiles['openai:default']?.key).toBe('sk-openai');
expect(profiles['anthropic:default']?.key).toBe('sk-anthropic');

logSpy.mockRestore();
});
});
Loading