diff --git a/trios/agent-server/apps/server/src/api/routes/queen-runners.ts b/trios/agent-server/apps/server/src/api/routes/queen-runners.ts new file mode 100644 index 0000000000..58066ea0e5 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/routes/queen-runners.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * THE RUNNER CABINET, AND THE DOOR A RUNNER KNOCKS ON. + * + * Two routes, two credentials that never meet: + * + * /queen/me/runners the PERSON, with the bearer token app.t27.ai issued + * them (verified by asking its issuer, see + * queen-app-identity.ts). Lists, mints and revokes that + * person's runner tokens - nobody else's. + * + * /queen/runner the RUNNER, with the token minted above. Today it can + * say it is alive and learn its lane; taking work and + * handing it back are the next stage, and the answer says + * so instead of pretending to offer work. + * + * Neither route accepts, stores or returns a provider key. The key stays on + * the runner's machine; that is the whole point of a runner. + */ +import { Hono } from 'hono' +import type { Pool } from 'pg' +import { createQueenPool } from '../../lib/db/queen-pool' +import { logger } from '../../lib/logger' +import { + bearerOf, + createAppIdentity, + type Identify, + IdentityUnavailableError, +} from '../services/queen-app-identity' +import { + cleanLabel, + createRunner, + heartbeatRunner, + laneOf, + listRunners, + MAX_RUNNERS_PER_PERSON, + revokeRunner, + viewOf, +} from '../services/queen-runners' + +/** What a runner speaks. Bumped when the claim/complete stage lands. */ +export const RUNNER_PROTOCOL = 1 + +type Queryable = Pick + +export interface RunnerRouteDeps { + /** The database, or null when none is configured (503). */ + pool: () => Queryable | null + identify: Identify +} + +/** + * One pool for the life of the process, created on first use. A pool per + * request that is never ended is a connection leak with a delay on it. + */ +let sharedPool: Pool | undefined +function defaultPool(): Queryable | null { + const url = process.env.DATABASE_URL || process.env.RAILWAY_SSOT_URL + if (!url) return null + if (!sharedPool) sharedPool = createQueenPool(url) + return sharedPool +} + +function defaults(deps: Partial): RunnerRouteDeps { + return { + pool: deps.pool ?? defaultPool, + identify: deps.identify ?? createAppIdentity(), + } +} + +const NO_DATABASE = { error: 'No database configured' } as const + +export function createQueenCabinetRoute(given: Partial = {}) { + const deps = defaults(given) + return new Hono<{ + Variables: { person: { telegramId: string; name: string } } + }>() + .use('/*', async (c, next) => { + const bearer = bearerOf(c.req.header('authorization')) + if (!bearer) return c.json({ error: 'Sign in to app.t27.ai first' }, 401) + try { + const person = await deps.identify(bearer) + if (!person) return c.json({ error: 'The session was refused' }, 401) + c.set('person', person) + } catch (error) { + if (!(error instanceof IdentityUnavailableError)) throw error + logger.warn('Runner cabinet could not verify a session', { + error: error.message, + }) + return c.json({ error: 'Sign-in service did not answer' }, 503) + } + await next() + return + }) + .get('/', async (c) => { + const pool = deps.pool() + if (!pool) return c.json(NO_DATABASE, 503) + const rows = await listRunners(pool, c.get('person').telegramId) + return c.json( + { + runners: rows.map((r) => viewOf(r)), + limit: MAX_RUNNERS_PER_PERSON, + protocol: RUNNER_PROTOCOL, + }, + 200, + { 'Cache-Control': 'no-store' }, + ) + }) + .post('/', async (c) => { + const pool = deps.pool() + if (!pool) return c.json(NO_DATABASE, 503) + const body = await c.req.json().catch(() => null) + const label = cleanLabel(body?.label) + if (!label) return c.json({ error: 'A runner needs a name' }, 400) + const made = await createRunner(pool, c.get('person'), label) + if (!made.ok) { + return c.json( + { + error: `At most ${MAX_RUNNERS_PER_PERSON} runners; revoke one first`, + }, + 409, + ) + } + // The only answer that ever carries the token. The page shows it once. + return c.json({ runner: viewOf(made.runner), token: made.token }, 201, { + 'Cache-Control': 'no-store', + }) + }) + .delete('/:id', async (c) => { + const pool = deps.pool() + if (!pool) return c.json(NO_DATABASE, 503) + const id = Number(c.req.param('id')) + if (!Number.isSafeInteger(id) || id <= 0) + return c.json({ error: 'No such runner' }, 404) + const done = await revokeRunner(pool, c.get('person').telegramId, id) + // Someone else's runner and no runner at all are the same answer: the + // cabinet does not confirm which ids exist. + return done ? c.body(null, 204) : c.json({ error: 'No such runner' }, 404) + }) +} + +export function createQueenRunnerRoute(given: Partial = {}) { + const deps = defaults(given) + return new Hono().post('/heartbeat', async (c) => { + const pool = deps.pool() + if (!pool) return c.json(NO_DATABASE, 503) + const token = bearerOf(c.req.header('authorization')) + const runner = token ? await heartbeatRunner(pool, token) : null + if (!runner) return c.json({ error: 'Unknown or revoked runner' }, 401) + return c.json( + { + runner: { id: runner.id, label: runner.label, lane: laneOf(runner.id) }, + protocol: RUNNER_PROTOCOL, + work: null, + note: 'Registered. Handing tasks to runners is the next stage; until it lands there is nothing to take.', + }, + 200, + { 'Cache-Control': 'no-store' }, + ) + }) +} diff --git a/trios/agent-server/apps/server/src/api/server.ts b/trios/agent-server/apps/server/src/api/server.ts index 5f64bf4534..a22e8b18b1 100644 --- a/trios/agent-server/apps/server/src/api/server.ts +++ b/trios/agent-server/apps/server/src/api/server.ts @@ -70,6 +70,10 @@ import { createQueenRoadmapDataRoute, createQueenRoadmapRoute, } from './routes/queen-roadmap' +import { + createQueenCabinetRoute, + createQueenRunnerRoute, +} from './routes/queen-runners' import { createQueenTreeRoute } from './routes/queen-tree' import { createRefinePromptRoutes } from './routes/refine-prompt' import { createShutdownRoute } from './routes/shutdown' @@ -89,7 +93,11 @@ import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/histor import { getOpenClawService } from './services/openclaw/openclaw-service' import { TaskQueueService } from './services/task-queue-service' import type { Env, HttpServerConfig } from './types' -import { publicReadCorsMiddleware, trustedCorsMiddleware } from './utils/cors' +import { + appCabinetCorsMiddleware, + publicReadCorsMiddleware, + trustedCorsMiddleware, +} from './utils/cors' import { requireTrustedAppOrigin } from './utils/request-auth' async function assertPortAvailable(port: number): Promise { @@ -386,6 +394,8 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/queen/public-credits', publicReadCorsMiddleware()) .use('/queen/public-earnings', publicReadCorsMiddleware()) .use('/queen/scheduler', publicReadCorsMiddleware()) + // The runner cabinet: exactly https://app.t27.ai, bearer only, no cookies. + .use('/queen/me/*', appCabinetCorsMiddleware()) .use('/*', trustedCorsMiddleware()) // The Inngest server registers and invokes functions here; each request // is signed with INNGEST_SIGNING_KEY and verified by the SDK, so this sits @@ -403,6 +413,10 @@ export async function createHttpServer(config: HttpServerConfig) { .route('/queen/public-board', createQueenPublicBoardRoute()) // Who lent a lane and what it did; no titles, no worker text, no key. .route('/queen/public-leaderboard', createQueenPublicLeaderboardRoute()) + // A person's runner tokens (their bearer, verified by its issuer), and the + // door their runner knocks on (the runner token). Neither takes a key. + .route('/queen/me/runners', createQueenCabinetRoute()) + .route('/queen/runner', createQueenRunnerRoute()) .route('/queen/public-credits', createQueenPublicCreditsRoute()) // What accepted spec work earned; recorded, not withdrawable. Repository, // issue, commit and declared .t27 paths only - no titles, notes or keys. diff --git a/trios/agent-server/apps/server/src/api/services/queen-app-identity.ts b/trios/agent-server/apps/server/src/api/services/queen-app-identity.ts new file mode 100644 index 0000000000..4709530b17 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-app-identity.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * WHO IS ASKING, FOR A PERSON SIGNED IN TO https://app.t27.ai. + * + * This server has never known a person. Its only credentials are the + * operator's TRIOS_API_TOKEN and the loopback local-auth token; Telegram + * sessions belong to the player's own service (vibee-render, in + * 999-multibots-telegraf), which issues the access token the board at + * app.t27.ai/queen/ already holds in memory. + * + * So the answer to "who is this" is asked of the service that issued the + * token, with the token: `tools/call whoami` on its /mcp, the same call the + * board itself makes to put a name beside the avatar. Its answer carries + * `telegram_id`, and that id is the only identity the runner cabinet keys on. + * + * WHAT THIS FILE DOES WITH THE TOKEN. It forwards it once to the issuer and + * nowhere else. It is never logged, never stored and never echoed; the cache + * below is keyed by its SHA-256, so a heap dump holds a hash, not a credential. + * + * THREE ANSWERS, NOT TWO. `null` means the issuer refused the token (sign in + * again); a thrown IdentityUnavailableError means the issuer did not answer + * (try again later). Collapsing them would tell a person whose session is fine + * that they are signed out every time vibee-render redeploys. + */ +import { createHash } from 'node:crypto' + +export const DEFAULT_APP_IDENTITY_URL = + 'https://vibee-render-production.up.railway.app' + +/** How long a verified answer is trusted before the issuer is asked again. */ +export const IDENTITY_CACHE_MS = 60_000 +/** Bounded: a flood of distinct tokens must not grow the heap without limit. */ +const IDENTITY_CACHE_MAX = 500 +/** The issuer is abandoned after this long; the caller says "try again". */ +export const IDENTITY_TIMEOUT_MS = 5_000 + +export interface AppPerson { + /** Digits only: Telegram ids are integers, and the column is text. */ + telegramId: string + /** Display name from the issuer's profile, for the person's own runners. */ + name: string +} + +export class IdentityUnavailableError extends Error { + constructor(reason: string) { + super(`app identity unavailable: ${reason}`) + this.name = 'IdentityUnavailableError' + } +} + +type FetchLike = ( + url: string, + init: { + method: string + headers: Record + body: string + signal: AbortSignal + }, +) => Promise<{ ok: boolean; status: number; json(): Promise }> + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +/** + * The payload of one MCP `tools/call` result: structured content when present, + * otherwise the first text block parsed as JSON. Same rule as the board's + * mcpAnswer.ts, because the same server is answering both. + */ +export function mcpPayload(result: unknown): unknown { + if (!isRecord(result)) return undefined + if (result.structuredContent !== undefined) return result.structuredContent + if (!Array.isArray(result.content)) return undefined + const text = result.content.find( + (part): part is { type: string; text: string } => + isRecord(part) && part.type === 'text' && typeof part.text === 'string', + )?.text + if (text === undefined) return undefined + try { + return JSON.parse(text) + } catch { + return text + } +} + +/** The person in a whoami answer, or null when it names nobody. */ +export function personFromWhoami(body: unknown): AppPerson | null { + if (!isRecord(body) || body.error !== undefined) return null + const result = body.result + if (isRecord(result) && result.isError === true) return null + const data = mcpPayload(result) + if (!isRecord(data)) return null + const raw = data.telegram_id + const telegramId = + typeof raw === 'number' && Number.isSafeInteger(raw) && raw > 0 + ? String(raw) + : typeof raw === 'string' && /^[1-9]\d{0,19}$/.test(raw) + ? raw + : null + if (!telegramId) return null + const profile = isRecord(data['профиль']) ? data['профиль'] : {} + const name = + [profile.display_name, profile.first_name, profile.username] + .find((v): v is string => typeof v === 'string' && v.trim() !== '') + ?.trim() + .slice(0, 40) ?? `tg ${telegramId.slice(-4)}` + return { telegramId, name } +} + +/** `Authorization: Bearer x` -> 'x'. Anything else is no credential. */ +export function bearerOf(header: string | undefined): string | null { + if (!header) return null + const match = /^Bearer ([A-Za-z0-9._~+/=-]{16,4096})$/.exec(header.trim()) + return match ? match[1] : null +} + +export interface AppIdentityOptions { + baseUrl?: string + fetch?: FetchLike + now?: () => number +} + +export type Identify = (bearer: string) => Promise + +export function createAppIdentity(options: AppIdentityOptions = {}): Identify { + const baseUrl = ( + options.baseUrl ?? + process.env.TRIOS_APP_IDENTITY_URL ?? + DEFAULT_APP_IDENTITY_URL + ).replace(/\/+$/, '') + const doFetch: FetchLike = options.fetch ?? (fetch as unknown as FetchLike) + const now = options.now ?? Date.now + const cache = new Map() + + return async (bearer) => { + const key = createHash('sha256').update(bearer).digest('hex') + const hit = cache.get(key) + if (hit && hit.until > now()) return hit.person + + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), IDENTITY_TIMEOUT_MS) + let status: number + let body: unknown + try { + const res = await doFetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${bearer}`, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'whoami', arguments: {} }, + }), + signal: abort.signal, + }) + status = res.status + body = res.ok ? await res.json() : null + } catch (error) { + throw new IdentityUnavailableError( + error instanceof Error ? error.name : 'fetch failed', + ) + } finally { + clearTimeout(timer) + } + + let person: AppPerson | null + if (status === 401 || status === 403) person = null + else if (status >= 200 && status < 300) person = personFromWhoami(body) + else throw new IdentityUnavailableError(`http ${status}`) + + if (cache.size >= IDENTITY_CACHE_MAX) { + // Oldest first: Map iterates in insertion order. + const oldest = cache.keys().next().value + if (oldest !== undefined) cache.delete(oldest) + } + cache.set(key, { person, until: now() + IDENTITY_CACHE_MS }) + return person + } +} diff --git a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts index 9d0da933d7..764df10087 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts @@ -40,6 +40,7 @@ */ import type { Pool } from 'pg' import { contributorOwnerNames } from './queen-contributor-keys' +import { runnerOwners } from './queen-runners' /** An issue the Queen accepted, on this key. */ export const ACCEPTED_XP = 100 @@ -73,8 +74,10 @@ export interface Contributor { specs?: number /** The operator's name for the lender, or `key #N` when nobody claimed it. */ name: string - /** Whether a person claimed this lane in TRIOS_KEY_OWNERS. */ + /** Whether a person claimed this lane in TRIOS_KEY_OWNERS, or runs it. */ claimed: boolean + /** The lanes ran on the lender's own machine (queen-runners.ts). */ + runner?: boolean /** Their GitHub login, when the name was written as `@login`. */ github?: string keys: number[] @@ -132,20 +135,36 @@ export function githubLoginOf(name: string): string | undefined { /** * The work of each lane, gathered by lender and ranked. Pure: the rows are the * database's, the ranking is this function's, and the test drives it directly. + * + * `runners` names the lanes that ran on somebody's own machine. They are + * gathered by PERSON, never by name: a runner's name is whatever its owner is + * called on Telegram, and gathering by name would let anyone called "Dmitrii" + * fold their lanes into the operator's row, or the operator's into theirs. And + * a runner never gets a GitHub link - a login nobody has verified is not one. */ export function rank( work: KeyWork[], owners: Record, + runners: Record = {}, ): Contributor[] { const byName = new Map() for (const lane of work) { - const claimed = Object.hasOwn(owners, lane.keyIndex) - const name = claimed ? owners[lane.keyIndex] : `key #${lane.keyIndex}` - const seen = byName.get(name) + const runner = Object.hasOwn(runners, lane.keyIndex) + ? runners[lane.keyIndex] + : undefined + const claimed = !!runner || Object.hasOwn(owners, lane.keyIndex) + const name = runner + ? runner.name + : claimed + ? owners[lane.keyIndex] + : `key #${lane.keyIndex}` + const group = runner ? `runner\u0000${runner.person}` : name + const seen = byName.get(group) const into: Contributor = seen ?? { name, claimed, - ...(claimed ? { github: githubLoginOf(name) } : {}), + ...(runner ? { runner: true } : {}), + ...(claimed && !runner ? { github: githubLoginOf(name) } : {}), keys: [], accepted: 0, specs: 0, @@ -158,7 +177,7 @@ export function rank( into.specs = (into.specs ?? 0) + (lane.specs ?? 0) into.finished += lane.finished into.hours = Math.round((into.hours + lane.hours) * 10) / 10 - byName.set(name, into) + byName.set(group, into) } const ranked = [...byName.values()].map((c) => ({ ...c, xp: xpFor(c) })) // XP first; then the one who finished more turns; then by name, so two equal @@ -256,13 +275,20 @@ export async function leaderboard( days: number | null = null, ): Promise { const work = await keyWork(pool, days) + // A database migrated before runners existed has no queen_runner table yet; + // that is "no runners", not "no leaderboard". + const runners = await runnerOwners(pool).catch(() => ({})) return { days, measuredAt: new Date().toISOString(), scoring: { acceptedXp: ACCEPTED_XP, specXp: SPEC_XP, hourXp: HOUR_XP }, - contributors: rank(work, { - ...parseOwners(process.env.TRIOS_KEY_OWNERS), - ...(await contributorOwnerNames(pool)), - }), + contributors: rank( + work, + { + ...parseOwners(process.env.TRIOS_KEY_OWNERS), + ...(await contributorOwnerNames(pool)), + }, + runners, + ), } } diff --git a/trios/agent-server/apps/server/src/api/services/queen-runners.ts b/trios/agent-server/apps/server/src/api/services/queen-runners.ts new file mode 100644 index 0000000000..a9758a4ae2 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-runners.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * A LANE THAT RUNS ON SOMEBODY ELSE'S MACHINE. + * + * Every bee runs on one provider key, and until now every key lived in the + * operator's environment. The article that invites people to lend one + * (how-to-join-the-swarm) also says why that is the wrong shape: OpenAI, + * Anthropic and Google all forbid handing a key to a third party, and a key in + * somebody else's container is a key handed over. The design that asks nobody + * to breach anything is the one where the key never moves: the swarm hands out + * the task, the bee runs on the lender's machine under the lender's account, + * and the work comes back. + * + * This file is the registry half of that. A signed-in person mints a RUNNER + * TOKEN in their cabinet; a runner process on their machine presents it. The + * token authorises exactly one thing - speaking as that runner - and it is not + * a provider key: nothing in this table can spend anybody's quota. + * + * WHAT IS STORED. The SHA-256 of the token and its last four characters, so the + * cabinet can say "…a1b2" without being able to say the rest. The token itself + * is shown once, in the answer that created it, and never again. + * + * WHICH LANE. A runner's lane is `RUNNER_KEY_BASE + id`. Every dispatch row + * already records the lane it ran on as `key_index`, and the leaderboard sums + * work by that column, so a runner that does work is credited by the same + * arithmetic as an operator key - with a block of indices no operator pool can + * reach (pools use `(pool - 1) * 10_000 + position`). + */ +import { createHash, randomBytes } from 'node:crypto' +import type { Pool } from 'pg' + +export const RUNNER_KEY_BASE = 100_000_000 +/** A person may hold this many live runners; a revoked one does not count. */ +export const MAX_RUNNERS_PER_PERSON = 5 +/** A runner that heartbeated within this long is shown as online. */ +export const RUNNER_ONLINE_MS = 3 * 60_000 +export const LABEL_MAX = 40 +const TOKEN_PREFIX = 'qr_' + +export interface RunnerRow { + id: number + telegramId: string + ownerName: string + label: string + tokenHint: string + createdAt: string + lastSeenAt: string | null + revokedAt: string | null +} + +/** What the cabinet shows. No hash, no telegram id. */ +export interface RunnerView { + id: number + label: string + lane: number + tokenHint: string + createdAt: string + lastSeenAt: string | null + state: 'never-seen' | 'online' | 'offline' +} + +export const laneOf = (id: number) => RUNNER_KEY_BASE + id +export const isRunnerLane = (keyIndex: number) => keyIndex > RUNNER_KEY_BASE + +export function hashRunnerToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +export function mintRunnerToken(): { + token: string + hash: string + hint: string +} { + const token = TOKEN_PREFIX + randomBytes(32).toString('base64url') + return { token, hash: hashRunnerToken(token), hint: token.slice(-4) } +} + +/** `qr_` + 43 base64url characters, exactly as minted. */ +export function looksLikeRunnerToken(value: string): boolean { + return /^qr_[A-Za-z0-9_-]{43}$/.test(value) +} + +/** + * A label is a few words the person chooses ("my laptop"). Control characters + * and markup-ish brackets are dropped rather than escaped: it is rendered as a + * text node, and there is no reason for it to contain them at all. + */ +export function cleanLabel(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const label = raw + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point + .replace(/[\u0000-\u001f\u007f<>]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, LABEL_MAX) + return label || null +} + +export function viewOf(row: RunnerRow, now = Date.now()): RunnerView { + const seen = row.lastSeenAt ? Date.parse(row.lastSeenAt) : null + return { + id: row.id, + label: row.label, + lane: laneOf(row.id), + tokenHint: row.tokenHint, + createdAt: row.createdAt, + lastSeenAt: row.lastSeenAt, + state: + seen === null + ? 'never-seen' + : now - seen <= RUNNER_ONLINE_MS + ? 'online' + : 'offline', + } +} + +const iso = (v: unknown): string | null => + v === null || v === undefined + ? null + : v instanceof Date + ? v.toISOString() + : String(v) + +function rowOf(r: Record): RunnerRow { + return { + id: Number(r.id), + telegramId: String(r.telegram_id), + ownerName: String(r.owner_name), + label: String(r.label), + tokenHint: String(r.token_hint), + createdAt: iso(r.created_at) ?? '', + lastSeenAt: iso(r.last_seen_at), + revokedAt: iso(r.revoked_at), + } +} + +type Queryable = Pick + +export async function listRunners( + pool: Queryable, + telegramId: string, +): Promise { + const { rows } = await pool.query( + `SELECT id, telegram_id, owner_name, label, token_hint, created_at, + last_seen_at, revoked_at + FROM queen_runner + WHERE telegram_id = $1 AND revoked_at IS NULL + ORDER BY id`, + [telegramId], + ) + return rows.map(rowOf) +} + +export type CreateRunnerResult = + | { ok: true; runner: RunnerRow; token: string } + | { ok: false; reason: 'limit' } + +/** + * One INSERT guarded by a count in the same statement, so two presses of + * "create" racing each other cannot both slip under the limit by reading the + * count before either wrote. + */ +export async function createRunner( + pool: Queryable, + person: { telegramId: string; name: string }, + label: string, +): Promise { + const minted = mintRunnerToken() + const { rows } = await pool.query( + `INSERT INTO queen_runner (telegram_id, owner_name, label, token_hash, token_hint) + SELECT $1, $2, $3, $4, $5 + WHERE (SELECT count(*) FROM queen_runner + WHERE telegram_id = $1 AND revoked_at IS NULL) < $6 + RETURNING id, telegram_id, owner_name, label, token_hint, created_at, + last_seen_at, revoked_at`, + [ + person.telegramId, + person.name, + label, + minted.hash, + minted.hint, + MAX_RUNNERS_PER_PERSON, + ], + ) + if (rows.length === 0) return { ok: false, reason: 'limit' } + return { ok: true, runner: rowOf(rows[0]), token: minted.token } +} + +/** True when a live runner of this person was revoked by this call. */ +export async function revokeRunner( + pool: Queryable, + telegramId: string, + id: number, +): Promise { + const { rowCount } = await pool.query( + `UPDATE queen_runner SET revoked_at = now() + WHERE id = $1 AND telegram_id = $2 AND revoked_at IS NULL`, + [id, telegramId], + ) + return (rowCount ?? 0) > 0 +} + +/** + * The live runner a token names, touching its last-seen time. The lookup is by + * the token's SHA-256: a caller cannot steer the bytes of a hash, so the + * comparison leaks nothing a guess could use, and the token itself never + * reaches the database. + */ +export async function heartbeatRunner( + pool: Queryable, + token: string, +): Promise { + if (!looksLikeRunnerToken(token)) return null + const { rows } = await pool.query( + `UPDATE queen_runner SET last_seen_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL + RETURNING id, telegram_id, owner_name, label, token_hint, created_at, + last_seen_at, revoked_at`, + [hashRunnerToken(token)], + ) + return rows[0] ? rowOf(rows[0]) : null +} + +/** + * Lane -> who it belongs to, for the leaderboard. Revoked runners are included: + * the work a lane did stays its owner's after the token is withdrawn. + * + * `person` is the merge key, so every runner of one person adds up to one row, + * and no runner can merge into an operator's row by choosing the same name. + */ +export async function runnerOwners( + pool: Queryable, +): Promise> { + const { rows } = await pool.query( + `SELECT id, telegram_id, owner_name FROM queen_runner`, + ) + const owners: Record = {} + for (const r of rows) { + owners[laneOf(Number(r.id))] = { + name: String(r.owner_name), + person: String(r.telegram_id), + } + } + return owners +} diff --git a/trios/agent-server/apps/server/src/api/utils/cors.ts b/trios/agent-server/apps/server/src/api/utils/cors.ts index 920c75e0ad..fa9f81f92f 100644 --- a/trios/agent-server/apps/server/src/api/utils/cors.ts +++ b/trios/agent-server/apps/server/src/api/utils/cors.ts @@ -132,6 +132,41 @@ export function publicReadCorsMiddleware(): MiddlewareHandler { } } +/** The origin that serves the board with a signed-in person's session. */ +export const APP_CABINET_ORIGIN = 'https://app.t27.ai' + +/** + * CORS for the runner cabinet (/queen/me/*), which the board at + * https://app.t27.ai/queen/ calls with the person's own bearer token. + * + * Exactly one origin, no credentials. The credential is the Authorization + * header the page chose to send, not a cookie the browser attaches on its own, + * so a page on any other origin cannot borrow a session it does not hold - and + * this grants nothing to any other route: it is mounted on the cabinet's path + * alone, rather than added to TRUSTED_ORIGINS, which would hand the origin the + * credentialed allowlist everywhere. + */ +export function appCabinetCorsMiddleware(): MiddlewareHandler { + return async (c, next) => { + const origin = c.req.header('origin') + if (origin !== APP_CABINET_ORIGIN || isAllowedCorsOrigin(origin)) { + await next() + return + } + + c.header('Access-Control-Allow-Origin', origin) + c.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS') + c.header('Access-Control-Allow-Headers', 'Authorization,Content-Type') + c.header('Access-Control-Max-Age', '600') + c.header('Vary', 'Origin', { append: true }) + + if (c.req.method === 'OPTIONS') return c.body(null, 204) + + await next() + return + } +} + /** * Hono CORS middleware that only emits `Access-Control-Allow-Credentials` when the * request origin is on the allowlist. Hono's bundled `cors()` cannot express a diff --git a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts index f896284278..e0f7b80db1 100644 --- a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts +++ b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts @@ -336,6 +336,25 @@ CREATE TABLE IF NOT EXISTS queen_report ( CREATE INDEX IF NOT EXISTS idx_queen_report_at ON queen_report (at DESC); +-- Runners: lanes that run on the lender's own machine with the lender's own +-- key, so the key never moves (queen-runners.ts). A row is a runner token the +-- person minted in their cabinet; only its SHA-256 and last four characters +-- are kept. Its lane is 100000000 + id, a block no operator pool can reach. +CREATE TABLE IF NOT EXISTS queen_runner ( + id bigserial PRIMARY KEY, + telegram_id text NOT NULL, + owner_name text NOT NULL, + label text NOT NULL, + token_hash text NOT NULL UNIQUE, + token_hint text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + revoked_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_queen_runner_owner + ON queen_runner (telegram_id) WHERE revoked_at IS NULL; + -- What an accepted spec has earned, written down once (queen-tri-earnings.ts). -- -- Append-only: a row is inserted, and later perhaps revoked, never deleted. diff --git a/trios/agent-server/apps/server/tests/api/queen-runners.test.ts b/trios/agent-server/apps/server/tests/api/queen-runners.test.ts new file mode 100644 index 0000000000..21cba156b2 --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-runners.test.ts @@ -0,0 +1,557 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' + +import { + createQueenCabinetRoute, + createQueenRunnerRoute, +} from '../../src/api/routes/queen-runners' +import { + type AppPerson, + bearerOf, + createAppIdentity, + IdentityUnavailableError, + personFromWhoami, +} from '../../src/api/services/queen-app-identity' +import { rank } from '../../src/api/services/queen-leaderboard' +import { + cleanLabel, + hashRunnerToken, + isRunnerLane, + laneOf, + looksLikeRunnerToken, + MAX_RUNNERS_PER_PERSON, + mintRunnerToken, + RUNNER_KEY_BASE, + runnerOwners, +} from '../../src/api/services/queen-runners' +import { + APP_CABINET_ORIGIN, + appCabinetCorsMiddleware, +} from '../../src/api/utils/cors' + +/** + * A runner is a lane that runs on its lender's machine with the lender's key. + * The server keeps only who minted which runner token, and a hash of it: these + * tests hold it to that, and to never mixing one person's runners with + * another's. + */ + +// --------------------------------------------------------------------------- +// An in-memory queen_runner table that answers the statements the service +// sends, by shape. Anything it does not recognise fails the test loudly. +// --------------------------------------------------------------------------- +interface Row { + id: number + telegram_id: string + owner_name: string + label: string + token_hash: string + token_hint: string + created_at: Date + last_seen_at: Date | null + revoked_at: Date | null +} + +function fakePool() { + const table: Row[] = [] + let next = 1 + const live = (tg: string) => + table.filter((r) => r.telegram_id === tg && r.revoked_at === null) + const pool = { + table, + async query(sql: string, params: unknown[] = []) { + if (/^\s*INSERT INTO queen_runner/.test(sql)) { + const [tg, name, label, hash, hint, limit] = params as [ + string, + string, + string, + string, + string, + number, + ] + if (live(tg).length >= limit) return { rows: [], rowCount: 0 } + const row: Row = { + id: next++, + telegram_id: tg, + owner_name: name, + label, + token_hash: hash, + token_hint: hint, + created_at: new Date('2026-10-01T00:00:00Z'), + last_seen_at: null, + revoked_at: null, + } + table.push(row) + return { rows: [row], rowCount: 1 } + } + if (/^\s*SELECT id, telegram_id, owner_name, label/.test(sql)) { + return { rows: live(params[0] as string), rowCount: 0 } + } + if (/^\s*UPDATE queen_runner SET revoked_at/.test(sql)) { + const [id, tg] = params as [number, string] + const row = table.find( + (r) => r.id === id && r.telegram_id === tg && r.revoked_at === null, + ) + if (row) row.revoked_at = new Date() + return { rows: [], rowCount: row ? 1 : 0 } + } + if (/^\s*UPDATE queen_runner SET last_seen_at/.test(sql)) { + const row = table.find( + (r) => r.token_hash === params[0] && r.revoked_at === null, + ) + if (row) row.last_seen_at = new Date() + return { rows: row ? [row] : [], rowCount: row ? 1 : 0 } + } + if ( + /^\s*SELECT id, telegram_id, owner_name FROM queen_runner/.test(sql) + ) { + return { rows: table, rowCount: table.length } + } + throw new Error(`fake pool: unexpected SQL ${sql}`) + }, + } + return pool +} + +const ALICE: AppPerson = { telegramId: '111', name: 'Alice' } +const BOB: AppPerson = { telegramId: '222', name: 'Bob' } +const TOKENS: Record = { + 'alice-session-token-0001': ALICE, + 'bob-session-token-000002': BOB, +} +const identify = async (bearer: string) => TOKENS[bearer] ?? null + +function app(pool = fakePool()) { + const deps = { pool: () => pool, identify } + const server = new Hono() + .use('/queen/me/*', appCabinetCorsMiddleware()) + .route('/queen/me/runners', createQueenCabinetRoute(deps)) + .route('/queen/runner', createQueenRunnerRoute(deps)) + return { server, pool } +} + +const as = (bearer: string, init: RequestInit = {}): RequestInit => ({ + ...init, + headers: { + Authorization: `Bearer ${bearer}`, + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, +}) + +// --------------------------------------------------------------------------- + +describe('who is asking', () => { + it('reads telegram_id and a name out of a whoami answer', () => { + const answer = { + jsonrpc: '2.0', + result: { + structuredContent: { + telegram_id: 144022504, + профиль: { display_name: ' Dmitrii ', username: 'd' }, + }, + }, + } + expect(personFromWhoami(answer)).toEqual({ + telegramId: '144022504', + name: 'Dmitrii', + }) + }) + + it('reads the same answer when it arrives as a JSON text block', () => { + const answer = { + result: { + content: [{ type: 'text', text: '{"telegram_id":"42"}' }], + }, + } + expect(personFromWhoami(answer)).toEqual({ + telegramId: '42', + name: 'tg 42', + }) + }) + + it('names nobody when the answer is an error or carries no usable id', () => { + expect(personFromWhoami({ error: { code: -32001 } })).toBeNull() + expect(personFromWhoami({ result: { isError: true } })).toBeNull() + expect( + personFromWhoami({ result: { structuredContent: { telegram_id: 'x' } } }), + ).toBeNull() + expect( + personFromWhoami({ result: { structuredContent: { telegram_id: -1 } } }), + ).toBeNull() + expect(personFromWhoami(null)).toBeNull() + }) + + it('takes only a well-formed bearer', () => { + expect(bearerOf('Bearer abcdefghijklmnop')).toBe('abcdefghijklmnop') + expect(bearerOf('Basic abcdefghijklmnop')).toBeNull() + expect(bearerOf('Bearer short')).toBeNull() + expect(bearerOf('Bearer has space in it xx')).toBeNull() + expect(bearerOf(undefined)).toBeNull() + }) + + it('tells a refused session from an issuer that did not answer', async () => { + const answering = (status: number, body: unknown = {}) => + createAppIdentity({ + baseUrl: 'https://issuer.invalid', + fetch: async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }), + }) + expect(await answering(401)('a-token-of-enough-length')).toBeNull() + await expect( + answering(502)('a-token-of-enough-length'), + ).rejects.toBeInstanceOf(IdentityUnavailableError) + const offline = createAppIdentity({ + baseUrl: 'https://issuer.invalid', + fetch: async () => { + throw new TypeError('fetch failed') + }, + }) + await expect(offline('a-token-of-enough-length')).rejects.toBeInstanceOf( + IdentityUnavailableError, + ) + }) + + it('asks the issuer once a minute per session, and sends the token only there', async () => { + const calls: { url: string; auth: string }[] = [] + let now = 0 + const identifyOnce = createAppIdentity({ + baseUrl: 'https://issuer.invalid/', + now: () => now, + fetch: async (url, init) => { + calls.push({ url, auth: init.headers.Authorization }) + return { + ok: true, + status: 200, + json: async () => ({ + result: { structuredContent: { telegram_id: 7 } }, + }), + } + }, + }) + await identifyOnce('session-token-abcdef') + await identifyOnce('session-token-abcdef') + expect(calls).toEqual([ + { + url: 'https://issuer.invalid/mcp', + auth: 'Bearer session-token-abcdef', + }, + ]) + now = 61_000 + await identifyOnce('session-token-abcdef') + expect(calls.length).toBe(2) + }) +}) + +describe('a runner token', () => { + it('is long, random, recognisable, and stored only as a hash', () => { + const a = mintRunnerToken() + const b = mintRunnerToken() + expect(a.token).not.toBe(b.token) + expect(looksLikeRunnerToken(a.token)).toBe(true) + expect(a.hash).toBe(hashRunnerToken(a.token)) + expect(a.hash).not.toContain(a.token) + expect(a.hint).toBe(a.token.slice(-4)) + expect(looksLikeRunnerToken('qr_short')).toBe(false) + expect(looksLikeRunnerToken(`sk-${'a'.repeat(43)}`)).toBe(false) + }) + + it('lives in a block of lanes no operator pool reaches', () => { + expect(laneOf(1)).toBe(RUNNER_KEY_BASE + 1) + expect(isRunnerLane(laneOf(1))).toBe(true) + // The highest index an operator pool can produce well before the block. + expect(isRunnerLane(99 * 10_000 + 9_999)).toBe(false) + }) + + it('keeps a label to a few plain words', () => { + expect(cleanLabel(' my laptop ')).toBe('my laptop') + expect(cleanLabel('')).toBe('scriptx/script') + expect(cleanLabel('a\u0000b\nc')).toBe('abc') + expect(cleanLabel('x'.repeat(100))?.length).toBe(40) + expect(cleanLabel(' ')).toBeNull() + expect(cleanLabel(42)).toBeNull() + }) +}) + +describe('the cabinet', () => { + it('refuses anyone who is not signed in', async () => { + const { server } = app() + expect((await server.request('/queen/me/runners')).status).toBe(401) + const stranger = await server.request( + '/queen/me/runners', + as('not-a-known-session-x'), + ) + expect(stranger.status).toBe(401) + }) + + it('says the sign-in service is down rather than that you are signed out', async () => { + const server = new Hono().route( + '/queen/me/runners', + createQueenCabinetRoute({ + pool: () => fakePool(), + identify: async () => { + throw new IdentityUnavailableError('http 502') + }, + }), + ) + const res = await server.request( + '/queen/me/runners', + as('alice-session-token-0001'), + ) + expect(res.status).toBe(503) + }) + + it('mints a runner, shows its token once, and lists it without the token', async () => { + const { server, pool } = app() + const made = await server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: 'laptop' }), + }), + ) + expect(made.status).toBe(201) + const { runner, token } = (await made.json()) as { + runner: { id: number; lane: number; tokenHint: string; state: string } + token: string + } + expect(looksLikeRunnerToken(token)).toBe(true) + expect(runner.lane).toBe(laneOf(runner.id)) + expect(runner.tokenHint).toBe(token.slice(-4)) + expect(runner.state).toBe('never-seen') + // What the database holds is the hash, and the owner Telegram named. + expect(pool.table[0].token_hash).toBe(hashRunnerToken(token)) + expect(pool.table[0].owner_name).toBe('Alice') + + const listed = await server.request( + '/queen/me/runners', + as('alice-session-token-0001'), + ) + const text = await listed.text() + expect(text).not.toContain(token) + expect(text).not.toContain(pool.table[0].token_hash) + expect(text).not.toContain('111') + const body = JSON.parse(text) + expect(body.limit).toBe(MAX_RUNNERS_PER_PERSON) + expect(body.runners.map((r: { label: string }) => r.label)).toEqual([ + 'laptop', + ]) + }) + + it('refuses a runner without a name', async () => { + const { server } = app() + const res = await server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: ' ' }), + }), + ) + expect(res.status).toBe(400) + }) + + it('stops at the limit, and a revoked runner frees its place', async () => { + const { server } = app() + const create = () => + server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: 'box' }), + }), + ) + for (let i = 0; i < MAX_RUNNERS_PER_PERSON; i++) + expect((await create()).status).toBe(201) + expect((await create()).status).toBe(409) + const revoked = await server.request( + '/queen/me/runners/1', + as('alice-session-token-0001', { method: 'DELETE' }), + ) + expect(revoked.status).toBe(204) + expect((await create()).status).toBe(201) + }) + + it('shows and revokes only your own runners', async () => { + const { server } = app() + await server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: 'alice box' }), + }), + ) + const bobsList = await server.request( + '/queen/me/runners', + as('bob-session-token-000002'), + ) + expect(((await bobsList.json()) as { runners: unknown[] }).runners).toEqual( + [], + ) + const bobRevokes = await server.request( + '/queen/me/runners/1', + as('bob-session-token-000002', { method: 'DELETE' }), + ) + expect(bobRevokes.status).toBe(404) + const nonsense = await server.request( + '/queen/me/runners/abc', + as('alice-session-token-0001', { method: 'DELETE' }), + ) + expect(nonsense.status).toBe(404) + }) +}) + +describe('the runner', () => { + it('heartbeats with its token, learns its lane, and is told there is no work yet', async () => { + const { server } = app() + const made = await server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: 'laptop' }), + }), + ) + const { token, runner } = (await made.json()) as { + token: string + runner: { id: number } + } + const beat = await server.request( + '/queen/runner/heartbeat', + as(token, { method: 'POST' }), + ) + expect(beat.status).toBe(200) + const body = (await beat.json()) as { + runner: { lane: number } + work: unknown + } + expect(body.runner.lane).toBe(laneOf(runner.id)) + expect(body.work).toBeNull() + + const list = await server.request( + '/queen/me/runners', + as('alice-session-token-0001'), + ) + const [shown] = ((await list.json()) as { runners: { state: string }[] }) + .runners + expect(shown.state).toBe('online') + }) + + it('is refused once revoked, and a session token is not a runner token', async () => { + const { server } = app() + const made = await server.request( + '/queen/me/runners', + as('alice-session-token-0001', { + method: 'POST', + body: JSON.stringify({ label: 'laptop' }), + }), + ) + const { token } = (await made.json()) as { token: string } + await server.request( + '/queen/me/runners/1', + as('alice-session-token-0001', { method: 'DELETE' }), + ) + const beat = await server.request( + '/queen/runner/heartbeat', + as(token, { method: 'POST' }), + ) + expect(beat.status).toBe(401) + const wrongKind = await server.request( + '/queen/runner/heartbeat', + as('alice-session-token-0001', { method: 'POST' }), + ) + expect(wrongKind.status).toBe(401) + }) +}) + +describe('the cabinet CORS', () => { + it('lets exactly app.t27.ai send a bearer, without cookies', async () => { + const { server } = app() + const pre = await server.request('/queen/me/runners', { + method: 'OPTIONS', + headers: { + Origin: APP_CABINET_ORIGIN, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'authorization,content-type', + }, + }) + expect(pre.status).toBe(204) + expect(pre.headers.get('access-control-allow-origin')).toBe( + APP_CABINET_ORIGIN, + ) + expect(pre.headers.get('access-control-allow-headers')).toContain( + 'Authorization', + ) + expect(pre.headers.get('access-control-allow-credentials')).toBeNull() + }) + + it('grants nothing to any other origin', async () => { + const { server } = app() + const res = await server.request('/queen/me/runners', { + headers: { + Origin: 'https://evil.example', + Authorization: 'Bearer alice-session-token-0001', + }, + }) + expect(res.headers.get('access-control-allow-origin')).toBeNull() + }) +}) + +describe('runner lanes on the leaderboard', () => { + const work = (keyIndex: number, accepted: number) => ({ + keyIndex, + accepted, + specs: 0, + finished: accepted, + hours: 1, + }) + + it('gathers one person’s runners into one row, apart from operator names', () => { + const rows = rank( + [work(0, 1), work(laneOf(1), 2), work(laneOf(2), 1), work(laneOf(3), 1)], + { 0: 'Dmitrii' }, + { + [laneOf(1)]: { name: 'Dmitrii', person: '111' }, + [laneOf(2)]: { name: 'Dmitrii', person: '111' }, + [laneOf(3)]: { name: 'Bob', person: '222' }, + }, + ) + const runnerRow = rows.find((r) => r.runner && r.name === 'Dmitrii') + const operatorRow = rows.find((r) => !r.runner && r.name === 'Dmitrii') + expect(runnerRow?.keys).toEqual([laneOf(1), laneOf(2)]) + expect(runnerRow?.accepted).toBe(3) + expect(operatorRow?.keys).toEqual([0]) + expect(rows.length).toBe(3) + }) + + it('never turns a runner name into a GitHub link', () => { + const [row] = rank( + [work(laneOf(1), 1)], + {}, + { + [laneOf(1)]: { name: '@torvalds', person: '111' }, + }, + ) + expect(row.github).toBeUndefined() + expect(row.claimed).toBe(true) + }) + + it('maps every runner ever minted, revoked ones included', async () => { + const pool = fakePool() + pool.table.push({ + id: 5, + telegram_id: '111', + owner_name: 'Alice', + label: 'old', + token_hash: 'h', + token_hint: 'xxxx', + created_at: new Date(), + last_seen_at: null, + revoked_at: new Date(), + }) + expect(await runnerOwners(pool)).toEqual({ + [laneOf(5)]: { name: 'Alice', person: '111' }, + }) + }) +}) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index a838ffbfca..c00426f06b 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -25,7 +25,7 @@ import { const source = readServerSource() const report = auditServer(source, DEFAULT_ALLOWLIST) -// Regression pin for the --no-allowlist run: exactly these nine mounts carry +// Regression pin for the --no-allowlist run: exactly these eleven mounts carry // no guard today, each for a reason the comments beside the mount give. // RE-MEASURED 2026-09-13: /api/inngest joined. It is not a shell - it is the // Queen's scheduler endpoint - and it is unguarded on purpose: Inngest signs @@ -36,6 +36,11 @@ const report = auditServer(source, DEFAULT_ALLOWLIST) // answered 404 for its whole life; mounting it is what put it on this list. A // shell on the same terms as the dashboard - no state and no token in the // HTML - which is the only reason a page is allowed to answer a stranger. +// RE-MEASURED 2026-10-01: /queen/me/runners and /queen/runner joined. Neither +// is a shell and neither is open: each checks its own bearer on every request +// (a session the app.t27.ai issuer confirms; a live runner token), which is a +// guard the trusted-origin check cannot express - it would refuse the one page +// and the one process that call them. Reasons in tools/route-guard-audit.mjs. const EXPECTED_UNGUARDED_WITHOUT_ALLOWLIST = [ '/api/inngest', '/health', @@ -44,7 +49,9 @@ const EXPECTED_UNGUARDED_WITHOUT_ALLOWLIST = [ '/queen/feed', '/queen/hq', '/queen/kanban', + '/queen/me/runners', '/queen/roadmap', + '/queen/runner', '/queen/tree', ] @@ -99,7 +106,10 @@ describe('route-guard audit over src/api/server.ts', () => { // outside watchers write into her report through. It WRITES, so it is a // guarded wrapper like /queen/needs-you, and `guardedSubAppCount` went 15 // to 16. Public-read and prefix counts unchanged. - expect(report.totalMounts).toBe(49) + // RE-MEASURED with the runner cabinet merged in: 49 became 51. + // /queen/me/runners and /queen/runner are allowlisted with their + // own-bearer reasons; no other count moved. + expect(report.totalMounts).toBe(51) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(16) expect(report.publicReadCount).toBe(10) @@ -112,7 +122,7 @@ describe('route-guard audit over src/api/server.ts', () => { expect(report.entriesMissingReason).toEqual([]) }) - it('reports exactly the nine reasoned exceptions when the allowlist is dropped', () => { + it('reports exactly the eleven reasoned exceptions when the allowlist is dropped', () => { // The classifier reports mounts in file order; the assertion is on the // exact set, so both sides are sorted before comparing. expect([...unguardedMounts(source, [])].sort()).toEqual( @@ -120,7 +130,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-six /queen mounts into 10 public-read, 9 wrapper-guarded and 7 allowlisted', () => { + it('splits the twenty-eight /queen mounts into 10 public-read, 9 wrapper-guarded and 9 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -159,7 +169,11 @@ describe('route-guard audit over src/api/server.ts', () => { // RE-MEASURED 2026-10-03: twenty-five became twenty-six. The ninth wrapper // is /queen/report, where outside watchers write into her report; guarded // inside its own sub-app because it writes. - expect(queenMounts.length).toBe(26) + // RE-MEASURED with the runner cabinet merged in: twenty-six became + // twenty-eight. The two new allowlisted mounts are not shells: + // /queen/me/runners and /queen/runner answer only a bearer they verify + // themselves (see the reasons). + expect(queenMounts.length).toBe(28) const counts: Record = { 'public-read': 0, @@ -176,7 +190,7 @@ describe('route-guard audit over src/api/server.ts', () => { 'public-read': 10, 'prefix-guard': 0, wrapper: 9, - unguarded: 7, + unguarded: 9, }) // Every unguarded /queen mount must be one of the allowlisted shells. diff --git a/trios/tools/route-guard-audit.mjs b/trios/tools/route-guard-audit.mjs index 5dda90e16d..fa3ab4ecb0 100644 --- a/trios/tools/route-guard-audit.mjs +++ b/trios/tools/route-guard-audit.mjs @@ -105,6 +105,16 @@ export const DEFAULT_ALLOWLIST = [ reason: 'shell only — the operator page holds no state and no token; its numbers come from /queen/lease and its one action POSTs there with a bearer the reader supplies, so both stay guarded (comment at the mount)', }, + { + path: '/queen/me/runners', + reason: + 'own bearer — every request is refused 401 unless its Authorization bearer is a session the app.t27.ai issuer (vibee-render whoami) confirms, and it only lists, mints or revokes runner tokens of the person that session names; the trusted-origin check would refuse the one page that calls it, and its CORS is exactly https://app.t27.ai with no credentials (src/api/routes/queen-runners.ts, tests/api/queen-runners.test.ts)', + }, + { + path: '/queen/runner', + reason: + 'own bearer — a runner process on the lender machine, with no browser Origin; every request is refused 401 unless its bearer hashes to a live runner token, and the answer is only that runner own lane (src/api/routes/queen-runners.ts, tests/api/queen-runners.test.ts)', + }, { path: '/queen/contributor-keys', reason: