diff --git a/CHANGELOG.md b/CHANGELOG.md index 902fe09..6b12c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ new version heading in the same commit. ## [Unreleased] +## [0.284.0] — 2026-07-31 +### Added +- **Runtime-account tokens are validated against the provider before they enter the pool, and their + weekly/session usage is shown per key.** A mis-pasted Claude subscription token used to be vaulted + as-is, then silently sent every future session to `/login` (an invalid `CLAUDE_CODE_OAUTH_TOKEN` — the + exact outage seen on instapods). Now `POST /api/runtime-accounts` probes the token against Claude's own + `GET /api/oauth/usage` endpoint (the source Claude Code's status line uses; no quota consumed) and + **rejects a definitive 401 with a clear message** instead of storing it. A valid token is accepted; a + transient network/429 is added and badged "could not verify" rather than blocked. `src/edge/runtime-account-check.ts` (new), + `src/server.ts`. +- **Per-key usage in Settings → Runtime accounts.** The pool table gains a **Usage** column (weekly 7d + + session 5h utilization, coloured amber ≥80% / red ≥100%, reset time on hover) and a **Refresh** action + to re-probe on demand; the add form echoes the validation result ("added · valid · weekly 13% used"). + Usage is populated for accounts whose token carries the `user:profile` scope — an interactive-login + **credential-dir** (`oauth` kind). A `claude setup-token` (the paste-token kind) is validated the same + way but **can't report usage** (Anthropic scopes it out of the profile endpoint) — shown honestly as + "valid · usage n/a (setup-token lacks the user:profile scope)". New columns on `runtime_accounts` + (`last_checked_at`/`check_ok`/`check_note`/`usage_json`); `POST /api/runtime-accounts/:runtime/:name/check`. + `web/src/App.tsx`, `web/src/lib/api.ts`, `src/state/runtime-accounts.ts`, `src/state/db.ts`. +### Fixed +- **A pool token that goes bad mid-life now rotates itself out instead of trapping every launch.** The + launch-time backstop to add-time validation: when an unattended run's pane shows a credential-rejection + banner ("invalid bearer token" / "oauth token expired" / "failed to authenticate") — as opposed to a + usage-limit — the account it launched under is **auto-disabled** (`markInvalid`), since an invalid token + won't self-heal at a reset the way a usage limit does. It drops out of the pool (a `Refresh` that + re-authenticates re-enables it) so the launcher falls back to another account / the box default rather + than sending run after run to `/login`. `src/terminal.ts` (`detectUsageLimit` now distinguishes + auth-failure from usage-limit), `src/state/runtime-accounts.ts` (`markInvalid`/`recordCheck`). + ## [0.283.2] — 2026-07-31 ### Changed - **System-agent runs (the Cockpit concierge/operator, consolidator, …) are hidden from the Chat + diff --git a/package-lock.json b/package-lock.json index 2cb7929..d303580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.283.2", + "version": "0.284.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.283.2", + "version": "0.284.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 478c241..1761273 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.283.2", + "version": "0.284.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/runtime-account-check.ts b/src/edge/runtime-account-check.ts new file mode 100644 index 0000000..99768c4 --- /dev/null +++ b/src/edge/runtime-account-check.ts @@ -0,0 +1,111 @@ +/** + * Validate a Claude Code subscription OAuth token (from `claude setup-token`, `sk-ant-oat01-…`) and read + * its remaining usage — the add-time / Refresh check for the runtime-account pool (see `RuntimeAccountStore`). + * + * We hit the SAME endpoint Claude Code's own status line uses — `GET /api/oauth/usage` on api.anthropic.com, + * with the token as a Bearer, the `oauth-2025-04-20` beta header, and a `claude-code/…` User-Agent (without + * that UA the endpoint drops you into an aggressively rate-limited bucket → persistent 429). The call is + * read-only and does NOT consume usage quota. + * + * Status code IS the validation, and there are THREE meaningful outcomes (all verified against live tokens): + * • 200 → valid AND the token carries the `user:profile` scope → body has the weekly/session usage numbers. + * This is what an interactive `claude login` credential (the `oauth` credential-dir kind) yields. + * • 403 `permission_error` (scope `user:profile`) → the token is VALID and authenticated, it just lacks the + * profile scope the usage endpoint needs. This is what `claude setup-token` mints (it can run inference + * but not read usage) — so the fleet's paste-token accounts land here: valid, but no usage readout. + * • 401 → invalid / revoked / expired — the definitively-bad case we must reject before it enters the pool. + * A 429 or anything else is "couldn't verify" — add anyway and badge it; never wedge on a transient blip. + * + * Consistency with the runtime: this token exists solely to run `claude` (the launcher injects it as + * CLAUDE_CODE_OAUTH_TOKEN), and this probe mirrors what Claude Code itself does — so validating here is in + * the same lane as the actual use, not a side-channel. Endpoint is undocumented/reverse-engineered — never + * let a check throw into a request handler. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { RuntimeUsage, RuntimeUsageWindow } from '../state/runtime-accounts'; + +const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; +const OAUTH_BETA = 'oauth-2025-04-20'; +// The endpoint 429s hard without a claude-code UA prefix; the exact version isn't validated, only the prefix. +const CLAUDE_CODE_UA = 'claude-code/2.1.0'; + +export interface RuntimeCheckResult { + /** true = authenticated (usable), false = rejected (401, definitively bad), null = couldn't verify. */ + ok: boolean | null; + /** Short human-readable status for the console badge / the reject message. */ + note: string; + usage?: RuntimeUsage; + /** When usage shows a window fully consumed, the epoch ms it resets — caller parks the account limited. */ + limitedUntil?: number; +} + +const toWindow = (o: unknown): RuntimeUsageWindow | undefined => { + if (!o || typeof o !== 'object') return undefined; + const util = (o as { utilization?: unknown }).utilization; + const resets = (o as { resets_at?: unknown }).resets_at; + if (typeof util !== 'number') return undefined; + const resetsAt = typeof resets === 'string' ? Date.parse(resets) : NaN; + return { usedPct: Math.round(util), resetsAt: Number.isFinite(resetsAt) ? resetsAt : undefined }; +}; + +const parseUsage = (body: unknown): RuntimeUsage => ({ + session: toWindow((body as { five_hour?: unknown })?.five_hour), + weekly: toWindow((body as { seven_day?: unknown })?.seven_day), +}); + +/** A window counts as exhausted at ≥100% utilization; return the soonest reset among any exhausted window. */ +const exhaustedUntil = (u: RuntimeUsage): number | undefined => { + const hits: number[] = []; + for (const w of [u.weekly, u.session]) { + if (w && (w.usedPct ?? 0) >= 100 && w.resetsAt) hits.push(w.resetsAt); + } + return hits.length ? Math.min(...hits) : undefined; +}; + +const describe = (u: RuntimeUsage): string => { + const parts: string[] = []; + if (u.weekly?.usedPct != null) parts.push(`weekly ${u.weekly.usedPct}%`); + if (u.session?.usedPct != null) parts.push(`session ${u.session.usedPct}%`); + return parts.length ? `valid · ${parts.join(' · ')} used` : 'valid'; +}; + +/** Best-effort read of the OAuth access token from a `claude login` credential dir's `.credentials.json` + * (the `oauth` account kind). These login tokens carry the `user:profile` scope, so — unlike a paste-token — + * they DO return usage. Returns undefined when the dir/file/shape isn't present. */ +export function readConfigDirToken(dir: string): string | undefined { + try { + const j = JSON.parse(readFileSync(join(dir, '.credentials.json'), 'utf8')); + return j?.claudeAiOauth?.accessToken ?? j?.accessToken ?? undefined; + } catch { return undefined; } +} + +/** Probe a Claude OAuth token. Never throws — a network/timeout/parse problem returns `ok:null`. */ +export async function checkClaudeToken(token: string, timeoutMs = 8000): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const r = await fetch(USAGE_URL, { + method: 'GET', + headers: { authorization: `Bearer ${token}`, 'anthropic-beta': OAUTH_BETA, 'user-agent': CLAUDE_CODE_UA }, + signal: ctrl.signal, + }); + if (r.status === 401) return { ok: false, note: 'rejected (401) — not a valid Claude subscription token; re-run `claude setup-token` and paste the full sk-ant-oat01-… value' }; + if (r.status === 403) { + // Authenticated but scope-limited: valid token, no usage readout. `setup-token` accounts live here. + const body = await r.text().catch(() => ''); + const scoped = /permission_error|scope/i.test(body); + return { ok: scoped ? true : null, note: scoped ? 'valid · usage n/a (setup-token lacks the user:profile scope)' : `could not verify (HTTP 403)` }; + } + if (r.status === 429) return { ok: null, note: 'could not verify (rate-limited) — added without a usage check; try Refresh in a few minutes' }; + if (!r.ok) return { ok: null, note: `could not verify (HTTP ${r.status})` }; + const body = await r.json().catch(() => null); + const usage = parseUsage(body); + return { ok: true, note: describe(usage), usage, limitedUntil: exhaustedUntil(usage) }; + } catch (e) { + const why = (e as Error)?.name === 'AbortError' ? 'timeout' : 'network error'; + return { ok: null, note: `could not verify (${why}) — added without a usage check` }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/server.ts b/src/server.ts index d8b2c48..32c040d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,7 @@ import { resolveLlm, chatComplete } from './edge/llm'; import { ensureConcierge, CONCIERGE_ID, ensureOperator, OPERATOR_ID } from './edge/concierge'; import { answerFromState } from './edge/ask'; import { SlackSocket } from './edge/slack-socket'; +import { checkClaudeToken, readConfigDirToken, RuntimeCheckResult } from './edge/runtime-account-check'; import { ClickupIngress } from './edge/clickup-ingress'; import { DiscordSocket } from './edge/discord-socket'; import { AppSupervisor } from './edge/app-supervisor'; @@ -4162,20 +4163,67 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // A 'token' account carries the raw long-lived OAuth token (from `claude setup-token`): seal it in the // vault HERE (never stored on the row, never in audit) and hand the account only its key ref. The value // never leaves this call except into the encrypted vault. + let check: RuntimeCheckResult | null = null; if (kind === 'token') { const token = String(b.token ?? '').trim(); if (!name) return sendJson(res, 400, { error: 'account name required' }); if (!token) return sendJson(res, 400, { error: 'token value required' }); if (!CODING_RUNTIMES[runtime].credentialEnv.tokenVar) return sendJson(res, 400, { error: `${runtime} has no OAuth-token env — use an API key or credential dir instead` }); + // Validate the token against the provider BEFORE it enters the pool, so a mis-pasted value is + // rejected here instead of silently sending every future session to /login. Claude-specific probe + // (the OAuth-usage endpoint); other runtimes skip straight through. A definitive 401 blocks the add; + // "couldn't verify" (network/429) is allowed through and badged — never wedge on a transient blip. + if (runtime === 'claude-code') { + check = await checkClaudeToken(token); + if (check.ok === false) return sendJson(res, 400, { error: check.note }); + } const key = `runtime-token:${runtime}:${name}`; os.secrets.set(os.tenant, key, token, { principal: '*', updatedBy: me.email }); apiKeyRef = key; } const acct = os.runtimeAccounts.add({ runtime, name, kind, configDir: b.configDir ? String(b.configDir) : undefined, apiKeyRef }); - os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'runtime.account.added', data: { runtime, name: acct.name, kind } }); - return sendJson(res, 200, { ok: true, account: acct }); + // An oauth credential-dir carries a login token WITH the usage scope — probe it too so its usage shows + // right after adding (non-blocking: a bad dir is just badged, not rejected — the operator set the path). + if (!check && kind === 'oauth' && runtime === 'claude-code' && acct.configDir) { + const dirTok = readConfigDirToken(acct.configDir); + if (dirTok) check = await checkClaudeToken(dirTok); + } + // Persist the validation snapshot (health + weekly/session usage) so the console can show it right away; + // if usage says a window is already exhausted, park the account limited until it resets. + if (check) { + os.runtimeAccounts.recordCheck(runtime, acct.name, { ok: check.ok, note: check.note, usage: check.usage }); + if (check.limitedUntil) os.runtimeAccounts.markLimited(runtime, acct.name, check.limitedUntil); + } + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'runtime.account.added', data: { runtime, name: acct.name, kind, check: check ? { ok: check.ok, note: check.note } : undefined } }); + return sendJson(res, 200, { ok: true, account: os.runtimeAccounts.get(runtime, acct.name) }); } catch (e) { return sendJson(res, 400, { error: (e as Error).message }); } } + { + // Re-validate an existing token account on demand (the console's Refresh) — re-probe the vaulted token, + // refresh its health + usage snapshot, and re-enable it if a previously-bad token now authenticates. + const mc = /^\/api\/runtime-accounts\/([^/]+)\/([^/]+)\/check$/.exec(p); + if (mc && method === 'POST') { + if (me.role !== 'owner') return sendJson(res, 403, { error: 'owner required' }); + const runtime = decodeURIComponent(mc[1]) as RuntimeId; + const name = decodeURIComponent(mc[2]); + if (!isCodingRuntime(runtime)) return sendJson(res, 400, { error: `unknown runtime: ${runtime}` }); + const acct = os.runtimeAccounts.get(runtime, name); + if (!acct) return sendJson(res, 404, { error: 'account not found' }); + if (runtime !== 'claude-code' || acct.kind === 'apikey') return sendJson(res, 400, { error: 'refresh is only available for Claude subscription-token / credential-dir accounts' }); + // token kind → the vaulted setup-token; oauth kind → the login token inside its credential dir. + const token = acct.kind === 'token' + ? (acct.apiKeyRef ? os.secrets.getSync(os.tenant, '*', acct.apiKeyRef) : undefined) + : (acct.configDir ? readConfigDirToken(acct.configDir) : undefined); + if (!token) return sendJson(res, 400, { error: acct.kind === 'token' ? 'token value not found in the vault' : 'no .credentials.json found in the credential dir' }); + const check = await checkClaudeToken(token); + os.runtimeAccounts.recordCheck(runtime, name, { ok: check.ok, note: check.note, usage: check.usage }); + // A now-valid token re-enables a previously auto-disabled account; usage-exhaustion re-parks it limited. + if (check.ok === true && !acct.enabled && acct.checkOk === false) os.runtimeAccounts.setEnabled(runtime, name, true); + if (check.limitedUntil) os.runtimeAccounts.markLimited(runtime, name, check.limitedUntil); + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'runtime.account.checked', data: { runtime, name, ok: check.ok, note: check.note } }); + return sendJson(res, 200, { ok: true, account: os.runtimeAccounts.get(runtime, name), check: { ok: check.ok, note: check.note } }); + } + } { const m = /^\/api\/runtime-accounts\/([^/]+)\/([^/]+)$/.exec(p); if (m && (method === 'PATCH' || method === 'DELETE')) { diff --git a/src/state/db.ts b/src/state/db.ts index c3f6ce1..3d86200 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -948,6 +948,15 @@ function migrate(db: Db): void { // teardown can park the RIGHT account. NULL = launched on the box's default single account. addColumn(db, 'term_sessions', 'runtime_account', 'TEXT'); + // Runtime-account health cache (RuntimeAccountStore): the last validation result + usage snapshot, so + // the console can show "valid / invalid / weekly 63% used · resets …" per key without re-hitting the + // provider on every page load. Written at add-time (validate before it enters the pool), on an explicit + // Refresh, and when a live run's teardown detects the credential went bad. + addColumn(db, 'runtime_accounts', 'last_checked_at', 'INTEGER'); // epoch ms of the last validation call + addColumn(db, 'runtime_accounts', 'check_ok', 'INTEGER'); // 1 = last validation authenticated, 0 = failed + addColumn(db, 'runtime_accounts', 'check_note', 'TEXT'); // human-readable status / auto-disable reason + addColumn(db, 'runtime_accounts', 'usage_json', 'TEXT'); // cached RuntimeUsage snapshot (weekly/session) + // Stale-prompt escalation: a once-per-item marker so the scheduler's re-nudge sweep DMs the approver / // operator EXACTLY ONCE when an approval or question has sat pending past the threshold, and never // re-alarms across restarts (same discipline as `markOverdueNotified` on tasks). NULL = not yet diff --git a/src/state/runtime-accounts.ts b/src/state/runtime-accounts.ts index b8965fa..22b9c52 100644 --- a/src/state/runtime-accounts.ts +++ b/src/state/runtime-accounts.ts @@ -24,6 +24,20 @@ import { CodingRuntimeId } from '../types'; export type RuntimeAccountKind = 'oauth' | 'apikey' | 'token'; +/** A single usage window (subscription session = ~5h, weekly = 7d) as reported by the runtime provider. + * `usedPct` is 0..100 of the window consumed; `resetsAt` is when it rolls over. Either may be absent when + * the provider doesn't surface it — the UI renders what's present. */ +export interface RuntimeUsageWindow { + usedPct?: number; + resetsAt?: number; +} +/** A cached usage snapshot for an account, shown per-key in the console. Provider-agnostic: the validator + * maps whatever the runtime exposes onto these two windows. */ +export interface RuntimeUsage { + weekly?: RuntimeUsageWindow; + session?: RuntimeUsageWindow; +} + export interface RuntimeAccount { /** The coding runtime this account authenticates (claude-code | codex | …). */ runtime: CodingRuntimeId; @@ -43,6 +57,14 @@ export interface RuntimeAccount { limitedUntil?: number; lastUsedAt?: number; createdAt: number; + /** Health cache from the last validation call (add-time / Refresh / teardown). */ + lastCheckedAt?: number; + /** Did the last validation authenticate? undefined = never checked. */ + checkOk?: boolean; + /** Human-readable status of the last check / reason an account was auto-disabled. */ + checkNote?: string; + /** Last usage snapshot (weekly/session), when the provider reports it. */ + usage?: RuntimeUsage; } interface Row { @@ -56,8 +78,17 @@ interface Row { limited_until: number | null; last_used_at: number | null; created_at: number; + last_checked_at: number | null; + check_ok: number | null; + check_note: string | null; + usage_json: string | null; } +const parseUsage = (raw: string | null): RuntimeUsage | undefined => { + if (!raw) return undefined; + try { return JSON.parse(raw) as RuntimeUsage; } catch { return undefined; } +}; + const toAccount = (r: Row): RuntimeAccount => ({ runtime: r.runtime as CodingRuntimeId, name: r.name, @@ -69,6 +100,10 @@ const toAccount = (r: Row): RuntimeAccount => ({ limitedUntil: r.limited_until ?? undefined, lastUsedAt: r.last_used_at ?? undefined, createdAt: r.created_at, + lastCheckedAt: r.last_checked_at ?? undefined, + checkOk: r.check_ok == null ? undefined : r.check_ok === 1, + checkNote: r.check_note ?? undefined, + usage: parseUsage(r.usage_json), }); export class RuntimeAccountStore { @@ -128,6 +163,27 @@ export class RuntimeAccountStore { .run(until, until, until, runtime, name); } + /** Cache the outcome of a validation call (add-time / Refresh) — health + usage snapshot. Doesn't touch + * `enabled`/`status`; a usage-derived limit is applied by the caller via {@link markLimited}. `ok=null` + * (couldn't verify) leaves the previous check_ok untouched so a transient network blip doesn't clear a + * known-good badge — only a definitive true/false overwrites it. */ + recordCheck(runtime: CodingRuntimeId, name: string, r: { ok: boolean | null; note: string; usage?: RuntimeUsage; now?: number }): void { + const now = r.now ?? Date.now(); + this.db.prepare(`UPDATE runtime_accounts + SET last_checked_at = ?, check_note = ?, usage_json = ?, + check_ok = CASE WHEN ? IS NULL THEN check_ok ELSE ? END + WHERE runtime = ? AND name = ?`) + .run(now, r.note, r.usage ? JSON.stringify(r.usage) : null, r.ok === null ? null : 1, r.ok ? 1 : 0, runtime, name); + } + + /** The credential is definitively bad (a live run authenticated with it and got a 401 / "invalid bearer + * token", or a Refresh returned 401): DISABLE it — unlike a usage limit it will NOT self-heal at a reset, + * so it must drop out of the pool until a human replaces the token. Records why, for the console badge. */ + markInvalid(runtime: CodingRuntimeId, name: string, note: string, now: number = Date.now()): void { + this.db.prepare("UPDATE runtime_accounts SET enabled = 0, check_ok = 0, check_note = ?, last_checked_at = ? WHERE runtime = ? AND name = ?") + .run(note, now, runtime, name); + } + /** Select the account to launch the next `runtime` session under — least-recently-used among enabled + * available — and stamp its last_used_at. Returns null when there are NO accounts for the runtime (caller * → box default) or every enabled one is currently limited (caller → box default for a member launch; the diff --git a/src/terminal.ts b/src/terminal.ts index 9ca56e5..b3e3c1f 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -2307,10 +2307,19 @@ export class TerminalManager { // a generic "rate limit" catch covers codex / future runtimes. Best-effort text scan, no API dependency. private static readonly USAGE_LIMIT_RE = /\b(weekly limit|usage limit|rate limit|hit your .{0,20}limit|limit reached|out of (?:usage|credits))\b/i; - /** Did this unattended run die on a usage-limit refusal? Scans the final pane for the limit signature and, - * if the run launched under a rotation-pool account, parks THAT account (with a parsed reset time, or a - * 1 h fallback so an unparseable reset still rotates without wedging) so the next launch picks another. - * No account recorded (box default) → nothing to rotate; still audited so the box's limit state is visible. + // Signature of a CREDENTIAL rejection (as opposed to a usage limit): the CLI got a 401 / bad-token banner, + // meaning the injected account's token is invalid/revoked/expired and will NOT self-heal at a reset. These + // phrases are the runtime's own auth-failure banners — specific enough not to trip on ordinary output. + private static readonly AUTH_FAIL_RE = /\b(invalid bearer token|oauth token (?:has )?expired|invalid api key|failed to authenticate)\b/i; + + /** How this unattended run ended, credentials-wise — scanned from the final pane. Two distinct outcomes for + * a rotation-pool account: + * • usage-limit refusal → the token is fine but EXHAUSTED: park it `limited` until reset (self-heals, + * next launch rotates on), with a 1 h fallback when the reset can't be parsed. + * • auth failure (401 / invalid-bearer) → the token is BAD: DISABLE the account (it won't recover at a + * reset) so it drops out of the pool until a human replaces it — the launch-time backstop to add-time + * validation, for a token that was good when added but got revoked/expired since. + * No pool account (box default) → nothing to rotate; still audited so the box's own limit state is visible. * Best-effort — never throws, never blocks teardown. */ private detectUsageLimit(sessionId: string, space: string, tmux: string): void { try { @@ -2318,10 +2327,24 @@ export class TerminalManager { .get<{ agent: string; runtime_account: string | null }>(sessionId); if (!row) return; const text = this.backend.capturePane(space, tmux); - if (!text || !TerminalManager.USAGE_LIMIT_RE.test(text)) return; - const until = this.parseLimitReset(text) ?? Date.now() + 60 * 60_000; // 1h fallback keeps it parked but self-heals + if (!text) return; + const usageLimited = TerminalManager.USAGE_LIMIT_RE.test(text); + // A usage-limit banner is checked first — it's the benign, self-healing case; an auth failure is only + // acted on when there's no usage-limit signature, so an exhausted-but-valid token is never disabled. + const authFailed = !usageLimited && TerminalManager.AUTH_FAIL_RE.test(text); + if (!usageLimited && !authFailed) return; const manifest = this.os.agents.get(row.agent); const runtime: CodingRuntimeId = isCodingRuntime(manifest?.runtime) ? manifest!.runtime : 'claude-code'; + if (authFailed) { + if (row.runtime_account) { + this.os.runtimeAccounts.markInvalid(runtime, row.runtime_account, 'auto-disabled: a run authenticated with this token and was rejected (401)'); + this.audit(sessionId, 'system', 'runtime.account.invalid', { runtime, account: row.runtime_account }); + } else { + this.audit(sessionId, 'system', 'runtime.auth_failed', { runtime }); + } + return; + } + const until = this.parseLimitReset(text) ?? Date.now() + 60 * 60_000; // 1h fallback keeps it parked but self-heals if (row.runtime_account) { this.os.runtimeAccounts.markLimited(runtime, row.runtime_account, until); this.audit(sessionId, 'system', 'runtime.account.limited', { runtime, account: row.runtime_account, until }); diff --git a/web/src/App.tsx b/web/src/App.tsx index aae3a36..2471404 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13540,6 +13540,22 @@ function ConcurrencySettings({ me }: { me: Member }) { /** Settings → Runtime → Runtime accounts. The per-runtime credential POOL the launcher rotates so a session * is never spawned into an exhausted usage quota. Empty pool = inert (the box uses its single default * credentials, i.e. today's behavior). Owner-only. Never shows an api-key value — only its vault ref. */ +/** Per-account usage cell: the weekly (7d) + session (5h) windows as compact "wk 13% / 5h 33%" lines, each + * coloured by pressure (amber ≥80%, red ≥100%) with the reset time on hover. Falls back to the last check + * note (e.g. "could not verify") or a dash when there's no usage snapshot. */ +function runtimeUsageCell(a: RuntimeAccount) { + const u = a.usage + const win = (label: string, w?: { usedPct?: number; resetsAt?: number }) => { + if (!w || w.usedPct == null) return null + const pct = w.usedPct + const cls = pct >= 100 ? 'text-red-600 dark:text-red-400' : pct >= 80 ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground' + return {label} {pct}% + } + const rows = u ? [win('wk', u.weekly), win('5h', u.session)].filter(Boolean) : [] + if (rows.length) return {rows} + return {a.kind === 'token' && a.checkNote && a.checkOk !== true ? a.checkNote : '—'} +} + function RuntimeAccountsSettings({ me }: { me: Member }) { const [resp, setResp] = useState(null) const [busy, setBusy] = useState(false) @@ -13568,11 +13584,21 @@ function RuntimeAccountsSettings({ me }: { me: Member }) { const r = await api.addRuntimeAccount(body) setBusy(false) if (r.error) return setHint('⚠ ' + r.error) - setName(''); setCred(''); setToken(''); setHint('added'); setTimeout(() => setHint(''), 2000); load() + // The server validated a token before storing it and returned the account with its usage snapshot; echo + // that ("added · valid · weekly 13% used") so the operator sees the key is live without hunting the table. + setName(''); setCred(''); setToken(''); setHint(r.account?.checkNote ? `added · ${r.account.checkNote}` : 'added'); setTimeout(() => setHint(''), 6000); load() } const addDisabled = busy || !name.trim() || (effKind === 'token' ? !token.trim() : !cred.trim()) const toggle = async (a: RuntimeAccount) => { await api.setRuntimeAccountEnabled(a.runtime, a.name, !a.enabled); load() } const remove = async (a: RuntimeAccount) => { if (!confirm(`Remove account "${a.name}" (${a.runtime})?`)) return; await api.removeRuntimeAccount(a.runtime, a.name); load() } + const [checking, setChecking] = useState('') // `${runtime}/${name}` currently being re-validated + const refresh = async (a: RuntimeAccount) => { + setChecking(`${a.runtime}/${a.name}`) + const r = await api.checkRuntimeAccount(a.runtime, a.name) + setChecking('') + if (r.error) { setHint('⚠ ' + r.error); setTimeout(() => setHint(''), 3000) } + load() + } const labelFor = (id: string) => runtimes.find((r) => r.id === id)?.label ?? id return ( @@ -13602,6 +13628,7 @@ function RuntimeAccountsSettings({ me }: { me: Member }) { Runtime Credential Status + Usage Last used @@ -13613,14 +13640,20 @@ function RuntimeAccountsSettings({ me }: { me: Member }) { {labelFor(a.runtime)} {a.kind === 'oauth' ? a.configDir : `secret:${a.apiKeyRef}`} - {a.status === 'limited' + {a.checkOk === false + ? invalid + : a.status === 'limited' ? limited{a.limitedUntil ? ` · resets ${new Date(a.limitedUntil).toLocaleString()}` : ''} : available} + {runtimeUsageCell(a)} {a.lastUsedAt ? new Date(a.lastUsedAt).toLocaleString() : '—'} {canEdit && ( + {a.kind !== 'apikey' && a.runtime === 'claude-code' && ( + + )} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 63c4fbe..424821b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -68,6 +68,8 @@ export interface Concurrency { /** One credential set in the runtime rotation pool (never carries the api-key value, only its vault ref). */ export type RuntimeAccountKind = 'oauth' | 'apikey' | 'token' +export interface RuntimeUsageWindow { usedPct?: number; resetsAt?: number } +export interface RuntimeUsage { weekly?: RuntimeUsageWindow; session?: RuntimeUsageWindow } export interface RuntimeAccount { runtime: string name: string @@ -79,6 +81,10 @@ export interface RuntimeAccount { limitedUntil?: number lastUsedAt?: number createdAt: number + lastCheckedAt?: number + checkOk?: boolean + checkNote?: string + usage?: RuntimeUsage } export interface RuntimeSpecInfo { id: string; label: string; credentialEnv: { configDirVar: string; apiKeyVar: string; tokenVar?: string } } export interface RuntimeAccountsResp { accounts: RuntimeAccount[]; runtimes: RuntimeSpecInfo[]; error?: string } @@ -1543,6 +1549,7 @@ export const api = { addRuntimeAccount: (body: { runtime: string; name: string; kind: RuntimeAccountKind; configDir?: string; apiKeyRef?: string; token?: string }) => call<{ ok: boolean; error?: string; account?: RuntimeAccount }>('POST', '/api/runtime-accounts', body), setRuntimeAccountEnabled: (runtime: string, name: string, enabled: boolean) => call<{ ok: boolean; error?: string }>('PATCH', `/api/runtime-accounts/${encodeURIComponent(runtime)}/${encodeURIComponent(name)}`, { enabled }), removeRuntimeAccount: (runtime: string, name: string) => call<{ ok: boolean; error?: string }>('DELETE', `/api/runtime-accounts/${encodeURIComponent(runtime)}/${encodeURIComponent(name)}`), + checkRuntimeAccount: (runtime: string, name: string) => call<{ ok: boolean; error?: string; account?: RuntimeAccount; check?: { ok: boolean | null; note: string } }>('POST', `/api/runtime-accounts/${encodeURIComponent(runtime)}/${encodeURIComponent(name)}/check`), governance: () => call('GET', '/api/settings/governance'), saveGovernance: (t: GovernanceThresholds & { hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean }) => call<{ ok: boolean; error?: string; hostGovernanceEnabled?: boolean; semanticGuardEnabled?: boolean; fileWriteGuardEnabled?: boolean } & GovernanceThresholds>('PUT', '/api/settings/governance', t),