Ctrl K
ComunidadGuías…

src/server/claude-login.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Web-UI Claude subscription login — connect / replace / disconnect without SSH.
 *
 * Drives the bundled CLI's `claude auth login` over plain pipes: it prints the OAuth URL and then reads
 * the pasted code from stdin, so one pending child per target waits between the two HTTP calls.
 * On Linux the login runs in a private temp CLAUDE_CONFIG_DIR and its credentials are installed into
 * the real target only after the CLI exits 0 — a failed Replace never breaks the working login.
 * macOS keeps credentials in a Keychain item keyed by the config dir (no file to move), so there the
 * login runs in place.
 */
import { spawn, execFile, type ChildProcess } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import * as contacts from './contacts.ts';
import { applyClaudeAccount, usesShared, USE_SHARED_MARKER } from './claude-account.ts';
import { WORKSPACE_DIR } from './workspace.ts';

const TAG = '[claude-login]';

export type ClaudeTargetName = 'me' | 'global';

export interface ClaudeTarget {
  key: string;
  name: ClaudeTargetName;
  /** CLAUDE_CONFIG_DIR the runs read — for `global` without an explicit env var, ~/.claude. */
  dir: string;
  /** Where the CLI writes `oauthAccount`: inside `dir` when CLAUDE_CONFIG_DIR is set, else ~/.claude.json. */
  accountFile: string;
  /** Whether runs set CLAUDE_CONFIG_DIR for this target (false only for an implicit global login). */
  explicitDir: boolean;
}

export interface ClaudeAccountStatus { connected: boolean; account: string | null; subscriptionType: string | null; useShared?: boolean }

export class ClaudeLoginError extends Error {
  constructor(public status: number, message: string) { super(message); }
}

export class ClaudeLoginOptions {
  cliPath: string = resolveClaudeCli();
  workspaceDir = WORKSPACE_DIR;
  findContact: (email: string) => { id: string } | null = email => contacts.find({ email });
  globalConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR;
  home = homedir();
  /** Install credentials from a temp dir on success (file-based platforms); darwin logs in place. */
  swap = process.platform !== 'darwin';
  sessionTtlMs = 10 * 60_000;
  urlTimeoutMs = 20_000;
  codeTimeoutMs = 60_000;
  /** Called after a target's login changed — connect/replace/disconnect (usage reader cache). */
  onChange: (target: ClaudeTarget) => void = () => {};
}

interface Pending { target: ClaudeTarget; child: ChildProcess; loginDir: string; output: () => string; exit: Promise<number | null>; timer: ReturnType<typeof setTimeout> }

export class ClaudeLogin {
  public options: ClaudeLoginOptions;
  private pending = new Map<string, Pending>();

  public constructor(options?: Partial<ClaudeLoginOptions>) {
    this.options = { ...new ClaudeLoginOptions(), ...options };
  }

  /** Authorization + target resolution. `me` needs a contact; `global` needs the owner. */
  resolve(name: string, user: { email?: string; isOwner?: boolean }): ClaudeTarget {
    const o = this.options;
    if (name === 'global') {
      if (!user.isOwner) throw new ClaudeLoginError(403, 'Only the owner can manage the shared Claude subscription');
      const dir = o.globalConfigDir || path.join(o.home, '.claude');
      return { key: 'global', name, dir, explicitDir: !!o.globalConfigDir, accountFile: o.globalConfigDir ? path.join(dir, '.claude.json') : path.join(o.home, '.claude.json') };
    }
    if (name !== 'me') throw new ClaudeLoginError(404, `Unknown target "${name}"`);
    const email = user.email?.trim().toLowerCase();
    const contact = email ? o.findContact(email) : null;
    if (!contact?.id) throw new ClaudeLoginError(409, `No contact matches ${email || 'your account'} — ask the owner to add you as a contact before connecting a personal subscription`);
    const dir = path.join(o.workspaceDir, 'users', contact.id, '.claude');
    return { key: `me:${contact.id}`, name, dir, explicitDir: true, accountFile: path.join(dir, '.claude.json') };
  }

  async status(target: ClaudeTarget): Promise<ClaudeAccountStatus> {
    const none = { connected: false, account: null, subscriptionType: null };
    if (target.name === 'me' && !(await exists(target.dir))) return none;
    try {
      const out = await this.run(['auth', 'status', '--json'], target.explicitDir ? target.dir : null);
      const s = JSON.parse(out);
      const useShared = target.name === 'me' && usesShared(target.dir);
      return { connected: !!s.loggedIn, account: s.email ?? null, subscriptionType: s.subscriptionType ?? null, ...(useShared && { useShared }) };
    } catch (err) {
      console.warn(`${TAG} status failed for ${target.key}:`, (err as Error).message);
      return none;
    }
  }

  /** Spawn `auth login`, return the OAuth URL. Replaces any pending login for the same target. */
  async start(target: ClaudeTarget): Promise<string> {
    this.cancel(target.key);
    const loginDir = this.options.swap ? await mkdtemp(path.join(tmpdir(), 'shraga-claude-login-')) : target.dir;
    if (!this.options.swap) await mkdir(loginDir, { recursive: true, mode: 0o700 });
    // In-place (darwin): same dir rule as status()/runs. macOS keys the Keychain item by CLAUDE_CONFIG_DIR, so
    // logging an implicit global login in with CLAUDE_CONFIG_DIR=~/.claude saved it under a suffixed item nobody reads.
    const envDir = this.options.swap || target.explicitDir ? loginDir : null;
    const child = spawn(this.options.cliPath, ['auth', 'login', '--claudeai'], { env: this.env(envDir, true), stdio: ['pipe', 'pipe', 'pipe'] });
    let out = '';
    const onData = (b: Buffer) => { out += b.toString(); };
    child.stdout!.on('data', onData);
    child.stderr!.on('data', onData);
    const exit = new Promise<number | null>(res => { child.on('exit', code => res(code)); child.on('error', () => res(-1)); });
    const timer = setTimeout(() => { console.info(`${TAG} login for ${target.key} expired`); this.cancel(target.key); }, this.options.sessionTtlMs);
    timer.unref?.();
    const p: Pending = { target, child, loginDir, output: () => out, exit, timer };
    this.pending.set(target.key, p);

    const url = await new Promise<string | null>(resolve => {
      const check = () => { const u = parseLoginUrl(out); if (u) done(u); };
      const done = (u: string | null) => { clearTimeout(t); child.stdout!.off('data', check); child.stderr!.off('data', check); resolve(u); };
      const t = setTimeout(() => done(null), this.options.urlTimeoutMs);
      child.stdout!.on('data', check);
      child.stderr!.on('data', check);
      exit.then(() => done(parseLoginUrl(out)));
      check();
    });
    if (!url) {
      this.cancel(target.key);
      throw new ClaudeLoginError(502, `Claude CLI did not print a login URL: ${tail(out)}`);
    }
    return url;
  }

  /** Feed the pasted code; on success install credentials and return the fresh status. */
  async submitCode(target: ClaudeTarget, code: string): Promise<ClaudeAccountStatus> {
    const p = this.pending.get(target.key);
    if (!p) throw new ClaudeLoginError(404, 'No login in progress (it may have expired) — start again');
    if (!code?.trim()) throw new ClaudeLoginError(400, 'Code is required');
    this.pending.delete(target.key);
    clearTimeout(p.timer);
    try {
      p.child.stdin!.end(code.trim() + '\n');
      const exitCode = await Promise.race([p.exit, new Promise<'timeout'>(r => setTimeout(() => r('timeout'), this.options.codeTimeoutMs))]);
      if (exitCode !== 0) {
        p.child.kill('SIGKILL');
        throw new ClaudeLoginError(400, `Login failed${exitCode === 'timeout' ? ' (timed out)' : ''}: ${tail(p.output())}`);
      }
      if (this.options.swap) await installLogin(p.loginDir, target);
    } finally {
      if (this.options.swap) await rm(p.loginDir, { recursive: true, force: true });
    }
    if (target.name === 'me') await rm(path.join(target.dir, USE_SHARED_MARKER), { force: true }); // (re)connecting = use it
    this.options.onChange(target);
    return this.status(target);
  }

  /** `me` only: hot-switch between the personal login and the shared one, keeping the credentials. */
  async setUseShared(target: ClaudeTarget, on: boolean): Promise<void> {
    if (target.name !== 'me') throw new ClaudeLoginError(400, 'Only a personal login can be switched');
    if (!(await exists(target.dir))) throw new ClaudeLoginError(409, 'No personal login to switch');
    const marker = path.join(target.dir, USE_SHARED_MARKER);
    // No onChange: the login itself is unchanged, and each dir's usage cache stays valid.
    if (on) await writeFile(marker, ''); else await rm(marker, { force: true });
  }

  /** `me`: logout + remove the dir → runs fall back to the shared subscription. `global`: logout. */
  async disconnect(target: ClaudeTarget): Promise<void> {
    this.cancel(target.key);
    if (target.name === 'me' && !(await exists(target.dir))) return;
    try { await this.run(['auth', 'logout'], target.explicitDir ? target.dir : null); }
    catch (err) { console.warn(`${TAG} logout failed for ${target.key}:`, (err as Error).message); }
    if (target.name === 'me') await rm(target.dir, { recursive: true, force: true });
    this.options.onChange(target);
  }

  cancel(key: string): void {
    const p = this.pending.get(key);
    if (!p) return;
    this.pending.delete(key);
    clearTimeout(p.timer);
    p.child.kill('SIGKILL');
    if (this.options.swap) rm(p.loginDir, { recursive: true, force: true }).catch((err: Error) => console.warn(`${TAG} temp cleanup failed:`, err.message));
  }

  private env(dir: string | null, login = false): Record<string, string> {
    const env: Record<string, string> = {};
    for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
    if (dir) applyClaudeAccount(env, dir);
    else { applyClaudeAccount(env, ''); delete env.CLAUDE_CONFIG_DIR; }
    if (login) env.BROWSER = 'true'; // never try to open a browser on the server
    return env;
  }

  private run(args: string[], dir: string | null): Promise<string> {
    return new Promise((resolve, reject) => {
      execFile(this.options.cliPath, args, { env: this.env(dir), timeout: 20_000 }, (err, stdout) => {
        // `auth status` exits 1 when logged out but still prints valid JSON.
        if (err && !stdout.trim()) reject(err); else resolve(stdout);
      });
    });
  }
}

/** The authorize URL from the CLI's "visit: <url>" line. */
export function parseLoginUrl(output: string): string | null {
  return output.match(/https:\/\/[^\s<>"']*oauth\/authorize[^\s<>"']*/)?.[0] ?? null;
}

/** Copy credential files + identity from a successful temp login into the target (atomic per file). */
export async function installLogin(loginDir: string, target: ClaudeTarget): Promise<void> {
  const creds = (await readdir(loginDir)).filter(n => /^\.credentials.*\.json$/.test(n));
  if (!creds.length) throw new ClaudeLoginError(500, 'Login succeeded but the CLI wrote no credentials file');
  await mkdir(target.dir, { recursive: true, mode: 0o700 });
  for (const name of creds) {
    const dest = path.join(target.dir, name);
    await copyFile(path.join(loginDir, name), dest + '.tmp');
    await rename(dest + '.tmp', dest);
  }
  const fresh = await readJson(path.join(loginDir, '.claude.json'));
  if (fresh.oauthAccount) {
    const current = await readJson(target.accountFile);
    await writeFile(target.accountFile + '.tmp', JSON.stringify({ ...current, oauthAccount: fresh.oauthAccount }, null, 2), { mode: 0o600 });
    await rename(target.accountFile + '.tmp', target.accountFile);
  }
}

/** The platform binary the Agent SDK itself spawns (same package lookup, musl first on musl Linux). */
export function resolveClaudeCli(): string {
  const require = createRequire(import.meta.url);
  const ext = process.platform === 'win32' ? '.exe' : '';
  const base = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`;
  for (const pkg of process.platform === 'linux' ? [base, `${base}-musl`] : [base]) {
    try { return require.resolve(`${pkg}/claude${ext}`); } catch { /* next */ }
  }
  return 'claude';
}

async function readJson(file: string): Promise<Record<string, unknown>> {
  try { return JSON.parse(await readFile(file, 'utf8')); } catch { return {}; }
}
async function exists(p: string): Promise<boolean> {
  try { await readdir(p); return true; } catch { return false; }
}
function tail(s: string): string {
  return s.trim().split('\n').slice(-3).join(' ').slice(0, 300) || '(no output)';
}