From 1b5e705531e06116a3f81d97ca66f67caf7eb8ee Mon Sep 17 00:00:00 2001 From: Dragos Dobro <12120601+dragosdobro@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:29:39 +0200 Subject: [PATCH 1/3] feat(tui): show a Fable weekly bar between the session and week bars - New Fbl bar shows Fable weekly usage and time to reset; a dash when there is no data - Appears on terminals 112 columns or wider; narrower screens look as before - Bar widths are now computed from the column layout, so rows no longer overflow - Tests cover the new bar and the width rules --- README.md | 2 +- src/tui.js | 78 +++++++++++++---- test/tui-fable-bar.test.js | 170 +++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 test/tui-fable-bar.test.js diff --git a/README.md b/README.md index 51a2acb..e3658a3 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ maxpool server ``` When running from a TTY, shows an interactive TUI with: -- Account table with session/weekly quota progress bars and reset countdowns +- Account table with session, Fable weekly and weekly quota progress bars and reset countdowns - Real-time activity log with request tracking - Keyboard shortcuts (see below) diff --git a/src/tui.js b/src/tui.js index 578ef53..a28ebb1 100644 --- a/src/tui.js +++ b/src/tui.js @@ -140,8 +140,8 @@ const vw = s => strip(s).length; // ── Accounts-table columns ─────────────────────────────────── // Fixed column widths shared by the header row (acctHeader) AND every data row, so // the header labels stay aligned with the columns they name. The Account/Provider/ -// Status/Quota start offsets (4/25/35/49) are pure functions of these widths + the -// 4-col row prefix, independent of the quota-bar width. +// Status/Rst/Quota start offsets (4/25/35/49/56) are pure functions of these widths + +// the 4-col row prefix, independent of the quota-bar width. const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19) const WHERE_W = 5; // 'Local' / 'Cloud' — where the account is provisioned const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic" @@ -171,8 +171,8 @@ function providerLabel(a) { /** * Aligned column header for the accounts table. Names the three columns that carry - * NO inline label (Account / Type / Status) plus a group label over the two quota - * bars (Quota). The per-bar naming (Ses/Wk for OAuth, Tok/Req for API keys) and the + * NO inline label (Account / Type / Status) plus a group label over the quota bars + * (Quota). The per-bar naming (Ses/Fbl/Wk for OAuth, Tok/Fbl/Req for API keys) and the * load fields (Now/15m/1h) stay inline per row — they're row-type-dependent and * begin at a variable offset — so the header stops after the Quota group label. */ @@ -193,6 +193,35 @@ function lpadVis(s, w) { return gap > 0 ? ' '.repeat(gap) + s : s; } +/** + * Which quota bars fit a terminal W columns wide, and how wide each bar is. The + * space before the first bar comes from the column widths above, never from a + * hard-coded number (the old number went stale when the Rst column was added). + * Each extra bar costs its label gap (" Fbl " or " Wk ") plus one bar width. + * Under 70 columns only the session bar shows, 5 to 20 wide; from 70 the weekly + * bar joins it, both 5 to 20 wide. The Fable bar joins only once all three can be + * 9 wide (112 columns), the narrowest bar that still shows "100% 2d5h", and three + * bars stop growing at 11 wide, so the row never passes column 118 and the tags + * after the bars (cap, prefer, staleness) keep the room they have with two bars. + * Narrower terminals keep today's two bars plus the "Fable NN%" tag. + */ +const BAR_START = ROW_PREFIX.length + NAME_W + 1 + WHERE_W + 1 + PROVIDER_W + 1 + STATUS_W + 1 + RESETS_W + 1 + 'Ses '.length; +const BAR_GAP = ' Wk '.length; +const BAR_MIN_W = 5; +const BAR_MAX_W = 20; +const THREE_BAR_MIN_W = 9; +const THREE_BAR_MAX_W = 11; +function barLayout(W) { + const showBoth = W >= 70; + const showFable = W >= BAR_START + 3 * THREE_BAR_MIN_W + 2 * BAR_GAP; + const bars = 1 + (showBoth ? 1 : 0) + (showFable ? 1 : 0); + const free = W - BAR_START - (bars - 1) * BAR_GAP; + const fit = Math.floor(free / bars); + const maxW = showFable ? THREE_BAR_MAX_W : BAR_MAX_W; + const bw = Math.max(BAR_MIN_W, Math.min(maxW, fit)); + return { showBoth, showFable, bw }; +} + function rpad(s, w) { const gap = w - vw(s); return gap > 0 ? s + ' '.repeat(gap) : s; @@ -541,6 +570,20 @@ function emptyBar(label, w = 10) { return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`; } +/** + * The "Fbl" bar: one model family's weekly limit, which Anthropic counts separately + * from the account-wide weekly. Drawn with the same bar() as Ses and Wk, so it reads + * the same way (used% and time to reset). Shows the empty dash cell when there is + * nothing to show: a provider row, no probe result yet, or a limit Anthropic marks + * as inactive. Routing + * and the "Fable NN%" tag ignore an inactive limit too, so the bar stays quiet as well. + */ +function scopedWeeklyBar(quota, family, w) { + const e = quota?.scopedWeekly?.[family]; + if (!e || e.isActive === false || e.utilization == null) return emptyBar('—', w); + return bar(e.utilization, w, e.resetAt); +} + /** Write a provider's enabled flag into the CONFIG entry `loadConfigProviders` reads. * * A config-sourced provider is re-created from config on every reload @@ -562,7 +605,7 @@ export function applyProviderEnabledToConfig(config, name, enabled) { return { changed: true, previous }; } -export const __tuiTest = { applyProviderEnabledToConfig, formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine, providerLabel, weeklyPolicyText, capText, capEffectivePct }; +export const __tuiTest = { applyProviderEnabledToConfig, formatReset, quotaLabel, bar, emptyBar, scopedWeeklyBar, barLayout, strip, loadText, countdown, acctHeader, fitLine, providerLabel, weeklyPolicyText, capText, capEffectivePct }; function timestamp() { return new Date().toLocaleTimeString('en-US', { hour12: false }); @@ -2433,18 +2476,13 @@ export class TUI { } else { lines.push(''); // Aligned column header (dim + underline so it reads as chrome, not data). It - // labels the fixed columns that have no inline label; the Ses/Wk/Tok/Req and + // labels the fixed columns that have no inline label; the Ses/Fbl/Wk/Tok/Req and // Now/15m/1h labels stay inline per row. It sits DIRECTLY above the rows — the // abbreviation glossary is a FOOTER below the rows (a caption), never between // the header and the data (a left-aligned sentence there reads as a broken, // misaligned second header). lines.push(dimUnderline(acctHeader(W))); - const showBoth = W >= 70; - // 65/54 = the fixed pre-bar column span (prefix + Account + Provider + Status + - // gaps); grew by +4 with the wider 20-col Account column (was 61/50 at NAME_W 16). - const bw = showBoth - ? Math.max(5, Math.min(20, Math.floor((W - 65) / 2))) - : Math.max(5, Math.min(20, W - 54)); + const { showBoth, showFable, bw } = barLayout(W); // Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable // display order over the canonical am.accounts array (which stays untouched so @@ -2457,7 +2495,7 @@ export class TUI { // effectively off (owner 2026-10-29: "should it hide the local ones that are // in the cloud... they're effectively disabled as well"). No separate key. if (this.hideDisabled && this.am.accounts[i]?.poolBenchedBy) { hidden++; continue; } - lines.push(this._renderAcct(i, bw, showBoth)); + lines.push(this._renderAcct(i, bw, showBoth, showFable)); } if (hidden > 0) { lines.push(` ${dim(`… ${hidden} disabled account${hidden === 1 ? '' : 's'} hidden — press h to show`)}`); @@ -2573,7 +2611,7 @@ export class TUI { process.stdout.write(buf); } - _renderAcct(idx, bw, showBoth) { + _renderAcct(idx, bw, showBoth, showFable = showBoth) { let a = this.am.accounts[idx]; // A row benched by the duplicate guard renders the CLOUD's live copy (status, // bars, in-flight, request counts) — never the stale local snapshot. Identity @@ -2842,7 +2880,7 @@ export class TUI { } if (a.type === 'provider') { - return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth); + return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth, showFable); } // Quota ratios — prefer unified (Claude Max), fall back to standard (API key) @@ -2866,6 +2904,9 @@ export class TUI { } let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} ${resetsCell} ${l1} ${bar(r1, bw, t1)}`; + // The Fable bar goes between the session and weekly bars on every row type, so + // the Wk column starts at the same offset on OAuth, API-key and provider rows. + if (showFable) line += ` Fbl ${scopedWeeklyBar(q, 'fable', bw)}`; if (showBoth) { // "no weekly cap on this plan" is a DIFFERENT state from "not read yet", and // both render as an empty bar. Say which, so a healthy uncapped account @@ -2930,7 +2971,9 @@ export class TUI { // so a BUSY oauth account with a header-fresh reading and no probe-only scoped // cap is NOT stale, even if its background probe is 429-throttled. Provider bars // and per-model scoped caps ARE probe-only, so for those a stale probe genuinely - // means stale data → keep flagging. + // means stale data → keep flagging. The Fbl bar shows the Fable reading at any + // level, but a stale low reading changes no routing decision, so this flag keeps + // the same reserve-floor gate as the tag. const q = a.quota || {}; const reserveFloor = this.am.scheduler?.weeklyReserveThreshold ?? 0.85; // mirrors _renderAcct's scoped-tag gate const hasDisplayedScopedCap = Object.values(q.scopedWeekly || {}).some( @@ -2971,7 +3014,7 @@ export class TUI { return interval > 0 ? `every ${formatAge(interval)}` : 'shortly'; } - _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true) { + _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true, showFable = showBoth) { const q = a.quota || {}; // Ses/Wk bars in the SAME columns as OAuth rows (right after status) so the @@ -3030,6 +3073,7 @@ export class TUI { for (const tag of settingsTags(this.am, a)) note += ` ${tag}`; let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} Ses ${sesCell}`; + if (showFable) line += ` Fbl ${scopedWeeklyBar(q, 'fable', bw)}`; if (showBoth) line += ` Wk ${wkCell}`; line += note; // Same recent-load columns as OAuth (providers track inFlight + load events), diff --git a/test/tui-fable-bar.test.js b/test/tui-fable-bar.test.js new file mode 100644 index 0000000..0ccb054 --- /dev/null +++ b/test/tui-fable-bar.test.js @@ -0,0 +1,170 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AccountManager } from '../src/account-manager.js'; +import { TUI, __tuiTest } from '../src/tui.js'; + +const DAY = 24 * 60 * 60 * 1000; +const { strip } = __tuiTest; + +// Neutral fixtures: two Claude accounts and one z.ai provider. +function pool() { + return new AccountManager([ + { name: 'home', type: 'oauth', accessToken: 't1', refreshToken: 'r1', expiresAt: Date.now() + 3600_000 }, + { name: 'work', type: 'oauth', accessToken: 't2', refreshToken: 'r2', expiresAt: Date.now() + 3600_000 }, + { name: 'glm', type: 'provider', provider: 'zai', authToken: 'zt', upstream: 'https://api.z.ai/api/anthropic' }, + ], 0.90); +} + +function fable(am, idx, entry) { + am.applyUsageData(idx, { + fiveHour: { utilization: 0.30, resetAt: Date.now() + 3600_000 }, + sevenDay: { utilization: 0.40, resetAt: Date.now() + 3 * DAY }, + scopedWeekly: { fable: { severity: 'normal', isActive: true, resetAt: Date.now() + 3 * DAY, ...entry } }, + }); +} + +function row(am, idx, bw = 11, showBoth = true, showFable = showBoth) { + return strip(new TUI({ accountManager: am })._renderAcct(idx, bw, showBoth, showFable)); +} + +// The cell that follows a label: the bar text between "Fbl " and the next " Wk". +function fableCell(line) { + const start = line.indexOf('Fbl ') + 4; + return line.slice(start, line.indexOf(' Wk', start)); +} + +// The ask: a third bar, between Ses and Wk, with the Fable weekly used% and reset. + +test('an OAuth row shows a Fbl bar between Ses and Wk with the Fable used% and resets-in', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + const line = row(am, 0); + const ses = line.indexOf('Ses '), fbl = line.indexOf('Fbl '), wk = line.indexOf(' Wk '); + assert.ok(ses >= 0 && fbl > ses && wk > fbl, `Ses, then Fbl, then Wk: ${JSON.stringify(line)}`); + assert.match(fableCell(line), /45% 3d/, 'used% and resets-in, like the other two bars'); +}); + +test('an OAuth row with no Fable reading shows an empty dash cell under Fbl', () => { + const am = pool(); + am.applyUsageData(1, { + fiveHour: { utilization: 0.10, resetAt: Date.now() + 3600_000 }, + sevenDay: { utilization: 0.20, resetAt: Date.now() + 3 * DAY }, + }); + const line = row(am, 1); + assert.match(line, /Fbl /); + assert.equal(fableCell(line).trim(), '—'); +}); + +test('a provider row shows a dash under Fbl and keeps every bar column aligned with the OAuth row', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + am.applyProviderUsage(2, { source: 'zai', ses: { utilization: 0.42, resetAt: Date.now() + 3600_000 } }); + const oauth = row(am, 0), provider = row(am, 2); + assert.equal(fableCell(provider).trim(), '—', 'a non-Anthropic provider has no Fable data'); + for (const label of ['Ses ', 'Fbl ', ' Wk ']) { + assert.equal(provider.indexOf(label), oauth.indexOf(label), `${label.trim()} column aligned`); + } +}); + +test('an exhausted Fable weekly shows 100% in the bar and still the "Fable maxed" tag', () => { + const am = pool(); + fable(am, 0, { utilization: 0.9995, severity: 'critical' }); + const line = row(am, 0); + assert.match(fableCell(line), /100%/); + assert.match(line, /Fable maxed/, 'the existing tag stays'); +}); + +test('a Fable limit Anthropic reports as inactive shows a dash, like no reading (routing ignores it too)', () => { + const am = pool(); + fable(am, 0, { utilization: 0.99, isActive: false }); + const line = row(am, 0); + assert.equal(fableCell(line).trim(), '—'); +}); + +test('a narrow terminal (showBoth=false) shows neither Fbl nor Wk', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + const line = row(am, 0, 11, false); + assert.match(line, /Ses /); + assert.doesNotMatch(line, /Fbl|\bWk\b/); +}); + +test('a mid-width terminal (showBoth, no showFable) shows Ses and Wk but not Fbl', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + const line = row(am, 0, 11, true, false); + assert.match(line, /Ses /); + assert.match(line, / Wk /); + assert.doesNotMatch(line, /Fbl/); +}); + +// Width tiers: the third bar drops out on a narrow terminal the way the second one does. + +test('barLayout: one bar under 70 columns, two from 70, three from 112, three bars capped at 11', () => { + const { barLayout } = __tuiTest; + assert.deepEqual(barLayout(69), { showBoth: false, showFable: false, bw: 5 }); + assert.deepEqual(barLayout(70), { showBoth: true, showFable: false, bw: 5 }); + assert.deepEqual(barLayout(93), { showBoth: true, showFable: false, bw: 7 }); + assert.deepEqual(barLayout(99), { showBoth: true, showFable: false, bw: 10 }); + assert.deepEqual(barLayout(111), { showBoth: true, showFable: false, bw: 16 }); + assert.deepEqual(barLayout(112), { showBoth: true, showFable: true, bw: 9 }); + assert.deepEqual(barLayout(117), { showBoth: true, showFable: true, bw: 10 }); + assert.deepEqual(barLayout(118), { showBoth: true, showFable: true, bw: 11 }); + assert.deepEqual(barLayout(120), { showBoth: true, showFable: true, bw: 11 }); + assert.deepEqual(barLayout(140), { showBoth: true, showFable: true, bw: 11 }); +}); + +test('barLayout: a two-bar row fills the terminal exactly at 95 columns and never overflows it', () => { + const { barLayout } = __tuiTest; + // 89 = the first width where two 5-wide bars fit; 70..88 is the long-standing + // two-bar tier, where the second bar is clipped at the right edge as before. + for (let W = 89; W <= 140; W++) { + const { showFable, bw } = barLayout(W); + const rowWidth = 73 + bw + (showFable ? 6 + bw : 0) + 6 + bw; + assert.ok(rowWidth <= W, `W=${W}: bars end at column ${rowWidth}`); + // Three bars never take more of the line than two bars could (119 columns), so + // the tags after the bars keep their room on a wide terminal. + if (showFable) assert.ok(rowWidth <= 119, `W=${W}: three bars end at column ${rowWidth}`); + } + assert.equal(73 + 2 * barLayout(95).bw + 6, 95); +}); + +// Capture the full-screen paint at a given terminal size, stripped of ANSI. +function frameAt(tui, columns) { + const saved = { columns: process.stdout.columns, rows: process.stdout.rows, write: process.stdout.write }; + let buf = ''; + process.stdout.columns = columns; process.stdout.rows = 24; + process.stdout.write = s => { buf += s; return true; }; + try { tui._render(); } finally { Object.assign(process.stdout, saved); } + return strip(buf).split(/\r?\n/); +} + +// The account rows of a full frame (an account with no reading yet renders Tok/Req, not Ses). +function frameRows(am, columns) { + const tui = new TUI({ accountManager: am, config: { proxy: { port: 3456 } } }); + const lines = frameAt(tui, columns); + return lines.filter(l => /^ {4}(home|work|glm) /.test(l)); +} + +test('a full frame at 112 columns shows three bars on every account row, each row fitting the width', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + const rows = frameRows(am, 112); + assert.equal(rows.length, 3, 'one row per account'); + for (const l of rows) { + assert.match(l, /(Ses|Tok) .*Fbl .* (Wk|Req) /, 'three bars in order'); + assert.ok(l.length <= 112, `fits 112 columns: ${l.length}`); + } + assert.match(rows[0], /Fbl\s+45% 3d/, 'the used% is readable at the first three-bar width'); +}); + +test('a full frame at 100 columns keeps the two-bar layout', () => { + const am = pool(); + fable(am, 0, { utilization: 0.45 }); + const rows = frameRows(am, 100); + assert.equal(rows.length, 3); + for (const l of rows) { + assert.match(l, /(Ses|Tok) .* (Wk|Req) /); + assert.doesNotMatch(l, /Fbl/); + } +}); From fd5371721ce217cedd41dc6825d1f4cdd54ab609 Mon Sep 17 00:00:00 2001 From: Dragos Dobro <12120601+dragosdobro@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:41:47 +0200 Subject: [PATCH 2/3] feat(accounts): keep two organizations of one Claude login as separate accounts - Accounts are told apart by user and organization, so a Team org and a personal org on the same email no longer overwrite each other - Logging in to the other organization adds a second account; the same organization still re-authenticates in place - Older accounts look up their organization with their own token; if that fails, the TUI asks whether to re-authenticate or add - The second account's suggested name is ` ()` - Team pool links, remote logins and duplicate detection compare the organization too - Tests cover the matching on every login, save and restore path --- README.md | 1 + src/account-manager.js | 24 ++-- src/cloud-login.js | 1 + src/index.js | 63 +++++----- src/oauth.js | 65 ++++++++++ src/pool-link.js | 7 +- src/server.js | 22 ++-- src/tui.js | 124 ++++++++++++++----- test/account-identity.test.js | 151 +++++++++++++++++++++++ test/pool-link.test.js | 10 ++ test/remote-inject.test.js | 13 ++ test/same-email-accounts.test.js | 185 ++++++++++++++++++++++++++++ test/tui-login-new-to-cloud.test.js | 3 +- test/tui-remote-login.test.js | 14 +++ 14 files changed, 605 insertions(+), 78 deletions(-) create mode 100644 test/account-identity.test.js create mode 100644 test/same-email-accounts.test.js diff --git a/README.md b/README.md index e3658a3..bc2de40 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,7 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server "name": "user@example.com", "type": "oauth", "accountUuid": "...", + "organizationUuid": "...", "accessToken": "sk-ant-oat01-...", "refreshToken": "sk-ant-ort01-...", "expiresAt": 1774384968427 diff --git a/src/account-manager.js b/src/account-manager.js index c103f41..faae00c 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -1,4 +1,4 @@ -import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js'; +import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint, sameUser, sameAccountIdentity } from './oauth.js'; import { computeDuplicates, credentialFp, accountIdentity, remoteAccountIdentity } from './pool-link.js'; import { appendEventLog } from './event-log.js'; import { hourFloor } from './outside-usage.js'; @@ -625,6 +625,7 @@ export class AccountManager { type: acct.type, provider: acct.provider || (acct.type === 'provider' ? 'provider' : 'anthropic'), accountUuid: acct.accountUuid || null, + organizationUuid: acct.organizationUuid || null, credential: acct.accessToken || acct.authToken || acct.apiKey, upstream: acct.upstream || null, authHeader: acct.authHeader || null, @@ -4860,7 +4861,7 @@ export class AccountManager { * fingerprint (same key shape). null = unresolvable (never merged by name). */ capacityIdentity(a) { if (!a) return null; - if (a.remote) return remoteAccountIdentity({ type: a.type, provider: a.provider, accountUuid: a.remoteUuid, keyFingerprint: a.remoteKeyFp }); + if (a.remote) return remoteAccountIdentity({ type: a.type, provider: a.provider, accountUuid: a.remoteUuid, organizationUuid: a.remoteOrganizationUuid, keyFingerprint: a.remoteKeyFp }); return accountIdentity(a); } @@ -5428,6 +5429,7 @@ export class AccountManager { type: acctData.type, provider: acctData.provider || (acctData.type === 'provider' ? 'provider' : 'anthropic'), accountUuid: acctData.accountUuid || null, + organizationUuid: acctData.organizationUuid || null, credential: acctData.accessToken || acctData.authToken || acctData.apiKey, upstream: acctData.upstream || null, authHeader: acctData.authHeader || null, @@ -5689,11 +5691,17 @@ export class AccountManager { return true; } - // Match a saved state entry to a live account by stable identity: prefer the - // account UUID when both have one, otherwise fall back to the name. + /** + * Match a saved state entry to a live account by stable identity: the account + * UUID + organization UUID when both have a UUID, otherwise the name. A saved + * entry without an organization (written before organizations were recorded) + * also matches the same user under the same name, so quota survives the first + * restart after an account learns its organization. + */ _sameIdentity(saved, account) { - if (saved.accountUuid && account.accountUuid) return saved.accountUuid === account.accountUuid; - return saved.name === account.name; + if (!saved.accountUuid || !account.accountUuid) return saved.name === account.name; + return sameAccountIdentity(saved, account) + || (!saved.organizationUuid && sameUser(saved, account) && saved.name === account.name); } /** @@ -5704,7 +5712,7 @@ export class AccountManager { return this.accounts.filter(a => !a.remote).map(a => { const quota = {}; for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f]; - const out = { accountUuid: a.accountUuid, name: a.name, quota }; + const out = { accountUuid: a.accountUuid, organizationUuid: a.organizationUuid, name: a.name, quota }; // OUTSIDE-USAGE flag survives restart (red-team R1.2): a restart is exactly // when a leak's cards are most at risk — remediation rotates keys and // restarts the pool, and the in-memory flag would vanish mid-remediation. @@ -5901,6 +5909,7 @@ export class AccountManager { // persistence, re-auth, disk sync) can never resolve to a mirror row. a.accountUuid = null; a.remoteUuid = ra.accountUuid || null; + a.remoteOrganizationUuid = ra.organizationUuid || null; a.provider = ra.provider || a.provider; a.quota = { ...emptyQuota(), ...(ra.quota || {}) }; a.status = ra.status || 'active'; @@ -6181,6 +6190,7 @@ export class AccountManager { // fingerprint. Without these a linked pool can't see a duplicate Claude login // (live-rehearsal 2026-10-29: status omitted accountUuid → no bench). accountUuid: a.type === 'oauth' ? (a.accountUuid || null) : null, + organizationUuid: a.type === 'oauth' ? (a.organizationUuid || null) : null, keyFingerprint: (a.type === 'provider' && a.credential) ? credentialFp(a.credential) : null, // Plan config (display-only) + this pool's closed cycles per window so a linked // pool can UNION history instead of picking one ledger (bounded: 12 weekly / diff --git a/src/cloud-login.js b/src/cloud-login.js index 13e1f4e..32134a2 100644 --- a/src/cloud-login.js +++ b/src/cloud-login.js @@ -23,6 +23,7 @@ export async function sendLoginToPool({ url, key, name, profile, creds, fetchImp account: { name, type: 'oauth', accountUuid: profile?.accountUuid, + organizationUuid: profile?.organizationUuid, accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt, diff --git a/src/index.js b/src/index.js index 670bf59..28011af 100755 --- a/src/index.js +++ b/src/index.js @@ -42,7 +42,7 @@ net.setDefaultAutoSelectFamilyAttemptTimeout( ); import { Prober } from './prober.js'; import { CapacityLedger } from './capacity-ledger.js'; -import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint, isLoginCancelled } from './oauth.js'; +import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint, isLoginCancelled, sameAccountIdentity, organizationUnknownMatch, resolveOrganizations, suggestAccountName } from './oauth.js'; import { TUI, writeTerminalSync, reclaimRawModeLogged, whenTerminalCaughtUp } from './tui.js'; import { createTerminalModeSwitcher } from './terminal-mode.js'; import { RestartController } from './restart-controller.js'; @@ -939,8 +939,7 @@ async function serverWorkerCommand() { // Pick up any new accounts from disk so index matching stays correct // (only add, don't refresh credentials — we're about to write the authoritative tokens) for (const diskAcct of diskConfig.accounts) { - const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid)) - || config.accounts.some(a => a.name === diskAcct.name); + const known = config.accounts.some(a => sameAccountIdentity(a, diskAcct) || a.name === diskAcct.name); if (!known) { config.accounts.push(diskAcct); accountManager.addAccount(diskAcct); @@ -1423,7 +1422,7 @@ async function serverWorkerCommand() { // Use live tokens from AccountManager (not the stale config.accounts copy). diskConfig.accounts = config.accounts.map(a => { const am = accountManager.accounts.find(candidate => - (a.accountUuid && candidate.accountUuid === a.accountUuid) || candidate.name === a.name + sameAccountIdentity(candidate, a) || candidate.name === a.name ); const live = am ? { ...a, @@ -1432,7 +1431,7 @@ async function serverWorkerCommand() { expiresAt: am.expiresAt, } : a; const diskAcct = diskConfig.accounts.find( - d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name + d => sameAccountIdentity(d, a) || d.name === a.name ); return diskAcct ? { ...diskAcct, ...live } : live; }); @@ -1990,7 +1989,8 @@ async function loginOAuthCommand() { // Cloud-aware: a linked team pool with a usable key may receive the login instead. // Profile FIRST (once): a local match must win over the cloud default. const profile = await fetchProfile(creds.accessToken); - const localMatch = (profile?.accountUuid && config.accounts.find(a => a.type === 'oauth' && a.accountUuid === profile.accountUuid)) + const localMatch = (profile?.accountUuid && config.accounts.find(a => a.type === 'oauth' && sameAccountIdentity(a, profile))) + || (profile?.accountUuid && organizationUnknownMatch(config.accounts, profile)) || (profile?.email && config.accounts.find(a => a.type === 'oauth' && a.name === profile.email)) || null; const { usable, unresolved, error } = await resolveUsablePools(config.pools, async (names) => { @@ -2304,23 +2304,28 @@ async function accountsCommand() { ) ); - // Deduplicate by accountUuid — keep the last (most recently added) entry + // Deduplicate by user + organization — keep the last (most recently added) entry const seen = new Map(); let removed = 0; for (let i = config.accounts.length - 1; i >= 0; i--) { const a = config.accounts[i]; const uuid = profiles[i]?.accountUuid || a.accountUuid; - if (uuid) { - if (seen.has(uuid)) { + const org = profiles[i]?.organizationUuid || a.organizationUuid || ''; + const identity = uuid ? `${uuid}/${org}` : null; + if (identity) { + if (seen.has(identity)) { config.accounts.splice(i, 1); profiles.splice(i, 1); removed++; } else { - seen.set(uuid, i); - // Update stored UUID and name from profile + seen.set(identity, i); + // Update stored UUIDs and name from profile. Two organizations of one user + // share the email, so the name is only taken when no other account has it. if (profiles[i] && !profiles[i].error) { a.accountUuid = profiles[i].accountUuid; - if (profiles[i].email) a.name = profiles[i].email; + a.organizationUuid = profiles[i].organizationUuid; + const email = profiles[i].email; + if (email && !config.accounts.some((other, j) => j !== i && other.name === email)) a.name = email; } } } @@ -2526,30 +2531,31 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown', prefe if (!profileOk) { console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`); } + if (profileOk) await resolveOrganizations(config.accounts.filter(a => a.type === 'oauth'), profile.accountUuid); + const uncertain = profileOk && !name ? organizationUnknownMatch(config.accounts, profile) : null; + if (uncertain) { + name = uncertain.name; + console.log(`Same Claude user as "${name}", and its organization could not be checked: re-authenticating it in place. To add this login as a second account, run again with --name .`); + } if (!name && profile?.email) { - name = profile.email; const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null; if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`); } - if (!name) { - const n = config.accounts.filter(a => a.name.startsWith('account-')).length + 1; - name = `account-${n}`; - } + if (!name) name = suggestAccountName(config.accounts, profile); const account = { name, type: 'oauth', source, accountUuid: profile?.accountUuid || null, + organizationUuid: profile?.organizationUuid || null, accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt, }; - // Deduplicate: match by UUID first, then by name - let idx = profile?.accountUuid - ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid) - : -1; + // Deduplicate: match by user + organization first, then by name + let idx = config.accounts.findIndex(a => sameAccountIdentity(a, account)); if (idx < 0) idx = config.accounts.findIndex(a => a.name === name); if (idx >= 0) { @@ -2567,13 +2573,12 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown', prefe // ── config sync helpers ───────────────────────────────────── /** - * Find a config account entry matching an in-memory account (by UUID, then name). + * Find a config account entry matching an in-memory account (by user + organization, + * then name). */ function findConfigAccount(diskConfig, account) { - if (account.accountUuid) { - const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid); - if (idx >= 0) return idx; - } + const idx = diskConfig.accounts.findIndex(a => sameAccountIdentity(a, account)); + if (idx >= 0) return idx; return diskConfig.accounts.findIndex(a => a.name === account.name); } @@ -2585,9 +2590,7 @@ function findConfigAccount(diskConfig, account) { async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) { let added = 0; for (const diskAcct of diskConfig.accounts) { - const matchByUuid = diskAcct.accountUuid - ? memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid) - : -1; + const matchByUuid = memConfig.accounts.findIndex(a => sameAccountIdentity(a, diskAcct)); const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name); const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1); @@ -2614,7 +2617,7 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) { // Find the corresponding AccountManager entry and update credentials const mgr = accountManager.accounts.find(a => - (diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name + sameAccountIdentity(a, diskAcct) || a.name === diskAcct.name ); if (!mgr) continue; const enabled = diskAcct.enabled !== false; diff --git a/src/oauth.js b/src/oauth.js index afe5a7e..0861055 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -153,6 +153,7 @@ export async function fetchProfile(accessToken) { const data = await res.json(); return { accountUuid: data.account?.uuid, + organizationUuid: data.organization?.uuid, email: data.account?.email, name: data.account?.display_name, orgName: data.organization?.name, @@ -165,6 +166,70 @@ export async function fetchProfile(accessToken) { } } +/** True when both records belong to the same Claude user (account.uuid). */ +export function sameUser(a, b) { + return Boolean(a?.accountUuid) && a.accountUuid === b?.accountUuid; +} + +/** + * True when both records are the same Claude login: the same user in the same + * organization. One user can hold a personal Max org and a Team org under one + * email; each is its own account. Records saved before organizations were + * recorded have no organizationUuid and match each other on the user alone; an + * entry with an org never matches one without, so a rotated single-use refresh + * token can never be written to the wrong entry. + */ +export function sameAccountIdentity(a, b) { + return sameUser(a, b) && (a.organizationUuid || null) === (b.organizationUuid || null); +} + +/** + * The one OAuth entry of the login's user whose organization is still unknown, + * when no entry matches the login exactly; null otherwise. The TUI then asks + * "re-authenticate or add?", the CLI re-authenticates it in place. One shared + * rule for both so they can never drift apart. + */ +export function organizationUnknownMatch(accounts, profile) { + if (accounts.some(a => sameAccountIdentity(a, profile))) return null; + return accounts.find(a => a.type === 'oauth' && sameUser(a, profile) && !a.organizationUuid) || null; +} + +/** + * Fill in organizationUuid on the accounts of one user that lack it, by reading + * each one's profile with its own current access token. Read only: an expired or + * missing token is skipped, never refreshed. `credentialsOf` supplies the live + * token of a running account; by default the stored one is used. Returns the + * accounts still without an organization. + */ +export async function resolveOrganizations(accounts, accountUuid, credentialsOf = a => a) { + const missing = accounts.filter(a => sameUser(a, { accountUuid }) && !a.organizationUuid); + for (const account of missing) { + const organizationUuid = await readOrganization(credentialsOf(account)); + if (organizationUuid) account.organizationUuid = organizationUuid; + } + return missing.filter(a => !a.organizationUuid); +} + +/** The organization uuid behind an unexpired access token (read only), else null. */ +async function readOrganization({ accessToken, expiresAt } = {}) { + if (!accessToken || isTokenExpiringSoon(expiresAt, 0)) return null; + const profile = await fetchProfile(accessToken); + return profile?.organizationUuid || null; +} + +/** + * Default name for a new account: its email, or " ()" when + * another account already carries that email as its name (the same user in + * another organization), or a numbered fallback when the profile has no email. + */ +export function suggestAccountName(accounts, profile) { + const email = profile?.email; + if (!email) return `account-${accounts.filter(a => a.name.startsWith('account-')).length + 1}`; + if (!accounts.some(a => a.name === email)) return email; + const type = String(profile.orgType || '').replace(/^claude_/, ''); + return `${email} (${type || '2'})`; +} + const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const OAUTH_USAGE_BETA = 'oauth-2025-04-20'; diff --git a/src/pool-link.js b/src/pool-link.js index dba0dc0..2925810 100644 --- a/src/pool-link.js +++ b/src/pool-link.js @@ -36,7 +36,8 @@ export function credentialFp(credential) { export function accountIdentity(account) { if (!account) return null; if (account.type === 'oauth') { - return account.accountUuid ? `uuid:${account.accountUuid}` : null; + if (!account.accountUuid) return null; + return account.organizationUuid ? `uuid:${account.accountUuid}:${account.organizationUuid}` : `uuid:${account.accountUuid}`; } if (account.type === 'provider' && account.provider && account.credential) { return `key:${account.provider}:${credentialFp(account.credential)}`; @@ -47,7 +48,7 @@ export function accountIdentity(account) { /** Identity of a REMOTE status row (same key shape as accountIdentity). */ export function remoteAccountIdentity(a) { if (!a) return null; - if (a.type === 'oauth') return a.accountUuid ? `uuid:${a.accountUuid}` : null; + if (a.type === 'oauth') return accountIdentity(a); if (a.type === 'provider' && a.provider && a.keyFingerprint) return `key:${a.provider}:${a.keyFingerprint}`; return null; } @@ -62,7 +63,7 @@ export function remoteIdentities(status) { for (const a of status.accounts) { if (a.enabled === false) continue; // a benched remote row doesn't bench the local copy let id = null; - if (a.type === 'oauth' && a.accountUuid) id = `uuid:${a.accountUuid}`; + if (a.type === 'oauth') id = accountIdentity(a); else if (a.type === 'provider' && a.keyFingerprint) id = `key:${a.provider}:${a.keyFingerprint}`; if (id) out.set(id, { name: a.name, provider: a.provider, type: a.type }); } diff --git a/src/server.js b/src/server.js index ddaac6f..a0e2555 100644 --- a/src/server.js +++ b/src/server.js @@ -9,7 +9,7 @@ import { ProviderThinkingScrubber, scrubProviderResponseBody, scrubEnabledFor, f import { writeFile, mkdir } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { modelFamily } from './oauth.js'; +import { modelFamily, sameAccountIdentity } from './oauth.js'; const HOP_BY_HOP_HEADERS = new Set([ @@ -460,6 +460,7 @@ export function createProxyServer(accountManager, config, hooks = {}) { try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; } const acct = body?.account; if (acct && typeof acct.name === 'string') acct.name = safeText(acct.name); + if (acct && typeof acct.organizationUuid !== 'string') delete acct.organizationUuid; if (!acct || acct.type !== 'oauth' || typeof acct.accountUuid !== 'string' || !acct.accountUuid || typeof acct.refreshToken !== 'string' || !acct.refreshToken || typeof acct.accessToken !== 'string' || !acct.accessToken) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -467,7 +468,7 @@ export function createProxyServer(accountManager, config, hooks = {}) { return; } const who = caller.name || 'legacy-key'; - const amAcct = accountManager.accounts.find(a => a.accountUuid === acct.accountUuid); + const amAcct = accountManager.accounts.find(a => sameAccountIdentity(a, acct)); const tokenFields = { accessToken: acct.accessToken, refreshToken: acct.refreshToken, @@ -485,26 +486,26 @@ export function createProxyServer(accountManager, config, hooks = {}) { if (amAcct.status === 'error') amAcct.status = 'active'; appendEventLog(`[Maxpool] Remote re-auth: "${amAcct.name}" (uuid ${acct.accountUuid.slice(0, 8)}) by ${who}`); } else { - const entry = { name: acct.name || acct.accountUuid.slice(0, 8), type: 'oauth', accountUuid: acct.accountUuid, enabled: true, ...tokenFields }; + const entry = newInjectedEntry(acct, tokenFields); accountManager.addAccount(entry); appendEventLog(`[Maxpool] Remote add: "${entry.name}" (uuid ${acct.accountUuid.slice(0, 8)}) by ${who}`); } try { await atomicConfigUpdate(diskConfig => { - const onDisk = diskConfig.accounts.find(a => a.accountUuid === acct.accountUuid); + const onDisk = diskConfig.accounts.find(a => sameAccountIdentity(a, acct)); if (onDisk) { Object.assign(onDisk, tokenFields); } else { - diskConfig.accounts.push({ name: acct.name || acct.accountUuid.slice(0, 8), type: 'oauth', accountUuid: acct.accountUuid, enabled: true, ...tokenFields }); + diskConfig.accounts.push(newInjectedEntry(acct, tokenFields)); } }); // Keep the in-memory config mirror in step so a later TUI saveConfig // can't clobber the fresh tokens with stale ones. // A NEW row must enter the mirror too: the disk sync adds any disk row whose // uuid the mirror lacks, so a missing entry became a second live row. - const mem = (config.accounts || []).find(a => a.accountUuid === acct.accountUuid); + const mem = (config.accounts || []).find(a => sameAccountIdentity(a, acct)); if (mem) Object.assign(mem, tokenFields); - else if (Array.isArray(config.accounts)) config.accounts.push({ name: acct.name || acct.accountUuid.slice(0, 8), type: 'oauth', accountUuid: acct.accountUuid, enabled: true, ...tokenFields }); + else if (Array.isArray(config.accounts)) config.accounts.push(newInjectedEntry(acct, tokenFields)); } catch (err) { // A single-use credential write is only "ok" when it is DURABLE: the browser // login may have invalidated the previous refresh token, so memory-only means @@ -755,6 +756,13 @@ export function createProxyServer(accountManager, config, hooks = {}) { /** * Relay a request to upstream with no header rewriting — pure passthrough. */ +/** The account a remote login adds: one Claude user in one organization, plus its tokens. */ +function newInjectedEntry(acct, tokenFields) { + const entry = { name: acct.name || acct.accountUuid.slice(0, 8), type: 'oauth', accountUuid: acct.accountUuid, enabled: true, ...tokenFields }; + if (acct.organizationUuid) entry.organizationUuid = acct.organizationUuid; + return entry; +} + async function relayRaw(req, res, upstream) { const bodyChunks = []; for await (const chunk of req) bodyChunks.push(chunk); diff --git a/src/tui.js b/src/tui.js index a28ebb1..348b564 100644 --- a/src/tui.js +++ b/src/tui.js @@ -2,7 +2,7 @@ import { createInterface } from 'node:readline'; import { readFileSync, writeSync } from 'node:fs'; import { isatty } from 'node:tty'; import { homedir } from 'node:os'; -import { fetchProfile, loginOAuth, tokenFingerprint, isLoginCancelled } from './oauth.js'; +import { fetchProfile, loginOAuth, tokenFingerprint, isLoginCancelled, sameUser, sameAccountIdentity, organizationUnknownMatch, resolveOrganizations, suggestAccountName } from './oauth.js'; import { appendEventLog, setConsoleStdoutSuppressed } from './event-log.js'; import { sendLoginToPool } from './cloud-login.js'; import { describeLinkError } from './pool-link.js'; @@ -1203,7 +1203,7 @@ export class TUI { } else if (k === 'l') { this._confirm( 'Log in / re-authenticate via browser?', - 'Opens a browser. Logging into an account that\'s already added RE-AUTHENTICATES it in place (revives a reauth/error account) — no duplicate. Otherwise a new account is added.', + 'Opens a browser. Logging into an account that\'s already added (same user, same organization) RE-AUTHENTICATES it in place (revives a reauth/error account) — no duplicate. Another organization of the same user, or a new user, is added as a new account.', () => this._doLogin(), ); } else if (k === 'n' && this.am.accounts.length > 0) { @@ -1243,7 +1243,7 @@ export class TUI { this.mode = 'accounts'; this._confirm( 'Log in / re-authenticate via browser?', - 'Opens a browser. Logging into an account that is already added RE-AUTHENTICATES it in place — no duplicate.', + 'Opens a browser. Logging into an account that is already added (same user, same organization) RE-AUTHENTICATES it in place — no duplicate. Another organization of the same user is added as a new account.', () => this._doLogin(), ); return; @@ -1774,7 +1774,7 @@ export class TUI { } // Browser OAuth login — the SAME flow re-authenticates an existing account (matched - // by accountUuid) or adds a new one. When the login is for an account already added, + // by user + organization) or adds a new one. When the login is for an account already added, // we say so, keep its name, and skip the "name this account" prompt — so it's clear // it's a RE-AUTH in place (revives a dead-refresh/reauth account), not a duplicate. async _doLogin() { @@ -1814,12 +1814,9 @@ export class TUI { // a mirror match → that team pool; a brand-new account with a linked team pool → // ask once (default team pool); otherwise the local save path. async _routeLogin(creds, profile) { - const decision = decideLoginTarget({ - mirror: this._findRemoteMirrorFor(profile), - links: this.am.poolLinks, - existing: this._findExistingOAuthAccount(profile), - accountUuid: profile?.accountUuid, - }); + const mirror = this._findRemoteMirrorFor(profile); + const existing = mirror ? null : await this._matchLogin(profile); + const decision = decideLoginTarget({ mirror, links: this.am.poolLinks, existing, accountUuid: profile?.accountUuid }); if (decision.kind === 'no-uuid') { setConsoleStdoutSuppressed(false); process.stdout.write("\nCould not read this account's id — the team pool needs it. Nothing was saved; try again.\n"); @@ -1830,14 +1827,14 @@ export class TUI { await this._sendLoginToRemote(decision.mirror, creds, profile); return; } - const existing = this._findExistingOAuthAccount(profile); let name; if (existing) { process.stdout.write(`\nThis Claude account is already added as "${existing.name}" — re-authenticating it in place (no duplicate).\n`); name = existing.name; } else { - const suggested = profile?.email - || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`; + const sibling = this._oauthAccounts().find(a => sameUser(a, profile)); + if (sibling) process.stdout.write(`\nSame Claude user as "${sibling.name}": adding a second account.\n`); + const suggested = suggestAccountName(this.config.accounts, profile); let toTeam = false; if (decision.kind === 'ask-team') { const where = String(await this._ask(`\nThis is a new account. Add it to the team pool "${decision.link.name}" (recommended) or this laptop only? [T/l]: `) || '').trim().toLowerCase(); @@ -1878,7 +1875,8 @@ export class TUI { // uuid-first matcher cannot alias onto a local account). _findRemoteMirrorFor(profile) { if (!profile?.accountUuid) return null; - return this.am.accounts.find(a => a.remote && a.remoteUuid === profile.accountUuid) || null; + return this.am.accounts.find(a => a.remote + && sameAccountIdentity({ accountUuid: a.remoteUuid, organizationUuid: a.remoteOrganizationUuid }, profile)) || null; } // Send a fresh browser login to the linked team pool that OWNS this account @@ -1998,23 +1996,19 @@ export class TUI { } // Upsert an OAuth account into config + the running manager. Dedupes by - // accountUuid, then name. Shared by import and browser login. + // user + organization, then name. Shared by import and browser login. async _upsertOAuthAccount({ creds, profile, name, source, verb = 'Added' }) { - if (!name) { - name = profile?.email - || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`; - } + if (!name) name = suggestAccountName(this.config.accounts, profile); const entry = { name, type: 'oauth', source, accountUuid: profile?.accountUuid || null, + organizationUuid: profile?.organizationUuid || null, accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt, }; - let idx = entry.accountUuid - ? this.config.accounts.findIndex(a => a.accountUuid === entry.accountUuid) - : -1; + let idx = this.config.accounts.findIndex(a => sameAccountIdentity(a, entry)); if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name); if (idx >= 0) { @@ -2030,13 +2024,14 @@ export class TUI { // heal disk (persistTokenRefresh's generation guard skips a disk token that // != _refreshedFrom), so disk must be made correct right here. const amAcct = this.am.accounts.find(account => - (entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name + sameAccountIdentity(account, entry) || account.name === name ); if (amAcct) { amAcct.credential = creds.accessToken; amAcct.refreshToken = creds.refreshToken; amAcct.expiresAt = creds.expiresAt; amAcct.accountUuid = entry.accountUuid; + amAcct.organizationUuid = entry.organizationUuid; amAcct.name = name; // Fresh tokens from re-auth revive a dead-refresh account. This in-place // update is the actual re-login path (the index.js reconcile sees no change @@ -2078,15 +2073,84 @@ export class TUI { } } + // Decide what a fresh browser-login profile is: an account already in the pool + // (re-authenticate in place) or a new one. Same-user entries that do not know + // their organization are read first; when one still cannot be read, the user decides. + async _matchLogin(profile) { + await this._resolveOrganizations(profile); + const existing = this._findExistingOAuthAccount(profile); + if (this._organizationUnknown(existing, profile)) return this._confirmReauth(existing); + return existing; + } + + // Fill in the organization of same-user entries that lack one, reading each with + // the running account's live token (read only, never refreshed), and mirror the + // result to the manager. The upsert that follows persists it. + async _resolveOrganizations(profile) { + if (!profile?.accountUuid) return; + await resolveOrganizations(this._oauthAccounts(), profile.accountUuid, entry => this._liveCredentials(entry)); + this._mirrorOrganizations(profile); + } + + _liveCredentials(entry) { + const live = this._runningAccount(entry); + return live ? { accessToken: live.credential, expiresAt: live.expiresAt } : entry; + } + + _mirrorOrganizations(profile) { + const known = this._oauthAccounts().filter(a => sameUser(a, profile) && a.organizationUuid); + for (const entry of known) this._mirrorOrganization(entry); + } + + _mirrorOrganization(entry) { + const live = this._runningAccount(entry); + if (live) live.organizationUuid = entry.organizationUuid; + } + + _runningAccount(entry) { + return this.am.accounts.find(a => a.name === entry.name); + } + + _oauthAccounts() { + return this.config.accounts.filter(a => a.type === 'oauth'); + } + // Find an already-configured OAuth account matching a fresh browser-login profile — - // by accountUuid (primary), then name===email — the SAME precedence _upsertOAuthAccount - // dedupes on, so the login flow can honestly say "re-authenticating" vs "adding new". + // the same user in the same organization; else a same-user entry that does not know + // its organization (the login then asks); else, for a profile without a uuid, + // name===email; else an entry saved before uuids were stored. The SAME precedence + // _upsertOAuthAccount dedupes on, so the login flow can honestly say + // "re-authenticating" vs "adding new". _findExistingOAuthAccount(profile) { - const uuid = profile?.accountUuid || null; + const accounts = this._oauthAccounts(); const email = profile?.email || null; - let acct = uuid ? this.config.accounts.find(a => a.type === 'oauth' && a.accountUuid === uuid) : null; - if (!acct && email) acct = this.config.accounts.find(a => a.type === 'oauth' && a.name === email); - return acct || null; + if (!profile?.accountUuid) return (email && accounts.find(a => a.name === email)) || null; + return accounts.find(a => sameAccountIdentity(a, profile)) + || organizationUnknownMatch(accounts, profile) + || accounts.find(a => !a.accountUuid && a.name === email) + || null; + } + + // True when the match rests on the user alone: the entry's organization is unknown + // while the login names one. + _organizationUnknown(existing, profile) { + return Boolean(existing?.accountUuid && profile?.organizationUuid && !existing.organizationUuid); + } + + // The user decides a same-user login whose organization could not be read: Enter + // or r re-authenticates the entry, a adds a second account; anything else asks again. + async _confirmReauth(existing) { + const choice = await this._askReauthOrAdd(existing.name); + if (choice === 'add') return null; + if (choice === 'reauth') return existing; + return this._confirmReauth(existing); + } + + async _askReauthOrAdd(name) { + const raw = await this._ask(`Same Claude user as "${name}", organization unknown. Re-authenticate it (r) or add a second account (a)? [r] `); + const answer = String(raw || '').trim().toLowerCase(); + if (['', 'r', 're-authenticate'].includes(answer)) return 'reauth'; + return ['a', 'add'].includes(answer) ? 'add' : null; } async _doAddKey(apiKey) { @@ -2139,7 +2203,7 @@ export class TUI { _configAccountIndex(account) { if (!account) return -1; if (account.accountUuid) { - const byUuid = this.config.accounts.findIndex(candidate => candidate.accountUuid === account.accountUuid); + const byUuid = this.config.accounts.findIndex(candidate => sameAccountIdentity(candidate, account)); if (byUuid >= 0) return byUuid; } return this.config.accounts.findIndex(candidate => candidate.name === account.name); diff --git a/test/account-identity.test.js b/test/account-identity.test.js new file mode 100644 index 0000000..e2d0c75 --- /dev/null +++ b/test/account-identity.test.js @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + sameUser, sameAccountIdentity, organizationUnknownMatch, fetchProfile, resolveOrganizations, suggestAccountName, +} from '../src/oauth.js'; + +// A Claude login is one user (account.uuid) inside one organization +// (organization.uuid). The same user can hold a personal Max org and a Team org +// under one email; each is its own pool account. + +const PROFILE_BODY = { + account: { uuid: 'user-1111', email: 'user@example.com', display_name: 'User', has_claude_max: true, has_claude_pro: false }, + organization: { uuid: 'org-aaaa', name: 'Example Team', organization_type: 'claude_team' }, +}; + +async function withFetch(impl, fn) { + const real = globalThis.fetch; + globalThis.fetch = impl; + try { return await fn(); } finally { globalThis.fetch = real; } +} + +// -- the two predicates -- + +test('sameUser: equal accountUuids only; a missing uuid never matches', () => { + assert.equal(sameUser({ accountUuid: 'u1' }, { accountUuid: 'u1' }), true); + assert.equal(sameUser({ accountUuid: 'u1' }, { accountUuid: 'u2' }), false); + assert.equal(sameUser({ accountUuid: null }, { accountUuid: null }), false, 'two unknown users are not the same user'); + assert.equal(sameUser({}, { accountUuid: 'u1' }), false); + assert.equal(sameUser(null, { accountUuid: 'u1' }), false); + assert.equal(sameUser({ accountUuid: 'u1' }, undefined), false); +}); + +test('sameAccountIdentity: same user AND same organization', () => { + const personal = { accountUuid: 'u1', organizationUuid: 'org-p' }; + const team = { accountUuid: 'u1', organizationUuid: 'org-t' }; + assert.equal(sameAccountIdentity(personal, { ...personal }), true); + assert.equal(sameAccountIdentity(personal, team), false, 'same email, same user, another org = another account'); + assert.equal(sameAccountIdentity({ accountUuid: 'u2', organizationUuid: 'org-p' }, personal), false); +}); + +test('sameAccountIdentity: entries saved before organizations were recorded', () => { + const old = { accountUuid: 'u1' }; + assert.equal(sameAccountIdentity(old, { accountUuid: 'u1', organizationUuid: null }), true, 'no org on either side = today\'s uuid match'); + assert.equal(sameAccountIdentity(old, { accountUuid: 'u1', organizationUuid: 'org-p' }), false, 'an entry with an org never matches one without'); + assert.equal(sameAccountIdentity({ accountUuid: 'u1', organizationUuid: 'org-p' }, old), false, 'in either direction'); + assert.equal(sameAccountIdentity({}, {}), false); +}); + +test('organizationUnknownMatch: a same-user entry without an org, only when nothing matches exactly', () => { + const login = { accountUuid: 'u1', organizationUuid: 'org-t' }; + const unknown = { name: 'old', type: 'oauth', accountUuid: 'u1' }; + const exact = { name: 'team', type: 'oauth', accountUuid: 'u1', organizationUuid: 'org-t' }; + assert.equal(organizationUnknownMatch([unknown], login), unknown, 'the org of this entry could not be read: ask'); + assert.equal(organizationUnknownMatch([unknown, exact], login), null, 'an exact match wins, no question'); + assert.equal(organizationUnknownMatch([{ name: 'other', type: 'oauth', accountUuid: 'u2' }], login), null, 'another user'); + assert.equal(organizationUnknownMatch([{ name: 'api-1', type: 'apikey' }], login), null, 'API key entries never match'); +}); + +// -- the profile carries the organization -- + +test('fetchProfile returns organizationUuid next to accountUuid', async () => { + const profile = await withFetch(async () => ({ ok: true, json: async () => PROFILE_BODY }), () => fetchProfile('tok')); + assert.equal(profile.accountUuid, 'user-1111'); + assert.equal(profile.organizationUuid, 'org-aaaa'); + assert.equal(profile.email, 'user@example.com'); + assert.equal(profile.orgType, 'claude_team'); +}); + +// -- healing old entries: read only, never a refresh -- + +const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; + +test('resolveOrganizations fills the org of same-user entries that lack one, using their own token', async () => { + const accounts = [ + { name: 'personal', type: 'oauth', accountUuid: 'u1', accessToken: 'tok-personal', expiresAt: Date.now() + 3600_000 }, + { name: 'other', type: 'oauth', accountUuid: 'u9', accessToken: 'tok-other', expiresAt: Date.now() + 3600_000 }, + { name: 'known', type: 'oauth', accountUuid: 'u1', organizationUuid: 'org-k', accessToken: 'tok-known', expiresAt: Date.now() + 3600_000 }, + ]; + const calls = []; + const unresolved = await withFetch(async (url, init) => { + calls.push({ url, auth: init?.headers?.Authorization }); + return { ok: true, json: async () => ({ account: { uuid: 'u1' }, organization: { uuid: 'org-p' } }) }; + }, () => resolveOrganizations(accounts, 'u1')); + assert.deepEqual(unresolved, []); + assert.equal(accounts[0].organizationUuid, 'org-p', 'healed from its own profile'); + assert.equal(accounts[1].organizationUuid, undefined, 'another user is not touched'); + assert.equal(accounts[2].organizationUuid, 'org-k', 'an entry that knows its org is not re-read'); + assert.deepEqual(calls, [{ url: PROFILE_URL, auth: 'Bearer tok-personal' }], 'exactly one read-only profile GET'); +}); + +test('resolveOrganizations skips expired or missing tokens and reports them unresolved (never refreshes)', async () => { + const accounts = [ + { name: 'expired', type: 'oauth', accountUuid: 'u1', accessToken: 'tok', refreshToken: 'r', expiresAt: Date.now() - 1000 }, + { name: 'no-token', type: 'oauth', accountUuid: 'u1', refreshToken: 'r' }, + ]; + let fetched = 0; + const unresolved = await withFetch(async () => { fetched++; throw new Error('must not be called'); }, + () => resolveOrganizations(accounts, 'u1')); + assert.equal(fetched, 0, 'no network call at all'); + assert.deepEqual(unresolved.map(a => a.name), ['expired', 'no-token']); + assert.equal(accounts[0].organizationUuid, undefined); +}); + +test('resolveOrganizations leaves an entry unresolved when the profile read fails', async () => { + const accounts = [{ name: 'p', type: 'oauth', accountUuid: 'u1', accessToken: 'tok', expiresAt: Date.now() + 3600_000 }]; + const unresolved = await withFetch(async () => ({ ok: false, status: 401, json: async () => ({}) }), + () => resolveOrganizations(accounts, 'u1')); + assert.deepEqual(unresolved.map(a => a.name), ['p']); + assert.equal(accounts[0].organizationUuid, undefined); +}); + +test('resolveOrganizations reads the live token through accessTokenOf when given', async () => { + const accounts = [{ name: 'p', type: 'oauth', accountUuid: 'u1', accessToken: 'stale', expiresAt: Date.now() + 3600_000 }]; + const seen = []; + await withFetch(async (_url, init) => { + seen.push(init.headers.Authorization); + return { ok: true, json: async () => ({ account: { uuid: 'u1' }, organization: { uuid: 'org-p' } }) }; + }, () => resolveOrganizations(accounts, 'u1', () => ({ accessToken: 'live', expiresAt: Date.now() + 3600_000 }))); + assert.deepEqual(seen, ['Bearer live']); + assert.equal(accounts[0].organizationUuid, 'org-p'); +}); + +// -- the suggested name of a second account under the same email -- + +test('suggestAccountName: the email when free, " ()" when taken, a counter without a type', () => { + const taken = [{ name: 'user@example.com', type: 'oauth' }]; + assert.equal(suggestAccountName([], { email: 'user@example.com', orgType: 'claude_team' }), 'user@example.com'); + assert.equal(suggestAccountName(taken, { email: 'user@example.com', orgType: 'claude_team' }), 'user@example.com (team)'); + assert.equal(suggestAccountName(taken, { email: 'user@example.com', orgType: 'claude_max' }), 'user@example.com (max)'); + assert.equal(suggestAccountName(taken, { email: 'user@example.com' }), 'user@example.com (2)'); + assert.equal(suggestAccountName([{ name: 'account-1' }], {}), 'account-2', 'no email: the numbered fallback as before'); + assert.equal(suggestAccountName([], { error: 'HTTP 500' }), 'account-1'); +}); + +// -- every dedupe site goes through the predicates -- + +test('no raw accountUuid comparison outside oauth.js (a missed site would merge two orgs of one user)', () => { + const srcDir = fileURLToPath(new URL('../src/', import.meta.url)); + const hits = []; + for (const f of readdirSync(srcDir)) { + if (!f.endsWith('.js') || f === 'oauth.js') continue; + const lines = readFileSync(srcDir + f, 'utf8').split('\n'); + lines.forEach((line, i) => { + // A typeof check is input validation, not an identity comparison. + if (/(? { + const { computeDuplicates } = await import('../src/pool-link.js'); + const remote = remoteIdentities({ accounts: [{ name: 'team', type: 'oauth', accountUuid: 'U1', organizationUuid: 'O-team', enabled: true }] }); + const local = [ + { index: 0, type: 'oauth', accountUuid: 'U1', organizationUuid: 'O-personal' }, + { index: 1, type: 'oauth', accountUuid: 'U1', organizationUuid: 'O-team' }, + ]; + assert.deepEqual(computeDuplicates(local, remote).map(d => d.index), [1]); +}); diff --git a/test/remote-inject.test.js b/test/remote-inject.test.js index 58ff25a..92915ad 100644 --- a/test/remote-inject.test.js +++ b/test/remote-inject.test.js @@ -191,3 +191,16 @@ test('inject add: the new row lands in the server config mirror too, so the disk assert.equal(am.accounts.filter(a => a.accountUuid === 'DUP1').length, 1, 'one live row'); } finally { await close(proxy); } }); + +test('inject for another organization of the same user adds an account and leaves the first untouched', async () => { + am.accounts[1].organizationUuid = 'O1'; + const body = JSON.parse(injectBody('H1', { name: 'second-org' })); + body.account.organizationUuid = 'O2'; + const { status, json } = await post(port, JSON.stringify(body)); + assert.equal(status, 200); + assert.equal(json.result, 'added'); + const first = am.accounts.find(a => a.name === 'healthy-row'); + assert.equal(first.refreshToken, 'h-ref', 'the first organization keeps its own token'); + const second = am.accounts.find(a => a.name === 'second-org'); + assert.equal(second.organizationUuid, 'O2'); +}); diff --git a/test/same-email-accounts.test.js b/test/same-email-accounts.test.js new file mode 100644 index 0000000..ff20918 --- /dev/null +++ b/test/same-email-accounts.test.js @@ -0,0 +1,185 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TUI } from '../src/tui.js'; +import { AccountManager } from '../src/account-manager.js'; + +// One Claude user (one email) with a personal Max org and a Team org: two pool +// accounts that must never be merged, while a login to an org already in the +// pool still re-authenticates that entry in place. + +const EMAIL = 'user@example.com'; +const USER = 'user-1111'; +const PERSONAL_ORG = 'org-personal'; +const TEAM_ORG = 'org-team'; + +const fresh = () => ({ accessToken: 'new-a', refreshToken: 'new-r', expiresAt: Date.now() + 3600_000 }); +const personalProfile = { accountUuid: USER, organizationUuid: PERSONAL_ORG, email: EMAIL, orgType: 'claude_max' }; +const teamProfile = { accountUuid: USER, organizationUuid: TEAM_ORG, email: EMAIL, orgType: 'claude_team' }; + +function personalEntry(extra = {}) { + return { + name: 'personal', type: 'oauth', source: 'login', accountUuid: USER, organizationUuid: PERSONAL_ORG, + accessToken: 'p-a', refreshToken: 'p-r', expiresAt: Date.now() + 3600_000, enabled: true, ...extra, + }; +} + +function setup(accounts) { + const am = new AccountManager(accounts.map(a => ({ ...a })), 0.90); + const config = { accounts: accounts.map(a => ({ ...a })) }; + const tui = new TUI({ accountManager: am, config, saveConfig: async () => {} }); + return { tui, am, config }; +} + +async function withFetch(impl, fn) { + const real = globalThis.fetch; + globalThis.fetch = impl; + try { return await fn(); } finally { globalThis.fetch = real; } +} + +// -- AccountManager carries the organization -- + +test('AccountManager keeps organizationUuid from config and from addAccount', () => { + const am = new AccountManager([personalEntry()], 0.90); + assert.equal(am.accounts[0].organizationUuid, PERSONAL_ORG); + am.addAccount(personalEntry({ name: 'team', organizationUuid: TEAM_ORG })); + assert.equal(am.accounts[1].organizationUuid, TEAM_ORG); + am.addAccount({ name: 'old', type: 'oauth', accountUuid: USER, accessToken: 't' }); + assert.equal(am.accounts[2].organizationUuid, null); +}); + +test('quota state is restored to the right org of the same user', () => { + const am = new AccountManager([personalEntry(), personalEntry({ name: 'team', organizationUuid: TEAM_ORG })], 0.90); + am.accounts[1].quota.unified7d = 0.42; + am.accounts[1].quota.unified7dReset = Date.now() + 86_400_000; + const saved = am.exportQuotaState(); + assert.equal(saved[1].organizationUuid, TEAM_ORG, 'state.json names the org'); + + const again = new AccountManager([personalEntry(), personalEntry({ name: 'team', organizationUuid: TEAM_ORG })], 0.90); + again.restoreQuotaState(saved); + assert.equal(again.accounts[0].quota.unified7d, null, 'the personal org did not inherit the Team quota'); + assert.equal(again.accounts[1].quota.unified7d, 0.42); +}); + +test('quota saved before organizations were recorded is restored to the same user with the same name', () => { + const saved = [{ accountUuid: USER, name: 'personal', quota: { unified7d: 0.31, unified7dReset: Date.now() + 86_400_000 } }]; + const am = new AccountManager([personalEntry(), personalEntry({ name: 'team', organizationUuid: TEAM_ORG })], 0.90); + am.restoreQuotaState(saved); + assert.equal(am.accounts[0].quota.unified7d, 0.31, 'the healed personal account keeps its quota across the upgrade'); + assert.equal(am.accounts[1].quota.unified7d, null, 'the Team account of the same user is not touched'); +}); + +// -- the match that decides re-authenticate vs add -- + +test('_findExistingOAuthAccount: the same user in another org is a new account', () => { + const { tui } = setup([personalEntry()]); + assert.equal(tui._findExistingOAuthAccount(teamProfile), null, 'Team org of the same user: add'); + assert.equal(tui._findExistingOAuthAccount(personalProfile)?.name, 'personal', 'same org: re-authenticate'); +}); + +test('_findExistingOAuthAccount: an entry that does not know its org is returned so the login can ask', () => { + const { tui } = setup([personalEntry({ organizationUuid: undefined })]); + assert.equal(tui._findExistingOAuthAccount(teamProfile)?.name, 'personal'); + assert.equal(tui._organizationUnknown(tui._findExistingOAuthAccount(teamProfile), teamProfile), true); + const { tui: known } = setup([personalEntry()]); + assert.equal(known._organizationUnknown(known._findExistingOAuthAccount(personalProfile), personalProfile), false); +}); + +test('_findExistingOAuthAccount: an exact match wins over a same-user entry with an unknown org', () => { + const { tui } = setup([personalEntry({ organizationUuid: undefined }), personalEntry({ name: 'team', organizationUuid: TEAM_ORG })]); + const found = tui._findExistingOAuthAccount(teamProfile); + assert.equal(found?.name, 'team'); + assert.equal(tui._organizationUnknown(found, teamProfile), false, 'no question when the login matches exactly'); +}); + +test('_findExistingOAuthAccount: an entry saved before uuids existed still matches by its email name, without a question', () => { + const { tui } = setup([{ name: EMAIL, type: 'oauth', accessToken: 't', refreshToken: 'r', expiresAt: Date.now() + 3600_000 }]); + const found = tui._findExistingOAuthAccount(personalProfile); + assert.equal(found?.name, EMAIL); + assert.equal(tui._organizationUnknown(found, personalProfile), false); +}); + +test('_confirmReauth: Enter or r keeps the entry, a drops it, anything else asks again', async () => { + const { tui } = setup([personalEntry()]); + const existing = tui.config.accounts[0]; + const answers = (...list) => { tui._ask = async () => list.shift(); }; + answers(''); assert.equal(await tui._confirmReauth(existing), existing); + answers('R'); assert.equal(await tui._confirmReauth(existing), existing); + answers('a'); assert.equal(await tui._confirmReauth(existing), null); + answers('add'); assert.equal(await tui._confirmReauth(existing), null); + answers('re-authenticate'); assert.equal(await tui._confirmReauth(existing), existing); + let asked = 0; + tui._ask = async () => (++asked === 1 ? 'x' : 'a'); + assert.equal(await tui._confirmReauth(existing), null); + assert.equal(asked, 2, 'an unknown answer is asked again'); +}); + +test('_matchLogin heals an old entry from its live token and then adds the Team org without asking', async () => { + const { tui, am, config } = setup([personalEntry({ organizationUuid: undefined })]); + am.accounts[0].credential = 'live-token'; + tui._ask = async () => { throw new Error('must not ask'); }; + const seen = []; + const existing = await withFetch(async (_url, init) => { + seen.push(init.headers.Authorization); + return { ok: true, json: async () => ({ account: { uuid: USER }, organization: { uuid: PERSONAL_ORG } }) }; + }, () => tui._matchLogin(teamProfile)); + assert.equal(existing, null, 'Team org: a second account'); + assert.deepEqual(seen, ['Bearer live-token'], 'read with the running account\'s live token'); + assert.equal(config.accounts[0].organizationUuid, PERSONAL_ORG, 'config entry healed'); + assert.equal(am.accounts[0].organizationUuid, PERSONAL_ORG, 'running account healed'); +}); + +test('_matchLogin asks when the old entry\'s org cannot be read', async () => { + const { tui, am } = setup([personalEntry({ organizationUuid: undefined })]); + am.accounts[0].expiresAt = Date.now() - 1000; + let fetched = 0; + tui._ask = async () => 'a'; + const existing = await withFetch(async () => { fetched++; throw new Error('must not be called'); }, + () => tui._matchLogin(teamProfile)); + assert.equal(fetched, 0, 'an expired token is never used or refreshed'); + assert.equal(existing, null, 'the user chose to add a second account'); + tui._ask = async () => 'r'; + assert.equal((await tui._matchLogin(teamProfile))?.name, 'personal', 'the user chose to re-authenticate'); +}); + +// -- upsert: two orgs of one user coexist -- + +test('_upsertOAuthAccount adds the Team org beside the personal org and leaves the first untouched', async () => { + const { tui, am, config } = setup([personalEntry()]); + const r = await tui._upsertOAuthAccount({ creds: fresh(), profile: teamProfile, name: `${EMAIL} (team)`, source: 'login' }); + assert.equal(r.updated, false, 'added, not re-authenticated'); + assert.equal(config.accounts.length, 2); + assert.equal(config.accounts[0].accessToken, 'p-a', 'the personal token was not replaced'); + assert.equal(config.accounts[1].organizationUuid, TEAM_ORG); + assert.equal(am.accounts.length, 2); + assert.equal(am.accounts[1].organizationUuid, TEAM_ORG); + assert.equal(am.accounts[0].credential, 'p-a'); +}); + +test('_upsertOAuthAccount re-authenticates the same org in place and records the org on an old entry', async () => { + const { tui, am, config } = setup([personalEntry({ organizationUuid: undefined })]); + const r = await tui._upsertOAuthAccount({ creds: fresh(), profile: personalProfile, name: 'personal', source: 'login' }); + assert.equal(r.updated, true); + assert.equal(config.accounts.length, 1); + assert.equal(config.accounts[0].organizationUuid, PERSONAL_ORG); + assert.equal(am.accounts[0].organizationUuid, PERSONAL_ORG); + assert.equal(am.accounts[0].credential, 'new-a'); +}); + +test('_configAccountIndex tells the two orgs of one user apart', () => { + const { tui, am } = setup([personalEntry(), personalEntry({ name: 'team', organizationUuid: TEAM_ORG })]); + assert.equal(tui._configAccountIndex(am.accounts[1]), 1); + assert.equal(tui._configAccountIndex(am.accounts[0]), 0); +}); + +// -- the confirm texts stay true -- + +test('both browser-login confirms say that another org of the same user is added, not merged', () => { + const { tui } = setup([personalEntry()]); + tui.mode = 'accounts'; + tui._keyAccounts('l'); + assert.match(tui.confirmDetail, /same organization/i); + assert.match(tui.confirmDetail, /another organization/i); + tui._clearConfirm(); + tui._addAccountOfType('anthropic-oauth'); + assert.match(tui.confirmDetail, /another organization/i); +}); diff --git a/test/tui-login-new-to-cloud.test.js b/test/tui-login-new-to-cloud.test.js index 4e9336a..c5d74a5 100644 --- a/test/tui-login-new-to-cloud.test.js +++ b/test/tui-login-new-to-cloud.test.js @@ -32,8 +32,9 @@ function stubTui({ links, accounts = [], answers }) { _ask: async q => { asked.push(q); return answers.shift(); }, _upsertOAuthAccount: async a => { local.push(a); return { updated: false }; }, _findExistingOAuthAccount: () => null, + _matchLogin: async () => null, }; - for (const k of ['_routeLogin', '_findRemoteMirrorFor', '_sendLoginToRemote', '_sendLoginToTarget']) t[k] = TUI.prototype[k]; + for (const k of ['_routeLogin', '_findRemoteMirrorFor', '_sendLoginToRemote', '_sendLoginToTarget', '_oauthAccounts']) t[k] = TUI.prototype[k]; return t; } const creds = { accessToken: 'acc', refreshToken: 'ref', expiresAt: 42 }; diff --git a/test/tui-remote-login.test.js b/test/tui-remote-login.test.js index c2d1dbf..70063d5 100644 --- a/test/tui-remote-login.test.js +++ b/test/tui-remote-login.test.js @@ -88,3 +88,17 @@ test('unreachable pool surfaces the error and never writes anything locally', as await t._sendLoginToRemote(MIRROR, { accessToken: 'a', refreshToken: 'r', expiresAt: 1 }, { accountUuid: 'R1' }); assert.equal(JSON.stringify(t.am.accounts), before, 'NO local mutation on remote failure'); }); + +test('mirror match uses the organization too: another org of the same user stays local', () => { + const MIRROR = { ...makeMirror(), remoteOrganizationUuid: 'O1' }; + const t = stubTui([MIRROR]); + assert.equal(t._findRemoteMirrorFor({ accountUuid: 'R1', organizationUuid: 'O1' }), MIRROR); + assert.equal(t._findRemoteMirrorFor({ accountUuid: 'R1', organizationUuid: 'O2' }), null); +}); + +test('the remote login carries the organization of the browser login', async () => { + const MIRROR = makeMirror(); + const t = stubTui([MIRROR]); + await t._sendLoginToRemote(MIRROR, { accessToken: 'a', refreshToken: 'r', expiresAt: 1 }, { accountUuid: 'R1', organizationUuid: 'O1' }); + assert.equal(received[0].body.account.organizationUuid, 'O1'); +}); From d16bb5797162989fccdfbd7986e88af426cc368b Mon Sep 17 00:00:00 2001 From: Dragos Dobro <12120601+dragosdobro@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:23:36 +0200 Subject: [PATCH 3/3] feat(tui): leave out the session bar when no account reports a session window - Plans without a 5-hour limit send no session reading; when no account has one, the Ses column is dropped - Fbl and Wk take its room: Wk always shows, and Fbl joins it from 97 columns - As soon as any Claude, API-key or provider account reports a session reading, every row shows Ses again - Tests cover the layout and both cases --- src/tui.js | 54 ++++++++++++-------- test/tui-fable-bar.test.js | 20 ++++---- test/tui-session-collapse.test.js | 82 +++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 test/tui-session-collapse.test.js diff --git a/src/tui.js b/src/tui.js index 348b564..ad4f4df 100644 --- a/src/tui.js +++ b/src/tui.js @@ -204,22 +204,32 @@ function lpadVis(s, w) { * bars stop growing at 11 wide, so the row never passes column 118 and the tags * after the bars (cap, prefer, staleness) keep the room they have with two bars. * Narrower terminals keep today's two bars plus the "Fable NN%" tag. + * When no account reports a session window, the session bar is left out: the + * weekly bar always shows and the Fable bar joins it from 97 columns. */ const BAR_START = ROW_PREFIX.length + NAME_W + 1 + WHERE_W + 1 + PROVIDER_W + 1 + STATUS_W + 1 + RESETS_W + 1 + 'Ses '.length; const BAR_GAP = ' Wk '.length; const BAR_MIN_W = 5; const BAR_MAX_W = 20; -const THREE_BAR_MIN_W = 9; +const FABLE_BAR_MIN_W = 9; const THREE_BAR_MAX_W = 11; -function barLayout(W) { - const showBoth = W >= 70; - const showFable = W >= BAR_START + 3 * THREE_BAR_MIN_W + 2 * BAR_GAP; - const bars = 1 + (showBoth ? 1 : 0) + (showFable ? 1 : 0); +function barLayout(W, showSession = true) { + const showBoth = W >= 70 || !showSession; + const others = (showSession ? 1 : 0) + (showBoth ? 1 : 0); + const showFable = W >= BAR_START + (others + 1) * FABLE_BAR_MIN_W + others * BAR_GAP; + const bars = others + (showFable ? 1 : 0); const free = W - BAR_START - (bars - 1) * BAR_GAP; const fit = Math.floor(free / bars); - const maxW = showFable ? THREE_BAR_MAX_W : BAR_MAX_W; + const maxW = bars === 3 ? THREE_BAR_MAX_W : BAR_MAX_W; const bw = Math.max(BAR_MIN_W, Math.min(maxW, fit)); - return { showBoth, showFable, bw }; + return { showSession, showBoth, showFable, bw }; +} + +/** True when the account's first bar (Ses, or Tok for an API key) has a reading. */ +function hasSessionReading(a) { + const q = a?.quota || {}; + if (a?.type === 'provider') return q.providerSes != null; + return q.unified5h != null || (q.tokensLimit != null && q.tokensRemaining != null); } function rpad(s, w) { @@ -2546,7 +2556,7 @@ export class TUI { // the header and the data (a left-aligned sentence there reads as a broken, // misaligned second header). lines.push(dimUnderline(acctHeader(W))); - const { showBoth, showFable, bw } = barLayout(W); + const { showSession, showBoth, showFable, bw } = barLayout(W, this.am.accounts.some(hasSessionReading)); // Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable // display order over the canonical am.accounts array (which stays untouched so @@ -2559,7 +2569,7 @@ export class TUI { // effectively off (owner 2026-10-29: "should it hide the local ones that are // in the cloud... they're effectively disabled as well"). No separate key. if (this.hideDisabled && this.am.accounts[i]?.poolBenchedBy) { hidden++; continue; } - lines.push(this._renderAcct(i, bw, showBoth, showFable)); + lines.push(this._renderAcct(i, bw, showBoth, showFable, showSession)); } if (hidden > 0) { lines.push(` ${dim(`… ${hidden} disabled account${hidden === 1 ? '' : 's'} hidden — press h to show`)}`); @@ -2675,7 +2685,7 @@ export class TUI { process.stdout.write(buf); } - _renderAcct(idx, bw, showBoth, showFable = showBoth) { + _renderAcct(idx, bw, showBoth, showFable = showBoth, showSession = true) { let a = this.am.accounts[idx]; // A row benched by the duplicate guard renders the CLOUD's live copy (status, // bars, in-flight, request counts) — never the stale local snapshot. Identity @@ -2944,7 +2954,7 @@ export class TUI { } if (a.type === 'provider') { - return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth, showFable); + return this._renderProviderAcct(sel, cur, name, type, status + ' ' + resetsCell, a, bw, showBoth, showFable, showSession); } // Quota ratios — prefer unified (Claude Max), fall back to standard (API key) @@ -2967,18 +2977,20 @@ export class TUI { t2 = t1; } - let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} ${resetsCell} ${l1} ${bar(r1, bw, t1)}`; + const bars = []; + if (showSession) bars.push(`${l1} ${bar(r1, bw, t1)}`); // The Fable bar goes between the session and weekly bars on every row type, so // the Wk column starts at the same offset on OAuth, API-key and provider rows. - if (showFable) line += ` Fbl ${scopedWeeklyBar(q, 'fable', bw)}`; + if (showFable) bars.push(`Fbl ${scopedWeeklyBar(q, 'fable', bw)}`); if (showBoth) { // "no weekly cap on this plan" is a DIFFERENT state from "not read yet", and // both render as an empty bar. Say which, so a healthy uncapped account // (privacy@gomokka.com, measured 2026-08-06) doesn't read as broken. - line += (r2 == null && q.weeklyAbsent) - ? ` ${l2} ${emptyBar('none', bw)}` - : ` ${l2} ${bar(r2, bw, t2)}`; + bars.push((r2 == null && q.weeklyAbsent) + ? `${l2} ${emptyBar('none', bw)}` + : `${l2} ${bar(r2, bw, t2)}`); } + let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} ${resetsCell} ${bars.join(' ')}`; const weekly = weeklyPolicyText(this.am, a); if (weekly) line += ` ${weekly}`; // Per-account SETTINGS (preferred pin, usage cap), whatever the account is @@ -3078,7 +3090,7 @@ export class TUI { return interval > 0 ? `every ${formatAge(interval)}` : 'shortly'; } - _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true, showFable = showBoth) { + _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true, showFable = showBoth, showSession = true) { const q = a.quota || {}; // Ses/Wk bars in the SAME columns as OAuth rows (right after status) so the @@ -3136,9 +3148,11 @@ export class TUI { // setting whenever the probe is quiet is how a shipped feature reads as absent. for (const tag of settingsTags(this.am, a)) note += ` ${tag}`; - let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} Ses ${sesCell}`; - if (showFable) line += ` Fbl ${scopedWeeklyBar(q, 'fable', bw)}`; - if (showBoth) line += ` Wk ${wkCell}`; + const bars = []; + if (showSession) bars.push(`Ses ${sesCell}`); + if (showFable) bars.push(`Fbl ${scopedWeeklyBar(q, 'fable', bw)}`); + if (showBoth) bars.push(`Wk ${wkCell}`); + let line = ` ${sel}${cur} ${name} ${whereCell(a)} ${type} ${status} ${bars.join(' ')}`; line += note; // Same recent-load columns as OAuth (providers track inFlight + load events), // plus a compact Last — the one signal that matters for a diff --git a/test/tui-fable-bar.test.js b/test/tui-fable-bar.test.js index 0ccb054..0661168 100644 --- a/test/tui-fable-bar.test.js +++ b/test/tui-fable-bar.test.js @@ -102,16 +102,16 @@ test('a mid-width terminal (showBoth, no showFable) shows Ses and Wk but not Fbl test('barLayout: one bar under 70 columns, two from 70, three from 112, three bars capped at 11', () => { const { barLayout } = __tuiTest; - assert.deepEqual(barLayout(69), { showBoth: false, showFable: false, bw: 5 }); - assert.deepEqual(barLayout(70), { showBoth: true, showFable: false, bw: 5 }); - assert.deepEqual(barLayout(93), { showBoth: true, showFable: false, bw: 7 }); - assert.deepEqual(barLayout(99), { showBoth: true, showFable: false, bw: 10 }); - assert.deepEqual(barLayout(111), { showBoth: true, showFable: false, bw: 16 }); - assert.deepEqual(barLayout(112), { showBoth: true, showFable: true, bw: 9 }); - assert.deepEqual(barLayout(117), { showBoth: true, showFable: true, bw: 10 }); - assert.deepEqual(barLayout(118), { showBoth: true, showFable: true, bw: 11 }); - assert.deepEqual(barLayout(120), { showBoth: true, showFable: true, bw: 11 }); - assert.deepEqual(barLayout(140), { showBoth: true, showFable: true, bw: 11 }); + assert.deepEqual(barLayout(69), { showSession: true, showBoth: false, showFable: false, bw: 5 }); + assert.deepEqual(barLayout(70), { showSession: true, showBoth: true, showFable: false, bw: 5 }); + assert.deepEqual(barLayout(93), { showSession: true, showBoth: true, showFable: false, bw: 7 }); + assert.deepEqual(barLayout(99), { showSession: true, showBoth: true, showFable: false, bw: 10 }); + assert.deepEqual(barLayout(111), { showSession: true, showBoth: true, showFable: false, bw: 16 }); + assert.deepEqual(barLayout(112), { showSession: true, showBoth: true, showFable: true, bw: 9 }); + assert.deepEqual(barLayout(117), { showSession: true, showBoth: true, showFable: true, bw: 10 }); + assert.deepEqual(barLayout(118), { showSession: true, showBoth: true, showFable: true, bw: 11 }); + assert.deepEqual(barLayout(120), { showSession: true, showBoth: true, showFable: true, bw: 11 }); + assert.deepEqual(barLayout(140), { showSession: true, showBoth: true, showFable: true, bw: 11 }); }); test('barLayout: a two-bar row fills the terminal exactly at 95 columns and never overflows it', () => { diff --git a/test/tui-session-collapse.test.js b/test/tui-session-collapse.test.js new file mode 100644 index 0000000..8bbbcb9 --- /dev/null +++ b/test/tui-session-collapse.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AccountManager } from '../src/account-manager.js'; +import { TUI, __tuiTest } from '../src/tui.js'; + +const DAY = 24 * 60 * 60 * 1000; +const { strip, barLayout } = __tuiTest; + +// Some plans no longer report a 5-hour session window. When no account in the +// pool reports one, the Ses column is dropped and Fbl and Wk take its room. + +function pool() { + return new AccountManager([ + { name: 'home', type: 'oauth', accessToken: 't1', refreshToken: 'r1', expiresAt: Date.now() + 3600_000 }, + { name: 'work', type: 'oauth', accessToken: 't2', refreshToken: 'r2', expiresAt: Date.now() + 3600_000 }, + { name: 'glm', type: 'provider', provider: 'zai', authToken: 'zt', upstream: 'https://api.z.ai/api/anthropic' }, + ], 0.90); +} + +function weeklyOnly(am, idx) { + am.applyUsageData(idx, { + sevenDay: { utilization: 0.40, resetAt: Date.now() + 3 * DAY }, + scopedWeekly: { fable: { severity: 'normal', isActive: true, utilization: 0.45, resetAt: Date.now() + 3 * DAY } }, + }); +} + +function withSession(am, idx) { + am.applyUsageData(idx, { + fiveHour: { utilization: 0.30, resetAt: Date.now() + 3600_000 }, + sevenDay: { utilization: 0.40, resetAt: Date.now() + 3 * DAY }, + }); +} + +function frameRows(am, columns) { + const tui = new TUI({ accountManager: am, config: { proxy: { port: 3456 } } }); + const saved = { columns: process.stdout.columns, rows: process.stdout.rows, write: process.stdout.write }; + let buf = ''; + process.stdout.columns = columns; process.stdout.rows = 24; + process.stdout.write = s => { buf += s; return true; }; + try { tui._render(); } finally { Object.assign(process.stdout, saved); } + return strip(buf).split(/\r?\n/).filter(l => /^ {4}(home|work|glm) /.test(l)); +} + +test('barLayout without the session column: Wk alone under 97 columns, Fbl and Wk from 97', () => { + assert.deepEqual(barLayout(69, false), { showSession: false, showBoth: true, showFable: false, bw: 5 }); + assert.deepEqual(barLayout(89, false), { showSession: false, showBoth: true, showFable: false, bw: 16 }); + assert.deepEqual(barLayout(96, false), { showSession: false, showBoth: true, showFable: false, bw: 20 }); + assert.deepEqual(barLayout(97, false), { showSession: false, showBoth: true, showFable: true, bw: 9 }); + assert.deepEqual(barLayout(119, false), { showSession: false, showBoth: true, showFable: true, bw: 20 }); + assert.deepEqual(barLayout(140, false), { showSession: false, showBoth: true, showFable: true, bw: 20 }); +}); + +test('no account reports a session window: rows show Fbl then Wk and no Ses column', () => { + const am = pool(); + weeklyOnly(am, 0); + weeklyOnly(am, 1); + const rows = frameRows(am, 120); + assert.equal(rows.length, 3); + for (const l of rows) { + assert.doesNotMatch(l, /\bSes\b|\bTok\b/, `no session column: ${l}`); + assert.match(l, /Fbl .* Wk /, `Fbl then Wk: ${l}`); + assert.ok(l.length <= 120, `fits 120 columns: ${l.length}`); + } + assert.match(rows[0], /Fbl\s+45% 3d/); +}); + +test('one account still reports a session window: every row keeps the Ses column', () => { + const am = pool(); + weeklyOnly(am, 0); + withSession(am, 1); + const rows = frameRows(am, 120); + for (const l of rows) assert.match(l, /(Ses|Tok) .*Fbl .* Wk /, `three bars: ${l}`); +}); + +test('a provider session reading also keeps the Ses column', () => { + const am = pool(); + weeklyOnly(am, 0); + weeklyOnly(am, 1); + am.applyProviderUsage(2, { source: 'zai', ses: { utilization: 0.42, resetAt: Date.now() + 3600_000 } }); + const rows = frameRows(am, 120); + for (const l of rows) assert.match(l, /Ses .*Fbl .* Wk /, `three bars: ${l}`); +});