Files
EJClaw/setup/login.ts
Codex 712664ca00 Fix owner context loss after finalize; green the test suite
Owner continuity:
- Seed a freshly created owner task with the previous task's latest owner
  final on a cold start after the previous task already closed, so a user
  reply to a finalized TASK_DONE no longer produces a "no context" answer.
  Activated for this deployment via PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL
  (.env); carried text is injected as clearly-marked background only.
- Skip intermediate STEP_DONE outputs when picking the carry-forward anchor.

Single-mode routing:
- enforceRoomModeOnLease strips a stale reviewer/arbiter lease from a room
  switched back to single, preventing single-mode messages from stalling in
  the paired path on a stuck execution lease.

Session auth / credentials:
- Pre-sync Claude credentials into each session dir before the agent spawns.
- Honor CLAUDE_CREDENTIALS_PATH in setup/login.ts (per-service isolation).
- Add a relogin-required gate so a permanently logged-out claude-code room
  asks the user to re-login instead of spawning a doomed agent.

Other:
- Arbiter verdicts written in the user's language (verdict keyword stays EN).
- status-dashboard chatName field; runtime-inventory credential path resolver.

Tests (make suite fully green: 1595 pass / 3 skip):
- service-routing: default owner is now the claude service and reviewer is
  codex-review; update the 7 failover/default expectations accordingly.
- migrate-room-registrations: owner inferred as claude-code (configured
  OWNER_AGENT_TYPE) for a dual legacy room; reviewer becomes codex.
- register: mock paired-workspace provisioning + reload signal (registration
  now provisions a workspace and hot-reloads); assert RELOADED status.
- paired-execution-context: force a claude-code reviewer to exercise the
  Claude read-only branch regardless of the deployment default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-25 18:40:14 +09:00

244 lines
7.1 KiB
TypeScript

/**
* Step: login — Sign in to Claude (Pro/Max) via OAuth PKCE manual flow.
*
* Mirrors the URL Claude Code's `claude auth login --claudeai` actually
* builds (captured from the official CLI). This is the manual / paste-code
* variant: the user visits the authorize URL, completes login in their
* browser, and pastes the code back. We exchange it for tokens and write
* `CLAUDE_CREDENTIALS_PATH` or `~/.claude/.credentials.json`.
*
* Usage:
* bun setup/index.ts --step login # phase 1: print authorize URL
* bun setup/index.ts --step login --code <c> # phase 2: exchange the code
*
* Phase 1 stashes the PKCE verifier + state in a /tmp login state file
* (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials.
*
* This step is run by hand for re-auth. Once `.credentials.json` exists, the
* existing token-refresh loop (src/token-refresh.ts) keeps it fresh.
*/
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
const REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
const SCOPES = [
'org:create_api_key',
'user:profile',
'user:inference',
'user:sessions:claude_code',
'user:mcp_servers',
'user:file_upload',
];
function credentialsPath(): string {
const configured = process.env.CLAUDE_CREDENTIALS_PATH?.trim();
return configured
? path.resolve(configured)
: path.join(os.homedir(), '.claude', '.credentials.json');
}
function stateFilePath(): string {
const hash = crypto
.createHash('sha256')
.update(credentialsPath())
.digest('hex')
.slice(0, 12);
return path.join(os.tmpdir(), `ejclaw-claude-login-${hash}.json`);
}
interface PendingState {
verifier: string;
state: string;
createdAt: number;
}
interface ExchangeResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
scope?: string;
account?: {
email_address?: string;
has_claude_max?: boolean;
has_claude_pro?: boolean;
};
}
function base64url(buf: Buffer): string {
return buf
.toString('base64')
.replace(/=+$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest(),
);
return { verifier, challenge };
}
function buildAuthorizeUrl(state: string, challenge: string): string {
const url = new URL(AUTHORIZE_URL);
url.searchParams.append('code', 'true');
url.searchParams.append('client_id', CLIENT_ID);
url.searchParams.append('response_type', 'code');
url.searchParams.append('redirect_uri', REDIRECT_URI);
url.searchParams.append('scope', SCOPES.join(' '));
url.searchParams.append('code_challenge', challenge);
url.searchParams.append('code_challenge_method', 'S256');
url.searchParams.append('state', state);
return url.toString();
}
function writePendingState(p: PendingState): void {
fs.writeFileSync(stateFilePath(), JSON.stringify(p), { mode: 0o600 });
}
function readPendingState(): PendingState | null {
const stateFile = stateFilePath();
if (!fs.existsSync(stateFile)) return null;
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as PendingState;
} catch {
return null;
}
}
async function exchangeCode(
code: string,
verifier: string,
state: string,
): Promise<ExchangeResponse> {
// The success page can hand the user a value of `code#state` or just
// `code` — accept both.
const [bareCode, embeddedState] = code.split('#');
const stateForExchange = embeddedState || state;
const body = JSON.stringify({
grant_type: 'authorization_code',
code: bareCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
state: stateForExchange,
});
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Token exchange failed (${res.status}): ${text.slice(0, 400)}`,
);
}
return (await res.json()) as ExchangeResponse;
}
function writeCredentials(resp: ExchangeResponse): void {
const credsPath = credentialsPath();
const expiresAt = Date.now() + resp.expires_in * 1000;
const creds = {
claudeAiOauth: {
accessToken: resp.access_token,
refreshToken: resp.refresh_token || '',
expiresAt,
scopes: resp.scope ? resp.scope.split(' ') : SCOPES,
subscriptionType: resp.account?.has_claude_max
? 'max'
: resp.account?.has_claude_pro
? 'pro'
: '',
},
};
const dir = path.dirname(credsPath);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = `${credsPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.renameSync(tmp, credsPath);
}
interface Args {
code: string | null;
}
function parseArgs(args: string[]): Args {
let code: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--code' && args[i + 1]) {
code = args[++i];
}
}
return { code };
}
export async function run(args: string[]): Promise<void> {
const { code } = parseArgs(args);
if (!code) {
// Phase 1: produce the authorize URL.
const { verifier, challenge } = generatePkce();
const state = base64url(crypto.randomBytes(32));
writePendingState({ verifier, state, createdAt: Date.now() });
const url = buildAuthorizeUrl(state, challenge);
console.log('AUTHORIZE_URL=' + url);
console.log(
'NEXT: open the URL above in your browser, complete the login,' +
' and re-run this command with --code <pasted_code>.',
);
emitStatus('LOGIN_URL', { STATUS: 'awaiting_code', URL: url });
return;
}
// Phase 2: exchange the code.
const pending = readPendingState();
if (!pending) {
emitStatus('LOGIN', {
STATUS: 'failed',
ERROR: 'no_pending_state — run phase 1 first',
});
process.exit(4);
}
if (Date.now() - pending.createdAt > 30 * 60 * 1000) {
logger.warn(
'Pending login state is older than 30 minutes — proceeding anyway',
);
}
try {
const resp = await exchangeCode(code, pending.verifier, pending.state);
writeCredentials(resp);
fs.unlinkSync(stateFilePath());
const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort();
logger.info(
{ scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) },
'Wrote Claude credentials',
);
emitStatus('LOGIN', {
STATUS: 'success',
CREDENTIALS_PATH: credentialsPath(),
SCOPES: newScopes.join(','),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err: message }, 'Token exchange failed');
emitStatus('LOGIN', { STATUS: 'failed', ERROR: message });
process.exit(4);
}
}