agentdev-webui 1.0.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.
- package/lib/agent-api.js +530 -0
- package/lib/auth.js +127 -0
- package/lib/config.js +53 -0
- package/lib/database.js +762 -0
- package/lib/device-flow.js +257 -0
- package/lib/email.js +420 -0
- package/lib/encryption.js +112 -0
- package/lib/github.js +339 -0
- package/lib/history.js +143 -0
- package/lib/pwa.js +107 -0
- package/lib/redis-logs.js +226 -0
- package/lib/routes.js +680 -0
- package/migrations/000_create_database.sql +33 -0
- package/migrations/001_create_agentdev_schema.sql +135 -0
- package/migrations/001_create_agentdev_schema.sql.old +100 -0
- package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
- package/migrations/002_add_github_token.sql +17 -0
- package/migrations/003_add_agent_logs_table.sql +23 -0
- package/migrations/004_remove_oauth_columns.sql +11 -0
- package/migrations/005_add_projects.sql +44 -0
- package/migrations/006_project_github_token.sql +7 -0
- package/migrations/007_project_repositories.sql +12 -0
- package/migrations/008_add_notifications.sql +20 -0
- package/migrations/009_unified_oauth.sql +153 -0
- package/migrations/README.md +97 -0
- package/package.json +37 -0
- package/public/css/styles.css +1140 -0
- package/public/device.html +384 -0
- package/public/docs.html +862 -0
- package/public/docs.md +697 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +271 -0
- package/public/js/app.js +2379 -0
- package/public/login.html +224 -0
- package/public/profile.html +394 -0
- package/public/register.html +392 -0
- package/public/reset-password.html +349 -0
- package/public/verify-email.html +177 -0
- package/server.js +1450 -0
package/lib/agent-api.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const deviceFlow = require('./device-flow');
|
|
3
|
+
const db = require('./database');
|
|
4
|
+
const redisLogs = require('./redis-logs');
|
|
5
|
+
const encryption = require('./encryption');
|
|
6
|
+
|
|
7
|
+
// Event emitter for notifying server.js about completions (SSE broadcast)
|
|
8
|
+
const events = new EventEmitter();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Middleware to verify agent JWT token
|
|
12
|
+
*/
|
|
13
|
+
async function verifyAgentAuth(req) {
|
|
14
|
+
const authHeader = req.headers.authorization;
|
|
15
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
16
|
+
return { error: 'Missing or invalid Authorization header', status: 401 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const token = authHeader.substring(7);
|
|
20
|
+
const decoded = deviceFlow.verifyAgentToken(token);
|
|
21
|
+
|
|
22
|
+
if (!decoded) {
|
|
23
|
+
return { error: 'Invalid or expired token', status: 401 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Verify agent exists
|
|
27
|
+
const agent = await db.getAgentById(decoded.agent_id);
|
|
28
|
+
if (!agent) {
|
|
29
|
+
return { error: 'Agent not found', status: 404 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Verify token hash matches (prevent token reuse after regeneration)
|
|
33
|
+
const tokenHash = deviceFlow.hashToken(token);
|
|
34
|
+
if (agent.access_token_hash !== tokenHash) {
|
|
35
|
+
return { error: 'Token has been revoked', status: 401 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { agent, user: { id: decoded.user_id } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* POST /api/agent/device/code
|
|
43
|
+
* Request device authorization code
|
|
44
|
+
*/
|
|
45
|
+
async function handleDeviceCodeRequest(req, res) {
|
|
46
|
+
let body = '';
|
|
47
|
+
req.on('data', chunk => body += chunk);
|
|
48
|
+
req.on('end', async () => {
|
|
49
|
+
try {
|
|
50
|
+
const { agent_name, capabilities } = JSON.parse(body);
|
|
51
|
+
|
|
52
|
+
if (!agent_name) {
|
|
53
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ error: 'agent_name is required' }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await deviceFlow.requestDeviceCode(agent_name, capabilities || {});
|
|
59
|
+
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
61
|
+
res.end(JSON.stringify(result));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Device code request error:', error);
|
|
64
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
65
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* POST /api/agent/device/token
|
|
72
|
+
* Poll for device authorization token
|
|
73
|
+
*/
|
|
74
|
+
async function handleDeviceTokenPoll(req, res) {
|
|
75
|
+
let body = '';
|
|
76
|
+
req.on('data', chunk => body += chunk);
|
|
77
|
+
req.on('end', async () => {
|
|
78
|
+
try {
|
|
79
|
+
const { device_code } = JSON.parse(body);
|
|
80
|
+
|
|
81
|
+
if (!device_code) {
|
|
82
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify({ error: 'device_code is required' }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await deviceFlow.pollDeviceToken(device_code);
|
|
88
|
+
|
|
89
|
+
if (result.error) {
|
|
90
|
+
const statusCode = result.error === 'authorization_pending' ? 428 : 400;
|
|
91
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
92
|
+
res.end(JSON.stringify(result));
|
|
93
|
+
} else {
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify(result));
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('Device token poll error:', error);
|
|
99
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
100
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* POST /api/agent/heartbeat
|
|
107
|
+
* Agent heartbeat to update status
|
|
108
|
+
*/
|
|
109
|
+
async function handleHeartbeat(req, res) {
|
|
110
|
+
// Verify token but allow missing agent (will auto-create)
|
|
111
|
+
const authHeader = req.headers.authorization;
|
|
112
|
+
|
|
113
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
114
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: 'Missing or invalid Authorization header' }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const token = authHeader.substring(7);
|
|
120
|
+
const decoded = deviceFlow.verifyAgentToken(token);
|
|
121
|
+
|
|
122
|
+
if (!decoded) {
|
|
123
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify({ error: 'Invalid or expired token' }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let body = '';
|
|
129
|
+
req.on('data', chunk => body += chunk);
|
|
130
|
+
req.on('end', async () => {
|
|
131
|
+
try {
|
|
132
|
+
const { status, current_ticket, instance } = JSON.parse(body);
|
|
133
|
+
|
|
134
|
+
// Support multi-agent: instance suffix creates separate agent rows
|
|
135
|
+
const effectiveId = instance
|
|
136
|
+
? `${decoded.agent_id}-${instance}`
|
|
137
|
+
: decoded.agent_id;
|
|
138
|
+
|
|
139
|
+
// Check if agent exists, if not create it
|
|
140
|
+
const tokenHash = deviceFlow.hashToken(token);
|
|
141
|
+
let agent = await db.getAgentById(effectiveId);
|
|
142
|
+
if (!agent) {
|
|
143
|
+
// For instances, inherit project_id from the parent agent
|
|
144
|
+
let projectId = decoded.project_id || null;
|
|
145
|
+
if (instance && !projectId) {
|
|
146
|
+
const parentAgent = await db.getAgentById(decoded.agent_id);
|
|
147
|
+
if (parentAgent) projectId = parentAgent.project_id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Auto-create agent (or sub-instance) from token info
|
|
151
|
+
await db.createAgent({
|
|
152
|
+
id: effectiveId,
|
|
153
|
+
userId: decoded.user_id,
|
|
154
|
+
name: effectiveId,
|
|
155
|
+
hostname: null,
|
|
156
|
+
capabilities: {},
|
|
157
|
+
accessTokenHash: tokenHash,
|
|
158
|
+
projectId
|
|
159
|
+
});
|
|
160
|
+
} else if (agent.access_token_hash !== tokenHash) {
|
|
161
|
+
// Sync token hash (fixes auth for other endpoints after token regeneration)
|
|
162
|
+
await db.updateAgentTokenHash(effectiveId, tokenHash);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update agent heartbeat
|
|
166
|
+
await db.updateAgentHeartbeat(
|
|
167
|
+
effectiveId,
|
|
168
|
+
status || 'idle',
|
|
169
|
+
current_ticket || null
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
173
|
+
res.end(JSON.stringify({ success: true }));
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error('Heartbeat error:', error);
|
|
176
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* GET /api/agent/work
|
|
184
|
+
* Claim next available ticket
|
|
185
|
+
*/
|
|
186
|
+
async function handleWorkRequest(req, res) {
|
|
187
|
+
const auth = await verifyAgentAuth(req);
|
|
188
|
+
if (auth.error) {
|
|
189
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
190
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Support multi-agent: use instance ID if provided
|
|
196
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
197
|
+
const instance = urlObj.searchParams.get('instance');
|
|
198
|
+
const decoded = deviceFlow.verifyAgentToken(
|
|
199
|
+
req.headers.authorization.substring(7)
|
|
200
|
+
);
|
|
201
|
+
const effectiveId = instance
|
|
202
|
+
? `${decoded.agent_id}-${instance}`
|
|
203
|
+
: auth.agent.id;
|
|
204
|
+
|
|
205
|
+
// Get next available ticket scoped to agent's project
|
|
206
|
+
const ticket = await db.getAvailableTicket(auth.agent.project_id);
|
|
207
|
+
|
|
208
|
+
if (!ticket) {
|
|
209
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
210
|
+
res.end(JSON.stringify({ ticket: null }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Assign ticket to agent (atomic operation)
|
|
215
|
+
const assigned = await db.assignTicket(ticket.id, effectiveId);
|
|
216
|
+
|
|
217
|
+
if (!assigned) {
|
|
218
|
+
// Another agent claimed it first
|
|
219
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
220
|
+
res.end(JSON.stringify({ ticket: null }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Update agent status to busy
|
|
225
|
+
await db.updateAgentHeartbeat(effectiveId, 'busy', ticket.id);
|
|
226
|
+
|
|
227
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
228
|
+
res.end(JSON.stringify({ ticket: assigned }));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error('Work request error:', error);
|
|
231
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
232
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* POST /api/agent/logs
|
|
238
|
+
* Stream log batch from agent
|
|
239
|
+
*/
|
|
240
|
+
async function handleLogUpload(req, res) {
|
|
241
|
+
const auth = await verifyAgentAuth(req);
|
|
242
|
+
if (auth.error) {
|
|
243
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
244
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let body = '';
|
|
249
|
+
req.on('data', chunk => body += chunk);
|
|
250
|
+
req.on('end', async () => {
|
|
251
|
+
try {
|
|
252
|
+
const { logs, ticket_id, instance } = JSON.parse(body);
|
|
253
|
+
|
|
254
|
+
if (!Array.isArray(logs)) {
|
|
255
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
256
|
+
res.end(JSON.stringify({ error: 'logs must be an array' }));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Support multi-agent: use instance ID if provided
|
|
261
|
+
const decoded = deviceFlow.verifyAgentToken(
|
|
262
|
+
req.headers.authorization.substring(7)
|
|
263
|
+
);
|
|
264
|
+
const effectiveId = instance
|
|
265
|
+
? `${decoded.agent_id}-${instance}`
|
|
266
|
+
: auth.agent.id;
|
|
267
|
+
|
|
268
|
+
// Process each log entry
|
|
269
|
+
for (const log of logs) {
|
|
270
|
+
const content = typeof log === 'string' ? log : log.content;
|
|
271
|
+
const level = typeof log === 'object' ? log.level : 'INFO';
|
|
272
|
+
|
|
273
|
+
// Store in database
|
|
274
|
+
await db.insertLog(effectiveId, ticket_id, content, level);
|
|
275
|
+
|
|
276
|
+
// Publish to Redis for real-time streaming
|
|
277
|
+
await redisLogs.publishLog(effectiveId, {
|
|
278
|
+
content,
|
|
279
|
+
level,
|
|
280
|
+
timestamp: new Date().toISOString()
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
285
|
+
res.end(JSON.stringify({ success: true, count: logs.length }));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error('Log upload error:', error);
|
|
288
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* POST /api/agent/complete
|
|
296
|
+
* Mark ticket as complete
|
|
297
|
+
*/
|
|
298
|
+
async function handleWorkComplete(req, res, onComplete) {
|
|
299
|
+
const auth = await verifyAgentAuth(req);
|
|
300
|
+
if (auth.error) {
|
|
301
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let body = '';
|
|
307
|
+
req.on('data', chunk => body += chunk);
|
|
308
|
+
req.on('end', async () => {
|
|
309
|
+
try {
|
|
310
|
+
const { ticket_id, success, error_message, instance } = JSON.parse(body);
|
|
311
|
+
|
|
312
|
+
if (!ticket_id) {
|
|
313
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
314
|
+
res.end(JSON.stringify({ error: 'ticket_id is required' }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Support multi-agent: use instance ID if provided
|
|
319
|
+
const decoded = deviceFlow.verifyAgentToken(
|
|
320
|
+
req.headers.authorization.substring(7)
|
|
321
|
+
);
|
|
322
|
+
const effectiveId = instance
|
|
323
|
+
? `${decoded.agent_id}-${instance}`
|
|
324
|
+
: auth.agent.id;
|
|
325
|
+
|
|
326
|
+
// Update ticket status
|
|
327
|
+
const status = success ? 'completed' : 'failed';
|
|
328
|
+
const updatedTicket = await db.updateTicketStatus(ticket_id, status, error_message);
|
|
329
|
+
const issueNum = updatedTicket?.github_issue_number || ticket_id;
|
|
330
|
+
|
|
331
|
+
// Set agent back to idle
|
|
332
|
+
await db.updateAgentHeartbeat(effectiveId, 'idle', null);
|
|
333
|
+
|
|
334
|
+
// Persist notification in DB
|
|
335
|
+
try {
|
|
336
|
+
const notifTitle = success
|
|
337
|
+
? `Ticket #${issueNum} completed`
|
|
338
|
+
: `Ticket #${issueNum} failed`;
|
|
339
|
+
await db.createNotification(
|
|
340
|
+
auth.user.id,
|
|
341
|
+
'ticket-completed',
|
|
342
|
+
notifTitle,
|
|
343
|
+
`Agent ${effectiveId} ${success ? 'completed' : 'failed'} ticket #${issueNum}`,
|
|
344
|
+
{ ticket_id, agent_id: effectiveId, success }
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error('Failed to persist notification:', err.message);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Notify frontend of completion via SSE
|
|
351
|
+
const completionData = {
|
|
352
|
+
id: effectiveId,
|
|
353
|
+
ticket: issueNum,
|
|
354
|
+
title: effectiveId,
|
|
355
|
+
success,
|
|
356
|
+
status,
|
|
357
|
+
user_id: auth.user.id
|
|
358
|
+
};
|
|
359
|
+
events.emit('ticket-completed', completionData);
|
|
360
|
+
if (onComplete) onComplete(completionData);
|
|
361
|
+
|
|
362
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
363
|
+
res.end(JSON.stringify({ success: true }));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Work complete error:', error);
|
|
366
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
367
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* GET /api/agent/oauth
|
|
374
|
+
* Get user's GitHub token for agent use
|
|
375
|
+
*/
|
|
376
|
+
async function handleOAuthRequest(req, res) {
|
|
377
|
+
const auth = await verifyAgentAuth(req);
|
|
378
|
+
if (auth.error) {
|
|
379
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
380
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
// Look up GitHub OAuth provider from unified table
|
|
386
|
+
const oauthProvider = await db.getOAuthProvider(auth.user.id, 'github');
|
|
387
|
+
|
|
388
|
+
if (!oauthProvider || !oauthProvider.access_token) {
|
|
389
|
+
// Fallback: check legacy github_token_encrypted column
|
|
390
|
+
const user = await db.getUserById(auth.user.id);
|
|
391
|
+
if (user && user.github_token_encrypted) {
|
|
392
|
+
const githubToken = encryption.decrypt(user.github_token_encrypted);
|
|
393
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
394
|
+
res.end(JSON.stringify({
|
|
395
|
+
github: { token: githubToken }
|
|
396
|
+
}));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
401
|
+
res.end(JSON.stringify({ error: 'GitHub token not configured. Add it in your profile.' }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Decrypt token from unified OAuth table
|
|
406
|
+
const githubToken = encryption.decrypt(oauthProvider.access_token);
|
|
407
|
+
|
|
408
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
409
|
+
res.end(JSON.stringify({
|
|
410
|
+
github: {
|
|
411
|
+
token: githubToken,
|
|
412
|
+
provider_key: oauthProvider.provider_key
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error('OAuth request error:', error);
|
|
417
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
418
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* POST /api/agent/ensure-ticket
|
|
424
|
+
* Upsert a ticket by github_repo + github_issue_number, return real DB id.
|
|
425
|
+
* Used by agents resuming leftover tickets found on the project board.
|
|
426
|
+
*/
|
|
427
|
+
async function handleEnsureTicket(req, res) {
|
|
428
|
+
const auth = await verifyAgentAuth(req);
|
|
429
|
+
if (auth.error) {
|
|
430
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
431
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let body = '';
|
|
436
|
+
req.on('data', chunk => body += chunk);
|
|
437
|
+
req.on('end', async () => {
|
|
438
|
+
try {
|
|
439
|
+
const { github_repo, github_issue_number, github_project_item_id, instance } = JSON.parse(body);
|
|
440
|
+
|
|
441
|
+
if (!github_repo || !github_issue_number) {
|
|
442
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
443
|
+
res.end(JSON.stringify({ error: 'github_repo and github_issue_number are required' }));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Support multi-agent: use instance ID if provided
|
|
448
|
+
const decoded = deviceFlow.verifyAgentToken(
|
|
449
|
+
req.headers.authorization.substring(7)
|
|
450
|
+
);
|
|
451
|
+
const effectiveId = instance
|
|
452
|
+
? `${decoded.agent_id}-${instance}`
|
|
453
|
+
: auth.agent.id;
|
|
454
|
+
|
|
455
|
+
const ticket = await db.createTicket({
|
|
456
|
+
issueNumber: github_issue_number,
|
|
457
|
+
repo: github_repo,
|
|
458
|
+
projectItemId: github_project_item_id || null,
|
|
459
|
+
priority: 5,
|
|
460
|
+
projectId: auth.agent.project_id || null
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Try to claim atomically — returns null if another agent already has it
|
|
464
|
+
let assigned = await db.assignTicket(ticket.id, effectiveId);
|
|
465
|
+
|
|
466
|
+
if (!assigned) {
|
|
467
|
+
// If the assigned agent is stale (no heartbeat in 2 min), reclaim
|
|
468
|
+
assigned = await db.reassignStaleTicket(ticket.id, effectiveId, 2);
|
|
469
|
+
if (!assigned) {
|
|
470
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
471
|
+
res.end(JSON.stringify({ ticket, claimed: false }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
477
|
+
res.end(JSON.stringify({ ticket: assigned, claimed: true }));
|
|
478
|
+
} catch (error) {
|
|
479
|
+
console.error('Ensure ticket error:', error);
|
|
480
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
481
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* GET /api/agent/should-stop?ticket_id=X
|
|
488
|
+
* Check if a stop was requested for a ticket
|
|
489
|
+
*/
|
|
490
|
+
async function handleShouldStop(req, res) {
|
|
491
|
+
const auth = await verifyAgentAuth(req);
|
|
492
|
+
if (auth.error) {
|
|
493
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
494
|
+
res.end(JSON.stringify({ error: auth.error }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
499
|
+
const ticketId = urlObj.searchParams.get('ticket_id');
|
|
500
|
+
|
|
501
|
+
if (!ticketId) {
|
|
502
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
503
|
+
res.end(JSON.stringify({ error: 'ticket_id is required' }));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const stopSignal = await redisLogs.get(`agentdev:stop:${ticketId}`);
|
|
509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
510
|
+
res.end(JSON.stringify({ should_stop: !!stopSignal }));
|
|
511
|
+
} catch (error) {
|
|
512
|
+
console.error('Should-stop check error:', error);
|
|
513
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
514
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
module.exports = {
|
|
519
|
+
events,
|
|
520
|
+
verifyAgentAuth,
|
|
521
|
+
handleDeviceCodeRequest,
|
|
522
|
+
handleDeviceTokenPoll,
|
|
523
|
+
handleHeartbeat,
|
|
524
|
+
handleWorkRequest,
|
|
525
|
+
handleLogUpload,
|
|
526
|
+
handleWorkComplete,
|
|
527
|
+
handleOAuthRequest,
|
|
528
|
+
handleShouldStop,
|
|
529
|
+
handleEnsureTicket
|
|
530
|
+
};
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const config = require('./config');
|
|
3
|
+
const db = require('./database');
|
|
4
|
+
|
|
5
|
+
// In-memory session store
|
|
6
|
+
const sessions = new Map();
|
|
7
|
+
|
|
8
|
+
function generateSessionId() {
|
|
9
|
+
return crypto.randomBytes(32).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hashPassword(password) {
|
|
13
|
+
return crypto.createHash('sha256').update(password).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createSession(userId, email) {
|
|
17
|
+
const sessionId = generateSessionId();
|
|
18
|
+
const session = {
|
|
19
|
+
userId,
|
|
20
|
+
email,
|
|
21
|
+
createdAt: Date.now(),
|
|
22
|
+
expiresAt: Date.now() + config.AUTH.SESSION_TTL
|
|
23
|
+
};
|
|
24
|
+
sessions.set(sessionId, session);
|
|
25
|
+
return sessionId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateSession(sessionId) {
|
|
29
|
+
if (!sessionId) return false;
|
|
30
|
+
const session = sessions.get(sessionId);
|
|
31
|
+
if (!session) return false;
|
|
32
|
+
if (Date.now() > session.expiresAt) {
|
|
33
|
+
sessions.delete(sessionId);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getSession(sessionId) {
|
|
40
|
+
return sessions.get(sessionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function destroySession(sessionId) {
|
|
44
|
+
sessions.delete(sessionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function validateCredentials(email, password) {
|
|
48
|
+
try {
|
|
49
|
+
const passwordHash = hashPassword(password);
|
|
50
|
+
const user = await db.getUserByEmail(email);
|
|
51
|
+
|
|
52
|
+
if (!user) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (user.password_hash === passwordHash) {
|
|
57
|
+
return user;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('Error validating credentials:', error);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseCookies(cookieHeader) {
|
|
68
|
+
const cookies = {};
|
|
69
|
+
if (!cookieHeader) return cookies;
|
|
70
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
71
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
72
|
+
if (name && rest.length) {
|
|
73
|
+
cookies[name] = rest.join('=');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return cookies;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getSessionFromRequest(req) {
|
|
80
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
81
|
+
return cookies.session;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isAuthenticated(req) {
|
|
85
|
+
const sessionId = getSessionFromRequest(req);
|
|
86
|
+
return validateSession(sessionId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Public paths that don't require authentication
|
|
90
|
+
const PUBLIC_PATHS = ['/login', '/login.html', '/register', '/register.html', '/reset-password', '/reset-password.html', '/verify-email', '/verify-email.html', '/docs', '/docs.html', '/docs.md', '/css/styles.css', '/manifest.json', '/sw.js', '/icon-192.png', '/icon-512.png', '/favicon.svg', '/favicon.ico'];
|
|
91
|
+
|
|
92
|
+
function requireAuth(req, res) {
|
|
93
|
+
const url = req.url.split('?')[0];
|
|
94
|
+
|
|
95
|
+
// Allow public paths
|
|
96
|
+
if (PUBLIC_PATHS.includes(url)) {
|
|
97
|
+
return false; // Don't require auth
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check if authenticated
|
|
101
|
+
if (isAuthenticated(req)) {
|
|
102
|
+
return false; // Don't require auth (already authenticated)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Redirect to login for HTML requests, return 401 for API requests
|
|
106
|
+
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
107
|
+
res.writeHead(302, { 'Location': '/login' });
|
|
108
|
+
res.end();
|
|
109
|
+
} else {
|
|
110
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
111
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return true; // Auth required but not authenticated
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
createSession,
|
|
119
|
+
validateSession,
|
|
120
|
+
getSession,
|
|
121
|
+
destroySession,
|
|
122
|
+
validateCredentials,
|
|
123
|
+
getSessionFromRequest,
|
|
124
|
+
isAuthenticated,
|
|
125
|
+
requireAuth,
|
|
126
|
+
hashPassword
|
|
127
|
+
};
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
PORT: process.env.PORT || 3847,
|
|
6
|
+
LOG_FILE: '/var/log/auto-ticket.log',
|
|
7
|
+
AGENT_LOGS_DIR: path.join(__dirname, '..', 'agent-logs'),
|
|
8
|
+
HISTORY_FILE: path.join(__dirname, '..', 'agent-history.json'),
|
|
9
|
+
MAX_HISTORY: 100,
|
|
10
|
+
CACHE_TTL: 60000, // 1 minute
|
|
11
|
+
|
|
12
|
+
// Redis configuration (dedicated instance)
|
|
13
|
+
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
|
|
14
|
+
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379'),
|
|
15
|
+
REDIS_DB: parseInt(process.env.REDIS_DB || '0'),
|
|
16
|
+
|
|
17
|
+
// PostgreSQL configuration
|
|
18
|
+
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://agentdev:agentdev_secure_password_change_me@localhost:6432/agentdev',
|
|
19
|
+
|
|
20
|
+
// JWT configuration for agent tokens
|
|
21
|
+
JWT_SECRET: process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex'),
|
|
22
|
+
JWT_EXPIRES_IN: '90d', // Agent tokens valid for 90 days
|
|
23
|
+
|
|
24
|
+
// Encryption key for OAuth secrets
|
|
25
|
+
ENCRYPTION_KEY: process.env.ENCRYPTION_SECRET_KEY || crypto.randomBytes(32).toString('hex'),
|
|
26
|
+
|
|
27
|
+
// GitHub project configuration
|
|
28
|
+
GITHUB_ORG: 'data-tamer',
|
|
29
|
+
PROJECT_NUMBER: 1,
|
|
30
|
+
PROJECT_ID: 'PVT_kwDOCJIWbs4AnuSZ',
|
|
31
|
+
STATUS_FIELD_ID: 'PVTSSF_lADOCJIWbs4AnuSZzgfaWGs',
|
|
32
|
+
STATUS_OPTIONS: {
|
|
33
|
+
TODO: 'f75ad846',
|
|
34
|
+
IN_PROGRESS: '47fc9ee4',
|
|
35
|
+
TEST: 'c48bc058',
|
|
36
|
+
DONE: '98236657'
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Authentication configuration
|
|
40
|
+
AUTH: {
|
|
41
|
+
USERNAME: 'riccardo.ravaro@datatamer.ai',
|
|
42
|
+
PASSWORD: 'Nettuno1999',
|
|
43
|
+
SESSION_SECRET: crypto.randomBytes(32).toString('hex'),
|
|
44
|
+
SESSION_TTL: 24 * 60 * 60 * 1000 // 24 hours
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Device flow configuration
|
|
48
|
+
DEVICE_CODE_TTL: 600, // 10 minutes
|
|
49
|
+
DEVICE_CODE_POLL_INTERVAL: 5, // 5 seconds
|
|
50
|
+
|
|
51
|
+
// Base URL for device authorization
|
|
52
|
+
BASE_URL: process.env.BASE_URL || 'http://localhost:3847'
|
|
53
|
+
};
|