diff --git a/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts b/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts new file mode 100644 index 00000000..0fa3a8c1 --- /dev/null +++ b/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts @@ -0,0 +1,77 @@ +/** + * Regression guard for the task #1 fix: pilot-agent hosts never run the + * first-run setup wizard, so the wizard's `auth_jwt_secret` generation + * (routes/auth.ts) never fires. Without that secret, the agent's loopback + * auth helper (pilot/agent.ts::getLoopbackAuthHeader) returns null and the + * local Sencho rejects every forwarded request with 401. + * + * `bootstrap/startup.ts` now generates the secret on pilot-mode boot when + * missing, and re-uses the persisted value on subsequent boots. This test + * exercises both branches by importing the underlying logic directly rather + * than starting a full server (the server would also try to bind a port and + * spawn pilot infrastructure). + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let ensurePilotJwtSecret: typeof import('../bootstrap/startup').ensurePilotJwtSecret; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ ensurePilotJwtSecret } = await import('../bootstrap/startup')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + delete process.env.SENCHO_MODE; +}); + +afterEach(() => { + delete process.env.SENCHO_MODE; +}); + +describe('pilot-agent bootstrap auth_jwt_secret', () => { + it('generates a secret on first pilot-mode boot when missing', () => { + const db = DatabaseService.getInstance(); + // Wipe any existing secret to simulate a fresh pilot host. + db.updateGlobalSetting('auth_jwt_secret', ''); + expect(db.getGlobalSettings().auth_jwt_secret).toBe(''); + + process.env.SENCHO_MODE = 'pilot'; + const generated = ensurePilotJwtSecret(); + expect(generated).toBe(true); + + const after = db.getGlobalSettings().auth_jwt_secret; + expect(after).toBeTruthy(); + expect(after.length).toBe(128); // 64 random bytes hex-encoded + }); + + it('does not regenerate on subsequent boots with a persisted secret', () => { + const db = DatabaseService.getInstance(); + const seeded = 'a'.repeat(128); + db.updateGlobalSetting('auth_jwt_secret', seeded); + + process.env.SENCHO_MODE = 'pilot'; + const generated = ensurePilotJwtSecret(); + expect(generated).toBe(false); + expect(db.getGlobalSettings().auth_jwt_secret).toBe(seeded); + }); + + it('does nothing in non-pilot mode (even if secret is missing)', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('auth_jwt_secret', ''); + + // Default: SENCHO_MODE unset — primary mode. + const generated = ensurePilotJwtSecret(); + expect(generated).toBe(false); + // Still empty: primary mode goes through the setup wizard, not this + // pilot-only auto-init. + expect(db.getGlobalSettings().auth_jwt_secret).toBe(''); + }); +}); diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index bab6cca5..3152162b 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -1,6 +1,8 @@ import type { Server } from 'http'; +import crypto from 'crypto'; import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; +import { DatabaseService } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import SelfUpdateService from '../services/SelfUpdateService'; import { MonitorService } from '../services/MonitorService'; @@ -16,6 +18,29 @@ import { BlueprintReconciler } from '../services/BlueprintReconciler'; import { sweepStaleTempDirs as sweepStaleGitTempDirs } from '../services/GitSourceService'; import { PORT } from '../helpers/constants'; +/** + * Pilot-agent hosts never run the first-run setup wizard, so the wizard + * path that normally generates `auth_jwt_secret` (routes/auth.ts) never + * fires. Without that secret, the agent-side loopback auth helper + * (`pilot/agent.ts::getLoopbackAuthHeader`) cannot mint the + * `pilot_tunnel`-scoped JWT it injects on every forwarded HTTP/WS request, + * and the local Sencho's `authMiddleware` rejects every proxied call with + * 401 "Authentication required". Generate the secret here on first boot in + * pilot mode; subsequent boots reuse the persisted value. No-op outside + * pilot mode (the wizard owns the lifecycle there). + * + * Returns true when a fresh secret was written, false otherwise. + */ +export function ensurePilotJwtSecret(): boolean { + if (process.env.SENCHO_MODE !== 'pilot') return false; + const dbSvc = DatabaseService.getInstance(); + if (dbSvc.getGlobalSettings().auth_jwt_secret) return false; + const generated = crypto.randomBytes(64).toString('hex'); + dbSvc.updateGlobalSetting('auth_jwt_secret', generated); + console.log('[Startup] pilot-agent: generated local auth_jwt_secret'); + return true; +} + /** * Run the startup sequence: stack-directory migration, service initialization, * background watchdogs, then bind the HTTP server. The caller passes the @@ -32,6 +57,8 @@ export async function startServer(server: Server): Promise { console.error('Migration failed:', error); } + ensurePilotJwtSecret(); + // Initialize the license service before any tier-gated code can run. LicenseService.getInstance().initialize();