Skip to content
Merged
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
38 changes: 24 additions & 14 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,10 @@ class BridgeProcess {
logger.debug(`Starting keepalive ping every ${KEEPALIVE_INTERVAL_MS / 1000}s`);

this.keepaliveInterval = setInterval(() => {
this.sendKeepalivePing().catch((error) => {
this.sendKeepalivePing().catch(async (error) => {
logger.error('Keepalive ping failed:', error);
// If ping fails, the session might be expired - check and handle
this.handlePossibleExpiration(error as Error);
await this.handlePossibleExpiration(error as Error);
});
}, KEEPALIVE_INTERVAL_MS);

Expand Down Expand Up @@ -744,19 +744,28 @@ class BridgeProcess {
* Session expiry is checked first since it's more specific (404/session-not-found),
* while auth errors are broader (401/403/unauthorized) and could overlap.
*/
private handlePossibleExpiration(error: Error): void {
private async handlePossibleExpiration(error: Error): Promise<void> {
let status: 'expired' | 'unauthorized' | null = null;
if (isSessionExpiredError(error.message)) {
logger.warn('Session appears to be expired, marking as expired and shutting down');
this.markSessionStatusAndExit('expired').catch((e) => {
logger.error('Failed to mark session as expired:', e);
process.exit(1);
});
status = 'expired';
} else if (isAuthenticationError(error.message)) {
logger.warn('Authentication rejected, marking session as unauthorized and shutting down');
this.markSessionStatusAndExit('unauthorized').catch((e) => {
logger.error('Failed to mark session as unauthorized:', e);
process.exit(1);
});
status = 'unauthorized';
}
if (status) {
// Update session status synchronously so it's visible to CLI immediately
try {
await updateSession(this.options.sessionName, { status });
logger.info(`Session ${this.options.sessionName} marked as ${status}`);
} catch (e) {
logger.error('Failed to update session status:', e);
}
// Delay shutdown so the error response socket.write() in the caller has
// time to flush to the client before the process tears down.
setTimeout(() => {
this.shutdown().catch(() => process.exit(1));
}, 100);
}
}

Expand Down Expand Up @@ -1168,10 +1177,11 @@ class BridgeProcess {
} catch (error) {
logger.error('Failed to forward MCP request to server:', error);

this.sendError(socket, error as Error, message.id);
// Update session status BEFORE sending error to CLI, so the status is
// visible when the CLI (or test) checks sessions.json immediately after
await this.handlePossibleExpiration(error as Error);

// Check if this error indicates session expiration
this.handlePossibleExpiration(error as Error);
this.sendError(socket, error as Error, message.id);
}
}

Expand Down
64 changes: 46 additions & 18 deletions src/lib/auth/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,30 +81,58 @@ function getEntry(): Promise<EntryConstructor> {
return _entryPromise;
}

/** Probe the OS keychain by performing a write, read-back, and delete. */
async function probeKeychain(EntryClass: EntryConstructor): Promise<boolean> {
const probeAccount = `__mcpc_probe_${Date.now()}_${Math.random().toString(36).slice(2)}__`;
try {
const entry = new EntryClass(SERVICE_NAME, probeAccount);
const probeValue = `probe-${Date.now()}`;
entry.setPassword(probeValue);
const readBack = entry.getPassword();
entry.deletePassword();
return readBack === probeValue;
} catch {
return false;
}
}

// Serialise the one-time probe so concurrent callers don't race.
let _probePromise: Promise<void> | null = null;

async function ensureProbed(): Promise<void> {
if (keychainAvailable !== null) return;
if (_probePromise === null) {
_probePromise = (async () => {
try {
const EntryClass = await getEntry();
keychainAvailable = await probeKeychain(EntryClass);
} catch {
// import() itself failed (missing native addon / libsecret)
keychainAvailable = false;
}
if (!keychainAvailable && !getJsonMode()) {
logger.warn(
chalk.red(
`OS keychain unavailable, ` +
`falling back to file-based credential storage (${credentialsPath()}). ` +
`Install a keyring daemon (e.g. gnome-keyring or kwallet) for better security.`
)
);
}
})();
}
return _probePromise;
}

async function withKeychain<T>(
keychainOp: (EntryClass: EntryConstructor) => T,
fallback: () => Promise<T>
): Promise<T> {
await ensureProbed();
if (keychainAvailable === false) return fallback();

try {
const EntryClass = await getEntry();
const result = keychainOp(EntryClass);
keychainAvailable = true;
return result;
} catch (error) {
if (keychainAvailable === null && !getJsonMode()) {
logger.warn(
chalk.red(
`OS keychain unavailable (${(error as Error).message}), ` +
`falling back to file-based credential storage (${credentialsPath()}). ` +
`Install a keyring daemon (e.g. gnome-keyring or kwallet) for better security.`
)
);
}
keychainAvailable = false;
return fallback();
}
const EntryClass = await getEntry();
return keychainOp(EntryClass);
}

function keychainSet(account: string, value: string): Promise<void> {
Expand Down
Loading