neoagent 2.1.18-beta.99 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,10 @@ const {
34
34
  sendEmailChangeRequestedNotice,
35
35
  sendPasswordChangedNotice,
36
36
  } = require('../services/account/service_email');
37
+ const {
38
+ approveChallenge,
39
+ resolveChallengeForApproval,
40
+ } = require('../services/account/qr_login');
37
41
 
38
42
  const accountLimiter = rateLimit({
39
43
  windowMs: 15 * 60 * 1000,
@@ -222,6 +226,38 @@ router.post('/2fa/recovery-codes', accountLimiter, async (req, res) => {
222
226
  }
223
227
  });
224
228
 
229
+ router.post('/qr-login/resolve', accountLimiter, (req, res) => {
230
+ try {
231
+ const challengeId = String(req.body?.challengeId || '').trim();
232
+ const secret = String(req.body?.secret || '').trim();
233
+ if (!challengeId || !secret) {
234
+ return res.status(400).json({ error: 'Challenge id and QR secret are required.' });
235
+ }
236
+ res.json(resolveChallengeForApproval({ challengeId, secret }));
237
+ } catch (err) {
238
+ sendRouteError(res, err);
239
+ }
240
+ });
241
+
242
+ router.post('/qr-login/approve', accountLimiter, (req, res) => {
243
+ try {
244
+ const challengeId = String(req.body?.challengeId || '').trim();
245
+ const secret = String(req.body?.secret || '').trim();
246
+ if (!challengeId || !secret) {
247
+ return res.status(400).json({ error: 'Challenge id and QR secret are required.' });
248
+ }
249
+ res.json(approveChallenge({
250
+ challengeId,
251
+ secret,
252
+ userId: req.session.userId,
253
+ approverSessionId: req.sessionID,
254
+ approvalMetadata: req.body?.approvalMetadata,
255
+ }));
256
+ } catch (err) {
257
+ sendRouteError(res, err);
258
+ }
259
+ });
260
+
225
261
  router.get('/sessions', (req, res) => {
226
262
  try {
227
263
  res.json({ sessions: listSessions(req, req.session.userId) });
@@ -24,6 +24,11 @@ const {
24
24
  sendSignupConfirmation,
25
25
  sendUnusualLoginNotice,
26
26
  } = require('../services/account/service_email');
27
+ const {
28
+ claimApprovedChallenge,
29
+ createChallenge,
30
+ getChallengeStatusForPoll,
31
+ } = require('../services/account/qr_login');
27
32
 
28
33
  const authLimiter = rateLimit({
29
34
  windowMs: 15 * 60 * 1000,
@@ -33,6 +38,22 @@ const authLimiter = rateLimit({
33
38
  legacyHeaders: false,
34
39
  });
35
40
 
41
+ const qrLoginPollLimiter = rateLimit({
42
+ windowMs: 15 * 60 * 1000,
43
+ max: 180,
44
+ message: { error: 'Too many QR login status checks, try again shortly' },
45
+ standardHeaders: true,
46
+ legacyHeaders: false,
47
+ });
48
+
49
+ const qrLoginClaimLimiter = rateLimit({
50
+ windowMs: 15 * 60 * 1000,
51
+ max: 40,
52
+ message: { error: 'Too many QR login completion attempts, try again shortly' },
53
+ standardHeaders: true,
54
+ legacyHeaders: false,
55
+ });
56
+
36
57
  const passwordResetLimiter = rateLimit({
37
58
  windowMs: 15 * 60 * 1000,
38
59
  max: 8,
@@ -87,6 +108,12 @@ function establishSession(req, res, user) {
87
108
  });
88
109
  }
89
110
 
111
+ function baseUrlFor(req) {
112
+ const configured = req.app?.locals?.httpRuntimeConfig?.publicUrl || process.env.PUBLIC_URL || '';
113
+ if (configured) return String(configured).replace(/\/+$/, '');
114
+ return `${req.protocol}://${req.get('host')}`;
115
+ }
116
+
90
117
  function readAuthenticatedUser(req) {
91
118
  if (!req.session || !req.session.userId) {
92
119
  return null;
@@ -550,6 +577,70 @@ router.post('/api/auth/password/forgot', authLimiter, async (req, res) => {
550
577
  }
551
578
  });
552
579
 
580
+ router.post('/api/auth/qr-login/challenge', authLimiter, (req, res) => {
581
+ try {
582
+ const challenge = createChallenge(req, {
583
+ requestMetadata: req.body?.requestMetadata,
584
+ });
585
+ const payload = new URL('neoagent://qr-login');
586
+ payload.searchParams.set('v', '1');
587
+ payload.searchParams.set('backend', baseUrlFor(req));
588
+ payload.searchParams.set('challenge', challenge.challengeId);
589
+ payload.searchParams.set('secret', challenge.approveSecret);
590
+ res.json({
591
+ challengeId: challenge.challengeId,
592
+ pollToken: challenge.pollToken,
593
+ expiresAt: challenge.expiresAt,
594
+ status: challenge.status,
595
+ qrPayload: payload.toString(),
596
+ backendUrl: baseUrlFor(req),
597
+ });
598
+ } catch (error) {
599
+ res.status(Number(error?.statusCode || 500)).json({
600
+ error: error?.message || 'Could not create QR login request.',
601
+ });
602
+ }
603
+ });
604
+
605
+ router.post('/api/auth/qr-login/challenge/:id/status', qrLoginPollLimiter, (req, res) => {
606
+ try {
607
+ const pollToken = String(req.body?.token || '').trim();
608
+ if (!pollToken) {
609
+ return res.status(400).json({ error: 'QR login poll token is required.' });
610
+ }
611
+ res.json(
612
+ getChallengeStatusForPoll({
613
+ challengeId: req.params.id,
614
+ pollToken,
615
+ }),
616
+ );
617
+ } catch (error) {
618
+ res.status(Number(error?.statusCode || 500)).json({
619
+ error: error?.message || 'Could not read QR login status.',
620
+ });
621
+ }
622
+ });
623
+
624
+ router.post('/api/auth/qr-login/challenge/:id/claim', qrLoginClaimLimiter, (req, res) => {
625
+ try {
626
+ const pollToken = String(req.body?.token || '').trim();
627
+ if (!pollToken) {
628
+ return res.status(400).json({ error: 'QR login poll token is required.' });
629
+ }
630
+ const result = claimApprovedChallenge({
631
+ challengeId: req.params.id,
632
+ pollToken,
633
+ });
634
+ updateLastLogin(result.user.id);
635
+ return establishSession(req, res, result.user);
636
+ } catch (error) {
637
+ const statusCode = Number(error?.statusCode || 500);
638
+ return res.status(statusCode).json({
639
+ error: error?.message || 'Could not complete QR login.',
640
+ });
641
+ }
642
+ });
643
+
553
644
  router.get('/api/auth/password/reset', (req, res) => {
554
645
  const token = String(req.query?.token || '').trim();
555
646
  if (!token) {
@@ -4,7 +4,7 @@ const db = require('../db/database');
4
4
  const { requireAuth } = require('../middleware/auth');
5
5
  const { sanitizeError } = require('../utils/security');
6
6
  const { validateRemoteMcpEndpoint } = require('../services/runtime/mcp');
7
- const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
7
+ const { getAgentIdFromRequest, isMainAgent, resolveAgentId } = require('../services/agents/manager');
8
8
  const { resolvePublicBaseUrl } = require('../services/integrations/env');
9
9
 
10
10
  const MCP_OAUTH_STATE_RE = /^(\d+)::[a-f0-9]{32}$/;
@@ -21,9 +21,22 @@ router.use(requireAuth);
21
21
 
22
22
  // List configured MCP servers
23
23
  router.get('/', (req, res) => {
24
- const servers = db.prepare('SELECT * FROM mcp_servers WHERE user_id = ? ORDER BY name ASC').all(req.session.userId);
24
+ const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
25
+ const includeLegacyMainServers = isMainAgent(req.session.userId, agentId);
26
+ const servers = includeLegacyMainServers
27
+ ? db.prepare(
28
+ `SELECT * FROM mcp_servers
29
+ WHERE user_id = ?
30
+ AND (agent_id = ? OR agent_id IS NULL)
31
+ ORDER BY name ASC`
32
+ ).all(req.session.userId, agentId)
33
+ : db.prepare(
34
+ `SELECT * FROM mcp_servers
35
+ WHERE user_id = ? AND agent_id = ?
36
+ ORDER BY name ASC`
37
+ ).all(req.session.userId, agentId);
25
38
  const mcpClient = req.app.locals.mcpClient;
26
- const liveStatuses = mcpClient.getStatus(req.session.userId);
39
+ const liveStatuses = mcpClient.getStatus(req.session.userId, { agentId });
27
40
 
28
41
  const result = servers.map(s => ({
29
42
  id: s.id,
@@ -10,7 +10,8 @@ router.use(requireAuth);
10
10
  // List scheduled tasks
11
11
  router.get('/', (req, res) => {
12
12
  const scheduler = req.app.locals.scheduler;
13
- res.json(scheduler.listTasks(req.session.userId));
13
+ const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
14
+ res.json(scheduler.listTasks(req.session.userId, { agentId }));
14
15
  });
15
16
 
16
17
  // Create a new scheduled task
@@ -0,0 +1,388 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { randomUUID } = require('crypto');
5
+ const db = require('../../db/database');
6
+ const { clientIpFromRequest, lookupIpLocation } = require('./geoip');
7
+ const { sessionHash } = require('./sessions');
8
+
9
+ const QR_LOGIN_TTL_MS = 2 * 60 * 1000;
10
+ const QR_LOGIN_TERMINAL_RETENTION_MS = 60 * 60 * 1000;
11
+
12
+ function sqliteDateFromMs(ms) {
13
+ return new Date(ms).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
14
+ }
15
+
16
+ function tokenHash(token) {
17
+ return crypto.createHash('sha256').update(String(token || '')).digest('hex');
18
+ }
19
+
20
+ function parseSqliteUtcMs(value) {
21
+ const raw = String(value || '').trim();
22
+ if (!raw) return Number.NaN;
23
+ const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
24
+ const utcValue = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)
25
+ ? normalized
26
+ : `${normalized}Z`;
27
+ return Date.parse(utcValue);
28
+ }
29
+
30
+ function randomToken(bytes = 24) {
31
+ return crypto.randomBytes(bytes).toString('base64url');
32
+ }
33
+
34
+ function trimmedString(value, maxLength = 160) {
35
+ return String(value || '').trim().slice(0, maxLength);
36
+ }
37
+
38
+ function userAgentFromRequest(req) {
39
+ return trimmedString(req.get?.('user-agent') || req.headers?.['user-agent'] || '', 500);
40
+ }
41
+
42
+ function parseJsonObject(value) {
43
+ try {
44
+ const parsed = JSON.parse(value || '{}');
45
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ function normalizeMetadata(value) {
52
+ const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
53
+ const metadata = {
54
+ deviceLabel: trimmedString(raw.deviceLabel, 120),
55
+ platformLabel: trimmedString(raw.platformLabel, 60),
56
+ browserLabel: trimmedString(raw.browserLabel, 60),
57
+ deviceClass: trimmedString(raw.deviceClass, 24).toLowerCase(),
58
+ appMode: trimmedString(raw.appMode, 24).toLowerCase(),
59
+ platform: trimmedString(raw.platform, 32).toLowerCase(),
60
+ };
61
+ if (!['mobile', 'tablet', 'desktop', 'server', 'unknown'].includes(metadata.deviceClass)) {
62
+ metadata.deviceClass = '';
63
+ }
64
+ return metadata;
65
+ }
66
+
67
+ function parseClientDescriptor(userAgent, metadata = {}) {
68
+ const lower = String(userAgent || '').toLowerCase();
69
+ const isTablet = lower.includes('ipad') || lower.includes('tablet');
70
+ const isMobile = !isTablet && (
71
+ lower.includes('iphone')
72
+ || lower.includes('android') && lower.includes('mobile')
73
+ );
74
+
75
+ const platformLabel = metadata.platformLabel || (() => {
76
+ if (lower.includes('iphone')) return 'iPhone';
77
+ if (lower.includes('ipad')) return 'iPad';
78
+ if (lower.includes('android')) return 'Android';
79
+ if (lower.includes('mac os x') || lower.includes('macintosh')) return 'macOS';
80
+ if (lower.includes('windows nt')) return 'Windows';
81
+ if (lower.includes('linux') || lower.includes('x11')) return 'Linux';
82
+ if (lower.includes('curl/') || lower.includes('wget/') || lower.includes('httpie/')) return 'CLI session';
83
+ return 'Unknown device';
84
+ })();
85
+
86
+ const browserLabel = metadata.browserLabel || (() => {
87
+ if (lower.includes('edg/')) return 'Edge';
88
+ if (lower.includes('opr/') || lower.includes('opera/')) return 'Opera';
89
+ if (lower.includes('brave/')) return 'Brave';
90
+ if (lower.includes('firefox/')) return 'Firefox';
91
+ if (lower.includes('chrome/') || lower.includes('crios/') || lower.includes('chromium/')) return 'Chrome';
92
+ if (lower.includes('safari/') && lower.includes('version/')) return 'Safari';
93
+ if (lower.includes('dart/')) return 'Flutter app';
94
+ if (lower.includes('curl/')) return 'curl';
95
+ if (lower.includes('wget/')) return 'wget';
96
+ if (lower.includes('httpie/')) return 'HTTPie';
97
+ return 'Unknown browser';
98
+ })();
99
+
100
+ const deviceClass = metadata.deviceClass || (() => {
101
+ if (platformLabel === 'CLI session') return 'server';
102
+ if (isTablet) return 'tablet';
103
+ if (isMobile) return 'mobile';
104
+ if (['macOS', 'Windows', 'Linux'].includes(platformLabel)) return 'desktop';
105
+ return 'unknown';
106
+ })();
107
+
108
+ const primaryLabel = metadata.deviceLabel || (() => {
109
+ const parts = [platformLabel];
110
+ if (browserLabel && browserLabel !== 'Unknown browser' && browserLabel !== 'Flutter app') {
111
+ parts.push(browserLabel);
112
+ } else if (browserLabel === 'Flutter app') {
113
+ parts.push('App');
114
+ }
115
+ return parts.join(' · ');
116
+ })();
117
+
118
+ return {
119
+ label: primaryLabel,
120
+ platformLabel,
121
+ browserLabel,
122
+ deviceClass,
123
+ };
124
+ }
125
+
126
+ function challengeIsExpired(row, nowMs = Date.now()) {
127
+ const expiresAtMs = parseSqliteUtcMs(row?.expires_at);
128
+ return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
129
+ }
130
+
131
+ function challengeNotFoundError() {
132
+ const error = new Error('QR login request was not found or has expired.');
133
+ error.statusCode = 404;
134
+ return error;
135
+ }
136
+
137
+ function challengeStateError(message, statusCode = 409) {
138
+ const error = new Error(message);
139
+ error.statusCode = statusCode;
140
+ return error;
141
+ }
142
+
143
+ function pruneExpiredChallenges() {
144
+ db.prepare(`
145
+ UPDATE user_qr_login_challenges
146
+ SET status = 'expired'
147
+ WHERE status IN ('pending', 'approved')
148
+ AND datetime(expires_at) <= datetime('now')
149
+ `).run();
150
+ const retentionCutoff = sqliteDateFromMs(
151
+ Date.now() - QR_LOGIN_TERMINAL_RETENTION_MS,
152
+ );
153
+ db.prepare(`
154
+ DELETE FROM user_qr_login_challenges
155
+ WHERE status IN ('expired', 'claimed')
156
+ AND datetime(COALESCE(claimed_at, expires_at, created_at)) <= datetime(?)
157
+ `).run(retentionCutoff);
158
+ }
159
+
160
+ function getChallengeRowByApproveSecret(challengeId, secret) {
161
+ pruneExpiredChallenges();
162
+ return db.prepare(`
163
+ SELECT *
164
+ FROM user_qr_login_challenges
165
+ WHERE id = ?
166
+ AND approve_secret_hash = ?
167
+ LIMIT 1
168
+ `).get(String(challengeId || '').trim(), tokenHash(secret));
169
+ }
170
+
171
+ function getChallengeRowByPollToken(challengeId, pollToken) {
172
+ pruneExpiredChallenges();
173
+ return db.prepare(`
174
+ SELECT *
175
+ FROM user_qr_login_challenges
176
+ WHERE id = ?
177
+ AND poll_token_hash = ?
178
+ LIMIT 1
179
+ `).get(String(challengeId || '').trim(), tokenHash(pollToken));
180
+ }
181
+
182
+ function serializeChallenge(row) {
183
+ const requestMetadata = parseJsonObject(row.request_metadata_json);
184
+ const approvedMetadata = parseJsonObject(row.approved_metadata_json);
185
+ const requestLocation = parseJsonObject(row.request_location_json);
186
+ const descriptor = parseClientDescriptor(row.request_user_agent, requestMetadata);
187
+
188
+ return {
189
+ challengeId: row.id,
190
+ status: row.status || 'pending',
191
+ requestedAt: row.created_at || null,
192
+ expiresAt: row.expires_at || null,
193
+ approvedAt: row.approved_at || null,
194
+ claimedAt: row.claimed_at || null,
195
+ requestedDevice: {
196
+ label: descriptor.label,
197
+ platformLabel: descriptor.platformLabel,
198
+ browserLabel: descriptor.browserLabel,
199
+ deviceClass: descriptor.deviceClass,
200
+ userAgent: row.request_user_agent || '',
201
+ metadata: requestMetadata,
202
+ },
203
+ requestLocation: {
204
+ label: row.request_location_label || 'Unknown',
205
+ ipAddress: row.request_ip_address || null,
206
+ city: trimmedString(requestLocation.city, 80) || null,
207
+ region: trimmedString(requestLocation.region, 80) || null,
208
+ country: trimmedString(requestLocation.country, 80) || null,
209
+ timezone: trimmedString(requestLocation.timezone, 80) || null,
210
+ },
211
+ approval: row.approved_by_user_id ? {
212
+ userId: Number(row.approved_by_user_id),
213
+ metadata: approvedMetadata,
214
+ } : null,
215
+ };
216
+ }
217
+
218
+ function createChallenge(req, options = {}) {
219
+ pruneExpiredChallenges();
220
+ const requestMetadata = normalizeMetadata(options.requestMetadata);
221
+ const geo = lookupIpLocation(clientIpFromRequest(req));
222
+ const userAgent = userAgentFromRequest(req);
223
+ const challengeId = randomUUID();
224
+ const pollToken = randomToken();
225
+ const approveSecret = randomToken();
226
+ const expiresAt = sqliteDateFromMs(Date.now() + QR_LOGIN_TTL_MS);
227
+
228
+ db.prepare(`
229
+ INSERT INTO user_qr_login_challenges (
230
+ id,
231
+ poll_token_hash,
232
+ approve_secret_hash,
233
+ status,
234
+ request_user_agent,
235
+ request_ip_address,
236
+ request_location_label,
237
+ request_location_json,
238
+ request_metadata_json,
239
+ expires_at
240
+ )
241
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
242
+ `).run(
243
+ challengeId,
244
+ tokenHash(pollToken),
245
+ tokenHash(approveSecret),
246
+ userAgent,
247
+ geo.ipAddress,
248
+ geo.label,
249
+ JSON.stringify(geo.data || {}),
250
+ JSON.stringify(requestMetadata),
251
+ expiresAt,
252
+ );
253
+
254
+ return {
255
+ challengeId,
256
+ pollToken,
257
+ approveSecret,
258
+ expiresAt,
259
+ status: 'pending',
260
+ };
261
+ }
262
+
263
+ function resolveChallengeForApproval({ challengeId, secret }) {
264
+ const row = getChallengeRowByApproveSecret(challengeId, secret);
265
+ if (!row || challengeIsExpired(row) || row.status === 'expired') {
266
+ throw challengeNotFoundError();
267
+ }
268
+ return serializeChallenge(row);
269
+ }
270
+
271
+ function approveChallenge({
272
+ challengeId,
273
+ secret,
274
+ userId,
275
+ approverSessionId,
276
+ approvalMetadata,
277
+ }) {
278
+ const metadata = normalizeMetadata(approvalMetadata);
279
+ const row = getChallengeRowByApproveSecret(challengeId, secret);
280
+ if (!row || challengeIsExpired(row) || row.status === 'expired') {
281
+ throw challengeNotFoundError();
282
+ }
283
+ if (row.status === 'claimed') {
284
+ throw challengeStateError('This QR login request was already used.');
285
+ }
286
+ if (row.status === 'approved' &&
287
+ row.approved_by_user_id &&
288
+ Number(row.approved_by_user_id) !== Number(userId)) {
289
+ throw challengeStateError('This QR login request was already approved.');
290
+ }
291
+
292
+ const approvedSessionHash = approverSessionId ? sessionHash(approverSessionId) : null;
293
+ const now = sqliteDateFromMs(Date.now());
294
+
295
+ db.prepare(`
296
+ UPDATE user_qr_login_challenges
297
+ SET status = 'approved',
298
+ approved_by_user_id = ?,
299
+ approved_session_hash = ?,
300
+ approved_metadata_json = ?,
301
+ approved_at = ?
302
+ WHERE id = ?
303
+ AND status IN ('pending', 'approved')
304
+ `).run(
305
+ userId,
306
+ approvedSessionHash,
307
+ JSON.stringify(metadata),
308
+ now,
309
+ row.id,
310
+ );
311
+
312
+ return resolveChallengeForApproval({ challengeId, secret });
313
+ }
314
+
315
+ function getChallengeStatusForPoll({ challengeId, pollToken }) {
316
+ const row = getChallengeRowByPollToken(challengeId, pollToken);
317
+ if (!row || challengeIsExpired(row) || row.status === 'expired') {
318
+ return {
319
+ challengeId: String(challengeId || '').trim(),
320
+ status: 'expired',
321
+ expiresAt: row?.expires_at || null,
322
+ };
323
+ }
324
+ return {
325
+ challengeId: row.id,
326
+ status: row.status || 'pending',
327
+ expiresAt: row.expires_at || null,
328
+ approvedAt: row.approved_at || null,
329
+ claimedAt: row.claimed_at || null,
330
+ };
331
+ }
332
+
333
+ function claimApprovedChallenge({ challengeId, pollToken }) {
334
+ const row = getChallengeRowByPollToken(challengeId, pollToken);
335
+ if (!row || challengeIsExpired(row) || row.status === 'expired') {
336
+ throw challengeNotFoundError();
337
+ }
338
+ if (row.status === 'claimed') {
339
+ throw challengeStateError('This QR login request was already used.');
340
+ }
341
+ if (row.status !== 'approved' || !row.approved_by_user_id) {
342
+ throw challengeStateError('This QR login request is not approved yet.', 409);
343
+ }
344
+
345
+ const now = sqliteDateFromMs(Date.now());
346
+ const result = db.prepare(`
347
+ UPDATE user_qr_login_challenges
348
+ SET status = 'claimed',
349
+ claimed_at = ?
350
+ WHERE id = ?
351
+ AND poll_token_hash = ?
352
+ AND status = 'approved'
353
+ `).run(now, row.id, tokenHash(pollToken));
354
+
355
+ if (result.changes !== 1) {
356
+ throw challengeStateError('This QR login request is no longer available.');
357
+ }
358
+
359
+ const user = db.prepare(`
360
+ SELECT id, username, email, email_verified_at, password_login_enabled, created_at, last_login
361
+ FROM users
362
+ WHERE id = ?
363
+ `).get(row.approved_by_user_id);
364
+ if (!user) {
365
+ throw challengeStateError('The approving account is no longer available.', 404);
366
+ }
367
+
368
+ return {
369
+ user,
370
+ challenge: serializeChallenge({
371
+ ...row,
372
+ status: 'claimed',
373
+ claimed_at: now,
374
+ }),
375
+ };
376
+ }
377
+
378
+ module.exports = {
379
+ createChallenge,
380
+ approveChallenge,
381
+ claimApprovedChallenge,
382
+ getChallengeStatusForPoll,
383
+ resolveChallengeForApproval,
384
+ __test: {
385
+ challengeIsExpired,
386
+ parseSqliteUtcMs,
387
+ },
388
+ };
@@ -177,8 +177,21 @@ class Scheduler {
177
177
  return { deleted: true };
178
178
  }
179
179
 
180
- listTasks(userId) {
181
- const tasks = db.prepare('SELECT * FROM scheduled_tasks WHERE user_id = ? ORDER BY created_at DESC').all(userId);
180
+ listTasks(userId, options = {}) {
181
+ const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
182
+ const includeLegacyMainTasks = isMainAgent(userId, agentId);
183
+ const tasks = includeLegacyMainTasks
184
+ ? db.prepare(
185
+ `SELECT * FROM scheduled_tasks
186
+ WHERE user_id = ?
187
+ AND (agent_id = ? OR agent_id IS NULL)
188
+ ORDER BY created_at DESC`
189
+ ).all(userId, agentId)
190
+ : db.prepare(
191
+ `SELECT * FROM scheduled_tasks
192
+ WHERE user_id = ? AND agent_id = ?
193
+ ORDER BY created_at DESC`
194
+ ).all(userId, agentId);
182
195
  return tasks.map(t => {
183
196
  const config = this._normalizeTaskConfig(t.task_config);
184
197
  return {