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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ new version heading in the same commit.

## [Unreleased]

## [0.453.1] - 2026-10-05
### Fixed
- **🔴 `GET /api/bets` was readable with no session.** The route shipped in v0.453.0 was added beside the agent loopback routes, which sit BEFORE the `/api/*` auth gate, so anyone who could reach the port got every bet on the tenant — titles, hypotheses, baselines and asset URLs. The handler was correct; its position in the file was the bug. Moved below the gate beside the other goal routes, with a comment saying why it must stay there, and `scripts/bets-test.cjs` now asserts that an un-cookied read and an un-cookied write both come back 401 — a position bug needs a reachability test, not a handler test. Also adds the human write side the board needs (`PATCH /api/bets/:id`, `POST /api/bets/:id/judge`, owner/admin): a human may decide and record the lesson, and may force the arithmetic early, but cannot type a number over a measurement either.

## [0.453.0] - 2026-10-04
### Added
- **Bets: the unit between a goal and the work, judged on what it actually shipped.** New `bets` + `bet_assets` tables (`src/state/bets.ts`, `os.bets`) and `tasks.bet_id`. A bet is one falsifiable attempt at a goal's number — hypothesis, `expectedLift`, a 7–60 day window — and its lift is `Σ(asset.value) − baseline` measured on its OWN assets, never on the goal, because whole-metric attribution cannot separate two concurrent bets (on the instapods trial three ran inside four days while the goal drifted down for unrelated reasons; judged on the goal all three read as failures). **The server owns the number and the agent owns the words:** `judge()` computes `observedLift` and `met|short|no_signal` from the assets, those columns are absent from every agent write path, and the agent supplies the lesson plus the kept/expanded/killed call — a terminal state requires a lesson, and killing a `met` bet is allowed with both numbers in the `bet.updated` audit row. `no_signal` outranks failure: no assets, no measurements, or every asset unindexed means untested, which is a publishing problem rather than evidence against the hypothesis. Judged off the scheduler tick by `src/edge/bet-review.ts` — spawn-free, one owner card per bet, then the wake queue asks the agent that ran it to close its own loop. New agent tools `bet_propose` / `bet_list` / `bet_update` / `asset_record` / `asset_measure`; read side `GET /api/bets?goal=`. Caps: 4 live bets per goal, 60 assets per bet, and re-lengthening a window re-stamps the judge date off the original start so judgement cannot be postponed forever. Pinned by `scripts/bets-test.cjs`; see `docs/bets-plan.md`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.453.0",
"version": "0.453.1",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
26 changes: 25 additions & 1 deletion scripts/bets-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,36 @@ const DAY = 86_400_000;
assert(longWin.windowDays === 60, 'a 900-day window is clamped down to 60', longWin.windowDays);
assert(Math.abs(longWin.judgeAt - (longWin.startedAt + 60 * DAY)) < 5000, 'and the judge date is re-stamped off the ORIGINAL start, not from now', { judgeAt: longWin.judgeAt, startedAt: longWin.startedAt });

console.log('\n\x1b[1m11) A human can read the board\x1b[0m');
console.log('\n\x1b[1m11) A human can read the board — and only a logged-in one\x1b[0m');
// v0.453.0 shipped GET /api/bets ABOVE the /api/* auth gate (it was added beside the agent loopback
// routes), so every bet was readable with no cookie. The handler was fine; its POSITION was the bug.
const anon = await fetch(`${base}/api/bets?goal=${goal.id}`);
assert(anon.status === 401, 'no cookie → 401, never the board', anon.status);
const anonPatch = await fetch(`${base}/api/bets/${a.bet.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: 'killed', lesson: 'x' }) });
assert(anonPatch.status === 401, 'and no anonymous writes either', anonPatch.status);
const cookie = `aos_sid=${aos.team.createSession(owner.id)}`;
const board = await (await fetch(`${base}/api/bets?goal=${goal.id}`, { headers: { cookie } })).json();
assert(Array.isArray(board.bets) && board.bets.length > 0, 'GET /api/bets lists them', board.bets && board.bets.length);
assert(board.bets.every((x) => Array.isArray(x.assets)), 'each with its assets');
assert(board.counts && board.counts.kept === 1, 'and a count per state', board.counts);
assert(board.canEdit === true, 'an owner is told they may edit', board.canEdit);

console.log('\n\x1b[1m12) A human can decide on the board\x1b[0m');
// Clear the live slots this test has filled — the cap is real and case 10 left it at the ceiling.
for (const b of aos.bets.list(aos.tenant, { goalId: goal.id, state: 'live' })) {
aos.bets.update(b.id, { state: 'killed', lesson: 'Test teardown.', by: owner.id });
}
const g = await propose({ goalId: goal.id, title: 'Human judges this one', expectedLift: 1, baseline: 0, windowDays: 7 });
await record({ betId: g.bet.id, url: 'https://x.test/human/' });
await measure({ betId: g.bet.id, url: 'https://x.test/human/', value: 0.8, indexed: true });
const early = await (await fetch(`${base}/api/bets/${g.bet.id}/judge`, { method: 'POST', headers: { cookie } })).json();
assert(early.ok && early.verdict === 'met', 'judge-now runs the arithmetic before the window closes', early);
const humanNoLesson = await (await fetch(`${base}/api/bets/${g.bet.id}`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ state: 'kept' }) })).json();
assert(/lesson/.test(humanNoLesson.error || ''), 'a human needs a lesson too — same rule as the agent', humanNoLesson);
const humanKept = await (await fetch(`${base}/api/bets/${g.bet.id}`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ state: 'kept', lesson: 'Judged by hand a week early; the pattern is clear enough.' }) })).json();
assert(humanKept.ok && humanKept.bet.state === 'kept', 'with one it is kept', humanKept.bet && humanKept.bet.state);
const tamper = await (await fetch(`${base}/api/bets/${g.bet.id}`, { method: 'PATCH', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ observedLift: 99, verdict: 'met' }) })).json();
assert(tamper.ok && Math.abs(aos.bets.get(g.bet.id).observedLift - 0.8) < 0.001, 'and not even an owner can type a number over the measurement', aos.bets.get(g.bet.id).observedLift);

server.close();
registry.stopAll?.();
Expand Down
58 changes: 50 additions & 8 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2373,14 +2373,6 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return sendJson(res, 200, { ok: true, asset });
}

// Console read side: the bets for a goal (the board in docs/bets-plan.md §UI). Member-gated like the
// rest of /api/*; the write side above is the agent's loopback lane.
if (method === 'GET' && p === '/api/bets') {
const goalId = url.searchParams.get('goal') || undefined;
const bets = os.bets.list(os.tenant, { goalId, limit: 200 });
return sendJson(res, 200, { bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })), counts: os.bets.counts(os.tenant, goalId) });
}

// agent proposes a new goal — drafts a NOT-YET-ACTIVE goal (status 'draft') + posts a 'goal.proposed'
// inbox card for an owner/admin to review and activate. Auto-apply + audited, like skill_propose.
if (method === 'POST' && p === '/api/goals/propose') {
Expand Down Expand Up @@ -4584,6 +4576,56 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
if (r.spawned) os.audit.append({ ts: Date.now(), runId: r.sessionId ?? '-', tenant: os.tenant, principal: me.email, type: 'goal.plan.requested', data: { goalId: goal.id, autoDispatch } });
return sendJson(res, r.spawned ? 200 : 409, r.spawned ? { ok: true, sessionId: r.sessionId } : { ok: false, error: r.reason });
}
// ── bets (the board, docs/bets-plan.md §6) — member-gated; the agent's lane is /api/agent/bets/* ──
// ⚠ These MUST stay below the `/api/*` auth gate. v0.453.0 shipped the read route above it by
// accident, which served every bet (titles, hypotheses, urls) to anyone who could reach the port.
if (method === 'GET' && p === '/api/bets') {
const goalId = url.searchParams.get('goal') || undefined;
const bets = os.bets.list(os.tenant, { goalId, limit: 200 });
return sendJson(res, 200, { bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })), counts: os.bets.counts(os.tenant, goalId), canEdit: isAdmin(me) });
}
// Console WRITE side for bets — owner/admin. The same two things an agent may do (decide + record the
// lesson) and nothing more: `observed_lift`/`verdict` stay the sweep's, so the board cannot be used to
// talk a number into existence either. `judge` forces the arithmetic early, for a window a human has
// already seen enough of.
const betMatch = p.match(/^\/api\/bets\/([\w-]+)$/);
if (betMatch && method === 'PATCH') {
if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' });
const before = os.bets.get(betMatch[1]);
if (!before) return sendJson(res, 404, { error: 'bet not found' });
const b = await readBody(req);
const state = b.state != null ? String(b.state) as BetState : undefined;
if (state && !BET_STATES.includes(state)) return sendJson(res, 400, { error: `state must be one of: ${BET_STATES.join(', ')}` });
if ((state === 'kept' || state === 'killed' || state === 'expanded') && !String(b.lesson || before.lesson || '').trim()) {
return sendJson(res, 400, { error: `write a lesson when you ${state} a bet — one sentence on what it taught` });
}
const bet = os.bets.update(before.id, {
state,
lesson: b.lesson != null ? String(b.lesson) : undefined,
hypothesis: b.hypothesis != null ? String(b.hypothesis) : undefined,
expectedLift: b.expectedLift !== undefined ? Number(b.expectedLift) : undefined,
baseline: b.baseline !== undefined ? Number(b.baseline) : undefined,
windowDays: b.windowDays !== undefined ? Number(b.windowDays) : undefined,
by: me.id,
});
os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'bet.updated', data: { betId: before.id, from: before.state, to: bet?.state ?? before.state, verdict: before.verdict ?? null, observedLift: before.observedLift ?? null, by: 'human' } });
return sendJson(res, 200, { ok: true, bet });
}
const betJudge = p.match(/^\/api\/bets\/([\w-]+)\/judge$/);
if (betJudge && method === 'POST') {
if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' });
const bet = os.bets.get(betJudge[1]);
if (!bet) return sendJson(res, 404, { error: 'bet not found' });
const j = os.bets.judge(bet.id, Date.now());
if (!j) return sendJson(res, 400, { error: `a ${bet.state} bet cannot be judged — only a running one` });
os.audit.append({
ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'bet.judged',
data: { betId: j.bet.id, goalId: j.bet.goalId, title: j.bet.title, verdict: j.verdict, observedLift: j.observedLift, expected: j.expected ?? null, baseline: j.baseline, measured: j.measured, assets: j.assets, windowDays: j.bet.windowDays, early: true, by: me.email },
});
return sendJson(res, 200, { ok: true, bet: j.bet, verdict: j.verdict, note: j.note, observedLift: j.observedLift });
}


if (method === 'GET' && p === '/api/goals') {
const goals = os.goals.list({ tenant: os.tenant, status: (url.searchParams.get('status') as GoalStatus) || undefined, query: url.searchParams.get('q') || undefined, limit: 500 });
// Derived progress per goal (from its linked tasks) for the page's progress bars — keyed by id.
Expand Down
Loading