Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
111 changes: 111 additions & 0 deletions src/edge/runtime-account-check.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeCheckResult> {
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);
}
}
52 changes: 50 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')) {
Expand Down
9 changes: 9 additions & 0 deletions src/state/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading