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