happy-imou-cloud 2.0.12 → 2.0.13

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 (35) hide show
  1. package/bin/happy-cloud.mjs +1 -1
  2. package/dist/ConversationHistory-V3VLmjJf.cjs +868 -0
  3. package/dist/ConversationHistory-_ciJNIgH.mjs +856 -0
  4. package/dist/{api-BxXBKBUy.mjs → api-D1meoL-9.mjs} +2 -2
  5. package/dist/{api-B4g8VLUn.cjs → api-DH5-IqeM.cjs} +2 -2
  6. package/dist/{command-CHiLfBa4.mjs → command-CMvWClny.mjs} +3 -3
  7. package/dist/{command-DVt_YmE6.cjs → command-Ch8Dgidj.cjs} +3 -3
  8. package/dist/createKeepAliveController-C5cQlDRr.mjs +51 -0
  9. package/dist/createKeepAliveController-DO8H6d5E.cjs +54 -0
  10. package/dist/{index-CWom7mSf.cjs → index-CryJfCh5.cjs} +10 -11
  11. package/dist/{index-DaAkW0VN.mjs → index-Cxrx9m5D.mjs} +9 -9
  12. package/dist/index.cjs +3 -3
  13. package/dist/index.mjs +3 -3
  14. package/dist/lib.cjs +1 -1
  15. package/dist/lib.mjs +1 -1
  16. package/dist/{persistence-8pNEvzaq.mjs → persistence-9Iu0wGNM.mjs} +1 -1
  17. package/dist/{persistence-DScOANDE.cjs → persistence-Bl3FYvwd.cjs} +1 -1
  18. package/dist/{registerKillSessionHandler-CNNguWyD.mjs → registerKillSessionHandler-BElGmD1E.mjs} +5 -541
  19. package/dist/{registerKillSessionHandler-Dr1inhTc.cjs → registerKillSessionHandler-BjkY-oUn.cjs} +4 -549
  20. package/dist/{runClaude-h-8llTrI.cjs → runClaude-CDZxAF3l.cjs} +129 -630
  21. package/dist/{runClaude-BcvOkIwh.mjs → runClaude-D7dF4RDM.mjs} +126 -627
  22. package/dist/{runCodex-CA58KUHf.cjs → runCodex-Cik8VzFs.cjs} +224 -17
  23. package/dist/{runCodex-ClJUgipy.mjs → runCodex-DnGz1XES.mjs} +213 -6
  24. package/dist/{runGemini-dAr7Gcn8.mjs → runGemini-B8tXMHeL.mjs} +5 -5
  25. package/dist/{runGemini-IFHhFMSU.cjs → runGemini-BM2BQ4I7.cjs} +13 -13
  26. package/package.json +9 -9
  27. package/scripts/build.mjs +66 -66
  28. package/scripts/devtools/README.md +9 -9
  29. package/scripts/e2e/fake-codex-acp-agent.mjs +139 -139
  30. package/scripts/e2e/local-server-session-roundtrip.mjs +1063 -1063
  31. package/scripts/release-smoke.mjs +202 -202
  32. package/dist/BaseReasoningProcessor-BrKUKAOr.cjs +0 -323
  33. package/dist/BaseReasoningProcessor-DrHf5B98.mjs +0 -320
  34. package/dist/ProviderSelectionHandler-BCDvmifJ.cjs +0 -265
  35. package/dist/ProviderSelectionHandler-BuZarTDc.mjs +0 -261
@@ -1,1063 +1,1063 @@
1
- #!/usr/bin/env node
2
-
3
- import assert from 'node:assert/strict';
4
- import { spawn, spawnSync } from 'node:child_process';
5
- import {
6
- createHash,
7
- createHmac,
8
- hkdfSync,
9
- randomBytes,
10
- randomUUID,
11
- } from 'node:crypto';
12
- import {
13
- existsSync,
14
- mkdirSync,
15
- mkdtempSync,
16
- readFileSync,
17
- rmSync,
18
- writeFileSync,
19
- } from 'node:fs';
20
- import { createServer } from 'node:net';
21
- import { setTimeout as delay } from 'node:timers/promises';
22
- import { dirname, join, resolve } from 'node:path';
23
- import { tmpdir } from 'node:os';
24
- import { fileURLToPath, pathToFileURL } from 'node:url';
25
-
26
- import { io } from 'socket.io-client';
27
- import tweetnacl from 'tweetnacl';
28
-
29
- const __dirname = dirname(fileURLToPath(import.meta.url));
30
- const packageRoot = resolve(__dirname, '..', '..');
31
- const workspaceRoot = resolve(packageRoot, '..', '..');
32
- const serverRoot = resolve(workspaceRoot, 'packages', 'happy-server');
33
- const cliBin = resolve(packageRoot, 'bin', 'happy-cloud.mjs');
34
- const demoProjectDir = resolve(packageRoot, 'demo-project');
35
- const fakeAcpAgentPath = resolve(packageRoot, 'scripts', 'e2e', 'fake-codex-acp-agent.mjs');
36
- const cliVersion = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')).version;
37
-
38
- const serverPort = process.env.HAPPY_CLI_E2E_SERVER_PORT || process.env.HAPPY_SERVER_TEST_PORT || '3005';
39
- const baseUrl = process.env.HAPPY_CLI_E2E_SERVER_URL || process.env.HAPPY_SERVER_BASE_URL || `http://127.0.0.1:${serverPort}`;
40
- const configuredDaemonPort = process.env.HAPPY_CLI_E2E_DAEMON_PORT || process.env.HAPPY_CLOUD_DAEMON_PORT || '';
41
- const fakeAcpReply = process.env.HAPPY_E2E_FAKE_ACP_RESPONSE || 'LOCAL_E2E_ACK';
42
- const skipServerBoot = process.env.HAPPY_CLI_E2E_SKIP_SERVER_BOOT === 'true' || process.env.HAPPY_SERVER_LIVE_SKIP_BOOT === 'true';
43
-
44
- function encodeBase64(data) {
45
- return Buffer.from(data).toString('base64');
46
- }
47
-
48
- function decodeBase64(data) {
49
- return new Uint8Array(Buffer.from(data, 'base64'));
50
- }
51
-
52
- function normalizePlatform(platform) {
53
- switch (platform) {
54
- case 'win32':
55
- return 'windows';
56
- case 'darwin':
57
- return 'macos';
58
- default:
59
- return platform;
60
- }
61
- }
62
-
63
- function buildClientHeaders(version = cliVersion) {
64
- return {
65
- 'X-Client-Name': 'happy-cli',
66
- 'X-Client-Version': version,
67
- 'X-Platform': normalizePlatform(process.platform),
68
- };
69
- }
70
-
71
- function canonicalizeQuery(url) {
72
- const entries = [...url.searchParams.entries()].sort(([aKey, aValue], [bKey, bValue]) => {
73
- if (aKey === bKey) {
74
- return aValue.localeCompare(bValue);
75
- }
76
- return aKey.localeCompare(bKey);
77
- });
78
- return entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
79
- }
80
-
81
- function hashBody(body) {
82
- if (body == null) {
83
- return createHash('sha256').update('').digest('hex');
84
- }
85
-
86
- const serialized = typeof body === 'string' ? body : JSON.stringify(body);
87
- return createHash('sha256').update(serialized).digest('hex');
88
- }
89
-
90
- function deriveSigningSecret(signing) {
91
- return Buffer.from(
92
- hkdfSync(
93
- 'sha256',
94
- Buffer.from(signing.seed, 'base64'),
95
- Buffer.alloc(0),
96
- Buffer.from('request-sign-v1'),
97
- 32,
98
- ),
99
- );
100
- }
101
-
102
- function signCanonical(signing, canonical) {
103
- return createHmac('sha256', deriveSigningSecret(signing)).update(canonical).digest('base64');
104
- }
105
-
106
- function buildHttpCanonicalString({ method, url, timestamp, nonce, bodySha256 }) {
107
- const parsed = new URL(url);
108
- return [
109
- method.toUpperCase(),
110
- parsed.pathname,
111
- canonicalizeQuery(parsed),
112
- timestamp,
113
- nonce,
114
- bodySha256,
115
- ].join('\n');
116
- }
117
-
118
- function buildWebSocketCanonicalString({ clientType, sessionId, machineId, timestamp, nonce }) {
119
- const params = new URLSearchParams();
120
- params.set('clientType', clientType);
121
- if (sessionId) {
122
- params.set('sessionId', sessionId);
123
- }
124
- if (machineId) {
125
- params.set('machineId', machineId);
126
- }
127
-
128
- const url = new URL('https://unused.local/v1/updates');
129
- url.search = params.toString();
130
-
131
- return [
132
- 'WS_CONNECT',
133
- '/v1/updates',
134
- canonicalizeQuery(url),
135
- timestamp,
136
- nonce,
137
- ].join('\n');
138
- }
139
-
140
- function buildSignedHeaders({ token, signing, method, url, body }) {
141
- const timestamp = String(Date.now());
142
- const nonce = randomUUID();
143
- const bodySha256 = hashBody(body);
144
- const canonical = buildHttpCanonicalString({
145
- method,
146
- url,
147
- timestamp,
148
- nonce,
149
- bodySha256,
150
- });
151
-
152
- return {
153
- ...buildClientHeaders(),
154
- Authorization: `Bearer ${token}`,
155
- ...(body != null ? { 'Content-Type': 'application/json' } : {}),
156
- 'X-Sign-Version': String(signing.version),
157
- 'X-Key-Id': signing.keyId,
158
- 'X-Timestamp': timestamp,
159
- 'X-Nonce': nonce,
160
- 'X-Body-SHA256': bodySha256,
161
- 'X-Signature': signCanonical(signing, canonical),
162
- };
163
- }
164
-
165
- function buildSocketAuth({ token, signing, clientType, sessionId, machineId }) {
166
- const auth = {
167
- token,
168
- clientType,
169
- clientName: 'happy-cli',
170
- clientVersion: cliVersion,
171
- ...(sessionId ? { sessionId } : {}),
172
- ...(machineId ? { machineId } : {}),
173
- };
174
-
175
- if (!signing) {
176
- return auth;
177
- }
178
-
179
- const timestamp = String(Date.now());
180
- const nonce = randomUUID();
181
- const canonical = buildWebSocketCanonicalString({
182
- clientType,
183
- sessionId,
184
- machineId,
185
- timestamp,
186
- nonce,
187
- });
188
-
189
- return {
190
- ...auth,
191
- signVersion: signing.version,
192
- timestamp,
193
- nonce,
194
- signature: signCanonical(signing, canonical),
195
- };
196
- }
197
-
198
- function authChallenge(secret) {
199
- const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
200
- const challenge = new Uint8Array(randomBytes(32));
201
- const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
202
-
203
- return {
204
- challenge,
205
- publicKey: keypair.publicKey,
206
- signature,
207
- };
208
- }
209
-
210
- function encryptLegacy(data, secret) {
211
- const nonce = new Uint8Array(randomBytes(tweetnacl.secretbox.nonceLength));
212
- const encrypted = tweetnacl.secretbox(
213
- new TextEncoder().encode(JSON.stringify(data)),
214
- nonce,
215
- secret,
216
- );
217
- const result = new Uint8Array(nonce.length + encrypted.length);
218
- result.set(nonce, 0);
219
- result.set(encrypted, nonce.length);
220
- return result;
221
- }
222
-
223
- function decryptLegacy(data, secret) {
224
- const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
225
- const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
226
- const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
227
- if (!decrypted) {
228
- return null;
229
- }
230
- return JSON.parse(new TextDecoder().decode(decrypted));
231
- }
232
-
233
- async function requestJson(url, init = {}) {
234
- const response = await fetch(url, init);
235
- const text = await response.text();
236
-
237
- let json = null;
238
- try {
239
- json = text ? JSON.parse(text) : null;
240
- } catch {
241
- json = { raw: text };
242
- }
243
-
244
- return {
245
- status: response.status,
246
- ok: response.ok,
247
- json,
248
- };
249
- }
250
-
251
- async function isServerHealthy(url) {
252
- try {
253
- const response = await fetch(`${url}/`);
254
- return response.ok;
255
- } catch {
256
- return false;
257
- }
258
- }
259
-
260
- async function waitForServer(url, timeoutMs = 30_000) {
261
- const startedAt = Date.now();
262
- while (Date.now() - startedAt < timeoutMs) {
263
- if (await isServerHealthy(url)) {
264
- return;
265
- }
266
- await delay(1_000);
267
- }
268
- throw new Error(`Server did not become ready within ${timeoutMs}ms`);
269
- }
270
-
271
- async function waitForCondition(fn, { timeoutMs = 15_000, intervalMs = 250, description = 'condition' } = {}) {
272
- const startedAt = Date.now();
273
- while (Date.now() - startedAt < timeoutMs) {
274
- const result = await fn();
275
- if (result) {
276
- return result;
277
- }
278
- await delay(intervalMs);
279
- }
280
- throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms`);
281
- }
282
-
283
- async function resolveDaemonPort() {
284
- if (configuredDaemonPort) {
285
- return configuredDaemonPort;
286
- }
287
-
288
- const server = createServer();
289
- try {
290
- await new Promise((resolve, reject) => {
291
- server.once('error', reject);
292
- server.listen(0, '127.0.0.1', resolve);
293
- });
294
-
295
- const address = server.address();
296
- if (!address || typeof address === 'string') {
297
- throw new Error('Failed to resolve an available daemon port');
298
- }
299
-
300
- return String(address.port);
301
- } finally {
302
- await new Promise((resolve) => {
303
- server.close(() => resolve());
304
- }).catch(() => {});
305
- }
306
- }
307
-
308
- function resolveTsxCli() {
309
- const candidates = [
310
- resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
311
- resolve(workspaceRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
312
- ];
313
-
314
- for (const candidate of candidates) {
315
- if (existsSync(candidate)) {
316
- return candidate;
317
- }
318
- }
319
-
320
- throw new Error('Could not resolve tsx CLI. Run yarn install first.');
321
- }
322
-
323
- function buildServerEnv() {
324
- return {
325
- ...process.env,
326
- DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/handy',
327
- TEST_USERS: process.env.TEST_USERS || 'apple:apple123',
328
- NODE_ENV: 'development',
329
- PORT: serverPort,
330
- ALLOW_LEGACY_UNSIGNED_REQUESTS: 'true',
331
- REQUEST_SIGN_ENFORCE_MIN_APP_VERSION: '1.8.0',
332
- LEGACY_SIGN_SUNSET_AT: '2026-12-31T00:00:00Z',
333
- };
334
- }
335
-
336
- function writeCliState({ homeDir, token, signing, secret, machineId }) {
337
- mkdirSync(homeDir, { recursive: true });
338
-
339
- writeFileSync(
340
- join(homeDir, 'access.key'),
341
- JSON.stringify(
342
- {
343
- secret: encodeBase64(secret),
344
- token,
345
- signing,
346
- },
347
- null,
348
- 2,
349
- ),
350
- 'utf8',
351
- );
352
-
353
- writeFileSync(
354
- join(homeDir, 'settings.json'),
355
- JSON.stringify(
356
- {
357
- schemaVersion: 2,
358
- onboardingCompleted: true,
359
- machineId,
360
- machineIdConfirmedByServer: false,
361
- profiles: [],
362
- localEnvironmentVariables: {},
363
- },
364
- null,
365
- 2,
366
- ),
367
- 'utf8',
368
- );
369
- }
370
-
371
- async function waitForSocketConnect(socket, timeoutMs = 10_000) {
372
- if (socket?.connected) {
373
- return;
374
- }
375
-
376
- return new Promise((resolve, reject) => {
377
- const timer = setTimeout(() => {
378
- cleanup();
379
- reject(new Error('Timed out waiting for socket connect'));
380
- }, timeoutMs);
381
-
382
- const cleanup = () => {
383
- clearTimeout(timer);
384
- socket.off('connect', onConnect);
385
- socket.off('connect_error', onError);
386
- };
387
-
388
- const onConnect = () => {
389
- cleanup();
390
- resolve();
391
- };
392
-
393
- const onError = (error) => {
394
- cleanup();
395
- reject(error instanceof Error ? error : new Error(String(error)));
396
- };
397
-
398
- socket.on('connect', onConnect);
399
- socket.on('connect_error', onError);
400
- });
401
- }
402
-
403
- async function waitForObserverMessage({ socket, secret, predicate, timeoutMs = 15_000 }) {
404
- return new Promise((resolve, reject) => {
405
- const timer = setTimeout(() => {
406
- cleanup();
407
- reject(new Error('Timed out waiting for observer update'));
408
- }, timeoutMs);
409
-
410
- const cleanup = () => {
411
- clearTimeout(timer);
412
- socket.off('update', onUpdate);
413
- socket.off('connect_error', onError);
414
- };
415
-
416
- const onError = (error) => {
417
- cleanup();
418
- reject(error instanceof Error ? error : new Error(String(error)));
419
- };
420
-
421
- const onUpdate = (update) => {
422
- try {
423
- if (!update?.body || update.body.t !== 'new-message') {
424
- return;
425
- }
426
- const encrypted = update.body?.message?.content;
427
- if (!encrypted || encrypted.t !== 'encrypted' || typeof encrypted.c !== 'string') {
428
- return;
429
- }
430
-
431
- const body = decryptLegacy(decodeBase64(encrypted.c), secret);
432
- if (!body) {
433
- return;
434
- }
435
-
436
- if (predicate(body, update)) {
437
- cleanup();
438
- resolve({ body, update });
439
- }
440
- } catch (error) {
441
- cleanup();
442
- reject(error);
443
- }
444
- };
445
-
446
- socket.on('update', onUpdate);
447
- socket.on('connect_error', onError);
448
- });
449
- }
450
-
451
- async function waitForClientMessage({ client, predicate, timeoutMs = 15_000 }) {
452
- return new Promise((resolve, reject) => {
453
- const timer = setTimeout(() => {
454
- cleanup();
455
- reject(new Error('Timed out waiting for client message'));
456
- }, timeoutMs);
457
-
458
- const cleanup = () => {
459
- clearTimeout(timer);
460
- client.off('message', onMessage);
461
- };
462
-
463
- const onMessage = (body) => {
464
- try {
465
- if (predicate(body)) {
466
- cleanup();
467
- resolve(body);
468
- }
469
- } catch (error) {
470
- cleanup();
471
- reject(error);
472
- }
473
- };
474
-
475
- client.on('message', onMessage);
476
- });
477
- }
478
-
479
- async function waitForChildExit(child, timeoutMs = 5_000) {
480
- if (!child || child.exitCode !== null) {
481
- return true;
482
- }
483
-
484
- return await new Promise((resolve) => {
485
- const timer = setTimeout(() => {
486
- cleanup();
487
- resolve(child.exitCode !== null);
488
- }, timeoutMs);
489
-
490
- const cleanup = () => {
491
- clearTimeout(timer);
492
- child.off('exit', onExit);
493
- child.off('error', onError);
494
- };
495
-
496
- const onExit = () => {
497
- cleanup();
498
- resolve(true);
499
- };
500
-
501
- const onError = () => {
502
- cleanup();
503
- resolve(child.exitCode !== null);
504
- };
505
-
506
- child.once('exit', onExit);
507
- child.once('error', onError);
508
- });
509
- }
510
-
511
- async function killProcessTree(child) {
512
- if (!child?.pid) {
513
- return;
514
- }
515
-
516
- if (process.platform === 'win32') {
517
- await new Promise((resolve) => {
518
- const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
519
- killer.once('exit', resolve);
520
- killer.once('error', resolve);
521
- });
522
- return;
523
- }
524
-
525
- try {
526
- process.kill(child.pid, 'SIGKILL');
527
- } catch {
528
- // Ignore if already exited.
529
- }
530
- }
531
-
532
- async function stopChildProcess(child) {
533
- if (!child) {
534
- return;
535
- }
536
-
537
- child.kill('SIGTERM');
538
- const exited = await waitForChildExit(child, 2_000);
539
- if (!exited) {
540
- await killProcessTree(child);
541
- await waitForChildExit(child, 2_000);
542
- }
543
- }
544
-
545
- async function authenticateWithKey(secret) {
546
- const { challenge, publicKey, signature } = authChallenge(secret);
547
- const response = await requestJson(`${baseUrl}/v1/auth`, {
548
- method: 'POST',
549
- headers: {
550
- ...buildClientHeaders(),
551
- 'Content-Type': 'application/json',
552
- },
553
- body: JSON.stringify({
554
- challenge: encodeBase64(challenge),
555
- publicKey: encodeBase64(publicKey),
556
- signature: encodeBase64(signature),
557
- }),
558
- });
559
-
560
- assert.equal(response.status, 200, `auth failed: ${JSON.stringify(response.json)}`);
561
- assert.equal(response.json?.success, true, `auth response invalid: ${JSON.stringify(response.json)}`);
562
-
563
- return {
564
- token: response.json.token,
565
- signing: response.json.signing,
566
- };
567
- }
568
-
569
- async function fetchMachine({ token, signing, machineId }) {
570
- const url = `${baseUrl}/v1/machines/${machineId}`;
571
- const response = await requestJson(url, {
572
- method: 'GET',
573
- headers: buildSignedHeaders({
574
- token,
575
- signing,
576
- method: 'GET',
577
- url,
578
- body: null,
579
- }),
580
- });
581
-
582
- if (response.status === 404) {
583
- return null;
584
- }
585
-
586
- assert.equal(response.status, 200, `fetch machine failed: ${JSON.stringify(response.json)}`);
587
- return response.json.machine;
588
- }
589
-
590
- async function listSessions({ token, signing }) {
591
- const url = `${baseUrl}/v1/sessions`;
592
- const response = await requestJson(url, {
593
- method: 'GET',
594
- headers: buildSignedHeaders({
595
- token,
596
- signing,
597
- method: 'GET',
598
- url,
599
- body: null,
600
- }),
601
- });
602
-
603
- assert.equal(response.status, 200, `list sessions failed: ${JSON.stringify(response.json)}`);
604
- return response.json.sessions || [];
605
- }
606
-
607
- async function fetchMessages({ token, signing, sessionId }) {
608
- const url = `${baseUrl}/v1/sessions/${sessionId}/messages`;
609
- const response = await requestJson(url, {
610
- method: 'GET',
611
- headers: buildSignedHeaders({
612
- token,
613
- signing,
614
- method: 'GET',
615
- url,
616
- body: null,
617
- }),
618
- });
619
-
620
- assert.equal(response.status, 200, `fetch messages failed: ${JSON.stringify(response.json)}`);
621
- return response.json.messages || [];
622
- }
623
-
624
- async function fetchSessionRecord({ token, signing, secret, sessionId }) {
625
- const sessions = await listSessions({
626
- token,
627
- signing,
628
- });
629
-
630
- const match = sessions.find((candidate) => candidate.id === sessionId);
631
- if (!match) {
632
- return null;
633
- }
634
-
635
- return decryptSessionRecord(match, secret);
636
- }
637
-
638
- function decryptSessionRecord(record, secret) {
639
- return {
640
- ...record,
641
- metadataDecoded: record.metadata ? decryptLegacy(decodeBase64(record.metadata), secret) : null,
642
- agentStateDecoded: record.agentState ? decryptLegacy(decodeBase64(record.agentState), secret) : null,
643
- };
644
- }
645
-
646
- function decodePersistedMessageContent(content, secret) {
647
- const encoded = typeof content === 'string'
648
- ? content
649
- : (content && typeof content === 'object' && typeof content.c === 'string' ? content.c : null);
650
-
651
- if (!encoded) {
652
- return null;
653
- }
654
-
655
- return decryptLegacy(decodeBase64(encoded), secret);
656
- }
657
-
658
- async function stopDaemon(cliEnv) {
659
- spawnSync(process.execPath, [cliBin, 'daemon', 'stop'], {
660
- cwd: packageRoot,
661
- env: cliEnv,
662
- stdio: 'ignore',
663
- timeout: 5_000,
664
- });
665
- }
666
-
667
- async function sendUserMessageWithAck({ client, sessionId, secret, text, localId, timeoutMs = 10_000 }) {
668
- const socket = client?.socket;
669
- if (!socket?.connected) {
670
- throw new Error('Sender socket is not connected');
671
- }
672
-
673
- const encrypted = encodeBase64(encryptLegacy({
674
- role: 'user',
675
- content: {
676
- type: 'text',
677
- text,
678
- },
679
- localKey: localId,
680
- meta: {
681
- sentFrom: 'cli',
682
- },
683
- }, secret));
684
-
685
- const ack = await Promise.race([
686
- socket.emitWithAck('message', {
687
- sid: sessionId,
688
- message: encrypted,
689
- localId,
690
- }),
691
- delay(timeoutMs).then(() => {
692
- throw new Error('Timed out waiting for sender ack');
693
- }),
694
- ]);
695
-
696
- assert.equal(ack?.ok, true, `sender ack failed: ${JSON.stringify(ack)}`);
697
- assert.equal(ack?.sessionId, sessionId, `sender ack session mismatch: ${JSON.stringify(ack)}`);
698
- assert.equal(ack?.status, 'accepted', `sender ack status unexpected: ${JSON.stringify(ack)}`);
699
- return ack;
700
- }
701
-
702
- async function main() {
703
- const tempRoot = mkdtempSync(join(tmpdir(), 'happy-cli-local-e2e-'));
704
- const cliHomeDir = join(tempRoot, '.happy-cloud-e2e');
705
- const fakeAcpLogPath = join(tempRoot, 'fake-codex-acp.log');
706
- const daemonPort = await resolveDaemonPort();
707
- const machineId = randomUUID();
708
- const secret = new Uint8Array(randomBytes(32));
709
- const userPrompt = `Please reply with ${fakeAcpReply} only.`;
710
- const userLocalId = `e2e-user-${randomUUID()}`;
711
-
712
- let server = null;
713
- let serverOwnedByScript = false;
714
- let cli = null;
715
- let observerClient = null;
716
- let senderClient = null;
717
- let cliStdout = '';
718
- let cliStderr = '';
719
- let serverStdout = '';
720
- let serverStderr = '';
721
-
722
- const cliEnv = {
723
- ...process.env,
724
- HAPPY_SERVER_URL: baseUrl,
725
- HAPPY_CLOUD_SERVER_URL: baseUrl,
726
- HAPPY_HOME_DIR: cliHomeDir,
727
- HAPPY_CLOUD_HOME_DIR: cliHomeDir,
728
- HAPPY_CLOUD_DAEMON_PORT: daemonPort,
729
- HAPPY_CODEX_ACP_COMMAND: process.execPath,
730
- HAPPY_DISABLE_CAFFEINATE: 'true',
731
- HAPPY_E2E_FAKE_ACP_RESPONSE: fakeAcpReply,
732
- HAPPY_E2E_FAKE_ACP_LOG: fakeAcpLogPath,
733
- NO_COLOR: '1',
734
- };
735
-
736
- try {
737
- if (await isServerHealthy(baseUrl)) {
738
- serverOwnedByScript = false;
739
- } else {
740
- if (skipServerBoot) {
741
- throw new Error(`Local server is not reachable at ${baseUrl}, and boot was skipped.`);
742
- }
743
-
744
- serverOwnedByScript = true;
745
- server = spawn(process.execPath, [
746
- resolveTsxCli(),
747
- '--env-file=.env',
748
- '--env-file=.env.dev',
749
- './sources/main.ts',
750
- ], {
751
- cwd: serverRoot,
752
- env: buildServerEnv(),
753
- stdio: ['ignore', 'pipe', 'pipe'],
754
- });
755
-
756
- server.stdout?.on('data', (chunk) => {
757
- serverStdout += chunk.toString();
758
- });
759
- server.stderr?.on('data', (chunk) => {
760
- serverStderr += chunk.toString();
761
- });
762
- }
763
-
764
- await waitForServer(baseUrl);
765
-
766
- const auth = await authenticateWithKey(secret);
767
- writeCliState({
768
- homeDir: cliHomeDir,
769
- token: auth.token,
770
- signing: auth.signing,
771
- secret,
772
- machineId,
773
- });
774
-
775
- cli = spawn(process.execPath, [
776
- cliBin,
777
- 'codex',
778
- fakeAcpAgentPath,
779
- '--happy-starting-mode',
780
- 'remote',
781
- '--started-by',
782
- 'daemon',
783
- ], {
784
- cwd: demoProjectDir,
785
- env: cliEnv,
786
- stdio: ['ignore', 'pipe', 'pipe'],
787
- });
788
-
789
- cli.stdout?.on('data', (chunk) => {
790
- cliStdout += chunk.toString();
791
- });
792
- cli.stderr?.on('data', (chunk) => {
793
- cliStderr += chunk.toString();
794
- });
795
-
796
- const daemonState = await waitForCondition(
797
- async () => {
798
- const statePath = join(cliHomeDir, 'daemon.state.json');
799
- if (!existsSync(statePath)) {
800
- return null;
801
- }
802
-
803
- try {
804
- return JSON.parse(readFileSync(statePath, 'utf8'));
805
- } catch {
806
- return null;
807
- }
808
- },
809
- {
810
- timeoutMs: 20_000,
811
- description: 'daemon state file',
812
- },
813
- );
814
-
815
- const machine = await waitForCondition(
816
- async () => {
817
- const result = await fetchMachine({
818
- token: auth.token,
819
- signing: auth.signing,
820
- machineId,
821
- });
822
- if (!result) {
823
- return null;
824
- }
825
-
826
- const metadata = result.metadata ? decryptLegacy(decodeBase64(result.metadata), secret) : null;
827
- return {
828
- ...result,
829
- metadataDecoded: metadata,
830
- };
831
- },
832
- {
833
- timeoutMs: 20_000,
834
- description: 'machine registration',
835
- },
836
- );
837
-
838
- let session = await waitForCondition(
839
- async () => {
840
- const sessions = await listSessions({
841
- token: auth.token,
842
- signing: auth.signing,
843
- });
844
-
845
- for (const rawSession of sessions) {
846
- const decodedSession = decryptSessionRecord(rawSession, secret);
847
- if (
848
- decodedSession.metadataDecoded?.path === demoProjectDir &&
849
- decodedSession.metadataDecoded?.machineId === machineId
850
- ) {
851
- return decodedSession;
852
- }
853
- }
854
-
855
- return null;
856
- },
857
- {
858
- timeoutMs: 20_000,
859
- description: 'CLI session registration',
860
- },
861
- );
862
-
863
- session = await waitForCondition(
864
- async () => {
865
- const latest = await fetchSessionRecord({
866
- token: auth.token,
867
- signing: auth.signing,
868
- secret,
869
- sessionId: session.id,
870
- });
871
-
872
- if (!latest) {
873
- return null;
874
- }
875
-
876
- if (latest.agentStateDecoded?.controlledByUser === false) {
877
- return latest;
878
- }
879
-
880
- return null;
881
- },
882
- {
883
- timeoutMs: 20_000,
884
- description: 'CLI session ready state',
885
- },
886
- );
887
-
888
- process.env.HAPPY_SERVER_URL = baseUrl;
889
- process.env.HAPPY_CLOUD_SERVER_URL = baseUrl;
890
- process.env.HAPPY_HOME_DIR = cliHomeDir;
891
- process.env.HAPPY_CLOUD_HOME_DIR = cliHomeDir;
892
-
893
- const { ApiSessionClient } = await import(pathToFileURL(resolve(packageRoot, 'dist', 'lib.mjs')).href);
894
- const sessionDescriptor = {
895
- id: session.id,
896
- seq: session.seq,
897
- metadata: session.metadataDecoded,
898
- metadataVersion: session.metadataVersion,
899
- agentState: session.agentStateDecoded,
900
- agentStateVersion: session.agentStateVersion,
901
- encryptionKey: secret,
902
- encryptionVariant: 'legacy',
903
- };
904
- const credentialsDescriptor = {
905
- token: auth.token,
906
- signing: auth.signing,
907
- encryption: {
908
- type: 'legacy',
909
- secret,
910
- },
911
- };
912
-
913
- observerClient = new ApiSessionClient(credentialsDescriptor, sessionDescriptor);
914
- senderClient = new ApiSessionClient(credentialsDescriptor, sessionDescriptor);
915
-
916
- await waitForSocketConnect(observerClient.socket);
917
- await waitForSocketConnect(senderClient.socket);
918
-
919
- const senderAck = await sendUserMessageWithAck({
920
- client: senderClient,
921
- sessionId: session.id,
922
- secret,
923
- text: userPrompt,
924
- localId: userLocalId,
925
- });
926
-
927
- const agentReplyPromise = waitForClientMessage({
928
- client: observerClient,
929
- predicate: (body) =>
930
- body?.role === 'agent'
931
- && body?.content?.type === 'codex'
932
- && body?.content?.data?.type === 'message'
933
- && typeof body?.content?.data?.message === 'string'
934
- && body.content.data.message.includes(fakeAcpReply),
935
- timeoutMs: 20_000,
936
- });
937
-
938
- let agentReply;
939
- try {
940
- agentReply = await agentReplyPromise;
941
- } catch (error) {
942
- const persistedMessagesOnFailure = await fetchMessages({
943
- token: auth.token,
944
- signing: auth.signing,
945
- sessionId: session.id,
946
- }).catch(() => []);
947
- const decodedMessagesOnFailure = persistedMessagesOnFailure.map((message) => ({
948
- id: message.id,
949
- seq: message.seq,
950
- decodedContent: decodePersistedMessageContent(message.content, secret),
951
- }));
952
- const fakeAcpLog = existsSync(fakeAcpLogPath) ? readFileSync(fakeAcpLogPath, 'utf8') : null;
953
- throw new Error([
954
- error instanceof Error ? error.message : String(error),
955
- `senderAck=${JSON.stringify(senderAck)}`,
956
- `decodedMessages=${JSON.stringify(decodedMessagesOnFailure, null, 2)}`,
957
- `fakeAcpLog=${JSON.stringify(fakeAcpLog)}`,
958
- ].join('\n'));
959
- }
960
-
961
- await delay(500);
962
-
963
- const persistedMessages = await fetchMessages({
964
- token: auth.token,
965
- signing: auth.signing,
966
- sessionId: session.id,
967
- });
968
-
969
- const decodedMessages = persistedMessages.map((message) => ({
970
- ...message,
971
- decodedContent: decodePersistedMessageContent(message.content, secret),
972
- }));
973
-
974
- const persistedUserMessage = decodedMessages.find(
975
- (message) =>
976
- message.decodedContent?.role === 'user'
977
- && message.decodedContent?.content?.type === 'text'
978
- && message.decodedContent?.content?.text === userPrompt,
979
- );
980
- const persistedAgentMessage = decodedMessages.find(
981
- (message) =>
982
- message.decodedContent?.role === 'agent'
983
- && message.decodedContent?.content?.type === 'codex'
984
- && message.decodedContent?.content?.data?.type === 'message'
985
- && typeof message.decodedContent?.content?.data?.message === 'string'
986
- && message.decodedContent.content.data.message.includes(fakeAcpReply),
987
- );
988
-
989
- assert.ok(persistedUserMessage, 'persisted user message not found');
990
- assert.ok(persistedAgentMessage, 'persisted agent message not found');
991
-
992
- console.log(JSON.stringify({
993
- ok: true,
994
- baseUrl,
995
- demoProjectDir,
996
- machineId,
997
- daemonPort,
998
- daemonPid: daemonState.pid,
999
- sessionId: session.id,
1000
- verified: [
1001
- 'local_server_ready',
1002
- 'legacy_key_auth_bootstrap',
1003
- 'daemon_state_written',
1004
- 'machine_registered',
1005
- 'demo_cli_session_registered',
1006
- 'cli_session_ready_state_observed',
1007
- 'session_scoped_socket_connected',
1008
- 'user_message_sender_acknowledged',
1009
- 'user_message_sent_via_api_client',
1010
- 'codex_agent_reply_received',
1011
- 'user_message_persisted',
1012
- 'agent_message_persisted',
1013
- ],
1014
- agentReply: agentReply?.content?.data?.message ?? null,
1015
- persistedMessageCount: decodedMessages.length,
1016
- machineMetadata: machine.metadataDecoded,
1017
- sessionMetadata: session.metadataDecoded,
1018
- }, null, 2));
1019
- } finally {
1020
- await observerClient?.close?.().catch(() => {});
1021
- await senderClient?.close?.().catch(() => {});
1022
-
1023
- await stopDaemon(cliEnv).catch(() => {});
1024
- await stopChildProcess(cli).catch(() => {});
1025
-
1026
- if (serverOwnedByScript) {
1027
- await stopChildProcess(server).catch(() => {});
1028
- }
1029
-
1030
- const shouldKeepArtifacts = process.env.DEBUG === '1' || process.env.HAPPY_CLI_E2E_KEEP_ARTIFACTS === 'true';
1031
- if (!shouldKeepArtifacts) {
1032
- try {
1033
- rmSync(tempRoot, { recursive: true, force: true });
1034
- } catch {
1035
- // Ignore cleanup errors.
1036
- }
1037
- }
1038
-
1039
- if (process.env.DEBUG && cliStdout.trim()) {
1040
- process.stdout.write(cliStdout);
1041
- }
1042
- if (process.env.DEBUG && cliStderr.trim()) {
1043
- process.stderr.write(cliStderr);
1044
- }
1045
- if (process.env.DEBUG && serverStdout.trim()) {
1046
- process.stdout.write(serverStdout);
1047
- }
1048
- if (process.env.DEBUG && serverStderr.trim()) {
1049
- process.stderr.write(serverStderr);
1050
- }
1051
- if (process.env.DEBUG && existsSync(fakeAcpLogPath)) {
1052
- process.stdout.write(readFileSync(fakeAcpLogPath, 'utf8'));
1053
- }
1054
- if (shouldKeepArtifacts) {
1055
- process.stderr.write(`[e2e] artifacts kept at ${tempRoot}\n`);
1056
- }
1057
- }
1058
- }
1059
-
1060
- main().catch((error) => {
1061
- console.error(error instanceof Error ? error.stack || error.message : String(error));
1062
- process.exitCode = 1;
1063
- });
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import {
6
+ createHash,
7
+ createHmac,
8
+ hkdfSync,
9
+ randomBytes,
10
+ randomUUID,
11
+ } from 'node:crypto';
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
20
+ import { createServer } from 'node:net';
21
+ import { setTimeout as delay } from 'node:timers/promises';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import { tmpdir } from 'node:os';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ import { io } from 'socket.io-client';
27
+ import tweetnacl from 'tweetnacl';
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const packageRoot = resolve(__dirname, '..', '..');
31
+ const workspaceRoot = resolve(packageRoot, '..', '..');
32
+ const serverRoot = resolve(workspaceRoot, 'packages', 'happy-server');
33
+ const cliBin = resolve(packageRoot, 'bin', 'happy-cloud.mjs');
34
+ const demoProjectDir = resolve(packageRoot, 'demo-project');
35
+ const fakeAcpAgentPath = resolve(packageRoot, 'scripts', 'e2e', 'fake-codex-acp-agent.mjs');
36
+ const cliVersion = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')).version;
37
+
38
+ const serverPort = process.env.HAPPY_CLI_E2E_SERVER_PORT || process.env.HAPPY_SERVER_TEST_PORT || '3005';
39
+ const baseUrl = process.env.HAPPY_CLI_E2E_SERVER_URL || process.env.HAPPY_SERVER_BASE_URL || `http://127.0.0.1:${serverPort}`;
40
+ const configuredDaemonPort = process.env.HAPPY_CLI_E2E_DAEMON_PORT || process.env.HAPPY_CLOUD_DAEMON_PORT || '';
41
+ const fakeAcpReply = process.env.HAPPY_E2E_FAKE_ACP_RESPONSE || 'LOCAL_E2E_ACK';
42
+ const skipServerBoot = process.env.HAPPY_CLI_E2E_SKIP_SERVER_BOOT === 'true' || process.env.HAPPY_SERVER_LIVE_SKIP_BOOT === 'true';
43
+
44
+ function encodeBase64(data) {
45
+ return Buffer.from(data).toString('base64');
46
+ }
47
+
48
+ function decodeBase64(data) {
49
+ return new Uint8Array(Buffer.from(data, 'base64'));
50
+ }
51
+
52
+ function normalizePlatform(platform) {
53
+ switch (platform) {
54
+ case 'win32':
55
+ return 'windows';
56
+ case 'darwin':
57
+ return 'macos';
58
+ default:
59
+ return platform;
60
+ }
61
+ }
62
+
63
+ function buildClientHeaders(version = cliVersion) {
64
+ return {
65
+ 'X-Client-Name': 'happy-cli',
66
+ 'X-Client-Version': version,
67
+ 'X-Platform': normalizePlatform(process.platform),
68
+ };
69
+ }
70
+
71
+ function canonicalizeQuery(url) {
72
+ const entries = [...url.searchParams.entries()].sort(([aKey, aValue], [bKey, bValue]) => {
73
+ if (aKey === bKey) {
74
+ return aValue.localeCompare(bValue);
75
+ }
76
+ return aKey.localeCompare(bKey);
77
+ });
78
+ return entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
79
+ }
80
+
81
+ function hashBody(body) {
82
+ if (body == null) {
83
+ return createHash('sha256').update('').digest('hex');
84
+ }
85
+
86
+ const serialized = typeof body === 'string' ? body : JSON.stringify(body);
87
+ return createHash('sha256').update(serialized).digest('hex');
88
+ }
89
+
90
+ function deriveSigningSecret(signing) {
91
+ return Buffer.from(
92
+ hkdfSync(
93
+ 'sha256',
94
+ Buffer.from(signing.seed, 'base64'),
95
+ Buffer.alloc(0),
96
+ Buffer.from('request-sign-v1'),
97
+ 32,
98
+ ),
99
+ );
100
+ }
101
+
102
+ function signCanonical(signing, canonical) {
103
+ return createHmac('sha256', deriveSigningSecret(signing)).update(canonical).digest('base64');
104
+ }
105
+
106
+ function buildHttpCanonicalString({ method, url, timestamp, nonce, bodySha256 }) {
107
+ const parsed = new URL(url);
108
+ return [
109
+ method.toUpperCase(),
110
+ parsed.pathname,
111
+ canonicalizeQuery(parsed),
112
+ timestamp,
113
+ nonce,
114
+ bodySha256,
115
+ ].join('\n');
116
+ }
117
+
118
+ function buildWebSocketCanonicalString({ clientType, sessionId, machineId, timestamp, nonce }) {
119
+ const params = new URLSearchParams();
120
+ params.set('clientType', clientType);
121
+ if (sessionId) {
122
+ params.set('sessionId', sessionId);
123
+ }
124
+ if (machineId) {
125
+ params.set('machineId', machineId);
126
+ }
127
+
128
+ const url = new URL('https://unused.local/v1/updates');
129
+ url.search = params.toString();
130
+
131
+ return [
132
+ 'WS_CONNECT',
133
+ '/v1/updates',
134
+ canonicalizeQuery(url),
135
+ timestamp,
136
+ nonce,
137
+ ].join('\n');
138
+ }
139
+
140
+ function buildSignedHeaders({ token, signing, method, url, body }) {
141
+ const timestamp = String(Date.now());
142
+ const nonce = randomUUID();
143
+ const bodySha256 = hashBody(body);
144
+ const canonical = buildHttpCanonicalString({
145
+ method,
146
+ url,
147
+ timestamp,
148
+ nonce,
149
+ bodySha256,
150
+ });
151
+
152
+ return {
153
+ ...buildClientHeaders(),
154
+ Authorization: `Bearer ${token}`,
155
+ ...(body != null ? { 'Content-Type': 'application/json' } : {}),
156
+ 'X-Sign-Version': String(signing.version),
157
+ 'X-Key-Id': signing.keyId,
158
+ 'X-Timestamp': timestamp,
159
+ 'X-Nonce': nonce,
160
+ 'X-Body-SHA256': bodySha256,
161
+ 'X-Signature': signCanonical(signing, canonical),
162
+ };
163
+ }
164
+
165
+ function buildSocketAuth({ token, signing, clientType, sessionId, machineId }) {
166
+ const auth = {
167
+ token,
168
+ clientType,
169
+ clientName: 'happy-cli',
170
+ clientVersion: cliVersion,
171
+ ...(sessionId ? { sessionId } : {}),
172
+ ...(machineId ? { machineId } : {}),
173
+ };
174
+
175
+ if (!signing) {
176
+ return auth;
177
+ }
178
+
179
+ const timestamp = String(Date.now());
180
+ const nonce = randomUUID();
181
+ const canonical = buildWebSocketCanonicalString({
182
+ clientType,
183
+ sessionId,
184
+ machineId,
185
+ timestamp,
186
+ nonce,
187
+ });
188
+
189
+ return {
190
+ ...auth,
191
+ signVersion: signing.version,
192
+ timestamp,
193
+ nonce,
194
+ signature: signCanonical(signing, canonical),
195
+ };
196
+ }
197
+
198
+ function authChallenge(secret) {
199
+ const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
200
+ const challenge = new Uint8Array(randomBytes(32));
201
+ const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
202
+
203
+ return {
204
+ challenge,
205
+ publicKey: keypair.publicKey,
206
+ signature,
207
+ };
208
+ }
209
+
210
+ function encryptLegacy(data, secret) {
211
+ const nonce = new Uint8Array(randomBytes(tweetnacl.secretbox.nonceLength));
212
+ const encrypted = tweetnacl.secretbox(
213
+ new TextEncoder().encode(JSON.stringify(data)),
214
+ nonce,
215
+ secret,
216
+ );
217
+ const result = new Uint8Array(nonce.length + encrypted.length);
218
+ result.set(nonce, 0);
219
+ result.set(encrypted, nonce.length);
220
+ return result;
221
+ }
222
+
223
+ function decryptLegacy(data, secret) {
224
+ const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
225
+ const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
226
+ const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
227
+ if (!decrypted) {
228
+ return null;
229
+ }
230
+ return JSON.parse(new TextDecoder().decode(decrypted));
231
+ }
232
+
233
+ async function requestJson(url, init = {}) {
234
+ const response = await fetch(url, init);
235
+ const text = await response.text();
236
+
237
+ let json = null;
238
+ try {
239
+ json = text ? JSON.parse(text) : null;
240
+ } catch {
241
+ json = { raw: text };
242
+ }
243
+
244
+ return {
245
+ status: response.status,
246
+ ok: response.ok,
247
+ json,
248
+ };
249
+ }
250
+
251
+ async function isServerHealthy(url) {
252
+ try {
253
+ const response = await fetch(`${url}/`);
254
+ return response.ok;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ async function waitForServer(url, timeoutMs = 30_000) {
261
+ const startedAt = Date.now();
262
+ while (Date.now() - startedAt < timeoutMs) {
263
+ if (await isServerHealthy(url)) {
264
+ return;
265
+ }
266
+ await delay(1_000);
267
+ }
268
+ throw new Error(`Server did not become ready within ${timeoutMs}ms`);
269
+ }
270
+
271
+ async function waitForCondition(fn, { timeoutMs = 15_000, intervalMs = 250, description = 'condition' } = {}) {
272
+ const startedAt = Date.now();
273
+ while (Date.now() - startedAt < timeoutMs) {
274
+ const result = await fn();
275
+ if (result) {
276
+ return result;
277
+ }
278
+ await delay(intervalMs);
279
+ }
280
+ throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms`);
281
+ }
282
+
283
+ async function resolveDaemonPort() {
284
+ if (configuredDaemonPort) {
285
+ return configuredDaemonPort;
286
+ }
287
+
288
+ const server = createServer();
289
+ try {
290
+ await new Promise((resolve, reject) => {
291
+ server.once('error', reject);
292
+ server.listen(0, '127.0.0.1', resolve);
293
+ });
294
+
295
+ const address = server.address();
296
+ if (!address || typeof address === 'string') {
297
+ throw new Error('Failed to resolve an available daemon port');
298
+ }
299
+
300
+ return String(address.port);
301
+ } finally {
302
+ await new Promise((resolve) => {
303
+ server.close(() => resolve());
304
+ }).catch(() => {});
305
+ }
306
+ }
307
+
308
+ function resolveTsxCli() {
309
+ const candidates = [
310
+ resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
311
+ resolve(workspaceRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
312
+ ];
313
+
314
+ for (const candidate of candidates) {
315
+ if (existsSync(candidate)) {
316
+ return candidate;
317
+ }
318
+ }
319
+
320
+ throw new Error('Could not resolve tsx CLI. Run yarn install first.');
321
+ }
322
+
323
+ function buildServerEnv() {
324
+ return {
325
+ ...process.env,
326
+ DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/handy',
327
+ TEST_USERS: process.env.TEST_USERS || 'apple:apple123',
328
+ NODE_ENV: 'development',
329
+ PORT: serverPort,
330
+ ALLOW_LEGACY_UNSIGNED_REQUESTS: 'true',
331
+ REQUEST_SIGN_ENFORCE_MIN_APP_VERSION: '1.8.0',
332
+ LEGACY_SIGN_SUNSET_AT: '2026-12-31T00:00:00Z',
333
+ };
334
+ }
335
+
336
+ function writeCliState({ homeDir, token, signing, secret, machineId }) {
337
+ mkdirSync(homeDir, { recursive: true });
338
+
339
+ writeFileSync(
340
+ join(homeDir, 'access.key'),
341
+ JSON.stringify(
342
+ {
343
+ secret: encodeBase64(secret),
344
+ token,
345
+ signing,
346
+ },
347
+ null,
348
+ 2,
349
+ ),
350
+ 'utf8',
351
+ );
352
+
353
+ writeFileSync(
354
+ join(homeDir, 'settings.json'),
355
+ JSON.stringify(
356
+ {
357
+ schemaVersion: 2,
358
+ onboardingCompleted: true,
359
+ machineId,
360
+ machineIdConfirmedByServer: false,
361
+ profiles: [],
362
+ localEnvironmentVariables: {},
363
+ },
364
+ null,
365
+ 2,
366
+ ),
367
+ 'utf8',
368
+ );
369
+ }
370
+
371
+ async function waitForSocketConnect(socket, timeoutMs = 10_000) {
372
+ if (socket?.connected) {
373
+ return;
374
+ }
375
+
376
+ return new Promise((resolve, reject) => {
377
+ const timer = setTimeout(() => {
378
+ cleanup();
379
+ reject(new Error('Timed out waiting for socket connect'));
380
+ }, timeoutMs);
381
+
382
+ const cleanup = () => {
383
+ clearTimeout(timer);
384
+ socket.off('connect', onConnect);
385
+ socket.off('connect_error', onError);
386
+ };
387
+
388
+ const onConnect = () => {
389
+ cleanup();
390
+ resolve();
391
+ };
392
+
393
+ const onError = (error) => {
394
+ cleanup();
395
+ reject(error instanceof Error ? error : new Error(String(error)));
396
+ };
397
+
398
+ socket.on('connect', onConnect);
399
+ socket.on('connect_error', onError);
400
+ });
401
+ }
402
+
403
+ async function waitForObserverMessage({ socket, secret, predicate, timeoutMs = 15_000 }) {
404
+ return new Promise((resolve, reject) => {
405
+ const timer = setTimeout(() => {
406
+ cleanup();
407
+ reject(new Error('Timed out waiting for observer update'));
408
+ }, timeoutMs);
409
+
410
+ const cleanup = () => {
411
+ clearTimeout(timer);
412
+ socket.off('update', onUpdate);
413
+ socket.off('connect_error', onError);
414
+ };
415
+
416
+ const onError = (error) => {
417
+ cleanup();
418
+ reject(error instanceof Error ? error : new Error(String(error)));
419
+ };
420
+
421
+ const onUpdate = (update) => {
422
+ try {
423
+ if (!update?.body || update.body.t !== 'new-message') {
424
+ return;
425
+ }
426
+ const encrypted = update.body?.message?.content;
427
+ if (!encrypted || encrypted.t !== 'encrypted' || typeof encrypted.c !== 'string') {
428
+ return;
429
+ }
430
+
431
+ const body = decryptLegacy(decodeBase64(encrypted.c), secret);
432
+ if (!body) {
433
+ return;
434
+ }
435
+
436
+ if (predicate(body, update)) {
437
+ cleanup();
438
+ resolve({ body, update });
439
+ }
440
+ } catch (error) {
441
+ cleanup();
442
+ reject(error);
443
+ }
444
+ };
445
+
446
+ socket.on('update', onUpdate);
447
+ socket.on('connect_error', onError);
448
+ });
449
+ }
450
+
451
+ async function waitForClientMessage({ client, predicate, timeoutMs = 15_000 }) {
452
+ return new Promise((resolve, reject) => {
453
+ const timer = setTimeout(() => {
454
+ cleanup();
455
+ reject(new Error('Timed out waiting for client message'));
456
+ }, timeoutMs);
457
+
458
+ const cleanup = () => {
459
+ clearTimeout(timer);
460
+ client.off('message', onMessage);
461
+ };
462
+
463
+ const onMessage = (body) => {
464
+ try {
465
+ if (predicate(body)) {
466
+ cleanup();
467
+ resolve(body);
468
+ }
469
+ } catch (error) {
470
+ cleanup();
471
+ reject(error);
472
+ }
473
+ };
474
+
475
+ client.on('message', onMessage);
476
+ });
477
+ }
478
+
479
+ async function waitForChildExit(child, timeoutMs = 5_000) {
480
+ if (!child || child.exitCode !== null) {
481
+ return true;
482
+ }
483
+
484
+ return await new Promise((resolve) => {
485
+ const timer = setTimeout(() => {
486
+ cleanup();
487
+ resolve(child.exitCode !== null);
488
+ }, timeoutMs);
489
+
490
+ const cleanup = () => {
491
+ clearTimeout(timer);
492
+ child.off('exit', onExit);
493
+ child.off('error', onError);
494
+ };
495
+
496
+ const onExit = () => {
497
+ cleanup();
498
+ resolve(true);
499
+ };
500
+
501
+ const onError = () => {
502
+ cleanup();
503
+ resolve(child.exitCode !== null);
504
+ };
505
+
506
+ child.once('exit', onExit);
507
+ child.once('error', onError);
508
+ });
509
+ }
510
+
511
+ async function killProcessTree(child) {
512
+ if (!child?.pid) {
513
+ return;
514
+ }
515
+
516
+ if (process.platform === 'win32') {
517
+ await new Promise((resolve) => {
518
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
519
+ killer.once('exit', resolve);
520
+ killer.once('error', resolve);
521
+ });
522
+ return;
523
+ }
524
+
525
+ try {
526
+ process.kill(child.pid, 'SIGKILL');
527
+ } catch {
528
+ // Ignore if already exited.
529
+ }
530
+ }
531
+
532
+ async function stopChildProcess(child) {
533
+ if (!child) {
534
+ return;
535
+ }
536
+
537
+ child.kill('SIGTERM');
538
+ const exited = await waitForChildExit(child, 2_000);
539
+ if (!exited) {
540
+ await killProcessTree(child);
541
+ await waitForChildExit(child, 2_000);
542
+ }
543
+ }
544
+
545
+ async function authenticateWithKey(secret) {
546
+ const { challenge, publicKey, signature } = authChallenge(secret);
547
+ const response = await requestJson(`${baseUrl}/v1/auth`, {
548
+ method: 'POST',
549
+ headers: {
550
+ ...buildClientHeaders(),
551
+ 'Content-Type': 'application/json',
552
+ },
553
+ body: JSON.stringify({
554
+ challenge: encodeBase64(challenge),
555
+ publicKey: encodeBase64(publicKey),
556
+ signature: encodeBase64(signature),
557
+ }),
558
+ });
559
+
560
+ assert.equal(response.status, 200, `auth failed: ${JSON.stringify(response.json)}`);
561
+ assert.equal(response.json?.success, true, `auth response invalid: ${JSON.stringify(response.json)}`);
562
+
563
+ return {
564
+ token: response.json.token,
565
+ signing: response.json.signing,
566
+ };
567
+ }
568
+
569
+ async function fetchMachine({ token, signing, machineId }) {
570
+ const url = `${baseUrl}/v1/machines/${machineId}`;
571
+ const response = await requestJson(url, {
572
+ method: 'GET',
573
+ headers: buildSignedHeaders({
574
+ token,
575
+ signing,
576
+ method: 'GET',
577
+ url,
578
+ body: null,
579
+ }),
580
+ });
581
+
582
+ if (response.status === 404) {
583
+ return null;
584
+ }
585
+
586
+ assert.equal(response.status, 200, `fetch machine failed: ${JSON.stringify(response.json)}`);
587
+ return response.json.machine;
588
+ }
589
+
590
+ async function listSessions({ token, signing }) {
591
+ const url = `${baseUrl}/v1/sessions`;
592
+ const response = await requestJson(url, {
593
+ method: 'GET',
594
+ headers: buildSignedHeaders({
595
+ token,
596
+ signing,
597
+ method: 'GET',
598
+ url,
599
+ body: null,
600
+ }),
601
+ });
602
+
603
+ assert.equal(response.status, 200, `list sessions failed: ${JSON.stringify(response.json)}`);
604
+ return response.json.sessions || [];
605
+ }
606
+
607
+ async function fetchMessages({ token, signing, sessionId }) {
608
+ const url = `${baseUrl}/v1/sessions/${sessionId}/messages`;
609
+ const response = await requestJson(url, {
610
+ method: 'GET',
611
+ headers: buildSignedHeaders({
612
+ token,
613
+ signing,
614
+ method: 'GET',
615
+ url,
616
+ body: null,
617
+ }),
618
+ });
619
+
620
+ assert.equal(response.status, 200, `fetch messages failed: ${JSON.stringify(response.json)}`);
621
+ return response.json.messages || [];
622
+ }
623
+
624
+ async function fetchSessionRecord({ token, signing, secret, sessionId }) {
625
+ const sessions = await listSessions({
626
+ token,
627
+ signing,
628
+ });
629
+
630
+ const match = sessions.find((candidate) => candidate.id === sessionId);
631
+ if (!match) {
632
+ return null;
633
+ }
634
+
635
+ return decryptSessionRecord(match, secret);
636
+ }
637
+
638
+ function decryptSessionRecord(record, secret) {
639
+ return {
640
+ ...record,
641
+ metadataDecoded: record.metadata ? decryptLegacy(decodeBase64(record.metadata), secret) : null,
642
+ agentStateDecoded: record.agentState ? decryptLegacy(decodeBase64(record.agentState), secret) : null,
643
+ };
644
+ }
645
+
646
+ function decodePersistedMessageContent(content, secret) {
647
+ const encoded = typeof content === 'string'
648
+ ? content
649
+ : (content && typeof content === 'object' && typeof content.c === 'string' ? content.c : null);
650
+
651
+ if (!encoded) {
652
+ return null;
653
+ }
654
+
655
+ return decryptLegacy(decodeBase64(encoded), secret);
656
+ }
657
+
658
+ async function stopDaemon(cliEnv) {
659
+ spawnSync(process.execPath, [cliBin, 'daemon', 'stop'], {
660
+ cwd: packageRoot,
661
+ env: cliEnv,
662
+ stdio: 'ignore',
663
+ timeout: 5_000,
664
+ });
665
+ }
666
+
667
+ async function sendUserMessageWithAck({ client, sessionId, secret, text, localId, timeoutMs = 10_000 }) {
668
+ const socket = client?.socket;
669
+ if (!socket?.connected) {
670
+ throw new Error('Sender socket is not connected');
671
+ }
672
+
673
+ const encrypted = encodeBase64(encryptLegacy({
674
+ role: 'user',
675
+ content: {
676
+ type: 'text',
677
+ text,
678
+ },
679
+ localKey: localId,
680
+ meta: {
681
+ sentFrom: 'cli',
682
+ },
683
+ }, secret));
684
+
685
+ const ack = await Promise.race([
686
+ socket.emitWithAck('message', {
687
+ sid: sessionId,
688
+ message: encrypted,
689
+ localId,
690
+ }),
691
+ delay(timeoutMs).then(() => {
692
+ throw new Error('Timed out waiting for sender ack');
693
+ }),
694
+ ]);
695
+
696
+ assert.equal(ack?.ok, true, `sender ack failed: ${JSON.stringify(ack)}`);
697
+ assert.equal(ack?.sessionId, sessionId, `sender ack session mismatch: ${JSON.stringify(ack)}`);
698
+ assert.equal(ack?.status, 'accepted', `sender ack status unexpected: ${JSON.stringify(ack)}`);
699
+ return ack;
700
+ }
701
+
702
+ async function main() {
703
+ const tempRoot = mkdtempSync(join(tmpdir(), 'happy-cli-local-e2e-'));
704
+ const cliHomeDir = join(tempRoot, '.happy-cloud-e2e');
705
+ const fakeAcpLogPath = join(tempRoot, 'fake-codex-acp.log');
706
+ const daemonPort = await resolveDaemonPort();
707
+ const machineId = randomUUID();
708
+ const secret = new Uint8Array(randomBytes(32));
709
+ const userPrompt = `Please reply with ${fakeAcpReply} only.`;
710
+ const userLocalId = `e2e-user-${randomUUID()}`;
711
+
712
+ let server = null;
713
+ let serverOwnedByScript = false;
714
+ let cli = null;
715
+ let observerClient = null;
716
+ let senderClient = null;
717
+ let cliStdout = '';
718
+ let cliStderr = '';
719
+ let serverStdout = '';
720
+ let serverStderr = '';
721
+
722
+ const cliEnv = {
723
+ ...process.env,
724
+ HAPPY_SERVER_URL: baseUrl,
725
+ HAPPY_CLOUD_SERVER_URL: baseUrl,
726
+ HAPPY_HOME_DIR: cliHomeDir,
727
+ HAPPY_CLOUD_HOME_DIR: cliHomeDir,
728
+ HAPPY_CLOUD_DAEMON_PORT: daemonPort,
729
+ HAPPY_CODEX_ACP_COMMAND: process.execPath,
730
+ HAPPY_DISABLE_CAFFEINATE: 'true',
731
+ HAPPY_E2E_FAKE_ACP_RESPONSE: fakeAcpReply,
732
+ HAPPY_E2E_FAKE_ACP_LOG: fakeAcpLogPath,
733
+ NO_COLOR: '1',
734
+ };
735
+
736
+ try {
737
+ if (await isServerHealthy(baseUrl)) {
738
+ serverOwnedByScript = false;
739
+ } else {
740
+ if (skipServerBoot) {
741
+ throw new Error(`Local server is not reachable at ${baseUrl}, and boot was skipped.`);
742
+ }
743
+
744
+ serverOwnedByScript = true;
745
+ server = spawn(process.execPath, [
746
+ resolveTsxCli(),
747
+ '--env-file=.env',
748
+ '--env-file=.env.dev',
749
+ './sources/main.ts',
750
+ ], {
751
+ cwd: serverRoot,
752
+ env: buildServerEnv(),
753
+ stdio: ['ignore', 'pipe', 'pipe'],
754
+ });
755
+
756
+ server.stdout?.on('data', (chunk) => {
757
+ serverStdout += chunk.toString();
758
+ });
759
+ server.stderr?.on('data', (chunk) => {
760
+ serverStderr += chunk.toString();
761
+ });
762
+ }
763
+
764
+ await waitForServer(baseUrl);
765
+
766
+ const auth = await authenticateWithKey(secret);
767
+ writeCliState({
768
+ homeDir: cliHomeDir,
769
+ token: auth.token,
770
+ signing: auth.signing,
771
+ secret,
772
+ machineId,
773
+ });
774
+
775
+ cli = spawn(process.execPath, [
776
+ cliBin,
777
+ 'codex',
778
+ fakeAcpAgentPath,
779
+ '--happy-starting-mode',
780
+ 'remote',
781
+ '--started-by',
782
+ 'daemon',
783
+ ], {
784
+ cwd: demoProjectDir,
785
+ env: cliEnv,
786
+ stdio: ['ignore', 'pipe', 'pipe'],
787
+ });
788
+
789
+ cli.stdout?.on('data', (chunk) => {
790
+ cliStdout += chunk.toString();
791
+ });
792
+ cli.stderr?.on('data', (chunk) => {
793
+ cliStderr += chunk.toString();
794
+ });
795
+
796
+ const daemonState = await waitForCondition(
797
+ async () => {
798
+ const statePath = join(cliHomeDir, 'daemon.state.json');
799
+ if (!existsSync(statePath)) {
800
+ return null;
801
+ }
802
+
803
+ try {
804
+ return JSON.parse(readFileSync(statePath, 'utf8'));
805
+ } catch {
806
+ return null;
807
+ }
808
+ },
809
+ {
810
+ timeoutMs: 20_000,
811
+ description: 'daemon state file',
812
+ },
813
+ );
814
+
815
+ const machine = await waitForCondition(
816
+ async () => {
817
+ const result = await fetchMachine({
818
+ token: auth.token,
819
+ signing: auth.signing,
820
+ machineId,
821
+ });
822
+ if (!result) {
823
+ return null;
824
+ }
825
+
826
+ const metadata = result.metadata ? decryptLegacy(decodeBase64(result.metadata), secret) : null;
827
+ return {
828
+ ...result,
829
+ metadataDecoded: metadata,
830
+ };
831
+ },
832
+ {
833
+ timeoutMs: 20_000,
834
+ description: 'machine registration',
835
+ },
836
+ );
837
+
838
+ let session = await waitForCondition(
839
+ async () => {
840
+ const sessions = await listSessions({
841
+ token: auth.token,
842
+ signing: auth.signing,
843
+ });
844
+
845
+ for (const rawSession of sessions) {
846
+ const decodedSession = decryptSessionRecord(rawSession, secret);
847
+ if (
848
+ decodedSession.metadataDecoded?.path === demoProjectDir &&
849
+ decodedSession.metadataDecoded?.machineId === machineId
850
+ ) {
851
+ return decodedSession;
852
+ }
853
+ }
854
+
855
+ return null;
856
+ },
857
+ {
858
+ timeoutMs: 20_000,
859
+ description: 'CLI session registration',
860
+ },
861
+ );
862
+
863
+ session = await waitForCondition(
864
+ async () => {
865
+ const latest = await fetchSessionRecord({
866
+ token: auth.token,
867
+ signing: auth.signing,
868
+ secret,
869
+ sessionId: session.id,
870
+ });
871
+
872
+ if (!latest) {
873
+ return null;
874
+ }
875
+
876
+ if (latest.agentStateDecoded?.controlledByUser === false) {
877
+ return latest;
878
+ }
879
+
880
+ return null;
881
+ },
882
+ {
883
+ timeoutMs: 20_000,
884
+ description: 'CLI session ready state',
885
+ },
886
+ );
887
+
888
+ process.env.HAPPY_SERVER_URL = baseUrl;
889
+ process.env.HAPPY_CLOUD_SERVER_URL = baseUrl;
890
+ process.env.HAPPY_HOME_DIR = cliHomeDir;
891
+ process.env.HAPPY_CLOUD_HOME_DIR = cliHomeDir;
892
+
893
+ const { ApiSessionClient } = await import(pathToFileURL(resolve(packageRoot, 'dist', 'lib.mjs')).href);
894
+ const sessionDescriptor = {
895
+ id: session.id,
896
+ seq: session.seq,
897
+ metadata: session.metadataDecoded,
898
+ metadataVersion: session.metadataVersion,
899
+ agentState: session.agentStateDecoded,
900
+ agentStateVersion: session.agentStateVersion,
901
+ encryptionKey: secret,
902
+ encryptionVariant: 'legacy',
903
+ };
904
+ const credentialsDescriptor = {
905
+ token: auth.token,
906
+ signing: auth.signing,
907
+ encryption: {
908
+ type: 'legacy',
909
+ secret,
910
+ },
911
+ };
912
+
913
+ observerClient = new ApiSessionClient(credentialsDescriptor, sessionDescriptor);
914
+ senderClient = new ApiSessionClient(credentialsDescriptor, sessionDescriptor);
915
+
916
+ await waitForSocketConnect(observerClient.socket);
917
+ await waitForSocketConnect(senderClient.socket);
918
+
919
+ const senderAck = await sendUserMessageWithAck({
920
+ client: senderClient,
921
+ sessionId: session.id,
922
+ secret,
923
+ text: userPrompt,
924
+ localId: userLocalId,
925
+ });
926
+
927
+ const agentReplyPromise = waitForClientMessage({
928
+ client: observerClient,
929
+ predicate: (body) =>
930
+ body?.role === 'agent'
931
+ && body?.content?.type === 'codex'
932
+ && body?.content?.data?.type === 'message'
933
+ && typeof body?.content?.data?.message === 'string'
934
+ && body.content.data.message.includes(fakeAcpReply),
935
+ timeoutMs: 20_000,
936
+ });
937
+
938
+ let agentReply;
939
+ try {
940
+ agentReply = await agentReplyPromise;
941
+ } catch (error) {
942
+ const persistedMessagesOnFailure = await fetchMessages({
943
+ token: auth.token,
944
+ signing: auth.signing,
945
+ sessionId: session.id,
946
+ }).catch(() => []);
947
+ const decodedMessagesOnFailure = persistedMessagesOnFailure.map((message) => ({
948
+ id: message.id,
949
+ seq: message.seq,
950
+ decodedContent: decodePersistedMessageContent(message.content, secret),
951
+ }));
952
+ const fakeAcpLog = existsSync(fakeAcpLogPath) ? readFileSync(fakeAcpLogPath, 'utf8') : null;
953
+ throw new Error([
954
+ error instanceof Error ? error.message : String(error),
955
+ `senderAck=${JSON.stringify(senderAck)}`,
956
+ `decodedMessages=${JSON.stringify(decodedMessagesOnFailure, null, 2)}`,
957
+ `fakeAcpLog=${JSON.stringify(fakeAcpLog)}`,
958
+ ].join('\n'));
959
+ }
960
+
961
+ await delay(500);
962
+
963
+ const persistedMessages = await fetchMessages({
964
+ token: auth.token,
965
+ signing: auth.signing,
966
+ sessionId: session.id,
967
+ });
968
+
969
+ const decodedMessages = persistedMessages.map((message) => ({
970
+ ...message,
971
+ decodedContent: decodePersistedMessageContent(message.content, secret),
972
+ }));
973
+
974
+ const persistedUserMessage = decodedMessages.find(
975
+ (message) =>
976
+ message.decodedContent?.role === 'user'
977
+ && message.decodedContent?.content?.type === 'text'
978
+ && message.decodedContent?.content?.text === userPrompt,
979
+ );
980
+ const persistedAgentMessage = decodedMessages.find(
981
+ (message) =>
982
+ message.decodedContent?.role === 'agent'
983
+ && message.decodedContent?.content?.type === 'codex'
984
+ && message.decodedContent?.content?.data?.type === 'message'
985
+ && typeof message.decodedContent?.content?.data?.message === 'string'
986
+ && message.decodedContent.content.data.message.includes(fakeAcpReply),
987
+ );
988
+
989
+ assert.ok(persistedUserMessage, 'persisted user message not found');
990
+ assert.ok(persistedAgentMessage, 'persisted agent message not found');
991
+
992
+ console.log(JSON.stringify({
993
+ ok: true,
994
+ baseUrl,
995
+ demoProjectDir,
996
+ machineId,
997
+ daemonPort,
998
+ daemonPid: daemonState.pid,
999
+ sessionId: session.id,
1000
+ verified: [
1001
+ 'local_server_ready',
1002
+ 'legacy_key_auth_bootstrap',
1003
+ 'daemon_state_written',
1004
+ 'machine_registered',
1005
+ 'demo_cli_session_registered',
1006
+ 'cli_session_ready_state_observed',
1007
+ 'session_scoped_socket_connected',
1008
+ 'user_message_sender_acknowledged',
1009
+ 'user_message_sent_via_api_client',
1010
+ 'codex_agent_reply_received',
1011
+ 'user_message_persisted',
1012
+ 'agent_message_persisted',
1013
+ ],
1014
+ agentReply: agentReply?.content?.data?.message ?? null,
1015
+ persistedMessageCount: decodedMessages.length,
1016
+ machineMetadata: machine.metadataDecoded,
1017
+ sessionMetadata: session.metadataDecoded,
1018
+ }, null, 2));
1019
+ } finally {
1020
+ await observerClient?.close?.().catch(() => {});
1021
+ await senderClient?.close?.().catch(() => {});
1022
+
1023
+ await stopDaemon(cliEnv).catch(() => {});
1024
+ await stopChildProcess(cli).catch(() => {});
1025
+
1026
+ if (serverOwnedByScript) {
1027
+ await stopChildProcess(server).catch(() => {});
1028
+ }
1029
+
1030
+ const shouldKeepArtifacts = process.env.DEBUG === '1' || process.env.HAPPY_CLI_E2E_KEEP_ARTIFACTS === 'true';
1031
+ if (!shouldKeepArtifacts) {
1032
+ try {
1033
+ rmSync(tempRoot, { recursive: true, force: true });
1034
+ } catch {
1035
+ // Ignore cleanup errors.
1036
+ }
1037
+ }
1038
+
1039
+ if (process.env.DEBUG && cliStdout.trim()) {
1040
+ process.stdout.write(cliStdout);
1041
+ }
1042
+ if (process.env.DEBUG && cliStderr.trim()) {
1043
+ process.stderr.write(cliStderr);
1044
+ }
1045
+ if (process.env.DEBUG && serverStdout.trim()) {
1046
+ process.stdout.write(serverStdout);
1047
+ }
1048
+ if (process.env.DEBUG && serverStderr.trim()) {
1049
+ process.stderr.write(serverStderr);
1050
+ }
1051
+ if (process.env.DEBUG && existsSync(fakeAcpLogPath)) {
1052
+ process.stdout.write(readFileSync(fakeAcpLogPath, 'utf8'));
1053
+ }
1054
+ if (shouldKeepArtifacts) {
1055
+ process.stderr.write(`[e2e] artifacts kept at ${tempRoot}\n`);
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ main().catch((error) => {
1061
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
1062
+ process.exitCode = 1;
1063
+ });