diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c01a10..e414787 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,6 +155,13 @@ and Chromium; set `CHROME_PATH` if needed. Screenshots go to `/tmp/t3-opencode-auth-ui` (`T3_AUTH_UI_OUTPUT` overrides it). These audits never read or change your real OpenCode credentials. +For Pi credential changes, run `node scripts/pi-auth-smoke.mjs /absolute/path/to/pi` +against both 0.80.5 (the supported floor) and the current release. It uses an +isolated home and fake keys, and checks that saved keys sit beside a `/login` +subscription, that Pi lists their providers' models, and removal, without a +login or model request. The image smoke suite runs it against the installed +release, then signs Pi in and out through the Setup API. + For Credentials changes, run `node --test tests/credential*.test.mjs` and `node scripts/credentials-ui-audit.mjs`. The browser audit starts a real Setup API in a disposable home and a loopback API server. It tests drafts, diff --git a/docker/harness/catalogue.mjs b/docker/harness/catalogue.mjs index 396dc6c..b8a0d4b 100644 --- a/docker/harness/catalogue.mjs +++ b/docker/harness/catalogue.mjs @@ -3,6 +3,7 @@ // credential surface, and minimum version the manager enforces comes from // here, so the module, the setup console, and the CLI all describe the same // tools. +import { PI_CREDENTIAL_ENV } from "./pi-providers.mjs"; /** * T3 refuses to serve OpenCode below this version, so a managed install must @@ -172,9 +173,10 @@ export const CATALOGUE = Object.freeze([ versionPattern: "(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?)", minimumVersion: "0.80.5", architectures: ["x64", "arm64"], - credentials: { env: [], paths: [".pi/agent/auth.json"] }, - // Pi signs in interactively with /login; saved credentials cannot prove auth. - auth: null, + // Keys and /login subscriptions live in auth.json (PI_CODING_AGENT_DIR + // moves it); Pi also reads each provider's key from the environment. + credentials: { env: PI_CREDENTIAL_ENV, paths: [".pi/agent/auth.json"] }, + auth: "pi", }, ]); diff --git a/docker/harness/index.mjs b/docker/harness/index.mjs index 56396e7..ba4de4e 100644 --- a/docker/harness/index.mjs +++ b/docker/harness/index.mjs @@ -46,3 +46,4 @@ export { export { compareVersions, meetsMinimum } from "./version.mjs"; export { createOpenCodeAuth, openCodeMajor, parseAuthList, hasStoredCredentials, storedCredentials, storedIntegrationIds } from "./opencode-auth.mjs"; +export { createPiAuth, parsePiModelList, piAuthFile, PI_KEY_PROVIDERS } from "./pi-auth.mjs"; diff --git a/docker/harness/pi-auth.mjs b/docker/harness/pi-auth.mjs new file mode 100644 index 0000000..e521f44 --- /dev/null +++ b/docker/harness/pi-auth.mjs @@ -0,0 +1,198 @@ +// Pi's saved sign-ins: one contract for the harness probe, Setup's key sheet +// and diagnostics. +// +// Pi keeps one credential per provider in /auth.json: an API key +// ({"type":"api_key","key":"..."}) or a subscription sign-in made with /login +// ({"type":"oauth",...}). Pi has no command that saves a key, so Setup writes +// the file itself. Pi takes a proper-lockfile lock (`auth.json.lock`, a +// directory) for every write, including OAuth refreshes; Setup takes the +// same lock, so neither can lose the other's change. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; + +const MAX_FILE = 1024 * 1024; +// Pi treats a lock older than this as abandoned. A live holder refreshes it +// at half this age, so an older lock is never someone's write in progress. +const LOCK_STALE_MS = 30_000; +const LOCK_WAIT_MS = 5_000; + +import { PI_KEY_PROVIDERS, PI_SUBSCRIPTION_ONLY } from './pi-providers.mjs'; +export { PI_KEY_PROVIDERS, PI_SUBSCRIPTION_ONLY, PI_CREDENTIAL_ENV } from './pi-providers.mjs'; + +const NAMES = new Map([...PI_KEY_PROVIDERS, ...PI_SUBSCRIPTION_ONLY].map((provider) => [provider.id, provider.name])); +/** A provider's display name; ids Pi adds later read as themselves. */ +export const piProviderName = (id) => NAMES.get(id) || id; + +// Pi's own ids are lower-case; custom providers from models.json can be +// anything a JSON key can be, within reason. +export const isPiProviderId = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value); + +/** Pi's agent directory: PI_CODING_AGENT_DIR (with ~ expanded) or ~/.pi/agent. */ +export function piAgentDir(home, env = {}) { + const value = String(env.PI_CODING_AGENT_DIR ?? '').trim(); + if (!value) return path.join(home, '.pi', 'agent'); + if (value === '~') return home; + if (value.startsWith('~/')) return path.join(home, value.slice(2)); + return path.resolve(home, value); +} +export const piAuthFile = (home, env) => path.join(piAgentDir(home, env), 'auth.json'); + +/** Providers with a credential in the environment, as Pi would find them. */ +export function piEnvCredentials(env = {}) { + const found = []; + for (const provider of [...PI_KEY_PROVIDERS, ...PI_SUBSCRIPTION_ONLY]) { + const variable = provider.env.find((name) => typeof env[name] === 'string' && env[name].trim()); + if (variable) found.push({ provider: provider.id, variable }); + } + return found; +} + +/** Saved credentials without their secrets: provider and kind only. */ +export function piSavedCredentials(auth) { + return Object.entries(auth).map(([provider, credential]) => ({ + provider, type: credential?.type === 'oauth' ? 'oauth' : 'api_key', + })).sort((a, b) => a.provider.localeCompare(b.provider)); +} + +/** + * `pi --list-models` prints a table of the models Pi can use now, which only + * includes providers it holds credentials for (saved, environment, models.json + * or extensions). That is also how T3 Code decides Pi is ready. An empty list + * means no sign-in; anything unrecognisable is no verdict at all. + */ +export function parsePiModelList(text) { + const lines = String(text ?? '').split(/\r?\n/); + if (lines.some((line) => /^\s*No models available\b/i.test(line))) return { available: false, providers: [] }; + const header = lines.findIndex((line) => /^\s*provider\s+model\b/i.test(line)); + if (header < 0) return { available: null, providers: [] }; + const providers = new Set(); + for (const line of lines.slice(header + 1)) { + const provider = /^\s*(\S+)\s+\S/.exec(line)?.[1]; + if (provider && isPiProviderId(provider)) providers.add(provider); + } + return providers.size ? { available: true, providers: [...providers].sort() } : { available: null, providers: [] }; +} + +/** Read auth.json without following it anywhere odd or loading something huge. */ +export async function readPiAuth({ home, env, io = fs }) { + const file = piAuthFile(home, env); + let text; + try { + const handle = await io.open(file, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw Object.assign(new Error(), { code: 'EINVAL' }); + if (stat.size > MAX_FILE) throw Object.assign(new Error(), { code: 'E2BIG' }); + text = await handle.readFile('utf8'); + } finally { await handle.close(); } + } catch (error) { + if (error.code === 'ENOENT') return {}; + if (error.code === 'E2BIG') throw new Error('Pi’s auth.json is too large to read.'); + throw new Error('Pi’s saved sign-ins could not be read.'); + } + if (Buffer.byteLength(text) > MAX_FILE) throw new Error('Pi’s auth.json is too large to read.'); + if (!text.trim()) return {}; + try { + const value = JSON.parse(text.replace(/^/, '')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(); + for (const credential of Object.values(value)) { + if (!credential || typeof credential !== 'object' || Array.isArray(credential)) throw new Error(); + } + return value; + } catch { + throw new Error('Pi’s auth.json is not valid. Repair it, or sign in again with /login in Pi, before changing keys here.'); + } +} + +// proper-lockfile's lock: a directory beside the file, made atomically. +async function withPiLock(io, file, work, { now = Date.now, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) { + const lock = file + '.lock'; + const deadline = now() + LOCK_WAIT_MS; + for (let attempt = 0; ; attempt++) { + try { await io.mkdir(lock); break; } catch (error) { + if (error.code !== 'EEXIST') throw new Error('Pi’s saved sign-ins could not be locked for writing.'); + let stale = false; + try { stale = now() - (await io.stat(lock)).mtimeMs > LOCK_STALE_MS; } catch (statError) { if (statError.code === 'ENOENT') continue; } + if (stale) { await io.rm(lock, { recursive: true, force: true }).catch(() => {}); continue; } + if (now() >= deadline) throw new Error('Pi is saving its sign-ins right now. Try again in a moment.'); + await wait(Math.min(25 * 2 ** attempt, 250)); + } + } + try { return await work(); } + finally { await io.rm(lock, { recursive: true, force: true }).catch(() => {}); } +} + +// Replace auth.json in one step, through a link to wherever it really lives. +async function writePiAuth(io, file, value) { + let target = file, entry = null; + try { entry = await io.lstat(file); } catch (error) { + if (error.code !== 'ENOENT') throw new Error('Pi’s auth.json could not be checked before writing.'); + } + if (entry?.isSymbolicLink()) { + // A link to nothing has no file to replace; refuse rather than guess. + try { target = await io.realpath(file); } catch { throw new Error('Pi’s auth.json links to a missing file. Fix the link first.'); } + } + const temporary = path.join(path.dirname(target), '.auth.json.' + randomBytes(8).toString('hex') + '.tmp'); + try { + await io.writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + await io.rename(temporary, target); + } finally { await io.rm(temporary, { force: true }).catch(() => {}); } +} + +/** + * Save and remove Pi credentials. Every change is a locked + * read-modify-write, so other providers, subscription sign-ins and Pi's own + * token refreshes stay as they are. + */ +export function createPiAuth({ home, env = {}, io = fs, onChange = async () => {}, lockOptions } = {}) { + let pending = Promise.resolve(); + const serial = (work) => { const current = pending.then(work); pending = current.catch(() => {}); return current; }; + const file = () => piAuthFile(home, env); + const change = (mutate) => serial(async () => { + const target = file(); + await io.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + try { + return await withPiLock(io, target, async () => { + const auth = await readPiAuth({ home, env, io }); + const { result, next } = mutate(auth); + if (next) await writePiAuth(io, target, next); + return result; + }, lockOptions); + } finally { await onChange(); } + }); + const validate = (provider) => { if (!isPiProviderId(provider)) throw new Error('Choose a provider (for example anthropic, openai or openrouter).'); }; + return { + file, + async list() { + return { saved: piSavedCredentials(await readPiAuth({ home, env, io })), env: piEnvCredentials(env) }; + }, + async saveKey(provider, key, { replaceSubscription = false } = {}) { + validate(provider); + if (typeof key !== 'string' || !key.trim() || key.trim().length > 1000) throw new Error('Enter a key of at most 1000 characters.'); + const value = key.trim(); + // Pi runs a value starting with ! as a shell command and expands $NAME. + if (value.startsWith('!') || value.includes('$')) throw new Error('Pi reads a key that starts with ! or contains $ as a command or variable. Enter the key itself.'); + if (/\s/.test(value)) throw new Error('A key has no spaces or line breaks. Check what was pasted.'); + return change((auth) => { + const current = auth[provider]; + if (current?.type === 'oauth' && !replaceSubscription) { + throw Object.assign(new Error(`Pi is signed in to ${piProviderName(provider)} with a subscription. Saving a key replaces that sign-in.`), { code: 'subscription-exists' }); + } + // Keep extra settings saved with an earlier key, such as account ids. + const extra = current?.type === 'api_key' && current.env && typeof current.env === 'object' ? { env: current.env } : {}; + return { result: { ok: true, replaced: current ? current.type : null }, next: { ...auth, [provider]: { type: 'api_key', key: value, ...extra } } }; + }); + }, + async removeProvider(provider) { + validate(provider); + return change((auth) => { + if (!Object.hasOwn(auth, provider)) return { result: { ok: true, removed: false }, next: null }; + const next = { ...auth }; + delete next[provider]; + return { result: { ok: true, removed: true }, next }; + }); + }, + }; +} diff --git a/docker/harness/pi-providers.mjs b/docker/harness/pi-providers.mjs new file mode 100644 index 0000000..80ae53d --- /dev/null +++ b/docker/harness/pi-providers.mjs @@ -0,0 +1,57 @@ +// Pi's providers, as data: the ones that take a plain API key, the variables +// Pi reads each credential from, and the subscription-only ones. Shared by +// the catalogue (which credentials count) and pi-auth.mjs (the key sheet). +// Ids and variables follow env-api-keys in @earendil-works/pi-ai. + +/** + * Providers that sign in with a plain API key. Providers that also need a + * base URL, account id or cloud credentials (Azure, Bedrock, Vertex, + * Cloudflare) are left to Pi's own /login. + */ +export const PI_KEY_PROVIDERS = Object.freeze([ + ['anthropic', 'Anthropic', ['ANTHROPIC_API_KEY', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN']], + ['openai', 'OpenAI', ['OPENAI_API_KEY']], + ['google', 'Google Gemini', ['GEMINI_API_KEY']], + ['openrouter', 'OpenRouter', ['OPENROUTER_API_KEY']], + ['deepseek', 'DeepSeek', ['DEEPSEEK_API_KEY']], + ['xai', 'xAI', ['XAI_API_KEY']], + ['mistral', 'Mistral', ['MISTRAL_API_KEY']], + ['groq', 'Groq', ['GROQ_API_KEY']], + ['cerebras', 'Cerebras', ['CEREBRAS_API_KEY']], + ['fireworks', 'Fireworks', ['FIREWORKS_API_KEY']], + ['together', 'Together AI', ['TOGETHER_API_KEY']], + ['huggingface', 'Hugging Face', ['HF_TOKEN']], + ['vercel-ai-gateway', 'Vercel AI Gateway', ['AI_GATEWAY_API_KEY']], + ['opencode', 'OpenCode Zen', ['OPENCODE_API_KEY']], + ['opencode-go', 'OpenCode Go', ['OPENCODE_API_KEY']], + ['zai', 'Z.AI Coding Plan', ['ZAI_API_KEY']], + ['zai-coding-cn', 'Z.AI Coding Plan (China)', ['ZAI_CODING_CN_API_KEY']], + ['kimi-coding', 'Kimi For Coding', ['KIMI_API_KEY']], + ['moonshotai', 'Moonshot AI', ['MOONSHOT_API_KEY']], + ['moonshotai-cn', 'Moonshot AI (China)', ['MOONSHOT_API_KEY']], + ['minimax', 'MiniMax', ['MINIMAX_API_KEY']], + ['minimax-cn', 'MiniMax (China)', ['MINIMAX_CN_API_KEY']], + ['nvidia', 'NVIDIA NIM', ['NVIDIA_API_KEY']], + ['baseten', 'Baseten', ['BASETEN_API_KEY']], + ['meta', 'Meta', ['META_API_KEY']], + ['qwen-token-plan', 'Qwen Token Plan', ['QWEN_TOKEN_PLAN_API_KEY']], + ['qwen-token-plan-individual', 'Qwen Token Plan (Individual)', ['QWEN_TOKEN_PLAN_API_KEY']], + ['qwen-token-plan-cn', 'Qwen Token Plan (China)', ['QWEN_TOKEN_PLAN_CN_API_KEY']], + ['xiaomi', 'Xiaomi MiMo', ['XIAOMI_API_KEY']], + ['xiaomi-token-plan-cn', 'Xiaomi MiMo Token Plan (China)', ['XIAOMI_TOKEN_PLAN_CN_API_KEY']], + ['xiaomi-token-plan-ams', 'Xiaomi MiMo Token Plan (Amsterdam)', ['XIAOMI_TOKEN_PLAN_AMS_API_KEY']], + ['xiaomi-token-plan-sgp', 'Xiaomi MiMo Token Plan (Singapore)', ['XIAOMI_TOKEN_PLAN_SGP_API_KEY']], + ['radius', 'Radius', ['RADIUS_API_KEY']], + ['typesafe', 'TypeSafe', ['TYPESAFE_API_KEY']], + ['ant-ling', 'Ant Ling', ['ANT_LING_API_KEY']], +].map(([id, name, env]) => Object.freeze({ id, name, env: Object.freeze(env) }))); + +// Providers whose sign-in is a subscription made with /login in Pi. The key +// list names most of them already; these are the ones it does not. +export const PI_SUBSCRIPTION_ONLY = Object.freeze([ + Object.freeze({ id: 'github-copilot', name: 'GitHub Copilot', env: Object.freeze(['COPILOT_GITHUB_TOKEN']) }), + Object.freeze({ id: 'openai-codex', name: 'ChatGPT (legacy sign-in)', env: Object.freeze([]) }), +]); + +/** Every variable Pi reads a provider credential from. */ +export const PI_CREDENTIAL_ENV = Object.freeze([...new Set([...PI_KEY_PROVIDERS, ...PI_SUBSCRIPTION_ONLY].flatMap((provider) => provider.env))]); diff --git a/docker/harness/probe.mjs b/docker/harness/probe.mjs index 37bf397..26b0f3d 100644 --- a/docker/harness/probe.mjs +++ b/docker/harness/probe.mjs @@ -31,6 +31,7 @@ export async function probeVersion(ctx, entry, executable) { export { openCodeMajor, parseAuthList as parseOpenCodeAuthList, storedCredentials as openCodeStoredCredentials, storedIntegrationIds as openCodeStoredIntegrationIds, hasStoredCredentials as openCodeHasStoredCredentials } from './opencode-auth.mjs'; import { openCodeMajor, parseAuthList, hasStoredCredentials, readLegacyAuth } from './opencode-auth.mjs'; +import { parsePiModelList } from './pi-auth.mjs'; /** Which credential sources exist for a harness: environment keys and files. */ export async function credentialSurface(ctx, entry) { @@ -111,6 +112,17 @@ export async function detectAuth(ctx, entry, { executable, runnable, version = n return null; } } + case "pi": { + // Pi has no sign-in status command before 0.84.1, and that one needs a + // provider. Its model list covers every source: saved keys, /login + // subscriptions, the environment, models.json and extensions. T3 Code + // uses the same list to decide Pi can start a turn. Offline, so the + // probe never refreshes Pi's model catalogue. + if (!runnable) return false; + const result = await probeCommand(ctx, executable, ["--list-models"], { PI_OFFLINE: "1" }); + if (result.error) return null; + return parsePiModelList(result.combined).available; + } default: return null; } @@ -120,9 +132,9 @@ function hasEnv(ctx, key) { return Boolean(ctx.env[key]?.trim?.()); } -async function probeCommand(ctx, executable, args) { +async function probeCommand(ctx, executable, args, env = null) { const result = await ctx.run([executable, ...args], { - env: ctx.env, + env: env ? { ...ctx.env, ...env } : ctx.env, cwd: ctx.home, timeoutMs: ctx.timeouts.probe, }); diff --git a/docker/setup/app.js b/docker/setup/app.js index 6d1f5d3..7e293f8 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -131,6 +131,10 @@ portsBusy, expandedPorts, providerNames: [], + // Pi's providers with a credential, saved or from the environment, and + // how many of them auth.json holds. + piNames: [], + piSaved: 0, signingIn: null, // A source control CLI being signed out, while its request runs. signingOut: null, @@ -230,6 +234,7 @@ storageUI.observe(res.data.storage); trackPairing(res.data); loadProvidersOnce(); + loadPiProvidersOnChange(); tellAttention(); checkHere(); if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate(); @@ -330,6 +335,41 @@ loadProviders().then(render); }; + // Pi's row names the providers it is signed in to, and its sheet lists + // them. A /login in a terminal changes them without Setup, so read them + // again whenever the sign-in verdict changes. + let piPending = null, piSeen, piRead = 0; + // A read started before a save or removal must not land after it. + const loadPiProviders = ({ fresh = false } = {}) => { + if (piPending && !fresh) return piPending; + const read = ++piRead; + piPending = (async () => { + const res = await api('/providers?agent=pi'); + if (read !== piRead) return state.piProviders; + piPending = null; + if (res.ok) { + state.piProviders = res.data; + const names = res.data.names || {}; + const credentials = res.data.credentials || {}; + const ids = [...new Set([...(credentials.saved || []).map((entry) => entry.provider), ...(credentials.env || []).map((entry) => entry.provider)])]; + ui.piNames = ids.map((id) => names[id] || id).sort((a, b) => a.localeCompare(b)); + ui.piSaved = (credentials.saved || []).length; + } else { + state.piProviders = { ...(state.piProviders || {}), credentials: { ...(state.piProviders?.credentials || {}), error: res.error || 'Pi’s saved sign-ins could not be read.' } }; + } + return state.piProviders; + })(); + return piPending; + }; + const loadPiProvidersOnChange = () => { + const pi = harness('pi'); + const seen = pi && pi.installed ? String(pi.signedIn) + ':' + (pi.installedVersion || pi.version || '') : null; + if (seen === piSeen) return; + piSeen = seen; + if (seen === null) { ui.piNames = []; ui.piSaved = 0; return; } + loadPiProviders().then(() => { render(); renderKeySheet(); }); + }; + // ------------------------------------------------------------ operations -- /** A click accepted earlier has finished: say how, once. */ const settleOperations = (s) => { @@ -2426,6 +2466,12 @@ // ------------------------------------------------------------- API keys -- const keySheet = { layer: null, agent: null, query: '', provider: null, other: false, reveal: false, saving: false, removing: null, error: null, active: 0 }; + // OpenCode and Pi take a key per provider, from their own catalogues; the + // other agents take one key on stdin. + const perProvider = (agent) => agent === 'opencode' || agent === 'pi'; + const keyCatalog = () => (keySheet.agent === 'pi' ? state.piProviders : state.providers) || {}; + const keyProviderName = (id) => ((keyCatalog().providers || []).find((p) => p.id === id) || {}).name || (keyCatalog().names || {})[id] || id; + const piSavedType = (id) => ((keyCatalog().credentials?.saved || []).find((entry) => entry.provider === id) || {}).type || null; const HUES = ['--id-claude', '--id-codex', '--id-opencode', '--id-grok', '--id-cursor']; const hueOf = (id) => HUES[[...String(id)].reduce((a, c) => (a + c.charCodeAt(0)) % HUES.length, 0)]; const initials = (name) => { @@ -2433,7 +2479,7 @@ return (words.length > 1 ? words[0][0] + words[1][0] : String(name).slice(0, 1)).toUpperCase(); }; const providerMatches = () => { - const list = (state.providers && state.providers.providers) || []; + const list = keyCatalog().providers || []; const q = keySheet.query.trim().toLowerCase(); if (!q) return list.slice(0, 40).map((p) => ({ p, at: -1 })); const scored = []; @@ -2450,22 +2496,29 @@ const keySheetBody = () => { const meta = M.AGENTS[keySheet.agent] || {}; - const isOpenCode = keySheet.agent === 'opencode'; - const providers = state.providers || {}; + const isOpenCode = keySheet.agent === 'opencode', isPi = keySheet.agent === 'pi'; + const providers = keyCatalog(); const configured = new Set(providers.configured || []); const names = new Map((providers.providers || []).map((p) => [p.id, p.name])); const chosen = keySheet.provider; - const credentials = providers.credentials || (state.providers ? {} : { available: false, refreshing: true }); + const loaded = isPi ? state.piProviders : state.providers; + const credentials = providers.credentials || (loaded ? {} : { available: false, refreshing: true }); const addsAccount = isOpenCode && /^v?2\./.test(harness('opencode')?.version || ''); + const chosenType = isPi && chosen ? piSavedType(chosen) : null; + const keyHint = addsAccount ? 'Adds and selects a new saved account. Other accounts are kept.' + : chosenType === 'oauth' ? 'Replaces your ' + keyProviderName(chosen) + ' subscription sign-in.' + : chosenType ? 'Replaces the saved ' + keyProviderName(chosen) + ' key.' + : isPi ? 'Saved for Pi only. New Pi sessions use it.' : 'Stored on the state volume, never in the image.'; const disabled = keySheet.saving ? raw(' disabled') : ''; const keyStep = (n, active) => html`
${String(n)}
- ${keySheet.error ? html`` : html`${addsAccount ? 'Adds and selects a new saved account. Other accounts are kept.' : 'Stored on the state volume, never in the image.'}`}
`; + ${keySheet.error ? html`` : html`${keyHint}`}`; let steps; - if (isOpenCode) { + if (isOpenCode || isPi) { const matches = providerMatches(); const cache = providers.cache || {}; - const freshness = cache.source === 'fallback' || !cache.at + const freshness = isPi ? (providers.providers || []).length + ' providers that take a key. Choose Other for one Pi added later.' + : cache.source === 'fallback' || !cache.at ? 'A built-in list; the full catalog loads once models.dev is reachable' : ((providers.providers || []).length + ' providers from models.dev · catalog cached ' + M.relTime(cache.at, now())); const options = matches.map(({ p, at }, i) => { @@ -2479,22 +2532,43 @@
${icon('search', 'tc-muted')}
${chosen ? html`Selected: ${names.get(chosen) || chosen} ${chosen}` : freshness}`; + const savedRow = (id, meta, action) => html` +
${keyProviderName(id)}${meta ? html`${meta}` : ''}
${action || ''}
`; + const removeButton = (id) => html``; + // Pi: what auth.json holds, then keys Pi reads from the environment + // for providers without one. Neither ever includes the secret. + const piSaved = () => { + const saved = credentials.saved || [], savedIds = new Set(saved.map((entry) => entry.provider)); + const fromEnv = (credentials.env || []).filter((entry) => !savedIds.has(entry.provider)); + return html`
Signed in to + ${credentials.refreshing ? html`Reading Pi’s sign-ins…` : ''} + ${credentials.error ? html`
${credentials.error}
` : ''} + ${saved.length || fromEnv.length ? html`
${saved.map((entry) => savedRow(entry.provider, entry.type === 'oauth' ? 'Subscription' : 'API key', removeButton(entry.provider)))}${fromEnv.map((entry) => savedRow(entry.provider, html`From ${entry.variable}`))}
` + : credentials.available !== false && !credentials.refreshing && !credentials.error ? html`No sign-ins yet.` : ''} +
`; + }; + // Subscriptions sign in inside Pi's own terminal app; Pi has no + // command for it that Setup could run. + const piSubscription = () => html`
Or sign in with a subscription + Claude Pro or Max, ChatGPT Plus or Pro, GitHub Copilot and other subscriptions sign in inside Pi. In a terminal on this server, such as T3 Code’s, run this command, then type /login and choose your subscription. + ${copyField(meta.command || 't3-login pi', { actions: copyButton(meta.command || 't3-login pi', 'Copy command', { iconOnly: true }) })} +
`; steps = html`
${chosen ? icon('check') : '1'}
${providerStep}
${keyStep(2, Boolean(chosen))} -
Saved sign-ins + ${isPi ? html`${piSubscription()}${piSaved()}` : html`
Saved sign-ins ${credentials.refreshing ? html`Checking saved sign-ins…` : ''} ${credentials.error ? html`
Could not check saved sign-ins. ${credentials.available ? 'The last checked providers are shown.' : ''}
` : ''} ${configured.size ? html`
${[...configured].map((id) => html`
${names.get(id) || id}${credentials.counts?.[id] ? html`${credentials.counts[id]} saved ${credentials.counts[id] === 1 ? 'account' : 'accounts'}` : ''}
`)}
` : credentials.available !== false && !credentials.refreshing && !credentials.error ? html`No saved sign-ins yet.` : ''} Remove clears every saved account for that provider. -
`; +
`}`; } else { steps = keyStep(1, true); } return html` -
${tile(meta, 'lg')}

${isOpenCode ? 'Add a provider key' : 'Use an API key'}

${isOpenCode ? html`Stored with OpenCode on the volume` : html`Runs ${meta.keyCommand}; the key goes in on stdin`}
+
${tile(meta, 'lg')}

${isOpenCode ? 'Add a provider key' : isPi ? 'Sign in to Pi' : 'Use an API key'}

${isOpenCode ? html`Stored with OpenCode on the volume` : isPi ? html`Keys are saved in Pi’s auth.json` : html`Runs ${meta.keyCommand}; the key goes in on stdin`}
${steps}
`; }; @@ -2505,7 +2579,7 @@ if (!panel) return; const key = panel.querySelector('#apikey'); const other = panel.querySelector('#prov-other'); - const provider = keySheet.agent !== 'opencode' || (keySheet.other ? other && other.value.trim() : keySheet.provider); + const provider = !perProvider(keySheet.agent) || (keySheet.other ? other && other.value.trim() : keySheet.provider); const save = panel.querySelector('[data-key="key-save"]'); if (save) save.disabled = keySheet.saving || !(key && key.value.trim() && provider); }; @@ -2528,7 +2602,7 @@ return; } keySheet.provider = id; - const p = ((state.providers && state.providers.providers) || []).find((x) => x.id === id); + const p = (keyCatalog().providers || []).find((x) => x.id === id); const input = keySheet.layer.panel.querySelector('#prov'); if (input && p) input.value = p.name; keySheet.query = p ? p.name : ''; @@ -2538,7 +2612,7 @@ if (key) key.focus(); }; - const openKeySheet = async (id, trigger) => { + const openKeySheet = async (id, trigger, { subscription = false } = {}) => { if (keySheet.layer) return; Object.assign(keySheet, { agent: id, query: '', provider: null, other: false, reveal: false, saving: false, removing: null, error: null, active: 0 }); keySheet.layer = Kit.open({ @@ -2547,13 +2621,24 @@ returnTo: trigger, backdrop: 'ignore', render: keySheetBody, - focus: id === 'opencode' ? '#prov' : '#apikey', + focus: perProvider(id) ? '#prov' : '#apikey', onEscape: () => { if (!keySheet.saving) keySheet.layer?.close('cancel'); }, onClose: () => { keySheet.layer = null; }, }); syncKeySave(); const layer = keySheet.layer, panel = layer.panel; if (id === 'opencode') void loadProviders().then(() => { if (keySheet.layer === layer) renderKeySheet(); }); + // "Sign in with a subscription" opens on its terminal steps, and stays + // there when the sign-ins arrive and the sheet renders again. + const showSubscription = () => { + const part = subscription && panel.querySelector('#pi-subscription'); + if (!part) return; + part.querySelector('[data-cmd="copy"]')?.focus({ preventScroll: true }); + part.scrollIntoView({ block: 'nearest' }); + }; + if (id === 'pi') void loadPiProviders().then(() => { if (keySheet.layer === layer) { renderKeySheet(); showSubscription(); } }); + showSubscription(); + panel.addEventListener('pointerdown', () => { subscription = false; }, { once: true }); panel.addEventListener('input', (e) => { if (e.target.id === 'prov') { keySheet.query = e.target.value; @@ -2592,21 +2677,27 @@ if (what === 'close') keySheet.layer.close('cancel'); else if (what === 'reveal') { keySheet.reveal = !keySheet.reveal; renderKeySheet(); } else if (what === 'list') { keySheet.other = false; renderKeySheet(); } - else if (what === 'retry-providers') { await loadProviders(true); if (keySheet.layer === layer) renderKeySheet(); } + else if (what === 'retry-providers') { await (keySheet.agent === 'pi' ? loadPiProviders({ fresh: true }) : loadProviders(true)); if (keySheet.layer === layer) renderKeySheet(); } else if (what === 'remove') { const provider = action.getAttribute('data-provider'); - const label = ((state.providers.providers || []).find((p) => p.id === provider) || {}).name || provider; - const ok = await Kit.confirm({ + const label = keyProviderName(provider); + const agent = keySheet.agent; + const ok = await Kit.confirm(agent === 'pi' ? { + title: 'Remove the ' + label + ' sign-in?', + body: (piSavedType(provider) === 'oauth' ? 'Signs Pi out of your ' + label + ' subscription on this server.' : 'Deletes the saved ' + label + ' key from Pi.') + + ' Other providers and keys in the environment stay in place.', + confirm: 'Remove sign-in', + } : { title: 'Remove ' + label + ' sign-ins?', body: 'Removes all saved OpenCode accounts for ' + label + ', including API keys and browser sign-ins. Other providers and environment credentials stay in place.', confirm: 'Remove sign-ins', }); if (!ok || keySheet.layer !== layer) return; keySheet.saving = true; keySheet.removing = provider; renderKeySheet(); - const res = await api('/auth/apikey/remove', { body: { agent: 'opencode', provider } }); - if (!res.ok) Kit.toast('Could not remove saved sign-ins', { tone: 'danger', detail: res.error }); - else Kit.toast('Removed saved ' + label + ' sign-ins'); - await loadProviders(true); loadStatus(); + const res = await api('/auth/apikey/remove', { body: { agent, provider } }); + if (!res.ok) Kit.toast(agent === 'pi' ? 'Could not remove the sign-in' : 'Could not remove saved sign-ins', { tone: 'danger', detail: res.error }); + else Kit.toast(agent === 'pi' ? 'Removed the ' + label + ' sign-in from Pi' : 'Removed saved ' + label + ' sign-ins'); + await (agent === 'pi' ? loadPiProviders({ fresh: true }) : loadProviders(true)); loadStatus(); if (keySheet.layer !== layer) return; keySheet.saving = false; keySheet.removing = null; renderKeySheet(); } @@ -2617,13 +2708,31 @@ const agent = keySheet.agent; const key = panel.querySelector('#apikey').value.trim(); const other = panel.querySelector('#prov-other'); - const provider = keySheet.agent === 'opencode' ? (keySheet.other ? other && other.value.trim() : keySheet.provider) : undefined; - if (!key || (keySheet.agent === 'opencode' && !provider)) return; + const provider = perProvider(agent) ? (keySheet.other ? other && other.value.trim() : keySheet.provider) : undefined; + if (!key || (perProvider(agent) && !provider)) return; + // Pi keeps one sign-in per provider: a key replaces a subscription. + const confirmReplace = () => Kit.confirm({ + title: 'Replace the ' + keyProviderName(provider) + ' subscription?', + body: 'Pi keeps one sign-in per provider. Saving this key signs Pi out of your ' + keyProviderName(provider) + ' subscription on this server.', + confirm: 'Replace with key', + }); + let replace = false; + if (agent === 'pi' && piSavedType(provider) === 'oauth') { + if (!(await confirmReplace()) || keySheet.layer !== layer) return; + replace = true; + } keySheet.saving = true; keySheet.error = null; renderKeySheet(); - const res = await api('/auth/apikey', { body: { agent, key, provider } }); + let res = await api('/auth/apikey', { body: { agent, key, provider, ...(replace ? { replace } : {}) } }); + // A /login elsewhere since the sheet opened: ask now, then save. + if (!res.ok && res.data?.code === 'subscription-exists' && keySheet.layer === layer) { + await loadPiProviders({ fresh: true }); + if (await confirmReplace() && keySheet.layer === layer) res = await api('/auth/apikey', { body: { agent, key, provider, replace: true } }); + else res = { ok: false, error: 'The key was not saved. Your subscription sign-in is unchanged.' }; + } if (agent === 'opencode') await loadProviders(true); + if (agent === 'pi') await loadPiProviders({ fresh: true }); loadStatus(); if (keySheet.layer !== layer) return; keySheet.saving = false; @@ -2634,7 +2743,7 @@ panel.querySelector('#key-error')?.focus({ preventScroll: true }); return; } - const label = provider ? (((state.providers || {}).providers || []).find((p) => p.id === provider) || {}).name || provider : null; + const label = provider ? keyProviderName(provider) : null; keySheet.layer.close('saved'); Kit.toast(label ? 'Saved the ' + label + ' key' : 'API key saved'); @@ -3794,6 +3903,7 @@ 'harness.version': (a, el) => openToolSheet({ id: a.id, target: 'harness', trigger: el }), 'harness.signin': (a, el) => openSignin(a.id, el), 'harness.apikey': (a, el) => openKeySheet(a.id, el), + 'harness.subscription': (a, el) => openKeySheet(a.id, el, { subscription: true }), 'harness.updateAll': () => updateAll('harness'), 'toolchain.install': (a) => callLifecycle('toolchain', 'install', a.id), 'toolchain.update': (a) => callLifecycle('toolchain', 'update', a.id), diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 31e3991..0104212 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -20,7 +20,8 @@ const T3Model = (() => { grok: { name: 'Grok Build', mark: 'grok', mono: 'GB', hue: '--id-grok', how: 'device code', flow: 'device', command: 'grok login --device-auth' }, cursor: { name: 'Cursor', mark: 'cursor', mono: 'CU', hue: '--id-cursor', how: 'browser sign-in', flow: 'browser', command: 'cursor-agent login' }, muse: { name: 'Muse Code', mark: 'muse', mono: 'MC', hue: '--id-toolchain', how: 'device code', flow: 'device', command: 'muse login', keyCommand: 'muse auth set --api-key-stdin', requiresNightly: true }, - pi: { name: 'Pi', mono: 'Pi', hue: '--id-toolchain', how: 'terminal /login', flow: 'terminal', command: 't3-login pi' }, + // Keys per provider from Setup; subscriptions with /login inside Pi. + pi: { name: 'Pi', mark: 'pi', mono: 'Pi', hue: '--id-toolchain', how: 'API key or subscription', flow: 'key', subscription: true, command: 't3-login pi' }, // Installed and signed in by T3 Code itself (managedBy: 't3'). antigravity: { name: 'Antigravity', mark: 'antigravity', mono: 'AG', hue: '--id-toolchain', how: 'Google sign-in', flow: 'redirect', command: null }, }; @@ -412,24 +413,30 @@ const T3Model = (() => { } else if (h.signedIn === false) { row.state = 'signin'; row.attention = true; - row.status = { dot: 'warn', text: isKey ? 'No provider key yet' : 'Not signed in' }; + row.status = { dot: 'warn', text: isKey && !meta.subscription ? 'No provider key yet' : 'Not signed in' }; + // Pi's sheet takes a key or explains its subscription sign-in. row.action = isKey - ? { cmd: 'harness.apikey', label: 'Add key', icon: 'key-round', variant: 'primary' } + ? { cmd: 'harness.apikey', label: meta.subscription ? 'Sign in' : 'Add key', icon: meta.subscription ? 'log-in' : 'key-round', variant: 'primary' } : { cmd: 'harness.signin', label: 'Sign in', icon: 'log-in', variant: 'primary' }; } else if (h.signedIn !== true) { row.state = 'unknown'; row.status.text = h.id === 'muse' && h.credentialsPresent ? 'Credentials saved, checked when you first use it' : 'Couldn’t check its sign-in'; - if (h.id === 'pi') row.status.text = 'Run t3-login pi, then /login; enable Pi in T3 Settings'; if ((h.id === 'muse' || h.id === 'pi') && updateAvailable) row.action = { cmd: 'harness.update', label: 'Update' }; } else { row.state = updateAvailable ? 'update' : 'ok'; // The version beside the name says when there is a newer one; the // status says only how it is signed in. - const names = (ui && ui.providerNames) || []; - row.status.text = isKey && names.length ? (names.length === 1 ? 'Key saved for ' : 'Keys saved for ') + listOf(names) : signedInAs(h); + const names = (ui && (h.id === 'pi' ? ui.piNames : ui.providerNames)) || []; + row.status.text = !isKey || !names.length ? signedInAs(h) + : meta.subscription ? 'Signed in to ' + listOf(names) + : (names.length === 1 ? 'Key saved for ' : 'Keys saved for ') + listOf(names); + // Once a provider key is saved, the same sheet adds, lists and removes + // keys, so the button says so. Pi's environment keys and models.json + // providers are not saved keys: there is nothing to manage for them. + const saved = h.id === 'pi' ? (ui && ui.piSaved) || 0 : names.length; if (updateAvailable) row.action = { cmd: 'harness.update', label: 'Update' }; - else if (isKey) row.action = { cmd: 'harness.apikey', label: 'Add key', icon: 'key-round' }; + else if (isKey) row.action = { cmd: 'harness.apikey', label: saved ? 'Manage keys' : 'Add key', icon: 'key-round' }; } row.notice = distinctNotice(notice, row.status); row.held = null; @@ -437,7 +444,8 @@ const T3Model = (() => { // Everything else is one press away in the menu. if (updateAvailable) row.menu.push({ cmd: 'harness.update', label: 'Update to ' + latest, icon: 'circle-arrow-up' }); row.menu.push({ cmd: 'harness.version', label: 'Install a specific version…', icon: 'history' }); - if (h.canSetKey) row.menu.push({ cmd: 'harness.apikey', label: isKey ? 'Add a provider key…' : 'Use an API key…', icon: 'key-round' }); + if (h.canSetKey) row.menu.push({ cmd: 'harness.apikey', label: meta.subscription ? 'Sign-ins and keys…' : isKey ? 'Add a provider key…' : 'Use an API key…', icon: 'key-round' }); + if (h.canSetKey && meta.subscription) row.menu.push({ cmd: 'harness.subscription', label: 'Sign in with a subscription…', icon: 'log-in' }); if (h.canSignIn && h.runnable && row.state !== 'signin') row.menu.push({ cmd: 'harness.signin', label: 'Sign in again', icon: 'log-in' }); row.menu.push({ sep: true }); row.menu.push({ cmd: 'harness.uninstall', label: 'Uninstall…', icon: 'trash-2', danger: true }); @@ -1676,7 +1684,7 @@ const T3Model = (() => { // Words people reach for that the label does not use ("login", "expose", // "log out"): a query matching only these still finds the item. const KEYWORDS = { - 'harness.signin': 'login log in auth authenticate', 'harness.apikey': 'api key token credential provider', + 'harness.signin': 'login log in auth authenticate', 'harness.apikey': 'api key token credential provider', 'harness.subscription': 'login log in subscription claude chatgpt copilot', 'harness.install': 'add download', 'harness.uninstall': 'remove delete', 'harness.update': 'upgrade', 'toolchain.install': 'add download', 'toolchain.uninstall': 'remove delete', 'toolchain.update': 'upgrade', 'scm.updateAll': 'upgrade', 'package.add': 'install new tool package mise registry', 'package.install': 'add download', 'package.uninstall': 'remove delete', @@ -1727,7 +1735,8 @@ const T3Model = (() => { if (m.sep || m.cmd === 'harness.update') continue; const label = m.cmd === 'harness.signin' ? 'Sign in ' + a.name + ' again' : m.cmd === 'harness.version' ? 'Install a specific version of ' + a.name + '…' - : m.cmd === 'harness.apikey' ? (a.id === 'opencode' ? 'Add a provider key for OpenCode…' : 'Use an API key for ' + a.name + '…') + : m.cmd === 'harness.apikey' ? (a.id === 'opencode' ? 'Add a provider key for OpenCode…' : a.id === 'pi' ? 'Pi sign-ins and keys…' : 'Use an API key for ' + a.name + '…') + : m.cmd === 'harness.subscription' ? 'Sign in to ' + a.name + ' with a subscription…' : m.cmd === 'harness.uninstall' ? 'Uninstall ' + a.name + '…' : m.label + ' ' + a.name; if (items.some((i) => i.label === label)) continue; add('Agents', label, m.icon, { cmd: m.cmd, id: a.id }, { meta: m.cmd === 'harness.uninstall' ? null : hint }); diff --git a/docker/setup/design/README.md b/docker/setup/design/README.md index c1e5954..c2bb5e8 100644 --- a/docker/setup/design/README.md +++ b/docker/setup/design/README.md @@ -169,7 +169,7 @@ Each of these is a bug in the handoff, not a restyle. Keep them when syncing. Not in the handoff. Keep them when syncing. - **Agent marks.** `T3C.agentMark(id)` draws an agent's own mark (Claude Code, - Codex, OpenCode, Grok, Cursor, Muse Code, Antigravity) as T3 Code's provider settings + Codex, OpenCode, Grok, Cursor, Muse Code, Pi, Antigravity) as T3 Code's provider settings draw it, from `AGENT_MARKS` in `ui.js`, and `.tc-tile--mark` sets it on a neutral tile in place of a monogram. One-colour marks fill with `currentColor` so they follow the theme; Claude keeps its orange, OpenCode's diff --git a/docker/setup/design/ui.js b/docker/setup/design/ui.js index cea8f6c..5467e4c 100644 --- a/docker/setup/design/ui.js +++ b/docker/setup/design/ui.js @@ -29,6 +29,9 @@ // Muse Code: the Meta mark from T3 Code’s Muse provider definition. // Gradient IDs are scoped by agentMark for every instance. AGENT_MARKS.muse = {"viewBox":"0 0 287.56 191","body":""}; + // Pi: the mark from T3 Code's Pi provider definition. One colour, so it + // fills with currentColor and follows the theme. + AGENT_MARKS.pi = {"viewBox":"165.29 165.29 469.43 469.43","body":""}; // Each source control provider's mark, as T3 Code's Source Control settings // draw it. GitHub's fills with currentColor; the rest keep their colours. diff --git a/docker/setup/pi-auth.mjs b/docker/setup/pi-auth.mjs new file mode 100644 index 0000000..2f372c4 --- /dev/null +++ b/docker/setup/pi-auth.mjs @@ -0,0 +1,5 @@ +// Shared Pi credential contract; image setup and harness live in separate /opt trees. +import { existsSync } from 'node:fs'; +const local = new URL('../harness/pi-auth.mjs', import.meta.url); +const shared = await import(existsSync(local) ? local.href : 'file:///opt/t3-harness/pi-auth.mjs'); +export const { PI_KEY_PROVIDERS, createPiAuth, isPiProviderId, piProviderName, piAuthFile } = shared; diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index c358854..411e738 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -34,6 +34,7 @@ import { createT3Sessions } from "./t3-session.mjs"; import { createDeviceLabels } from "./device-labels.mjs"; import * as Context from "./context.mjs"; import { createOpenCodeAuth } from "./opencode-auth.mjs"; +import { createPiAuth, PI_KEY_PROVIDERS, piProviderName } from "./pi-auth.mjs"; import { createSkillsService } from "./skills.mjs"; import { createMcpService } from "./mcp.mjs"; import { clientAddress } from "./client-address.mjs"; @@ -1373,6 +1374,9 @@ const AGENTS = { apiKey: { kind: "stdin", argv: ["muse", "auth", "set", "--api-key-stdin"] }, signin: { argv: ["muse", "login"], pty: false } }, opencode: { name: "OpenCode", apiKey: { kind: "opencode" } }, + // A key per provider in Pi's auth.json. Subscriptions sign in with /login + // inside Pi itself, which has no command Setup could drive. + pi: { name: "Pi", apiKey: { kind: "pi" } }, // Signed in through T3's own Google flow (startAntigravitySignin). antigravity: { name: "Antigravity" }, }; @@ -1597,7 +1601,7 @@ const publicSession = (s) => ({ tail: s.output.trim().split("\n").slice(-4).join("\n"), }); -const setApiKey = async (agentId, key, providerId) => { +const setApiKey = async (agentId, key, providerId, { replaceSubscription = false } = {}) => { const agent = AGENTS[agentId]; if (!agent?.apiKey) throw new Error(`${agentId} does not take a stored API key`); if (!key || key.length > 500) throw new Error("Enter a key"); @@ -1629,6 +1633,12 @@ const setApiKey = async (agentId, key, providerId) => { return result; } + if (agent.apiKey.kind === "pi") { + const result = await piAuth.saveKey(providerId, key, { replaceSubscription }); + recordEvent("signin.ok", `Saved a ${piProviderName(providerId)} key for Pi`); + return result; + } + throw new Error("unsupported"); }; @@ -1638,12 +1648,39 @@ const openCodeAuth = createOpenCodeAuth({ onChange: async () => { openCodeCredentials.invalidate(); await refreshSignInState("opencode"); }, }); +// Pi's auth.json, written under Pi's own lock. A change drops the cached +// sign-in verdict, so the next poll asks Pi again. +const piAuth = createPiAuth({ + home: process.env.HOME || "/home/t3", env: process.env, + onChange: () => refreshSignInState("pi"), +}); + /** Remove all saved sign-ins for one provider; other providers stay. */ const removeApiKey = async (agentId, providerId) => { + if (agentId === "pi") { + const result = await piAuth.removeProvider(providerId); + if (result.removed) recordEvent("signin.out", `Removed the ${piProviderName(providerId)} sign-in from Pi`); + return result; + } if (agentId !== "opencode") throw new Error(`${agentId} keeps no per-provider keys here`); return openCodeAuth.removeProvider(providerId); }; +// Pi's key sheet: the providers it takes a key for, and what is saved now. +// Only provider ids and credential kinds leave the server, never a secret. +const piProviders = async () => { + let saved = [], env = [], error = null; + try { ({ saved, env } = await piAuth.list()); } + catch (failure) { error = String(failure?.message ?? failure); } + return { + providers: PI_KEY_PROVIDERS.map(({ id, name }) => ({ id, name })), + configured: saved.map((entry) => entry.provider), + names: Object.fromEntries([...saved, ...env].map((entry) => [entry.provider, piProviderName(entry.provider)])), + credentials: { available: error === null, refreshing: false, stale: false, error, saved, env }, + cache: { at: null, stale: false, source: "pi", refreshing: false }, + }; +}; + const send = (res, code, body, headers = {}) => { res.writeHead(code, { "cache-control": "no-store", ...headers }); res.end(body); @@ -2913,6 +2950,9 @@ const server = createServer(async (req, res) => { } } + if (req.method === "GET" && route === "/providers" && raw.searchParams.get("agent") === "pi") { + return sendJson(res, 200, await piProviders()); + } if (req.method === "GET" && route === "/providers") { const snap = await providersSnapshot(); if (raw.searchParams.get("refresh") === "1") openCodeCredentials.invalidate(); @@ -2939,9 +2979,9 @@ const server = createServer(async (req, res) => { if (req.method === "POST" && route === "/auth/apikey") { try { const input = JSON.parse((await readBody(req)) || "{}"); - return sendJson(res, 200, await setApiKey(input.agent, input.key, input.provider)); + return sendJson(res, 200, await setApiKey(input.agent, input.key, input.provider, { replaceSubscription: input.replace === true })); } catch (error) { - return sendJson(res, 400, { error: String(error?.message ?? error) }); + return sendJson(res, 400, { error: String(error?.message ?? error), ...(error?.code === "subscription-exists" ? { code: error.code } : {}) }); } } diff --git a/docs/agents.md b/docs/agents.md index 0991506..dedcbc8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -53,9 +53,9 @@ in a comma-separated list; `all` and `agents` include it too. It uses the official `@earendil-works/pi-coding-agent` npm package through mise, with dependency lifecycle scripts disabled by mise's default policy. The image's Node.js meets Pi's Node 22.19 minimum; T3 Code requires Pi 0.80.5 or newer. -Run `docker compose exec -it t3code t3-login pi`, then `/login` to connect a provider. -Enable Pi under T3 Code's **Settings → Providers** and refresh it. Setup does not -probe Pi's provider-specific authentication, so it leaves sign-in status unknown. +**Sign in** on its row saves an API key for a provider, or shows how to use a +subscription (see [Signing agents in](#signing-agents-in-and-keeping-them-up-to-date)). +Enable Pi under T3 Code's **Settings → Providers** and refresh it. Its credentials and sessions in `~/.pi/agent` survive updates and uninstall. Updates from Setup and T3 Code's provider settings use the same managed updater. See [T3 Code's Pi guide](https://github.com/pingdotgg/t3code/blob/main/docs/user/providers-pi.md) @@ -147,7 +147,7 @@ Each agent offers the sign-in methods it supports: | Cursor | browser sign-in, checked until it completes | — | | Grok Build | device code: a URL and a code to confirm | — | | Muse Code | Meta device code: a URL and a code to confirm | stored with `auth set --api-key-stdin` | -| Pi | terminal sign-in: `t3-login pi`, then `/login` | — | +| Pi | subscriptions: `t3-login pi`, then `/login` | choose a provider; saved in Pi's `auth.json` | | Antigravity | Google sign-in through T3 Code: paste back the address you land on | — | **Sign in** opens a sheet that follows the agent's own flow as numbered steps: @@ -173,7 +173,7 @@ both count, as they do for T3 Code: | Grok Build | `grok models`, plus `XAI_API_KEY`, as T3 Code does | | OpenCode | 1.x: its `auth.json`; 2.x: `auth list --format json --standalone` for saved accounts | | Muse Code | saved credentials in `~/.config/muse/auth.json`; sign-in is checked on the first message | -| Pi | no status probe; sign-in stays unknown (terminal `/login`) | +| Pi | `pi --list-models`, offline: signed in when Pi lists any model, as T3 Code decides | | Antigravity | T3 Code's provider status | Codex 0.164.0 changed API-key status from "Logged in using an API key" to @@ -226,6 +226,33 @@ docker compose exec -it t3code t3-login opencode anthropic --method key OpenCode 1 uses its interactive picker instead. If the managed version cannot be checked, extra login arguments are rejected rather than guessed. +Pi keeps one sign-in per provider in `~/.pi/agent/auth.json` (or under +`PI_CODING_AGENT_DIR`): an API key, or a subscription made with `/login`. Pi +has no command that saves a key, so **Save key** writes that file itself. It +takes the same lock Pi takes for its own writes, replaces the file in one step +(through a link, if `auth.json` is one), and keeps every other provider and +any saved extra settings. The picker lists the providers that sign in with a +plain key; **Other** takes any id, such as a custom provider from Pi's +`models.json`. + +Saving a key for a provider you signed in to with a subscription would replace +that sign-in, so the sheet asks first. A key that starts with `!` or contains +`$` is refused, because Pi would run it as a command or read it as a variable. + +Subscriptions (Claude Pro or Max, ChatGPT Plus or Pro, GitHub Copilot and +others) sign in inside Pi, which has no command Setup could run for them. The +sheet gives the command: run `t3-login pi` in a terminal on the server (T3 +Code's terminal works), then type `/login`. **Signed in to** lists what +`auth.json` holds, by provider and kind, plus keys Pi reads from the +environment, such as `OPENROUTER_API_KEY`. It never shows a key. **Remove** +deletes one provider's saved sign-in; keys in the environment stay. + +The row asks Pi for its model list, which includes only providers it holds +credentials for. That covers keys, subscriptions, the environment, custom +providers and extensions, and it is what T3 Code uses to decide Pi can start a +turn. Like the other agents' checks, it confirms that a credential is there, +not that the provider will accept it. + You can also use T3 Code's own provider setup, which opens a terminal on the server with the sign-in command ready to run. Both store credentials in the same place. Revoking a device's session signs that device out; it doesn't diff --git a/docs/media/setup-dark.png b/docs/media/setup-dark.png index 1960de5..138000f 100644 Binary files a/docs/media/setup-dark.png and b/docs/media/setup-dark.png differ diff --git a/docs/media/setup-light.png b/docs/media/setup-light.png index bc83e47..2c01926 100644 Binary files a/docs/media/setup-light.png and b/docs/media/setup-light.png differ diff --git a/scripts/console-preview.mjs b/scripts/console-preview.mjs index 179966b..b464635 100644 --- a/scripts/console-preview.mjs +++ b/scripts/console-preview.mjs @@ -34,7 +34,7 @@ http.createServer(async (req, res) => { catch { res.writeHead(400); res.end(); return; } const result = await credentials.respond(req.method, url, input) || await files.respond(req.method, url, input) || await mcp.respond(req.method, url, input) || storage.respond(req.method, url, input) || api.respond(req.method, url, input) || (url.pathname.endsWith('/status') ? { body: { ...statusFor('skills'), credentials: credentials.status(), storage: storage.status(), context: { ...statusFor('skills').context, mcp: mcp.status() } } } : url.pathname.endsWith('/ports') ? { body: portsFor('ready') } - : url.pathname.endsWith('/providers') ? { body: fixture('providers.json') } + : url.pathname.endsWith('/providers') ? { body: fixture(url.searchParams.get('agent') === 'pi' ? 'pi-providers.json' : 'providers.json') } : { status: 404, body: { ok: false, error: 'This is a read-only fixture preview. No live API is connected.' } }); if (result.body?.etag && req.headers['if-none-match'] === result.body.etag) { res.writeHead(304, { ETag: result.body.etag }); res.end(); return; } res.writeHead(result.status || 200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...result.headers }); diff --git a/scripts/fixtures/console/pi-providers.json b/scripts/fixtures/console/pi-providers.json new file mode 100644 index 0000000..b0f2851 --- /dev/null +++ b/scripts/fixtures/console/pi-providers.json @@ -0,0 +1,181 @@ +{ + "providers": [ + { + "id": "anthropic", + "name": "Anthropic" + }, + { + "id": "openai", + "name": "OpenAI" + }, + { + "id": "google", + "name": "Google Gemini" + }, + { + "id": "openrouter", + "name": "OpenRouter" + }, + { + "id": "deepseek", + "name": "DeepSeek" + }, + { + "id": "xai", + "name": "xAI" + }, + { + "id": "mistral", + "name": "Mistral" + }, + { + "id": "groq", + "name": "Groq" + }, + { + "id": "cerebras", + "name": "Cerebras" + }, + { + "id": "fireworks", + "name": "Fireworks" + }, + { + "id": "together", + "name": "Together AI" + }, + { + "id": "huggingface", + "name": "Hugging Face" + }, + { + "id": "vercel-ai-gateway", + "name": "Vercel AI Gateway" + }, + { + "id": "opencode", + "name": "OpenCode Zen" + }, + { + "id": "opencode-go", + "name": "OpenCode Go" + }, + { + "id": "zai", + "name": "Z.AI Coding Plan" + }, + { + "id": "zai-coding-cn", + "name": "Z.AI Coding Plan (China)" + }, + { + "id": "kimi-coding", + "name": "Kimi For Coding" + }, + { + "id": "moonshotai", + "name": "Moonshot AI" + }, + { + "id": "moonshotai-cn", + "name": "Moonshot AI (China)" + }, + { + "id": "minimax", + "name": "MiniMax" + }, + { + "id": "minimax-cn", + "name": "MiniMax (China)" + }, + { + "id": "nvidia", + "name": "NVIDIA NIM" + }, + { + "id": "baseten", + "name": "Baseten" + }, + { + "id": "meta", + "name": "Meta" + }, + { + "id": "qwen-token-plan", + "name": "Qwen Token Plan" + }, + { + "id": "qwen-token-plan-individual", + "name": "Qwen Token Plan (Individual)" + }, + { + "id": "qwen-token-plan-cn", + "name": "Qwen Token Plan (China)" + }, + { + "id": "xiaomi", + "name": "Xiaomi MiMo" + }, + { + "id": "xiaomi-token-plan-cn", + "name": "Xiaomi MiMo Token Plan (China)" + }, + { + "id": "xiaomi-token-plan-ams", + "name": "Xiaomi MiMo Token Plan (Amsterdam)" + }, + { + "id": "xiaomi-token-plan-sgp", + "name": "Xiaomi MiMo Token Plan (Singapore)" + }, + { + "id": "radius", + "name": "Radius" + }, + { + "id": "typesafe", + "name": "TypeSafe" + }, + { + "id": "ant-ling", + "name": "Ant Ling" + } + ], + "configured": [ + "anthropic", + "openrouter" + ], + "names": { + "anthropic": "Anthropic", + "openrouter": "OpenRouter", + "github-copilot": "GitHub Copilot" + }, + "credentials": { + "available": true, + "refreshing": false, + "stale": false, + "error": null, + "saved": [ + { + "provider": "anthropic", + "type": "oauth" + }, + { + "provider": "openrouter", + "type": "api_key" + } + ], + "env": [ + { + "provider": "github-copilot", + "variable": "COPILOT_GITHUB_TOKEN" + } + ] + }, + "cache": { + "at": null, + "stale": false, + "source": "pi", + "refreshing": false + } +} diff --git a/scripts/lib/console-fixtures.cjs b/scripts/lib/console-fixtures.cjs index d03399c..315bdab 100644 --- a/scripts/lib/console-fixtures.cjs +++ b/scripts/lib/console-fixtures.cjs @@ -126,7 +126,7 @@ const routeConsole = async (page, { status, ports, skillOptions, downloadHTTP = if (request.method() === "POST" && !at("/login")) return route.fulfill({ json: { ok: true } }); if (at("/status")) return route.fulfill({ json: status() }); if (at("/ports")) return route.fulfill({ json: ports() }); - if (at("/providers")) return route.fulfill({ json: fixture("providers.json") }); + if (at("/providers")) return route.fulfill({ json: fixture(url.searchParams.get("agent") === "pi" ? "pi-providers.json" : "providers.json") }); if (at("/credentials")) return route.fulfill({ json: fixture("credentials.json") }); // Allow a native-shell substitution at the context level to run too. return route.fallback(); diff --git a/scripts/opencode-auth-ui-audit.mjs b/scripts/opencode-auth-ui-audit.mjs index fcf2d4e..d3b6533 100644 --- a/scripts/opencode-auth-ui-audit.mjs +++ b/scripts/opencode-auth-ui-audit.mjs @@ -27,7 +27,8 @@ try { await page.goto(f.base + '/' + (embedded ? '?embed=t3&theme=' + theme : '') + '#agents'); await page.getByRole('textbox', { name: 'Setup key', exact: true }).fill('auth-fixture'); await page.getByRole('button', { name: 'Unlock', exact: true }).click(); - await page.getByRole('button', { name: 'Add key', exact: true }).click(); + // The first open races the saved-account read: the label follows it. + await page.locator('[data-cmd="harness.apikey"][data-id="opencode"]').click(); await sheet.waitFor(); await page.getByText('Checking saved sign-ins…', { exact: true }).waitFor(); assert.equal(await page.getByText('No saved sign-ins yet.', { exact: true }).count(), 0); @@ -63,7 +64,7 @@ try { // A later result must not dismiss a newly opened sheet or replace its draft. await page.evaluate(() => { location.hash = '#overview'; }); await sheet.waitFor({ state: 'hidden' }); await page.evaluate(() => { location.hash = '#agents'; }); - await page.getByRole('button', { name: 'Add key', exact: true }).click(); + await page.getByRole('button', { name: 'Manage keys', exact: true }).click(); await page.locator('#apikey').fill('draft-for-the-new-sheet'); const completedSave = page.waitForResponse((response) => response.url().endsWith('/auth/apikey')); await f.mode({}); await completedSave; @@ -80,7 +81,7 @@ try { assert.equal(await page.locator('#apikey').inputValue(), 'draft-for-the-new-sheet'); await f.mode({ failList: true }); await f.request('/providers?refresh=1'); await sheet.getByRole('button', { name: 'Cancel', exact: true }).click(); - await page.getByRole('button', { name: 'Add key', exact: true }).click(); + await page.getByRole('button', { name: 'Manage keys', exact: true }).click(); await page.getByText('Could not check saved sign-ins.', { exact: false }).waitFor(); await page.locator('#apikey').fill('draft-through-retry'); await f.mode({}); await page.getByRole('button', { name: 'Try again', exact: true }).click(); diff --git a/scripts/pi-auth-smoke.mjs b/scripts/pi-auth-smoke.mjs new file mode 100644 index 0000000..dc3cbd4 --- /dev/null +++ b/scripts/pi-auth-smoke.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Pi's credential file, written by Setup and read by the real Pi. Uses a +// disposable home and fake keys: no login, no network, no model request. +// +// node scripts/pi-auth-smoke.mjs /absolute/path/to/pi +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { execFile } from 'node:child_process'; +const { createPiAuth, parsePiModelList, piAuthFile } = await import(process.env.T3_HARNESS_MODULE || new URL('../docker/harness/index.mjs', import.meta.url)); +const [executable] = process.argv.slice(2); +if (!path.isAbsolute(executable || '')) throw new Error('Pass an absolute Pi executable path.'); +const home = await fs.mkdtemp(path.join(os.tmpdir(), 't3-pi-auth-native-')); +const env = { ...process.env, HOME: home, PI_OFFLINE: '1' }; +for (const key of Object.keys(env)) if (/TOKEN|API_KEY|^PI_CODING_AGENT_DIR$/.test(key)) delete env[key]; +// Pi exits non-zero from `auth check` when a provider is not ready; keep its output either way. +const run = (args) => new Promise((resolve) => execFile(executable, args, { env, cwd: home, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }, + (error, stdout, stderr) => resolve({ code: error ? error.code ?? 1 : 0, stdout, stderr }))); +const models = async () => { const result = await run(['--list-models']); return parsePiModelList(result.stdout + '\n' + result.stderr); }; +const compare = (a, b) => a.split('.').map(Number).reduce((order, part, i) => order || part - Number(b.split('.')[i] || 0), 0); +try { + const version = /(\d+\.\d+\.\d+)/.exec((await run(['--version'])).stdout)?.[1]; + assert.ok(version, 'Pi reports its version'); + const auth = createPiAuth({ home, env }); + assert.equal((await models()).available, false, 'a fresh home has no sign-in'); + + // A subscription made with /login elsewhere must survive a saved key. + const file = piAuthFile(home, env); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify({ anthropic: { type: 'oauth', access: 'fixture-access', refresh: 'fixture-refresh', expires: Date.now() + 3600_000 } })); + await auth.saveKey('openrouter', 'sk-or-fixture-key'); + await auth.saveKey('groq', 'gsk-fixture-key'); + const signedIn = await models(); + assert.equal(signedIn.available, true); + for (const provider of ['anthropic', 'openrouter', 'groq']) assert.ok(signedIn.providers.includes(provider), `Pi lists ${provider} models`); + if (compare(version, '0.84.1') >= 0) { + const check = JSON.parse((await run(['auth', 'check', '--provider', 'openrouter', '--json', '--no-refresh'])).stdout); + assert.deepEqual([check.status, check.authType], ['ready', 'api_key'], 'Pi reads the saved key as an API key'); + assert.equal(JSON.parse((await run(['auth', 'check', '--provider', 'anthropic', '--json', '--no-refresh'])).stdout).authType, 'oauth'); + } + assert.equal((await auth.removeProvider('openrouter')).removed, true); + assert.equal((await auth.removeProvider('groq')).removed, true); + assert.deepEqual((await models()).providers, ['anthropic'], 'removal leaves the subscription'); + await auth.removeProvider('anthropic'); + assert.equal((await models()).available, false); + assert.equal((await fs.stat(file)).mode & 0o777, 0o600); + console.log(`Pi ${version}: saved keys beside a subscription, Pi's model list and removal passed.`); +} finally { await fs.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 32a7f2a..af57da6 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -983,6 +983,17 @@ sys.exit(0 if all(h[i]["runnable"] and h[i]["version"] for i in ("claude", "code # Retried: the card's facts refresh on the poll after the last install lands. check "every agent is runnable at a recorded version" "retry 10 agents_runnable" +# Setup writes Pi's auth.json itself (Pi has no command that saves a key), so +# the installed Pi must read those keys back, beside a /login subscription. +pi_auth_native() { + local executable + docker cp scripts/pi-auth-smoke.mjs "$NAME:/tmp/pi-auth-smoke.mjs" >/dev/null 2>&1 || return 1 + executable="$(status_json | jq -r '.harnesses[] | select(.id == "pi") | .executable')" + [ -n "$executable" ] && [ "$executable" != null ] || { printf 'No managed executable returned for pi\n'; return 1; } + docker exec -u t3 -e T3_HARNESS_MODULE=/opt/t3-harness/index.mjs "$NAME" node /tmp/pi-auth-smoke.mjs "$executable" +} +check "keys saved for Pi sit beside subscriptions and the installed Pi reads them" "pi_auth_native >>\"$UI_AUDIT_LOG\" 2>&1" + # Exercise the modules actually copied into this image with its exact managed # executables. All provider settings/skills live in each script's scratch home; # no credential, prompt or model request is needed. @@ -1183,6 +1194,21 @@ opencode_key_stored() { } check "an API key is saved to the selected OpenCode credential store" opencode_key_stored +# Pi's row asks Pi for its models, so a saved key turns the verdict around, +# and removing it turns it back. The key never needs to be valid. +pi_signed_in() { status_json | jq -e '.harnesses[] | select(.id == "pi") | .signedIn == true' >/dev/null; } +pi_signed_out() { status_json | jq -e '.harnesses[] | select(.id == "pi") | .signedIn == false' >/dev/null; } +pi_key_signs_in() { + pi_signed_out || return 1 + auth_post '{"agent":"pi","provider":"openrouter","key":"sk-or-smoke-test-key"}' /auth/apikey | jq -e '.ok' >/dev/null || return 1 + retry 10 pi_signed_in || return 1 + docker exec -u t3 "$NAME" sh -c 'cat "$HOME/.pi/agent/auth.json"' | + jq -e '.openrouter == {"type": "api_key", "key": "sk-or-smoke-test-key"}' >/dev/null || return 1 + auth_post '{"agent":"pi","provider":"openrouter"}' /auth/apikey/remove | jq -e '.removed == true' >/dev/null || return 1 + retry 10 pi_signed_out +} +check "a provider key signs Pi in, and removing it signs Pi out" pi_key_signs_in + check "an unknown agent is refused" \ "auth_post '{\"agent\":\"bogus\",\"key\":\"x\"}' /auth/apikey | grep -q error" diff --git a/tests/console-model.test.mjs b/tests/console-model.test.mjs index 0b0740b..4527ecc 100644 --- a/tests/console-model.test.mjs +++ b/tests/console-model.test.mjs @@ -227,6 +227,10 @@ test("a row shows exactly the one verb its state calls for", () => { const opencode = row(s, "opencode"); assert.equal(opencode.action.label, "Add key", "OpenCode keeps Add key visible"); assert.notEqual(opencode.action.variant, "primary"); + // With a key saved, the same sheet lists and removes keys too. + const managed = row(s, "opencode", { providerNames: ["Anthropic"] }); + assert.deepEqual([managed.action.cmd, managed.action.label], ["harness.apikey", "Manage keys"]); + assert.notEqual(managed.action.variant, "primary"); const grok = row(s, "grok"); assert.equal(grok.state, "signin"); @@ -1524,15 +1528,47 @@ test('Credentials attention feeds Overview, navigation and host badges without c assert.equal(M.credentialState({ type: 'api', test: { code: 'rejected', detail: { status: 401 } } }).label, 'Rejected · HTTP 401'); }); -test("Pi offers an optional install and updates with terminal login guidance", () => { - const at = (overrides) => row(statusWith({ harnesses: [harness("pi", { canSignIn: false, canSetKey: false, signedIn: null, ...overrides })] }), "pi"); - const missing = at({ installed: false, runnable: false, version: null, installedVersion: null }); +test("Pi signs in with a key per provider or a subscription, from one sheet", () => { + const status = (overrides) => statusWith({ harnesses: [harness("pi", { canSignIn: false, canSetKey: true, keyKind: "pi", ...overrides })] }); + const at = (overrides, extra) => row(status(overrides), "pi", extra); + const missing = at({ installed: false, runnable: false, version: null, installedVersion: null, signedIn: null }); assert.equal(missing.name, "Pi"); + assert.equal(missing.mark, "pi"); assert.equal(missing.action.cmd, "harness.install"); assert.equal(missing.attention, false); - const installed = at({ installedVersion: "1.4.0", latestVersion: "1.4.1" }); - assert.equal(installed.action.cmd, "harness.update"); - assert.match(installed.status.text, /t3-login pi.*\/login/); - assert.ok(installed.menu.some(item => item.cmd === "harness.uninstall")); - assert.ok(!installed.menu.some(item => item.cmd === "harness.signin" || item.cmd === "harness.apikey")); + + // Signed out: one primary Sign in that opens the key and subscription sheet. + const signedOut = at({ signedIn: false }); + assert.equal(signedOut.state, "signin"); + assert.equal(signedOut.status.text, "Not signed in"); + assert.deepEqual([signedOut.action.cmd, signedOut.action.label, signedOut.action.variant], ["harness.apikey", "Sign in", "primary"]); + assert.ok(signedOut.menu.some(item => item.cmd === "harness.apikey" && item.label === "Sign-ins and keys…")); + assert.ok(signedOut.menu.some(item => item.cmd === "harness.subscription")); + assert.ok(signedOut.menu.some(item => item.cmd === "harness.uninstall")); + assert.ok(!signedOut.menu.some(item => item.cmd === "harness.signin"), "Pi has no sign-in command Setup can drive"); + + // Signed in: names the providers, saved or from the environment. + const signedIn = at({ signedIn: true }, { piNames: ["Anthropic", "OpenRouter"], piSaved: 2 }); + assert.equal(signedIn.status.text, "Signed in to Anthropic and OpenRouter"); + assert.deepEqual([signedIn.action.cmd, signedIn.action.label], ["harness.apikey", "Manage keys"]); + // A key in the environment is not one the sheet can manage. + assert.equal(at({ signedIn: true }, { piNames: ["OpenAI"], piSaved: 0 }).action.label, "Add key"); + assert.equal(at({ signedIn: true }).status.text, "Signed in", "models.json or an extension can sign Pi in without a named provider"); + assert.equal(at({ signedIn: true, latestVersion: "1.1.0" }).action.cmd, "harness.update"); + + // A probe that could not answer is not "signed out". + const unknown = at({ signedIn: null, latestVersion: "1.1.0" }); + assert.equal(unknown.state, "unknown"); + assert.equal(unknown.status.text, "Couldn’t check its sign-in"); + assert.equal(unknown.attention, false); + assert.equal(unknown.action.cmd, "harness.update"); + assert.equal(at({ signedIn: null }).action, null); + + // OpenCode keeps its own wording. + assert.equal(row(statusWith({ harnesses: [harness("opencode", { signedIn: false, canSetKey: true })] }), "opencode").status.text, "No provider key yet"); + + const labels = plain(M.paletteItems(status({ signedIn: false }), null, ui(), NOW)).map((item) => item.label); + assert.ok(labels.includes("Sign in to Pi")); + assert.ok(labels.includes("Pi sign-ins and keys…")); + assert.ok(labels.includes("Sign in to Pi with a subscription…")); }); diff --git a/tests/pi-auth.test.mjs b/tests/pi-auth.test.mjs new file mode 100644 index 0000000..46ad152 --- /dev/null +++ b/tests/pi-auth.test.mjs @@ -0,0 +1,206 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createPiAuth, parsePiModelList, piAgentDir, piEnvCredentials, PI_KEY_PROVIDERS, PI_CREDENTIAL_ENV, readPiAuth } from '../docker/harness/pi-auth.mjs'; +import { CATALOGUE } from '../docker/harness/catalogue.mjs'; +import { detectAuth } from '../docker/harness/probe.mjs'; +import { authFixture } from './fixtures/opencode-auth.mjs'; + +const tempHome = async (t) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 't3-pi-auth-')); + t.after(() => fs.rm(home, { recursive: true, force: true })); + return home; +}; +const authPath = (home) => path.join(home, '.pi/agent/auth.json'); +const readAuth = async (home) => JSON.parse(await fs.readFile(authPath(home), 'utf8')); +const seed = async (home, value) => { + await fs.mkdir(path.dirname(authPath(home)), { recursive: true }); + await fs.writeFile(authPath(home), typeof value === 'string' ? value : JSON.stringify(value)); +}; +const SUBSCRIPTION = { type: 'oauth', access: 'access-token', refresh: 'refresh-token', expires: 1 }; + +test('a saved key joins auth.json beside subscriptions and other keys, private to the user', async (t) => { + const home = await tempHome(t); + await seed(home, { anthropic: SUBSCRIPTION, openai: { type: 'api_key', key: 'sk-old' } }); + let changes = 0; + const auth = createPiAuth({ home, env: {}, onChange: async () => { changes++; } }); + assert.deepEqual(await auth.saveKey('openrouter', ' sk-or-new '), { ok: true, replaced: null }); + assert.deepEqual(await readAuth(home), { anthropic: SUBSCRIPTION, openai: { type: 'api_key', key: 'sk-old' }, openrouter: { type: 'api_key', key: 'sk-or-new' } }); + assert.equal((await fs.stat(authPath(home))).mode & 0o777, 0o600); + assert.equal(changes, 1); + await assert.rejects(fs.access(authPath(home) + '.lock'), 'the lock is released'); + assert.deepEqual((await fs.readdir(path.dirname(authPath(home)))).sort(), ['auth.json'], 'no temporary file is left behind'); + // Listing names providers and kinds only, never a secret. + const listed = await auth.list(); + assert.deepEqual(listed.saved, [{ provider: 'anthropic', type: 'oauth' }, { provider: 'openai', type: 'api_key' }, { provider: 'openrouter', type: 'api_key' }]); + assert.ok(!JSON.stringify(listed).includes('sk-')); +}); + +test('a key replaces a subscription only when asked, and keeps settings saved with an earlier key', async (t) => { + const home = await tempHome(t); + await seed(home, { anthropic: SUBSCRIPTION, 'cloudflare-workers-ai': { type: 'api_key', key: 'old', env: { CLOUDFLARE_ACCOUNT_ID: 'account' } } }); + const auth = createPiAuth({ home, env: {} }); + await assert.rejects(auth.saveKey('anthropic', 'sk-ant-new'), (error) => error.code === 'subscription-exists' && /subscription/.test(error.message)); + assert.deepEqual((await readAuth(home)).anthropic, SUBSCRIPTION, 'a refused save leaves the sign-in in place'); + assert.deepEqual(await auth.saveKey('anthropic', 'sk-ant-new', { replaceSubscription: true }), { ok: true, replaced: 'oauth' }); + assert.deepEqual((await readAuth(home)).anthropic, { type: 'api_key', key: 'sk-ant-new' }); + assert.deepEqual(await auth.saveKey('cloudflare-workers-ai', 'new'), { ok: true, replaced: 'api_key' }); + assert.deepEqual((await readAuth(home))['cloudflare-workers-ai'], { type: 'api_key', key: 'new', env: { CLOUDFLARE_ACCOUNT_ID: 'account' } }); +}); + +test('keys Pi would run or expand, and malformed input, are refused before anything is written', async (t) => { + const home = await tempHome(t); + const auth = createPiAuth({ home, env: {} }); + for (const key of ['!cat ~/.ssh/id_rsa', 'sk-$HOME', 'sk-${OPENAI_API_KEY}', 'two words', 'line\nbreak', '', ' ', 'x'.repeat(1001)]) { + await assert.rejects(auth.saveKey('openai', key), Error, JSON.stringify(key)); + } + for (const provider of ['', '../evil', 'a b', '-dash', 'x'.repeat(65), null]) { + await assert.rejects(auth.saveKey(provider, 'sk-fine'), /provider/, String(provider)); + } + await assert.rejects(fs.access(authPath(home)), 'nothing was created'); +}); + +test('removing a provider leaves every other sign-in', async (t) => { + const home = await tempHome(t); + await seed(home, { anthropic: SUBSCRIPTION, openai: { type: 'api_key', key: 'sk-old' } }); + const auth = createPiAuth({ home, env: {} }); + assert.deepEqual(await auth.removeProvider('anthropic'), { ok: true, removed: true }); + assert.deepEqual(await readAuth(home), { openai: { type: 'api_key', key: 'sk-old' } }); + const before = await fs.stat(authPath(home)); + assert.deepEqual(await auth.removeProvider('anthropic'), { ok: true, removed: false }); + assert.equal((await fs.stat(authPath(home))).mtimeMs, before.mtimeMs, 'removing nothing writes nothing'); +}); + +test('an unreadable auth.json is reported and never overwritten', async (t) => { + const home = await tempHome(t); + const auth = createPiAuth({ home, env: {} }); + for (const text of ['{broken', '[]', '{"openai":"sk-plain"}']) { + await seed(home, text); + await assert.rejects(auth.saveKey('openai', 'sk-new'), /not valid/); + await assert.rejects(auth.list(), /not valid/); + assert.equal(await fs.readFile(authPath(home), 'utf8'), text); + } + await seed(home, '' + JSON.stringify({ openai: { type: 'api_key', key: 'k' } })); + assert.deepEqual(Object.keys(await readPiAuth({ home, env: {} })), ['openai'], 'a byte order mark is tolerated, as Pi does'); + await seed(home, ''); + assert.deepEqual(await readPiAuth({ home, env: {} }), {}, 'Pi creates the file empty'); +}); + +test('a linked auth.json is written through its link; a dangling link is refused', async (t) => { + const home = await tempHome(t); + const real = path.join(home, 'state/pi-auth.json'); + await fs.mkdir(path.dirname(real), { recursive: true }); + await fs.writeFile(real, JSON.stringify({ openai: { type: 'api_key', key: 'sk-old' } })); + await fs.mkdir(path.dirname(authPath(home)), { recursive: true }); + await fs.symlink(real, authPath(home)); + const auth = createPiAuth({ home, env: {} }); + await auth.saveKey('groq', 'gsk-new'); + assert.ok((await fs.lstat(authPath(home))).isSymbolicLink(), 'the link stays a link'); + assert.deepEqual(Object.keys(JSON.parse(await fs.readFile(real, 'utf8'))).sort(), ['groq', 'openai']); + await fs.rm(real); + await assert.rejects(auth.saveKey('groq', 'gsk-other'), /links to a missing file/); + await assert.rejects(fs.access(real), 'nothing is created at the link target'); +}); + +test('Setup waits for Pi’s lock, gives up on a busy one, and clears an abandoned one', async (t) => { + const home = await tempHome(t); + await seed(home, {}); + const lock = authPath(home) + '.lock'; + let clock = Date.now(); + const lockOptions = { now: () => clock, wait: async (ms) => { clock += ms; } }; + await fs.mkdir(lock); + const auth = createPiAuth({ home, env: {}, lockOptions }); + await assert.rejects(auth.saveKey('openai', 'sk-new'), /saving its sign-ins right now/); + assert.deepEqual(await readAuth(home), {}); + await fs.access(lock); + // proper-lockfile refreshes a held lock; one untouched past Pi's 30 s is abandoned. + const old = new Date(Date.now() - 60_000); + await fs.utimes(lock, old, old); + clock = Date.now(); + await auth.saveKey('openai', 'sk-new'); + assert.equal((await readAuth(home)).openai.key, 'sk-new'); + await assert.rejects(fs.access(lock)); + + // A lock released while Setup waits is taken over. + await fs.mkdir(lock); + const released = { now: () => Date.now(), wait: async () => { await fs.rm(lock, { recursive: true, force: true }); } }; + await createPiAuth({ home, env: {}, lockOptions: released }).saveKey('groq', 'gsk-new'); + assert.equal((await readAuth(home)).groq.key, 'gsk-new'); +}); + +test('PI_CODING_AGENT_DIR moves the file, with ~ meaning the home directory', async (t) => { + const home = await tempHome(t); + assert.equal(piAgentDir(home, {}), path.join(home, '.pi/agent')); + assert.equal(piAgentDir(home, { PI_CODING_AGENT_DIR: '~/profiles/work' }), path.join(home, 'profiles/work')); + assert.equal(piAgentDir(home, { PI_CODING_AGENT_DIR: '/srv/pi' }), '/srv/pi'); + assert.equal(piAgentDir(home, { PI_CODING_AGENT_DIR: 'relative' }), path.join(home, 'relative')); + await createPiAuth({ home, env: { PI_CODING_AGENT_DIR: '~/profiles/work' } }).saveKey('openai', 'sk-work'); + assert.equal(JSON.parse(await fs.readFile(path.join(home, 'profiles/work/auth.json'), 'utf8')).openai.key, 'sk-work'); + await assert.rejects(fs.access(authPath(home))); +}); + +test('environment keys are found the way Pi reads them', () => { + assert.deepEqual(piEnvCredentials({ ANTHROPIC_OAUTH_TOKEN: 't', OPENROUTER_API_KEY: ' ', COPILOT_GITHUB_TOKEN: 'g', UNRELATED: 'x' }), + [{ provider: 'anthropic', variable: 'ANTHROPIC_OAUTH_TOKEN' }, { provider: 'github-copilot', variable: 'COPILOT_GITHUB_TOKEN' }]); + assert.equal(new Set(PI_KEY_PROVIDERS.map((provider) => provider.id)).size, PI_KEY_PROVIDERS.length); + const pi = CATALOGUE.find((entry) => entry.id === 'pi'); + assert.equal(pi.auth, 'pi'); + assert.deepEqual(pi.credentials.env, PI_CREDENTIAL_ENV); + assert.ok(pi.credentials.env.includes('OPENAI_API_KEY') && pi.credentials.env.includes('COPILOT_GITHUB_TOKEN')); +}); + +test('the model list decides sign-in, and output it cannot read decides nothing', () => { + const table = 'provider model context max-out thinking images\nanthropic claude-fable-5 1M 128K yes yes\nopenrouter qwen/qwen3-coder 262K 66K no no\n'; + assert.deepEqual(parsePiModelList(table), { available: true, providers: ['anthropic', 'openrouter'] }); + assert.deepEqual(parsePiModelList('No models available. Use /login to log into a provider via OAuth or API key. See:\n /docs/providers.md'), { available: false, providers: [] }); + assert.equal(parsePiModelList('provider model\n').available, null, 'a header alone is not a verdict'); + assert.equal(parsePiModelList('Error: something else').available, null); + assert.equal(parsePiModelList('').available, null); +}); + +test('the Pi probe asks the managed executable for its models, offline', async () => { + const entry = CATALOGUE.find((item) => item.id === 'pi'); + const calls = []; + const ctx = (result) => ({ home: '/home/t3', env: { HOME: '/home/t3' }, timeouts: { probe: 1000 }, + run: async (argv, options) => { calls.push({ argv, options }); return { code: 0, stdout: '', stderr: '', error: null, ...result }; } }); + const facts = { executable: '/mise/pi', runnable: true, version: '1.4.0' }; + assert.equal(await detectAuth(ctx({ stdout: 'provider model\nopenai gpt-5\n' }), entry, facts), true); + assert.deepEqual(calls[0].argv, ['/mise/pi', '--list-models']); + assert.equal(calls[0].options.env.PI_OFFLINE, '1'); + assert.equal(calls[0].options.env.HOME, '/home/t3'); + assert.equal(await detectAuth(ctx({ stdout: 'No models available. Use /login' }), entry, facts), false); + assert.equal(await detectAuth(ctx({ error: 'timed out', stdout: 'provider model\nopenai gpt-5\n' }), entry, facts), null); + assert.equal(await detectAuth(ctx({ code: 1, stderr: 'Unknown flag' }), entry, facts), null); + assert.equal(await detectAuth(ctx({}), entry, { ...facts, runnable: false }), false); +}); + +test('Setup saves, lists and removes Pi sign-ins over HTTP without returning a secret', { timeout: 20000 }, async (t) => { + const f = await authFixture(); t.after(f.close); + const file = path.join(f.home, '.pi/agent/auth.json'); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify({ anthropic: SUBSCRIPTION })); + assert.equal((await fetch(f.base + '/providers?agent=pi')).status, 401); + const listed = await (await f.request('/providers?agent=pi')).json(); + assert.ok(listed.providers.some((provider) => provider.id === 'openrouter' && provider.name === 'OpenRouter')); + assert.deepEqual(listed.credentials.saved, [{ provider: 'anthropic', type: 'oauth' }]); + assert.equal(listed.names.anthropic, 'Anthropic'); + assert.equal((await f.request('/auth/apikey', { agent: 'pi', provider: 'openrouter', key: 'sk-or-fixture' }, { 'sec-fetch-site': 'cross-site' })).status, 403); + const saved = await f.request('/auth/apikey', { agent: 'pi', provider: 'openrouter', key: 'sk-or-fixture' }); + assert.equal(saved.status, 200); + assert.ok(!(await saved.text()).includes('sk-or-fixture')); + const refused = await f.request('/auth/apikey', { agent: 'pi', provider: 'anthropic', key: 'sk-ant-fixture' }); + assert.equal(refused.status, 400); + assert.equal((await refused.json()).code, 'subscription-exists'); + assert.equal((await f.request('/auth/apikey', { agent: 'pi', provider: 'anthropic', key: 'sk-ant-fixture', replace: true })).status, 200); + const after = await (await f.request('/providers?agent=pi')).json(); + assert.deepEqual(after.credentials.saved, [{ provider: 'anthropic', type: 'api_key' }, { provider: 'openrouter', type: 'api_key' }]); + assert.ok(!JSON.stringify(after).includes('sk-'), 'the catalogue never carries a key'); + const removed = await f.request('/auth/apikey/remove', { agent: 'pi', provider: 'openrouter' }); + assert.deepEqual(await removed.json(), { ok: true, removed: true }); + assert.deepEqual(Object.keys(JSON.parse(await fs.readFile(file, 'utf8'))), ['anthropic']); + const failure = await f.request('/auth/apikey', { agent: 'pi', provider: 'openai', key: '!echo leaked-sentinel' }); + assert.equal(failure.status, 400); + assert.ok(!(await failure.text()).includes('leaked-sentinel')); +});