@owloops/browserbird 1.0.2 → 1.0.4
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/bin/browserbird +7 -1
- package/dist/db-BsYEYsul.mjs +1011 -0
- package/dist/index.mjs +4749 -0
- package/package.json +6 -3
- package/src/channel/blocks.ts +0 -485
- package/src/channel/coalesce.ts +0 -79
- package/src/channel/commands.ts +0 -216
- package/src/channel/handler.ts +0 -272
- package/src/channel/slack.ts +0 -573
- package/src/channel/types.ts +0 -59
- package/src/cli/banner.ts +0 -10
- package/src/cli/birds.ts +0 -396
- package/src/cli/config.ts +0 -77
- package/src/cli/doctor.ts +0 -63
- package/src/cli/index.ts +0 -5
- package/src/cli/jobs.ts +0 -166
- package/src/cli/logs.ts +0 -67
- package/src/cli/run.ts +0 -148
- package/src/cli/sessions.ts +0 -158
- package/src/cli/style.ts +0 -19
- package/src/config.ts +0 -291
- package/src/core/logger.ts +0 -78
- package/src/core/redact.ts +0 -75
- package/src/core/types.ts +0 -83
- package/src/core/uid.ts +0 -26
- package/src/core/utils.ts +0 -137
- package/src/cron/parse.ts +0 -146
- package/src/cron/scheduler.ts +0 -242
- package/src/daemon.ts +0 -169
- package/src/db/auth.ts +0 -49
- package/src/db/birds.ts +0 -357
- package/src/db/core.ts +0 -377
- package/src/db/index.ts +0 -10
- package/src/db/jobs.ts +0 -289
- package/src/db/logs.ts +0 -64
- package/src/db/messages.ts +0 -79
- package/src/db/path.ts +0 -30
- package/src/db/sessions.ts +0 -165
- package/src/jobs.ts +0 -140
- package/src/provider/claude.test.ts +0 -95
- package/src/provider/claude.ts +0 -196
- package/src/provider/opencode.test.ts +0 -169
- package/src/provider/opencode.ts +0 -248
- package/src/provider/session.ts +0 -65
- package/src/provider/spawn.ts +0 -173
- package/src/provider/stream.ts +0 -67
- package/src/provider/types.ts +0 -24
- package/src/server/auth.ts +0 -135
- package/src/server/health.ts +0 -87
- package/src/server/http.ts +0 -132
- package/src/server/index.ts +0 -6
- package/src/server/lifecycle.ts +0 -135
- package/src/server/routes.ts +0 -1199
- package/src/server/sse.ts +0 -54
- package/src/server/static.ts +0 -45
- package/src/server/vnc-proxy.ts +0 -75
package/src/server/routes.ts
DELETED
|
@@ -1,1199 +0,0 @@
|
|
|
1
|
-
/** @fileoverview API route definitions. */
|
|
2
|
-
|
|
3
|
-
import type { Config, AgentConfig } from '../core/types.ts';
|
|
4
|
-
import type { Route, WebServerDeps } from './http.ts';
|
|
5
|
-
import {
|
|
6
|
-
pathToRegex,
|
|
7
|
-
json,
|
|
8
|
-
jsonError,
|
|
9
|
-
parsePagination,
|
|
10
|
-
parseSystemFlag,
|
|
11
|
-
parseSortParam,
|
|
12
|
-
parseSearchParam,
|
|
13
|
-
readJsonBody,
|
|
14
|
-
} from './http.ts';
|
|
15
|
-
import { broadcastSSE } from './sse.ts';
|
|
16
|
-
import { resolveByUid } from '../db/core.ts';
|
|
17
|
-
import type { CronJobRow } from '../db/birds.ts';
|
|
18
|
-
import {
|
|
19
|
-
SYSTEM_CRON_PREFIX,
|
|
20
|
-
listSessions,
|
|
21
|
-
getSession,
|
|
22
|
-
getSessionMessages,
|
|
23
|
-
getSessionTokenStats,
|
|
24
|
-
getSessionCount,
|
|
25
|
-
listJobs,
|
|
26
|
-
getJobStats,
|
|
27
|
-
listCronJobs,
|
|
28
|
-
listFlights,
|
|
29
|
-
getMessageStats,
|
|
30
|
-
getFlightStats,
|
|
31
|
-
getRecentLogs,
|
|
32
|
-
retryJob,
|
|
33
|
-
retryAllFailedJobs,
|
|
34
|
-
deleteJob,
|
|
35
|
-
clearJobs,
|
|
36
|
-
setCronJobEnabled,
|
|
37
|
-
createCronJob,
|
|
38
|
-
updateCronJob,
|
|
39
|
-
deleteCronJob,
|
|
40
|
-
getEnabledCronJobs,
|
|
41
|
-
getUserCount,
|
|
42
|
-
getUserByEmail,
|
|
43
|
-
createUser,
|
|
44
|
-
getSetting,
|
|
45
|
-
setSetting,
|
|
46
|
-
} from '../db/index.ts';
|
|
47
|
-
import {
|
|
48
|
-
hashPassword,
|
|
49
|
-
verifyPassword,
|
|
50
|
-
generateTokenKey,
|
|
51
|
-
getOrCreateSecret,
|
|
52
|
-
signToken,
|
|
53
|
-
} from './auth.ts';
|
|
54
|
-
import { enqueue } from '../jobs.ts';
|
|
55
|
-
import { deriveBirdName } from '../core/utils.ts';
|
|
56
|
-
import { checkDoctor } from '../cli/index.ts';
|
|
57
|
-
import { probeBrowser } from './health.ts';
|
|
58
|
-
import { parseCron, nextCronMatch } from '../cron/parse.ts';
|
|
59
|
-
import {
|
|
60
|
-
DEFAULTS,
|
|
61
|
-
loadRawConfig,
|
|
62
|
-
saveConfig,
|
|
63
|
-
saveEnvFile,
|
|
64
|
-
loadDotEnv,
|
|
65
|
-
deepMerge,
|
|
66
|
-
} from '../config.ts';
|
|
67
|
-
import { resolve } from 'node:path';
|
|
68
|
-
|
|
69
|
-
export function buildStatusPayload(
|
|
70
|
-
getConfig: () => Config,
|
|
71
|
-
startedAt: number,
|
|
72
|
-
getDeps: () => WebServerDeps,
|
|
73
|
-
): object {
|
|
74
|
-
const config = getConfig();
|
|
75
|
-
const deps = getDeps();
|
|
76
|
-
const jobs = getJobStats();
|
|
77
|
-
const flights = getFlightStats();
|
|
78
|
-
const messages = getMessageStats();
|
|
79
|
-
const health = deps.serviceHealth();
|
|
80
|
-
return {
|
|
81
|
-
uptime: Date.now() - startedAt,
|
|
82
|
-
processes: {
|
|
83
|
-
active: deps.activeProcessCount(),
|
|
84
|
-
maxConcurrent: config.sessions.maxConcurrent,
|
|
85
|
-
},
|
|
86
|
-
jobs,
|
|
87
|
-
flights,
|
|
88
|
-
messages,
|
|
89
|
-
sessions: { total: getSessionCount() },
|
|
90
|
-
web: { enabled: config.web.enabled, port: config.web.port },
|
|
91
|
-
agent: health.agent,
|
|
92
|
-
browser: { enabled: config.browser.enabled, connected: health.browser.connected },
|
|
93
|
-
slack: { connected: deps.slackConnected() },
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface RouteOptions {
|
|
98
|
-
configPath: string;
|
|
99
|
-
onLaunch: () => Promise<void>;
|
|
100
|
-
onConfigReload: () => void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function resolveBirdParam(
|
|
104
|
-
params: Record<string, string>,
|
|
105
|
-
res: import('node:http').ServerResponse,
|
|
106
|
-
): CronJobRow | null {
|
|
107
|
-
const uid = params['id'];
|
|
108
|
-
if (!uid) {
|
|
109
|
-
jsonError(res, 'Missing bird ID', 400);
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
const result = resolveByUid<CronJobRow>('cron_jobs', uid);
|
|
113
|
-
if (!result) {
|
|
114
|
-
jsonError(res, `Bird ${uid} not found`, 404);
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
if ('ambiguous' in result) {
|
|
118
|
-
jsonError(
|
|
119
|
-
res,
|
|
120
|
-
`Ambiguous bird ID "${uid}" matches ${result.count} birds. Use a longer prefix.`,
|
|
121
|
-
400,
|
|
122
|
-
);
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
return result.row;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const VALID_PROVIDERS = new Set(['claude', 'opencode']);
|
|
129
|
-
|
|
130
|
-
function maskSecret(value: string | undefined): { set: boolean; hint: string } {
|
|
131
|
-
if (!value) return { set: false, hint: '' };
|
|
132
|
-
const prefixes = ['xoxb-', 'xapp-', 'sk-ant-api', 'sk-ant-oat'];
|
|
133
|
-
const prefix = prefixes.find((p) => value.startsWith(p)) ?? '';
|
|
134
|
-
const tail = value.length > 4 ? value.slice(-4) : '';
|
|
135
|
-
return { set: true, hint: prefix ? `${prefix}...${tail}` : `...${tail}` };
|
|
136
|
-
}
|
|
137
|
-
const HH_MM_RE = /^\d{2}:\d{2}$/;
|
|
138
|
-
|
|
139
|
-
const ALLOWED_TOP_LEVEL_KEYS = new Set([
|
|
140
|
-
'timezone',
|
|
141
|
-
'agents',
|
|
142
|
-
'sessions',
|
|
143
|
-
'slack',
|
|
144
|
-
'birds',
|
|
145
|
-
'browser',
|
|
146
|
-
'database',
|
|
147
|
-
]);
|
|
148
|
-
|
|
149
|
-
function sanitizeConfig(config: Config): object {
|
|
150
|
-
return {
|
|
151
|
-
timezone: config.timezone,
|
|
152
|
-
agents: config.agents.map((a: AgentConfig) => ({
|
|
153
|
-
id: a.id,
|
|
154
|
-
name: a.name,
|
|
155
|
-
provider: a.provider,
|
|
156
|
-
model: a.model,
|
|
157
|
-
fallbackModel: a.fallbackModel ?? null,
|
|
158
|
-
maxTurns: a.maxTurns,
|
|
159
|
-
systemPrompt: a.systemPrompt,
|
|
160
|
-
channels: a.channels,
|
|
161
|
-
})),
|
|
162
|
-
sessions: {
|
|
163
|
-
ttlHours: config.sessions.ttlHours,
|
|
164
|
-
maxConcurrent: config.sessions.maxConcurrent,
|
|
165
|
-
processTimeoutMs: config.sessions.processTimeoutMs,
|
|
166
|
-
},
|
|
167
|
-
slack: {
|
|
168
|
-
requireMention: config.slack.requireMention,
|
|
169
|
-
coalesce: config.slack.coalesce,
|
|
170
|
-
channels: config.slack.channels,
|
|
171
|
-
quietHours: config.slack.quietHours,
|
|
172
|
-
},
|
|
173
|
-
birds: config.birds,
|
|
174
|
-
browser: {
|
|
175
|
-
enabled: config.browser.enabled,
|
|
176
|
-
mode: process.env['BROWSER_MODE'] ?? 'persistent',
|
|
177
|
-
novncHost: config.browser.novncHost,
|
|
178
|
-
vncPort: config.browser.vncPort,
|
|
179
|
-
novncPort: config.browser.novncPort,
|
|
180
|
-
},
|
|
181
|
-
database: config.database,
|
|
182
|
-
web: { port: config.web.port },
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function validateConfigPatch(body: Record<string, unknown>): string | null {
|
|
187
|
-
for (const key of Object.keys(body)) {
|
|
188
|
-
if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
|
|
189
|
-
return `Unknown config key "${key}"`;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if ('timezone' in body && typeof body['timezone'] !== 'string') {
|
|
194
|
-
return '"timezone" must be a string';
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if ('agents' in body) {
|
|
198
|
-
const agents = body['agents'];
|
|
199
|
-
if (!Array.isArray(agents) || agents.length === 0) {
|
|
200
|
-
return '"agents" must be a non-empty array';
|
|
201
|
-
}
|
|
202
|
-
for (const a of agents as Record<string, unknown>[]) {
|
|
203
|
-
if (!a['id'] || typeof a['id'] !== 'string') return 'Each agent must have a string "id"';
|
|
204
|
-
if (!a['name'] || typeof a['name'] !== 'string')
|
|
205
|
-
return 'Each agent must have a string "name"';
|
|
206
|
-
if (!a['provider'] || !VALID_PROVIDERS.has(a['provider'] as string)) {
|
|
207
|
-
return `Agent "${a['id']}": invalid provider (expected: ${[...VALID_PROVIDERS].join(', ')})`;
|
|
208
|
-
}
|
|
209
|
-
if (!a['model'] || typeof a['model'] !== 'string') {
|
|
210
|
-
return `Agent "${a['id']}": "model" is required`;
|
|
211
|
-
}
|
|
212
|
-
if (!Array.isArray(a['channels']) || (a['channels'] as unknown[]).length === 0) {
|
|
213
|
-
return `Agent "${a['id']}": "channels" must be a non-empty array`;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if ('sessions' in body) {
|
|
219
|
-
const s = body['sessions'] as Record<string, unknown>;
|
|
220
|
-
if (typeof s !== 'object' || s == null) return '"sessions" must be an object';
|
|
221
|
-
for (const k of ['ttlHours', 'maxConcurrent', 'processTimeoutMs'] as const) {
|
|
222
|
-
if (k in s && (typeof s[k] !== 'number' || (s[k] as number) <= 0)) {
|
|
223
|
-
return `"sessions.${k}" must be a positive number`;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if ('slack' in body) {
|
|
229
|
-
const sl = body['slack'] as Record<string, unknown>;
|
|
230
|
-
if (typeof sl !== 'object' || sl == null) return '"slack" must be an object';
|
|
231
|
-
if ('requireMention' in sl && typeof sl['requireMention'] !== 'boolean') {
|
|
232
|
-
return '"slack.requireMention" must be a boolean';
|
|
233
|
-
}
|
|
234
|
-
if ('channels' in sl) {
|
|
235
|
-
if (!Array.isArray(sl['channels'])) return '"slack.channels" must be an array';
|
|
236
|
-
}
|
|
237
|
-
if ('coalesce' in sl) {
|
|
238
|
-
const c = sl['coalesce'] as Record<string, unknown>;
|
|
239
|
-
if (typeof c !== 'object' || c == null) return '"slack.coalesce" must be an object';
|
|
240
|
-
if (
|
|
241
|
-
'debounceMs' in c &&
|
|
242
|
-
(typeof c['debounceMs'] !== 'number' || (c['debounceMs'] as number) <= 0)
|
|
243
|
-
) {
|
|
244
|
-
return '"slack.coalesce.debounceMs" must be a positive number';
|
|
245
|
-
}
|
|
246
|
-
if ('bypassDms' in c && typeof c['bypassDms'] !== 'boolean') {
|
|
247
|
-
return '"slack.coalesce.bypassDms" must be a boolean';
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
if ('quietHours' in sl) {
|
|
251
|
-
const q = sl['quietHours'] as Record<string, unknown>;
|
|
252
|
-
if (typeof q !== 'object' || q == null) return '"slack.quietHours" must be an object';
|
|
253
|
-
if ('enabled' in q && typeof q['enabled'] !== 'boolean') {
|
|
254
|
-
return '"slack.quietHours.enabled" must be a boolean';
|
|
255
|
-
}
|
|
256
|
-
if (
|
|
257
|
-
'start' in q &&
|
|
258
|
-
(typeof q['start'] !== 'string' || !HH_MM_RE.test(q['start'] as string))
|
|
259
|
-
) {
|
|
260
|
-
return '"slack.quietHours.start" must be HH:MM format';
|
|
261
|
-
}
|
|
262
|
-
if ('end' in q && (typeof q['end'] !== 'string' || !HH_MM_RE.test(q['end'] as string))) {
|
|
263
|
-
return '"slack.quietHours.end" must be HH:MM format';
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if ('birds' in body) {
|
|
269
|
-
const b = body['birds'] as Record<string, unknown>;
|
|
270
|
-
if (typeof b !== 'object' || b == null) return '"birds" must be an object';
|
|
271
|
-
if (
|
|
272
|
-
'maxAttempts' in b &&
|
|
273
|
-
(!Number.isInteger(b['maxAttempts']) || (b['maxAttempts'] as number) <= 0)
|
|
274
|
-
) {
|
|
275
|
-
return '"birds.maxAttempts" must be a positive integer';
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if ('browser' in body) {
|
|
280
|
-
const br = body['browser'] as Record<string, unknown>;
|
|
281
|
-
if (typeof br !== 'object' || br == null) return '"browser" must be an object';
|
|
282
|
-
if ('enabled' in br && typeof br['enabled'] !== 'boolean') {
|
|
283
|
-
return '"browser.enabled" must be a boolean';
|
|
284
|
-
}
|
|
285
|
-
if (
|
|
286
|
-
'novncHost' in br &&
|
|
287
|
-
(typeof br['novncHost'] !== 'string' || !(br['novncHost'] as string).trim())
|
|
288
|
-
) {
|
|
289
|
-
return '"browser.novncHost" must be a non-empty string';
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if ('database' in body) {
|
|
294
|
-
const d = body['database'] as Record<string, unknown>;
|
|
295
|
-
if (typeof d !== 'object' || d == null) return '"database" must be an object';
|
|
296
|
-
if (
|
|
297
|
-
'retentionDays' in d &&
|
|
298
|
-
(!Number.isInteger(d['retentionDays']) || (d['retentionDays'] as number) <= 0)
|
|
299
|
-
) {
|
|
300
|
-
return '"database.retentionDays" must be a positive integer';
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export function buildRoutes(
|
|
308
|
-
getConfig: () => Config,
|
|
309
|
-
startedAt: number,
|
|
310
|
-
getDeps: () => WebServerDeps,
|
|
311
|
-
options: RouteOptions,
|
|
312
|
-
): Route[] {
|
|
313
|
-
return [
|
|
314
|
-
{
|
|
315
|
-
method: 'GET',
|
|
316
|
-
pattern: pathToRegex('/api/auth/check'),
|
|
317
|
-
skipAuth: true,
|
|
318
|
-
handler(_req, res) {
|
|
319
|
-
const count = getUserCount();
|
|
320
|
-
json(res, {
|
|
321
|
-
setupRequired: count === 0,
|
|
322
|
-
authRequired: count > 0,
|
|
323
|
-
onboardingRequired: count > 0 && getSetting('onboarding_completed') !== 'true',
|
|
324
|
-
});
|
|
325
|
-
},
|
|
326
|
-
},
|
|
327
|
-
{
|
|
328
|
-
method: 'POST',
|
|
329
|
-
pattern: pathToRegex('/api/auth/setup'),
|
|
330
|
-
skipAuth: true,
|
|
331
|
-
async handler(req, res) {
|
|
332
|
-
if (getUserCount() > 0) {
|
|
333
|
-
jsonError(res, 'Setup already completed', 403);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
let body: { email?: string; password?: string };
|
|
337
|
-
try {
|
|
338
|
-
body = await readJsonBody(req);
|
|
339
|
-
} catch {
|
|
340
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (!body.email || typeof body.email !== 'string' || !body.email.trim()) {
|
|
344
|
-
jsonError(res, '"email" is required', 400);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (!body.password || typeof body.password !== 'string' || body.password.length < 8) {
|
|
348
|
-
jsonError(res, 'Password must be at least 8 characters', 400);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const email = body.email.trim().toLowerCase();
|
|
352
|
-
const passwordHash = await hashPassword(body.password);
|
|
353
|
-
const tokenKey = generateTokenKey();
|
|
354
|
-
const user = createUser(email, passwordHash, tokenKey);
|
|
355
|
-
const secret = getOrCreateSecret();
|
|
356
|
-
const token = signToken(user.id, tokenKey, secret);
|
|
357
|
-
json(res, { token, user: { id: user.id, email: user.email } }, 201);
|
|
358
|
-
},
|
|
359
|
-
},
|
|
360
|
-
{
|
|
361
|
-
method: 'POST',
|
|
362
|
-
pattern: pathToRegex('/api/auth/login'),
|
|
363
|
-
skipAuth: true,
|
|
364
|
-
async handler(req, res) {
|
|
365
|
-
let body: { email?: string; password?: string };
|
|
366
|
-
try {
|
|
367
|
-
body = await readJsonBody(req);
|
|
368
|
-
} catch {
|
|
369
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
if (!body.email || !body.password) {
|
|
373
|
-
jsonError(res, 'Invalid credentials', 401);
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
const user = getUserByEmail(body.email.trim());
|
|
377
|
-
if (!user) {
|
|
378
|
-
jsonError(res, 'Invalid credentials', 401);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const valid = await verifyPassword(body.password, user.password_hash);
|
|
382
|
-
if (!valid) {
|
|
383
|
-
jsonError(res, 'Invalid credentials', 401);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
const secret = getOrCreateSecret();
|
|
387
|
-
const token = signToken(user.id, user.token_key, secret);
|
|
388
|
-
json(res, { token, user: { id: user.id, email: user.email } });
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
{
|
|
392
|
-
method: 'POST',
|
|
393
|
-
pattern: pathToRegex('/api/auth/verify'),
|
|
394
|
-
handler(_req, res) {
|
|
395
|
-
json(res, { valid: true });
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
{
|
|
399
|
-
method: 'GET',
|
|
400
|
-
pattern: pathToRegex('/api/doctor'),
|
|
401
|
-
handler(_req, res) {
|
|
402
|
-
json(res, checkDoctor());
|
|
403
|
-
},
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
method: 'GET',
|
|
407
|
-
pattern: pathToRegex('/api/status'),
|
|
408
|
-
handler(_req, res) {
|
|
409
|
-
json(res, buildStatusPayload(getConfig, startedAt, getDeps));
|
|
410
|
-
},
|
|
411
|
-
},
|
|
412
|
-
{
|
|
413
|
-
method: 'GET',
|
|
414
|
-
pattern: pathToRegex('/api/config'),
|
|
415
|
-
handler(_req, res) {
|
|
416
|
-
json(res, sanitizeConfig(getConfig()));
|
|
417
|
-
},
|
|
418
|
-
},
|
|
419
|
-
{
|
|
420
|
-
method: 'PATCH',
|
|
421
|
-
pattern: pathToRegex('/api/config'),
|
|
422
|
-
async handler(req, res) {
|
|
423
|
-
let body: Record<string, unknown>;
|
|
424
|
-
try {
|
|
425
|
-
body = await readJsonBody(req);
|
|
426
|
-
} catch {
|
|
427
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const error = validateConfigPatch(body);
|
|
432
|
-
if (error) {
|
|
433
|
-
jsonError(res, error, 400);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
const raw = loadRawConfig(options.configPath);
|
|
439
|
-
const merged = deepMerge(raw, body);
|
|
440
|
-
saveConfig(options.configPath, merged);
|
|
441
|
-
options.onConfigReload();
|
|
442
|
-
broadcastSSE('invalidate', { resource: 'config' });
|
|
443
|
-
json(res, sanitizeConfig(getConfig()));
|
|
444
|
-
} catch (err) {
|
|
445
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
446
|
-
jsonError(res, `Failed to save config: ${msg}`, 500);
|
|
447
|
-
}
|
|
448
|
-
},
|
|
449
|
-
},
|
|
450
|
-
{
|
|
451
|
-
method: 'GET',
|
|
452
|
-
pattern: pathToRegex('/api/secrets'),
|
|
453
|
-
handler(_req, res) {
|
|
454
|
-
const activeAnthropicValue =
|
|
455
|
-
process.env['CLAUDE_CODE_OAUTH_TOKEN'] || process.env['ANTHROPIC_API_KEY'];
|
|
456
|
-
|
|
457
|
-
json(res, {
|
|
458
|
-
slack: {
|
|
459
|
-
botToken: maskSecret(process.env['SLACK_BOT_TOKEN']),
|
|
460
|
-
appToken: maskSecret(process.env['SLACK_APP_TOKEN']),
|
|
461
|
-
},
|
|
462
|
-
anthropic: maskSecret(activeAnthropicValue),
|
|
463
|
-
});
|
|
464
|
-
},
|
|
465
|
-
},
|
|
466
|
-
{
|
|
467
|
-
method: 'PUT',
|
|
468
|
-
pattern: pathToRegex('/api/secrets/slack'),
|
|
469
|
-
async handler(req, res) {
|
|
470
|
-
let body: { botToken?: string; appToken?: string };
|
|
471
|
-
try {
|
|
472
|
-
body = await readJsonBody(req);
|
|
473
|
-
} catch {
|
|
474
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const botToken = body.botToken?.trim();
|
|
479
|
-
const appToken = body.appToken?.trim();
|
|
480
|
-
|
|
481
|
-
if (!botToken && !appToken) {
|
|
482
|
-
jsonError(res, 'Provide "botToken" and/or "appToken"', 400);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const { WebClient } = await import('@slack/web-api');
|
|
487
|
-
const envVars: Record<string, string> = {};
|
|
488
|
-
|
|
489
|
-
if (botToken) {
|
|
490
|
-
if (!botToken.startsWith('xoxb-')) {
|
|
491
|
-
jsonError(res, 'Bot token must start with xoxb-', 400);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
try {
|
|
495
|
-
const client = new WebClient(botToken);
|
|
496
|
-
await client.auth.test();
|
|
497
|
-
} catch {
|
|
498
|
-
jsonError(res, 'Invalid bot token. Check that you copied the full xoxb- token.', 400);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
envVars['SLACK_BOT_TOKEN'] = botToken;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (appToken) {
|
|
505
|
-
if (!appToken.startsWith('xapp-')) {
|
|
506
|
-
jsonError(res, 'App token must start with xapp-', 400);
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
try {
|
|
510
|
-
const appClient = new WebClient(appToken);
|
|
511
|
-
await appClient.apiCall('apps.connections.open');
|
|
512
|
-
} catch {
|
|
513
|
-
jsonError(
|
|
514
|
-
res,
|
|
515
|
-
'Invalid app token. Check that you created an app-level token with the connections:write scope.',
|
|
516
|
-
400,
|
|
517
|
-
);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
envVars['SLACK_APP_TOKEN'] = appToken;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
try {
|
|
524
|
-
const envPath = resolve('.env');
|
|
525
|
-
saveEnvFile(envPath, envVars);
|
|
526
|
-
loadDotEnv(envPath);
|
|
527
|
-
options.onConfigReload();
|
|
528
|
-
broadcastSSE('invalidate', { resource: 'secrets' });
|
|
529
|
-
json(res, { success: true, requiresRestart: true });
|
|
530
|
-
} catch (err) {
|
|
531
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
532
|
-
jsonError(res, `Failed to save Slack tokens: ${msg}`, 500);
|
|
533
|
-
}
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
{
|
|
537
|
-
method: 'PUT',
|
|
538
|
-
pattern: pathToRegex('/api/secrets/anthropic'),
|
|
539
|
-
async handler(req, res) {
|
|
540
|
-
let body: { apiKey?: string };
|
|
541
|
-
try {
|
|
542
|
-
body = await readJsonBody(req);
|
|
543
|
-
} catch {
|
|
544
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
if (!body.apiKey || typeof body.apiKey !== 'string' || !body.apiKey.trim()) {
|
|
548
|
-
jsonError(res, '"apiKey" is required', 400);
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const key = body.apiKey.trim();
|
|
553
|
-
let envVar: string;
|
|
554
|
-
if (key.startsWith('sk-ant-oat')) {
|
|
555
|
-
envVar = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
556
|
-
} else if (key.startsWith('sk-ant-api')) {
|
|
557
|
-
envVar = 'ANTHROPIC_API_KEY';
|
|
558
|
-
} else {
|
|
559
|
-
jsonError(res, 'Invalid key. Expected an Anthropic key starting with sk-ant-...', 400);
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
try {
|
|
564
|
-
const envPath = resolve('.env');
|
|
565
|
-
saveEnvFile(envPath, { [envVar]: key });
|
|
566
|
-
loadDotEnv(envPath);
|
|
567
|
-
options.onConfigReload();
|
|
568
|
-
broadcastSSE('invalidate', { resource: 'secrets' });
|
|
569
|
-
json(res, { success: true, requiresRestart: false });
|
|
570
|
-
} catch (err) {
|
|
571
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
572
|
-
jsonError(res, `Failed to save Anthropic key: ${msg}`, 500);
|
|
573
|
-
}
|
|
574
|
-
},
|
|
575
|
-
},
|
|
576
|
-
{
|
|
577
|
-
method: 'GET',
|
|
578
|
-
pattern: pathToRegex('/api/sessions'),
|
|
579
|
-
handler(req, res) {
|
|
580
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
581
|
-
const { page, perPage } = parsePagination(url);
|
|
582
|
-
json(res, listSessions(page, perPage, parseSortParam(url), parseSearchParam(url)));
|
|
583
|
-
},
|
|
584
|
-
},
|
|
585
|
-
{
|
|
586
|
-
method: 'GET',
|
|
587
|
-
pattern: pathToRegex('/api/sessions/:id'),
|
|
588
|
-
handler(req, res, params) {
|
|
589
|
-
const uid = params['id'];
|
|
590
|
-
if (!uid) {
|
|
591
|
-
jsonError(res, 'Missing session ID', 400);
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
const session = getSession(uid);
|
|
595
|
-
if (!session) {
|
|
596
|
-
jsonError(res, `Session ${uid} not found`, 404);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
600
|
-
const { page, perPage } = parsePagination(url);
|
|
601
|
-
const messages = getSessionMessages(
|
|
602
|
-
session.channel_id,
|
|
603
|
-
session.thread_id,
|
|
604
|
-
page,
|
|
605
|
-
perPage,
|
|
606
|
-
parseSortParam(url),
|
|
607
|
-
parseSearchParam(url),
|
|
608
|
-
);
|
|
609
|
-
const stats = getSessionTokenStats(session.channel_id, session.thread_id);
|
|
610
|
-
json(res, { session, messages, stats });
|
|
611
|
-
},
|
|
612
|
-
},
|
|
613
|
-
{
|
|
614
|
-
method: 'GET',
|
|
615
|
-
pattern: pathToRegex('/api/jobs/stats'),
|
|
616
|
-
handler(_req, res) {
|
|
617
|
-
json(res, getJobStats());
|
|
618
|
-
},
|
|
619
|
-
},
|
|
620
|
-
{
|
|
621
|
-
method: 'POST',
|
|
622
|
-
pattern: pathToRegex('/api/jobs/retry-all'),
|
|
623
|
-
handler(_req, res) {
|
|
624
|
-
const count = retryAllFailedJobs();
|
|
625
|
-
json(res, { count });
|
|
626
|
-
},
|
|
627
|
-
},
|
|
628
|
-
{
|
|
629
|
-
method: 'DELETE',
|
|
630
|
-
pattern: pathToRegex('/api/jobs/clear'),
|
|
631
|
-
handler(req, res) {
|
|
632
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
633
|
-
const status = url.searchParams.get('status');
|
|
634
|
-
if (status !== 'completed' && status !== 'failed') {
|
|
635
|
-
jsonError(res, 'Query param "status" must be "completed" or "failed"', 400);
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
const count = clearJobs(status);
|
|
639
|
-
json(res, { count });
|
|
640
|
-
},
|
|
641
|
-
},
|
|
642
|
-
{
|
|
643
|
-
method: 'GET',
|
|
644
|
-
pattern: pathToRegex('/api/jobs'),
|
|
645
|
-
handler(req, res) {
|
|
646
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
647
|
-
const { page, perPage } = parsePagination(url);
|
|
648
|
-
const status = url.searchParams.get('status') ?? undefined;
|
|
649
|
-
const cronJobUid = url.searchParams.get('cronJobUid') ?? undefined;
|
|
650
|
-
const name = url.searchParams.get('name') ?? undefined;
|
|
651
|
-
json(
|
|
652
|
-
res,
|
|
653
|
-
listJobs(
|
|
654
|
-
page,
|
|
655
|
-
perPage,
|
|
656
|
-
{ status, cronJobUid, name },
|
|
657
|
-
parseSortParam(url),
|
|
658
|
-
parseSearchParam(url),
|
|
659
|
-
),
|
|
660
|
-
);
|
|
661
|
-
},
|
|
662
|
-
},
|
|
663
|
-
{
|
|
664
|
-
method: 'POST',
|
|
665
|
-
pattern: pathToRegex('/api/jobs/:id/retry'),
|
|
666
|
-
handler(_req, res, params) {
|
|
667
|
-
const id = Number(params['id']);
|
|
668
|
-
if (!Number.isFinite(id)) {
|
|
669
|
-
jsonError(res, 'Invalid job ID', 400);
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
if (retryJob(id)) {
|
|
673
|
-
json(res, { success: true });
|
|
674
|
-
} else {
|
|
675
|
-
jsonError(res, `Job #${id} not found or not in failed state`, 404);
|
|
676
|
-
}
|
|
677
|
-
},
|
|
678
|
-
},
|
|
679
|
-
{
|
|
680
|
-
method: 'DELETE',
|
|
681
|
-
pattern: pathToRegex('/api/jobs/:id'),
|
|
682
|
-
handler(_req, res, params) {
|
|
683
|
-
const id = Number(params['id']);
|
|
684
|
-
if (!Number.isFinite(id)) {
|
|
685
|
-
jsonError(res, 'Invalid job ID', 400);
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
if (deleteJob(id)) {
|
|
689
|
-
json(res, { success: true });
|
|
690
|
-
} else {
|
|
691
|
-
jsonError(res, `Job #${id} not found`, 404);
|
|
692
|
-
}
|
|
693
|
-
},
|
|
694
|
-
},
|
|
695
|
-
{
|
|
696
|
-
method: 'GET',
|
|
697
|
-
pattern: pathToRegex('/api/birds/upcoming'),
|
|
698
|
-
handler(req, res) {
|
|
699
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
700
|
-
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 5, 1), 20);
|
|
701
|
-
const now = new Date();
|
|
702
|
-
const upcoming: {
|
|
703
|
-
uid: string;
|
|
704
|
-
name: string;
|
|
705
|
-
schedule: string;
|
|
706
|
-
agent_id: string;
|
|
707
|
-
next_run: string;
|
|
708
|
-
}[] = [];
|
|
709
|
-
for (const bird of getEnabledCronJobs()) {
|
|
710
|
-
if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) continue;
|
|
711
|
-
try {
|
|
712
|
-
const schedule = parseCron(bird.schedule);
|
|
713
|
-
const next = nextCronMatch(schedule, now, bird.timezone);
|
|
714
|
-
if (next) {
|
|
715
|
-
upcoming.push({
|
|
716
|
-
uid: bird.uid,
|
|
717
|
-
name: bird.name,
|
|
718
|
-
schedule: bird.schedule,
|
|
719
|
-
agent_id: bird.agent_id,
|
|
720
|
-
next_run: next.toISOString(),
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
} catch {
|
|
724
|
-
// skip birds with invalid cron expressions
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
upcoming.sort((a, b) => a.next_run.localeCompare(b.next_run));
|
|
728
|
-
json(res, upcoming.slice(0, limit));
|
|
729
|
-
},
|
|
730
|
-
},
|
|
731
|
-
{
|
|
732
|
-
method: 'GET',
|
|
733
|
-
pattern: pathToRegex('/api/birds'),
|
|
734
|
-
handler(req, res) {
|
|
735
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
736
|
-
const { page, perPage } = parsePagination(url);
|
|
737
|
-
json(
|
|
738
|
-
res,
|
|
739
|
-
listCronJobs(
|
|
740
|
-
page,
|
|
741
|
-
perPage,
|
|
742
|
-
parseSystemFlag(url),
|
|
743
|
-
parseSortParam(url),
|
|
744
|
-
parseSearchParam(url),
|
|
745
|
-
),
|
|
746
|
-
);
|
|
747
|
-
},
|
|
748
|
-
},
|
|
749
|
-
{
|
|
750
|
-
method: 'PATCH',
|
|
751
|
-
pattern: pathToRegex('/api/birds/:id/enable'),
|
|
752
|
-
handler(_req, res, params) {
|
|
753
|
-
const bird = resolveBirdParam(params, res);
|
|
754
|
-
if (!bird) return;
|
|
755
|
-
setCronJobEnabled(bird.uid, true);
|
|
756
|
-
broadcastSSE('invalidate', { resource: 'birds' });
|
|
757
|
-
json(res, { success: true });
|
|
758
|
-
},
|
|
759
|
-
},
|
|
760
|
-
{
|
|
761
|
-
method: 'PATCH',
|
|
762
|
-
pattern: pathToRegex('/api/birds/:id/disable'),
|
|
763
|
-
handler(_req, res, params) {
|
|
764
|
-
const bird = resolveBirdParam(params, res);
|
|
765
|
-
if (!bird) return;
|
|
766
|
-
setCronJobEnabled(bird.uid, false);
|
|
767
|
-
broadcastSSE('invalidate', { resource: 'birds' });
|
|
768
|
-
json(res, { success: true });
|
|
769
|
-
},
|
|
770
|
-
},
|
|
771
|
-
{
|
|
772
|
-
method: 'POST',
|
|
773
|
-
pattern: pathToRegex('/api/birds'),
|
|
774
|
-
async handler(req, res) {
|
|
775
|
-
let body: {
|
|
776
|
-
schedule?: string;
|
|
777
|
-
prompt?: string;
|
|
778
|
-
channel?: string;
|
|
779
|
-
agent?: string;
|
|
780
|
-
timezone?: string;
|
|
781
|
-
activeHoursStart?: string;
|
|
782
|
-
activeHoursEnd?: string;
|
|
783
|
-
};
|
|
784
|
-
try {
|
|
785
|
-
body = await readJsonBody(req);
|
|
786
|
-
} catch {
|
|
787
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
if (!body.schedule || typeof body.schedule !== 'string' || !body.schedule.trim()) {
|
|
791
|
-
jsonError(res, '"schedule" is required', 400);
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
if (!body.prompt || typeof body.prompt !== 'string' || !body.prompt.trim()) {
|
|
795
|
-
jsonError(res, '"prompt" is required', 400);
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
const job = createCronJob(
|
|
799
|
-
deriveBirdName(body.prompt),
|
|
800
|
-
body.schedule.trim(),
|
|
801
|
-
body.prompt.trim(),
|
|
802
|
-
body.channel?.trim() || undefined,
|
|
803
|
-
body.agent?.trim() || undefined,
|
|
804
|
-
body.timezone?.trim() || getConfig().timezone,
|
|
805
|
-
body.activeHoursStart?.trim() || undefined,
|
|
806
|
-
body.activeHoursEnd?.trim() || undefined,
|
|
807
|
-
);
|
|
808
|
-
broadcastSSE('invalidate', { resource: 'birds' });
|
|
809
|
-
json(res, job, 201);
|
|
810
|
-
},
|
|
811
|
-
},
|
|
812
|
-
{
|
|
813
|
-
method: 'PATCH',
|
|
814
|
-
pattern: pathToRegex('/api/birds/:id'),
|
|
815
|
-
async handler(req, res, params) {
|
|
816
|
-
const bird = resolveBirdParam(params, res);
|
|
817
|
-
if (!bird) return;
|
|
818
|
-
let body: {
|
|
819
|
-
schedule?: string;
|
|
820
|
-
prompt?: string;
|
|
821
|
-
channel?: string | null;
|
|
822
|
-
agent?: string;
|
|
823
|
-
timezone?: string;
|
|
824
|
-
activeHoursStart?: string | null;
|
|
825
|
-
activeHoursEnd?: string | null;
|
|
826
|
-
};
|
|
827
|
-
try {
|
|
828
|
-
body = await readJsonBody(req);
|
|
829
|
-
} catch {
|
|
830
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
const updated = updateCronJob(bird.uid, {
|
|
834
|
-
schedule: body.schedule?.trim() || undefined,
|
|
835
|
-
prompt: body.prompt?.trim() || undefined,
|
|
836
|
-
name: body.prompt ? deriveBirdName(body.prompt) : undefined,
|
|
837
|
-
targetChannelId: body.channel !== undefined ? body.channel?.trim() || null : undefined,
|
|
838
|
-
agentId: body.agent?.trim() || undefined,
|
|
839
|
-
timezone: body.timezone?.trim() || undefined,
|
|
840
|
-
activeHoursStart:
|
|
841
|
-
body.activeHoursStart !== undefined ? body.activeHoursStart?.trim() || null : undefined,
|
|
842
|
-
activeHoursEnd:
|
|
843
|
-
body.activeHoursEnd !== undefined ? body.activeHoursEnd?.trim() || null : undefined,
|
|
844
|
-
});
|
|
845
|
-
if (updated) {
|
|
846
|
-
broadcastSSE('invalidate', { resource: 'birds' });
|
|
847
|
-
json(res, updated);
|
|
848
|
-
} else {
|
|
849
|
-
jsonError(res, `Bird ${bird.uid} not found`, 404);
|
|
850
|
-
}
|
|
851
|
-
},
|
|
852
|
-
},
|
|
853
|
-
{
|
|
854
|
-
method: 'DELETE',
|
|
855
|
-
pattern: pathToRegex('/api/birds/:id'),
|
|
856
|
-
handler(_req, res, params) {
|
|
857
|
-
const bird = resolveBirdParam(params, res);
|
|
858
|
-
if (!bird) return;
|
|
859
|
-
if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) {
|
|
860
|
-
jsonError(res, 'System birds cannot be deleted', 403);
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
deleteCronJob(bird.uid);
|
|
864
|
-
broadcastSSE('invalidate', { resource: 'birds' });
|
|
865
|
-
json(res, { success: true });
|
|
866
|
-
},
|
|
867
|
-
},
|
|
868
|
-
{
|
|
869
|
-
method: 'POST',
|
|
870
|
-
pattern: pathToRegex('/api/birds/:id/fly'),
|
|
871
|
-
handler(_req, res, params) {
|
|
872
|
-
const bird = resolveBirdParam(params, res);
|
|
873
|
-
if (!bird) return;
|
|
874
|
-
const isSystem = bird.name.startsWith(SYSTEM_CRON_PREFIX);
|
|
875
|
-
const job = isSystem
|
|
876
|
-
? enqueue(
|
|
877
|
-
'system_cron_run',
|
|
878
|
-
{ cronJobUid: bird.uid, cronName: bird.name },
|
|
879
|
-
{ maxAttempts: 3, timeout: 300, cronJobUid: bird.uid },
|
|
880
|
-
)
|
|
881
|
-
: enqueue(
|
|
882
|
-
'cron_run',
|
|
883
|
-
{
|
|
884
|
-
cronJobUid: bird.uid,
|
|
885
|
-
prompt: bird.prompt,
|
|
886
|
-
channelId: bird.target_channel_id,
|
|
887
|
-
agentId: bird.agent_id,
|
|
888
|
-
},
|
|
889
|
-
{ cronJobUid: bird.uid },
|
|
890
|
-
);
|
|
891
|
-
json(res, { success: true, jobId: job.id });
|
|
892
|
-
},
|
|
893
|
-
},
|
|
894
|
-
{
|
|
895
|
-
method: 'GET',
|
|
896
|
-
pattern: pathToRegex('/api/flights'),
|
|
897
|
-
handler(req, res) {
|
|
898
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
899
|
-
const { page, perPage } = parsePagination(url);
|
|
900
|
-
const status = url.searchParams.get('status') ?? undefined;
|
|
901
|
-
const birdUid = url.searchParams.get('birdUid') ?? undefined;
|
|
902
|
-
const system = parseSystemFlag(url);
|
|
903
|
-
json(
|
|
904
|
-
res,
|
|
905
|
-
listFlights(
|
|
906
|
-
page,
|
|
907
|
-
perPage,
|
|
908
|
-
{ status, birdUid, system },
|
|
909
|
-
parseSortParam(url),
|
|
910
|
-
parseSearchParam(url),
|
|
911
|
-
),
|
|
912
|
-
);
|
|
913
|
-
},
|
|
914
|
-
},
|
|
915
|
-
{
|
|
916
|
-
method: 'GET',
|
|
917
|
-
pattern: pathToRegex('/api/birds/:id/flights'),
|
|
918
|
-
handler(req, res, params) {
|
|
919
|
-
const bird = resolveBirdParam(params, res);
|
|
920
|
-
if (!bird) return;
|
|
921
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
922
|
-
const { page, perPage } = parsePagination(url);
|
|
923
|
-
json(
|
|
924
|
-
res,
|
|
925
|
-
listFlights(
|
|
926
|
-
page,
|
|
927
|
-
perPage,
|
|
928
|
-
{ birdUid: bird.uid, system: true },
|
|
929
|
-
parseSortParam(url),
|
|
930
|
-
parseSearchParam(url),
|
|
931
|
-
),
|
|
932
|
-
);
|
|
933
|
-
},
|
|
934
|
-
},
|
|
935
|
-
{
|
|
936
|
-
method: 'GET',
|
|
937
|
-
pattern: pathToRegex('/api/messages/stats'),
|
|
938
|
-
handler(_req, res) {
|
|
939
|
-
json(res, getMessageStats());
|
|
940
|
-
},
|
|
941
|
-
},
|
|
942
|
-
{
|
|
943
|
-
method: 'GET',
|
|
944
|
-
pattern: pathToRegex('/api/logs'),
|
|
945
|
-
handler(req, res) {
|
|
946
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
947
|
-
const { page, perPage } = parsePagination(url);
|
|
948
|
-
const level = url.searchParams.get('level') ?? undefined;
|
|
949
|
-
const source = url.searchParams.get('source') ?? undefined;
|
|
950
|
-
json(
|
|
951
|
-
res,
|
|
952
|
-
getRecentLogs(page, perPage, level, source, parseSortParam(url), parseSearchParam(url)),
|
|
953
|
-
);
|
|
954
|
-
},
|
|
955
|
-
},
|
|
956
|
-
{
|
|
957
|
-
method: 'GET',
|
|
958
|
-
pattern: pathToRegex('/api/onboarding/defaults'),
|
|
959
|
-
handler(_req, res) {
|
|
960
|
-
const doctor = checkDoctor();
|
|
961
|
-
const defaultAgent = DEFAULTS.agents[0]!;
|
|
962
|
-
json(res, {
|
|
963
|
-
agent: {
|
|
964
|
-
name: defaultAgent.name,
|
|
965
|
-
provider: defaultAgent.provider,
|
|
966
|
-
model: defaultAgent.model,
|
|
967
|
-
systemPrompt: defaultAgent.systemPrompt,
|
|
968
|
-
maxTurns: defaultAgent.maxTurns,
|
|
969
|
-
channels: defaultAgent.channels,
|
|
970
|
-
},
|
|
971
|
-
browser: {
|
|
972
|
-
enabled: DEFAULTS.browser.enabled,
|
|
973
|
-
novncHost: DEFAULTS.browser.novncHost,
|
|
974
|
-
novncPort: DEFAULTS.browser.novncPort,
|
|
975
|
-
},
|
|
976
|
-
doctor,
|
|
977
|
-
});
|
|
978
|
-
},
|
|
979
|
-
},
|
|
980
|
-
{
|
|
981
|
-
method: 'POST',
|
|
982
|
-
pattern: pathToRegex('/api/onboarding/slack'),
|
|
983
|
-
async handler(req, res) {
|
|
984
|
-
let body: { botToken?: string; appToken?: string };
|
|
985
|
-
try {
|
|
986
|
-
body = await readJsonBody(req);
|
|
987
|
-
} catch {
|
|
988
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
if (!body.botToken || typeof body.botToken !== 'string' || !body.botToken.trim()) {
|
|
992
|
-
jsonError(res, '"botToken" is required', 400);
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
if (!body.appToken || typeof body.appToken !== 'string' || !body.appToken.trim()) {
|
|
996
|
-
jsonError(res, '"appToken" is required', 400);
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const botToken = body.botToken.trim();
|
|
1001
|
-
const appToken = body.appToken.trim();
|
|
1002
|
-
|
|
1003
|
-
if (!botToken.startsWith('xoxb-')) {
|
|
1004
|
-
jsonError(res, 'Bot token must start with xoxb-', 400);
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
if (!appToken.startsWith('xapp-')) {
|
|
1008
|
-
jsonError(res, 'App token must start with xapp-', 400);
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
const { WebClient } = await import('@slack/web-api');
|
|
1013
|
-
|
|
1014
|
-
let authResult: Awaited<ReturnType<InstanceType<typeof WebClient>['auth']['test']>>;
|
|
1015
|
-
try {
|
|
1016
|
-
const client = new WebClient(botToken);
|
|
1017
|
-
authResult = await client.auth.test();
|
|
1018
|
-
} catch {
|
|
1019
|
-
jsonError(res, 'Invalid bot token. Check that you copied the full xoxb- token.', 400);
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
try {
|
|
1024
|
-
const appClient = new WebClient(appToken);
|
|
1025
|
-
await appClient.apiCall('apps.connections.open');
|
|
1026
|
-
} catch {
|
|
1027
|
-
jsonError(
|
|
1028
|
-
res,
|
|
1029
|
-
'Invalid app token. Check that you created an app-level token with the connections:write scope.',
|
|
1030
|
-
400,
|
|
1031
|
-
);
|
|
1032
|
-
return;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
try {
|
|
1036
|
-
const envPath = resolve('.env');
|
|
1037
|
-
saveEnvFile(envPath, {
|
|
1038
|
-
SLACK_BOT_TOKEN: botToken,
|
|
1039
|
-
SLACK_APP_TOKEN: appToken,
|
|
1040
|
-
});
|
|
1041
|
-
|
|
1042
|
-
const configPath = options.configPath;
|
|
1043
|
-
const raw = loadRawConfig(configPath);
|
|
1044
|
-
const slack = (raw['slack'] ?? {}) as Record<string, unknown>;
|
|
1045
|
-
slack['botToken'] = 'env:SLACK_BOT_TOKEN';
|
|
1046
|
-
slack['appToken'] = 'env:SLACK_APP_TOKEN';
|
|
1047
|
-
raw['slack'] = slack;
|
|
1048
|
-
saveConfig(configPath, raw);
|
|
1049
|
-
|
|
1050
|
-
json(res, {
|
|
1051
|
-
valid: true,
|
|
1052
|
-
team: authResult.team ?? '',
|
|
1053
|
-
botUser: authResult.user ?? '',
|
|
1054
|
-
});
|
|
1055
|
-
} catch (err) {
|
|
1056
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1057
|
-
jsonError(res, `Failed to save Slack config: ${msg}`, 500);
|
|
1058
|
-
}
|
|
1059
|
-
},
|
|
1060
|
-
},
|
|
1061
|
-
{
|
|
1062
|
-
method: 'POST',
|
|
1063
|
-
pattern: pathToRegex('/api/onboarding/agent'),
|
|
1064
|
-
async handler(req, res) {
|
|
1065
|
-
let body: {
|
|
1066
|
-
name?: string;
|
|
1067
|
-
provider?: string;
|
|
1068
|
-
model?: string;
|
|
1069
|
-
systemPrompt?: string;
|
|
1070
|
-
maxTurns?: number;
|
|
1071
|
-
channels?: string[];
|
|
1072
|
-
};
|
|
1073
|
-
try {
|
|
1074
|
-
body = await readJsonBody(req);
|
|
1075
|
-
} catch {
|
|
1076
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
if (!body.name || typeof body.name !== 'string') {
|
|
1080
|
-
jsonError(res, '"name" is required', 400);
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
if (!body.provider || typeof body.provider !== 'string') {
|
|
1084
|
-
jsonError(res, '"provider" is required', 400);
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
if (!body.model || typeof body.model !== 'string') {
|
|
1088
|
-
jsonError(res, '"model" is required', 400);
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
const configPath = options.configPath;
|
|
1093
|
-
const raw = loadRawConfig(configPath);
|
|
1094
|
-
raw['agents'] = [
|
|
1095
|
-
{
|
|
1096
|
-
id: 'default',
|
|
1097
|
-
name: body.name.trim(),
|
|
1098
|
-
provider: body.provider.trim(),
|
|
1099
|
-
model: body.model.trim(),
|
|
1100
|
-
maxTurns: body.maxTurns ?? DEFAULTS.agents[0]!.maxTurns,
|
|
1101
|
-
systemPrompt: body.systemPrompt?.trim() ?? DEFAULTS.agents[0]!.systemPrompt,
|
|
1102
|
-
channels: body.channels ?? ['*'],
|
|
1103
|
-
},
|
|
1104
|
-
];
|
|
1105
|
-
saveConfig(configPath, raw);
|
|
1106
|
-
json(res, { agents: raw['agents'] });
|
|
1107
|
-
},
|
|
1108
|
-
},
|
|
1109
|
-
{
|
|
1110
|
-
method: 'POST',
|
|
1111
|
-
pattern: pathToRegex('/api/onboarding/auth'),
|
|
1112
|
-
async handler(req, res) {
|
|
1113
|
-
let body: { apiKey?: string };
|
|
1114
|
-
try {
|
|
1115
|
-
body = await readJsonBody(req);
|
|
1116
|
-
} catch {
|
|
1117
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
if (!body.apiKey || typeof body.apiKey !== 'string' || !body.apiKey.trim()) {
|
|
1121
|
-
jsonError(res, '"apiKey" is required', 400);
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
const key = body.apiKey.trim();
|
|
1126
|
-
let envVar: string;
|
|
1127
|
-
if (key.startsWith('sk-ant-oat')) {
|
|
1128
|
-
envVar = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
1129
|
-
} else if (key.startsWith('sk-ant-api')) {
|
|
1130
|
-
envVar = 'ANTHROPIC_API_KEY';
|
|
1131
|
-
} else {
|
|
1132
|
-
jsonError(res, 'Invalid key. Expected an Anthropic key starting with sk-ant-...', 400);
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const envPath = resolve('.env');
|
|
1137
|
-
saveEnvFile(envPath, { [envVar]: key });
|
|
1138
|
-
json(res, { valid: true });
|
|
1139
|
-
},
|
|
1140
|
-
},
|
|
1141
|
-
{
|
|
1142
|
-
method: 'POST',
|
|
1143
|
-
pattern: pathToRegex('/api/onboarding/browser/probe'),
|
|
1144
|
-
async handler(req, res) {
|
|
1145
|
-
let body: { host?: string; port?: number };
|
|
1146
|
-
try {
|
|
1147
|
-
body = await readJsonBody(req);
|
|
1148
|
-
} catch {
|
|
1149
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
const host = body.host?.trim();
|
|
1153
|
-
if (!host) {
|
|
1154
|
-
jsonError(res, '"host" is required', 400);
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
const port = body.port ?? DEFAULTS.browser.novncPort;
|
|
1158
|
-
const reachable = await probeBrowser(host, port);
|
|
1159
|
-
json(res, { reachable });
|
|
1160
|
-
},
|
|
1161
|
-
},
|
|
1162
|
-
{
|
|
1163
|
-
method: 'POST',
|
|
1164
|
-
pattern: pathToRegex('/api/onboarding/browser'),
|
|
1165
|
-
async handler(req, res) {
|
|
1166
|
-
let body: { enabled?: boolean; novncHost?: string };
|
|
1167
|
-
try {
|
|
1168
|
-
body = await readJsonBody(req);
|
|
1169
|
-
} catch {
|
|
1170
|
-
jsonError(res, 'Invalid JSON body', 400);
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
const configPath = options.configPath;
|
|
1175
|
-
const raw = loadRawConfig(configPath);
|
|
1176
|
-
const browser = (raw['browser'] ?? {}) as Record<string, unknown>;
|
|
1177
|
-
if (body.enabled !== undefined) browser['enabled'] = body.enabled;
|
|
1178
|
-
if (body.novncHost !== undefined) browser['novncHost'] = body.novncHost.trim();
|
|
1179
|
-
raw['browser'] = browser;
|
|
1180
|
-
saveConfig(configPath, raw);
|
|
1181
|
-
json(res, { browser: raw['browser'] });
|
|
1182
|
-
},
|
|
1183
|
-
},
|
|
1184
|
-
{
|
|
1185
|
-
method: 'POST',
|
|
1186
|
-
pattern: pathToRegex('/api/onboarding/complete'),
|
|
1187
|
-
async handler(_req, res) {
|
|
1188
|
-
try {
|
|
1189
|
-
await options.onLaunch();
|
|
1190
|
-
setSetting('onboarding_completed', 'true');
|
|
1191
|
-
json(res, { success: true });
|
|
1192
|
-
} catch (err) {
|
|
1193
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1194
|
-
jsonError(res, `Launch failed: ${msg}`, 500);
|
|
1195
|
-
}
|
|
1196
|
-
},
|
|
1197
|
-
},
|
|
1198
|
-
];
|
|
1199
|
-
}
|