From 2cef552888cec7d4f1256b4a2bb1cee9a204c3c5 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Feb 2026 22:23:55 -0600 Subject: [PATCH 1/2] =?UTF-8?q?[Gastown]=20PR=208.5:=20Mayor=20Tools=20?= =?UTF-8?q?=E2=80=94=20Cross-Rig=20Delegation=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mayor-specific tools so the Mayor agent can delegate work across rigs, transforming it from a chatbot into the town coordinator described in the Gastown architecture spec. Worker: - New /api/mayor/:townId/tools/* routes (sling, rigs, beads, agents, mail) - mayorAuthMiddleware validates townId-scoped JWT (no rigId restriction) - Handlers fan out to GastownUserDO (list rigs) and RigDO (sling, beads, agents, mail) Plugin: - MayorGastownClient for town-scoped HTTP operations - 5 mayor tools (gt_sling, gt_list_rigs, gt_list_beads, gt_list_agents, gt_mail_send) - Conditional loading: GASTOWN_AGENT_ROLE=mayor loads mayor tools, otherwise rig tools - Guarded prime/checkpoint hooks (mayor has no rig-scoped prime) MayorDO: - Passes GASTOWN_AGENT_ROLE, GASTOWN_TOWN_ID, GASTOWN_API_URL env vars to container - Uses buildMayorSystemPrompt() with detailed tool docs and delegation instructions Closes #339 --- cloudflare-gastown/container/plugin/client.ts | 142 +++++++++++++- cloudflare-gastown/container/plugin/index.ts | 35 ++-- .../container/plugin/mayor-tools.ts | 145 ++++++++++++++ cloudflare-gastown/container/plugin/types.ts | 29 ++- cloudflare-gastown/src/dos/Mayor.do.ts | 23 ++- cloudflare-gastown/src/gastown.worker.ts | 24 +++ .../src/handlers/mayor-tools.handler.ts | 180 ++++++++++++++++++ .../src/middleware/mayor-auth.middleware.ts | 54 ++++++ .../src/prompts/mayor-system.prompt.ts | 60 ++++++ 9 files changed, 668 insertions(+), 24 deletions(-) create mode 100644 cloudflare-gastown/container/plugin/mayor-tools.ts create mode 100644 cloudflare-gastown/src/handlers/mayor-tools.handler.ts create mode 100644 cloudflare-gastown/src/middleware/mayor-auth.middleware.ts create mode 100644 cloudflare-gastown/src/prompts/mayor-system.prompt.ts diff --git a/cloudflare-gastown/container/plugin/client.ts b/cloudflare-gastown/container/plugin/client.ts index 7ab5bfbea0..ce261d89f7 100644 --- a/cloudflare-gastown/container/plugin/client.ts +++ b/cloudflare-gastown/container/plugin/client.ts @@ -1,4 +1,17 @@ -import type { ApiResponse, Bead, BeadPriority, GastownEnv, Mail, PrimeContext } from './types'; +import type { + Agent, + ApiResponse, + Bead, + BeadPriority, + BeadStatus, + BeadType, + GastownEnv, + Mail, + MayorGastownEnv, + PrimeContext, + Rig, + SlingResult, +} from './types'; function isApiResponse(value: unknown): value is ApiResponse { return ( @@ -130,6 +143,114 @@ export class GastownClient { } } +/** + * Mayor-scoped client for town-level cross-rig operations. + * Uses `/api/mayor/:townId/tools/*` routes authenticated via townId-scoped JWT. + */ +export class MayorGastownClient { + private baseUrl: string; + private token: string; + private agentId: string; + private townId: string; + + constructor(env: MayorGastownEnv) { + this.baseUrl = env.apiUrl.replace(/\/+$/, ''); + this.token = env.sessionToken; + this.agentId = env.agentId; + this.townId = env.townId; + } + + private mayorPath(path: string): string { + return `${this.baseUrl}/api/mayor/${this.townId}/tools${path}`; + } + + private async request(url: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set('Content-Type', 'application/json'); + headers.set('Authorization', `Bearer ${this.token}`); + + let response: Response; + try { + response = await fetch(url, { ...init, headers }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new GastownApiError(`Network error: ${message}`, 0); + } + + if (response.status === 204) { + return undefined as T; + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new GastownApiError(`Invalid JSON response (HTTP ${response.status})`, response.status); + } + + if (!isApiResponse(body)) { + throw new GastownApiError( + `Unexpected response shape (HTTP ${response.status})`, + response.status + ); + } + + if (!body.success) { + throw new GastownApiError((body as { error: string }).error, response.status); + } + + return (body as { data: T }).data; + } + + // -- Mayor tool endpoints -- + + async sling(input: { + rig_id: string; + title: string; + body?: string; + metadata?: Record; + }): Promise { + return this.request(this.mayorPath('/sling'), { + method: 'POST', + body: JSON.stringify(input), + }); + } + + async listRigs(): Promise { + return this.request(this.mayorPath('/rigs')); + } + + async listBeads( + rigId: string, + filter?: { status?: BeadStatus; type?: BeadType } + ): Promise { + const params = new URLSearchParams(); + if (filter?.status) params.set('status', filter.status); + if (filter?.type) params.set('type', filter.type); + const qs = params.toString(); + return this.request(this.mayorPath(`/rigs/${rigId}/beads${qs ? `?${qs}` : ''}`)); + } + + async listAgents(rigId: string): Promise { + return this.request(this.mayorPath(`/rigs/${rigId}/agents`)); + } + + async sendMail(input: { + rig_id: string; + to_agent_id: string; + subject: string; + body: string; + }): Promise { + await this.request(this.mayorPath('/mail'), { + method: 'POST', + body: JSON.stringify({ + ...input, + from_agent_id: this.agentId, + }), + }); + } +} + export class GastownApiError extends Error { readonly status: number; @@ -158,3 +279,22 @@ export function createClientFromEnv(): GastownClient { return new GastownClient({ apiUrl, sessionToken, agentId, rigId }); } + +export function createMayorClientFromEnv(): MayorGastownClient { + const apiUrl = process.env.GASTOWN_API_URL; + const sessionToken = process.env.GASTOWN_SESSION_TOKEN; + const agentId = process.env.GASTOWN_AGENT_ID; + const townId = process.env.GASTOWN_TOWN_ID; + + if (!apiUrl || !sessionToken || !agentId || !townId) { + const missing = [ + !apiUrl && 'GASTOWN_API_URL', + !sessionToken && 'GASTOWN_SESSION_TOKEN', + !agentId && 'GASTOWN_AGENT_ID', + !townId && 'GASTOWN_TOWN_ID', + ].filter(Boolean); + throw new Error(`Missing required mayor environment variables: ${missing.join(', ')}`); + } + + return new MayorGastownClient({ apiUrl, sessionToken, agentId, townId }); +} diff --git a/cloudflare-gastown/container/plugin/index.ts b/cloudflare-gastown/container/plugin/index.ts index 404a4c2079..a9da2599c6 100644 --- a/cloudflare-gastown/container/plugin/index.ts +++ b/cloudflare-gastown/container/plugin/index.ts @@ -1,6 +1,7 @@ import type { Plugin } from '@opencode-ai/plugin'; -import { createClientFromEnv, GastownApiError } from './client'; +import { createClientFromEnv, createMayorClientFromEnv, GastownApiError } from './client'; import { createTools } from './tools'; +import { createMayorTools } from './mayor-tools'; const SERVICE = 'gastown-plugin'; @@ -17,8 +18,16 @@ function formatPrimeContextForInjection(primeResult: string): string { } export const GastownPlugin: Plugin = async ({ client }) => { - const gastownClient = createClientFromEnv(); - const tools = createTools(gastownClient); + const isMayor = process.env.GASTOWN_AGENT_ROLE === 'mayor'; + + // Mayor gets town-scoped tools; rig agents get rig-scoped tools. + // The mayor doesn't have a rigId — it operates across rigs. + const gastownClient = isMayor ? null : createClientFromEnv(); + const mayorClient = isMayor ? createMayorClientFromEnv() : null; + + const rigTools = gastownClient ? createTools(gastownClient) : {}; + const mayorTools = mayorClient ? createMayorTools(mayorClient) : {}; + const tools = { ...rigTools, ...mayorTools }; // Best-effort logging — never let telemetry failures break tool execution async function log(level: 'info' | 'error', message: string) { @@ -29,8 +38,9 @@ export const GastownPlugin: Plugin = async ({ client }) => { } } - // Prime on session start and inject into context - async function primeAndLog(): Promise { + // Prime on session start and inject context (rig agents only — mayor has no prime) + async function primeAndLog(): Promise { + if (!gastownClient) return null; try { const ctx = await gastownClient.prime(); await log('info', 'primed successfully'); @@ -46,7 +56,7 @@ export const GastownPlugin: Plugin = async ({ client }) => { tool: tools, event: async ({ event }) => { - if (event.type === 'session.deleted') { + if (event.type === 'session.deleted' && gastownClient) { // Notify Rig DO that session ended — best-effort, don't throw try { await gastownClient.writeCheckpoint({ @@ -61,20 +71,23 @@ export const GastownPlugin: Plugin = async ({ client }) => { } }, - // Inject prime context into the system prompt on the first message + // Inject prime context into the system prompt on the first message (rig agents only) 'experimental.chat.system.transform': async (_input, output) => { - // Only inject once — check if already present const alreadyInjected = output.system.some(s => s.includes('GASTOWN CONTEXT')); if (!alreadyInjected) { const primeResult = await primeAndLog(); - output.system.push(formatPrimeContextForInjection(primeResult)); + if (primeResult) { + output.system.push(formatPrimeContextForInjection(primeResult)); + } } }, - // Re-inject prime context after compaction so the agent doesn't lose orientation + // Re-inject prime context after compaction (rig agents only) 'experimental.session.compacting': async (_input, output) => { const primeResult = await primeAndLog(); - output.context.push(formatPrimeContextForInjection(primeResult)); + if (primeResult) { + output.context.push(formatPrimeContextForInjection(primeResult)); + } }, }; }; diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts new file mode 100644 index 0000000000..67ec82f281 --- /dev/null +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -0,0 +1,145 @@ +import { tool } from '@opencode-ai/plugin'; +import type { MayorGastownClient } from './client'; + +function parseJsonObject(value: string, label: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`Invalid JSON in "${label}"`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `"${label}" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` + ); + } + return parsed as Record; +} + +/** + * Mayor-specific tools for cross-rig delegation. + * These are only registered when `GASTOWN_AGENT_ROLE=mayor`. + */ +export function createMayorTools(client: MayorGastownClient) { + return { + gt_sling: tool({ + description: + 'Delegate a task to a polecat agent in a specific rig. ' + + 'Creates a bead (work item), assigns a polecat, and arms the dispatch alarm. ' + + 'The polecat will be started automatically and begin working on the task. ' + + 'You must specify which rig the work belongs to — use gt_list_rigs first if unsure.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig to assign work to'), + title: tool.schema.string().describe('Short title describing the task'), + body: tool.schema + .string() + .describe( + 'Detailed description of the work to be done. Include requirements, context, acceptance criteria.' + ) + .optional(), + metadata: tool.schema + .string() + .describe('JSON-encoded metadata object for additional context') + .optional(), + }, + async execute(args) { + const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; + const result = await client.sling({ + rig_id: args.rig_id, + title: args.title, + body: args.body, + metadata, + }); + return [ + `Task slung successfully.`, + `Bead: ${result.bead.id} — "${result.bead.title}"`, + `Assigned to: ${result.agent.name} (${result.agent.role}, id: ${result.agent.id})`, + `Status: ${result.bead.status}`, + `The polecat will be dispatched automatically by the alarm scheduler.`, + ].join('\n'); + }, + }), + + gt_list_rigs: tool({ + description: + 'List all rigs (repositories) in your town. ' + + 'Returns the rig ID, name, git URL, and default branch for each rig. ' + + 'Use this to discover available rigs before delegating work with gt_sling.', + args: {}, + async execute() { + const rigs = await client.listRigs(); + if (rigs.length === 0) { + return 'No rigs configured in this town. A rig must be created before work can be delegated.'; + } + return JSON.stringify(rigs, null, 2); + }, + }), + + gt_list_beads: tool({ + description: + 'List beads (work items) in a specific rig. ' + + 'Optionally filter by status (open, in_progress, closed, failed) or type (issue, message, escalation, merge_request). ' + + 'Use this to check what work exists in a rig, what is in progress, and what has been completed.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig to list beads from'), + status: tool.schema + .enum(['open', 'in_progress', 'closed', 'failed']) + .describe('Filter by bead status') + .optional(), + type: tool.schema + .enum(['issue', 'message', 'escalation', 'merge_request']) + .describe('Filter by bead type') + .optional(), + }, + async execute(args) { + const beads = await client.listBeads(args.rig_id, { + status: args.status, + type: args.type, + }); + if (beads.length === 0) { + return 'No beads found matching the filter.'; + } + return JSON.stringify(beads, null, 2); + }, + }), + + gt_list_agents: tool({ + description: + 'List all agents in a specific rig. ' + + 'Returns agent ID, role, name, status, and current hook (assigned bead). ' + + 'Use this to see which agents are active, idle, or working on what.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig to list agents from'), + }, + async execute(args) { + const agents = await client.listAgents(args.rig_id); + if (agents.length === 0) { + return 'No agents registered in this rig.'; + } + return JSON.stringify(agents, null, 2); + }, + }), + + gt_mail_send: tool({ + description: + 'Send a mail message to an agent in any rig. ' + + 'Use this for cross-rig coordination, instructions, or status requests. ' + + 'The recipient must be identified by their agent UUID and rig UUID.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the recipient agent belongs to'), + to_agent_id: tool.schema.string().describe('The UUID of the recipient agent'), + subject: tool.schema.string().describe('Subject line for the mail'), + body: tool.schema.string().describe('Body content of the mail'), + }, + async execute(args) { + await client.sendMail({ + rig_id: args.rig_id, + to_agent_id: args.to_agent_id, + subject: args.subject, + body: args.body, + }); + return `Mail sent to agent ${args.to_agent_id} in rig ${args.rig_id}.`; + }, + }), + }; +} diff --git a/cloudflare-gastown/container/plugin/types.ts b/cloudflare-gastown/container/plugin/types.ts index 8a672ef61f..80852dcd2d 100644 --- a/cloudflare-gastown/container/plugin/types.ts +++ b/cloudflare-gastown/container/plugin/types.ts @@ -1,7 +1,7 @@ // Types mirroring the Rig DO domain model. // These are the API response shapes — the plugin never touches SQLite directly. -export type BeadStatus = 'open' | 'in_progress' | 'closed'; +export type BeadStatus = 'open' | 'in_progress' | 'closed' | 'failed'; export type BeadType = 'issue' | 'message' | 'escalation' | 'merge_request'; export type BeadPriority = 'low' | 'medium' | 'high' | 'critical'; @@ -60,10 +60,35 @@ export type ApiSuccess = { success: true; data: T }; export type ApiError = { success: false; error: string }; export type ApiResponse = ApiSuccess | ApiError; -// Environment variable config for the plugin +// Rig metadata (from GastownUserDO) +export type Rig = { + id: string; + town_id: string; + name: string; + git_url: string; + default_branch: string; + created_at: string; + updated_at: string; +}; + +// Sling result (bead + assigned agent) +export type SlingResult = { + bead: Bead; + agent: Agent; +}; + +// Environment variable config for the plugin (rig-scoped agents) export type GastownEnv = { apiUrl: string; sessionToken: string; agentId: string; rigId: string; }; + +// Environment variable config for the mayor (town-scoped) +export type MayorGastownEnv = { + apiUrl: string; + sessionToken: string; + agentId: string; + townId: string; +}; diff --git a/cloudflare-gastown/src/dos/Mayor.do.ts b/cloudflare-gastown/src/dos/Mayor.do.ts index 96228f9be7..9d2cf4288d 100644 --- a/cloudflare-gastown/src/dos/Mayor.do.ts +++ b/cloudflare-gastown/src/dos/Mayor.do.ts @@ -1,6 +1,7 @@ import { DurableObject } from 'cloudflare:workers'; import { getTownContainerStub } from './TownContainer.do'; import { signAgentJWT } from '../util/jwt.util'; +import { buildMayorSystemPrompt } from '../prompts/mayor-system.prompt'; const MAYOR_LOG = '[Mayor.do]'; @@ -290,14 +291,8 @@ export class MayorDO extends DurableObject { } /** System prompt for the mayor agent. */ - private static mayorSystemPrompt(identity: string): string { - return [ - `You are ${identity}, the Mayor of this Gastown town.`, - 'You are a persistent conversational agent that coordinates work across all rigs in your town.', - 'Users send you messages and you respond conversationally.', - 'When you need to delegate work, use your tools (gt_sling, gt_list_rigs, gt_list_beads, gt_list_agents, gt_mail_send).', - 'You maintain context across messages — this is a continuous conversation, not one-shot requests.', - ].join(' '); + private static mayorSystemPrompt(identity: string, townId: string): string { + return buildMayorSystemPrompt({ identity, townId }); } /** @@ -319,10 +314,18 @@ export class MayorDO extends DurableObject { const token = await this.mintMayorToken(agentId, config); - const envVars: Record = {}; + const envVars: Record = { + // Mayor-specific: tells the plugin to load mayor tools instead of rig tools + GASTOWN_AGENT_ROLE: 'mayor', + GASTOWN_TOWN_ID: config.townId, + GASTOWN_AGENT_ID: agentId, + }; if (token) { envVars.GASTOWN_SESSION_TOKEN = token; } + if (this.env.GASTOWN_API_URL) { + envVars.GASTOWN_API_URL = this.env.GASTOWN_API_URL; + } if (this.env.KILO_API_URL) { envVars.KILO_API_URL = this.env.KILO_API_URL; } @@ -349,7 +352,7 @@ export class MayorDO extends DurableObject { identity, prompt: initialMessage, model: model ?? 'kilo/claude-sonnet-4-20250514', - systemPrompt: MayorDO.mayorSystemPrompt(identity), + systemPrompt: MayorDO.mayorSystemPrompt(identity, config.townId), gitUrl: config.gitUrl, branch: `gt/mayor`, defaultBranch: config.defaultBranch, diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 6e4b6ebee2..57143012ef 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -59,6 +59,14 @@ import { handleMayorCompleted, handleDestroyMayor, } from './handlers/mayor.handler'; +import { + handleMayorSling, + handleMayorListRigs, + handleMayorListBeads, + handleMayorListAgents, + handleMayorSendMail, +} from './handlers/mayor-tools.handler'; +import { mayorAuthMiddleware } from './middleware/mayor-auth.middleware'; export { RigDO } from './dos/Rig.do'; export { GastownUserDO } from './dos/GastownUser.do'; @@ -202,6 +210,22 @@ app.get('/api/towns/:townId/mayor/status', c => handleGetMayorStatus(c, c.req.pa app.post('/api/towns/:townId/mayor/completed', c => handleMayorCompleted(c, c.req.param())); app.post('/api/towns/:townId/mayor/destroy', c => handleDestroyMayor(c, c.req.param())); +// ── Mayor Tools ────────────────────────────────────────────────────────── +// Tool endpoints called by the mayor's kilo serve session via the Gastown plugin. +// Authenticated via mayor JWT (townId-scoped, no rigId restriction). + +app.use('/api/mayor/:townId/tools/*', async (c, next) => + c.env.ENVIRONMENT === 'development' ? next() : mayorAuthMiddleware(c, next) +); + +app.post('/api/mayor/:townId/tools/sling', c => handleMayorSling(c, c.req.param())); +app.get('/api/mayor/:townId/tools/rigs', c => handleMayorListRigs(c, c.req.param())); +app.get('/api/mayor/:townId/tools/rigs/:rigId/beads', c => handleMayorListBeads(c, c.req.param())); +app.get('/api/mayor/:townId/tools/rigs/:rigId/agents', c => + handleMayorListAgents(c, c.req.param()) +); +app.post('/api/mayor/:townId/tools/mail', c => handleMayorSendMail(c, c.req.param())); + // ── Error handling ────────────────────────────────────────────────────── app.notFound(c => c.json(resError('Not found'), 404)); diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts new file mode 100644 index 0000000000..64af96b3e8 --- /dev/null +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -0,0 +1,180 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; +import { getRigDOStub } from '../dos/Rig.do'; +import { getGastownUserStub } from '../dos/GastownUser.do'; +import { resSuccess, resError } from '../util/res.util'; +import { parseJsonBody } from '../util/parse-json-body.util'; +import { BeadStatus, BeadType } from '../types'; +import type { GastownEnv } from '../gastown.worker'; + +const HANDLER_LOG = '[mayor-tools.handler]'; + +// ── Schemas ────────────────────────────────────────────────────────────── + +const MayorSlingBody = z.object({ + rig_id: z.string().min(1), + title: z.string().min(1), + body: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const MayorMailBody = z.object({ + rig_id: z.string().min(1), + to_agent_id: z.string().min(1), + subject: z.string().min(1), + body: z.string().min(1), + from_agent_id: z.string().min(1), +}); + +const NonNegativeInt = z.coerce.number().int().nonnegative(); + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** + * Resolve the userId for the town by reading the JWT payload. + * The mayor's JWT contains the userId that owns the town. + */ +function getUserIdFromJWT(c: Context): string | null { + const jwt = c.get('agentJWT'); + return jwt?.userId ?? null; +} + +// ── Handlers ───────────────────────────────────────────────────────────── + +/** + * POST /api/mayor/:townId/tools/sling + * Sling a task to a polecat in a specific rig. Creates a bead, assigns + * an agent, and arms the alarm for dispatch. + */ +export async function handleMayorSling(c: Context, params: { townId: string }) { + const parsed = MayorSlingBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + console.log( + `${HANDLER_LOG} handleMayorSling: townId=${params.townId} rigId=${parsed.data.rig_id} title="${parsed.data.title.slice(0, 80)}"` + ); + + const rig = getRigDOStub(c.env, parsed.data.rig_id); + const result = await rig.slingBead({ + title: parsed.data.title, + body: parsed.data.body, + metadata: parsed.data.metadata, + }); + + console.log( + `${HANDLER_LOG} handleMayorSling: completed, result=${JSON.stringify(result).slice(0, 300)}` + ); + + return c.json(resSuccess(result), 201); +} + +/** + * GET /api/mayor/:townId/tools/rigs + * List all rigs in the town. Requires userId from JWT to route to the + * correct GastownUserDO instance. + */ +export async function handleMayorListRigs(c: Context, params: { townId: string }) { + const userId = getUserIdFromJWT(c); + if (!userId) { + return c.json(resError('Missing userId in token'), 401); + } + + console.log(`${HANDLER_LOG} handleMayorListRigs: townId=${params.townId} userId=${userId}`); + + const userDO = getGastownUserStub(c.env, userId); + const rigs = await userDO.listRigs(params.townId); + + return c.json(resSuccess(rigs)); +} + +/** + * GET /api/mayor/:townId/tools/rigs/:rigId/beads + * List beads in a specific rig. Supports status and type filtering. + */ +export async function handleMayorListBeads( + c: Context, + params: { townId: string; rigId: string } +) { + const limitRaw = c.req.query('limit'); + const offsetRaw = c.req.query('offset'); + const limit = limitRaw !== undefined ? NonNegativeInt.safeParse(limitRaw) : undefined; + const offset = offsetRaw !== undefined ? NonNegativeInt.safeParse(offsetRaw) : undefined; + if ((limit && !limit.success) || (offset && !offset.success)) { + return c.json(resError('limit and offset must be non-negative integers'), 400); + } + + const statusRaw = c.req.query('status'); + const typeRaw = c.req.query('type'); + const status = statusRaw !== undefined ? BeadStatus.safeParse(statusRaw) : undefined; + const type = typeRaw !== undefined ? BeadType.safeParse(typeRaw) : undefined; + if ((status && !status.success) || (type && !type.success)) { + return c.json(resError('Invalid status or type filter'), 400); + } + + console.log( + `${HANDLER_LOG} handleMayorListBeads: townId=${params.townId} rigId=${params.rigId} status=${statusRaw ?? 'all'} type=${typeRaw ?? 'all'}` + ); + + const rig = getRigDOStub(c.env, params.rigId); + const beads = await rig.listBeads({ + status: status?.data, + type: type?.data, + assignee_agent_id: c.req.query('assignee_agent_id'), + convoy_id: c.req.query('convoy_id'), + limit: limit?.data, + offset: offset?.data, + }); + + return c.json(resSuccess(beads)); +} + +/** + * GET /api/mayor/:townId/tools/rigs/:rigId/agents + * List agents in a specific rig. + */ +export async function handleMayorListAgents( + c: Context, + params: { townId: string; rigId: string } +) { + console.log( + `${HANDLER_LOG} handleMayorListAgents: townId=${params.townId} rigId=${params.rigId}` + ); + + const rig = getRigDOStub(c.env, params.rigId); + const agents = await rig.listAgents({}); + + return c.json(resSuccess(agents)); +} + +/** + * POST /api/mayor/:townId/tools/mail + * Send mail to an agent in any rig. The mayor can communicate cross-rig. + */ +export async function handleMayorSendMail(c: Context, params: { townId: string }) { + const parsed = MayorMailBody.safeParse(await parseJsonBody(c)); + if (!parsed.success) { + return c.json( + { success: false, error: 'Invalid request body', issues: parsed.error.issues }, + 400 + ); + } + + console.log( + `${HANDLER_LOG} handleMayorSendMail: townId=${params.townId} rigId=${parsed.data.rig_id} to=${parsed.data.to_agent_id} subject="${parsed.data.subject.slice(0, 80)}"` + ); + + const rig = getRigDOStub(c.env, parsed.data.rig_id); + await rig.sendMail({ + from_agent_id: parsed.data.from_agent_id, + to_agent_id: parsed.data.to_agent_id, + subject: parsed.data.subject, + body: parsed.data.body, + }); + + return c.json(resSuccess({ sent: true })); +} diff --git a/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts b/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts new file mode 100644 index 0000000000..5a9b6223c5 --- /dev/null +++ b/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts @@ -0,0 +1,54 @@ +import { createMiddleware } from 'hono/factory'; +import { verifyAgentJWT, type AgentJWTPayload } from '../util/jwt.util'; +import { resError } from '../util/res.util'; +import type { GastownEnv } from '../gastown.worker'; + +/** + * Resolves a secret value from either a `SecretsStoreSecret` (production, has `.get()`) + * or a plain string (test env vars set in wrangler.test.jsonc). + */ +async function resolveSecret(binding: SecretsStoreSecret | string): Promise { + if (typeof binding === 'string') return binding; + return binding.get(); +} + +/** + * Auth middleware for mayor tool routes. Validates a Gastown agent JWT + * and checks that the JWT's `townId` matches the `:townId` route param. + * + * Unlike the rig-scoped `authMiddleware` (which checks `rigId` match), + * this validates `townId` — the mayor operates cross-rig. + * + * Sets `agentJWT` on the Hono context. + */ +export const mayorAuthMiddleware = createMiddleware(async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.toLowerCase().startsWith('bearer ')) { + return c.json(resError('Authentication required'), 401); + } + + const token = authHeader.slice(7).trim(); + if (!token) { + return c.json(resError('Missing token'), 401); + } + + const secret = await resolveSecret(c.env.GASTOWN_JWT_SECRET); + if (!secret) { + console.error('[mayor-auth] GASTOWN_JWT_SECRET not configured'); + return c.json(resError('Internal server error'), 500); + } + + const result = verifyAgentJWT(token, secret); + if (!result.success) { + return c.json(resError(result.error), 401); + } + + // Verify the townId in the JWT matches the route param + const townId = c.req.param('townId'); + if (townId && result.payload.townId !== townId) { + return c.json(resError('Token townId does not match route'), 403); + } + + c.set('agentJWT', result.payload); + return next(); +}); diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts new file mode 100644 index 0000000000..a1cd5b4c58 --- /dev/null +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -0,0 +1,60 @@ +/** + * Build the system prompt for the Mayor agent. + * + * The prompt establishes identity, the mayor's role as town coordinator, + * available tools, the conversational model, delegation instructions, and + * the GUPP principle. + */ +export function buildMayorSystemPrompt(params: { identity: string; townId: string }): string { + return `You are the Mayor of Gastown town "${params.townId}". +Your identity: ${params.identity} + +## Role + +You are a persistent conversational agent that coordinates all work across the rigs (repositories) in your town. Users talk to you in natural language. You respond conversationally and delegate work to polecat agents when needed. + +You are NOT a worker. You do not write code, run tests, or make commits. You are a coordinator: you understand what the user wants, decide which rig and what kind of task it is, and delegate to polecats via gt_sling. + +## Available Tools + +You have these tools for cross-rig coordination: + +- **gt_list_rigs** — List all rigs in your town. Returns rig ID, name, git URL, and default branch. Call this first when you need to know what repositories are available. +- **gt_sling** — Delegate a task to a polecat in a specific rig. Provide the rig_id, a clear title, and a detailed body with requirements. A polecat will be automatically dispatched to work on it. +- **gt_list_beads** — List beads (work items) in a rig. Filter by status or type. Use this to check progress, find open work, or review completed tasks. +- **gt_list_agents** — List agents in a rig. Shows who is working, idle, or stuck. Use this to understand workforce capacity. +- **gt_mail_send** — Send a message to any agent in any rig. Use for coordination, follow-up instructions, or status checks. + +## Conversational Model + +- **Respond directly for questions.** If the user asks a question you can answer from context, respond conversationally. Don't delegate questions. +- **Delegate via gt_sling for work.** When the user describes work to be done (bugs to fix, features to add, refactoring, etc.), delegate it by calling gt_sling with the appropriate rig. +- **Non-blocking delegation.** After slinging work, respond immediately to the user. Do NOT wait for the polecat to finish. Say something like "I've assigned [agent name] to work on that in [rig name]" and move on. The user can check progress later. +- **Multi-task naturally.** If the user describes multiple tasks, sling them individually to separate polecats. +- **Discover rigs first.** If you don't know which rig to use, call gt_list_rigs before slinging. + +## GUPP Principle + +The Gas Town Universal Propulsion Principle: if there is work to be done, do it immediately. When the user asks for something, act on it right away. Don't ask for confirmation unless the request is genuinely ambiguous. Prefer action over clarification. + +## Writing Good Sling Titles and Bodies + +When calling gt_sling, write clear, actionable descriptions: + +- **Title**: A concise imperative sentence describing what needs to happen. Good: "Fix login redirect loop on /dashboard". Bad: "Login issue". +- **Body**: Include all context the polecat needs to do the work independently: + - What is the current behavior? + - What is the expected behavior? + - Where in the codebase is the relevant code? (if known) + - What are the acceptance criteria? + - Any constraints or approaches to prefer/avoid? + +The polecat works autonomously — it cannot ask you questions mid-task. Front-load all necessary context in the body. + +## Important + +- You maintain context across messages. This is a continuous conversation. +- Never fabricate rig IDs or agent IDs. Always use gt_list_rigs to discover real IDs. +- If no rigs exist, tell the user they need to create one first. +- If a task spans multiple rigs, create separate slings for each rig.`; +} From d2b6273d36dc1ae3cca4870f52ee1f5d880129bd Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 18 Feb 2026 22:28:36 -0600 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?rig=20ownership=20validation,=20dev-mode=20userId=20fallback,?= =?UTF-8?q?=20unused=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add verifyRigBelongsToTown() to all handlers that accept a rig_id, preventing cross-town rig access - Add resolveUserId() with query param fallback for dev mode (where mayorAuthMiddleware is skipped and agentJWT is unset) - Remove unused AgentJWTPayload import from mayor-auth.middleware.ts --- .../src/handlers/mayor-tools.handler.ts | 58 ++++++++++++++++--- .../src/middleware/mayor-auth.middleware.ts | 2 +- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts index 64af96b3e8..8b745185ba 100644 --- a/cloudflare-gastown/src/handlers/mayor-tools.handler.ts +++ b/cloudflare-gastown/src/handlers/mayor-tools.handler.ts @@ -31,12 +31,34 @@ const NonNegativeInt = z.coerce.number().int().nonnegative(); // ── Helpers ────────────────────────────────────────────────────────────── /** - * Resolve the userId for the town by reading the JWT payload. - * The mayor's JWT contains the userId that owns the town. + * Resolve the userId for the mayor's town. + * + * In production the JWT is always present (set by mayorAuthMiddleware). + * In development the middleware is skipped, so we fall back to a + * `userId` query parameter to keep the routes testable. */ -function getUserIdFromJWT(c: Context): string | null { +function resolveUserId(c: Context): string | null { const jwt = c.get('agentJWT'); - return jwt?.userId ?? null; + if (jwt?.userId) return jwt.userId; + // Dev-mode fallback: accept userId as a query param + return c.req.query('userId') ?? null; +} + +/** + * Verify that `rigId` belongs to `townId` by checking the user's rig + * registry. Returns the rig record on success, or null if the rig + * doesn't belong to this town (or doesn't exist). + */ +async function verifyRigBelongsToTown( + c: Context, + townId: string, + rigId: string +): Promise { + const userId = resolveUserId(c); + if (!userId) return false; + const userDO = getGastownUserStub(c.env, userId); + const rig = await userDO.getRigAsync(rigId); + return rig !== null && rig.town_id === townId; } // ── Handlers ───────────────────────────────────────────────────────────── @@ -55,6 +77,11 @@ export async function handleMayorSling(c: Context, params: { townId: ); } + const rigOwned = await verifyRigBelongsToTown(c, params.townId, parsed.data.rig_id); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + console.log( `${HANDLER_LOG} handleMayorSling: townId=${params.townId} rigId=${parsed.data.rig_id} title="${parsed.data.title.slice(0, 80)}"` ); @@ -75,13 +102,13 @@ export async function handleMayorSling(c: Context, params: { townId: /** * GET /api/mayor/:townId/tools/rigs - * List all rigs in the town. Requires userId from JWT to route to the - * correct GastownUserDO instance. + * List all rigs in the town. Requires userId to route to the correct + * GastownUserDO instance (from JWT in prod, query param in dev). */ export async function handleMayorListRigs(c: Context, params: { townId: string }) { - const userId = getUserIdFromJWT(c); + const userId = resolveUserId(c); if (!userId) { - return c.json(resError('Missing userId in token'), 401); + return c.json(resError('Missing userId in token (or userId query param in dev mode)'), 401); } console.log(`${HANDLER_LOG} handleMayorListRigs: townId=${params.townId} userId=${userId}`); @@ -100,6 +127,11 @@ export async function handleMayorListBeads( c: Context, params: { townId: string; rigId: string } ) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + const limitRaw = c.req.query('limit'); const offsetRaw = c.req.query('offset'); const limit = limitRaw !== undefined ? NonNegativeInt.safeParse(limitRaw) : undefined; @@ -141,6 +173,11 @@ export async function handleMayorListAgents( c: Context, params: { townId: string; rigId: string } ) { + const rigOwned = await verifyRigBelongsToTown(c, params.townId, params.rigId); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + console.log( `${HANDLER_LOG} handleMayorListAgents: townId=${params.townId} rigId=${params.rigId}` ); @@ -164,6 +201,11 @@ export async function handleMayorSendMail(c: Context, params: { town ); } + const rigOwned = await verifyRigBelongsToTown(c, params.townId, parsed.data.rig_id); + if (!rigOwned) { + return c.json(resError('Rig not found in this town'), 403); + } + console.log( `${HANDLER_LOG} handleMayorSendMail: townId=${params.townId} rigId=${parsed.data.rig_id} to=${parsed.data.to_agent_id} subject="${parsed.data.subject.slice(0, 80)}"` ); diff --git a/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts b/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts index 5a9b6223c5..16e2020cc4 100644 --- a/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/mayor-auth.middleware.ts @@ -1,5 +1,5 @@ import { createMiddleware } from 'hono/factory'; -import { verifyAgentJWT, type AgentJWTPayload } from '../util/jwt.util'; +import { verifyAgentJWT } from '../util/jwt.util'; import { resError } from '../util/res.util'; import type { GastownEnv } from '../gastown.worker';