hanzi-browse 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +182 -0
  2. package/dist/agent/loop.d.ts +63 -0
  3. package/dist/agent/loop.js +186 -0
  4. package/dist/agent/system-prompt.d.ts +7 -0
  5. package/dist/agent/system-prompt.js +41 -0
  6. package/dist/agent/tools.d.ts +9 -0
  7. package/dist/agent/tools.js +154 -0
  8. package/dist/cli/detect-credentials.d.ts +31 -0
  9. package/dist/cli/detect-credentials.js +44 -0
  10. package/dist/cli/import-credentials-handler.d.ts +14 -0
  11. package/dist/cli/import-credentials-handler.js +22 -0
  12. package/dist/cli/session-files.d.ts +28 -0
  13. package/dist/cli/session-files.js +118 -0
  14. package/dist/cli/setup.d.ts +10 -0
  15. package/dist/cli/setup.js +915 -0
  16. package/dist/cli.d.ts +16 -0
  17. package/dist/cli.js +506 -0
  18. package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
  19. package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
  20. package/dist/dashboard/index.html +13 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1116 -0
  23. package/dist/ipc/index.d.ts +8 -0
  24. package/dist/ipc/index.js +8 -0
  25. package/dist/ipc/native-host.d.ts +96 -0
  26. package/dist/ipc/native-host.js +223 -0
  27. package/dist/ipc/websocket-client.d.ts +73 -0
  28. package/dist/ipc/websocket-client.js +199 -0
  29. package/dist/license/manager.d.ts +20 -0
  30. package/dist/license/manager.js +15 -0
  31. package/dist/llm/client.d.ts +72 -0
  32. package/dist/llm/client.js +227 -0
  33. package/dist/llm/credentials.d.ts +61 -0
  34. package/dist/llm/credentials.js +200 -0
  35. package/dist/llm/vertex.d.ts +22 -0
  36. package/dist/llm/vertex.js +335 -0
  37. package/dist/managed/api-http.test.d.ts +7 -0
  38. package/dist/managed/api-http.test.js +623 -0
  39. package/dist/managed/api.d.ts +51 -0
  40. package/dist/managed/api.js +1448 -0
  41. package/dist/managed/api.test.d.ts +10 -0
  42. package/dist/managed/api.test.js +146 -0
  43. package/dist/managed/auth.d.ts +38 -0
  44. package/dist/managed/auth.js +192 -0
  45. package/dist/managed/billing.d.ts +70 -0
  46. package/dist/managed/billing.js +227 -0
  47. package/dist/managed/deploy.d.ts +17 -0
  48. package/dist/managed/deploy.js +385 -0
  49. package/dist/managed/e2e.test.d.ts +15 -0
  50. package/dist/managed/e2e.test.js +151 -0
  51. package/dist/managed/hardening.test.d.ts +14 -0
  52. package/dist/managed/hardening.test.js +346 -0
  53. package/dist/managed/integration.test.d.ts +8 -0
  54. package/dist/managed/integration.test.js +274 -0
  55. package/dist/managed/log.d.ts +18 -0
  56. package/dist/managed/log.js +31 -0
  57. package/dist/managed/server.d.ts +12 -0
  58. package/dist/managed/server.js +69 -0
  59. package/dist/managed/store-pg.d.ts +191 -0
  60. package/dist/managed/store-pg.js +479 -0
  61. package/dist/managed/store.d.ts +188 -0
  62. package/dist/managed/store.js +379 -0
  63. package/dist/relay/auto-start.d.ts +19 -0
  64. package/dist/relay/auto-start.js +71 -0
  65. package/dist/relay/server.d.ts +17 -0
  66. package/dist/relay/server.js +403 -0
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/types/index.js +4 -0
  69. package/dist/types/session.d.ts +134 -0
  70. package/dist/types/session.js +16 -0
  71. package/package.json +61 -0
  72. package/skills/README.md +48 -0
  73. package/skills/a11y-auditor/SKILL.md +42 -0
  74. package/skills/e2e-tester/SKILL.md +154 -0
  75. package/skills/hanzi-browse/SKILL.md +182 -0
  76. package/skills/linkedin-prospector/SKILL.md +149 -0
  77. package/skills/social-poster/SKILL.md +146 -0
  78. package/skills/x-marketer/SKILL.md +479 -0
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Combined Managed Backend + Relay Server
4
+ *
5
+ * Single process for cloud deployment. Runs:
6
+ * 1. WebSocket relay (for extension communication)
7
+ * 2. Managed REST API (for client integration)
8
+ * 3. Vertex AI LLM client
9
+ *
10
+ * Environment variables:
11
+ * VERTEX_SA_JSON - Service account JSON string (for cloud deployment)
12
+ * VERTEX_SA_PATH - Path to service account JSON file (for local)
13
+ * PORT - HTTP/WS port (default: 3456, Railway sets this)
14
+ * RELAY_PORT - Internal relay port (default: 7862)
15
+ */
16
+ /** Check if a browser session is connected */
17
+ export declare function isSessionConnected(browserSessionId: string): boolean;
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Combined Managed Backend + Relay Server
4
+ *
5
+ * Single process for cloud deployment. Runs:
6
+ * 1. WebSocket relay (for extension communication)
7
+ * 2. Managed REST API (for client integration)
8
+ * 3. Vertex AI LLM client
9
+ *
10
+ * Environment variables:
11
+ * VERTEX_SA_JSON - Service account JSON string (for cloud deployment)
12
+ * VERTEX_SA_PATH - Path to service account JSON file (for local)
13
+ * PORT - HTTP/WS port (default: 3456, Railway sets this)
14
+ * RELAY_PORT - Internal relay port (default: 7862)
15
+ */
16
+ import { WebSocketServer, WebSocket } from "ws";
17
+ import { randomUUID } from "crypto";
18
+ import { initVertex } from "../llm/vertex.js";
19
+ import { startManagedAPI, initManagedAPI, handleRelayMessage, setStoreModule, onSessionDisconnected, shutdownManagedAPI, recoverStuckTasks } from "./api.js";
20
+ import { initBilling, setBillingStore } from "./billing.js";
21
+ import { WebSocketClient } from "../ipc/websocket-client.js";
22
+ // Dynamic store import — Postgres when DATABASE_URL is set, file-based otherwise
23
+ const DATABASE_URL = process.env.DATABASE_URL;
24
+ let store;
25
+ if (DATABASE_URL) {
26
+ const pgStore = await import("./store-pg.js");
27
+ pgStore.initPgStore(DATABASE_URL);
28
+ store = pgStore;
29
+ setStoreModule(store); // Also swap the API's store
30
+ console.error("[Server] Using Postgres store");
31
+ }
32
+ else {
33
+ store = await import("./store.js");
34
+ console.error("[Server] Using file-based store");
35
+ }
36
+ // --- Config ---
37
+ const PORT = parseInt(process.env.PORT || "3456", 10);
38
+ const RELAY_PORT = parseInt(process.env.RELAY_PORT || "7862", 10);
39
+ // Shared secret between the managed backend and the relay.
40
+ // Only clients registering with this secret can route to managed sessions.
41
+ import { randomBytes, timingSafeEqual } from "crypto";
42
+ const RELAY_INTERNAL_SECRET = process.env.RELAY_SECRET || randomBytes(32).toString("hex");
43
+ if (!process.env.RELAY_SECRET && process.env.NODE_ENV === "production") {
44
+ console.error("[Server] WARNING: RELAY_SECRET not set — generated a random secret. Set RELAY_SECRET env var for stable deployments.");
45
+ }
46
+ const relayClients = new Map();
47
+ const extensionQueue = [];
48
+ // --- Managed session routing ---
49
+ /** Find the relay client for a specific browser session */
50
+ function getSessionClient(browserSessionId) {
51
+ for (const c of relayClients.values()) {
52
+ if (c.browserSessionId === browserSessionId && c.ws.readyState === WebSocket.OPEN) {
53
+ return c;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ /** Check if a browser session is connected */
59
+ export function isSessionConnected(browserSessionId) {
60
+ return getSessionClient(browserSessionId) !== null;
61
+ }
62
+ // --- Legacy BYOM routing ---
63
+ function getLegacyExtension() {
64
+ for (const c of relayClients.values()) {
65
+ if (c.role === "extension" && !c.browserSessionId)
66
+ return c;
67
+ }
68
+ return null;
69
+ }
70
+ // --- Relay Limits ---
71
+ const RELAY_MAX_MESSAGE_BYTES = 5 * 1024 * 1024; // 5 MB max message (screenshots can be large)
72
+ const RELAY_MAX_CONNECTIONS = 100; // max simultaneous WebSocket connections
73
+ function startRelay() {
74
+ // In production, bind to loopback — Caddy reverse-proxies from the internet.
75
+ // Set RELAY_HOST=0.0.0.0 for local dev without a reverse proxy.
76
+ const RELAY_HOST = process.env.RELAY_HOST || (process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0");
77
+ const wss = new WebSocketServer({
78
+ port: RELAY_PORT,
79
+ host: RELAY_HOST,
80
+ maxPayload: RELAY_MAX_MESSAGE_BYTES,
81
+ });
82
+ wss.on("listening", () => {
83
+ console.error(`[Relay] Listening on ws://${RELAY_HOST}:${RELAY_PORT} (max ${RELAY_MAX_CONNECTIONS} connections, max ${RELAY_MAX_MESSAGE_BYTES / 1024 / 1024}MB/msg)`);
84
+ });
85
+ wss.on("error", (err) => {
86
+ if (err.code === "EADDRINUSE") {
87
+ console.error(`[Relay] Port ${RELAY_PORT} in use — relay already running`);
88
+ return;
89
+ }
90
+ console.error(`[Relay] Error: ${err.message}`);
91
+ });
92
+ wss.on("connection", (ws) => {
93
+ // Enforce max connections
94
+ if (wss.clients.size > RELAY_MAX_CONNECTIONS) {
95
+ ws.close(1013, "Server too busy — max connections reached");
96
+ console.error(`[Relay] Rejected connection: max ${RELAY_MAX_CONNECTIONS} connections exceeded`);
97
+ return;
98
+ }
99
+ ws.on("message", async (data) => {
100
+ try {
101
+ const msg = JSON.parse(data.toString());
102
+ // --- Registration ---
103
+ if (msg.type === "register") {
104
+ // Check for managed session token auth
105
+ if (msg.session_token) {
106
+ const session = await store.validateSessionToken(msg.session_token);
107
+ if (!session) {
108
+ ws.send(JSON.stringify({ type: "error", error: "Invalid session token" }));
109
+ ws.close();
110
+ return;
111
+ }
112
+ const client = {
113
+ ws,
114
+ role: "extension",
115
+ clientId: randomUUID(),
116
+ browserSessionId: session.id,
117
+ isInternalBackend: false,
118
+ };
119
+ relayClients.set(ws, client);
120
+ store.heartbeatSession(session.id);
121
+ ws.send(JSON.stringify({
122
+ type: "registered",
123
+ clientId: client.clientId,
124
+ role: "extension",
125
+ browserSessionId: session.id,
126
+ }));
127
+ console.error(`[Relay] Managed session registered: ${session.id}`);
128
+ return;
129
+ }
130
+ // Check relay_secret for internal backend auth
131
+ let isInternal = false;
132
+ if (typeof msg.relay_secret === "string" && msg.relay_secret.length === RELAY_INTERNAL_SECRET.length) {
133
+ try {
134
+ isInternal = timingSafeEqual(Buffer.from(msg.relay_secret), Buffer.from(RELAY_INTERNAL_SECRET));
135
+ }
136
+ catch {
137
+ isInternal = false;
138
+ }
139
+ }
140
+ // In production, reject unauthenticated legacy registrations.
141
+ // Only the internal backend (with relay_secret) and managed sessions (with session_token) can connect.
142
+ if (process.env.NODE_ENV === "production" && !isInternal) {
143
+ ws.send(JSON.stringify({ type: "error", error: "Legacy relay mode disabled in production. Use session_token auth." }));
144
+ ws.close();
145
+ return;
146
+ }
147
+ const client = {
148
+ ws,
149
+ role: msg.role || "cli",
150
+ clientId: randomUUID(),
151
+ isInternalBackend: isInternal,
152
+ };
153
+ relayClients.set(ws, client);
154
+ ws.send(JSON.stringify({ type: "registered", clientId: client.clientId, role: client.role }));
155
+ console.error(`[Relay] ${isInternal ? "Internal backend" : "Legacy"} registered: ${client.role} (${client.clientId})`);
156
+ // Flush queue to legacy extension
157
+ if (client.role === "extension" && extensionQueue.length > 0) {
158
+ for (const queued of extensionQueue) {
159
+ ws.send(queued);
160
+ }
161
+ extensionQueue.length = 0;
162
+ }
163
+ return;
164
+ }
165
+ // Ping/pong — client-initiated heartbeat
166
+ if (msg.type === "ping") {
167
+ // Heartbeat for managed sessions — enforces expiry/revocation
168
+ const sender = relayClients.get(ws);
169
+ if (sender?.browserSessionId) {
170
+ const valid = await store.heartbeatSession(sender.browserSessionId);
171
+ if (!valid) {
172
+ ws.send(JSON.stringify({ type: "error", error: "Session expired or revoked" }));
173
+ ws.close();
174
+ return;
175
+ }
176
+ }
177
+ // No token rotation here — rotation is driven solely by the server-initiated
178
+ // keepalive pings (below) to avoid double-rotation races.
179
+ ws.send(JSON.stringify({ type: "pong" }));
180
+ return;
181
+ }
182
+ const sender = relayClients.get(ws);
183
+ if (!sender)
184
+ return;
185
+ // --- Managed routing: route by targetSessionId ---
186
+ // SECURITY: Only the internal managed backend (authenticated with relay_secret)
187
+ // can route to managed sessions. Self-declared roles are NOT trusted.
188
+ if (msg.targetSessionId) {
189
+ if (!sender.isInternalBackend) {
190
+ ws.send(JSON.stringify({
191
+ type: "error",
192
+ requestId: msg.requestId,
193
+ error: "Unauthorized: only the managed backend can route to browser sessions",
194
+ }));
195
+ return;
196
+ }
197
+ const target = getSessionClient(msg.targetSessionId);
198
+ if (target) {
199
+ msg.sourceClientId = sender.clientId;
200
+ target.ws.send(JSON.stringify(msg));
201
+ }
202
+ else {
203
+ ws.send(JSON.stringify({
204
+ type: "tool_result",
205
+ requestId: msg.requestId,
206
+ error: `Browser session ${msg.targetSessionId} is not connected`,
207
+ }));
208
+ }
209
+ return;
210
+ }
211
+ // --- Legacy routing (BYOM Local) ---
212
+ if (sender.role === "extension" || sender.browserSessionId) {
213
+ // Extension → route to specific MCP/CLI client or broadcast
214
+ const targetId = msg.sourceClientId;
215
+ if (targetId) {
216
+ for (const c of relayClients.values()) {
217
+ if (c.clientId === targetId && c.ws.readyState === WebSocket.OPEN) {
218
+ c.ws.send(JSON.stringify(msg));
219
+ return;
220
+ }
221
+ }
222
+ }
223
+ for (const c of relayClients.values()) {
224
+ if (c.role !== "extension" && !c.browserSessionId && c.ws.readyState === WebSocket.OPEN) {
225
+ c.ws.send(JSON.stringify(msg));
226
+ }
227
+ }
228
+ }
229
+ else {
230
+ // MCP/CLI → route to legacy extension
231
+ const ext = getLegacyExtension();
232
+ if (ext && ext.ws.readyState === WebSocket.OPEN) {
233
+ msg.sourceClientId = sender.clientId;
234
+ ext.ws.send(JSON.stringify(msg));
235
+ }
236
+ else {
237
+ extensionQueue.push(JSON.stringify(msg));
238
+ if (extensionQueue.length > 50)
239
+ extensionQueue.shift();
240
+ }
241
+ }
242
+ }
243
+ catch (e) {
244
+ console.error("[Relay] Parse error:", e);
245
+ }
246
+ });
247
+ ws.on("close", () => {
248
+ const client = relayClients.get(ws);
249
+ if (client) {
250
+ if (client.browserSessionId) {
251
+ store.disconnectSession(client.browserSessionId);
252
+ // Immediately fail any pending tool executions for this session
253
+ // so the agent loop doesn't wait for timeout on each one.
254
+ onSessionDisconnected(client.browserSessionId);
255
+ console.error(`[Relay] Managed session disconnected: ${client.browserSessionId}`);
256
+ }
257
+ else {
258
+ console.error(`[Relay] Legacy disconnected: ${client.role}`);
259
+ }
260
+ relayClients.delete(ws);
261
+ }
262
+ });
263
+ });
264
+ // Keepalive pings to extensions — keeps Chrome MV3 service workers alive
265
+ // (they sleep after ~30s of inactivity, dropping the WebSocket).
266
+ // Also drives heartbeat and token rotation for managed sessions.
267
+ setInterval(async () => {
268
+ for (const [ws, client] of relayClients) {
269
+ if (client.role !== "extension" || ws.readyState !== WebSocket.OPEN)
270
+ continue;
271
+ const pingPayload = { type: "ping" };
272
+ if (client.browserSessionId) {
273
+ // Heartbeat + expiry check
274
+ const valid = await store.heartbeatSession(client.browserSessionId);
275
+ if (!valid) {
276
+ ws.send(JSON.stringify({ type: "error", error: "Session expired or revoked" }));
277
+ ws.close();
278
+ continue;
279
+ }
280
+ // Token rotation: every ~10 minutes (every 30th ping at 20s intervals)
281
+ client._heartbeatCount = (client._heartbeatCount || 0) + 1;
282
+ if (client._heartbeatCount % 30 === 0) {
283
+ const newToken = await store.rotateSessionToken(client.browserSessionId);
284
+ if (newToken) {
285
+ pingPayload.new_session_token = newToken;
286
+ console.error(`[Relay] Rotated token for session ${client.browserSessionId}`);
287
+ }
288
+ }
289
+ }
290
+ ws.send(JSON.stringify(pingPayload));
291
+ }
292
+ }, 20_000);
293
+ }
294
+ // --- Main ---
295
+ async function main() {
296
+ // 1. Init Vertex AI (optional — managed task execution disabled without it)
297
+ const saJson = process.env.VERTEX_SA_JSON;
298
+ const saPath = process.env.VERTEX_SA_PATH;
299
+ if (saJson) {
300
+ initVertex(JSON.parse(saJson));
301
+ }
302
+ else if (saPath) {
303
+ try {
304
+ initVertex(saPath);
305
+ }
306
+ catch (err) {
307
+ console.error(`[Server] Vertex AI disabled: ${err.message}`);
308
+ console.error("[Server] Managed task execution won't work. Set VERTEX_SA_PATH to enable it.");
309
+ }
310
+ }
311
+ else {
312
+ console.error("[Server] Vertex AI not configured (no VERTEX_SA_PATH or VERTEX_SA_JSON).");
313
+ console.error("[Server] BYOM + dashboard + API work fine. Managed task execution disabled.");
314
+ }
315
+ // 2. Bootstrap workspace + API key
316
+ // In production, warn that auto-bootstrap is a convenience, not long-term behavior.
317
+ const { workspace, apiKey } = await store.ensureDefaultWorkspace();
318
+ const keyDisplay = apiKey.key.startsWith("hic_live_") ? apiKey.key.slice(0, 20) + "..." : "(existing)";
319
+ if (process.env.NODE_ENV === "production") {
320
+ console.error(`[Server] WARNING: Auto-bootstrapped default workspace. Use explicit workspace provisioning for multi-tenant.`);
321
+ }
322
+ console.error(`[Server] Workspace: ${workspace.id}, API key: ${keyDisplay}`);
323
+ // 3. Start relay
324
+ startRelay();
325
+ // 4. Connect to relay as internal client
326
+ await new Promise((r) => setTimeout(r, 500)); // Let relay start
327
+ const relay = new WebSocketClient({
328
+ role: "mcp",
329
+ relayUrl: `ws://127.0.0.1:${RELAY_PORT}`,
330
+ autoStartRelay: false,
331
+ registerExtra: { relay_secret: RELAY_INTERNAL_SECRET },
332
+ });
333
+ relay.onMessage((message) => {
334
+ if (handleRelayMessage(message))
335
+ return;
336
+ if (message?.type === "pong" || message?.type === "registered")
337
+ return;
338
+ });
339
+ // Connect with timeout — if relay fails to start, exit instead of hanging forever
340
+ const connectTimeout = setTimeout(() => {
341
+ console.error("[Server] FATAL: Relay connection timed out after 10s");
342
+ process.exit(1);
343
+ }, 10_000);
344
+ await relay.connect();
345
+ clearTimeout(connectTimeout);
346
+ // 5. Init billing (optional — works without Stripe credentials)
347
+ initBilling();
348
+ setBillingStore(store);
349
+ // 6. Start API — pass session connectivity checker
350
+ initManagedAPI(relay, isSessionConnected);
351
+ startManagedAPI(PORT);
352
+ store.startHeartbeatFlush();
353
+ // 7. Recover tasks stuck in "running" from a previous process
354
+ await recoverStuckTasks();
355
+ console.error(`
356
+ ╔════════════════════════════════════════════════╗
357
+ ║ Hanzi Managed Backend (deployed) ║
358
+ ║ ║
359
+ ║ API: http://${process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0"}:${String(PORT).padEnd(5)} ║
360
+ ║ Relay: ws://${process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0"}:${String(RELAY_PORT).padEnd(5)} ║
361
+ ║ LLM: Vertex AI (Gemini 2.5 Flash) ║
362
+ ║ Key: ${keyDisplay.padEnd(33)} ║
363
+ ╚════════════════════════════════════════════════╝
364
+ `);
365
+ }
366
+ main().catch((err) => {
367
+ console.error("[Server] Fatal:", err);
368
+ process.exit(1);
369
+ });
370
+ // --- Graceful Shutdown ---
371
+ // On SIGTERM/SIGINT, abort running tasks with clean status updates
372
+ // before the process exits. Without this, tasks stay in "running" state
373
+ // permanently in the database.
374
+ async function handleShutdown(signal) {
375
+ console.error(`\n[Server] Received ${signal} — shutting down gracefully...`);
376
+ try {
377
+ await shutdownManagedAPI();
378
+ }
379
+ catch (err) {
380
+ console.error(`[Server] Shutdown error:`, err.message);
381
+ }
382
+ process.exit(0);
383
+ }
384
+ process.on("SIGTERM", () => handleShutdown("SIGTERM"));
385
+ process.on("SIGINT", () => handleShutdown("SIGINT"));
@@ -0,0 +1,15 @@
1
+ /**
2
+ * End-to-end managed task test.
3
+ *
4
+ * Tests the full partner flow with a mock-connected session:
5
+ * 1. Create API key
6
+ * 2. Create pairing token with metadata
7
+ * 3. Register session
8
+ * 4. Task creation succeeds (session reports as connected)
9
+ * 5. Task enters "running" state
10
+ *
11
+ * Limitation: cannot test actual agent loop execution (needs LLM + extension).
12
+ * But this proves the full auth → pairing → session → task creation pipeline
13
+ * with a connected session, which is the gap the other tests don't cover.
14
+ */
15
+ export {};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * End-to-end managed task test.
3
+ *
4
+ * Tests the full partner flow with a mock-connected session:
5
+ * 1. Create API key
6
+ * 2. Create pairing token with metadata
7
+ * 3. Register session
8
+ * 4. Task creation succeeds (session reports as connected)
9
+ * 5. Task enters "running" state
10
+ *
11
+ * Limitation: cannot test actual agent loop execution (needs LLM + extension).
12
+ * But this proves the full auth → pairing → session → task creation pipeline
13
+ * with a connected session, which is the gap the other tests don't cover.
14
+ */
15
+ import { initVertex } from "../llm/vertex.js";
16
+ import { startManagedAPI, initManagedAPI } from "./api.js";
17
+ import { createWorkspace, createApiKey, consumePairingToken, createPairingToken } from "./store.js";
18
+ const PORT = 4569;
19
+ const BASE = `http://localhost:${PORT}`;
20
+ // Track which sessions should report as "connected"
21
+ const connectedSessions = new Set();
22
+ const mockRelay = {
23
+ send: () => { },
24
+ onMessage: () => { },
25
+ connect: async () => { },
26
+ isConnected: () => true,
27
+ };
28
+ let testKey;
29
+ let testWorkspaceId;
30
+ async function setup() {
31
+ try {
32
+ initVertex("/tmp/hanzi-vertex-sa.json");
33
+ }
34
+ catch { }
35
+ const ws = createWorkspace("E2E Test Workspace");
36
+ testWorkspaceId = ws.id;
37
+ const key = createApiKey(ws.id, "e2e-key");
38
+ testKey = key.key;
39
+ // Pass a session connectivity checker that uses our connectedSessions set
40
+ initManagedAPI(mockRelay, (id) => connectedSessions.has(id));
41
+ startManagedAPI(PORT);
42
+ await new Promise((r) => setTimeout(r, 500));
43
+ }
44
+ async function req(method, path, body, apiKey) {
45
+ const headers = { "Content-Type": "application/json" };
46
+ if (apiKey)
47
+ headers["Authorization"] = `Bearer ${apiKey}`;
48
+ const res = await fetch(`${BASE}${path}`, {
49
+ method,
50
+ headers,
51
+ body: body ? JSON.stringify(body) : undefined,
52
+ });
53
+ const data = await res.json().catch(() => null);
54
+ return { status: res.status, data, headers: res.headers };
55
+ }
56
+ function assert(condition, msg) {
57
+ if (!condition)
58
+ throw new Error(`FAIL: ${msg}`);
59
+ console.log(` ✓ ${msg}`);
60
+ }
61
+ async function testFullPartnerFlow() {
62
+ console.log("\n--- Full partner flow (connected session) ---");
63
+ // 1. Create pairing token with metadata
64
+ const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {
65
+ label: "E2E test browser",
66
+ external_user_id: "e2e-user-001",
67
+ }, testKey);
68
+ assert(s1 === 201, "Pairing token created");
69
+ assert(d1.pairing_token.startsWith("hic_pair_"), "Token has correct prefix");
70
+ // 2. Register session (simulates extension pairing)
71
+ const { status: s2, data: d2 } = await req("POST", "/v1/browser-sessions/register", {
72
+ pairing_token: d1.pairing_token,
73
+ });
74
+ assert(s2 === 201, "Session registered");
75
+ const sessionId = d2.browser_session_id;
76
+ assert(!!sessionId, "Got session ID");
77
+ // 3. Mark session as connected (simulates relay registering the extension)
78
+ connectedSessions.add(sessionId);
79
+ // 4. Verify session shows as connected
80
+ const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, testKey);
81
+ const session = d3.sessions.find((s) => s.id === sessionId);
82
+ assert(!!session, "Session in list");
83
+ assert(session.status === "connected", "Session reports connected");
84
+ assert(session.label === "E2E test browser", "Label present");
85
+ assert(session.external_user_id === "e2e-user-001", "external_user_id present");
86
+ // 5. Create a task — this should succeed (session is connected)
87
+ const { status: s4, data: d4, headers: h4 } = await req("POST", "/v1/tasks", {
88
+ task: "Read the title of the current page",
89
+ browser_session_id: sessionId,
90
+ }, testKey);
91
+ assert(s4 === 201, "Task created successfully (session is connected)");
92
+ assert(d4.status === "running", "Task status is running");
93
+ assert(d4.id, "Task has an ID");
94
+ assert(!!h4.get("x-request-id"), "Response has X-Request-Id");
95
+ const taskId = d4.id;
96
+ // 6. Task appears in list
97
+ const { data: d5 } = await req("GET", "/v1/tasks", undefined, testKey);
98
+ const task = d5.tasks.find((t) => t.id === taskId);
99
+ assert(!!task, "Task appears in list");
100
+ // 7. Task is retrievable by ID
101
+ const { status: s6, data: d6 } = await req("GET", `/v1/tasks/${taskId}`, undefined, testKey);
102
+ assert(s6 === 200, "Task retrievable by ID");
103
+ assert(d6.browser_session_id === sessionId, "Task bound to correct session");
104
+ // 8. Cancel the task (since no real relay/LLM, it would hang otherwise)
105
+ const { status: s7 } = await req("POST", `/v1/tasks/${taskId}/cancel`, {}, testKey);
106
+ assert(s7 === 200, "Task cancelled");
107
+ // 9. Verify cancelled state
108
+ const { data: d8 } = await req("GET", `/v1/tasks/${taskId}`, undefined, testKey);
109
+ assert(d8.status === "cancelled", "Task shows cancelled status");
110
+ // 10. Usage endpoint works
111
+ const { status: s9, data: d9 } = await req("GET", "/v1/usage", undefined, testKey);
112
+ assert(s9 === 200, "Usage endpoint returns 200");
113
+ assert(typeof d9.totalInputTokens === "number", "Usage has token count");
114
+ // Clean up
115
+ connectedSessions.delete(sessionId);
116
+ }
117
+ async function testConnectedVsDisconnectedBehavior() {
118
+ console.log("\n--- Connected vs disconnected session behavior ---");
119
+ // Create a session
120
+ const pt = createPairingToken(testWorkspaceId, "test");
121
+ const session = consumePairingToken(pt._plainToken);
122
+ // Disconnected: task creation should fail
123
+ const { status: s1 } = await req("POST", "/v1/tasks", {
124
+ task: "test",
125
+ browser_session_id: session.id,
126
+ }, testKey);
127
+ assert(s1 === 409, "Disconnected session → 409");
128
+ // Connect
129
+ connectedSessions.add(session.id);
130
+ // Connected: task creation should succeed
131
+ const { status: s2, data: d2 } = await req("POST", "/v1/tasks", {
132
+ task: "test",
133
+ browser_session_id: session.id,
134
+ }, testKey);
135
+ assert(s2 === 201, "Connected session → 201");
136
+ // Cancel to clean up
137
+ await req("POST", `/v1/tasks/${d2.id}/cancel`, {}, testKey);
138
+ connectedSessions.delete(session.id);
139
+ }
140
+ async function runAll() {
141
+ await setup();
142
+ console.log("=== E2E Managed Task Tests ===");
143
+ await testFullPartnerFlow();
144
+ await testConnectedVsDisconnectedBehavior();
145
+ console.log("\n=== All E2E tests passed ===\n");
146
+ process.exit(0);
147
+ }
148
+ runAll().catch((err) => {
149
+ console.error("\n❌ E2E TEST FAILED:", err.message);
150
+ process.exit(1);
151
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Hardening Tests — Slice 1
3
+ *
4
+ * Covers the new reliability fixes:
5
+ * 1. Agent loop tool name validation
6
+ * 2. Request body size limits
7
+ * 3. Input validation (task, context, URL length)
8
+ * 4. Task execution timeout
9
+ * 5. Usage recording ordering (before task completion)
10
+ * 6. Session expiry enforcement at task creation
11
+ * 7. Model attribution in agent loop result
12
+ * 8. Vertex service account validation
13
+ */
14
+ export {};