From 126f14e4841d85ac7c3b6b3066954fc6f41cd629 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:07:29 +0000 Subject: [PATCH 1/7] feat(queen): runner cabinet - lend a lane without lending a key A signed-in person (app.t27.ai session, verified by asking its issuer's whoami for telegram_id) can mint, list and revoke runner tokens at /queen/me/runners. A runner process on the lender's own machine presents that token at /queen/runner/heartbeat and learns its lane. The provider key never reaches the Queen: no route accepts, stores or returns one. - queen_runner table: only the token's SHA-256 and last four characters are kept; at most 5 live runners per person. - A runner's lane is 100000000 + id, a key_index block no operator pool reaches, so dispatch rows on it are credited by the existing leaderboard arithmetic. - Leaderboard gathers runner lanes by person (telegram_id), never by name, so a runner cannot merge into an operator's row, and never links a runner to an unverified GitHub login. - CORS for /queen/me/* is exactly https://app.t27.ai, bearer only, no credentials; both new mounts are on the route-guard allowlist with an own-bearer reason, and the audit pins are re-measured. Taking tasks and handing work back (claim/complete, remote review, runner CLI) is the next stage; the heartbeat says so instead of offering work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../server/src/api/routes/queen-runners.ts | 164 ++++++ .../apps/server/src/api/server.ts | 16 +- .../src/api/services/queen-app-identity.ts | 185 ++++++ .../src/api/services/queen-leaderboard.ts | 40 +- .../server/src/api/services/queen-runners.ts | 248 ++++++++ .../apps/server/src/api/utils/cors.ts | 35 ++ .../apps/server/src/lib/db/pg-migrate.ts | 19 + .../server/tests/api/queen-runners.test.ts | 557 ++++++++++++++++++ .../tests/api/routes/route-guard.test.ts | 23 +- trios/tools/route-guard-audit.mjs | 10 + 10 files changed, 1284 insertions(+), 13 deletions(-) create mode 100644 trios/agent-server/apps/server/src/api/routes/queen-runners.ts create mode 100644 trios/agent-server/apps/server/src/api/services/queen-app-identity.ts create mode 100644 trios/agent-server/apps/server/src/api/services/queen-runners.ts create mode 100644 trios/agent-server/apps/server/tests/api/queen-runners.test.ts 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 7959e7361f..2295991829 100644 --- a/trios/agent-server/apps/server/src/api/server.ts +++ b/trios/agent-server/apps/server/src/api/server.ts @@ -66,6 +66,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' @@ -85,7 +89,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 { @@ -373,6 +381,8 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/queen/public-agents', publicReadCorsMiddleware()) .use('/queen/public-leaderboard', 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 @@ -390,6 +400,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/registry', queenRegistryRoutes) // The shell only. It holds no state and no token; every byte of data it // shows comes from /queen/lease, which stays guarded. See the route header 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 665a03ca0f..736af70145 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 @@ -39,6 +39,7 @@ * leaves the environment, and only its index appears in the database. */ import type { Pool } from 'pg' +import { runnerOwners } from './queen-runners' /** An issue the Queen accepted, on this key. */ export const ACCEPTED_XP = 100 @@ -72,8 +73,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[] @@ -131,20 +134,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, @@ -157,7 +176,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 @@ -255,10 +274,17 @@ 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)), + contributors: rank( + work, + parseOwners(process.env.TRIOS_KEY_OWNERS), + 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 c74b3d7425..be0e993ccc 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 @@ -288,6 +288,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; + CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation_order ON "conversationMessages" ("conversationId", "orderIndex"); ` 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 f5950ac673..c0f4113a6f 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 @@ -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', @@ -43,7 +48,9 @@ const EXPECTED_UNGUARDED_WITHOUT_ALLOWLIST = [ '/queen/feed', '/queen/hq', '/queen/kanban', + '/queen/me/runners', '/queen/roadmap', + '/queen/runner', '/queen/tree', ] @@ -84,7 +91,10 @@ describe('route-guard audit over src/api/server.ts', () => { // where a route named `public` belongs. Every other number here is // unchanged, which is the part worth stating: no guarded route quietly lost // its guard to make room for it. - expect(report.totalMounts).toBe(45) + // RE-MEASURED 2026-10-01: 45 became 47 with the runner cabinet and the + // runner door, both allowlisted with their own-bearer reason; no other + // count moved, so no guarded route lost its guard to make room for them. + expect(report.totalMounts).toBe(47) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(15) expect(report.publicReadCount).toBe(8) @@ -97,7 +107,7 @@ describe('route-guard audit over src/api/server.ts', () => { expect(report.entriesMissingReason).toEqual([]) }) - it('reports exactly the seven reasoned exceptions when the allowlist is dropped', () => { + it('reports exactly the 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( @@ -105,7 +115,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-two /queen mounts into 8 public-read, 8 wrapper-guarded and 6 allowlisted shells', () => { + it('splits the twenty-four /queen mounts into 8 public-read, 8 wrapper-guarded and 8 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -127,7 +137,10 @@ describe('route-guard audit over src/api/server.ts', () => { // issue title, no worker text and no credential: only a key's INDEX ever // reaches the database, so there is nothing here a stranger could read that // the board does not already show. - expect(queenMounts.length).toBe(22) + // RE-MEASURED 2026-10-01: twenty-two became twenty-four. 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(24) const counts: Record = { 'public-read': 0, @@ -144,7 +157,7 @@ describe('route-guard audit over src/api/server.ts', () => { 'public-read': 8, 'prefix-guard': 0, wrapper: 8, - unguarded: 6, + unguarded: 8, }) // 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 0254f4feb8..4d7dfe7b1a 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: '/api/inngest', reason: From 53e555d83ea1f5cfcc015fae970a3ec6af41f291 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:52:29 +0000 Subject: [PATCH 2/7] fix(ci): pin Bun to the workspace version and untrack a local node_modules symlink Every `Tests / *` job on #517 and #518 was cancelled at the 20-minute budget while still inside `bun ci`, before any test ran. Two things combined: - trios/agent-server/apps/server/node_modules was committed as a symlink to a local macOS path. `.gitignore` said `node_modules/`, which only matches directories, so the symlink slipped through. - setup-bun ran without a version. The `packageManager: bun@1.3.6` pin lives in trios/agent-server/package.json, not at the repo root, so CI got the latest release (1.4.2), which hangs on that dangling symlink. 1.3.x installs past it. Untrack the symlink, make the ignore rule match files too, and read the Bun version from the workspace package.json in test.yml. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .github/workflows/test.yml | 6 ++++++ trios/agent-server/.gitignore | 4 +++- trios/agent-server/apps/server/node_modules | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) delete mode 120000 trios/agent-server/apps/server/node_modules diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da5e35e3f3..decf3c12d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -134,6 +134,12 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + # The pin (packageManager: bun@1.3.6) lives in the workspace, not at the + # repo root, so without this setup-bun installs the latest release. + # Bun 1.4.2 hung in `bun ci` for the whole 20-minute budget on every + # suite (#517, #518) before a single test ran. + bun-version-file: trios/agent-server/package.json - name: Install dependencies run: bun ci diff --git a/trios/agent-server/.gitignore b/trios/agent-server/.gitignore index 5b07fe5f44..fade51cb84 100644 --- a/trios/agent-server/.gitignore +++ b/trios/agent-server/.gitignore @@ -53,7 +53,9 @@ bower_components build/Release # Dependency directories -node_modules/ +# No trailing slash: a symlinked node_modules is a file, and `node_modules/` +# let one pointing at a local Mac path be committed and hang CI's install. +node_modules jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/trios/agent-server/apps/server/node_modules b/trios/agent-server/apps/server/node_modules deleted file mode 120000 index d9dd43591a..0000000000 --- a/trios/agent-server/apps/server/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/playom/queen-patches/work/browseros-deploy/trios/agent-server/apps/server/node_modules \ No newline at end of file From 83d28a894338efaeca70bf927be2207235216ab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:04:04 +0000 Subject: [PATCH 3/7] test(tools): get_page_content reads a constructed page, not the live example.com With installs no longer hanging, server-tools ran for the first time since 2026-09-23 and failed one test: get_page_content read https://example.com 57 ms after opening it and found no "Example Domain". The test is about extracting text, so it now writes that text into about:blank with evaluate_script, as get_page_links already does. Locally (BrowserOS AppImage, headless, --no-sandbox): the old test fails the same way; the new one passes 3/3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/observation.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/observation.test.ts b/trios/agent-server/apps/server/tests/tools/observation.test.ts index 982296c644..373f2e41fa 100644 --- a/trios/agent-server/apps/server/tests/tools/observation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/observation.test.ts @@ -157,8 +157,17 @@ describe('observation tools', () => { it('get_page_content returns markdown text', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // Constructed in place rather than fetched: reading https://example.com + // raced its own load (CI read it 57 ms after opening, before any text + // arrived), and the test is about extracting text, not about the network. + const newResult = await execute(new_page, { url: 'about:blank' }) const pageId = pageIdOf(newResult) + await execute(evaluate_script, { + page: pageId, + expression: `document.body.innerHTML = ${JSON.stringify( + '

Example Domain

This domain is for use in documentation examples.

', + )}`, + }) const contentResult = await execute(get_page_content, { page: pageId }) assert.ok(!contentResult.isError, textOf(contentResult)) From 5d69adb8dee0e772275ed396e3fb99131272b145 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:13:48 +0000 Subject: [PATCH 4/7] test(helpers): killProcessOnPort kills listeners only, never the test process server-tools still exited 1 after every test in observation.test.ts passed: before navigation-newtab-guard.test.ts the helper ran `lsof -ti :`, which also lists clients still connected to the port. One of them was the bun test process itself (its CDP socket to the previous file's browser), so the SIGTERM ended the whole run and no junit report was written ("workflow > server-tools setup"). Use `lsof -ti tcp: -sTCP:LISTEN` and drop process.pid. Locally, input.test.ts + navigation-newtab-guard.test.ts in one process: before, exit 143 right after "Terminating process(es) , ..."; after, 18 pass / 0 fail. The whole test:tools group now runs to the end (242 pass; the 2 local failures load https://example.com, which this sandbox's browser cannot reach and CI can). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/__helpers__/utils.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/trios/agent-server/apps/server/tests/__helpers__/utils.ts b/trios/agent-server/apps/server/tests/__helpers__/utils.ts index 7c684896b4..c808f6e245 100644 --- a/trios/agent-server/apps/server/tests/__helpers__/utils.ts +++ b/trios/agent-server/apps/server/tests/__helpers__/utils.ts @@ -14,22 +14,29 @@ export async function killProcessOnPort(port: number): Promise { try { console.log(`Finding process on port ${port}...`) - const pids = execSync(`lsof -ti :${port}`, { + // LISTEN only, and never this process. A bare `lsof -i :port` also lists + // every client still connected to the port - including this test process, + // whose CDP socket to the previous file's browser outlives it - and the + // SIGTERM that followed ended the whole server-tools run (exit 143). + const pids = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], - }).trim() + }) + .split('\n') + .map((pid) => pid.trim()) + .filter((pid) => pid !== '' && pid !== String(process.pid)) + .join(' ') if (pids) { - const pidList = pids.replace(/\n/g, ', ') - console.log(`Terminating process(es) ${pidList} on port ${port}...`) + console.log(`Terminating process(es) ${pids} on port ${port}...`) try { - execSync(`kill -15 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -15 ${pids}`, { stdio: 'ignore', }) await new Promise((resolve) => setTimeout(resolve, 500)) } catch { - execSync(`kill -9 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -9 ${pids}`, { stdio: 'ignore', }) } From 6bf548280ff3b2f77e4fe634f5967af82d39b068 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:38:27 +0000 Subject: [PATCH 5/7] test(tools): wait_for waits for text a data: page adds, not the live example.com With the run no longer killing itself, server-tools finished in CI with 243 pass / 1 fail: `wait_for finds text on page` waited its full 10 s for "Example Domain" on https://example.com and never saw it - the same page get_page_content could not read either. The page now adds that text itself 500 ms after load, so the test still proves wait_for waits, with nothing outside the runner involved. Locally: 2/2 wait_for tests pass on repeat; the whole test:tools group is 243 pass, the one local failure being take_screenshot (a 60 s hang in this sandbox only - it passes in CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/navigation.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/navigation.test.ts b/trios/agent-server/apps/server/tests/tools/navigation.test.ts index f78b9942bf..b69331d2ed 100644 --- a/trios/agent-server/apps/server/tests/tools/navigation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/navigation.test.ts @@ -157,7 +157,15 @@ describe('navigation tools', () => { it('wait_for finds text on page', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // The text arrives half a second after load, from the page itself, so + // this still exercises the waiting - without depending on the live + // https://example.com, which CI's browser never showed it on. + const page = `` + const newResult = await execute(new_page, { + url: `data:text/html,${encodeURIComponent(page)}`, + }) const pageId = structuredOf<{ pageId: number }>(newResult).pageId const waitResult = await execute(wait_for, { From 4f8bb9f9c24b1f9816e0af93c2417d4fc57b75f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:42:02 +0000 Subject: [PATCH 6/7] test(tools): the class-selector search_dom test retries the load race too server-tools on 3d57649 ran clean except one test that had passed on both earlier runs: `search_dom > finds multiple elements with CSS class selector` (123 ms, fewer than 3 matches). It searches once, straight after new_page - the race this file already names and fixes with searchUntil for two sibling tests. Use the same helper here. Locally: search_dom 13/13, three runs in a row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- trios/agent-server/apps/server/tests/tools/dom.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/trios/agent-server/apps/server/tests/tools/dom.test.ts b/trios/agent-server/apps/server/tests/tools/dom.test.ts index d487a8f2cb..53e9ccd686 100644 --- a/trios/agent-server/apps/server/tests/tools/dom.test.ts +++ b/trios/agent-server/apps/server/tests/tools/dom.test.ts @@ -400,10 +400,9 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - const result = await execute(search_dom, { - page: pageId, - query: '.nav-link', - }) + // Same load race searchUntil exists for: CI found fewer than 3 once + // (2026-10-01) on a run where the identical query passed before. + const result = await searchUntil(execute, pageId, '.nav-link', 'Found 3') assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(text.includes('Found 3'), 'Should find exactly 3 nav links') From bf15d74fa873cfd9375d7aaa1fbdb3fbb2b6c8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:44:28 +0000 Subject: [PATCH 7/7] test(queen): give the 205-file salvage rename test an explicit 30 s budget `the salvage commit > never splits a rename across the path cap` runs real git over 205 files and salvageWorktree. It takes ~2 s for the whole file locally and passed on the two CI runs before, then hit bun's 5 s default once on a loaded runner (job 110500921083) with nothing in the change touching salvage. A git-heavy fixture test should not share the budget of a pure unit test. Locally: queen-salvage-guards.test.ts 13 pass / 0 fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/api/queen-salvage-guards.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts index a533efeecc..f80ba552ca 100644 --- a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts @@ -541,7 +541,9 @@ describe('the salvage commit', () => { expect(head).toContain('trios/docs/a/new.md') expect(head).not.toContain('trios/docs/z/old.md') rmSync(f.scratch, { recursive: true, force: true }) - }) + // Real git over 205 files: ~0.2 s here, but once over bun's 5 s default on + // a loaded CI runner (2026-10-01) while passing on the runs either side. + }, 30_000) }) // ---------------------------------------------------------------------------