diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 913ed4d23a..521f8ac0c7 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { z } from 'zod'; import { runAgent } from './agent-runner'; import { stopAgent, @@ -24,29 +25,45 @@ import type { const MAX_TICKETS = 1000; const streamTickets = new Map(); -export const app = new Hono(); +// Minimal Zod schema for the town config delivered via X-Town-Config header. +// Uses z.record() so any string-keyed object is accepted and future keys are preserved. +const TownConfigHeader = z.record(z.string(), z.unknown()); -// Apply town config from X-Town-Config header (sent by TownDO on every request) -let currentTownConfig: Record | null = null; +// Last-known-good town config. Updated on every request that carries the header. +// Used as a fallback by code that runs outside a request context (e.g. background tasks). +let lastKnownTownConfig: Record | null = null; /** Get the latest town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { - return currentTownConfig; + return lastKnownTownConfig; } +export const app = new Hono(); + +// Parse and validate town config from X-Town-Config header (sent by TownDO on +// every request). The validated config is stored in a module-level cache +// accessible via getCurrentTownConfig(). app.use('*', async (c, next) => { const configHeader = c.req.header('X-Town-Config'); if (configHeader) { try { - const parsed = JSON.parse(configHeader); - currentTownConfig = parsed; - const hasToken = - typeof parsed.kilocode_token === 'string' && parsed.kilocode_token.length > 0; - console.log( - `[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(parsed).join(',')}` - ); + const raw: unknown = JSON.parse(configHeader); + const result = TownConfigHeader.safeParse(raw); + if (result.success) { + lastKnownTownConfig = result.data; + const hasToken = + typeof result.data.kilocode_token === 'string' && result.data.kilocode_token.length > 0; + console.log( + `[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(result.data).join(',')}` + ); + } else { + console.warn( + '[control-server] X-Town-Config header failed validation:', + result.error.issues + ); + } } catch { - console.warn('[control-server] X-Town-Config header malformed'); + console.warn('[control-server] X-Town-Config header malformed (invalid JSON)'); } } await next(); diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index 86fb02aae4..db6af27be8 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -7,11 +7,16 @@ */ import { createOpencode, type OpencodeClient } from '@kilocode/sdk'; +import { z } from 'zod'; import type { ManagedAgent, StartAgentRequest, KiloSSEEvent, KiloSSEEventData } from './types'; import { reportAgentCompleted } from './completion-reporter'; const MANAGER_LOG = '[process-manager]'; +// Validates the shape returned by client.session.create() so we fail fast +// if the SDK changes its return type. +const SessionResponse = z.object({ id: z.string().min(1) }).passthrough(); + type SDKInstance = { client: OpencodeClient; server: { url: string; close(): void }; @@ -298,9 +303,17 @@ export async function startAgent( // 2. Create a session const sessionResult = await client.session.create({ body: {} }); - const session = sessionResult.data ?? sessionResult; - const sessionId = - typeof session === 'object' && session && 'id' in session ? String(session.id) : ''; + const rawSession: unknown = sessionResult.data ?? sessionResult; + const parsed = SessionResponse.safeParse(rawSession); + if (!parsed.success) { + console.error( + `${MANAGER_LOG} SDK session.create returned unexpected shape:`, + JSON.stringify(rawSession).slice(0, 200), + parsed.error.issues + ); + throw new Error('SDK session.create response missing required "id" field'); + } + const sessionId = parsed.data.id; agent.sessionId = sessionId; // 3. Subscribe to events (async, runs in background) diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts index 0612dd499b..bf2aaa4ada 100644 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/bead-events.table.ts @@ -9,6 +9,7 @@ export const BeadEventType = z.enum([ 'status_changed', 'closed', 'escalated', + 'notification_failed', 'mail_sent', 'review_submitted', 'review_completed', diff --git a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts b/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts index 09c131646f..fe30d2aae7 100644 --- a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts +++ b/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts @@ -9,6 +9,7 @@ export const BeadEventType = z.enum([ 'status_changed', 'closed', 'escalated', + 'notification_failed', 'mail_sent', 'review_submitted', 'review_completed', diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 5bf2a8ccb5..c717ce364e 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -597,7 +597,9 @@ export class TownDO extends DurableObject { // Fire-and-forget dispatch so the sling call returns immediately. // The alarm loop retries if this fails. - void this.dispatchAgent(hookedAgent, bead); + this.dispatchAgent(hookedAgent, bead).catch(err => + console.error(`${TOWN_LOG} slingBead: fire-and-forget dispatchAgent failed:`, err) + ); await this.armAlarmIfNeeded(); return { bead, agent: hookedAgent }; } @@ -1072,7 +1074,26 @@ export class TownDO extends DurableObject { if (input.severity !== 'low') { this.sendMayorMessage( `[Escalation:${input.severity}] rig=${input.source_rig_id} ${input.message}` - ).catch(err => console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err)); + ).catch(err => { + console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); + try { + beadOps.logBeadEvent(this.sql, { + beadId, + agentId: input.source_agent_id ?? null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: input.severity, + }, + }); + } catch (logErr) { + console.error( + `${TOWN_LOG} routeEscalation: failed to log notification_failed event:`, + logErr + ); + } + }); } return escalation; @@ -1539,7 +1560,27 @@ export class TownDO extends DurableObject { if (newSeverity !== 'low') { this.sendMayorMessage( `[Re-Escalation:${newSeverity}] rig=${esc.source_rig_id} ${esc.message}` - ).catch(() => {}); + ).catch(err => { + console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); + try { + beadOps.logBeadEvent(this.sql, { + beadId: esc.id, + agentId: null, + eventType: 'notification_failed', + metadata: { + target: 'mayor', + reason: err instanceof Error ? err.message : String(err), + severity: newSeverity, + re_escalation: true, + }, + }); + } catch (logErr) { + console.error( + `${TOWN_LOG} re-escalation: failed to log notification_failed event:`, + logErr + ); + } + }); } } } diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 6246f749fb..a446539892 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -6,6 +6,7 @@ import { withCloudflareAccess, validateCfAccessRequest } from './middleware/cf-a import { authMiddleware, agentOnlyMiddleware, + townIdMiddleware, type AuthVariables, } from './middleware/auth.middleware'; import { @@ -134,10 +135,13 @@ app.get('/', c => c.html(dashboardHtml())); app.get('/health', c => c.json({ status: 'ok' })); -// ── Auth ──────────────────────────────────────────────────────────────── +// ── Town ID + Auth ────────────────────────────────────────────────────── // All rig routes live under /api/towns/:townId/rigs/:rigId so the townId -// is always available from the URL path. Auth middleware skipped in dev. +// is always available from the URL path. +// townIdMiddleware always runs (even in dev) so c.get('townId') is +// guaranteed for handlers. Auth middleware is skipped in dev. +app.use('/api/towns/:townId/rigs/:rigId/*', townIdMiddleware); app.use('/api/towns/:townId/rigs/:rigId/*', async (c, next) => c.env.ENVIRONMENT === 'development' ? next() : authMiddleware(c, next) ); diff --git a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts index fc69913b37..36af7e5187 100644 --- a/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agent-events.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const AppendEventBody = z.object({ @@ -34,8 +34,7 @@ export async function handleAppendAgentEvent(c: Context, params: { r return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.appendAgentEvent(parsed.data.agent_id, parsed.data.event_type, parsed.data.data); return c.json(resSuccess({ appended: true }), 201); @@ -58,8 +57,7 @@ export async function handleGetAgentEvents( return c.json(resError('Invalid query parameters'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const events = await town.getAgentEvents( params.agentId, diff --git a/cloudflare-gastown/src/handlers/rig-agents.handler.ts b/cloudflare-gastown/src/handlers/rig-agents.handler.ts index d86c09e16f..d7e77de8cf 100644 --- a/cloudflare-gastown/src/handlers/rig-agents.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-agents.handler.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; import { AgentRole, AgentStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -42,8 +41,7 @@ export async function handleRegisterAgent(c: Context, params: { rigI 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.registerAgent({ ...parsed.data, rig_id: params.rigId }); return c.json(resSuccess(agent), 201); @@ -58,8 +56,7 @@ export async function handleListAgents(c: Context, params: { rigId: return c.json(resError('Invalid role or status filter'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agents = await town.listAgents({ role: role?.data, @@ -73,8 +70,7 @@ export async function handleGetAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); @@ -96,8 +92,7 @@ export async function handleHookBead( console.log( `${AGENT_LOG} handleHookBead: rigId=${params.rigId} agentId=${params.agentId} beadId=${parsed.data.bead_id}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.hookBead(params.agentId, parsed.data.bead_id); console.log(`${AGENT_LOG} handleHookBead: hooked successfully`); @@ -108,8 +103,7 @@ export async function handleUnhookBead( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.unhookBead(params.agentId); return c.json(resSuccess({ unhooked: true })); @@ -119,8 +113,7 @@ export async function handlePrime( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const context = await town.prime(params.agentId); return c.json(resSuccess(context)); @@ -137,8 +130,7 @@ export async function handleAgentDone( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.agentDone(params.agentId, parsed.data); return c.json(resSuccess({ done: true })); @@ -159,8 +151,7 @@ export async function handleAgentCompleted( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.agentCompleted(params.agentId, parsed.data); return c.json(resSuccess({ completed: true })); @@ -177,8 +168,7 @@ export async function handleWriteCheckpoint( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.writeCheckpoint(params.agentId, parsed.data.data); return c.json(resSuccess({ written: true })); @@ -188,8 +178,7 @@ export async function handleCheckMail( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const messages = await town.checkMail(params.agentId); return c.json(resSuccess(messages)); @@ -203,8 +192,7 @@ export async function handleHeartbeat( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.touchAgentHeartbeat(params.agentId); return c.json(resSuccess({ heartbeat: true })); @@ -230,8 +218,7 @@ export async function handleGetOrCreateAgent(c: Context, params: { r console.log( `${AGENT_LOG} handleGetOrCreateAgent: rigId=${params.rigId} role=${parsed.data.role}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getOrCreateAgent(parsed.data.role, params.rigId); console.log(`${AGENT_LOG} handleGetOrCreateAgent: result=${JSON.stringify(agent).slice(0, 200)}`); @@ -242,8 +229,7 @@ export async function handleDeleteAgent( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const agent = await town.getAgentAsync(params.agentId); if (!agent || agent.rig_id !== params.rigId) return c.json(resError('Agent not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts index c685273993..5924a5c5b1 100644 --- a/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-bead-events.handler.ts @@ -1,7 +1,6 @@ import type { Context } from 'hono'; import { getTownDOStub } from '../dos/Town.do'; -import { resSuccess, resError } from '../util/res.util'; -import { getTownId } from '../middleware/auth.middleware'; +import { resSuccess } from '../util/res.util'; import type { GastownEnv } from '../gastown.worker'; export async function handleListBeadEvents(c: Context, params: { rigId: string }) { @@ -14,8 +13,7 @@ export async function handleListBeadEvents(c: Context, params: { rig ? parsedLimit : undefined; - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const events = await town.listBeadEvents({ beadId, since, limit }); return c.json(resSuccess(events)); diff --git a/cloudflare-gastown/src/handlers/rig-beads.handler.ts b/cloudflare-gastown/src/handlers/rig-beads.handler.ts index 3a078d1b54..615fe24786 100644 --- a/cloudflare-gastown/src/handlers/rig-beads.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-beads.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import { BeadType, BeadPriority, BeadStatus } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -43,8 +43,7 @@ export async function handleCreateBead(c: Context, params: { rigId: console.log( `${HANDLER_LOG} handleCreateBead: rigId=${params.rigId} type=${parsed.data.type} title="${parsed.data.title?.slice(0, 80)}" assignee=${parsed.data.assignee_agent_id ?? 'none'}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.createBead({ ...parsed.data, rig_id: params.rigId }); console.log( @@ -70,8 +69,7 @@ export async function handleListBeads(c: Context, params: { rigId: s return c.json(resError('Invalid status or type filter'), 400); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const beads = await town.listBeads({ status: status?.data, @@ -89,8 +87,7 @@ export async function handleGetBead( c: Context, params: { rigId: string; beadId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); @@ -112,8 +109,7 @@ export async function handleUpdateBeadStatus( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.updateBeadStatus(params.beadId, parsed.data.status, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -134,8 +130,7 @@ export async function handleCloseBead( if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.closeBead(params.beadId, parsed.data.agent_id); return c.json(resSuccess(bead)); @@ -159,8 +154,7 @@ export async function handleSlingBead(c: Context, params: { rigId: s console.log( `${HANDLER_LOG} handleSlingBead: rigId=${params.rigId} title="${parsed.data.title?.slice(0, 80)}" metadata=${JSON.stringify(parsed.data.metadata)}` ); - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const result = await town.slingBead({ ...parsed.data, rigId: params.rigId }); console.log( @@ -173,8 +167,7 @@ export async function handleDeleteBead( c: Context, params: { rigId: string; beadId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const bead = await town.getBeadAsync(params.beadId); if (!bead || bead.rig_id !== params.rigId) return c.json(resError('Bead not found'), 404); diff --git a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts index 67b1b1eddb..219b8a1a7a 100644 --- a/cloudflare-gastown/src/handlers/rig-escalations.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-escalations.handler.ts @@ -1,9 +1,8 @@ import type { Context } from 'hono'; import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; -import { resSuccess, resError } from '../util/res.util'; +import { resSuccess } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; import { BeadPriority } from '../types'; import type { GastownEnv } from '../gastown.worker'; @@ -22,8 +21,7 @@ export async function handleCreateEscalation(c: Context, params: { r 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const escalation = await town.routeEscalation({ townId, diff --git a/cloudflare-gastown/src/handlers/rig-mail.handler.ts b/cloudflare-gastown/src/handlers/rig-mail.handler.ts index b77862da53..2c77d282af 100644 --- a/cloudflare-gastown/src/handlers/rig-mail.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-mail.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SendMailBody = z.object({ @@ -25,8 +25,7 @@ export async function handleSendMail(c: Context, params: { rigId: st if (enforced && enforced !== parsed.data.from_agent_id) { return c.json(resError('from_agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.sendMail(parsed.data); return c.json(resSuccess({ sent: true }), 201); diff --git a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts index 2216b6bf59..720e69b4a9 100644 --- a/cloudflare-gastown/src/handlers/rig-molecules.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-molecules.handler.ts @@ -3,15 +3,13 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getTownId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; export async function handleGetMoleculeCurrentStep( c: Context, params: { rigId: string; agentId: string } ) { - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const step = await town.getMoleculeCurrentStep(params.agentId); if (!step) return c.json(resError('No active molecule for this agent'), 404); @@ -35,8 +33,7 @@ export async function handleAdvanceMoleculeStep( ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const result = await town.advanceMoleculeStep(params.agentId, parsed.data.summary); return c.json(resSuccess(result)); @@ -66,8 +63,7 @@ export async function handleCreateMolecule(c: Context, params: { rig ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); const mol = await town.createMolecule(parsed.data.bead_id, parsed.data.formula); return c.json(resSuccess(mol), 201); diff --git a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts index 81fff9e95e..0998a6e2fd 100644 --- a/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts +++ b/cloudflare-gastown/src/handlers/rig-review-queue.handler.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getTownDOStub } from '../dos/Town.do'; import { resSuccess, resError } from '../util/res.util'; import { parseJsonBody } from '../util/parse-json-body.util'; -import { getEnforcedAgentId, getTownId } from '../middleware/auth.middleware'; +import { getEnforcedAgentId } from '../middleware/auth.middleware'; import type { GastownEnv } from '../gastown.worker'; const SubmitToReviewQueueBody = z.object({ @@ -26,8 +26,7 @@ export async function handleSubmitToReviewQueue(c: Context, params: if (enforced && enforced !== parsed.data.agent_id) { return c.json(resError('agent_id does not match authenticated agent'), 403); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.submitToReviewQueue(parsed.data); return c.json(resSuccess({ submitted: true }), 201); @@ -50,8 +49,7 @@ export async function handleCompleteReview( 400 ); } - const townId = getTownId(c); - if (!townId) return c.json(resError('Missing townId'), 400); + const townId = c.get('townId'); const town = getTownDOStub(c.env, townId); await town.completeReviewWithResult({ entry_id: params.entryId, diff --git a/cloudflare-gastown/src/middleware/auth.middleware.ts b/cloudflare-gastown/src/middleware/auth.middleware.ts index d33a6563e5..fcca98f6e7 100644 --- a/cloudflare-gastown/src/middleware/auth.middleware.ts +++ b/cloudflare-gastown/src/middleware/auth.middleware.ts @@ -6,15 +6,34 @@ import type { GastownEnv } from '../gastown.worker'; export type AuthVariables = { agentJWT: AgentJWTPayload; + townId: string; }; import { resolveSecret } from '../util/secret.util'; +/** + * Extracts `townId` from the route param `:townId` and sets it on the Hono + * context. Returns 400 if the param is missing. + * + * Must run unconditionally (even in dev) so handlers can always call + * `c.get('townId')`. Does NOT check JWT — cross-town validation is handled + * by `authMiddleware` which runs after this in production. + */ +export const townIdMiddleware = createMiddleware(async (c, next) => { + const townId = c.req.param('townId'); + if (!townId) { + return c.json(resError('Missing townId'), 400); + } + c.set('townId', townId); + return next(); +}); + /** * Auth middleware that requires a valid Gastown agent JWT via * `Authorization: Bearer `. * - * Sets `agentJWT` on the Hono context. + * Sets `agentJWT` on the Hono context. Also validates the JWT's townId + * and rigId match the route params to prevent cross-town/cross-rig access. */ export const authMiddleware = createMiddleware(async (c, next) => { const authHeader = c.req.header('Authorization'); @@ -44,6 +63,12 @@ export const authMiddleware = createMiddleware(async (c, next) => { return c.json(resError('Token rigId does not match route'), 403); } + // Verify the townId in the JWT matches the route param (cross-town guard) + const townId = c.req.param('townId'); + if (townId && townId !== result.payload.townId) { + return c.json(resError('Cross-town access denied'), 403); + } + c.set('agentJWT', result.payload); return next(); }); @@ -75,21 +100,3 @@ export function getEnforcedAgentId(c: Context): string | null { if (!jwt) return null; return jwt.agentId; } - -/** - * Resolve townId from the route param `:townId`, falling back to the JWT's - * `townId`. When both are present, verifies they match to prevent an agent - * authenticated for town A from accessing town B's data via URL manipulation. - * - * Returns null if no townId is available. - */ -export function getTownId(c: Context): string | null { - const fromParam = c.req.param('townId'); - const jwt = c.get('agentJWT'); - - if (fromParam && jwt?.townId && fromParam !== jwt.townId) { - return null; - } - - return fromParam ?? jwt?.townId ?? null; -} diff --git a/cloudflare-gastown/test/integration/rig-do.test.ts b/cloudflare-gastown/test/integration/rig-do.test.ts index 6df99e6ce3..eed7a872be 100644 --- a/cloudflare-gastown/test/integration/rig-do.test.ts +++ b/cloudflare-gastown/test/integration/rig-do.test.ts @@ -106,6 +106,31 @@ describe('TownDO', () => { expect(retrieved).toMatchObject({ id: agent.id, name: 'Polecat-1' }); }); + it('should store rig_id when provided', async () => { + const rigId = `rig-${crypto.randomUUID()}`; + const agent = await town.registerAgent({ + role: 'polecat', + name: 'Polecat-RigTest', + identity: `polecat-rig-${townName}`, + rig_id: rigId, + }); + + expect(agent.rig_id).toBe(rigId); + + const retrieved = await town.getAgentAsync(agent.id); + expect(retrieved?.rig_id).toBe(rigId); + }); + + it('should store null rig_id when not provided', async () => { + const agent = await town.registerAgent({ + role: 'polecat', + name: 'Polecat-NoRig', + identity: `polecat-norig-${townName}`, + }); + + expect(agent.rig_id).toBeNull(); + }); + it('should return null for non-existent agent', async () => { const result = await town.getAgentAsync('non-existent'); expect(result).toBeNull(); diff --git a/src/routers/gastown-router.ts b/src/routers/gastown-router.ts index 09d033cac2..dd878a8c83 100644 --- a/src/routers/gastown-router.ts +++ b/src/routers/gastown-router.ts @@ -195,24 +195,38 @@ export const gastownRouter = createTRPCRouter({ // Resolve git credentials from the platform integration and store // them in the town config so agents can clone and push. + // This is a best-effort write: if it fails, the rig exists without + // git credentials. The container-side resolveGitCredentialsIfMissing + // and the refreshTownGitCredentials helper serve as recovery mechanisms. if (platformIntegrationId) { - const gitCredentials = await resolveGitCredentialsFromIntegration(platformIntegrationId); - if (gitCredentials) { - console.log( - `${LOG_PREFIX} createRig: resolved git credentials for integration=${platformIntegrationId} hasGithub=${!!gitCredentials.github_token} hasGitlab=${!!gitCredentials.gitlab_token}` - ); - await withGastownError(() => - gastown.updateTownConfig(input.townId, { - git_auth: { - ...gitCredentials, - // Store the integration ID so we can refresh tokens later - platform_integration_id: platformIntegrationId, - }, - }) - ); - } else { - console.warn( - `${LOG_PREFIX} createRig: could not resolve git credentials for integration=${platformIntegrationId}` + try { + const gitCredentials = await resolveGitCredentialsFromIntegration(platformIntegrationId); + if (gitCredentials) { + console.log( + `${LOG_PREFIX} createRig: resolved git credentials for integration=${platformIntegrationId} hasGithub=${!!gitCredentials.github_token} hasGitlab=${!!gitCredentials.gitlab_token}` + ); + await withGastownError(() => + gastown.updateTownConfig(input.townId, { + git_auth: { + ...gitCredentials, + // Store the integration ID so we can refresh tokens later + platform_integration_id: platformIntegrationId, + }, + }) + ); + } else { + console.warn( + `${LOG_PREFIX} createRig: could not resolve git credentials for integration=${platformIntegrationId}` + ); + } + } catch (credErr) { + // Rig was created successfully but git credentials could not be + // written. Log the error clearly — agents can still resolve + // credentials on-demand via the container's credential API, and + // refreshTownGitCredentials can retry later. + console.error( + `${LOG_PREFIX} createRig: rig=${rig.id} created but git credential write FAILED for integration=${platformIntegrationId}:`, + credErr ); } }