From 11b630011357fb83593db3ac4ddd19bd4e8fa9f9 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 21:10:12 +0100 Subject: [PATCH] fix: revalidate authentication before bot start --- docs/deployment.md | 6 ++++ src/web/bot-control.ts | 17 +++++++-- src/web/routes/agent.ts | 10 +++--- src/web/routes/control.ts | 8 ++--- tests/control-auth.test.ts | 73 +++++++++++++++++++++++++++++++++++++- 5 files changed, 101 insertions(+), 13 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 84acc53..0b65e4e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -75,6 +75,12 @@ Success should distinguish: - no crash loop or new high-severity errors; - requested workload actually completed. +The authentication lease deliberately expires while an account is idle. A +start request revalidates the persisted Reddit and Matrix credentials before +changing the run state, so an otherwise valid paused account does not become +stuck behind the start safety gate. A failed revalidation still returns a +conflict and requires a fresh extension session import. + ## Account session operations The supported workflow is: diff --git a/src/web/bot-control.ts b/src/web/bot-control.ts index f3f99e8..215a417 100644 --- a/src/web/bot-control.ts +++ b/src/web/bot-control.ts @@ -12,12 +12,13 @@ export function botControlStatus(ctx: WebContext): ControlResponse { }; } -export function setBotRunning( +export async function setBotRunning( ctx: WebContext, running: boolean, actor: 'dashboard' | 'agent' = 'dashboard', -): ControlResponse { - const authenticated = hasAuthenticatedAccount(ctx); +): Promise { + const authenticated = hasAuthenticatedAccount(ctx) + || (running && await ensureAuthenticatedAccount(ctx)); if (running && !authenticated) { ctx.logger.warn(`Bot start blocked for ${actor} until full account authentication`); return { @@ -60,3 +61,13 @@ function hasAuthenticatedAccount(ctx: WebContext): boolean { return ctx.services.accountRuntimes?.get(snapshot.accountId)?.resources.accountAuth.isFullyAuthenticated() ?? false; }); } + +async function ensureAuthenticatedAccount(ctx: WebContext): Promise { + if (ctx.services.accountAuth && await ctx.services.accountAuth.ensureFullyAuthenticated()) return true; + for (const snapshot of ctx.services.accountRuntimes?.list() ?? []) { + if (snapshot.state !== 'running') continue; + const auth = ctx.services.accountRuntimes?.get(snapshot.accountId)?.resources.accountAuth; + if (auth && await auth.ensureFullyAuthenticated()) return true; + } + return false; +} diff --git a/src/web/routes/agent.ts b/src/web/routes/agent.ts index e855a64..f88a90e 100644 --- a/src/web/routes/agent.ts +++ b/src/web/routes/agent.ts @@ -54,20 +54,20 @@ export function createAgentRouter(ctx: WebContext): Router { }); router.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx))); - router.post('/v1/control', (req, res) => { + router.post('/v1/control', async (req, res) => { const body = ControlRequestSchema.safeParse(req.body); if (!body.success) { res.status(400).json({ error: 'Invalid control request', detail: body.error.format() }); return; } - const result = setBotRunning(ctx, body.data.running, 'agent'); + const result = await setBotRunning(ctx, body.data.running, 'agent'); res.status(result.success ? 200 : 409).json(result); }); - router.post('/v1/control/start', (_req, res) => { - const result = setBotRunning(ctx, true, 'agent'); + router.post('/v1/control/start', async (_req, res) => { + const result = await setBotRunning(ctx, true, 'agent'); res.status(result.success ? 200 : 409).json(result); }); - router.post('/v1/control/stop', (_req, res) => res.json(setBotRunning(ctx, false, 'agent'))); + router.post('/v1/control/stop', async (_req, res) => res.json(await setBotRunning(ctx, false, 'agent'))); router.get('/v1/jobs', (req, res) => { const limit = Number.parseInt(String(req.query.limit ?? '20'), 10); diff --git a/src/web/routes/control.ts b/src/web/routes/control.ts index c1802b2..c290ff0 100644 --- a/src/web/routes/control.ts +++ b/src/web/routes/control.ts @@ -5,15 +5,15 @@ import { setBotRunning } from '../bot-control.js'; export function createControlRouter(ctx: WebContext): Router { const router = Router(); - router.post('/start', (_req, res) => { - const result = setBotRunning(ctx, true); + router.post('/start', async (_req, res) => { + const result = await setBotRunning(ctx, true); res.status(result.success ? 200 : 409).json(result.success ? { success: true, running: result.running } : { success: false, running: false, authenticated: false }); }); - router.post('/stop', (_req, res) => { - const result = setBotRunning(ctx, false); + router.post('/stop', async (_req, res) => { + const result = await setBotRunning(ctx, false); res.json({ success: true, running: result.running }); }); diff --git a/tests/control-auth.test.ts b/tests/control-auth.test.ts index 996a4bd..0877e01 100644 --- a/tests/control-auth.test.ts +++ b/tests/control-auth.test.ts @@ -16,7 +16,12 @@ describe('bot control account authentication gate', () => { const setBotRunning = vi.fn(); const app = express(); app.use(createControlRouter({ - services: { accountAuth: { isFullyAuthenticated: () => false } }, + services: { + accountAuth: { + isFullyAuthenticated: () => false, + ensureFullyAuthenticated: vi.fn(async () => false), + }, + }, setBotRunning, activity: { push: vi.fn() }, logger: { warn: vi.fn(), info: vi.fn() }, @@ -32,4 +37,70 @@ describe('bot control account authentication gate', () => { expect(await response.json()).toMatchObject({ success: false, authenticated: false }); expect(setBotRunning).not.toHaveBeenCalled(); }); + + it('renews an expired authentication lease before starting the bot', async () => { + let running = false; + const ensureFullyAuthenticated = vi.fn(async () => true); + const app = express(); + app.use(createControlRouter({ + config: { browser: { dryRun: false } }, + services: { + accountAuth: { + isFullyAuthenticated: () => false, + ensureFullyAuthenticated, + }, + }, + getBotRunning: () => running, + setBotRunning: (next: boolean) => { running = next; }, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + broadcaster: { dashboardEvents: { bot: vi.fn() } }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true, running: true }); + expect(ensureFullyAuthenticated).toHaveBeenCalledOnce(); + expect(running).toBe(true); + }); + + it('renews a running account runtime when the legacy auth context is unavailable', async () => { + let running = false; + const ensureFullyAuthenticated = vi.fn(async () => true); + const runtime = { + resources: { + accountAuth: { isFullyAuthenticated: () => false, ensureFullyAuthenticated }, + }, + }; + const app = express(); + app.use(createControlRouter({ + config: { browser: { dryRun: false } }, + services: { + accountRuntimes: { + list: () => [{ accountId: '@t2_account:reddit.com', state: 'running' }], + get: () => runtime, + }, + }, + getBotRunning: () => running, + setBotRunning: (next: boolean) => { running = next; }, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + broadcaster: { dashboardEvents: { bot: vi.fn() } }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' }); + + expect(response.status).toBe(200); + expect(ensureFullyAuthenticated).toHaveBeenCalledOnce(); + expect(running).toBe(true); + }); });