diff --git a/packages/bus/README.md b/packages/bus/README.md index 8518187dd..a389fda74 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -824,6 +824,9 @@ It returns total plus byRun, byInstance, byProvider, byModel and byAccount array Each total has input, output, cacheWrite, cacheRead and unknownTurns. Known readings sum; unknown turns count distinctly by run and turn next to every total. An exact aggregate beyond JavaScript's safe integer range is a decimal string, -not a rounded count or a policy ceiling. Account is null until account tracking -ships. A session can edit its own transcript: this is a best reading, not proof. +not a rounded count or a policy ceiling. Row 126: a run's account is the one +the launcher bound it with (`launch.account.name` for a Claude Code run), kept +on the session and written on each usage record; a pi run's is null. The +account stays out of `session.launched`'s body. `mayLaunch` and `mustStop` +pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof. No enforcement or resets are added. Settings reads through the same bus view. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 5b8385acd..026e1393c 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -410,7 +410,7 @@ export class Broker { bindLaunch(record) { keys( record, - ['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model'], + ['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account'], ['business', 'role', 'run', 'harness'], ); const b = this.#business(identifier(record.business)); @@ -420,6 +420,9 @@ export class Broker { if (record.address !== undefined) string(record.address, 1024); if (record.provider !== undefined) string(record.provider, 1024); if (record.model !== undefined) string(record.model, 1024); + // Row 126: the account a claude-code run spends (launch.account.name); null for a pi run. The + // session keeps it for usage records and mustStop; session.launched's body doesn't carry it. + if (record.account !== undefined && record.account !== null) identifier(record.account); // Row 121: a thread instance's run is for one thread, and no other run names one. if (this.#threaded(record.business, record.role)) { if (record.thread === undefined) fail('thread-required'); @@ -1282,7 +1285,7 @@ export class Broker { if (!this.#limits) return; let answer; try { - answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null }); + answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null, account: s.account ?? null }); } catch { answer = { stop: true, check: null, code: 'check-threw' }; } diff --git a/packages/bus/src/limits.mjs b/packages/bus/src/limits.mjs index f80c7237e..cbcc623f9 100644 --- a/packages/bus/src/limits.mjs +++ b/packages/bus/src/limits.mjs @@ -26,10 +26,10 @@ const turns = value => Number.isSafeInteger(value) && value >= 0; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); // Every token limit in launch.limits, in file order, at its schema depth only: tokens.instances.X, -// tokens.providers.X, tokens.keys.X and tokens.models.P.M. The agent limits (total, providers, +// tokens.providers.X, tokens.keys.X, tokens.models.P.M and tokens.daily.A (row 126). The agent limits (total, providers, // models) are never checks' limits, whatever their names, so a provider called `limit` there isn't // taken for one. The settings writer (business launch-settings.mjs) walks the same depths. -const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 }; +const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 }; export function limitLeaves(limits) { const out = []; const walk = (value, depth, path) => { diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 3971bde8e..cc2046e62 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -184,7 +184,7 @@ export async function startBroker({ threadsState: (business) => broker.threadsState(business), threadIdle: (r) => broker.threadIdle(r), // Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch. - mayLaunch: ({ business, instance, provider, model }) => limits.mayLaunch(business, { instance, provider, model }), + mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }), identity: (cap) => broker.identity(cap), authorizeLaunch, checkPush, diff --git a/packages/bus/src/usage.mjs b/packages/bus/src/usage.mjs index f6a183675..eecd20150 100644 --- a/packages/bus/src/usage.mjs +++ b/packages/bus/src/usage.mjs @@ -62,7 +62,7 @@ export async function createUsage({ store, dataRoot, businesses, log }) { // This map serializes retries per run, without keeping a SQL transaction across source awaits. const pending = new Map(); async function perform(session, phase, turn) { - const { business, run, role: instance, harness, provider = null, model = null } = session; + const { business, run, role: instance, harness, provider = null, model = null, account = null } = session; const existing = store.all('SELECT * FROM usage_records WHERE business=? AND run=? AND turn=? AND phase=?', business, run, turn, phase); if (existing.length) return { recorded: true }; const loader = loaders.get(business); @@ -91,8 +91,8 @@ export async function createUsage({ store, dataRoot, businesses, log }) { const known = result.status === 'known'; const counts = phase === 'begin' ? zero() : result; store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read) - VALUES (?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance, - provider, model, turn, name, phase, result.status, result.code ?? null, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance, + provider, model, account, turn, name, phase, result.status, result.code ?? null, phase === 'begin' ? JSON.stringify(result) : null, known ? counts.input : null, known ? counts.output : null, known ? counts.cacheWrite : null, known ? counts.cacheRead : null); } diff --git a/packages/bus/tests/daily-quota-runtime.test.mjs b/packages/bus/tests/daily-quota-runtime.test.mjs new file mode 100644 index 000000000..421330e31 --- /dev/null +++ b/packages/bus/tests/daily-quota-runtime.test.mjs @@ -0,0 +1,89 @@ +// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the account a run spends. bindLaunch keeps it on the +// session, out of session.launched's body; usage records carry it; mayLaunch and mustStop pass it +// to the shipped daily-quota through the real loader. A run with no account is null throughout. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; + +const SINCE = '2026-10-18T07:00:00.000Z'; +const DAILY = ['tokens', 'daily', 'jason-max']; +const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }; + +async function setup(t, limit = 0) { + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs'); + const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo'); + doc.plugins = { 'limit-check': ['daily-quota'] }; + doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' }; + doc.launch.limits = { ...doc.launch.limits, + tokens: { daily: { 'jason-max': { limit, since: SINCE, reset: '05:00', timeZone: 'America/Chicago' } } } }; + writeJson(join(root, 'businesses/demo.json'), doc); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ dataRoot: join(root, 'data'), + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, + organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; +} + +async function turn(broker, human, cap) { + const { id } = broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } }); + broker.request(cap, { verb: 'message.receive' }); + await broker.request(cap, { verb: 'usage.begin', args: { turn: id } }); + await broker.request(cap, { verb: 'usage.end', args: { turn: id } }); +} + +test('REQ-LIMIT-2 runtime: limits counts the daily quota, and mayLaunch refuses only the account at it', async t => { + const { bus, human } = await setup(t); + const [row] = bus.broker.request(human, { verb: 'limits' }).tokens; + assert.deepEqual({ path: row.path, limit: row.limit, since: row.since, status: row.status, check: row.check, count: row.count }, + { path: DAILY, limit: 0, since: SINCE, status: 'counted', check: 'daily-quota', count: 0 }); + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'anthropic', model: 'opus', account: 'jason-max' }), + { allow: false, check: 'daily-quota', hit: { path: DAILY, limit: 0, count: 0, unknownTurns: 0 } }); + // A pi launch has no account, so no daily quota covers it; the op may also leave it out. + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm', account: null }), { allow: true }); + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm' }), { allow: true }); +}); + +test('REQ-LIMIT-4 runtime: a run bound with the account writes it on its usage records and stops at the quota', async t => { + const { bus, human } = await setup(t); + const { broker } = bus; + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'claude-code', provider: 'anthropic', model: 'opus', account: 'jason-max' }); + broker.request(coder, { verb: 'role.claim' }); + await turn(broker, human, coder); + assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), ['jason-max']); + assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' }); + assert.deepEqual(bus.endLaunch({ business: 'demo', run: 'run1', reason: 'exited', exitCode: 22 }), + { released: false, limit: 'limit: daily-quota tokens.daily.jason-max limit 0 count 0' }); +}); + +test('REQ-LIMIT-4 runtime: a run bound without an account records null and the daily quota never stops it', async t => { + const { bus, human } = await setup(t); + const { broker } = bus; + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'zai', model: 'glm' }); + broker.request(coder, { verb: 'role.claim' }); + await turn(broker, human, coder); + assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), [null]); + assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []); +}); + +test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one that is not an identifier, and takes null as none', t => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } }); + const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' }; + for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries()) + assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account)); + assert.ok(broker.bindLaunch({ ...base, run: 'none', account: null })); + assert.ok(broker.bindLaunch({ ...base, run: 'run1', account: 'jason-max' })); + // The body is what it was before row 126, so a rebind after a broker restart compares the same. + const { body } = store.get("SELECT body FROM events WHERE kind='session.launched' AND subject='run1'"); + assert.deepEqual(JSON.parse(body), { harness: 'claude-code', address: null, pid: null, startTime: null }); +}); diff --git a/packages/business/README.md b/packages/business/README.md index ead6538d1..f1140b36c 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -107,6 +107,15 @@ instance whose definition holds `role.launch` within-role; otherwise the file refuses. Only that instance keeps `role.launch`. Every other instance loses it, so for them it's gated. +`launch.account` (optional, row 126) is `{name, credential}`: the account +the business's Claude Code launches spend, an identifier, and the +environment variables of one login set joined with `+` (for example +`CLAUDE_CODE_OAUTH_TOKEN`), never a value. Which sets exist is the +launcher's check. `launch.limits.tokens.daily` maps an account to +`{limit, since, reset, timeZone}`: `reset` is `HH:MM`, `timeZone` an IANA +name `Intl` accepts. A daily quota for any name but `launch.account.name` +refuses the file, since it would count nothing. + `launch.threadIdleSeconds` (optional, row 121) is how long a thread's session runs with nothing pending and no new message or open before the launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index 5a13c80ef..aad1187bd 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, normalize } from "node:path"; import { homedir } from "node:os"; import { refuse } from "./errors.mjs"; -import { checkModels, checkLimits } from "./launch.mjs"; +import { checkModels, checkLimits, checkAccount } from "./launch.mjs"; import { checkVars } from "./vars.mjs"; import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; @@ -118,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) { const where = `${file} launch`; requireObject(launch, where); if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`); - rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds"], where); + rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where); requireId(launch.by, `${where}.by`); if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`); const launcher = definitions[roles[launch.by].definition]; @@ -133,10 +133,16 @@ function checkLaunch(launch, roles, definitions, file) { const models=checkModels(launch.models,`${where}.models`); const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`); for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`); + // Row 126 (#1620): a daily quota for any account but launch.account would count nothing and still + // show as counted, so it refuses the read. + const account = launch.account === undefined ? undefined : checkAccount(launch.account, `${where}.account`); + for (const name of Object.keys(limits.tokens?.daily ?? {})) { + if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`); + } for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`); // No upper bound: limits have no ceiling in code (Q150). const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`); - return { by: launch.by, instances, models, limits, threadIdleSeconds }; + return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) }; } // Validate a parsed business document read from `file`. `rolesDir` is where diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs index 670320b54..be8a0ee99 100644 --- a/packages/business/src/launch-settings.mjs +++ b/packages/business/src/launch-settings.mjs @@ -18,10 +18,10 @@ const own = (value, key) => value !== null && typeof value === "object" && !Arra const plain = value => value !== null && typeof value === "object" && !Array.isArray(value); // Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X, -// tokens.providers.X, tokens.keys.X and tokens.models.P.M. Nothing else in launch.limits is one, +// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one, // so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is // never mistaken for a leaf. The bus core's limitLeaves walks the same depths. -const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 }; +const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 }; function tokenLeaves(limits) { const out = []; const walk = (value, depth, path) => { diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs index 6aaa8f5eb..6f9b18690 100644 --- a/packages/business/src/launch.mjs +++ b/packages/business/src/launch.mjs @@ -34,15 +34,33 @@ const tokenLimit = (value, at) => { new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`); return { limit: count(value.limit, `${at}.limit`), since }; }; +// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day +// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00 +// is refused) that Intl accepts. +const RESET = /^([01]\d|2[0-3]):[0-5]\d$/; +const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/; +const dailyQuota = (value, at) => { + requireObject(value, at); + rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at); + const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at); + if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`); + const timeZone = value.timeZone; + let known = typeof timeZone === "string" && ZONE.test(timeZone); + if (known) { + try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; } + } + if (!known) refuse(`${at}.timeZone must be an IANA time zone name`); + return { limit, since, reset: value.reset, timeZone }; +}; const tokenMap = (value, at, key, each) => { requireObject(value, at); return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)])); }; -// launch.limits.tokens: instances, providers, models (per provider) and keys, each optional. +// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional. export function checkTokenLimits(value, where = "launch.limits.tokens") { requireObject(value, where); - rejectUnknownKeys(value, ["instances", "providers", "models", "keys"], where); + rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where); const out = {}; if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit); if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit); @@ -51,9 +69,25 @@ export function checkTokenLimits(value, where = "launch.limits.tokens") { } // A key is a name until row 126 names the accounts. if (Object.hasOwn(value, "keys")) out.keys = tokenMap(value.keys, `${where}.keys`, requireId, tokenLimit); + // One quota per account; the business read checks each key names launch.account. + if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota); return out; } +// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the +// environment variables of one credential set, joined with "+", so a secret can't pass the check. +// Which sets exist is the launcher's to check (seat imports business, not the other way). +const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/; +export function checkAccount(value, where = "launch.account") { + requireObject(value, where); + rejectUnknownKeys(value, ["name", "credential"], where); + const accountName = requireId(value.name, `${where}.name`); + if (typeof value.credential !== "string" || !CREDENTIAL.test(value.credential)) { + refuse(`${where}.credential must name environment variables, joined with "+"`); + } + return { name: accountName, credential: value.credential }; +} + export function checkLimits(value = {}, where = "launch.limits") { requireObject(value, where); rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where); diff --git a/packages/business/tests/token-limit-settings.test.mjs b/packages/business/tests/token-limit-settings.test.mjs index 8ac3e953e..9fd18717a 100644 --- a/packages/business/tests/token-limit-settings.test.mjs +++ b/packages/business/tests/token-limit-settings.test.mjs @@ -101,3 +101,52 @@ test('REQ-LIMIT-3 a reset moves since to now, is logged with its actor, and refu assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset},'jason'),{code:'invalid-request'},JSON.stringify(reset)); assert.equal(readFileSync(f.file,'utf8'),bytes); }); + +// Row 126 (#1620, REQ-LIMIT-2): launch.account and tokens.daily.. +const ACCOUNT={name:'max',credential:'CLAUDE_CODE_OAUTH_TOKEN'}; +const DAILY={limit:40,since:T0,reset:'05:00',timeZone:'America/Chicago'}; +test('REQ-LIMIT-2 schema: launch.account names a credential set; a daily quota needs a reset and an IANA zone and names the account',()=>{ + const f=fixture(),read=(launch)=>{const d=structuredClone(f.doc);Object.assign(d.launch,launch);return validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir}).launch;}; + // No account: the launch reads as before, with no account key. + assert.equal(Object.hasOwn(read({}),'account'),false); + for(const credential of ['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL']) + assert.deepEqual(read({account:{name:'max',credential}}).account,{name:'max',credential}); + const ok=read({account:ACCOUNT,limits:{tokens:{daily:{max:DAILY}}}}); + assert.deepEqual(ok.limits.tokens.daily,{max:DAILY}); + for(const timeZone of ['UTC','Etc/GMT+5','America/Argentina/Buenos_Aires']) + assert.equal(read({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}}).limits.tokens.daily.max.timeZone,timeZone); + const bad=[ + {account:{name:'max'}},{account:{credential:'ANTHROPIC_API_KEY'}},{account:{name:'Max Account',credential:'ANTHROPIC_API_KEY'}}, + {account:{name:'max',credential:'anthropic_api_key'}},{account:{name:'max',credential:'sk-ant-api03-abc'}}, + {account:{name:'max',credential:'ANTHROPIC_API_KEY+'}},{account:{name:'max',credential:'ANTHROPIC_API_KEY',key:'x'}},{account:'max'}, + ]; + for(const reset of ['5:00','24:00','05:60','05:00:00','',undefined,500])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,reset}}}}}); + for(const timeZone of ['Chicago time','+05:00','-0600','Mars/Phobos','America/Chicago ','',undefined,5])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}}); + bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,limit:-1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,since:'2026-10-18'}}}}}, + {account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,extra:1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{limit:40,since:T0}}}}}); + // A daily quota for another account, or with no account at all, would count nothing. + bad.push({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}},{limits:{tokens:{daily:{max:DAILY}}}}); + for(const launch of bad)assert.throws(()=>read(launch),JSON.stringify(launch)); + // The credential check's refusal names the field, never the value given. + assert.throws(()=>read({account:{name:'max',credential:'sk-ant-api03-abc'}}),e=>/launch\.account\.credential/.test(e.message)&&!e.message.includes('sk-ant')); + assert.throws(()=>read({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}}),/daily names other/); +}); + +test('REQ-LIMIT-2 the writer stamps a daily quota\'s since, keeps its reset and zone, and a reset moves since',()=>{ + const f=fixture(); + f.doc.launch.account=ACCOUNT;writeJson(f.file,f.doc); + let view=f.settings.read('acme'); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago'}}}}},'jason'); + assert.deepEqual(stored(f),{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago',since:T0}}}); + f.tick(T1); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC'}}}}},'jason'); + assert.deepEqual(stored(f),{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T0}}}); + assert.throws(()=>f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T2}}}}},'jason'),{code:'invalid-request'}); + f.tick(T2); + view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily','max']]},'chief'); + assert.equal(stored(f).daily.max.since,T2); + assert.equal(lastLog(f).actor,'chief'); + assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily']]},'jason'),{code:'invalid-request'}); + // The account itself stays a hand edit: the file's account is untouched by the writes. + assert.deepEqual(JSON.parse(readFileSync(f.file,'utf8')).launch.account,ACCOUNT); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index 20f3fe739..94c02ef42 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -341,6 +341,8 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or | the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` | | its role has a contract file | `no-role-contract` | | a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` | +| with `launch.account` set, a Claude Code session's account names one of those login sets and the bus host holds all of it | `account-credential-missing` | +| with `launch.account` set, the bus host sets no other of those variables, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` | | bwrap runs here | `sandbox-unavailable` | | its model selects an exact entry in `launch.models` | `unsupported-harness` | | total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` | @@ -348,6 +350,13 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or | the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id | | the bundle builds and the runner starts | `launch-failed` | +Row 126 (#1620): only a Claude Code launch spends `launch.account.name`. It +goes to the `mayLaunch` limit checks, to the bus with the run, onto the +run's usage records, and onto the `launch` record as `account`. A pi launch +has account null: its records say null, no daily quota covers it, and the +two account checks above are skipped. Both account refusals name the +variables, never a value. + Every refusal is an `action.refused` event (the broker's `refuse`, or its own refusal for the last broker check) and a line in the launch log. Then the launcher reads the instance's credential status from the broker diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index f9271003a..4b905bd82 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -243,6 +243,17 @@ export function createLauncher({ const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", "); throw new Refusal("no-session-credential", `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts`); } + // Row 126 (#1620): only a claude-code launch spends the named account; a pi launch has none. + // The bus environment must hold the named set and no other credential variable, so the session + // can't log in some other way. Messages name variables, never values. + const account = model.harness === "claude-code" ? b.launch.account?.name ?? null : null; + if (account !== null) { + const named = b.launch.account.credential.split("+"); + if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k]))) + throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`); + const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k])); + if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`); + } const unavailable = sandboxProblem({ bwrap }); if (unavailable) throw new Refusal("sandbox-unavailable", unavailable); const hit=limitHit(registry.sessions,b.id,b.launch.limits,model); @@ -251,7 +262,7 @@ export function createLauncher({ // and so does a bus that can't answer (fail closed, lead decision 150). let may; try { - may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model }); + may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model, account }); } catch (e) { throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`); } @@ -259,11 +270,11 @@ export function createLauncher({ if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; } throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`); } - return { resolved, model }; + return { resolved, model, account }; } // Bundle, spawn, bind, register, hand over the capability. - async function start({ instance, launchedBy, resolved, model, thread = null }) { + async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) { // A thread's store is checked first, so a bad id or an unsafe directory // launches nothing. let store = null; @@ -293,7 +304,7 @@ export function createLauncher({ if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason }); else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason }); } - ({resolved,model}=await check(instance,{pm:instance===b.launch.by,thread})); + ({resolved,model,account}=await check(instance,{pm:instance===b.launch.by,thread})); const { run, runDir } = newRun(dataRoot, b.id); const sessionDir = store ? store.session : join(runDir, "session"); if (!store) mkdirSync(sessionDir, { mode: 0o700 }); @@ -369,7 +380,7 @@ export function createLauncher({ const outerStart = startTimeOf(child.pid); const runner = await runnerOf(child); if (!outerStart) throw new Error("the session process exited at once"); - const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) }); + const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) }); bound = true; entry = { business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, @@ -404,7 +415,7 @@ export function createLauncher({ refusedRelease.delete(run); } }); - record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest }); + record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest }); return { run, instance, harness: model.harness, model: model.model }; } diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs index 4f8a8ec3d..e1243ffa3 100644 --- a/packages/cli/src/limits.mjs +++ b/packages/cli/src/limits.mjs @@ -11,6 +11,8 @@ export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file "reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " + "PATCH keys: revision, limits, models, instances, reset, assistants"; +export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else."; + // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): one line per token limit, its path in the words // `mosaic limits reset` takes. A limit no switched-on check counts reads "not enforced". export function formatTokens(tokens) { @@ -24,7 +26,9 @@ export function formatTokens(tokens) { if (t.status === "not-enforced") return `${head}, not enforced${t.reason ? `: ${t.reason}` : ""}`; return `${head}, check failed: ${t.code}${t.check ? ` (${t.check})` : ""}`; }); - return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n`; + // Row 126 (#1620): a daily quota counts only the records this stack wrote. Said once, after the list. + const daily = tokens.some(t => t.path?.[1] === "daily") ? `${DAILY_NOTE}\n` : ""; + return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n${daily}`; } export function parseLimits(args) { diff --git a/packages/cli/tests/launch-account.test.mjs b/packages/cli/tests/launch-account.test.mjs new file mode 100644 index 000000000..5646dcb68 --- /dev/null +++ b/packages/cli/tests/launch-account.test.mjs @@ -0,0 +1,94 @@ +// Row 126 (#1620, REQ-LIMIT-2): the account a launch spends. Only a claude-code launch carries +// launch.account.name; the bus environment must hold the named login set and no other credential +// variable, and the refusals name variables, never values. A pi launch has account null and skips +// both checks. A stub host answers the ops; no sandbox, Docker or session process. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createLauncher} from '../src/launcher.mjs'; +import {fixture,tmp} from './helpers.mjs'; +import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs'; + +const VALUE='stand-in-value-7f3a'; +const ACCOUNT={name:'jason-max',credential:'CLAUDE_CODE_OAUTH_TOKEN'}; + +// set is the credential variables the bus environment holds; every other one is removed. +function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_TOKEN']}={}){ + const root=tmp(t),f=fixture(root,'acme',doc=>{ + doc.roles.coder.vars.model='claude-sonnet-5-5';doc.roles.coder.vars.harness=harness; + if(account)doc.launch.account=account; + return doc; + }),ops=[]; + for(const k of SESSION_CREDENTIALS.flat())delete f.env[k]; + for(const k of set)f.env[k]=VALUE; + const host={business:'acme',beforeClose:()=>{},op:async x=>{ + ops.push(x); + if(x.op==='identity')return {business:'acme',role:'pm'}; + if(x.op==='mayLaunch')return {allow:true}; + if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'}); + return {}; + }}; + const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}}); + const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1)); + const may=()=>ops.find(x=>x.op==='mayLaunch'); + return {launcher,ops,last,may}; +} + +test('REQ-LIMIT-2 a claude-code launch asks mayLaunch with launch.account.name',async t=>{ + const {launcher,may}=setup(t); + assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy'); + assert.equal(may().account,'jason-max'); +}); + +test('REQ-LIMIT-2 each login set the account can name is accepted when the bus host holds exactly it',async t=>{ + for(const set of SESSION_CREDENTIALS){ + const {launcher,may}=setup(t,{account:{name:'jason-max',credential:set.join('+')},set}); + assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join('+')); + assert.equal(may().account,'jason-max'); + } +}); + +test('REQ-LIMIT-2 account-credential-missing: the named set is not a login set, or the bus host lacks part of it',async t=>{ + for(const [credential,set] of [ + ['ANTHROPIC_API_KEY',['CLAUDE_CODE_OAUTH_TOKEN']], + // The host holds a login (OAuth), so no-session-credential passes; the named set lacks its URL. + ['ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL',['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_AUTH_TOKEN']], + ['ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']], + ['ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']], + ]){ + const {launcher,ops,last}=setup(t,{account:{name:'jason-max',credential},set}); + assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-missing'},credential); + const event=last(); + assert.equal(event.code,'account-credential-missing'); + assert.match(event.why,new RegExp(`^account jason-max names ${credential.split('+').join(' with ')},`)); + assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log'); + assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,credential); + } +}); + +test('REQ-LIMIT-2 account-credential-ambiguous: the bus host sets a credential variable outside the named set, ANTHROPIC_BASE_URL included',async t=>{ + for(const [extra,named] of [[['ANTHROPIC_API_KEY'],'ANTHROPIC_API_KEY'],[['ANTHROPIC_BASE_URL'],'ANTHROPIC_BASE_URL'], + [['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL'],'ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL']]){ + const {launcher,ops,last}=setup(t,{set:['CLAUDE_CODE_OAUTH_TOKEN',...extra]}); + assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-ambiguous'},named); + const event=last(); + assert.equal(event.why,`account jason-max names CLAUDE_CODE_OAUTH_TOKEN, but the bus host also sets ${named}`); + assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log'); + assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,named); + } +}); + +test('REQ-LIMIT-2 a pi launch carries account null and skips both account checks',async t=>{ + // No credential variable at all, and two at once: neither refuses a pi launch. + for(const set of [[],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){ + const {launcher,may}=setup(t,{harness:'pi',set}); + assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join()); + assert.equal(may().account,null); + } +}); + +test('REQ-LIMIT-2 with no launch.account a claude-code launch carries null and the environment is not checked for ambiguity',async t=>{ + const {launcher,may}=setup(t,{account:null,set:['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']}); + assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy'); + assert.equal(may().account,null); +}); diff --git a/packages/cli/tests/launch-limit-checks.test.mjs b/packages/cli/tests/launch-limit-checks.test.mjs index 68f1384fd..488f421b6 100644 --- a/packages/cli/tests/launch-limit-checks.test.mjs +++ b/packages/cli/tests/launch-limit-checks.test.mjs @@ -26,10 +26,10 @@ function setup(t,answer){ return {launcher,ops,last}; } -test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider and model, and goes on to authorization on an allow',async t=>{ +test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider, model and account (row 126), and goes on to authorization on an allow',async t=>{ const {launcher,ops}=setup(t,{allow:true}); assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy'); - assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5'}); + assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5',account:null}); assert.ok(ops.findIndex(x=>x.op==='mayLaunch')x.op==='authorizeLaunch')); }); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index e7cd64b25..ab15f26a0 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -171,6 +171,8 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its const [launched] = log(f).filter((e) => e.event === "launch"); assert.equal(launched.run, chief.run); assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json")); + // Row 126: a pi launch spends no account, so the record says null. + assert.equal(launched.account, null); assert.equal(manifest.model, "claude-opus-5-5"); // A second PM is refused before anything spawns. @@ -708,6 +710,8 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything const given = prepare(t, (doc) => { doc.roles.coder.vars.harness = "claude-code"; doc.roles.coder.vars["limits.tools"] = ["bash"]; + // Row 126: the claude-code launch spends this account, which names the proxy login set. + doc.launch.account = { name: "stand-in", credential: "ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL" }; return doc; }); delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN; @@ -743,6 +747,7 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything assert.equal(JSON.parse(launched.body).instance, "coder", launched.body); const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder"); assert.equal(coder.harness, "claude-code"); + assert.deepEqual(log(f).filter((e) => e.event === "launch").map((e) => [e.instance, e.account]), [["chief", null], ["coder", "stand-in"]]); const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "probe" }); const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id), 150000); @@ -758,6 +763,13 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything // The session logged in with the stand-in proxy key, against the mock. assert.ok(api.requests.length > 0); assert.ok(api.requests.some((r) => r.tools.includes("Bash")), api.requests.map((r) => r.tools.join(",")).join(" | ")); + // Row 126: the bus got the account with the run, so the coder's turn records it (no usage + // source here, so the turn is unknown); the pi chief's turns record null. + const accounts = await until(async () => { + const seen = (await client.call("usage")).byAccount.map((r) => r.account); + return seen.includes("stand-in") && seen; + }); + assert.deepEqual([...accounts].sort(), [null, "stand-in"]); }); for (const exited of [false, true]) { diff --git a/packages/cli/tests/limits-tokens.test.mjs b/packages/cli/tests/limits-tokens.test.mjs index b2dc19fd5..6c85a9d6a 100644 --- a/packages/cli/tests/limits-tokens.test.mjs +++ b/packages/cli/tests/limits-tokens.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {main} from '../src/cli.mjs'; -import {formatTokens} from '../src/limits.mjs'; +import {formatTokens,DAILY_NOTE} from '../src/limits.mjs'; import {io,tmp} from './helpers.mjs'; const SINCE='2026-10-18T07:00:00.000Z'; @@ -26,6 +26,15 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 formatTokens shows each limit counted, not enforce for(const none of [[],undefined])assert.equal(formatTokens(none),'token limits: none\n'); }); +test('REQ-LIMIT-2 row 126: any daily quota row adds one line after the list saying the count is this stack\'s own use',()=>{ + const daily={path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0}; + const out=formatTokens([TOKENS[0],daily,{...daily,path:['tokens','daily','other'],status:'not-enforced',reason:null}]); + assert.ok(out.endsWith(` tokens daily other: limit 40 since ${SINCE}, not enforced\n${DAILY_NOTE}\n`),out); + assert.equal(out.split(DAILY_NOTE).length,2,'said once'); + assert.equal(DAILY_NOTE,"Daily quotas count only this stack's own use of the account, not its use anywhere else."); + assert.ok(!formatTokens(TOKENS).includes(DAILY_NOTE)); +}); + test('REQ-LIMIT-3 the view prints the limits then the token lines; --json keeps tokens in the result',async t=>{ const root=tmp(t),deps={system:{dataRoot:root},transport:()=>()=>({revision:'b'.repeat(64),limits:{total:3},tokens:TOKENS})}; const x=io();await main(['limits','--business','acme'],x,deps); diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 66b1279b2..e2723c1d7 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -193,6 +193,22 @@ sets `since` when a limit is added; a changed limit keeps it, a caller's record's account; while no record names one, a key limit reports as not enforced. See `limit-check/token-limit/README.md`. +### daily-quota + +`limit-check/daily-quota` (row 126) enforces `launch.limits.tokens.daily`, +one quota per account, each with the `reset` time of day and the IANA +`timeZone` its day starts in: + +```json +"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}} +``` + +A count is the sum of all four kinds over the account's known records since +the later of the day's start and `since`. Only a Claude Code launch carries +an account; a pi launch's is null, so no quota covers it. The count is this +stack's own use of the account, not its use anywhere else, and `mosaic +limits` and Settings say so. See `limit-check/daily-quota/README.md`. + ## Human project/thread channels `channel` modules carry project/thread operations between a human surface and diff --git a/packages/plugins/limit-check/daily-quota/README.md b/packages/plugins/limit-check/daily-quota/README.md new file mode 100644 index 000000000..ec1654ca1 --- /dev/null +++ b/packages/plugins/limit-check/daily-quota/README.md @@ -0,0 +1,41 @@ +# Daily quota check + +Row 126 (#1620), REQ-LIMIT-2 and REQ-LIMIT-4, limit-check interface 2. Enable +with `"plugins": { "limit-check": ["daily-quota"] }` in the organization file, +keeping its other plug-in kinds. Human changes use `mosaic plugins set` and the +current revision. + +It enforces `launch.limits.tokens.daily`, one quota per account: + +```json +"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}} +``` + +- The account is `launch.account.name`. Only a claude-code launch carries it; + a pi launch has account null, so no daily quota covers it. +- A day starts at `reset`, wall-clock time in `timeZone`. A date's reset is + the earliest instant on that local date at or after `reset`: when the + clocks skip it, the day starts at the first instant after the gap (02:30 + on 2026-03-08 in America/Chicago is 03:00 CDT); when they repeat it, the + first one starts the day and the second starts nothing. A day with a DST + change is 23 or 25 hours long. +- A count is `input + output + cacheWrite + cacheRead` summed over the known + per-turn records whose `account` is the quota's and whose `at` is at or + after the later of the day's start and `since`. An unknown turn adds + nothing; `unknownTurns` counts the distinct unknown turns apart, so a count + is a floor, not proof. +- `mayLaunch` and `mustStop` answer the first quota for the subject's + account whose count is at or over it. A limit of 0 refuses every launch on + the account. +- `report` gives one counted entry per quota for the day that holds `now`. + Its `since` is the quota's own `since`, not the day's start. +- The count is this stack's own use of the account. Use of the same account + anywhere else isn't in the records, and the CLI and Settings say so. +- Counts are summed as BigInt; one past `Number.MAX_SAFE_INTEGER` is reported + as a digit string. + +The module is pure: it reads no file and no store. A bad `since`, record `at` +or `now` throws `bad-time`, a bad amount `bad-record`, records that aren't a +list `bad-records`, and a quota without a whole `limit`, an `HH:MM` `reset` +or a zone `Intl` accepts `bad-leaf`; core turns any throw into a refusal, a +stop, or `check failed` (fail closed). diff --git a/packages/plugins/limit-check/daily-quota/index.mjs b/packages/plugins/limit-check/daily-quota/index.mjs new file mode 100644 index 000000000..b6addc1ab --- /dev/null +++ b/packages/plugins/limit-check/daily-quota/index.mjs @@ -0,0 +1,115 @@ +// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): a daily token quota per account. Pure: core passes +// the business's launch limits, its per-turn usage records and the time, and nothing here reads a +// file or a store. Each `launch.limits.tokens.daily.` leaf is {limit, since, reset, +// timeZone}. A count is every kind summed over the account's known records since the later of the +// day's reset and the leaf's `since`; an unknown turn adds nothing and is counted on its own. +// It copies token-limit's tally helpers rather than importing a sibling check, so each loads alone. +export const interfaceVersion = 2; + +const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead']; +const SAFE = BigInt(Number.MAX_SAFE_INTEGER); +const DAY = 86400000; +const RESET = /^([01]\d|2[0-3]):([0-5]\d)$/; +const refuse = code => { throw Object.assign(new Error(code), { code }); }; +const plain = n => n <= SAFE ? Number(n) : String(n); +const time = value => { + const t = typeof value === 'string' ? Date.parse(value) : NaN; + if (Number.isNaN(t)) refuse('bad-time'); + return t; +}; +const amount = value => Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : refuse('bad-record'); + +// The wall-clock reading of instant t in a zone, as the UTC milliseconds of the same reading. +function reader(timeZone) { + let format; + try { + format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', + day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }); + } catch { refuse('bad-leaf'); } + return t => { + const p = Object.fromEntries(format.formatToParts(new Date(t)).map(x => [x.type, Number(x.value)])); + return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) + (t % 1000 + 1000) % 1000; + }; +} + +// Date D's reset: the earliest instant whose local date is D and whose local time is at or after +// `reset`. D is given as the UTC midnight of its date; wall is D's reset as a local reading. +// Each offset in force a day either side gives a candidate. A candidate that reads back as the +// reset is one; the earliest wins (a repeated hour). If neither does, the reset is in a skipped +// hour, and the first instant reading at or after it lies between the two candidates. +function resetOf(local, date, minutes) { + const wall = date + minutes * 60000; + const candidates = [wall - (local(wall - DAY) - (wall - DAY)), wall - (local(wall + DAY) - (wall + DAY))].sort((a, b) => a - b); + const exact = candidates.find(c => local(c) === wall); + if (exact !== undefined) return exact; + let [lo, hi] = candidates; + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + if (local(mid) >= wall) hi = mid; else lo = mid; + } + return hi; +} + +// The start of the day window that holds now. +function windowStart(entry, now) { + const local = reader(entry.timeZone); + const [, hh, mm] = RESET.exec(entry.reset); + const minutes = Number(hh) * 60 + Number(mm); + const today = Math.floor(local(now) / DAY) * DAY; + const start = resetOf(local, today, minutes); + return start <= now ? start : resetOf(local, today - DAY, minutes); +} + +function leaves(limits) { + const daily = limits?.tokens?.daily ?? {}; + return Object.entries(daily).map(([account, entry]) => { + if (!Number.isSafeInteger(entry?.limit) || entry.limit < 0 || typeof entry.reset !== 'string' || !RESET.test(entry.reset) || + typeof entry.timeZone !== 'string') refuse('bad-leaf'); + return { path: ['tokens', 'daily', account], entry, account }; + }); +} + +function tally(leaf, records, now) { + const from = Math.max(windowStart(leaf.entry, now), time(leaf.entry.since)); + const kinds = Object.fromEntries(KINDS.map(k => [k, 0n])); + const unknown = new Set(); + for (const r of records) { + if (r.account !== leaf.account || time(r.at) < from) continue; + if (r.status === 'known') for (const k of KINDS) kinds[k] += amount(r[k]); + else unknown.add(JSON.stringify([r.run, r.turn])); + } + return { count: KINDS.reduce((sum, k) => sum + kinds[k], 0n), kinds, unknownTurns: unknown.size }; +} + +// The first quota for the subject's account whose count is at or over it. +function hitFor({ limits, subject, records, now }) { + if (!Array.isArray(records)) refuse('bad-records'); + const at = time(now); + for (const leaf of leaves(limits)) { + if (subject?.account !== leaf.account) continue; + const { count, unknownTurns } = tally(leaf, records, at); + if (count >= BigInt(leaf.entry.limit)) return { path: leaf.path, limit: leaf.entry.limit, count: plain(count), unknownTurns }; + } + return null; +} + +export function mayLaunch(args) { + const hit = hitFor(args); + return hit ? { allow: false, hit } : { allow: true }; +} + +export function mustStop(args) { + const hit = hitFor(args); + return hit ? { stop: true, hit } : { stop: false }; +} + +// Every daily quota with today's count. `since` is the leaf's own, as core requires. +export function report({ limits, records, now }) { + if (!Array.isArray(records)) refuse('bad-records'); + const at = time(now); + return leaves(limits).map(leaf => { + const { path, entry: { limit, since } } = leaf; + const { count, kinds, unknownTurns } = tally(leaf, records, at); + return { path, limit, since, count: plain(count), kinds: Object.fromEntries(KINDS.map(k => [k, plain(kinds[k])])), unknownTurns }; + }); +} diff --git a/packages/plugins/tests/daily-quota.test.mjs b/packages/plugins/tests/daily-quota.test.mjs new file mode 100644 index 000000000..c09c1bfe8 --- /dev/null +++ b/packages/plugins/tests/daily-quota.test.mjs @@ -0,0 +1,171 @@ +// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the daily-quota check on its own. It is pure, so +// these tests pass it limits, records and a time and read its answers; no loader, store or file. +// America/Chicago in 2026: CDT (UTC-5) until 2026-11-01 07:00Z, CST (UTC-6) from then, and CDT +// again from 2026-03-08 08:00Z. +import test from "node:test"; +import assert from "node:assert/strict"; +import * as check from "../limit-check/daily-quota/index.mjs"; + +const SINCE = "2026-01-01T00:00:00.000Z"; +const NOW = "2026-10-18T12:00:00.000Z"; +const ZONE = "America/Chicago"; +const PATH = ["tokens", "daily", "max"]; +const subject = (over = {}) => ({ instance: "coder", provider: "anthropic", model: "opus", account: "max", ...over }); +let seq = 0; +const known = (at, over = {}, counts = [10, 20, 30, 40]) => { + const [input, output, cacheWrite, cacheRead] = counts; + return { at, run: "r1", turn: `t${++seq}`, source: "pi-session", instance: "coder", provider: "anthropic", model: "opus", + account: "max", status: "known", input, output, cacheWrite, cacheRead, ...over }; +}; +const unknown = (at, over = {}) => ({ ...known(at, over), status: "unknown", input: null, output: null, cacheWrite: null, cacheRead: null }); +const quota = (over = {}) => ({ limit: 1000, since: SINCE, reset: "05:00", timeZone: ZONE, ...over }); +const args = (daily, records, over = {}) => ({ business: "demo", limits: { tokens: { daily } }, subject: subject(), records, now: NOW, ...over }); +const count = (daily, records, now) => check.report(args(daily, records, { now }))[0].count; + +test("REQ-LIMIT-2 daily-quota exports interface version 2 with mayLaunch, mustStop and report", () => { + assert.equal(check.interfaceVersion, 2); + for (const name of ["mayLaunch", "mustStop", "report"]) assert.equal(typeof check[name], "function"); +}); + +test("REQ-LIMIT-2 the count sums every kind of the account's known records since the later of the day start and since", () => { + const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 2, 3, 4]), known("2026-10-18T11:30:00.000Z", { account: "other" }), + known("2026-10-18T11:40:00.000Z", { account: null }), known("2026-10-18T11:50:00.000Z", {}, [5, 0, 0, 5])]; + assert.deepEqual(check.report(args({ max: quota() }, records)), [{ path: PATH, limit: 1000, since: SINCE, count: 20, + kinds: { input: 6, output: 2, cacheWrite: 3, cacheRead: 9 }, unknownTurns: 0 }]); + // A since after the day's start moves the count's start to it; report keeps the quota's own since. + const late = "2026-10-18T11:45:00.000Z"; + assert.deepEqual(check.report(args({ max: quota({ since: late }) }, records))[0], + { path: PATH, limit: 1000, since: late, count: 10, kinds: { input: 5, output: 0, cacheWrite: 0, cacheRead: 5 }, unknownTurns: 0 }); + // Another account's quota covers neither these records nor this subject's launches. + const full = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")]; + assert.deepEqual(check.mayLaunch(args({ other: quota({ limit: 150 }) }, full)), { allow: true }); + assert.equal(check.report(args({ other: quota({ limit: 150 }) }, full))[0].count, 0); + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 150 }) }, full, { subject: subject({ account: "other" }) })), { allow: true }); +}); + +test("REQ-LIMIT-2 a launch with no account (pi) is covered by no daily quota", () => { + const records = [known("2026-10-18T11:00:00.000Z", { account: null })]; + const a = args({ max: quota({ limit: 0 }) }, records, { subject: subject({ account: null }) }); + assert.deepEqual(check.mayLaunch(a), { allow: true }); + assert.deepEqual(check.mustStop(a), { stop: false }); + assert.equal(check.report(a)[0].count, 0); +}); + +test("REQ-LIMIT-4 an unknown turn adds nothing and is counted apart", () => { + const u = unknown("2026-10-18T11:00:00.000Z", { run: "r2", turn: "u1" }); + const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 1, 1, 1]), u, { ...u, source: "other-source" }, + unknown("2026-10-18T11:10:00.000Z", { run: "r2", turn: "u2" }), unknown("2026-10-18T09:00:00.000Z", { run: "r2", turn: "u0" })]; + const [entry] = check.report(args({ max: quota({ limit: 5 }) }, records)); + assert.equal(entry.count, 4); + assert.equal(entry.unknownTurns, 2); + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 5 }) }, records)), { allow: true }); +}); + +test("REQ-LIMIT-2 the day turns at the reset in its zone, not in UTC, and before it the day is yesterday's", () => { + const daily = { max: quota() }; // 05:00 America/Chicago is 10:00Z in October + const records = [known("2026-10-17T11:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-10-18T09:59:59.999Z", {}, [10, 0, 0, 0]), + known("2026-10-18T10:00:00.000Z", {}, [100, 0, 0, 0])]; + assert.equal(count(daily, records, "2026-10-18T12:00:00.000Z"), 100); + assert.equal(count(daily, records, "2026-10-18T10:00:00.000Z"), 100); + // At 04:59 CDT the day that started at 05:00 yesterday still runs. + assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:59:59.999Z"), 11); + assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:00:00.000Z"), 11); +}); + +test("REQ-LIMIT-2 the spring DST change makes the day after a 00:00 reset 23 hours long", () => { + const daily = { max: quota({ reset: "00:00" }) }; + // 2026-03-08 00:00 CST is 06:00Z; 2026-03-09 00:00 CDT is 05:00Z. + const records = [known("2026-03-08T05:30:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T06:00:00.000Z", {}, [10, 0, 0, 0]), + known("2026-03-09T04:59:59.999Z", {}, [100, 0, 0, 0]), known("2026-03-09T05:00:00.000Z", {}, [1000, 0, 0, 0])]; + assert.equal(count(daily, records.slice(0, 3), "2026-03-09T04:59:59.999Z"), 110); + assert.equal(count(daily, records, "2026-03-09T05:00:00.000Z"), 1000); +}); + +test("REQ-LIMIT-2 the fall DST change makes the day after a 00:00 reset 25 hours long", () => { + const daily = { max: quota({ reset: "00:00" }) }; + // 2026-11-01 00:00 CDT is 05:00Z; 2026-11-02 00:00 CST is 06:00Z. + const records = [known("2026-11-01T04:59:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T05:30:00.000Z", {}, [10, 0, 0, 0]), + known("2026-11-02T05:30:00.000Z", {}, [100, 0, 0, 0]), known("2026-11-02T06:00:00.000Z", {}, [1000, 0, 0, 0])]; + assert.equal(count(daily, records.slice(0, 3), "2026-11-02T05:59:59.999Z"), 110); + assert.equal(count(daily, records, "2026-11-02T06:00:00.000Z"), 1000); +}); + +test("REQ-LIMIT-2 a reset the clocks skip starts the day at the first instant after the gap", () => { + const daily = { max: quota({ reset: "02:30" }) }; + // 02:30 doesn't happen on 2026-03-08: 01:59:59 CST (07:59:59Z) is followed by 03:00 CDT (08:00Z). + const records = [known("2026-03-07T09:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T07:59:59.999Z", {}, [10, 0, 0, 0]), + known("2026-03-08T08:00:00.000Z", {}, [100, 0, 0, 0])]; + assert.equal(count(daily, records, "2026-03-08T08:15:00.000Z"), 100); + assert.equal(count(daily, records, "2026-03-08T08:00:00.000Z"), 100); + // Before the gap ends, the day that started at 02:30 CST on 03-07 (08:30Z) still runs. + assert.equal(count(daily, records.slice(0, 2), "2026-03-08T07:59:59.999Z"), 11); +}); + +test("REQ-LIMIT-2 a reset the clocks repeat starts one day, at the first one", () => { + const daily = { max: quota({ reset: "01:30" }) }; + // 01:30 happens twice on 2026-11-01: CDT at 06:30Z and CST at 07:30Z. + const records = [known("2026-11-01T06:29:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T06:45:00.000Z", {}, [10, 0, 0, 0]), + known("2026-11-01T07:45:00.000Z", {}, [100, 0, 0, 0])]; + assert.equal(count(daily, records, "2026-11-01T07:50:00.000Z"), 110); + assert.equal(count(daily, records.slice(0, 2), "2026-11-01T06:50:00.000Z"), 10); + // Before the first 01:30, the day that started at 01:30 CDT on 10-31 (06:30Z) still runs. + assert.equal(count(daily, records.slice(0, 1), "2026-11-01T06:29:59.999Z"), 1); +}); + +test("REQ-LIMIT-2 a launch is refused at the quota, a turn that reaches it stops, and the next day allows again", () => { + const records = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")]; // 200 tokens + const hit = { path: PATH, limit: 200, count: 200, unknownTurns: 0 }; + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records)), { allow: false, hit }); + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 201 }) }, records)), { allow: true }); + // mustStop answers on the records the turn's usage end added. + assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records.slice(0, 1))), { stop: false }); + assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records)), { stop: true, hit }); + // The next reset, 05:00 CDT on 10-19 (10:00Z), starts a day with nothing counted. + const next = { now: "2026-10-19T10:00:00.000Z" }; + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, { now: "2026-10-19T09:59:59.999Z" })), { allow: false, hit }); + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, next)), { allow: true }); + assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records, next)), { stop: false }); + // A limit of 0 refuses with no records. + assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 0 }) }, [])), + { allow: false, hit: { path: PATH, limit: 0, count: 0, unknownTurns: 0 } }); +}); + +test("REQ-LIMIT-2 counts past the safe integer range come back as digit strings", () => { + const big = Number.MAX_SAFE_INTEGER; + const records = [known("2026-10-18T11:00:00.000Z", {}, [big, big, 0, 0])]; + const [entry] = check.report(args({ max: quota({ limit: 1 }) }, records)); + assert.equal(entry.count, (2n * BigInt(big)).toString()); + assert.equal(entry.kinds.input, big); + assert.equal(check.mustStop(args({ max: quota({ limit: 1 }) }, records)).hit.count, (2n * BigInt(big)).toString()); +}); + +test("REQ-LIMIT-4 bad leaves, records or times throw a code, and the check reads nothing but its arguments", () => { + const ok = [known("2026-10-18T11:00:00.000Z")]; + const leaves = [{ limit: -1 }, { limit: 1.5 }, { limit: "1" }, { reset: "5:00" }, { reset: "24:00" }, { reset: "05:60" }, + { reset: undefined }, { timeZone: "Mars/Phobos" }, { timeZone: undefined }, { timeZone: 5 }]; + for (const fn of ["mayLaunch", "mustStop", "report"]) { + for (const over of leaves) { + assert.throws(() => check[fn](args({ max: quota(over) }, ok)), { code: "bad-leaf" }, `${fn} ${JSON.stringify(over)}`); + } + assert.throws(() => check[fn](args({ max: null }, ok)), { code: "bad-leaf" }, fn); + assert.throws(() => check[fn](args({ max: quota({ since: "never" }) }, ok)), { code: "bad-time" }, fn); + assert.throws(() => check[fn](args({ max: quota() }, ok, { now: "soon" })), { code: "bad-time" }, fn); + assert.throws(() => check[fn](args({ max: quota() }, null)), { code: "bad-records" }, fn); + assert.throws(() => check[fn](args({ max: quota() }, [known("yesterday")])), { code: "bad-time" }, fn); + assert.throws(() => check[fn](args({ max: quota() }, [known("2026-10-18T11:00:00.000Z", {}, [-1, 0, 0, 0])])), { code: "bad-record" }, fn); + } + const input = args({ max: quota() }, ok); + const copy = structuredClone(input); + check.mayLaunch(input); check.mustStop(input); check.report(input); + assert.deepEqual(input, copy); +}); + +test("REQ-LIMIT-2 no daily quotas: every launch is allowed and the report is empty", () => { + const records = [known("2026-10-18T11:00:00.000Z")]; + for (const l of [{}, { tokens: {} }, { tokens: { daily: {} } }, { tokens: { instances: { coder: { limit: 0, since: SINCE } } } }]) { + const a = { business: "demo", limits: l, subject: subject(), records, now: NOW }; + assert.deepEqual(check.mayLaunch(a), { allow: true }); + assert.deepEqual(check.mustStop(a), { stop: false }); + assert.deepEqual(check.report(a), []); + } +}); diff --git a/packages/plugins/tests/token-limit.test.mjs b/packages/plugins/tests/token-limit.test.mjs index 451a29a54..21067d6cd 100644 --- a/packages/plugins/tests/token-limit.test.mjs +++ b/packages/plugins/tests/token-limit.test.mjs @@ -141,3 +141,11 @@ test("REQ-LIMIT-3 no token limits: every launch is allowed and the report is emp assert.deepEqual(check.report(a), []); } }); + +test("REQ-LIMIT-3 token-limit ignores tokens.daily, which the daily-quota check enforces (row 126)", () => { + const daily = { k1: { limit: 0, since: SINCE, reset: "05:00", timeZone: "America/Chicago" } }; + const a = { business: "demo", limits: { tokens: { daily } }, subject: subject({ account: "k1" }), records: [known({ account: "k1" })], now: NOW }; + assert.deepEqual(check.mayLaunch(a), { allow: true }); + assert.deepEqual(check.mustStop(a), { stop: false }); + assert.deepEqual(check.report(a), []); +}); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 40c6b6758..e29e67aa8 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -588,7 +588,8 @@ catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);} }); // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A - // limit no switched-on check counts reads "not enforced". Reset counts it again from now. + // limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126 + // (#1620): any daily quota row adds one line saying the count is this stack's own use only. let resetReceipt=''; function tokenLimits(rows, refused) { const title = '

Token limits

'; @@ -600,7 +601,7 @@ : t.status === 'not-enforced' ? `not enforced${t.reason ? `: ${txt(t.reason)}` : ''}` : `check failed: ${txt(t.code)}${t.check ? ` (${txt(t.check)})` : ''}`; const body = rows.map(t => `${txt((t.path || []).join(' / '))}${txt(t.limit)}${txt(t.since)}${state(t)}`).join(''); - return `
${title}

A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.

${body}
SubjectLimitSinceCountReset

${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}

${txt(resetReceipt)}

`; + return `
${title}

A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.

${body}
SubjectLimitSinceCountReset
${rows.some(t => t.path?.[1] === 'daily') ? '

Daily quotas count only this stack\'s own use of the account, not its use anywhere else.

' : ''}

${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}

${txt(resetReceipt)}

`; } document.addEventListener('click',async e=>{ const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return; diff --git a/packages/webui/tests/limits-browser.test.mjs b/packages/webui/tests/limits-browser.test.mjs index c34152bba..64d0f1bca 100644 --- a/packages/webui/tests/limits-browser.test.mjs +++ b/packages/webui/tests/limits-browser.test.mjs @@ -37,6 +37,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced ['tokens / models / anthropic / claude-opus-5-5','7',SINCE,`check failed: check-threw (${hostile})`,'Reset'], ]); assert.equal(await b.evaluate('[...document.querySelectorAll("[data-limit-reset]")].every(e=>e.disabled)'),true); + assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),0,'no daily row, no daily line'); assert.equal(await b.evaluate('window.injected===true'),false); // Signed-in send mode: Reset posts the revision and that one path, and nothing else. A draft // typed before the reset stays, and so does the revision it was typed against. @@ -54,6 +55,12 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced assert.equal(save.revision,'a'.repeat(64)); assert.equal(save.limits.total,3); assert.deepEqual(save.limits.tokens,tokens); + // Row 126 (#1620): a daily quota row adds one line under the table. + answer={revision:launch.revision,limits:launch.limits,tokens:[...TOKENS,{path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0}]}; + await b.navigate(`http://127.0.0.1:${port}/#/settings`); + await wait('!!document.querySelector("#token-limits-daily")'); + assert.equal(await b.evaluate('document.querySelector("#token-limits-daily").textContent'),"Daily quotas count only this stack's own use of the account, not its use anywhere else."); + assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),1); // A limits answer the server refuses shows no rows and no count. answer={revision:launch.revision,limits:launch.limits}; await b.navigate(`http://127.0.0.1:${port}/#/settings`);