1147 lines
73 KiB
Diff
1147 lines
73 KiB
Diff
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.<account>.
|
|
+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')<ops.findIndex(x=>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.<account>` 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 = '<h2 id="token-limits-title">Token limits</h2>';
|
|
@@ -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 => `<tr><th scope="row">${txt((t.path || []).join(' / '))}</th><td>${txt(t.limit)}</td><td>${txt(t.since)}</td><td>${state(t)}</td><td><button type="button" data-limit-reset="${esc(JSON.stringify(t.path || []))}"${can ? '' : ' disabled'}>Reset</button></td></tr>`).join('');
|
|
- return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div><p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
|
|
+ return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div>${rows.some(t => t.path?.[1] === 'daily') ? '<p class="small muted" id="token-limits-daily">Daily quotas count only this stack\'s own use of the account, not its use anywhere else.</p>' : ''}<p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
|
|
}
|
|
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`);
|