doer-agent 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/agent.js +372 -3
  2. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, statSync } from "node:fs";
3
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType } from "nats";
6
+ import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType, StringCodec } from "nats";
7
7
  const DEFAULT_SERVER_BASE_URL = "https://doer.cranix.net";
8
8
  const AGENT_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
9
9
  const AGENT_PROJECT_DIR = path.join(AGENT_MODULE_DIR, "..");
@@ -11,6 +11,8 @@ const AGENT_PACKAGE_JSON_PATH = path.join(AGENT_PROJECT_DIR, "package.json");
11
11
  let activeTaskLogContext = null;
12
12
  const activeTaskCancelRequests = new Map();
13
13
  let workspaceRootOverride = null;
14
+ const fsRpcCodec = StringCodec();
15
+ const shellRpcCodec = StringCodec();
14
16
  function sanitizeUserId(userId) {
15
17
  const normalized = userId.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
16
18
  return normalized.length > 0 ? normalized : "anonymous";
@@ -534,6 +536,341 @@ function resolveTaskWorkspace(rawCwd) {
534
536
  }
535
537
  return resolvedCwd;
536
538
  }
539
+ function buildAgentFsRpcSubject(userId, agentId) {
540
+ return `doer.agent.fs.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
541
+ }
542
+ function buildAgentShellRpcSubject(userId, agentId) {
543
+ return `doer.agent.shell.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
544
+ }
545
+ function normalizeFsRpcPath(rawPath) {
546
+ const root = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
547
+ const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
548
+ const normalizedRaw = raw.replace(/\\/g, "/");
549
+ const useAbsolute = path.isAbsolute(normalizedRaw);
550
+ const rel = normalizedRaw.replace(/^\/+/, "") || ".";
551
+ const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(root, rel);
552
+ if (!useAbsolute && abs !== root && !abs.startsWith(root + path.sep)) {
553
+ throw new Error("path escapes workspace root");
554
+ }
555
+ const formatPath = (target) => {
556
+ if (useAbsolute) {
557
+ return target.split(path.sep).join("/") || "/";
558
+ }
559
+ return path.relative(root, target).split(path.sep).join("/") || ".";
560
+ };
561
+ return { abs, formatPath };
562
+ }
563
+ function parseFsRpcAction(value) {
564
+ if (value === "list" || value === "stat" || value === "fetch_file" || value === "read_text") {
565
+ return value;
566
+ }
567
+ throw new Error("unsupported action");
568
+ }
569
+ function normalizeFsRpcNumber(value, fallback) {
570
+ const n = Number(value);
571
+ if (!Number.isFinite(n)) {
572
+ return fallback;
573
+ }
574
+ return Math.floor(n);
575
+ }
576
+ async function executeFsRpc(args) {
577
+ const action = parseFsRpcAction(args.request.action);
578
+ const { abs, formatPath } = normalizeFsRpcPath(args.request.path);
579
+ if (action === "stat") {
580
+ const entry = await stat(abs);
581
+ return {
582
+ ok: true,
583
+ action,
584
+ path: formatPath(abs),
585
+ kind: entry.isDirectory() ? "dir" : "file",
586
+ size: entry.size,
587
+ mtimeMs: entry.mtimeMs,
588
+ };
589
+ }
590
+ if (action === "list") {
591
+ const entry = await stat(abs);
592
+ if (!entry.isDirectory()) {
593
+ throw new Error("path is not a directory");
594
+ }
595
+ const limit = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.limit, 200), 1000));
596
+ const rows = await readdir(abs, { withFileTypes: true });
597
+ const items = await Promise.all(rows.map(async (row) => {
598
+ const child = path.join(abs, row.name);
599
+ const childStat = await stat(child);
600
+ return {
601
+ name: row.name,
602
+ path: formatPath(child),
603
+ kind: row.isDirectory() ? "dir" : "file",
604
+ size: childStat.size,
605
+ mtimeMs: childStat.mtimeMs,
606
+ };
607
+ }));
608
+ items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1));
609
+ return {
610
+ ok: true,
611
+ action,
612
+ path: formatPath(abs),
613
+ items: items.slice(0, limit),
614
+ truncated: items.length > limit,
615
+ total: items.length,
616
+ };
617
+ }
618
+ if (action === "fetch_file") {
619
+ const entry = await stat(abs);
620
+ if (!entry.isFile()) {
621
+ throw new Error("path is not a file");
622
+ }
623
+ const uploadUrl = typeof args.request.uploadUrl === "string" ? args.request.uploadUrl : "";
624
+ const chatId = typeof args.request.chatId === "string" ? args.request.chatId : "";
625
+ const agentId = typeof args.request.agentId === "string" ? args.request.agentId : "";
626
+ if (!uploadUrl || !chatId || !agentId) {
627
+ throw new Error("missing upload parameters");
628
+ }
629
+ const data = await readFile(abs);
630
+ const fileName = path.basename(abs) || "file";
631
+ const form = new FormData();
632
+ form.append("file", new File([data], fileName));
633
+ form.append("chatId", chatId);
634
+ form.append("agentId", agentId);
635
+ const response = await fetch(uploadUrl, {
636
+ method: "POST",
637
+ headers: { Authorization: `Bearer ${args.agentToken}` },
638
+ body: form,
639
+ });
640
+ const text = await response.text();
641
+ let upload = {};
642
+ try {
643
+ upload = JSON.parse(text || "{}");
644
+ }
645
+ catch {
646
+ upload = {};
647
+ }
648
+ if (!response.ok) {
649
+ const message = typeof upload.error === "string" ? upload.error : `upload failed: ${response.status}`;
650
+ throw new Error(message);
651
+ }
652
+ return {
653
+ ok: true,
654
+ action,
655
+ path: formatPath(abs),
656
+ size: entry.size,
657
+ upload,
658
+ };
659
+ }
660
+ const entry = await stat(abs);
661
+ if (!entry.isFile()) {
662
+ throw new Error("path is not a file");
663
+ }
664
+ const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
665
+ const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
666
+ const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
667
+ const fd = await open(abs, "r");
668
+ try {
669
+ const buffer = Buffer.alloc(length);
670
+ const readResult = await fd.read(buffer, 0, length, offset);
671
+ const slice = buffer.subarray(0, readResult.bytesRead);
672
+ try {
673
+ const text = slice.toString(encoding);
674
+ return {
675
+ ok: true,
676
+ action,
677
+ path: formatPath(abs),
678
+ offset,
679
+ length: readResult.bytesRead,
680
+ totalSize: entry.size,
681
+ eof: offset + readResult.bytesRead >= entry.size,
682
+ encoding,
683
+ text,
684
+ bytesRead: readResult.bytesRead,
685
+ };
686
+ }
687
+ catch (error) {
688
+ const message = error instanceof Error ? error.message : "failed to decode text";
689
+ return {
690
+ ok: false,
691
+ action,
692
+ path: formatPath(abs),
693
+ error: message,
694
+ };
695
+ }
696
+ }
697
+ finally {
698
+ await fd.close();
699
+ }
700
+ }
701
+ async function handleFsRpcMessage(args) {
702
+ let payload = {};
703
+ try {
704
+ payload = JSON.parse(fsRpcCodec.decode(args.msg.data));
705
+ if (typeof payload.agentId === "string" && payload.agentId.trim() && payload.agentId !== args.agentId) {
706
+ throw new Error("agent id mismatch");
707
+ }
708
+ const result = await executeFsRpc({ request: payload, agentToken: args.agentToken });
709
+ args.msg.respond(fsRpcCodec.encode(JSON.stringify(result)));
710
+ }
711
+ catch (error) {
712
+ const message = error instanceof Error ? error.message : "unknown error";
713
+ const action = typeof payload.action === "string" ? payload.action : "";
714
+ const response = {
715
+ ok: false,
716
+ action,
717
+ path: typeof payload.path === "string" ? payload.path : ".",
718
+ error: message,
719
+ };
720
+ args.msg.respond(fsRpcCodec.encode(JSON.stringify(response)));
721
+ writeAgentError(`fs rpc failed action=${action || "unknown"} error=${message}`);
722
+ }
723
+ }
724
+ function subscribeToFsRpc(args) {
725
+ const subject = buildAgentFsRpcSubject(args.userId, args.agentId);
726
+ args.jetstream.nc.subscribe(subject, {
727
+ callback: (error, msg) => {
728
+ if (error) {
729
+ const message = error instanceof Error ? error.message : String(error);
730
+ writeAgentError(`fs rpc subscription error: ${message}`);
731
+ return;
732
+ }
733
+ void handleFsRpcMessage({
734
+ msg,
735
+ serverBaseUrl: args.serverBaseUrl,
736
+ userId: args.userId,
737
+ agentId: args.agentId,
738
+ agentToken: args.agentToken,
739
+ });
740
+ },
741
+ });
742
+ writeAgentInfo(`fs rpc subscribed subject=${subject}`);
743
+ }
744
+ function normalizeShellRpcRequest(args) {
745
+ const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
746
+ if (!requestId) {
747
+ throw new Error("missing requestId");
748
+ }
749
+ const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
750
+ if (!requestAgentId) {
751
+ throw new Error("missing agentId");
752
+ }
753
+ if (requestAgentId !== args.agentId) {
754
+ throw new Error("agent id mismatch");
755
+ }
756
+ const command = typeof args.request.command === "string" ? args.request.command.trim() : "";
757
+ if (!command) {
758
+ throw new Error("missing command");
759
+ }
760
+ const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
761
+ if (!responseSubject) {
762
+ throw new Error("missing responseSubject");
763
+ }
764
+ const cwd = typeof args.request.cwd === "string" && args.request.cwd.trim() ? args.request.cwd.trim() : null;
765
+ const timeoutRaw = Number(args.request.timeoutMs);
766
+ const timeoutMs = Number.isFinite(timeoutRaw) ? Math.max(1000, Math.min(Math.floor(timeoutRaw), 300000)) : 30000;
767
+ return { requestId, command, cwd, timeoutMs, responseSubject };
768
+ }
769
+ function publishShellRpcResponse(args) {
770
+ args.nc.publish(args.responseSubject, shellRpcCodec.encode(JSON.stringify(args.payload)));
771
+ }
772
+ async function handleShellRpcMessage(args) {
773
+ let requestId = "unknown";
774
+ let responseSubject = "";
775
+ let stdout = "";
776
+ let stderr = "";
777
+ try {
778
+ const payload = JSON.parse(shellRpcCodec.decode(args.msg.data));
779
+ const request = normalizeShellRpcRequest({ request: payload, agentId: args.agentId });
780
+ requestId = request.requestId;
781
+ responseSubject = request.responseSubject;
782
+ const shellPath = resolveShellPath();
783
+ const taskWorkspace = resolveTaskWorkspace(request.cwd);
784
+ const runtimeBinPath = path.join(AGENT_PROJECT_DIR, "runtime/bin");
785
+ const taskPath = [runtimeBinPath, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
786
+ const child = spawn(request.command, {
787
+ cwd: taskWorkspace,
788
+ shell: shellPath,
789
+ detached: process.platform !== "win32",
790
+ env: {
791
+ ...process.env,
792
+ WORKSPACE: taskWorkspace,
793
+ PATH: taskPath,
794
+ },
795
+ stdio: ["ignore", "pipe", "pipe"],
796
+ });
797
+ child.stdout.setEncoding("utf8");
798
+ child.stderr.setEncoding("utf8");
799
+ child.stdout.on("data", (chunk) => {
800
+ stdout += chunk;
801
+ });
802
+ child.stderr.on("data", (chunk) => {
803
+ stderr += chunk;
804
+ });
805
+ let timedOut = false;
806
+ const timeout = setTimeout(() => {
807
+ timedOut = true;
808
+ sendSignalToTaskProcess(child, "SIGTERM");
809
+ setTimeout(() => {
810
+ sendSignalToTaskProcess(child, "SIGKILL");
811
+ }, 1000).unref?.();
812
+ }, request.timeoutMs);
813
+ timeout.unref?.();
814
+ const result = await new Promise((resolve, reject) => {
815
+ child.once("error", reject);
816
+ child.once("close", (code, signal) => {
817
+ resolve({ exitCode: typeof code === "number" ? code : null, signal });
818
+ });
819
+ }).finally(() => {
820
+ clearTimeout(timeout);
821
+ });
822
+ publishShellRpcResponse({
823
+ nc: args.jetstream.nc,
824
+ responseSubject,
825
+ payload: {
826
+ requestId,
827
+ ok: !timedOut,
828
+ exitCode: result.exitCode,
829
+ signal: result.signal,
830
+ stdout,
831
+ stderr,
832
+ ...(timedOut ? { error: `Command timed out after ${request.timeoutMs}ms` } : {}),
833
+ },
834
+ });
835
+ }
836
+ catch (error) {
837
+ const message = error instanceof Error ? error.message : String(error);
838
+ if (responseSubject) {
839
+ publishShellRpcResponse({
840
+ nc: args.jetstream.nc,
841
+ responseSubject,
842
+ payload: {
843
+ requestId,
844
+ ok: false,
845
+ exitCode: null,
846
+ signal: null,
847
+ stdout,
848
+ stderr,
849
+ error: message,
850
+ },
851
+ });
852
+ }
853
+ writeAgentError(`shell rpc failed requestId=${requestId} error=${message}`);
854
+ }
855
+ }
856
+ function subscribeToShellRpc(args) {
857
+ const subject = buildAgentShellRpcSubject(args.userId, args.agentId);
858
+ args.jetstream.nc.subscribe(subject, {
859
+ callback: (error, msg) => {
860
+ if (error) {
861
+ const message = error instanceof Error ? error.message : String(error);
862
+ writeAgentError(`shell rpc subscription error: ${message}`);
863
+ return;
864
+ }
865
+ void handleShellRpcMessage({
866
+ msg,
867
+ jetstream: args.jetstream,
868
+ agentId: args.agentId,
869
+ });
870
+ },
871
+ });
872
+ writeAgentInfo(`shell rpc subscribed subject=${subject}`);
873
+ }
537
874
  async function postJson(url, body) {
538
875
  const res = await fetch(url, {
539
876
  method: "POST",
@@ -980,12 +1317,16 @@ async function main() {
980
1317
  });
981
1318
  const maxConcurrency = Math.max(1, parseEnvInteger(process.env.DOER_AGENT_MAX_CONCURRENCY, 5));
982
1319
  const agentVersion = await resolveAgentVersion();
1320
+ const initialAgentId = typeof natsBootstrap.agentId === "string" ? natsBootstrap.agentId : "";
1321
+ if (!initialAgentId) {
1322
+ throw new Error("agent id missing from bootstrap");
1323
+ }
983
1324
  process.stdout.write(`\n[doer-agent v${agentVersion}]\n`);
984
1325
  if (!usesDefaultServer) {
985
1326
  process.stdout.write(`- server: ${serverBaseUrl}\n`);
986
1327
  }
987
1328
  process.stdout.write(`- userId: ${userId}\n`);
988
- process.stdout.write(`- agentId: ${typeof natsBootstrap.agentId === "string" ? natsBootstrap.agentId : "unknown"}\n`);
1329
+ process.stdout.write(`- agentId: ${initialAgentId}\n`);
989
1330
  process.stdout.write(`\n- transport: nats\n`);
990
1331
  process.stdout.write(`- natsServers: ${jetstream.servers.join(",")}\n`);
991
1332
  process.stdout.write(`- natsStream: ${jetstream.stream}\n`);
@@ -1038,6 +1379,18 @@ async function main() {
1038
1379
  const taskPromise = taskPromiseFactory();
1039
1380
  trackInFlight(taskPromise);
1040
1381
  }
1382
+ subscribeToFsRpc({
1383
+ jetstream,
1384
+ serverBaseUrl,
1385
+ userId,
1386
+ agentId: initialAgentId,
1387
+ agentToken,
1388
+ });
1389
+ subscribeToShellRpc({
1390
+ jetstream,
1391
+ userId,
1392
+ agentId: initialAgentId,
1393
+ });
1041
1394
  for (const pendingTaskId of pendingTaskIds) {
1042
1395
  await waitForAvailableSlot();
1043
1396
  scheduleTask(async () => {
@@ -1167,6 +1520,22 @@ async function main() {
1167
1520
  natsBootstrap = refreshed.natsBootstrap;
1168
1521
  pendingTaskIds = refreshed.pendingTaskIds;
1169
1522
  jetstream = refreshed.jetstream;
1523
+ const refreshedAgentId = typeof natsBootstrap.agentId === "string" ? natsBootstrap.agentId : "";
1524
+ if (!refreshedAgentId) {
1525
+ throw new Error("agent id missing from refreshed bootstrap");
1526
+ }
1527
+ subscribeToFsRpc({
1528
+ jetstream,
1529
+ serverBaseUrl,
1530
+ userId,
1531
+ agentId: refreshedAgentId,
1532
+ agentToken,
1533
+ });
1534
+ subscribeToShellRpc({
1535
+ jetstream,
1536
+ userId,
1537
+ agentId: refreshedAgentId,
1538
+ });
1170
1539
  for (const pendingTaskId of pendingTaskIds) {
1171
1540
  await waitForAvailableSlot();
1172
1541
  scheduleTask(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",