From 3d1cf0f8f15f6d331f55bad88264ac5ab952f83c Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 8 May 2026 11:59:02 -0400 Subject: [PATCH] fix(pilot): generate auth_jwt_secret on pilot-agent boot (#997) 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 added in PR #990 (pilot/agent.ts::getLoopbackAuthHeader) returned null at mint time, no Authorization header was attached on the forwarded loopback request, and the local Sencho's authMiddleware rejected every proxied call with 401 "Authentication required". This was reproducible end-to-end on v0.74.1, .2, and .3. Add ensurePilotJwtSecret() to bootstrap/startup, called early in startServer (before LicenseService.initialize, which only reads system_state and does not touch global_settings, so the order is safe). When SENCHO_MODE=pilot and global_settings.auth_jwt_secret is empty, generate 64 random bytes (hex-encoded, matching the wizard's pattern in routes/auth.ts) and persist. Subsequent boots see the persisted value and no-op. Outside pilot mode the function is a no-op since the wizard owns the lifecycle. The helper is exported so the regression test can exercise the real function rather than maintaining a hand-mirrored copy. Three vitest cases cover: first-boot generation, no-op on persisted secret, no-op in non-pilot mode. Together with PR #989 (proxy-side bridge dispatch), PR #990 (agent-side loopback auth injection), PR #992 (cross-node control plane via HTTP proxy), and PR #994 (local-node mesh-reachable diagnostic), this completes the central, bridge, agent, local-Sencho HTTP path for pilot-agent-mode nodes. --- .../pilot-agent-bootstrap-secret.test.ts | 77 +++++++++++++++++++ backend/src/bootstrap/startup.ts | 27 +++++++ 2 files changed, 104 insertions(+) create mode 100644 backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts 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();