diff --git a/CHANGELOG.md b/CHANGELOG.md index e9723650..7bf5b6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/package-lock.json b/package-lock.json index 118536b4..264d04c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.453.0", + "version": "0.453.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.453.0", + "version": "0.453.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index f036318f..72585e23 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/bets-test.cjs b/scripts/bets-test.cjs index f980c2d1..c32b22b5 100644 --- a/scripts/bets-test.cjs +++ b/scripts/bets-test.cjs @@ -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?.(); diff --git a/src/server.ts b/src/server.ts index c992dac2..47d71598 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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') { @@ -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.