@kmmao/happy-agent 0.2.3 → 0.3.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.
- package/README.md +1 -1
- package/dist/index.cjs +1059 -96
- package/dist/index.d.cts +327 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +1041 -97
- package/package.json +3 -2
package/dist/index.mjs
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { homedir, hostname } from 'node:os';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
|
-
import { mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { mkdirSync, writeFileSync, unlinkSync, readFileSync, appendFileSync } from 'node:fs';
|
|
5
5
|
import { randomBytes, createCipheriv, createHash, createDecipheriv, createHmac } from 'node:crypto';
|
|
6
6
|
import tweetnacl from 'tweetnacl';
|
|
7
7
|
import axios, { AxiosError } from 'axios';
|
|
8
8
|
import qrcode from 'qrcode-terminal';
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
10
|
import { io } from 'socket.io-client';
|
|
11
|
+
import { exec } from 'child_process';
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { readFile, mkdir, writeFile, readdir, stat } from 'fs/promises';
|
|
14
|
+
import { createHash as createHash$1 } from 'crypto';
|
|
15
|
+
import { join as join$1, resolve } from 'path';
|
|
16
|
+
import { realpathSync } from 'fs';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
11
18
|
|
|
12
|
-
var version = "0.
|
|
19
|
+
var version = "0.3.0";
|
|
13
20
|
|
|
14
21
|
function loadConfig() {
|
|
15
22
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
23
|
+
const webappUrl = (process.env.HAPPY_WEBAPP_URL ?? "https://happyapp.xycloud.info").replace(/\/+$/, "");
|
|
16
24
|
const homeDir = process.env.HAPPY_HOME_DIR ?? join(homedir(), ".happy");
|
|
17
25
|
const credentialPath = join(homeDir, "agent.key");
|
|
18
|
-
return { serverUrl, homeDir, credentialPath };
|
|
26
|
+
return { serverUrl, webappUrl, homeDir, credentialPath };
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
function encodeBase64(buffer) {
|
|
@@ -182,7 +190,7 @@ function requireCredentials(config) {
|
|
|
182
190
|
|
|
183
191
|
const POLL_INTERVAL_MS = 1e3;
|
|
184
192
|
const AUTH_TIMEOUT_MS = 12e4;
|
|
185
|
-
async function authLogin(config) {
|
|
193
|
+
async function authLogin(config, opts) {
|
|
186
194
|
const seed = getRandomBytes(32);
|
|
187
195
|
const keypair = tweetnacl.box.keyPair.fromSecretKey(seed);
|
|
188
196
|
const publicKeyBase64 = encodeBase64(keypair.publicKey);
|
|
@@ -196,15 +204,25 @@ async function authLogin(config) {
|
|
|
196
204
|
}
|
|
197
205
|
throw err;
|
|
198
206
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
console.log(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
if (opts?.web) {
|
|
208
|
+
const publicKeyBase64Url = encodeBase64Url(keypair.publicKey);
|
|
209
|
+
const webUrl = `${config.webappUrl}/terminal/connect#key=${publicKeyBase64Url}`;
|
|
210
|
+
console.log("");
|
|
211
|
+
console.log("## Authentication (Web)");
|
|
212
|
+
console.log(`- Open this URL in your browser: ${webUrl}`);
|
|
213
|
+
console.log("- Then sign in with your Happy account to complete linking.");
|
|
214
|
+
console.log("");
|
|
215
|
+
} else {
|
|
216
|
+
const qrData = `happy:///account?${encodeBase64Url(keypair.publicKey)}`;
|
|
217
|
+
console.log("");
|
|
218
|
+
qrcode.generate(qrData, { small: true }, (code) => {
|
|
219
|
+
console.log(code);
|
|
220
|
+
});
|
|
221
|
+
console.log("## Authentication");
|
|
222
|
+
console.log("- Action: Scan this QR code with the Happy app");
|
|
223
|
+
console.log("- Path: Settings -> Account -> Link New Device");
|
|
224
|
+
console.log("");
|
|
225
|
+
}
|
|
208
226
|
const startTime = Date.now();
|
|
209
227
|
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
|
|
210
228
|
await sleep(POLL_INTERVAL_MS);
|
|
@@ -388,9 +406,7 @@ async function deleteSession(config, creds, sessionId) {
|
|
|
388
406
|
try {
|
|
389
407
|
await axios.delete(
|
|
390
408
|
`${config.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
|
|
391
|
-
{
|
|
392
|
-
headers: authHeaders(creds)
|
|
393
|
-
}
|
|
409
|
+
{ headers: authHeaders(creds) }
|
|
394
410
|
);
|
|
395
411
|
} catch (err) {
|
|
396
412
|
handleApiError(err, `deleting session ${sessionId}`);
|
|
@@ -416,9 +432,602 @@ async function getSessionMessages(config, creds, sessionId, encryption) {
|
|
|
416
432
|
updatedAt: msg.updatedAt
|
|
417
433
|
}));
|
|
418
434
|
}
|
|
435
|
+
async function fetchMessagesAfterSeq(config, creds, sessionId, encryption, afterSeq, limit = 100) {
|
|
436
|
+
let data;
|
|
437
|
+
try {
|
|
438
|
+
const resp = await axios.get(
|
|
439
|
+
`${config.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
440
|
+
{
|
|
441
|
+
params: { after_seq: afterSeq, limit },
|
|
442
|
+
headers: authHeaders(creds),
|
|
443
|
+
timeout: 6e4
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
data = resp.data;
|
|
447
|
+
} catch (err) {
|
|
448
|
+
handleApiError(err, `fetching v3 messages for session ${sessionId}`);
|
|
449
|
+
}
|
|
450
|
+
const messages = data.messages.map((msg) => {
|
|
451
|
+
const content = decryptField(msg.content.c, encryption);
|
|
452
|
+
if (content === null) return null;
|
|
453
|
+
return {
|
|
454
|
+
id: msg.id,
|
|
455
|
+
seq: msg.seq,
|
|
456
|
+
content,
|
|
457
|
+
localId: msg.localId ?? null,
|
|
458
|
+
createdAt: msg.createdAt,
|
|
459
|
+
updatedAt: msg.updatedAt
|
|
460
|
+
};
|
|
461
|
+
}).filter((m) => m !== null);
|
|
462
|
+
return { messages, hasMore: data.hasMore };
|
|
463
|
+
}
|
|
464
|
+
async function sendMessagesBatch(config, creds, sessionId, encryption, contents) {
|
|
465
|
+
const messages = contents.map((content) => ({
|
|
466
|
+
content: encodeBase64(
|
|
467
|
+
encrypt(encryption.key, encryption.variant, content)
|
|
468
|
+
)
|
|
469
|
+
}));
|
|
470
|
+
let data;
|
|
471
|
+
try {
|
|
472
|
+
const resp = await axios.post(
|
|
473
|
+
`${config.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
474
|
+
{ messages },
|
|
475
|
+
{ headers: authHeaders(creds), timeout: 6e4 }
|
|
476
|
+
);
|
|
477
|
+
data = resp.data;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
handleApiError(err, `sending v3 messages to session ${sessionId}`);
|
|
480
|
+
}
|
|
481
|
+
return data;
|
|
482
|
+
}
|
|
483
|
+
async function getOrCreateMachine(config, creds, metadata) {
|
|
484
|
+
const sessionKey = getRandomBytes(32);
|
|
485
|
+
const encryptedKey = libsodiumEncryptForPublicKey(
|
|
486
|
+
sessionKey,
|
|
487
|
+
creds.contentKeyPair.publicKey
|
|
488
|
+
);
|
|
489
|
+
const withVersion = new Uint8Array(1 + encryptedKey.length);
|
|
490
|
+
withVersion[0] = 0;
|
|
491
|
+
withVersion.set(encryptedKey, 1);
|
|
492
|
+
const encryptedMetadata = encryptWithDataKey(metadata, sessionKey);
|
|
493
|
+
let data;
|
|
494
|
+
try {
|
|
495
|
+
const resp = await axios.post(
|
|
496
|
+
`${config.serverUrl}/v2/machines`,
|
|
497
|
+
{
|
|
498
|
+
metadata: encodeBase64(encryptedMetadata),
|
|
499
|
+
dataEncryptionKey: encodeBase64(withVersion)
|
|
500
|
+
},
|
|
501
|
+
{ headers: authHeaders(creds) }
|
|
502
|
+
);
|
|
503
|
+
data = resp.data;
|
|
504
|
+
} catch (err) {
|
|
505
|
+
handleApiError(err, "registering machine");
|
|
506
|
+
}
|
|
507
|
+
const raw = data.machine;
|
|
508
|
+
let encKey;
|
|
509
|
+
let encVariant;
|
|
510
|
+
if (raw.dataEncryptionKey) {
|
|
511
|
+
const encrypted = decodeBase64(raw.dataEncryptionKey);
|
|
512
|
+
const bundle = encrypted.slice(1);
|
|
513
|
+
const key = decryptBoxBundle(bundle, creds.contentKeyPair.secretKey);
|
|
514
|
+
if (!key) throw new Error("Failed to decrypt machine encryption key");
|
|
515
|
+
encKey = key;
|
|
516
|
+
encVariant = "dataKey";
|
|
517
|
+
} else {
|
|
518
|
+
encKey = creds.secret;
|
|
519
|
+
encVariant = "legacy";
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
id: raw.id,
|
|
523
|
+
encryptionKey: encKey,
|
|
524
|
+
encryptionVariant: encVariant,
|
|
525
|
+
metadata: decryptField(raw.metadata, { key: encKey, variant: encVariant }) ?? metadata,
|
|
526
|
+
metadataVersion: raw.metadataVersion,
|
|
527
|
+
daemonState: raw.daemonState ? decryptField(raw.daemonState, { key: encKey, variant: encVariant }) : null,
|
|
528
|
+
daemonStateVersion: raw.daemonStateVersion
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async function listMachines(config, creds) {
|
|
532
|
+
let data;
|
|
533
|
+
try {
|
|
534
|
+
const resp = await axios.get(`${config.serverUrl}/v2/machines`, {
|
|
535
|
+
headers: authHeaders(creds)
|
|
536
|
+
});
|
|
537
|
+
data = resp.data;
|
|
538
|
+
} catch (err) {
|
|
539
|
+
handleApiError(err, "listing machines");
|
|
540
|
+
}
|
|
541
|
+
return data.machines;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function timestamp() {
|
|
545
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
|
|
546
|
+
hour12: false,
|
|
547
|
+
hour: "2-digit",
|
|
548
|
+
minute: "2-digit",
|
|
549
|
+
second: "2-digit",
|
|
550
|
+
fractionalSecondDigits: 3
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
function filenameTimestamp() {
|
|
554
|
+
return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", {
|
|
555
|
+
year: "numeric",
|
|
556
|
+
month: "2-digit",
|
|
557
|
+
day: "2-digit",
|
|
558
|
+
hour: "2-digit",
|
|
559
|
+
minute: "2-digit",
|
|
560
|
+
second: "2-digit"
|
|
561
|
+
}).replace(/[: ]/g, "-").replace(/,/g, "");
|
|
562
|
+
}
|
|
563
|
+
function resolveLogPath() {
|
|
564
|
+
const config = loadConfig();
|
|
565
|
+
const logsDir = join(config.homeDir, "logs");
|
|
566
|
+
mkdirSync(logsDir, { recursive: true });
|
|
567
|
+
return join(logsDir, `agent-${filenameTimestamp()}-pid-${process.pid}.log`);
|
|
568
|
+
}
|
|
569
|
+
class Logger {
|
|
570
|
+
logFilePath;
|
|
571
|
+
constructor() {
|
|
572
|
+
this.logFilePath = resolveLogPath();
|
|
573
|
+
}
|
|
574
|
+
debug(message, ...args) {
|
|
575
|
+
this.write("DEBUG", message, ...args);
|
|
576
|
+
}
|
|
577
|
+
warn(message, ...args) {
|
|
578
|
+
this.write("WARN", message, ...args);
|
|
579
|
+
}
|
|
580
|
+
error(message, ...args) {
|
|
581
|
+
this.write("ERROR", message, ...args);
|
|
582
|
+
}
|
|
583
|
+
write(level, message, ...args) {
|
|
584
|
+
const extra = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
585
|
+
const line = `[${timestamp()}] [${level}] ${message}${extra ? " " + extra : ""}
|
|
586
|
+
`;
|
|
587
|
+
try {
|
|
588
|
+
appendFileSync(this.logFilePath, line);
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const logger = new Logger();
|
|
594
|
+
|
|
595
|
+
async function withBackoff(fn, options) {
|
|
596
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
597
|
+
const baseDelay = options?.baseDelayMs ?? 200;
|
|
598
|
+
const label = options?.label ?? "operation";
|
|
599
|
+
let lastError;
|
|
600
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
601
|
+
try {
|
|
602
|
+
return await fn();
|
|
603
|
+
} catch (error) {
|
|
604
|
+
lastError = error;
|
|
605
|
+
if (attempt < maxRetries) {
|
|
606
|
+
const delay = baseDelay * 2 ** attempt;
|
|
607
|
+
logger.debug(`[backoff] ${label} attempt ${attempt + 1} failed, retrying in ${delay}ms`);
|
|
608
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
throw lastError;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
class RpcHandlerManager {
|
|
616
|
+
handlers = /* @__PURE__ */ new Map();
|
|
617
|
+
scopePrefix;
|
|
618
|
+
encryptionKey;
|
|
619
|
+
encryptionVariant;
|
|
620
|
+
logger;
|
|
621
|
+
socket = null;
|
|
622
|
+
reregisterInterval = null;
|
|
623
|
+
constructor(config) {
|
|
624
|
+
this.scopePrefix = config.scopePrefix;
|
|
625
|
+
this.encryptionKey = config.encryptionKey;
|
|
626
|
+
this.encryptionVariant = config.encryptionVariant;
|
|
627
|
+
this.logger = config.logger ?? ((msg, data) => logger.debug(msg, data));
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Register an RPC handler for a specific method
|
|
631
|
+
*/
|
|
632
|
+
registerHandler(method, handler) {
|
|
633
|
+
const prefixedMethod = this.getPrefixedMethod(method);
|
|
634
|
+
this.handlers.set(prefixedMethod, handler);
|
|
635
|
+
if (this.socket) {
|
|
636
|
+
this.emitRegisterWithRetry(this.socket, prefixedMethod);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Handle an incoming RPC request
|
|
641
|
+
*/
|
|
642
|
+
async handleRequest(request) {
|
|
643
|
+
try {
|
|
644
|
+
const handler = this.handlers.get(request.method);
|
|
645
|
+
if (!handler) {
|
|
646
|
+
this.logger("[RPC] [ERROR] Method not found", {
|
|
647
|
+
method: request.method
|
|
648
|
+
});
|
|
649
|
+
const errorResponse = { error: "Method not found" };
|
|
650
|
+
return encodeBase64(
|
|
651
|
+
encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
const decryptedParams = decrypt(
|
|
655
|
+
this.encryptionKey,
|
|
656
|
+
this.encryptionVariant,
|
|
657
|
+
decodeBase64(request.params)
|
|
658
|
+
);
|
|
659
|
+
this.logger("[RPC] Calling handler", { method: request.method });
|
|
660
|
+
const result = await handler(decryptedParams);
|
|
661
|
+
this.logger("[RPC] Handler returned", {
|
|
662
|
+
method: request.method,
|
|
663
|
+
hasResult: result !== void 0
|
|
664
|
+
});
|
|
665
|
+
const encryptedResponse = encodeBase64(
|
|
666
|
+
encrypt(this.encryptionKey, this.encryptionVariant, result)
|
|
667
|
+
);
|
|
668
|
+
return encryptedResponse;
|
|
669
|
+
} catch (error) {
|
|
670
|
+
this.logger("[RPC] [ERROR] Error handling request", { error });
|
|
671
|
+
const errorResponse = {
|
|
672
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
673
|
+
};
|
|
674
|
+
return encodeBase64(
|
|
675
|
+
encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
onSocketConnect(socket) {
|
|
680
|
+
this.socket = socket;
|
|
681
|
+
this.registerAllHandlers(socket);
|
|
682
|
+
this.startReregisterInterval();
|
|
683
|
+
}
|
|
684
|
+
onSocketDisconnect() {
|
|
685
|
+
this.socket = null;
|
|
686
|
+
this.stopReregisterInterval();
|
|
687
|
+
}
|
|
688
|
+
getHandlerCount() {
|
|
689
|
+
return this.handlers.size;
|
|
690
|
+
}
|
|
691
|
+
hasHandler(method) {
|
|
692
|
+
return this.handlers.has(this.getPrefixedMethod(method));
|
|
693
|
+
}
|
|
694
|
+
clearHandlers() {
|
|
695
|
+
this.handlers.clear();
|
|
696
|
+
this.logger("Cleared all RPC handlers");
|
|
697
|
+
}
|
|
698
|
+
// -----------------------------------------------------------------------
|
|
699
|
+
// Private
|
|
700
|
+
// -----------------------------------------------------------------------
|
|
701
|
+
/**
|
|
702
|
+
* Register a single method with ack + retry.
|
|
703
|
+
* Falls back to fire-and-forget emit after all retries exhausted.
|
|
704
|
+
*/
|
|
705
|
+
emitRegisterWithRetry(socket, method, maxRetries = 3) {
|
|
706
|
+
const attempt = (remaining) => {
|
|
707
|
+
if (this.socket !== socket) return;
|
|
708
|
+
socket.timeout(5e3).emit(
|
|
709
|
+
"rpc-register",
|
|
710
|
+
{ method },
|
|
711
|
+
(err, ackResponse) => {
|
|
712
|
+
if (this.socket !== socket) return;
|
|
713
|
+
if (err && remaining > 0) {
|
|
714
|
+
this.logger("[RPC] rpc-register ack timeout, retrying", {
|
|
715
|
+
method,
|
|
716
|
+
remaining
|
|
717
|
+
});
|
|
718
|
+
setTimeout(() => attempt(remaining - 1), 1e3);
|
|
719
|
+
} else if (err) {
|
|
720
|
+
this.logger(
|
|
721
|
+
"[RPC] [WARN] rpc-register failed after retries, falling back to emit",
|
|
722
|
+
{ method }
|
|
723
|
+
);
|
|
724
|
+
socket.emit("rpc-register", { method });
|
|
725
|
+
} else if (!ackResponse?.ok) {
|
|
726
|
+
this.logger("[RPC] [WARN] rpc-register rejected by server", {
|
|
727
|
+
method,
|
|
728
|
+
error: ackResponse?.error
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
);
|
|
733
|
+
};
|
|
734
|
+
attempt(maxRetries);
|
|
735
|
+
}
|
|
736
|
+
registerAllHandlers(socket) {
|
|
737
|
+
for (const [prefixedMethod] of this.handlers) {
|
|
738
|
+
this.emitRegisterWithRetry(socket, prefixedMethod);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Periodic re-registration every 60s as a safety net.
|
|
743
|
+
*/
|
|
744
|
+
startReregisterInterval() {
|
|
745
|
+
this.stopReregisterInterval();
|
|
746
|
+
this.reregisterInterval = setInterval(() => {
|
|
747
|
+
if (this.socket && this.handlers.size > 0) {
|
|
748
|
+
this.registerAllHandlers(this.socket);
|
|
749
|
+
}
|
|
750
|
+
}, 6e4);
|
|
751
|
+
}
|
|
752
|
+
stopReregisterInterval() {
|
|
753
|
+
if (this.reregisterInterval) {
|
|
754
|
+
clearInterval(this.reregisterInterval);
|
|
755
|
+
this.reregisterInterval = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
getPrefixedMethod(method) {
|
|
759
|
+
return `${this.scopePrefix}:${method}`;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function createRpcHandlerManager(config) {
|
|
763
|
+
return new RpcHandlerManager(config);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const execAsync = promisify(exec);
|
|
767
|
+
const UPLOAD_TEMP_DIR = join$1(tmpdir(), "happy", "uploads");
|
|
768
|
+
const MAX_WRITE_SIZE = 10 * 1024 * 1024;
|
|
769
|
+
function validatePath(targetPath, workingDirectory, additionalAllowedDirs) {
|
|
770
|
+
const resolvedTarget = resolve(workingDirectory, targetPath);
|
|
771
|
+
let realTarget;
|
|
772
|
+
try {
|
|
773
|
+
realTarget = realpathSync(resolvedTarget);
|
|
774
|
+
} catch {
|
|
775
|
+
const parentDir = resolve(resolvedTarget, "..");
|
|
776
|
+
try {
|
|
777
|
+
realTarget = realpathSync(parentDir) + "/" + resolvedTarget.split("/").pop();
|
|
778
|
+
} catch {
|
|
779
|
+
realTarget = resolvedTarget;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const allowedDirs = [
|
|
783
|
+
workingDirectory,
|
|
784
|
+
...additionalAllowedDirs ?? []
|
|
785
|
+
].map((d) => {
|
|
786
|
+
const resolved = resolve(d);
|
|
787
|
+
try {
|
|
788
|
+
return realpathSync(resolved);
|
|
789
|
+
} catch {
|
|
790
|
+
return resolved;
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
for (const dir of allowedDirs) {
|
|
794
|
+
if (realTarget.startsWith(dir + "/") || realTarget === dir) {
|
|
795
|
+
return { valid: true };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
valid: false,
|
|
800
|
+
error: `Access denied: Path '${targetPath}' is outside the allowed directories`
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const BLOCKED_BASH_PATTERNS = [
|
|
804
|
+
{ pattern: /\bprintenv\b/i, reason: "printenv is blocked for security" },
|
|
805
|
+
{
|
|
806
|
+
pattern: /\benv\b(?:\s|$|;|\|)/i,
|
|
807
|
+
reason: "env command is blocked for security"
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
pattern: /\bset\b\s*(?:$|;|\|)/i,
|
|
811
|
+
reason: "set (list env) is blocked for security"
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
pattern: /\bexport\s+-p\b/i,
|
|
815
|
+
reason: "export -p is blocked for security"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
pattern: /\bcompgen\s+-e\b/i,
|
|
819
|
+
reason: "compgen -e is blocked for security"
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
pattern: /\bdeclare\s+-x\b/i,
|
|
823
|
+
reason: "declare -x is blocked for security"
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
pattern: /\/proc\/[^/]*\/environ/i,
|
|
827
|
+
reason: "reading /proc/environ is blocked for security"
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
pattern: /\$\{?\s*(ANTHROPIC_AUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_BASE_URL|OPENAI_API_KEY|OPENAI_BASE_URL|DATABASE_URL|REDIS_URL|JWT_SECRET|ENCRYPTION_KEY|AWS_SECRET_ACCESS_KEY|GOOGLE_API_KEY|GEMINI_API_KEY|TOGETHER_API_KEY|GITHUB_CLIENT_SECRET|CLAUDE_CODE_OAUTH_TOKEN)\b/i,
|
|
831
|
+
reason: "accessing sensitive environment variables is blocked"
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
pattern: /\.(env|env\.local|env\.prod|env\.production|env\.dev)\b/i,
|
|
835
|
+
reason: "reading .env files is blocked for security"
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
pattern: /\.aws\/credentials/i,
|
|
839
|
+
reason: "reading AWS credentials is blocked for security"
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
pattern: /\.netrc/i,
|
|
843
|
+
reason: "reading .netrc is blocked for security"
|
|
844
|
+
}
|
|
845
|
+
];
|
|
846
|
+
function checkBlockedBashCommand(command) {
|
|
847
|
+
for (const { pattern, reason } of BLOCKED_BASH_PATTERNS) {
|
|
848
|
+
if (pattern.test(command)) {
|
|
849
|
+
return reason;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
function registerAgentHandlers(rpcHandlerManager, workingDirectory, sessionId) {
|
|
855
|
+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9-]/g, "");
|
|
856
|
+
rpcHandlerManager.registerHandler(
|
|
857
|
+
"bash",
|
|
858
|
+
async (data) => {
|
|
859
|
+
logger.debug("Shell command request:", data.command);
|
|
860
|
+
const blockedReason = checkBlockedBashCommand(data.command);
|
|
861
|
+
if (blockedReason) {
|
|
862
|
+
logger.warn(
|
|
863
|
+
`[SECURITY] Blocked bash RPC command: ${blockedReason}`,
|
|
864
|
+
{ command: data.command }
|
|
865
|
+
);
|
|
866
|
+
return { success: false, error: blockedReason };
|
|
867
|
+
}
|
|
868
|
+
if (data.cwd && data.cwd !== "/") {
|
|
869
|
+
const validation = validatePath(data.cwd, workingDirectory);
|
|
870
|
+
if (!validation.valid) {
|
|
871
|
+
return { success: false, error: validation.error };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const options = {
|
|
876
|
+
cwd: data.cwd === "/" ? void 0 : data.cwd,
|
|
877
|
+
timeout: data.timeout ?? 3e4
|
|
878
|
+
};
|
|
879
|
+
const { stdout, stderr } = await execAsync(data.command, options);
|
|
880
|
+
return {
|
|
881
|
+
success: true,
|
|
882
|
+
stdout: stdout?.toString() ?? "",
|
|
883
|
+
stderr: stderr?.toString() ?? "",
|
|
884
|
+
exitCode: 0
|
|
885
|
+
};
|
|
886
|
+
} catch (error) {
|
|
887
|
+
const execError = error;
|
|
888
|
+
if (execError.code === "ETIMEDOUT" || execError.killed) {
|
|
889
|
+
return {
|
|
890
|
+
success: false,
|
|
891
|
+
stdout: execError.stdout ?? "",
|
|
892
|
+
stderr: execError.stderr ?? "",
|
|
893
|
+
exitCode: typeof execError.code === "number" ? execError.code : -1,
|
|
894
|
+
error: "Command timed out"
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
success: false,
|
|
899
|
+
stdout: execError.stdout?.toString() ?? "",
|
|
900
|
+
stderr: execError.stderr?.toString() ?? execError.message ?? "Command failed",
|
|
901
|
+
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
|
902
|
+
error: execError.message ?? "Command failed"
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
);
|
|
907
|
+
rpcHandlerManager.registerHandler(
|
|
908
|
+
"readFile",
|
|
909
|
+
async (data) => {
|
|
910
|
+
logger.debug("Read file request:", data.path);
|
|
911
|
+
const sessionUploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
|
|
912
|
+
const validation = validatePath(data.path, workingDirectory, [
|
|
913
|
+
sessionUploadDir
|
|
914
|
+
]);
|
|
915
|
+
if (!validation.valid) {
|
|
916
|
+
return { success: false, error: validation.error };
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
const resolvedPath = resolve(workingDirectory, data.path);
|
|
920
|
+
const buffer = await readFile(resolvedPath);
|
|
921
|
+
return { success: true, content: buffer.toString("base64") };
|
|
922
|
+
} catch (error) {
|
|
923
|
+
logger.debug("Failed to read file:", error);
|
|
924
|
+
return {
|
|
925
|
+
success: false,
|
|
926
|
+
error: error instanceof Error ? error.message : "Failed to read file"
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
);
|
|
931
|
+
rpcHandlerManager.registerHandler(
|
|
932
|
+
"writeFile",
|
|
933
|
+
async (data) => {
|
|
934
|
+
logger.debug("Write file request:", data.path);
|
|
935
|
+
if (data.content && data.content.length > MAX_WRITE_SIZE) {
|
|
936
|
+
return {
|
|
937
|
+
success: false,
|
|
938
|
+
error: `File content exceeds maximum allowed size (${MAX_WRITE_SIZE} bytes)`
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
const sessionUploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
|
|
942
|
+
const validation = validatePath(data.path, workingDirectory, [
|
|
943
|
+
sessionUploadDir
|
|
944
|
+
]);
|
|
945
|
+
if (!validation.valid) {
|
|
946
|
+
return { success: false, error: validation.error };
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const resolvedPath = resolve(workingDirectory, data.path);
|
|
950
|
+
if (data.expectedHash !== null && data.expectedHash !== void 0) {
|
|
951
|
+
try {
|
|
952
|
+
const existingBuffer = await readFile(resolvedPath);
|
|
953
|
+
const existingHash = createHash$1("sha256").update(existingBuffer).digest("hex");
|
|
954
|
+
if (existingHash !== data.expectedHash) {
|
|
955
|
+
return {
|
|
956
|
+
success: false,
|
|
957
|
+
error: `File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}`
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
return {
|
|
962
|
+
success: false,
|
|
963
|
+
error: "File does not exist but expectedHash was provided"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const dir = resolve(resolvedPath, "..");
|
|
968
|
+
await mkdir(dir, { recursive: true });
|
|
969
|
+
const buffer = Buffer.from(data.content, "base64");
|
|
970
|
+
await writeFile(resolvedPath, buffer);
|
|
971
|
+
const hash = createHash$1("sha256").update(buffer).digest("hex");
|
|
972
|
+
return { success: true, hash };
|
|
973
|
+
} catch (error) {
|
|
974
|
+
logger.debug("Failed to write file:", error);
|
|
975
|
+
return {
|
|
976
|
+
success: false,
|
|
977
|
+
error: error instanceof Error ? error.message : "Failed to write file"
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
);
|
|
982
|
+
rpcHandlerManager.registerHandler("listDirectory", async (data) => {
|
|
983
|
+
logger.debug("List directory request:", data.path);
|
|
984
|
+
const validation = validatePath(data.path, workingDirectory);
|
|
985
|
+
if (!validation.valid) {
|
|
986
|
+
return { success: false, error: validation.error };
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
const entries = await readdir(data.path, { withFileTypes: true });
|
|
990
|
+
const directoryEntries = await Promise.all(
|
|
991
|
+
entries.map(async (entry) => {
|
|
992
|
+
const fullPath = join$1(data.path, entry.name);
|
|
993
|
+
let type = "other";
|
|
994
|
+
let size;
|
|
995
|
+
let modified;
|
|
996
|
+
if (entry.isDirectory()) type = "directory";
|
|
997
|
+
else if (entry.isFile()) type = "file";
|
|
998
|
+
try {
|
|
999
|
+
const stats = await stat(fullPath);
|
|
1000
|
+
size = stats.size;
|
|
1001
|
+
modified = stats.mtime.getTime();
|
|
1002
|
+
} catch {
|
|
1003
|
+
}
|
|
1004
|
+
return { name: entry.name, type, size, modified };
|
|
1005
|
+
})
|
|
1006
|
+
);
|
|
1007
|
+
directoryEntries.sort((a, b) => {
|
|
1008
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
1009
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
1010
|
+
return a.name.localeCompare(b.name);
|
|
1011
|
+
});
|
|
1012
|
+
return { success: true, entries: directoryEntries };
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
logger.debug("Failed to list directory:", error);
|
|
1015
|
+
return {
|
|
1016
|
+
success: false,
|
|
1017
|
+
error: error instanceof Error ? error.message : "Failed to list directory"
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
rpcHandlerManager.registerHandler("getUploadDir", async () => {
|
|
1022
|
+
const uploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
|
|
1023
|
+
await mkdir(uploadDir, { recursive: true });
|
|
1024
|
+
return { success: true, path: uploadDir };
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
419
1027
|
|
|
420
1028
|
class SessionClient extends EventEmitter {
|
|
421
1029
|
sessionId;
|
|
1030
|
+
rpcHandlerManager;
|
|
422
1031
|
encryptionKey;
|
|
423
1032
|
encryptionVariant;
|
|
424
1033
|
socket;
|
|
@@ -427,6 +1036,7 @@ class SessionClient extends EventEmitter {
|
|
|
427
1036
|
agentState = null;
|
|
428
1037
|
agentStateVersion = 0;
|
|
429
1038
|
aliveInterval = null;
|
|
1039
|
+
thinking = false;
|
|
430
1040
|
constructor(opts) {
|
|
431
1041
|
super();
|
|
432
1042
|
this.sessionId = opts.sessionId;
|
|
@@ -437,6 +1047,16 @@ class SessionClient extends EventEmitter {
|
|
|
437
1047
|
}
|
|
438
1048
|
this.on("error", () => {
|
|
439
1049
|
});
|
|
1050
|
+
this.rpcHandlerManager = createRpcHandlerManager({
|
|
1051
|
+
scopePrefix: `session:${opts.sessionId}`,
|
|
1052
|
+
encryptionKey: opts.encryptionKey,
|
|
1053
|
+
encryptionVariant: opts.encryptionVariant,
|
|
1054
|
+
logger: (msg, data) => logger.debug(msg, data)
|
|
1055
|
+
});
|
|
1056
|
+
if (opts.enableRpc !== false) {
|
|
1057
|
+
const workDir = opts.workingDirectory ?? process.cwd();
|
|
1058
|
+
registerAgentHandlers(this.rpcHandlerManager, workDir, opts.sessionId);
|
|
1059
|
+
}
|
|
440
1060
|
this.socket = io(opts.serverUrl, {
|
|
441
1061
|
auth: {
|
|
442
1062
|
token: opts.token,
|
|
@@ -451,84 +1071,17 @@ class SessionClient extends EventEmitter {
|
|
|
451
1071
|
transports: ["websocket"],
|
|
452
1072
|
autoConnect: false
|
|
453
1073
|
});
|
|
454
|
-
this.
|
|
455
|
-
this.emit("connected");
|
|
456
|
-
this.aliveInterval = setInterval(() => {
|
|
457
|
-
this.socket.emit("session-alive", {
|
|
458
|
-
sid: this.sessionId,
|
|
459
|
-
time: Date.now()
|
|
460
|
-
});
|
|
461
|
-
}, 2e4);
|
|
462
|
-
});
|
|
463
|
-
this.socket.on("disconnect", (reason) => {
|
|
464
|
-
if (this.aliveInterval !== null) {
|
|
465
|
-
clearInterval(this.aliveInterval);
|
|
466
|
-
this.aliveInterval = null;
|
|
467
|
-
}
|
|
468
|
-
this.emit("disconnected", reason);
|
|
469
|
-
});
|
|
470
|
-
this.socket.on("connect_error", (error) => {
|
|
471
|
-
this.emit("connect_error", error);
|
|
472
|
-
});
|
|
473
|
-
this.socket.on("update", (data) => {
|
|
474
|
-
try {
|
|
475
|
-
const body = data?.body;
|
|
476
|
-
if (!body) return;
|
|
477
|
-
if (body.t === "new-message" && body.message?.content?.t === "encrypted") {
|
|
478
|
-
const msg = body.message;
|
|
479
|
-
const decrypted = decrypt(
|
|
480
|
-
this.encryptionKey,
|
|
481
|
-
this.encryptionVariant,
|
|
482
|
-
decodeBase64(msg.content.c)
|
|
483
|
-
);
|
|
484
|
-
if (decrypted === null) return;
|
|
485
|
-
this.emit("message", {
|
|
486
|
-
id: msg.id,
|
|
487
|
-
seq: msg.seq,
|
|
488
|
-
content: decrypted,
|
|
489
|
-
localId: msg.localId,
|
|
490
|
-
createdAt: msg.createdAt,
|
|
491
|
-
updatedAt: msg.updatedAt
|
|
492
|
-
});
|
|
493
|
-
} else if (body.t === "update-session") {
|
|
494
|
-
if (body.metadata && body.metadata.version > this.metadataVersion) {
|
|
495
|
-
this.metadata = decrypt(
|
|
496
|
-
this.encryptionKey,
|
|
497
|
-
this.encryptionVariant,
|
|
498
|
-
decodeBase64(body.metadata.value)
|
|
499
|
-
);
|
|
500
|
-
this.metadataVersion = body.metadata.version;
|
|
501
|
-
}
|
|
502
|
-
if (body.agentState && body.agentState.version > this.agentStateVersion) {
|
|
503
|
-
this.agentState = body.agentState.value ? decrypt(
|
|
504
|
-
this.encryptionKey,
|
|
505
|
-
this.encryptionVariant,
|
|
506
|
-
decodeBase64(body.agentState.value)
|
|
507
|
-
) : null;
|
|
508
|
-
this.agentStateVersion = body.agentState.version;
|
|
509
|
-
}
|
|
510
|
-
this.emit("state-change", {
|
|
511
|
-
metadata: this.metadata,
|
|
512
|
-
agentState: this.agentState
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
} catch (err) {
|
|
516
|
-
this.emit("error", err);
|
|
517
|
-
}
|
|
518
|
-
});
|
|
1074
|
+
this.setupSocketListeners();
|
|
519
1075
|
this.socket.connect();
|
|
520
1076
|
}
|
|
1077
|
+
// -----------------------------------------------------------------------
|
|
1078
|
+
// Public API (backward compatible)
|
|
1079
|
+
// -----------------------------------------------------------------------
|
|
521
1080
|
sendMessage(text, meta) {
|
|
522
1081
|
const content = {
|
|
523
1082
|
role: "user",
|
|
524
|
-
content: {
|
|
525
|
-
|
|
526
|
-
text
|
|
527
|
-
},
|
|
528
|
-
meta: {
|
|
529
|
-
sentFrom: "happy-agent",
|
|
530
|
-
...meta
|
|
531
|
-
}
|
|
1083
|
+
content: { type: "text", text },
|
|
1084
|
+
meta: { sentFrom: "happy-agent", ...meta }
|
|
532
1085
|
};
|
|
533
1086
|
const encrypted = encodeBase64(
|
|
534
1087
|
encrypt(this.encryptionKey, this.encryptionVariant, content)
|
|
@@ -544,6 +1097,9 @@ class SessionClient extends EventEmitter {
|
|
|
544
1097
|
getAgentState() {
|
|
545
1098
|
return this.agentState;
|
|
546
1099
|
}
|
|
1100
|
+
setThinking(thinking) {
|
|
1101
|
+
this.thinking = thinking;
|
|
1102
|
+
}
|
|
547
1103
|
waitForConnect(timeoutMs = 1e4) {
|
|
548
1104
|
return new Promise((resolve, reject) => {
|
|
549
1105
|
if (this.socket.connected) {
|
|
@@ -573,13 +1129,9 @@ class SessionClient extends EventEmitter {
|
|
|
573
1129
|
return new Promise((resolve, reject) => {
|
|
574
1130
|
const checkIdle = () => {
|
|
575
1131
|
const meta = this.metadata;
|
|
576
|
-
if (meta?.lifecycleState === "archived")
|
|
577
|
-
return "archived";
|
|
578
|
-
}
|
|
1132
|
+
if (meta?.lifecycleState === "archived") return "archived";
|
|
579
1133
|
const state = this.agentState;
|
|
580
|
-
if (!state)
|
|
581
|
-
return false;
|
|
582
|
-
}
|
|
1134
|
+
if (!state) return false;
|
|
583
1135
|
const controlledByUser = state.controlledByUser === true;
|
|
584
1136
|
const requests = state.requests;
|
|
585
1137
|
const hasRequests = requests != null && typeof requests === "object" && !Array.isArray(requests) && Object.keys(requests).length > 0;
|
|
@@ -636,8 +1188,352 @@ class SessionClient extends EventEmitter {
|
|
|
636
1188
|
clearInterval(this.aliveInterval);
|
|
637
1189
|
this.aliveInterval = null;
|
|
638
1190
|
}
|
|
1191
|
+
this.rpcHandlerManager.onSocketDisconnect();
|
|
639
1192
|
this.socket.close();
|
|
640
1193
|
}
|
|
1194
|
+
// -----------------------------------------------------------------------
|
|
1195
|
+
// New: OCC metadata/state updates
|
|
1196
|
+
// -----------------------------------------------------------------------
|
|
1197
|
+
async updateMetadata(newMetadata) {
|
|
1198
|
+
const encrypted = encodeBase64(
|
|
1199
|
+
encrypt(this.encryptionKey, this.encryptionVariant, newMetadata)
|
|
1200
|
+
);
|
|
1201
|
+
await withBackoff(
|
|
1202
|
+
() => new Promise((resolve, reject) => {
|
|
1203
|
+
this.socket.emit(
|
|
1204
|
+
"update-metadata",
|
|
1205
|
+
{
|
|
1206
|
+
sid: this.sessionId,
|
|
1207
|
+
expectedVersion: this.metadataVersion,
|
|
1208
|
+
metadata: encrypted
|
|
1209
|
+
},
|
|
1210
|
+
(answer) => {
|
|
1211
|
+
if (answer.result === "success" && answer.version !== void 0) {
|
|
1212
|
+
this.metadataVersion = answer.version;
|
|
1213
|
+
this.metadata = newMetadata;
|
|
1214
|
+
resolve();
|
|
1215
|
+
} else if (answer.result === "version-mismatch" && answer.version !== void 0) {
|
|
1216
|
+
this.metadataVersion = answer.version;
|
|
1217
|
+
if (answer.metadata) {
|
|
1218
|
+
this.metadata = decrypt(
|
|
1219
|
+
this.encryptionKey,
|
|
1220
|
+
this.encryptionVariant,
|
|
1221
|
+
decodeBase64(answer.metadata)
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
reject(new Error("version-mismatch"));
|
|
1225
|
+
} else {
|
|
1226
|
+
reject(new Error(`update-metadata failed: ${answer.result}`));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
);
|
|
1230
|
+
}),
|
|
1231
|
+
{ maxRetries: 3, label: "updateMetadata" }
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
async updateAgentState(newState) {
|
|
1235
|
+
const encrypted = newState !== null ? encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, newState)) : null;
|
|
1236
|
+
await withBackoff(
|
|
1237
|
+
() => new Promise((resolve, reject) => {
|
|
1238
|
+
this.socket.emit(
|
|
1239
|
+
"update-state",
|
|
1240
|
+
{
|
|
1241
|
+
sid: this.sessionId,
|
|
1242
|
+
expectedVersion: this.agentStateVersion,
|
|
1243
|
+
agentState: encrypted
|
|
1244
|
+
},
|
|
1245
|
+
(answer) => {
|
|
1246
|
+
if (answer.result === "success" && answer.version !== void 0) {
|
|
1247
|
+
this.agentStateVersion = answer.version;
|
|
1248
|
+
this.agentState = newState;
|
|
1249
|
+
resolve();
|
|
1250
|
+
} else if (answer.result === "version-mismatch" && answer.version !== void 0) {
|
|
1251
|
+
this.agentStateVersion = answer.version;
|
|
1252
|
+
if (answer.agentState) {
|
|
1253
|
+
this.agentState = decrypt(
|
|
1254
|
+
this.encryptionKey,
|
|
1255
|
+
this.encryptionVariant,
|
|
1256
|
+
decodeBase64(answer.agentState)
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
reject(new Error("version-mismatch"));
|
|
1260
|
+
} else {
|
|
1261
|
+
reject(new Error(`update-state failed: ${answer.result}`));
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
);
|
|
1265
|
+
}),
|
|
1266
|
+
{ maxRetries: 3, label: "updateAgentState" }
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
// -----------------------------------------------------------------------
|
|
1270
|
+
// Private: socket listeners
|
|
1271
|
+
// -----------------------------------------------------------------------
|
|
1272
|
+
setupSocketListeners() {
|
|
1273
|
+
this.socket.on("connect", () => {
|
|
1274
|
+
this.emit("connected");
|
|
1275
|
+
this.rpcHandlerManager.onSocketConnect(this.socket);
|
|
1276
|
+
this.aliveInterval = setInterval(() => {
|
|
1277
|
+
this.socket.emit("session-alive", {
|
|
1278
|
+
sid: this.sessionId,
|
|
1279
|
+
time: Date.now(),
|
|
1280
|
+
thinking: this.thinking
|
|
1281
|
+
});
|
|
1282
|
+
}, 2e4);
|
|
1283
|
+
});
|
|
1284
|
+
this.socket.on("disconnect", (reason) => {
|
|
1285
|
+
if (this.aliveInterval !== null) {
|
|
1286
|
+
clearInterval(this.aliveInterval);
|
|
1287
|
+
this.aliveInterval = null;
|
|
1288
|
+
}
|
|
1289
|
+
this.rpcHandlerManager.onSocketDisconnect();
|
|
1290
|
+
this.emit("disconnected", reason);
|
|
1291
|
+
});
|
|
1292
|
+
this.socket.on("connect_error", (error) => {
|
|
1293
|
+
this.emit("connect_error", error);
|
|
1294
|
+
});
|
|
1295
|
+
this.socket.on("rpc-request", async (data, callback) => {
|
|
1296
|
+
try {
|
|
1297
|
+
const response = await this.rpcHandlerManager.handleRequest(data);
|
|
1298
|
+
callback(response);
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
logger.error("[RPC] Unhandled error in rpc-request handler", err);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
this.socket.on("update", (data) => {
|
|
1304
|
+
try {
|
|
1305
|
+
const body = data?.body;
|
|
1306
|
+
if (!body) return;
|
|
1307
|
+
if (body.t === "new-message" && body.message?.content?.t === "encrypted") {
|
|
1308
|
+
const msg = body.message;
|
|
1309
|
+
const decrypted = decrypt(
|
|
1310
|
+
this.encryptionKey,
|
|
1311
|
+
this.encryptionVariant,
|
|
1312
|
+
decodeBase64(msg.content.c)
|
|
1313
|
+
);
|
|
1314
|
+
if (decrypted === null) return;
|
|
1315
|
+
this.emit("message", {
|
|
1316
|
+
id: msg.id,
|
|
1317
|
+
seq: msg.seq,
|
|
1318
|
+
content: decrypted,
|
|
1319
|
+
localId: msg.localId,
|
|
1320
|
+
createdAt: msg.createdAt,
|
|
1321
|
+
updatedAt: msg.updatedAt
|
|
1322
|
+
});
|
|
1323
|
+
} else if (body.t === "update-session") {
|
|
1324
|
+
if (body.metadata && body.metadata.version > this.metadataVersion) {
|
|
1325
|
+
this.metadata = decrypt(
|
|
1326
|
+
this.encryptionKey,
|
|
1327
|
+
this.encryptionVariant,
|
|
1328
|
+
decodeBase64(body.metadata.value)
|
|
1329
|
+
);
|
|
1330
|
+
this.metadataVersion = body.metadata.version;
|
|
1331
|
+
}
|
|
1332
|
+
if (body.agentState && body.agentState.version > this.agentStateVersion) {
|
|
1333
|
+
this.agentState = body.agentState.value ? decrypt(
|
|
1334
|
+
this.encryptionKey,
|
|
1335
|
+
this.encryptionVariant,
|
|
1336
|
+
decodeBase64(body.agentState.value)
|
|
1337
|
+
) : null;
|
|
1338
|
+
this.agentStateVersion = body.agentState.version;
|
|
1339
|
+
}
|
|
1340
|
+
this.emit("state-change", {
|
|
1341
|
+
metadata: this.metadata,
|
|
1342
|
+
agentState: this.agentState
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
this.emit("error", err);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
class MachineClient {
|
|
1353
|
+
machine;
|
|
1354
|
+
rpcHandlerManager;
|
|
1355
|
+
socket;
|
|
1356
|
+
keepAliveInterval = null;
|
|
1357
|
+
token;
|
|
1358
|
+
serverUrl;
|
|
1359
|
+
onEphemeral;
|
|
1360
|
+
constructor(opts) {
|
|
1361
|
+
this.token = opts.token;
|
|
1362
|
+
this.machine = opts.machine;
|
|
1363
|
+
this.serverUrl = opts.serverUrl;
|
|
1364
|
+
this.onEphemeral = opts.onEphemeral;
|
|
1365
|
+
this.rpcHandlerManager = new RpcHandlerManager({
|
|
1366
|
+
scopePrefix: opts.machine.id,
|
|
1367
|
+
encryptionKey: opts.machine.encryptionKey,
|
|
1368
|
+
encryptionVariant: opts.machine.encryptionVariant,
|
|
1369
|
+
logger: (msg, data) => logger.debug(msg, data)
|
|
1370
|
+
});
|
|
1371
|
+
const workDir = opts.workingDirectory ?? process.cwd();
|
|
1372
|
+
registerAgentHandlers(this.rpcHandlerManager, workDir, opts.machine.id);
|
|
1373
|
+
}
|
|
1374
|
+
// -----------------------------------------------------------------------
|
|
1375
|
+
// Connection
|
|
1376
|
+
// -----------------------------------------------------------------------
|
|
1377
|
+
connect() {
|
|
1378
|
+
logger.debug(`[MACHINE] Connecting to ${this.serverUrl}`);
|
|
1379
|
+
this.socket = io(this.serverUrl, {
|
|
1380
|
+
transports: ["websocket"],
|
|
1381
|
+
auth: {
|
|
1382
|
+
token: this.token,
|
|
1383
|
+
clientType: "machine-scoped",
|
|
1384
|
+
machineId: this.machine.id
|
|
1385
|
+
},
|
|
1386
|
+
path: "/v1/updates",
|
|
1387
|
+
reconnection: true,
|
|
1388
|
+
reconnectionDelay: 1e3,
|
|
1389
|
+
reconnectionDelayMax: 5e3
|
|
1390
|
+
});
|
|
1391
|
+
this.socket.on("connect", () => {
|
|
1392
|
+
logger.debug("[MACHINE] Connected to server");
|
|
1393
|
+
this.rpcHandlerManager.onSocketConnect(this.socket);
|
|
1394
|
+
this.startKeepAlive();
|
|
1395
|
+
});
|
|
1396
|
+
this.socket.on("disconnect", () => {
|
|
1397
|
+
logger.debug("[MACHINE] Disconnected from server");
|
|
1398
|
+
this.rpcHandlerManager.onSocketDisconnect();
|
|
1399
|
+
this.stopKeepAlive();
|
|
1400
|
+
});
|
|
1401
|
+
this.socket.on(
|
|
1402
|
+
"rpc-request",
|
|
1403
|
+
async (data, callback) => {
|
|
1404
|
+
logger.debug("[MACHINE] Received RPC request:", data.method);
|
|
1405
|
+
callback(await this.rpcHandlerManager.handleRequest(data));
|
|
1406
|
+
}
|
|
1407
|
+
);
|
|
1408
|
+
this.socket.on("update", (data) => {
|
|
1409
|
+
const body = data?.body;
|
|
1410
|
+
if (body?.t === "update-machine" && body.machineId === this.machine.id) {
|
|
1411
|
+
if (body.metadata && body.metadata.version > this.machine.metadataVersion) {
|
|
1412
|
+
this.machine.metadata = decrypt(
|
|
1413
|
+
this.machine.encryptionKey,
|
|
1414
|
+
this.machine.encryptionVariant,
|
|
1415
|
+
decodeBase64(body.metadata.value)
|
|
1416
|
+
);
|
|
1417
|
+
this.machine.metadataVersion = body.metadata.version;
|
|
1418
|
+
}
|
|
1419
|
+
if (body.daemonState && body.daemonState.version > this.machine.daemonStateVersion) {
|
|
1420
|
+
this.machine.daemonState = decrypt(
|
|
1421
|
+
this.machine.encryptionKey,
|
|
1422
|
+
this.machine.encryptionVariant,
|
|
1423
|
+
decodeBase64(body.daemonState.value)
|
|
1424
|
+
);
|
|
1425
|
+
this.machine.daemonStateVersion = body.daemonState.version;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
if (this.onEphemeral) {
|
|
1430
|
+
this.socket.on("ephemeral", (data) => {
|
|
1431
|
+
logger.debug("[MACHINE] Received ephemeral event:", data.type);
|
|
1432
|
+
this.onEphemeral?.(data);
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
this.socket.on("connect_error", (error) => {
|
|
1436
|
+
logger.debug(`[MACHINE] Connection error: ${error.message}`);
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
// -----------------------------------------------------------------------
|
|
1440
|
+
// OCC updates
|
|
1441
|
+
// -----------------------------------------------------------------------
|
|
1442
|
+
async updateMachineMetadata(handler) {
|
|
1443
|
+
await withBackoff(async () => {
|
|
1444
|
+
const updated = handler(this.machine.metadata);
|
|
1445
|
+
const answer = await new Promise((resolve) => {
|
|
1446
|
+
this.socket.emit(
|
|
1447
|
+
"machine-update-metadata",
|
|
1448
|
+
{
|
|
1449
|
+
machineId: this.machine.id,
|
|
1450
|
+
metadata: encodeBase64(
|
|
1451
|
+
encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)
|
|
1452
|
+
),
|
|
1453
|
+
expectedVersion: this.machine.metadataVersion
|
|
1454
|
+
},
|
|
1455
|
+
resolve
|
|
1456
|
+
);
|
|
1457
|
+
});
|
|
1458
|
+
if (answer.result === "success") {
|
|
1459
|
+
this.machine.metadata = decrypt(
|
|
1460
|
+
this.machine.encryptionKey,
|
|
1461
|
+
this.machine.encryptionVariant,
|
|
1462
|
+
decodeBase64(answer.metadata)
|
|
1463
|
+
);
|
|
1464
|
+
this.machine.metadataVersion = answer.version;
|
|
1465
|
+
} else if (answer.result === "version-mismatch") {
|
|
1466
|
+
this.machine.metadataVersion = answer.version;
|
|
1467
|
+
this.machine.metadata = decrypt(
|
|
1468
|
+
this.machine.encryptionKey,
|
|
1469
|
+
this.machine.encryptionVariant,
|
|
1470
|
+
decodeBase64(answer.metadata)
|
|
1471
|
+
);
|
|
1472
|
+
throw new Error("Metadata version mismatch");
|
|
1473
|
+
}
|
|
1474
|
+
}, { maxRetries: 3, label: "updateMachineMetadata" });
|
|
1475
|
+
}
|
|
1476
|
+
async updateDaemonState(handler) {
|
|
1477
|
+
await withBackoff(async () => {
|
|
1478
|
+
const updated = handler(this.machine.daemonState);
|
|
1479
|
+
const answer = await new Promise((resolve) => {
|
|
1480
|
+
this.socket.emit(
|
|
1481
|
+
"machine-update-state",
|
|
1482
|
+
{
|
|
1483
|
+
machineId: this.machine.id,
|
|
1484
|
+
daemonState: encodeBase64(
|
|
1485
|
+
encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)
|
|
1486
|
+
),
|
|
1487
|
+
expectedVersion: this.machine.daemonStateVersion
|
|
1488
|
+
},
|
|
1489
|
+
resolve
|
|
1490
|
+
);
|
|
1491
|
+
});
|
|
1492
|
+
if (answer.result === "success") {
|
|
1493
|
+
this.machine.daemonState = decrypt(
|
|
1494
|
+
this.machine.encryptionKey,
|
|
1495
|
+
this.machine.encryptionVariant,
|
|
1496
|
+
decodeBase64(answer.daemonState)
|
|
1497
|
+
);
|
|
1498
|
+
this.machine.daemonStateVersion = answer.version;
|
|
1499
|
+
} else if (answer.result === "version-mismatch") {
|
|
1500
|
+
this.machine.daemonStateVersion = answer.version;
|
|
1501
|
+
this.machine.daemonState = decrypt(
|
|
1502
|
+
this.machine.encryptionKey,
|
|
1503
|
+
this.machine.encryptionVariant,
|
|
1504
|
+
decodeBase64(answer.daemonState)
|
|
1505
|
+
);
|
|
1506
|
+
throw new Error("Daemon state version mismatch");
|
|
1507
|
+
}
|
|
1508
|
+
}, { maxRetries: 3, label: "updateDaemonState" });
|
|
1509
|
+
}
|
|
1510
|
+
// -----------------------------------------------------------------------
|
|
1511
|
+
// Lifecycle
|
|
1512
|
+
// -----------------------------------------------------------------------
|
|
1513
|
+
shutdown() {
|
|
1514
|
+
logger.debug("[MACHINE] Shutting down");
|
|
1515
|
+
this.stopKeepAlive();
|
|
1516
|
+
this.rpcHandlerManager.onSocketDisconnect();
|
|
1517
|
+
this.socket?.close();
|
|
1518
|
+
}
|
|
1519
|
+
// -----------------------------------------------------------------------
|
|
1520
|
+
// Private
|
|
1521
|
+
// -----------------------------------------------------------------------
|
|
1522
|
+
startKeepAlive() {
|
|
1523
|
+
this.stopKeepAlive();
|
|
1524
|
+
this.keepAliveInterval = setInterval(() => {
|
|
1525
|
+
this.socket.emit("machine-alive", {
|
|
1526
|
+
machineId: this.machine.id,
|
|
1527
|
+
time: Date.now()
|
|
1528
|
+
});
|
|
1529
|
+
}, 2e4);
|
|
1530
|
+
}
|
|
1531
|
+
stopKeepAlive() {
|
|
1532
|
+
if (this.keepAliveInterval) {
|
|
1533
|
+
clearInterval(this.keepAliveInterval);
|
|
1534
|
+
this.keepAliveInterval = null;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
641
1537
|
}
|
|
642
1538
|
|
|
643
1539
|
function formatTime(ts) {
|
|
@@ -817,9 +1713,9 @@ function createClient(session, creds, config) {
|
|
|
817
1713
|
const program = new Command();
|
|
818
1714
|
program.name("happy-agent").description("CLI client for controlling Happy Coder agents remotely").version(version);
|
|
819
1715
|
program.command("auth").description("Manage authentication").addCommand(
|
|
820
|
-
new Command("login").description("Authenticate via QR code").action(async () => {
|
|
1716
|
+
new Command("login").description("Authenticate via QR code or web URL").option("--web", "Use web URL instead of QR code (for SSH/headless environments)").action(async (opts) => {
|
|
821
1717
|
const config = loadConfig();
|
|
822
|
-
await authLogin(config);
|
|
1718
|
+
await authLogin(config, { web: opts.web });
|
|
823
1719
|
})
|
|
824
1720
|
).addCommand(
|
|
825
1721
|
new Command("logout").description("Clear stored credentials").action(async () => {
|
|
@@ -1029,7 +1925,55 @@ program.command("wait").description("Wait for agent to become idle").argument("<
|
|
|
1029
1925
|
client.close();
|
|
1030
1926
|
}
|
|
1031
1927
|
});
|
|
1928
|
+
program.command("machine").description("Manage machine identity").addCommand(
|
|
1929
|
+
new Command("register").description("Register this machine with the server").option("--json", "Output as JSON").action(async (opts) => {
|
|
1930
|
+
const config = loadConfig();
|
|
1931
|
+
const creds = requireCredentials(config);
|
|
1932
|
+
const metadata = {
|
|
1933
|
+
host: hostname(),
|
|
1934
|
+
platform: process.platform,
|
|
1935
|
+
happyCliVersion: version,
|
|
1936
|
+
homeDir: config.homeDir,
|
|
1937
|
+
happyHomeDir: config.homeDir,
|
|
1938
|
+
happyLibDir: config.homeDir
|
|
1939
|
+
};
|
|
1940
|
+
const machine = await getOrCreateMachine(config, creds, metadata);
|
|
1941
|
+
if (opts.json) {
|
|
1942
|
+
console.log(formatJson({ id: machine.id, metadata: machine.metadata }));
|
|
1943
|
+
} else {
|
|
1944
|
+
console.log(
|
|
1945
|
+
[
|
|
1946
|
+
"## Machine Registered",
|
|
1947
|
+
"",
|
|
1948
|
+
`- Machine ID: \`${machine.id}\``,
|
|
1949
|
+
`- Host: ${machine.metadata.host}`,
|
|
1950
|
+
`- Platform: ${machine.metadata.platform}`
|
|
1951
|
+
].join("\n")
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
})
|
|
1955
|
+
).addCommand(
|
|
1956
|
+
new Command("list").description("List all registered machines").option("--json", "Output as JSON").action(async (opts) => {
|
|
1957
|
+
const config = loadConfig();
|
|
1958
|
+
const creds = requireCredentials(config);
|
|
1959
|
+
const machines = await listMachines(config, creds);
|
|
1960
|
+
if (opts.json) {
|
|
1961
|
+
console.log(formatJson(machines));
|
|
1962
|
+
} else {
|
|
1963
|
+
if (machines.length === 0) {
|
|
1964
|
+
console.log("No machines registered.");
|
|
1965
|
+
} else {
|
|
1966
|
+
console.log(`## Machines (${machines.length})`);
|
|
1967
|
+
for (const m of machines) {
|
|
1968
|
+
console.log(`- \`${m.id}\``);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
})
|
|
1973
|
+
);
|
|
1032
1974
|
program.parseAsync(process.argv).catch((err) => {
|
|
1033
1975
|
console.error(err instanceof Error ? err.message : String(err));
|
|
1034
1976
|
process.exitCode = 1;
|
|
1035
1977
|
});
|
|
1978
|
+
|
|
1979
|
+
export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
|