fix: revalidate authentication before bot start

This commit is contained in:
jjbrunton committed 2026-08-10 21:10:12 +01:00
1 parent 8ea1cf16d4
commit 11b6300113
5 files changed
+101 -13

No files matched your search

+6
View File
@@ -75,6 +75,12 @@ Success should distinguish:
- no crash loop or new high-severity errors; - no crash loop or new high-severity errors;
- requested workload actually completed. - 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 ## Account session operations
The supported workflow is: The supported workflow is:
+14 -3
View File
@@ -12,12 +12,13 @@ export function botControlStatus(ctx: WebContext): ControlResponse {
}; };
} }
export function setBotRunning( export async function setBotRunning(
ctx: WebContext, ctx: WebContext,
running: boolean, running: boolean,
actor: 'dashboard' | 'agent' = 'dashboard', actor: 'dashboard' | 'agent' = 'dashboard',
): ControlResponse { ): Promise<ControlResponse> {
const authenticated = hasAuthenticatedAccount(ctx); const authenticated = hasAuthenticatedAccount(ctx)
|| (running && await ensureAuthenticatedAccount(ctx));
if (running && !authenticated) { if (running && !authenticated) {
ctx.logger.warn(`Bot start blocked for ${actor} until full account authentication`); ctx.logger.warn(`Bot start blocked for ${actor} until full account authentication`);
return { return {
@@ -60,3 +61,13 @@ function hasAuthenticatedAccount(ctx: WebContext): boolean {
return ctx.services.accountRuntimes?.get(snapshot.accountId)?.resources.accountAuth.isFullyAuthenticated() ?? false; return ctx.services.accountRuntimes?.get(snapshot.accountId)?.resources.accountAuth.isFullyAuthenticated() ?? false;
}); });
} }
async function ensureAuthenticatedAccount(ctx: WebContext): Promise<boolean> {
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;
}
+5 -5
View File
@@ -54,20 +54,20 @@ export function createAgentRouter(ctx: WebContext): Router {
}); });
router.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx))); 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); const body = ControlRequestSchema.safeParse(req.body);
if (!body.success) { if (!body.success) {
res.status(400).json({ error: 'Invalid control request', detail: body.error.format() }); res.status(400).json({ error: 'Invalid control request', detail: body.error.format() });
return; 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); res.status(result.success ? 200 : 409).json(result);
}); });
router.post('/v1/control/start', (_req, res) => { router.post('/v1/control/start', async (_req, res) => {
const result = setBotRunning(ctx, true, 'agent'); const result = await setBotRunning(ctx, true, 'agent');
res.status(result.success ? 200 : 409).json(result); 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) => { router.get('/v1/jobs', (req, res) => {
const limit = Number.parseInt(String(req.query.limit ?? '20'), 10); const limit = Number.parseInt(String(req.query.limit ?? '20'), 10);
+4 -4
View File
@@ -5,15 +5,15 @@ import { setBotRunning } from '../bot-control.js';
export function createControlRouter(ctx: WebContext): Router { export function createControlRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
router.post('/start', (_req, res) => { router.post('/start', async (_req, res) => {
const result = setBotRunning(ctx, true); const result = await setBotRunning(ctx, true);
res.status(result.success ? 200 : 409).json(result.success res.status(result.success ? 200 : 409).json(result.success
? { success: true, running: result.running } ? { success: true, running: result.running }
: { success: false, running: false, authenticated: false }); : { success: false, running: false, authenticated: false });
}); });
router.post('/stop', (_req, res) => { router.post('/stop', async (_req, res) => {
const result = setBotRunning(ctx, false); const result = await setBotRunning(ctx, false);
res.json({ success: true, running: result.running }); res.json({ success: true, running: result.running });
}); });
+72 -1
View File
@@ -16,7 +16,12 @@ describe('bot control account authentication gate', () => {
const setBotRunning = vi.fn(); const setBotRunning = vi.fn();
const app = express(); const app = express();
app.use(createControlRouter({ app.use(createControlRouter({
services: { accountAuth: { isFullyAuthenticated: () => false } }, services: {
accountAuth: {
isFullyAuthenticated: () => false,
ensureFullyAuthenticated: vi.fn(async () => false),
},
},
setBotRunning, setBotRunning,
activity: { push: vi.fn() }, activity: { push: vi.fn() },
logger: { warn: vi.fn(), info: 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(await response.json()).toMatchObject({ success: false, authenticated: false });
expect(setBotRunning).not.toHaveBeenCalled(); 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<void>((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<void>((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);
});
}); });