diff --git a/setup/auto.ts b/setup/auto.ts index 91f9bc1..8c78d64 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -22,6 +22,8 @@ * headless `claude -p` call for IANA-zone resolution. */ import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; @@ -636,6 +638,16 @@ async function runAuthStep(): Promise { return; } + // Custom Anthropic-compatible endpoint flow. Both URL and token must be set; + // OneCLI stores the token as a generic Bearer secret keyed to the URL host, + // so the container only ever sees ANTHROPIC_BASE_URL + a placeholder. + const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim(); + const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim(); + if (customBaseUrl && customAuthToken) { + await runCustomEndpointAuth(customBaseUrl, customAuthToken); + return; + } + const method = ensureAnswer( await brightSelect({ message: 'How would you like to connect to Claude?', @@ -741,6 +753,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { } } +/** + * Set up Anthropic auth for a custom endpoint. The token is stored as a + * OneCLI generic secret with header injection so the proxy rewrites the + * Authorization header on the wire — the container only ever sees + * ANTHROPIC_BASE_URL + a placeholder bearer. + */ +async function runCustomEndpointAuth( + baseUrl: string, + token: string, +): Promise { + let host: string; + try { + host = new URL(baseUrl).hostname; + } catch { + await fail( + 'auth', + `Invalid Anthropic base URL: ${baseUrl}`, + 'Check --anthropic-base-url and retry.', + ); + return; + } + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', + 'create', + '--name', + 'Anthropic', + '--type', + 'generic', + '--value', + token, + '--host-pattern', + host, + '--header-name', + 'Authorization', + '--value-format', + 'Bearer {value}', + ], + { + running: `Saving your Anthropic auth token to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { extraFields: { METHOD: 'custom-endpoint', HOST: host } }, + ); + if (!res.ok) { + await fail( + 'auth', + `Couldn't save your Anthropic auth token to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } + + // ANTHROPIC_BASE_URL has to be in .env so the runtime provider config + // reads it when building container env. The token is *not* written — + // OneCLI holds it. + writeEnvLine('ANTHROPIC_BASE_URL', baseUrl); + + // Register the claude provider so the runtime passes ANTHROPIC_BASE_URL + // and the placeholder bearer into the container. Only appended when the + // user has configured a custom endpoint; standard installs don't load + // the file at all. + appendProviderImport('./claude.js'); +} + +function writeEnvLine(key: string, value: string): void { + const envFile = path.join(process.cwd(), '.env'); + const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; + const re = new RegExp(`^${key}=.*$`, 'm'); + const next = re.test(content) + ? content.replace(re, `${key}=${value}`) + : content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`; + fs.writeFileSync(envFile, next); +} + +function appendProviderImport(modulePath: string): void { + const file = path.join(process.cwd(), 'src', 'providers', 'index.ts'); + const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const line = `import '${modulePath}';`; + if (content.includes(line)) return; + const sep = content && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(file, content + sep + line + '\n'); +} + // ─── timezone step ───────────────────────────────────────────────────── /** diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 0a59731..1fa6ad4 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -95,6 +95,16 @@ export const CONFIG: Entry[] = [ placeholder: 'https://api.anthropic.com', validate: httpUrl, }, + { + key: 'anthropicAuthToken', + label: 'Anthropic auth token', + help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'string', + secret: true, + validate: (v) => (v.trim() ? undefined : 'Required'), + }, // Existing env-var knobs — flag-only so they don't clutter the UI screen. { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 7252da8..e61d721 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,16 +1,28 @@ +/** + * Claude provider container config — only registered when the user has + * configured a custom Anthropic-compatible endpoint via setup. Setup + * appends `import './claude.js'` to providers/index.ts at that point; + * standard installs hitting api.anthropic.com don't need this file + * loaded. + * + * The real auth token never enters the container. Setup creates an + * OneCLI generic secret (host-pattern = base URL hostname, header-name + * = Authorization, value-format = "Bearer {value}") so the proxy + * rewrites the Authorization header on the wire. The container only + * needs: + * - ANTHROPIC_BASE_URL — so the SDK knows where to call + * - ANTHROPIC_AUTH_TOKEN=placeholder — so the SDK adds an + * Authorization: Bearer header for OneCLI to overwrite + */ import { readEnvFile } from '../env.js'; import { registerProviderContainerConfig } from './provider-container-registry.js'; registerProviderContainerConfig('claude', () => { - const dotenv = readEnvFile(['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']); + const dotenv = readEnvFile(['ANTHROPIC_BASE_URL']); const env: Record = {}; if (dotenv.ANTHROPIC_BASE_URL) { env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL; - const host = new URL(dotenv.ANTHROPIC_BASE_URL).hostname; - env.NO_PROXY = host; - env.no_proxy = host; + env.ANTHROPIC_AUTH_TOKEN = 'placeholder'; } - if (dotenv.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = dotenv.ANTHROPIC_AUTH_TOKEN; - if (dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = dotenv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; return { env }; }); diff --git a/src/providers/index.ts b/src/providers/index.ts index 1a3a638..3ec9512 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,4 +4,3 @@ // needs (claude, mock) don't appear here. // // Skills add a new provider by appending one import line below. -import './claude.js';