diff --git a/services/platform-service/src/modules/fleet/enrollment.test.ts b/services/platform-service/src/modules/fleet/enrollment.test.ts index 141cb6fb..0d13a7aa 100644 --- a/services/platform-service/src/modules/fleet/enrollment.test.ts +++ b/services/platform-service/src/modules/fleet/enrollment.test.ts @@ -267,6 +267,40 @@ describe('fleet enrollment + scoped tokens', () => { expect(JSON.parse(ok.body).claimed).toBe(true); }); + it('enforcement ON: heartbeat advertises only enrolled caps (out-of-scope cap dropped)', async () => { + const app = await buildApp(); + const enrolled = await enrollment.enrollFactory({ + productId: PID, + factoryId: 'fac_1', + capabilities: ['os:mac', 'engine:devin'], + }); + process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1'; + + const hb = await app.inject({ + method: 'POST', + url: '/api/fleet/factories/heartbeat', + headers: { 'x-factory-token': enrolled.token }, + // claims engine:codex it was NOT enrolled for, and omits a granted cap + payload: { factoryId: 'fac_1', capabilities: ['engine:devin', 'engine:codex'] }, + }); + expect(hb.statusCode).toBe(200); + const caps = JSON.parse(hb.body).factory.capabilities as string[]; + expect(caps).toContain('engine:devin'); // enrolled + reported → kept + expect(caps).not.toContain('engine:codex'); // not enrolled → dropped + expect(caps).not.toContain('os:mac'); // enrolled but not reported → not invented + }); + + it('enforcement OFF: heartbeat advertises self-reported caps verbatim', async () => { + const app = await buildApp(); + const hb = await app.inject({ + method: 'POST', + url: '/api/fleet/factories/heartbeat', + payload: { factoryId: 'fac_1', capabilities: ['engine:codex'] }, + }); + expect(hb.statusCode).toBe(200); + expect(JSON.parse(hb.body).factory.capabilities).toEqual(['engine:codex']); + }); + it('enforcement ON: revoked token → 401 on heartbeat', async () => { const app = await buildApp(); const enrolled = await enrollment.enrollFactory({ diff --git a/services/platform-service/src/modules/fleet/routes.ts b/services/platform-service/src/modules/fleet/routes.ts index 16778c0d..527af349 100644 --- a/services/platform-service/src/modules/fleet/routes.ts +++ b/services/platform-service/src/modules/fleet/routes.ts @@ -319,11 +319,19 @@ export async function fleetRoutes(app: FastifyInstance) { factoryId: parsed.data.factoryId, }); const pid = scope?.productId ?? pidCandidate; + // §12 trust boundary: when enforcement is ON, a factory may advertise only the + // capabilities it was ENROLLED with — intersect its self-reported caps with the + // token scope (it can report fewer, e.g. an engine temporarily unavailable, but + // never MORE). This keeps a factory from advertising e.g. engine:codex it wasn't + // granted, which would otherwise pollute the engine picker and route decisions. + const advertisedCaps = scope + ? (parsed.data.capabilities ?? []).filter(c => scope.capabilities.includes(c)) + : parsed.data.capabilities; await coordinator.heartbeat({ productId: pid, factoryId: parsed.data.factoryId, descriptor: parsed.data.descriptor, - capabilities: parsed.data.capabilities, + capabilities: advertisedCaps, health: parsed.data.health, load: parsed.data.load, seatLimit: parsed.data.seatLimit,