@muyichengshayu/promptx 0.2.13 → 0.2.15

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/apps/server/src/agentSessionDiscovery.js +180 -7
  3. package/apps/web/dist/assets/{CodexSessionManagerDialog-Dic9kMHK.js → CodexSessionManagerDialog-y7O-JTxP.js} +1 -1
  4. package/apps/web/dist/assets/{TaskDiffReviewDialog-CKiZdXqi.js → TaskDiffReviewDialog-CTr_zoAn.js} +1 -1
  5. package/apps/web/dist/assets/{WorkbenchSettingsDialog-CP0z90bm.js → WorkbenchSettingsDialog-Bf2DCuN_.js} +1 -1
  6. package/apps/web/dist/assets/{WorkbenchView-D1oxqNr4.css → WorkbenchView-CK1snPBz.css} +1 -1
  7. package/apps/web/dist/assets/WorkbenchView-Gq3mmtsK.js +60 -0
  8. package/apps/web/dist/assets/index-Co1Ssha9.js +2 -0
  9. package/apps/web/dist/index.html +1 -1
  10. package/package.json +21 -14
  11. package/apps/runner/src/engines/claudeCodeRunner.test.js +0 -467
  12. package/apps/runner/src/engines/kimiCodeRunner.test.js +0 -127
  13. package/apps/runner/src/engines/openCodeRunner.test.js +0 -236
  14. package/apps/runner/src/engines/runnerContract.test.js +0 -449
  15. package/apps/runner/src/engines/shellRunner.test.js +0 -46
  16. package/apps/runner/src/runManager.test.js +0 -913
  17. package/apps/runner/src/serverClient.test.js +0 -93
  18. package/apps/server/src/agentSessionDiscovery.test.js +0 -186
  19. package/apps/server/src/appPaths.test.js +0 -52
  20. package/apps/server/src/assetRoutes.test.js +0 -168
  21. package/apps/server/src/codex.test.js +0 -518
  22. package/apps/server/src/codexRoutes.test.js +0 -376
  23. package/apps/server/src/codexRuns.test.js +0 -160
  24. package/apps/server/src/codexSessions.test.js +0 -369
  25. package/apps/server/src/db.test.js +0 -182
  26. package/apps/server/src/gitDiff.test.js +0 -542
  27. package/apps/server/src/gitDiffClient.test.js +0 -140
  28. package/apps/server/src/internalRoutes.test.js +0 -134
  29. package/apps/server/src/maintenance.test.js +0 -154
  30. package/apps/server/src/processControl.test.js +0 -147
  31. package/apps/server/src/relayClient.test.js +0 -478
  32. package/apps/server/src/relayConfig.test.js +0 -73
  33. package/apps/server/src/relayProtocol.test.js +0 -49
  34. package/apps/server/src/relayServer.test.js +0 -798
  35. package/apps/server/src/relayTenants.test.js +0 -137
  36. package/apps/server/src/relayUsageStore.test.js +0 -65
  37. package/apps/server/src/repository.test.js +0 -150
  38. package/apps/server/src/runDispatchService.test.js +0 -563
  39. package/apps/server/src/runEventIngest.test.js +0 -225
  40. package/apps/server/src/runRecovery.test.js +0 -73
  41. package/apps/server/src/runnerClient.test.js +0 -80
  42. package/apps/server/src/runnerDispatch.test.js +0 -136
  43. package/apps/server/src/systemConfig.test.js +0 -112
  44. package/apps/server/src/systemRoutes.test.js +0 -319
  45. package/apps/server/src/taskRoutes.test.js +0 -775
  46. package/apps/server/src/upload.test.js +0 -30
  47. package/apps/server/src/webAppRoutes.test.js +0 -67
  48. package/apps/server/src/workspaceFiles.test.js +0 -279
  49. package/apps/web/dist/assets/WorkbenchView-noayQwj4.js +0 -60
  50. package/apps/web/dist/assets/index-HLkdzIYF.js +0 -2
  51. package/packages/shared/src/dailyLogStream.test.js +0 -29
  52. package/packages/shared/src/shellCommands.test.js +0 -45
@@ -1,798 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import http from 'node:http'
4
- import os from 'node:os'
5
- import path from 'node:path'
6
- import test from 'node:test'
7
- import { setTimeout as delay } from 'node:timers/promises'
8
- import WebSocket from 'ws'
9
-
10
- import {
11
- normalizeRelayHost,
12
- normalizeRequestBodyToBuffer,
13
- readRelayServerConfig,
14
- resolveRelayTenantByHost,
15
- startRelayServer,
16
- } from './relayServer.js'
17
-
18
- function withEnv(overrides, run) {
19
- const previous = {}
20
- Object.keys(overrides).forEach((key) => {
21
- previous[key] = process.env[key]
22
- const value = overrides[key]
23
- if (value === null) {
24
- delete process.env[key]
25
- return
26
- }
27
- process.env[key] = value
28
- })
29
-
30
- try {
31
- return run()
32
- } finally {
33
- Object.entries(previous).forEach(([key, value]) => {
34
- if (typeof value === 'undefined') {
35
- delete process.env[key]
36
- return
37
- }
38
- process.env[key] = value
39
- })
40
- }
41
- }
42
-
43
- async function waitFor(check, timeoutMs = 2_000) {
44
- const startedAt = Date.now()
45
- while (Date.now() - startedAt < timeoutMs) {
46
- const value = check()
47
- if (value) {
48
- return value
49
- }
50
- await delay(20)
51
- }
52
- throw new Error('waitFor timeout')
53
- }
54
-
55
- function requestRelay({ port, host, path: requestPath, method = 'GET', body = '', headers = {} }) {
56
- const payload = typeof body === 'string' ? body : JSON.stringify(body)
57
- const normalizedHeaders = { ...headers }
58
- const hasExplicitContentType = Object.keys(normalizedHeaders).some((key) => String(key).toLowerCase() === 'content-type')
59
- const hasExplicitContentLength = Object.keys(normalizedHeaders).some((key) => String(key).toLowerCase() === 'content-length')
60
-
61
- return new Promise((resolve, reject) => {
62
- const request = http.request({
63
- host: '127.0.0.1',
64
- port,
65
- path: requestPath,
66
- method,
67
- headers: {
68
- Host: host,
69
- ...normalizedHeaders,
70
- ...(payload ? {
71
- ...(hasExplicitContentType ? {} : { 'content-type': 'application/json' }),
72
- ...(hasExplicitContentLength ? {} : { 'content-length': Buffer.byteLength(payload) }),
73
- } : {}),
74
- },
75
- }, (response) => {
76
- const chunks = []
77
- response.on('data', (chunk) => chunks.push(chunk))
78
- response.on('end', () => {
79
- const responseBody = Buffer.concat(chunks).toString('utf8')
80
- const responseType = String(response.headers['content-type'] || '').toLowerCase()
81
- resolve({
82
- statusCode: response.statusCode || 0,
83
- headers: response.headers,
84
- body: responseBody
85
- ? (responseType.includes('application/json') ? JSON.parse(responseBody) : responseBody)
86
- : null,
87
- })
88
- })
89
- })
90
-
91
- request.on('error', reject)
92
- if (payload) {
93
- request.write(payload)
94
- }
95
- request.end()
96
- })
97
- }
98
-
99
- test('normalizeRequestBodyToBuffer handles common relay request body shapes', () => {
100
- assert.equal(normalizeRequestBodyToBuffer(null).length, 0)
101
- assert.equal(normalizeRequestBodyToBuffer(undefined).length, 0)
102
- assert.equal(normalizeRequestBodyToBuffer('hello').toString('utf8'), 'hello')
103
- assert.equal(normalizeRequestBodyToBuffer({ foo: 'bar' }).toString('utf8'), '{"foo":"bar"}')
104
- assert.equal(normalizeRequestBodyToBuffer(123).toString('utf8'), '123')
105
-
106
- const source = Buffer.from('abc')
107
- assert.equal(normalizeRequestBodyToBuffer(source), source)
108
-
109
- const bytes = new Uint8Array([65, 66, 67])
110
- assert.equal(normalizeRequestBodyToBuffer(bytes).toString('utf8'), 'ABC')
111
- })
112
-
113
- test('normalizeRelayHost strips protocol, port and casing', () => {
114
- assert.equal(normalizeRelayHost('https://User1.PromptX.mushayu.com/path?a=1'), 'user1.promptx.mushayu.com')
115
- assert.equal(normalizeRelayHost('USER2.promptx.mushayu.com:443'), 'user2.promptx.mushayu.com')
116
- assert.equal(normalizeRelayHost(' user3.promptx.mushayu.com , proxy-host '), 'user3.promptx.mushayu.com')
117
- })
118
-
119
- test('resolveRelayTenantByHost matches configured subdomains', () => {
120
- const tenants = [
121
- {
122
- key: 'user1',
123
- hosts: ['user1.promptx.mushayu.com'],
124
- deviceToken: 'token-1',
125
- accessToken: 'access-1',
126
- expectedDeviceId: 'user1-mac',
127
- },
128
- {
129
- key: 'user2',
130
- hosts: ['user2.promptx.mushayu.com'],
131
- deviceToken: 'token-2',
132
- accessToken: 'access-2',
133
- expectedDeviceId: 'user2-mac',
134
- },
135
- ]
136
-
137
- assert.equal(resolveRelayTenantByHost(tenants, 'user1.promptx.mushayu.com:443')?.key, 'user1')
138
- assert.equal(resolveRelayTenantByHost(tenants, 'https://user2.promptx.mushayu.com')?.key, 'user2')
139
- assert.equal(resolveRelayTenantByHost(tenants, 'promptx.mushayu.com'), null)
140
- })
141
-
142
- test('readRelayServerConfig loads multi-tenant relay settings from file', () => {
143
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-server-'))
144
- const configPath = path.join(tempDir, 'relay-tenants.json')
145
- fs.writeFileSync(configPath, `${JSON.stringify({
146
- tenants: [
147
- {
148
- key: 'user1',
149
- host: 'https://user1.promptx.mushayu.com',
150
- deviceId: 'user1-mac',
151
- deviceToken: 'token-1',
152
- accessToken: 'access-1',
153
- },
154
- {
155
- key: 'user2',
156
- hosts: ['user2.promptx.mushayu.com', 'USER2-ALT.promptx.mushayu.com'],
157
- deviceId: 'user2-mac',
158
- deviceToken: 'token-2',
159
- accessToken: 'access-2',
160
- },
161
- ],
162
- }, null, 2)}\n`, 'utf8')
163
-
164
- withEnv({
165
- PROMPTX_RELAY_TENANTS_FILE: configPath,
166
- PROMPTX_RELAY_HOST: '0.0.0.0',
167
- PROMPTX_RELAY_PORT: '3030',
168
- PROMPTX_RELAY_PUBLIC_URL: null,
169
- PROMPTX_RELAY_DEVICE_ID: null,
170
- PROMPTX_RELAY_DEVICE_TOKEN: null,
171
- PROMPTX_RELAY_ACCESS_TOKEN: null,
172
- }, () => {
173
- const config = readRelayServerConfig()
174
- assert.equal(config.tenantSource, configPath)
175
- assert.equal(config.tenants.length, 2)
176
- assert.deepEqual(config.tenants[0], {
177
- key: 'user1',
178
- hosts: ['user1.promptx.mushayu.com'],
179
- expectedDeviceId: 'user1-mac',
180
- deviceToken: 'token-1',
181
- accessToken: 'access-1',
182
- })
183
- assert.deepEqual(config.tenants[1], {
184
- key: 'user2',
185
- hosts: ['user2.promptx.mushayu.com', 'user2-alt.promptx.mushayu.com'],
186
- expectedDeviceId: 'user2-mac',
187
- deviceToken: 'token-2',
188
- accessToken: 'access-2',
189
- })
190
- })
191
- })
192
-
193
- test('relay login page uses POST and successful login sets cookie without query token', async () => {
194
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-login-page-'))
195
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-login</body></html>\n', 'utf8')
196
-
197
- const relay = await startRelayServer({
198
- logger: false,
199
- webDistDir: tempDir,
200
- config: {
201
- host: '127.0.0.1',
202
- port: 0,
203
- accessCookieName: 'promptx_relay_access',
204
- tenantSource: 'test',
205
- tenants: [
206
- {
207
- key: 'user1',
208
- hosts: ['user1.promptx.test'],
209
- expectedDeviceId: 'user1-mac',
210
- deviceToken: 'token-1',
211
- accessToken: 'access-1',
212
- },
213
- ],
214
- },
215
- })
216
-
217
- try {
218
- const loginPage = await requestRelay({
219
- port: relay.port,
220
- host: 'user1.promptx.test',
221
- path: '/relay/login?redirect=%2F',
222
- headers: {
223
- accept: 'text/html',
224
- },
225
- })
226
- assert.equal(loginPage.statusCode, 200)
227
- assert.match(String(loginPage.body || ''), /action="\/relay\/login" method="post"/)
228
- assert.doesNotMatch(String(loginPage.body || ''), /name="token" type="password".*method="get"/s)
229
-
230
- const loginResult = await requestRelay({
231
- port: relay.port,
232
- host: 'user1.promptx.test',
233
- path: '/relay/login',
234
- method: 'POST',
235
- body: 'token=access-1&redirect=%2F',
236
- headers: {
237
- accept: 'text/html',
238
- 'content-type': 'application/x-www-form-urlencoded',
239
- },
240
- })
241
- assert.equal(loginResult.statusCode, 302)
242
- assert.equal(loginResult.headers.location, '/')
243
- assert.match(String(loginResult.headers['set-cookie'] || ''), /promptx_relay_access=/)
244
- } finally {
245
- await relay.close()
246
- }
247
- })
248
-
249
- test('relay login rate limit blocks repeated invalid attempts', async () => {
250
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-login-limit-'))
251
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-limit</body></html>\n', 'utf8')
252
-
253
- const relay = await startRelayServer({
254
- logger: false,
255
- webDistDir: tempDir,
256
- loginRateLimitMaxAttempts: 2,
257
- loginRateLimitWindowMs: 60_000,
258
- config: {
259
- host: '127.0.0.1',
260
- port: 0,
261
- accessCookieName: 'promptx_relay_access',
262
- tenantSource: 'test',
263
- tenants: [
264
- {
265
- key: 'user1',
266
- hosts: ['user1.promptx.test'],
267
- expectedDeviceId: 'user1-mac',
268
- deviceToken: 'token-1',
269
- accessToken: 'access-1',
270
- },
271
- ],
272
- },
273
- })
274
-
275
- try {
276
- const firstAttempt = await requestRelay({
277
- port: relay.port,
278
- host: 'user1.promptx.test',
279
- path: '/relay/login',
280
- method: 'POST',
281
- body: 'token=wrong&redirect=%2F',
282
- headers: {
283
- accept: 'text/html',
284
- 'content-type': 'application/x-www-form-urlencoded',
285
- },
286
- })
287
- assert.equal(firstAttempt.statusCode, 401)
288
- assert.match(String(firstAttempt.body || ''), /访问令牌不正确/)
289
-
290
- const secondAttempt = await requestRelay({
291
- port: relay.port,
292
- host: 'user1.promptx.test',
293
- path: '/relay/login',
294
- method: 'POST',
295
- body: 'token=still-wrong&redirect=%2F',
296
- headers: {
297
- accept: 'text/html',
298
- 'content-type': 'application/x-www-form-urlencoded',
299
- },
300
- })
301
- assert.equal(secondAttempt.statusCode, 429)
302
- assert.match(String(secondAttempt.body || ''), /尝试次数过多/)
303
- } finally {
304
- await relay.close()
305
- }
306
- })
307
-
308
- test('relay admin login uses POST and successful login sets admin cookie', async () => {
309
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-admin-login-'))
310
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-admin-login</body></html>\n', 'utf8')
311
-
312
- const relay = await startRelayServer({
313
- logger: false,
314
- webDistDir: tempDir,
315
- config: {
316
- host: '127.0.0.1',
317
- port: 0,
318
- accessCookieName: 'promptx_relay_access',
319
- adminCookieName: 'promptx_relay_admin',
320
- adminToken: 'relay-admin-token',
321
- tenantSource: 'test',
322
- tenants: [
323
- {
324
- key: 'user1',
325
- hosts: ['user1.promptx.test'],
326
- expectedDeviceId: 'user1-mac',
327
- deviceToken: 'token-1',
328
- accessToken: 'access-1',
329
- },
330
- ],
331
- },
332
- })
333
-
334
- try {
335
- const loginPage = await requestRelay({
336
- port: relay.port,
337
- host: 'relay-admin.promptx.test',
338
- path: '/relay/admin/login?redirect=%2Frelay%2Fadmin%2Fusage',
339
- headers: {
340
- accept: 'text/html',
341
- },
342
- })
343
- assert.equal(loginPage.statusCode, 200)
344
- assert.match(String(loginPage.body || ''), /action="\/relay\/admin\/login" method="post"/)
345
-
346
- const loginResult = await requestRelay({
347
- port: relay.port,
348
- host: 'relay-admin.promptx.test',
349
- path: '/relay/admin/login',
350
- method: 'POST',
351
- body: 'token=relay-admin-token&redirect=%2Frelay%2Fadmin%2Fusage',
352
- headers: {
353
- accept: 'text/html',
354
- 'content-type': 'application/x-www-form-urlencoded',
355
- },
356
- })
357
- assert.equal(loginResult.statusCode, 302)
358
- assert.equal(loginResult.headers.location, '/relay/admin/usage')
359
- assert.match(String(loginResult.headers['set-cookie'] || ''), /promptx_relay_admin=/)
360
- } finally {
361
- await relay.close()
362
- }
363
- })
364
-
365
- test('multi-tenant relay routes requests to matching device sockets without cross-talk', async () => {
366
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-web-'))
367
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-test</body></html>\n', 'utf8')
368
-
369
- const relay = await startRelayServer({
370
- logger: false,
371
- webDistDir: tempDir,
372
- config: {
373
- host: '127.0.0.1',
374
- port: 0,
375
- accessCookieName: 'promptx_relay_access',
376
- tenantSource: 'test',
377
- tenants: [
378
- {
379
- key: 'user1',
380
- hosts: ['user1.promptx.test'],
381
- expectedDeviceId: 'user1-mac',
382
- deviceToken: 'token-1',
383
- accessToken: '',
384
- },
385
- {
386
- key: 'user2',
387
- hosts: ['user2.promptx.test'],
388
- expectedDeviceId: 'user2-mac',
389
- deviceToken: 'token-2',
390
- accessToken: '',
391
- },
392
- ],
393
- },
394
- })
395
-
396
- const seenByTenant = {
397
- user1: [],
398
- user2: [],
399
- }
400
-
401
- function connectDevice({ tenantKey, host, deviceId, deviceToken }) {
402
- const socket = new WebSocket(`ws://127.0.0.1:${relay.port}/relay/connect`, {
403
- headers: {
404
- Host: host,
405
- 'x-forwarded-host': host,
406
- },
407
- })
408
- const pending = new Map()
409
- let acknowledged = false
410
-
411
- socket.on('message', (payload, isBinary) => {
412
- if (isBinary) {
413
- return
414
- }
415
-
416
- const message = JSON.parse(payload.toString('utf8'))
417
- if (message.type === 'hello.ack') {
418
- acknowledged = true
419
- return
420
- }
421
-
422
- if (message.type === 'request.start') {
423
- pending.set(message.requestId, {
424
- method: message.method,
425
- path: message.path,
426
- bodyChunks: [],
427
- })
428
- return
429
- }
430
-
431
- if (message.type === 'request.body') {
432
- pending.get(message.requestId)?.bodyChunks.push(Buffer.from(String(message.chunk || ''), 'base64'))
433
- return
434
- }
435
-
436
- if (message.type === 'request.end') {
437
- const record = pending.get(message.requestId)
438
- pending.delete(message.requestId)
439
- seenByTenant[tenantKey].push({
440
- path: record?.path || '',
441
- method: record?.method || '',
442
- body: Buffer.concat(record?.bodyChunks || []).toString('utf8'),
443
- })
444
-
445
- const responseBody = Buffer.from(JSON.stringify({
446
- tenant: tenantKey,
447
- path: record?.path || '',
448
- body: Buffer.concat(record?.bodyChunks || []).toString('utf8'),
449
- }))
450
-
451
- socket.send(JSON.stringify({
452
- type: 'response.start',
453
- requestId: message.requestId,
454
- status: 200,
455
- headers: {
456
- 'content-type': 'application/json',
457
- },
458
- }))
459
- socket.send(JSON.stringify({
460
- type: 'response.body',
461
- requestId: message.requestId,
462
- chunk: responseBody.toString('base64'),
463
- }))
464
- socket.send(JSON.stringify({
465
- type: 'response.end',
466
- requestId: message.requestId,
467
- }))
468
- }
469
- })
470
-
471
- return new Promise((resolve, reject) => {
472
- socket.once('open', () => {
473
- socket.send(JSON.stringify({
474
- type: 'hello',
475
- deviceId,
476
- deviceToken,
477
- version: 'test',
478
- }))
479
- resolve({
480
- socket,
481
- async waitUntilReady() {
482
- await waitFor(() => acknowledged === true)
483
- },
484
- close() {
485
- socket.close()
486
- },
487
- })
488
- })
489
- socket.once('error', reject)
490
- })
491
- }
492
-
493
- let user1Device = null
494
- let user2Device = null
495
- try {
496
- user1Device = await connectDevice({
497
- tenantKey: 'user1',
498
- host: 'user1.promptx.test',
499
- deviceId: 'user1-mac',
500
- deviceToken: 'token-1',
501
- })
502
- user2Device = await connectDevice({
503
- tenantKey: 'user2',
504
- host: 'user2.promptx.test',
505
- deviceId: 'user2-mac',
506
- deviceToken: 'token-2',
507
- })
508
-
509
- await user1Device.waitUntilReady()
510
- await user2Device.waitUntilReady()
511
-
512
- const [user1Response, user2Response] = await Promise.all([
513
- requestRelay({
514
- port: relay.port,
515
- host: 'user1.promptx.test',
516
- path: '/api/tenant-check',
517
- method: 'POST',
518
- body: { hello: 'user1' },
519
- }),
520
- requestRelay({
521
- port: relay.port,
522
- host: 'user2.promptx.test',
523
- path: '/api/tenant-check?source=user2',
524
- method: 'POST',
525
- body: { hello: 'user2' },
526
- }),
527
- ])
528
-
529
- const user1Payload = user1Response.body
530
- const user2Payload = user2Response.body
531
-
532
- assert.equal(user1Payload.tenant, 'user1')
533
- assert.equal(user2Payload.tenant, 'user2')
534
- assert.equal(seenByTenant.user1.length, 1)
535
- assert.equal(seenByTenant.user2.length, 1)
536
- assert.match(seenByTenant.user1[0].body, /user1/)
537
- assert.match(seenByTenant.user2[0].body, /user2/)
538
- assert.equal(seenByTenant.user1[0].path.includes('source=user2'), false)
539
- assert.equal(seenByTenant.user2[0].path.includes('source=user2'), true)
540
- } finally {
541
- user1Device?.close()
542
- user2Device?.close()
543
- await relay.close()
544
- }
545
- })
546
-
547
- test('relay server closes stale device connection after heartbeat timeout', async () => {
548
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-heartbeat-'))
549
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-heartbeat</body></html>\n', 'utf8')
550
-
551
- const relay = await startRelayServer({
552
- logger: false,
553
- webDistDir: tempDir,
554
- heartbeatIntervalMs: 40,
555
- heartbeatTimeoutMs: 90,
556
- config: {
557
- host: '127.0.0.1',
558
- port: 0,
559
- accessCookieName: 'promptx_relay_access',
560
- tenantSource: 'test',
561
- tenants: [
562
- {
563
- key: 'user1',
564
- hosts: ['user1.promptx.test'],
565
- expectedDeviceId: 'user1-mac',
566
- deviceToken: 'token-1',
567
- accessToken: '',
568
- },
569
- ],
570
- },
571
- })
572
-
573
- const socket = new WebSocket(`ws://127.0.0.1:${relay.port}/relay/connect`, {
574
- headers: {
575
- Host: 'user1.promptx.test',
576
- 'x-forwarded-host': 'user1.promptx.test',
577
- },
578
- })
579
-
580
- let acknowledged = false
581
- let closeInfo = null
582
-
583
- socket.on('message', (payload, isBinary) => {
584
- if (isBinary) {
585
- return
586
- }
587
- const message = JSON.parse(payload.toString('utf8'))
588
- if (message.type === 'hello.ack') {
589
- acknowledged = true
590
- }
591
- })
592
-
593
- socket.on('close', (code, reason) => {
594
- closeInfo = {
595
- code,
596
- reason: reason.toString('utf8'),
597
- }
598
- })
599
-
600
- try {
601
- await new Promise((resolve, reject) => {
602
- socket.once('open', resolve)
603
- socket.once('error', reject)
604
- })
605
-
606
- socket.send(JSON.stringify({
607
- type: 'hello',
608
- deviceId: 'user1-mac',
609
- deviceToken: 'token-1',
610
- version: 'test',
611
- }))
612
-
613
- await waitFor(() => acknowledged === true)
614
-
615
- socket.pong = () => {}
616
-
617
- await waitFor(() => closeInfo && closeInfo.reason === 'heartbeat_timeout', 2_000)
618
-
619
- const status = await requestRelay({
620
- port: relay.port,
621
- host: 'user1.promptx.test',
622
- path: '/relay/device-status',
623
- })
624
-
625
- assert.equal(status.statusCode, 200)
626
- assert.equal(status.body.deviceOnline, false)
627
- assert.equal(status.body.lastDisconnectReason, 'heartbeat_timeout')
628
- assert.equal(status.body.lastDisconnectCode, 4000)
629
- assert.equal(Array.isArray(status.body.recentEvents), true)
630
- assert.equal(status.body.recentEvents.some((event) => event.type === 'heartbeat_timeout'), true)
631
- assert.equal(status.body.recentEvents.some((event) => event.type === 'auth_ok'), true)
632
- } finally {
633
- socket.close()
634
- await relay.close()
635
- }
636
- })
637
-
638
- test('relay admin usage api returns today tenant activity summary', async () => {
639
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-relay-admin-usage-'))
640
- fs.writeFileSync(path.join(tempDir, 'index.html'), '<!doctype html><html><body>relay-admin-usage</body></html>\n', 'utf8')
641
-
642
- const relay = await startRelayServer({
643
- logger: false,
644
- webDistDir: tempDir,
645
- config: {
646
- host: '127.0.0.1',
647
- port: 0,
648
- accessCookieName: 'promptx_relay_access',
649
- adminCookieName: 'promptx_relay_admin',
650
- adminToken: 'relay-admin-token',
651
- usageFile: path.join(tempDir, 'relay-usage.json'),
652
- tenantSource: 'test',
653
- tenants: [
654
- {
655
- key: 'user1',
656
- hosts: ['user1.promptx.test'],
657
- expectedDeviceId: 'user1-mac',
658
- deviceToken: 'token-1',
659
- accessToken: '',
660
- },
661
- {
662
- key: 'user2',
663
- hosts: ['user2.promptx.test'],
664
- expectedDeviceId: 'user2-win',
665
- deviceToken: 'token-2',
666
- accessToken: '',
667
- },
668
- ],
669
- },
670
- })
671
-
672
- function connectDevice({ host, deviceId, deviceToken }) {
673
- const socket = new WebSocket(`ws://127.0.0.1:${relay.port}/relay/connect`, {
674
- headers: {
675
- Host: host,
676
- 'x-forwarded-host': host,
677
- },
678
- })
679
- const pending = new Map()
680
- let acknowledged = false
681
-
682
- socket.on('message', (payload, isBinary) => {
683
- if (isBinary) {
684
- return
685
- }
686
-
687
- const message = JSON.parse(payload.toString('utf8'))
688
- if (message.type === 'hello.ack') {
689
- acknowledged = true
690
- return
691
- }
692
-
693
- if (message.type === 'request.start') {
694
- pending.set(message.requestId, true)
695
- return
696
- }
697
-
698
- if (message.type === 'request.body') {
699
- return
700
- }
701
-
702
- if (message.type === 'request.end') {
703
- socket.send(JSON.stringify({
704
- type: 'response.start',
705
- requestId: message.requestId,
706
- status: 200,
707
- headers: {
708
- 'content-type': 'application/json; charset=utf-8',
709
- },
710
- }))
711
- socket.send(JSON.stringify({
712
- type: 'response.body',
713
- requestId: message.requestId,
714
- chunk: Buffer.from(JSON.stringify({ ok: true })).toString('base64'),
715
- }))
716
- socket.send(JSON.stringify({
717
- type: 'response.end',
718
- requestId: message.requestId,
719
- }))
720
- pending.delete(message.requestId)
721
- }
722
- })
723
-
724
- socket.once('open', () => {
725
- socket.send(JSON.stringify({
726
- type: 'hello',
727
- deviceId,
728
- deviceToken,
729
- version: '0.1.12',
730
- }))
731
- })
732
-
733
- return {
734
- socket,
735
- waitForAck() {
736
- return waitFor(() => acknowledged === true)
737
- },
738
- }
739
- }
740
-
741
- try {
742
- const user1Device = connectDevice({
743
- host: 'user1.promptx.test',
744
- deviceId: 'user1-mac',
745
- deviceToken: 'token-1',
746
- })
747
- const user2Device = connectDevice({
748
- host: 'user2.promptx.test',
749
- deviceId: 'user2-win',
750
- deviceToken: 'token-2',
751
- })
752
-
753
- await Promise.all([user1Device.waitForAck(), user2Device.waitForAck()])
754
-
755
- const proxyResponse = await requestRelay({
756
- port: relay.port,
757
- host: 'user1.promptx.test',
758
- path: '/api/tasks',
759
- headers: {
760
- accept: 'application/json',
761
- },
762
- })
763
- assert.equal(proxyResponse.statusCode, 200)
764
- assert.deepEqual(proxyResponse.body, { ok: true })
765
-
766
- await delay(350)
767
-
768
- const usageResponse = await requestRelay({
769
- port: relay.port,
770
- host: 'relay-admin.promptx.test',
771
- path: '/relay/admin/api/usage?days=7',
772
- headers: {
773
- accept: 'application/json',
774
- authorization: 'Bearer relay-admin-token',
775
- },
776
- })
777
-
778
- assert.equal(usageResponse.statusCode, 200)
779
- assert.equal(usageResponse.body.ok, true)
780
- assert.equal(usageResponse.body.today.tenantCount, 2)
781
- assert.equal(usageResponse.body.today.connectCount, 2)
782
- assert.equal(usageResponse.body.today.proxyRequestCount, 1)
783
-
784
- const user1 = usageResponse.body.today.tenants.find((item) => item.tenantKey === 'user1')
785
- const user2 = usageResponse.body.today.tenants.find((item) => item.tenantKey === 'user2')
786
- assert.equal(user1.connectCount, 1)
787
- assert.equal(user1.proxyRequestCount, 1)
788
- assert.equal(user1.apiRequestCount, 1)
789
- assert.equal(user1.lastDeviceId, 'user1-mac')
790
- assert.equal(user2.connectCount, 1)
791
- assert.equal(user2.proxyRequestCount, 0)
792
-
793
- user1Device.socket.close()
794
- user2Device.socket.close()
795
- } finally {
796
- await relay.close()
797
- }
798
- })