@relauts/spotcheck-service 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.
Files changed (124) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +19 -0
  3. package/README.md +169 -0
  4. package/dist/api/auth.d.ts +11 -0
  5. package/dist/api/auth.js +32 -0
  6. package/dist/api/auth.js.map +1 -0
  7. package/dist/api/history.d.ts +46 -0
  8. package/dist/api/history.js +172 -0
  9. package/dist/api/history.js.map +1 -0
  10. package/dist/api/saved-run.d.ts +35 -0
  11. package/dist/api/saved-run.js +234 -0
  12. package/dist/api/saved-run.js.map +1 -0
  13. package/dist/api/saved.d.ts +33 -0
  14. package/dist/api/saved.js +209 -0
  15. package/dist/api/saved.js.map +1 -0
  16. package/dist/api/server.d.ts +23 -0
  17. package/dist/api/server.js +852 -0
  18. package/dist/api/server.js.map +1 -0
  19. package/dist/computer-use/actions.d.ts +14 -0
  20. package/dist/computer-use/actions.js +212 -0
  21. package/dist/computer-use/actions.js.map +1 -0
  22. package/dist/computer-use/args.d.ts +11 -0
  23. package/dist/computer-use/args.js +74 -0
  24. package/dist/computer-use/args.js.map +1 -0
  25. package/dist/computer-use/cli.d.ts +1 -0
  26. package/dist/computer-use/cli.js +8 -0
  27. package/dist/computer-use/cli.js.map +1 -0
  28. package/dist/computer-use/client.d.ts +3 -0
  29. package/dist/computer-use/client.js +68 -0
  30. package/dist/computer-use/client.js.map +1 -0
  31. package/dist/computer-use/coords.d.ts +5 -0
  32. package/dist/computer-use/coords.js +21 -0
  33. package/dist/computer-use/coords.js.map +1 -0
  34. package/dist/computer-use/history.d.ts +11 -0
  35. package/dist/computer-use/history.js +59 -0
  36. package/dist/computer-use/history.js.map +1 -0
  37. package/dist/computer-use/index.d.ts +17 -0
  38. package/dist/computer-use/index.js +75 -0
  39. package/dist/computer-use/index.js.map +1 -0
  40. package/dist/computer-use/keys.d.ts +3 -0
  41. package/dist/computer-use/keys.js +50 -0
  42. package/dist/computer-use/keys.js.map +1 -0
  43. package/dist/computer-use/loop.d.ts +22 -0
  44. package/dist/computer-use/loop.js +90 -0
  45. package/dist/computer-use/loop.js.map +1 -0
  46. package/dist/computer-use/safety.d.ts +15 -0
  47. package/dist/computer-use/safety.js +45 -0
  48. package/dist/computer-use/safety.js.map +1 -0
  49. package/dist/computer-use/screenshot.d.ts +5 -0
  50. package/dist/computer-use/screenshot.js +16 -0
  51. package/dist/computer-use/screenshot.js.map +1 -0
  52. package/dist/computer-use/system-prompt.d.ts +11 -0
  53. package/dist/computer-use/system-prompt.js +41 -0
  54. package/dist/computer-use/system-prompt.js.map +1 -0
  55. package/dist/computer-use/types.d.ts +83 -0
  56. package/dist/computer-use/types.js +31 -0
  57. package/dist/computer-use/types.js.map +1 -0
  58. package/dist/playwright/actions.d.ts +81 -0
  59. package/dist/playwright/actions.js +170 -0
  60. package/dist/playwright/actions.js.map +1 -0
  61. package/dist/playwright/chromium.d.ts +10 -0
  62. package/dist/playwright/chromium.js +27 -0
  63. package/dist/playwright/chromium.js.map +1 -0
  64. package/dist/playwright/index.d.ts +2 -0
  65. package/dist/playwright/index.js +64 -0
  66. package/dist/playwright/index.js.map +1 -0
  67. package/dist/playwright/processed.d.ts +11 -0
  68. package/dist/playwright/processed.js +183 -0
  69. package/dist/playwright/processed.js.map +1 -0
  70. package/dist/playwright/session.d.ts +28 -0
  71. package/dist/playwright/session.js +165 -0
  72. package/dist/playwright/session.js.map +1 -0
  73. package/dist/shared/config.d.ts +38 -0
  74. package/dist/shared/config.js +227 -0
  75. package/dist/shared/config.js.map +1 -0
  76. package/dist/shared/cost.d.ts +15 -0
  77. package/dist/shared/cost.js +40 -0
  78. package/dist/shared/cost.js.map +1 -0
  79. package/dist/shared/logger.d.ts +4 -0
  80. package/dist/shared/logger.js +10 -0
  81. package/dist/shared/logger.js.map +1 -0
  82. package/dist/shared/main-module.d.ts +1 -0
  83. package/dist/shared/main-module.js +19 -0
  84. package/dist/shared/main-module.js.map +1 -0
  85. package/dist/shared/models.d.ts +12 -0
  86. package/dist/shared/models.js +40 -0
  87. package/dist/shared/models.js.map +1 -0
  88. package/dist/shared/telemetry.d.ts +9 -0
  89. package/dist/shared/telemetry.js +43 -0
  90. package/dist/shared/telemetry.js.map +1 -0
  91. package/files/model.json +26 -0
  92. package/files/system_prompt.txt +15 -0
  93. package/openapi.yaml +901 -0
  94. package/package.json +60 -0
  95. package/relauts-spotcheck-service-config.example.json +19 -0
  96. package/src/api/auth.ts +40 -0
  97. package/src/api/history.ts +251 -0
  98. package/src/api/saved-run.ts +346 -0
  99. package/src/api/saved.ts +275 -0
  100. package/src/api/server.ts +1198 -0
  101. package/src/computer-use/actions.ts +273 -0
  102. package/src/computer-use/args.ts +97 -0
  103. package/src/computer-use/cli.ts +8 -0
  104. package/src/computer-use/client.ts +81 -0
  105. package/src/computer-use/coords.ts +26 -0
  106. package/src/computer-use/history.ts +80 -0
  107. package/src/computer-use/index.ts +121 -0
  108. package/src/computer-use/keys.ts +54 -0
  109. package/src/computer-use/loop.ts +140 -0
  110. package/src/computer-use/safety.ts +59 -0
  111. package/src/computer-use/screenshot.ts +20 -0
  112. package/src/computer-use/system-prompt.ts +65 -0
  113. package/src/computer-use/types.ts +126 -0
  114. package/src/playwright/actions.ts +332 -0
  115. package/src/playwright/chromium.ts +30 -0
  116. package/src/playwright/index.ts +77 -0
  117. package/src/playwright/processed.ts +253 -0
  118. package/src/playwright/session.ts +220 -0
  119. package/src/shared/config.ts +320 -0
  120. package/src/shared/cost.ts +58 -0
  121. package/src/shared/logger.ts +9 -0
  122. package/src/shared/main-module.ts +20 -0
  123. package/src/shared/models.ts +62 -0
  124. package/src/shared/telemetry.ts +63 -0
@@ -0,0 +1,852 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { AgentRunError, runAgent } from "../computer-use/index.js";
6
+ import { confirmSafetyForWeb } from "../computer-use/safety.js";
7
+ import { launchLightChromium } from "../playwright/chromium.js";
8
+ import { getLatestWebpPath, getSavedRunItemScreenshotRelPaths, getSavedRunScreenshotRelPath, } from "../playwright/processed.js";
9
+ import { BrowserClosedError, createBrowserSession, } from "../playwright/session.js";
10
+ import { loadConfig, requireApiToken } from "../shared/config.js";
11
+ import { computeCostUsd, EMPTY_USAGE } from "../shared/cost.js";
12
+ import { logger } from "../shared/logger.js";
13
+ import { isMainModule } from "../shared/main-module.js";
14
+ import { assertAllowedModel, getModelEntry, loadModels, } from "../shared/models.js";
15
+ import { createAutomationReporter } from "../shared/telemetry.js";
16
+ import { isAuthorized } from "./auth.js";
17
+ import { findLatestHistory, listHistoryRuns, readHistoryFile, serializeHistoryDetailItem } from "./history.js";
18
+ import { CREATE_DEFAULT_FILE, SavedExistsError, SavedNameError, SavedNotFoundError, deleteSavedFile, ensureCreateDefaultFile, isCreateDefaultFileName, listSavedFiles, normalizeSavedFileName, overwriteSavedFile, parseCreateItems, parseSavedItems, readSavedFile, upsertSavedFile, writeSavedFile, } from "./saved.js";
19
+ import { SavedRunBusyError, SavedRunFinishedError, SavedRunLimitError, SavedRunNotRunningError, createSavedRunManager, } from "./saved-run.js";
20
+ const MAX_AGENT_BODY_BYTES = 16_384;
21
+ const MAX_CREATE_BODY_BYTES = 262_144;
22
+ class HttpError extends Error {
23
+ status;
24
+ constructor(status, message) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ const serverSessions = new WeakMap();
30
+ const serverSavedRuns = new WeakMap();
31
+ let activeServer;
32
+ let shuttingDown = false;
33
+ const pendingSafetyConfirmations = new Map();
34
+ async function loadAgentStreamExtras(session, processedDir) {
35
+ const status = session.getStatus();
36
+ let imageName = "";
37
+ try {
38
+ const filePath = await getLatestWebpPath(processedDir);
39
+ if (filePath) {
40
+ imageName = path.basename(filePath);
41
+ }
42
+ }
43
+ catch {
44
+ // Same as GET /v1/screenshot with no file: do not fail the event.
45
+ }
46
+ return {
47
+ session: { open: status.open, url: status.url },
48
+ screenshot: { imageName },
49
+ };
50
+ }
51
+ async function writeAgentEvent(response, session, processedDir, body) {
52
+ writeNdjson(response, {
53
+ ...body,
54
+ ...(await loadAgentStreamExtras(session, processedDir)),
55
+ });
56
+ }
57
+ /**
58
+ * Builds a per-request SafetyConfirmer that pushes a "safety_confirm" event down
59
+ * the open NDJSON stream and waits for POST /v1/agent/safety.
60
+ */
61
+ function createWebSafetyConfirmer(response, signal, session, processedDir) {
62
+ return async (explanation, actionName) => {
63
+ const extras = await loadAgentStreamExtras(session, processedDir);
64
+ return new Promise((resolve) => {
65
+ const id = randomUUID();
66
+ function onAbort() {
67
+ pendingSafetyConfirmations.delete(id);
68
+ resolve(false);
69
+ }
70
+ function settle(confirmed) {
71
+ if (signal.aborted) {
72
+ pendingSafetyConfirmations.delete(id);
73
+ signal.removeEventListener("abort", onAbort);
74
+ resolve(false);
75
+ return;
76
+ }
77
+ signal.removeEventListener("abort", onAbort);
78
+ resolve(confirmed);
79
+ }
80
+ pendingSafetyConfirmations.set(id, settle);
81
+ signal.addEventListener("abort", onAbort);
82
+ writeNdjson(response, {
83
+ type: "safety_confirm",
84
+ id,
85
+ action: actionName,
86
+ explanation,
87
+ ...extras,
88
+ });
89
+ });
90
+ };
91
+ }
92
+ function requestPath(request) {
93
+ return new URL(request.url ?? "/", "http://localhost").pathname;
94
+ }
95
+ function send(response, status, body, headers) {
96
+ response.writeHead(status, headers);
97
+ response.end(body);
98
+ }
99
+ function sendJson(response, status, body, extraHeaders = {}) {
100
+ send(response, status, JSON.stringify(body), {
101
+ "Content-Type": "application/json; charset=utf-8",
102
+ "Cache-Control": "no-store",
103
+ ...extraHeaders,
104
+ });
105
+ }
106
+ function sendMethodNotAllowed(response, allow, cors) {
107
+ send(response, 405, "Method not allowed", {
108
+ "Content-Type": "text/plain; charset=utf-8",
109
+ Allow: allow,
110
+ ...cors,
111
+ });
112
+ }
113
+ function beginNdjson(response, extraHeaders = {}) {
114
+ response.writeHead(200, {
115
+ "Content-Type": "application/x-ndjson; charset=utf-8",
116
+ "Cache-Control": "no-store",
117
+ "X-Accel-Buffering": "no",
118
+ ...extraHeaders,
119
+ });
120
+ response.flushHeaders();
121
+ }
122
+ function writeNdjson(response, body) {
123
+ if (response.writableEnded || response.destroyed) {
124
+ return;
125
+ }
126
+ response.write(`${JSON.stringify(body)}\n`);
127
+ }
128
+ function endNdjson(response) {
129
+ if (!response.writableEnded && !response.destroyed) {
130
+ response.end();
131
+ }
132
+ }
133
+ function errorMessage(error) {
134
+ return error instanceof Error ? error.message : String(error);
135
+ }
136
+ function corsHeadersFor(request, allowedOrigins) {
137
+ const origin = request.headers.origin;
138
+ if (!origin || !allowedOrigins.includes(origin)) {
139
+ return {};
140
+ }
141
+ return {
142
+ "Access-Control-Allow-Origin": origin,
143
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
144
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
145
+ "Access-Control-Max-Age": "86400",
146
+ Vary: "Origin",
147
+ };
148
+ }
149
+ function applyCors(request, response, allowedOrigins) {
150
+ const headers = corsHeadersFor(request, allowedOrigins);
151
+ for (const [key, value] of Object.entries(headers)) {
152
+ if (value !== undefined) {
153
+ response.setHeader(key, value);
154
+ }
155
+ }
156
+ return headers;
157
+ }
158
+ async function readJsonBody(request, maxBytes = MAX_AGENT_BODY_BYTES) {
159
+ const chunks = [];
160
+ let size = 0;
161
+ for await (const chunk of request) {
162
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
163
+ size += buffer.length;
164
+ if (size > maxBytes) {
165
+ request.destroy();
166
+ throw new HttpError(413, "Request body too large");
167
+ }
168
+ chunks.push(buffer);
169
+ }
170
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
171
+ if (!raw) {
172
+ throw new HttpError(400, "Request body is required");
173
+ }
174
+ try {
175
+ return JSON.parse(raw);
176
+ }
177
+ catch {
178
+ throw new HttpError(400, "Body must be valid JSON");
179
+ }
180
+ }
181
+ function parseSavedItemsBody(body, models) {
182
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
183
+ throw new HttpError(400, "Body must be a JSON object");
184
+ }
185
+ try {
186
+ return parseSavedItems(body.items).map((item) => ({
187
+ ...item,
188
+ model: assertAllowedModel(item.model, models),
189
+ }));
190
+ }
191
+ catch (error) {
192
+ throw new HttpError(400, errorMessage(error));
193
+ }
194
+ }
195
+ function parseSavedRequest(body, models) {
196
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
197
+ throw new HttpError(400, "Body must be a JSON object");
198
+ }
199
+ const payload = body;
200
+ if (typeof payload.fileName !== "string" || !payload.fileName.trim()) {
201
+ throw new HttpError(400, "fileName is required");
202
+ }
203
+ const items = parseSavedItemsBody(body, models);
204
+ return { fileName: payload.fileName.trim(), items };
205
+ }
206
+ async function handleSavedList(request, response, savedDir, cors) {
207
+ if (request.method !== "GET") {
208
+ sendMethodNotAllowed(response, "GET, POST", cors);
209
+ return;
210
+ }
211
+ const files = (await listSavedFiles(savedDir)).filter((name) => name.toLowerCase() !== CREATE_DEFAULT_FILE);
212
+ sendJson(response, 200, { files }, cors);
213
+ }
214
+ async function handleSavedItem(request, response, pathname, savedDir, cors) {
215
+ const rawName = decodeURIComponent(pathname.slice("/v1/saved/".length));
216
+ if (request.method === "GET") {
217
+ try {
218
+ sendJson(response, 200, await readSavedFile(rawName, savedDir), cors);
219
+ }
220
+ catch (error) {
221
+ if (error instanceof SavedNotFoundError) {
222
+ throw new HttpError(404, error.message);
223
+ }
224
+ if (error instanceof SavedNameError) {
225
+ throw new HttpError(400, error.message);
226
+ }
227
+ throw error;
228
+ }
229
+ return;
230
+ }
231
+ if (request.method === "PUT") {
232
+ const models = await loadModels();
233
+ const items = parseSavedItemsBody(await readJsonBody(request), models);
234
+ try {
235
+ const name = await overwriteSavedFile(rawName, items, savedDir);
236
+ sendJson(response, 200, { fileName: name }, cors);
237
+ }
238
+ catch (error) {
239
+ if (error instanceof SavedNotFoundError) {
240
+ throw new HttpError(404, error.message);
241
+ }
242
+ if (error instanceof SavedNameError) {
243
+ throw new HttpError(400, error.message);
244
+ }
245
+ throw error;
246
+ }
247
+ return;
248
+ }
249
+ if (request.method === "DELETE") {
250
+ try {
251
+ const name = await deleteSavedFile(rawName, savedDir);
252
+ sendJson(response, 200, { fileName: name }, cors);
253
+ }
254
+ catch (error) {
255
+ if (error instanceof SavedNotFoundError) {
256
+ throw new HttpError(404, error.message);
257
+ }
258
+ if (error instanceof SavedNameError) {
259
+ throw new HttpError(400, error.message);
260
+ }
261
+ throw error;
262
+ }
263
+ return;
264
+ }
265
+ sendMethodNotAllowed(response, "GET, PUT, DELETE", cors);
266
+ }
267
+ async function handleSavedCreate(request, response, savedDir, cors) {
268
+ if (request.method !== "POST") {
269
+ sendMethodNotAllowed(response, "GET, POST", cors);
270
+ return;
271
+ }
272
+ const models = await loadModels();
273
+ const { fileName, items } = parseSavedRequest(await readJsonBody(request), models);
274
+ try {
275
+ if (isCreateDefaultFileName(fileName)) {
276
+ throw new SavedNameError(`${CREATE_DEFAULT_FILE} is reserved`);
277
+ }
278
+ const name = await writeSavedFile(fileName, items, savedDir);
279
+ sendJson(response, 201, { fileName: name }, cors);
280
+ }
281
+ catch (error) {
282
+ if (error instanceof SavedExistsError) {
283
+ throw new HttpError(409, error.message);
284
+ }
285
+ if (error instanceof SavedNameError) {
286
+ throw new HttpError(400, error.message);
287
+ }
288
+ throw error;
289
+ }
290
+ }
291
+ function parseCreateRequest(body, models) {
292
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
293
+ throw new HttpError(400, "Body must be a JSON object");
294
+ }
295
+ try {
296
+ return parseCreateItems(body.items).map((item) => ({
297
+ ...item,
298
+ model: assertAllowedModel(item.model, models),
299
+ }));
300
+ }
301
+ catch (error) {
302
+ throw new HttpError(400, errorMessage(error));
303
+ }
304
+ }
305
+ async function handleCreateDefault(request, response, savedDir, cors) {
306
+ if (request.method === "GET") {
307
+ try {
308
+ sendJson(response, 200, await readSavedFile(CREATE_DEFAULT_FILE, savedDir), cors);
309
+ }
310
+ catch (error) {
311
+ if (error instanceof SavedNotFoundError) {
312
+ sendJson(response, 200, { fileName: CREATE_DEFAULT_FILE, items: [] }, cors);
313
+ return;
314
+ }
315
+ if (error instanceof SavedNameError) {
316
+ throw new HttpError(400, error.message);
317
+ }
318
+ throw error;
319
+ }
320
+ return;
321
+ }
322
+ if (request.method !== "PUT") {
323
+ sendMethodNotAllowed(response, "GET, PUT", cors);
324
+ return;
325
+ }
326
+ const models = await loadModels();
327
+ const items = parseCreateRequest(await readJsonBody(request, MAX_CREATE_BODY_BYTES), models);
328
+ const name = await upsertSavedFile(CREATE_DEFAULT_FILE, items, savedDir);
329
+ sendJson(response, 200, { fileName: name }, cors);
330
+ }
331
+ function parseAgentRequest(body, models) {
332
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
333
+ throw new HttpError(400, "Body must be a JSON object");
334
+ }
335
+ const payload = body;
336
+ const task = payload.task;
337
+ if (typeof task !== "string" || !task.trim()) {
338
+ throw new HttpError(400, "task is required");
339
+ }
340
+ if (typeof payload.model !== "string" || !payload.model.trim()) {
341
+ throw new HttpError(400, "model is required");
342
+ }
343
+ try {
344
+ return {
345
+ task: task.trim(),
346
+ model: assertAllowedModel(payload.model, models),
347
+ };
348
+ }
349
+ catch (error) {
350
+ throw new HttpError(400, errorMessage(error));
351
+ }
352
+ }
353
+ async function handleModelsRequest(request, response, cors) {
354
+ if (request.method !== "GET") {
355
+ sendMethodNotAllowed(response, "GET", cors);
356
+ return;
357
+ }
358
+ const models = await loadModels();
359
+ send(response, 200, JSON.stringify(models), {
360
+ "Content-Type": "application/json; charset=utf-8",
361
+ "Cache-Control": "no-store",
362
+ ...cors,
363
+ });
364
+ }
365
+ async function handleScreenshotRequest(request, response, processedDir, cors) {
366
+ if (request.method !== "GET") {
367
+ sendMethodNotAllowed(response, "GET", cors);
368
+ return;
369
+ }
370
+ const filePath = await getLatestWebpPath(processedDir);
371
+ if (!filePath) {
372
+ throw new HttpError(404, "No processed webp yet");
373
+ }
374
+ sendJson(response, 200, { imageName: path.basename(filePath) }, cors);
375
+ }
376
+ async function handleAgentRequest(request, response, runTask, session, busy, processedDir, cors, reportAutomation) {
377
+ if (request.method !== "POST") {
378
+ sendMethodNotAllowed(response, "POST", cors);
379
+ return;
380
+ }
381
+ if (busy.current) {
382
+ throw new HttpError(409, "Agent already running");
383
+ }
384
+ busy.current = true;
385
+ try {
386
+ const models = await loadModels();
387
+ const { task, model } = parseAgentRequest(await readJsonBody(request), models);
388
+ const prices = getModelEntry(model, models);
389
+ const abort = new AbortController();
390
+ let finished = false;
391
+ const stopOnDisconnect = () => {
392
+ if (!finished) {
393
+ abort.abort();
394
+ }
395
+ };
396
+ response.once("close", stopOnDisconnect);
397
+ beginNdjson(response, cors);
398
+ try {
399
+ const startedAt = new Date();
400
+ const result = await runTask(task, model, {
401
+ signal: abort.signal,
402
+ onIntent: async (label) => {
403
+ await writeAgentEvent(response, session, processedDir, { type: "intent", label });
404
+ },
405
+ confirmSafety: createWebSafetyConfirmer(response, abort.signal, session, processedDir),
406
+ });
407
+ reportAutomation(startedAt, new Date());
408
+ await writeAgentEvent(response, session, processedDir, {
409
+ type: "done",
410
+ text: result.text,
411
+ sessionReset: result.sessionReset,
412
+ browserOpen: session.isOpen(),
413
+ costUsd: computeCostUsd(result.usage ?? EMPTY_USAGE, prices),
414
+ });
415
+ }
416
+ catch (error) {
417
+ await writeAgentEvent(response, session, processedDir, {
418
+ type: "error",
419
+ error: errorMessage(error),
420
+ costUsd: computeCostUsd(error instanceof AgentRunError ? error.usage : EMPTY_USAGE, prices),
421
+ });
422
+ if (!(error instanceof AgentRunError)) {
423
+ logger.error("Failed to run agent", error);
424
+ }
425
+ }
426
+ finally {
427
+ finished = true;
428
+ response.off("close", stopOnDisconnect);
429
+ endNdjson(response);
430
+ }
431
+ }
432
+ finally {
433
+ busy.current = false;
434
+ }
435
+ }
436
+ async function handleAgentSafety(request, response, cors) {
437
+ if (request.method !== "POST") {
438
+ sendMethodNotAllowed(response, "POST", cors);
439
+ return;
440
+ }
441
+ const body = await readJsonBody(request);
442
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
443
+ throw new HttpError(400, "Body must be a JSON object");
444
+ }
445
+ const payload = body;
446
+ if (typeof payload.id !== "string" || !payload.id.trim()) {
447
+ throw new HttpError(400, "id is required");
448
+ }
449
+ if (typeof payload.confirmed !== "boolean") {
450
+ throw new HttpError(400, "confirmed must be a boolean");
451
+ }
452
+ const settle = pendingSafetyConfirmations.get(payload.id);
453
+ if (!settle) {
454
+ throw new HttpError(404, "No pending confirmation for id");
455
+ }
456
+ settle(payload.confirmed);
457
+ sendJson(response, 200, { ok: true }, cors);
458
+ }
459
+ function handleSessionStatus(request, response, session, cors) {
460
+ if (request.method !== "GET") {
461
+ sendMethodNotAllowed(response, "GET", cors);
462
+ return;
463
+ }
464
+ const status = session.getStatus();
465
+ sendJson(response, 200, { open: status.open, url: status.url }, cors);
466
+ }
467
+ async function handleSessionNavigate(request, response, session, busy, action, cors) {
468
+ if (request.method !== "POST") {
469
+ sendMethodNotAllowed(response, "POST", cors);
470
+ return;
471
+ }
472
+ if (busy.current) {
473
+ throw new HttpError(409, "Agent already running");
474
+ }
475
+ try {
476
+ let status;
477
+ if (action === "back") {
478
+ status = await session.goBack();
479
+ }
480
+ else if (action === "forward") {
481
+ status = await session.goForward();
482
+ }
483
+ else {
484
+ status = await session.reload();
485
+ }
486
+ sendJson(response, 200, { open: status.open, url: status.url }, cors);
487
+ }
488
+ catch (error) {
489
+ if (error instanceof BrowserClosedError) {
490
+ throw new HttpError(409, error.message);
491
+ }
492
+ throw error;
493
+ }
494
+ }
495
+ function handleHealth(request, response, cors) {
496
+ if (request.method !== "GET") {
497
+ sendMethodNotAllowed(response, "GET", cors);
498
+ return;
499
+ }
500
+ sendJson(response, 200, { ok: true }, cors);
501
+ }
502
+ function parseRequiredName(body, field = "fileName") {
503
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
504
+ throw new HttpError(400, "Body must be a JSON object");
505
+ }
506
+ const value = body[field];
507
+ if (typeof value !== "string" || !value.trim()) {
508
+ throw new HttpError(400, `${field} is required`);
509
+ }
510
+ return value.trim();
511
+ }
512
+ async function handleSavedRunStart(request, response, savedRun, cors) {
513
+ if (request.method !== "POST") {
514
+ sendMethodNotAllowed(response, "POST", cors);
515
+ return;
516
+ }
517
+ const fileName = parseRequiredName(await readJsonBody(request), "fileName");
518
+ try {
519
+ const historyFile = await savedRun.start(fileName);
520
+ sendJson(response, 202, { historyFile }, cors);
521
+ }
522
+ catch (error) {
523
+ if (error instanceof SavedRunBusyError || error instanceof SavedRunLimitError) {
524
+ throw new HttpError(409, error.message);
525
+ }
526
+ if (error instanceof SavedNotFoundError) {
527
+ throw new HttpError(404, error.message);
528
+ }
529
+ if (error instanceof SavedNameError) {
530
+ throw new HttpError(400, error.message);
531
+ }
532
+ throw error;
533
+ }
534
+ }
535
+ async function handleHistoryList(request, response, historyDir, cors) {
536
+ if (request.method !== "GET") {
537
+ sendMethodNotAllowed(response, "GET", cors);
538
+ return;
539
+ }
540
+ sendJson(response, 200, { runs: await listHistoryRuns(historyDir) }, cors);
541
+ }
542
+ async function handleHistoryDetail(request, response, historyDir, processedDir, cors) {
543
+ if (request.method !== "POST") {
544
+ sendMethodNotAllowed(response, "POST", cors);
545
+ return;
546
+ }
547
+ const historyFile = parseRequiredName(await readJsonBody(request), "historyFile");
548
+ try {
549
+ const name = normalizeSavedFileName(historyFile);
550
+ const document = await readHistoryFile(name, historyDir);
551
+ const items = await Promise.all(document.items.map(async (item) => {
552
+ const screenshots = await getSavedRunItemScreenshotRelPaths(processedDir, name, item.sequence, item.id);
553
+ return serializeHistoryDetailItem(item, screenshots);
554
+ }));
555
+ sendJson(response, 200, {
556
+ historyFile: name,
557
+ status: document.status,
558
+ items,
559
+ }, cors);
560
+ }
561
+ catch (error) {
562
+ if (error instanceof SavedNotFoundError) {
563
+ throw new HttpError(404, error.message);
564
+ }
565
+ if (error instanceof SavedNameError) {
566
+ throw new HttpError(400, error.message);
567
+ }
568
+ throw error;
569
+ }
570
+ }
571
+ function handleSavedRunningList(request, response, savedRun, cors) {
572
+ if (request.method !== "GET") {
573
+ sendMethodNotAllowed(response, "GET", cors);
574
+ return;
575
+ }
576
+ sendJson(response, 200, { files: savedRun.listRunning() }, cors);
577
+ }
578
+ async function handleHistoryStatus(request, response, historyDir, processedDir, cors) {
579
+ if (request.method !== "POST") {
580
+ sendMethodNotAllowed(response, "POST", cors);
581
+ return;
582
+ }
583
+ const fileName = parseRequiredName(await readJsonBody(request));
584
+ try {
585
+ const latest = await findLatestHistory(fileName, historyDir);
586
+ const screenshotPath = await getSavedRunScreenshotRelPath(processedDir, latest.historyFile);
587
+ sendJson(response, 200, {
588
+ fileName: latest.document.fileName,
589
+ historyFile: latest.historyFile,
590
+ status: latest.document.status,
591
+ current: latest.document.current,
592
+ total: latest.document.total,
593
+ screenshotPath,
594
+ items: [...latest.document.items],
595
+ }, cors);
596
+ }
597
+ catch (error) {
598
+ if (error instanceof SavedNotFoundError) {
599
+ throw new HttpError(404, error.message);
600
+ }
601
+ if (error instanceof SavedNameError) {
602
+ throw new HttpError(400, error.message);
603
+ }
604
+ throw error;
605
+ }
606
+ }
607
+ async function handleHistoryStop(request, response, savedRun, historyDir, cors) {
608
+ if (request.method !== "POST") {
609
+ sendMethodNotAllowed(response, "POST", cors);
610
+ return;
611
+ }
612
+ const fileName = parseRequiredName(await readJsonBody(request));
613
+ try {
614
+ const latest = await findLatestHistory(fileName, historyDir);
615
+ await savedRun.stop(latest.historyFile);
616
+ sendJson(response, 200, { ok: true }, cors);
617
+ }
618
+ catch (error) {
619
+ if (error instanceof SavedRunNotRunningError) {
620
+ throw new HttpError(404, error.message);
621
+ }
622
+ if (error instanceof SavedRunFinishedError) {
623
+ throw new HttpError(409, error.message);
624
+ }
625
+ if (error instanceof SavedNotFoundError) {
626
+ throw new HttpError(404, error.message);
627
+ }
628
+ if (error instanceof SavedNameError) {
629
+ throw new HttpError(400, error.message);
630
+ }
631
+ throw error;
632
+ }
633
+ }
634
+ export async function startServiceServer(port, deps = {}) {
635
+ const config = deps.config ?? loadConfig();
636
+ const listenPort = port ?? config.webPort;
637
+ const apiToken = deps.apiToken ?? requireApiToken(config);
638
+ const corsOrigins = deps.corsOrigins ?? config.corsOrigins;
639
+ const processedDir = deps.processedDir ?? config.processedDir;
640
+ const savedDir = deps.savedDir ?? config.savedDir;
641
+ const historyDir = deps.historyDir ?? config.historyDir;
642
+ const busy = { current: false };
643
+ const session = deps.session ??
644
+ createBrowserSession({
645
+ launch: launchLightChromium,
646
+ screenshotDir: processedDir,
647
+ });
648
+ await ensureCreateDefaultFile(savedDir);
649
+ const runTask = deps.runTask ??
650
+ ((task, model, extras) => runAgent(task, config, confirmSafetyForWeb, session, model, extras));
651
+ const reportAutomation = deps.reportAutomation ?? createAutomationReporter(config);
652
+ const savedRun = createSavedRunManager({
653
+ savedDir,
654
+ historyDir,
655
+ processedDir,
656
+ config,
657
+ runTask: deps.savedRunTask,
658
+ createSession: deps.createSavedRunSession,
659
+ reportAutomation,
660
+ });
661
+ const server = http.createServer((request, response) => {
662
+ void (async () => {
663
+ const pathname = requestPath(request);
664
+ const method = request.method ?? "GET";
665
+ logger.info(`${method} ${pathname}`);
666
+ const cors = applyCors(request, response, corsOrigins);
667
+ try {
668
+ if (method === "OPTIONS") {
669
+ send(response, 204, "", {
670
+ ...cors,
671
+ "Access-Control-Allow-Methods": cors["Access-Control-Allow-Methods"] ??
672
+ "GET, POST, PUT, DELETE, OPTIONS",
673
+ "Access-Control-Allow-Headers": cors["Access-Control-Allow-Headers"] ??
674
+ "Authorization, Content-Type",
675
+ });
676
+ return;
677
+ }
678
+ if (pathname === "/v1/health") {
679
+ handleHealth(request, response, cors);
680
+ return;
681
+ }
682
+ if (!isAuthorized(request.headers.authorization, apiToken)) {
683
+ sendJson(response, 401, { error: "Unauthorized" }, cors);
684
+ return;
685
+ }
686
+ if (pathname === "/v1/screenshot") {
687
+ await handleScreenshotRequest(request, response, processedDir, cors);
688
+ return;
689
+ }
690
+ if (pathname === "/v1/models") {
691
+ await handleModelsRequest(request, response, cors);
692
+ return;
693
+ }
694
+ if (pathname === "/v1/agent") {
695
+ await handleAgentRequest(request, response, runTask, session, busy, processedDir, cors, reportAutomation);
696
+ return;
697
+ }
698
+ if (pathname === "/v1/agent/safety") {
699
+ await handleAgentSafety(request, response, cors);
700
+ return;
701
+ }
702
+ if (pathname === "/v1/saved") {
703
+ if (request.method === "GET") {
704
+ await handleSavedList(request, response, savedDir, cors);
705
+ return;
706
+ }
707
+ await handleSavedCreate(request, response, savedDir, cors);
708
+ return;
709
+ }
710
+ if (pathname === "/v1/saved/run") {
711
+ await handleSavedRunStart(request, response, savedRun, cors);
712
+ return;
713
+ }
714
+ if (pathname === "/v1/saved/running") {
715
+ handleSavedRunningList(request, response, savedRun, cors);
716
+ return;
717
+ }
718
+ if (pathname === "/v1/saved/create-default.json" ||
719
+ pathname === "/v1/saved/create-default") {
720
+ await handleCreateDefault(request, response, savedDir, cors);
721
+ return;
722
+ }
723
+ if (pathname.startsWith("/v1/saved/")) {
724
+ await handleSavedItem(request, response, pathname, savedDir, cors);
725
+ return;
726
+ }
727
+ if (pathname === "/v1/history") {
728
+ await handleHistoryList(request, response, historyDir, cors);
729
+ return;
730
+ }
731
+ if (pathname === "/v1/history/detail") {
732
+ await handleHistoryDetail(request, response, historyDir, processedDir, cors);
733
+ return;
734
+ }
735
+ if (pathname === "/v1/history/status") {
736
+ await handleHistoryStatus(request, response, historyDir, processedDir, cors);
737
+ return;
738
+ }
739
+ if (pathname === "/v1/history/stop") {
740
+ await handleHistoryStop(request, response, savedRun, historyDir, cors);
741
+ return;
742
+ }
743
+ if (pathname === "/v1/session") {
744
+ handleSessionStatus(request, response, session, cors);
745
+ return;
746
+ }
747
+ if (pathname === "/v1/session/back") {
748
+ await handleSessionNavigate(request, response, session, busy, "back", cors);
749
+ return;
750
+ }
751
+ if (pathname === "/v1/session/forward") {
752
+ await handleSessionNavigate(request, response, session, busy, "forward", cors);
753
+ return;
754
+ }
755
+ if (pathname === "/v1/session/reload") {
756
+ await handleSessionNavigate(request, response, session, busy, "reload", cors);
757
+ return;
758
+ }
759
+ sendJson(response, 404, { error: "Not found" }, cors);
760
+ }
761
+ catch (error) {
762
+ if (error instanceof HttpError) {
763
+ sendJson(response, error.status, { error: error.message }, cors);
764
+ return;
765
+ }
766
+ logger.error("Failed to handle request", error);
767
+ sendJson(response, 500, { error: errorMessage(error) }, cors);
768
+ }
769
+ })();
770
+ });
771
+ serverSessions.set(server, session);
772
+ serverSavedRuns.set(server, savedRun);
773
+ try {
774
+ await new Promise((resolve, reject) => {
775
+ const onError = (error) => reject(error);
776
+ server.once("error", onError);
777
+ server.listen(listenPort, "127.0.0.1", () => {
778
+ server.removeListener("error", onError);
779
+ const address = server.address();
780
+ const actualPort = typeof address === "object" && address ? address.port : listenPort;
781
+ logger.info(`Spotcheck service listening at http://127.0.0.1:${actualPort}`);
782
+ resolve();
783
+ });
784
+ });
785
+ }
786
+ catch (error) {
787
+ serverSessions.delete(server);
788
+ serverSavedRuns.delete(server);
789
+ await session.close();
790
+ throw error;
791
+ }
792
+ activeServer = server;
793
+ return server;
794
+ }
795
+ export async function closeServiceServer(server) {
796
+ if (!server) {
797
+ return;
798
+ }
799
+ const session = serverSessions.get(server);
800
+ serverSessions.delete(server);
801
+ const savedRun = serverSavedRuns.get(server);
802
+ serverSavedRuns.delete(server);
803
+ if (savedRun) {
804
+ try {
805
+ await savedRun.close();
806
+ }
807
+ catch (error) {
808
+ logger.error("Failed to close saved run", error);
809
+ }
810
+ }
811
+ if (session) {
812
+ try {
813
+ await session.close();
814
+ }
815
+ catch (error) {
816
+ logger.error("Failed to close browser session", error);
817
+ }
818
+ }
819
+ await new Promise((resolve) => {
820
+ server.close(() => resolve());
821
+ });
822
+ }
823
+ async function shutdown(exitCode, reason) {
824
+ if (shuttingDown) {
825
+ process.exit(exitCode);
826
+ }
827
+ shuttingDown = true;
828
+ logger.info(reason);
829
+ await closeServiceServer(activeServer);
830
+ activeServer = undefined;
831
+ process.exit(exitCode);
832
+ }
833
+ function registerShutdownHandlers() {
834
+ const onSignal = (signal) => {
835
+ void shutdown(0, `Received ${signal}, shutting down`);
836
+ };
837
+ process.once("SIGINT", onSignal);
838
+ process.once("SIGTERM", onSignal);
839
+ }
840
+ async function main() {
841
+ registerShutdownHandlers();
842
+ const config = loadConfig();
843
+ requireApiToken(config);
844
+ await startServiceServer(config.webPort, { config });
845
+ }
846
+ if (isMainModule(import.meta.url)) {
847
+ main().catch((error) => {
848
+ logger.error("Fatal error", error);
849
+ void shutdown(1, "Exiting after fatal error");
850
+ });
851
+ }
852
+ //# sourceMappingURL=server.js.map