@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
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@relauts/spotcheck-service",
3
+ "version": "1.0.0",
4
+ "description": "Local HTTP API that runs a Gemini computer-use agent in a Playwright Chromium session",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Relauts Pvt. Ltd.",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "homepage": "https://github.com/relauts/spotcheck-service#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/relauts/spotcheck-service.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/relauts/spotcheck-service/issues"
18
+ },
19
+ "keywords": [
20
+ "ai",
21
+ "browser-automation",
22
+ "computer-use",
23
+ "playwright",
24
+ "api"
25
+ ],
26
+ "files": [
27
+ "relauts-spotcheck-service-config.example.json",
28
+ "dist",
29
+ "src",
30
+ "files",
31
+ "openapi.yaml",
32
+ "LICENSE",
33
+ "NOTICE"
34
+ ],
35
+ "bin": {
36
+ "spotcheck-service": "dist/api/server.js"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.18.0"
40
+ },
41
+ "main": "dist/api/server.js",
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "prepublishOnly": "npm run build",
45
+ "start": "npm run build && node dist/api/server.js",
46
+ "dev": "tsx src/api/server.ts",
47
+ "agent": "tsx src/computer-use/index.ts",
48
+ "test": "tsx --test tests/shared/*.test.ts tests/playwright/*.test.ts tests/api/*.test.ts tests/computer-use/*.test.ts",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit"
50
+ },
51
+ "dependencies": {
52
+ "@google/genai": "^2.17.1",
53
+ "playwright": "^1.55.0"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^24.3.0",
57
+ "tsx": "^4.20.4",
58
+ "typescript": "^5.9.2"
59
+ }
60
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "apiToken": "replace-me",
3
+ "geminiApiKey": "replace-me",
4
+ "targetUrl": "https://example.com",
5
+ "port": 18732,
6
+ "headless": true,
7
+ "chromiumSandbox": true,
8
+ "navigationTimeoutMs": 30000,
9
+ "viewportWidth": 1024,
10
+ "viewportHeight": 768,
11
+ "geminiModel": "gemini-3.6-flash",
12
+ "geminiThinkingLevel": "minimal",
13
+ "agentMaxTurns": 15,
14
+ "corsOrigins": [],
15
+ "savedDir": "saved",
16
+ "processedDir": "processed",
17
+ "historyDir": "history",
18
+ "maxSavedRuns": 2
19
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Extracts the raw token from an Authorization header.
5
+ * Only the Bearer scheme is accepted.
6
+ */
7
+ export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
8
+ if (!authorizationHeader) {
9
+ return undefined;
10
+ }
11
+
12
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(authorizationHeader);
13
+ if (!match) {
14
+ return undefined;
15
+ }
16
+
17
+ return match[1];
18
+ }
19
+
20
+ /**
21
+ * Compares tokens in constant time by hashing both to fixed-length digests first.
22
+ * Avoids leaking token length via early return on Buffer length mismatch.
23
+ */
24
+ export function tokensEqual(expected: string, provided: string): boolean {
25
+ const expectedDigest = createHash("sha256").update(expected, "utf8").digest();
26
+ const providedDigest = createHash("sha256").update(provided, "utf8").digest();
27
+ return timingSafeEqual(expectedDigest, providedDigest);
28
+ }
29
+
30
+ export function isAuthorized(
31
+ authorizationHeader: string | undefined,
32
+ expectedToken: string,
33
+ ): boolean {
34
+ const provided = extractBearerToken(authorizationHeader);
35
+ if (provided === undefined) {
36
+ return false;
37
+ }
38
+
39
+ return tokensEqual(expectedToken, provided);
40
+ }
@@ -0,0 +1,251 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { SavedNameError, SavedNotFoundError, normalizeSavedFileName, type SavedPromptItem } from "./saved.js";
4
+
5
+ export const HISTORY_DIR = path.resolve(process.cwd(), "history");
6
+
7
+ export const HISTORY_RUN_STATUSES = ["running", "done", "error", "stopped"] as const;
8
+ export type HistoryRunStatus = (typeof HISTORY_RUN_STATUSES)[number];
9
+ export type HistoryItemStatus = "pending" | "running" | "done" | "error" | "skipped";
10
+
11
+ export interface HistoryRunSummary {
12
+ readonly historyFile: string;
13
+ readonly status: HistoryRunStatus;
14
+ }
15
+
16
+ export interface HistoryDetailItem {
17
+ readonly sequence: number;
18
+ readonly prompt: string;
19
+ readonly model: string;
20
+ readonly resultText?: string;
21
+ readonly screenshots: readonly string[];
22
+ }
23
+
24
+ function isHistoryRunStatus(value: unknown): value is HistoryRunStatus {
25
+ return typeof value === "string" && (HISTORY_RUN_STATUSES as readonly string[]).includes(value);
26
+ }
27
+
28
+ export interface HistoryItem {
29
+ readonly sequence: number;
30
+ readonly id: string;
31
+ readonly prompt: string;
32
+ readonly model: string;
33
+ readonly status: HistoryItemStatus;
34
+ readonly resultText?: string;
35
+ readonly costUsd?: number;
36
+ readonly error?: string;
37
+ }
38
+
39
+ export interface HistoryDocument {
40
+ readonly fileName: string;
41
+ readonly status: HistoryRunStatus;
42
+ readonly current: number;
43
+ readonly total: number;
44
+ readonly items: readonly HistoryItem[];
45
+ }
46
+
47
+ export function makeHistoryFileName(savedFileName: string, now = new Date()): string {
48
+ const name = normalizeSavedFileName(savedFileName);
49
+ const stem = name.slice(0, -".json".length);
50
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
51
+ return `${stem}-${stamp}.json`;
52
+ }
53
+
54
+ export function createHistoryDocument(
55
+ savedFileName: string,
56
+ items: readonly SavedPromptItem[],
57
+ ): HistoryDocument {
58
+ return {
59
+ fileName: normalizeSavedFileName(savedFileName),
60
+ status: "running",
61
+ current: 0,
62
+ total: items.length,
63
+ items: items.map((item) => ({
64
+ sequence: item.sequence,
65
+ id: item.id,
66
+ prompt: item.prompt,
67
+ model: item.model,
68
+ status: "pending",
69
+ })),
70
+ };
71
+ }
72
+
73
+ export function serializeHistoryDetailItem(
74
+ item: HistoryItem,
75
+ screenshots: readonly string[],
76
+ ): HistoryDetailItem {
77
+ return {
78
+ sequence: item.sequence,
79
+ prompt: item.prompt,
80
+ model: item.model,
81
+ ...(item.resultText ? { resultText: item.resultText } : {}),
82
+ screenshots: [...screenshots],
83
+ };
84
+ }
85
+
86
+ export function serializeHistoryItem(item: HistoryItem): HistoryItem {
87
+ return {
88
+ sequence: item.sequence,
89
+ id: item.id,
90
+ prompt: item.prompt,
91
+ model: item.model,
92
+ status: item.status,
93
+ ...(item.resultText ? { resultText: item.resultText } : {}),
94
+ ...(typeof item.costUsd === "number" ? { costUsd: item.costUsd } : {}),
95
+ ...(item.error ? { error: item.error } : {}),
96
+ };
97
+ }
98
+
99
+ export function serializeHistoryDocument(document: HistoryDocument): HistoryDocument {
100
+ return {
101
+ fileName: document.fileName,
102
+ status: document.status,
103
+ current: document.current,
104
+ total: document.total,
105
+ items: document.items.map(serializeHistoryItem),
106
+ };
107
+ }
108
+
109
+ export async function writeHistoryFile(
110
+ historyFileName: string,
111
+ document: HistoryDocument,
112
+ directory = HISTORY_DIR,
113
+ ): Promise<string> {
114
+ const name = normalizeSavedFileName(historyFileName);
115
+ await fs.mkdir(directory, { recursive: true });
116
+ const body = `${JSON.stringify(serializeHistoryDocument(document), null, 2)}\n`;
117
+ const filePath = path.join(directory, name);
118
+ const tempPath = `${filePath}.${process.pid}.tmp`;
119
+ await fs.writeFile(tempPath, body);
120
+ await fs.rename(tempPath, filePath);
121
+ return name;
122
+ }
123
+
124
+ export async function readHistoryFile(
125
+ historyFileName: string,
126
+ directory = HISTORY_DIR,
127
+ ): Promise<HistoryDocument> {
128
+ const name = normalizeSavedFileName(historyFileName);
129
+ const filePath = path.join(directory, name);
130
+ let raw: string;
131
+ try {
132
+ raw = await fs.readFile(filePath, "utf8");
133
+ } catch (error: unknown) {
134
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
135
+ throw new SavedNotFoundError(`History file not found: ${name}`);
136
+ }
137
+
138
+ throw error;
139
+ }
140
+
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch {
145
+ throw new SavedNameError("History file is not valid JSON");
146
+ }
147
+
148
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
149
+ throw new SavedNameError("History file must be a JSON object");
150
+ }
151
+
152
+ return parsed as HistoryDocument;
153
+ }
154
+
155
+ export interface LatestHistoryMatch {
156
+ readonly historyFile: string;
157
+ readonly document: HistoryDocument;
158
+ }
159
+
160
+ export async function listHistoryRuns(directory = HISTORY_DIR): Promise<HistoryRunSummary[]> {
161
+ let names: string[];
162
+ try {
163
+ names = await fs.readdir(directory);
164
+ } catch (error: unknown) {
165
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
166
+ return [];
167
+ }
168
+
169
+ throw error;
170
+ }
171
+
172
+ const ranked: Array<{ historyFile: string; status: HistoryRunStatus; mtimeMs: number }> = [];
173
+ for (const name of names) {
174
+ if (!name.toLowerCase().endsWith(".json")) {
175
+ continue;
176
+ }
177
+
178
+ try {
179
+ const document = await readHistoryFile(name, directory);
180
+ if (!isHistoryRunStatus(document.status)) {
181
+ continue;
182
+ }
183
+
184
+ const stats = await fs.stat(path.join(directory, name));
185
+ ranked.push({ historyFile: name, status: document.status, mtimeMs: stats.mtimeMs });
186
+ } catch {
187
+ // Skip unreadable or invalid files.
188
+ }
189
+ }
190
+
191
+ ranked.sort((left, right) => {
192
+ if (right.mtimeMs !== left.mtimeMs) {
193
+ return right.mtimeMs - left.mtimeMs;
194
+ }
195
+
196
+ return right.historyFile.localeCompare(left.historyFile);
197
+ });
198
+
199
+ return ranked.map(({ historyFile, status }) => ({ historyFile, status }));
200
+ }
201
+
202
+ export async function findLatestHistory(
203
+ savedFileName: string,
204
+ directory = HISTORY_DIR,
205
+ ): Promise<LatestHistoryMatch> {
206
+ const wanted = normalizeSavedFileName(savedFileName);
207
+ let names: string[];
208
+ try {
209
+ names = await fs.readdir(directory);
210
+ } catch (error: unknown) {
211
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
212
+ throw new SavedNotFoundError(`History file not found for: ${wanted}`);
213
+ }
214
+
215
+ throw error;
216
+ }
217
+
218
+ const ranked: Array<{ historyFile: string; document: HistoryDocument; mtimeMs: number }> = [];
219
+ for (const name of names) {
220
+ if (!name.toLowerCase().endsWith(".json")) {
221
+ continue;
222
+ }
223
+
224
+ try {
225
+ const document = await readHistoryFile(name, directory);
226
+ if (normalizeSavedFileName(document.fileName) !== wanted) {
227
+ continue;
228
+ }
229
+
230
+ const stats = await fs.stat(path.join(directory, name));
231
+ ranked.push({ historyFile: name, document, mtimeMs: stats.mtimeMs });
232
+ } catch {
233
+ // Skip unreadable or invalid files.
234
+ }
235
+ }
236
+
237
+ ranked.sort((left, right) => {
238
+ if (right.mtimeMs !== left.mtimeMs) {
239
+ return right.mtimeMs - left.mtimeMs;
240
+ }
241
+
242
+ return right.historyFile.localeCompare(left.historyFile);
243
+ });
244
+
245
+ const latest = ranked[0];
246
+ if (!latest) {
247
+ throw new SavedNotFoundError(`History file not found for: ${wanted}`);
248
+ }
249
+
250
+ return { historyFile: latest.historyFile, document: latest.document };
251
+ }