feat(auth): stdio oauth helper

This commit is contained in:
Peter Steinberger 2025-12-03 17:12:35 +00:00
parent 5e1491d86c
commit 4d3552b99b
3 changed files with 52 additions and 0 deletions

View File

@ -21,6 +21,8 @@ import { consumeOutputFormat } from './cli/output-format.js';
import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js';
import { boldText, dimText, extraDimText, supportsAnsiColor } from './cli/terminal.js';
import { resolveConfigPath } from './config.js';
import type { ServerDefinition } from './config-schema.js';
import { spawn } from 'node:child_process';
import { DaemonClient } from './daemon/client.js';
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
import { analyzeConnectionError } from './error-classifier.js';
@ -476,6 +478,13 @@ export async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntim
logInfo(`Cleared cached credentials for '${target}'.`);
}
if (definition.command.kind === 'stdio' && definition.oauthCommand) {
logInfo(`Starting auth helper for '${target}' (stdio). Leave this running until the browser flow completes.`);
await runStdioAuth(definition);
logInfo(`Auth helper for '${target}' finished. You can now call tools.`);
return;
}
// Kick off the interactive OAuth flow without blocking list output. We retry once if the
// server gets auto-promoted to OAuth mid-flight.
for (let attempt = 0; attempt < 2; attempt += 1) {
@ -514,6 +523,28 @@ async function invokeAuthCommand(runtimeOptions: Parameters<typeof createRuntime
}
}
async function runStdioAuth(definition: ServerDefinition): Promise<void> {
const authArgs = [...(definition.command.kind === 'stdio' ? definition.command.args ?? [] : [])];
if (definition.oauthCommand) {
authArgs.push(...definition.oauthCommand.args);
}
return new Promise((resolve, reject) => {
const child = spawn(definition.command.kind === 'stdio' ? definition.command.command : '', authArgs, {
stdio: 'inherit',
cwd: definition.command.kind === 'stdio' ? definition.command.cwd : process.cwd(),
env: process.env,
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Auth helper exited with code ${code ?? 'null'}`));
}
});
});
}
function shouldRetryAuthError(error: unknown): boolean {
return analyzeConnectionError(error).kind === 'auth';
}

View File

@ -15,6 +15,8 @@ export function normalizeServerEntry(
const tokenCacheDir = normalizePath(raw.tokenCacheDir ?? raw.token_cache_dir);
const clientName = raw.clientName ?? raw.client_name;
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
const oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
const headers = buildHeaders(raw);
const httpUrl = getUrl(raw);
@ -42,6 +44,11 @@ export function normalizeServerEntry(
const lifecycle = resolveLifecycle(name, raw.lifecycle, command);
const logging = normalizeLogging(raw.logging);
const defaultedOauthCommand =
!oauthCommand && name.toLowerCase() === 'gmail' && command.kind === 'stdio'
? { args: ['auth', 'http://localhost:3000/oauth2callback'] }
: oauthCommand;
return {
name,
description,
@ -51,6 +58,7 @@ export function normalizeServerEntry(
tokenCacheDir,
clientName,
oauthRedirectUrl,
oauthCommand: defaultedOauthCommand,
source,
sources,
lifecycle,

View File

@ -62,6 +62,16 @@ export const RawEntrySchema = z.object({
client_name: z.string().optional(),
oauthRedirectUrl: z.string().optional(),
oauth_redirect_url: z.string().optional(),
oauthCommand: z
.object({
args: z.array(z.string()),
})
.optional(),
oauth_command: z
.object({
args: z.array(z.string()),
})
.optional(),
bearerToken: z.string().optional(),
bearer_token: z.string().optional(),
bearerTokenEnv: z.string().optional(),
@ -123,6 +133,9 @@ export interface ServerDefinition {
readonly tokenCacheDir?: string;
readonly clientName?: string;
readonly oauthRedirectUrl?: string;
readonly oauthCommand?: {
readonly args: string[];
};
readonly source?: ServerSource;
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;