@proagentstore/cli 0.4.38 → 0.4.40

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.
@@ -19,11 +19,35 @@ import { join } from "node:path";
19
19
  export function sanitizeSessionName(label) {
20
20
  return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
21
21
  }
22
+ /**
23
+ * The clone URL with the credential embedded, or the URL untouched.
24
+ *
25
+ * PURE and exported so the one line that decides where a credential goes is unit-testable
26
+ * without a network or a git binary. `username` is provider-specific — GitHub wants
27
+ * `x-access-token`, GitLab `oauth2`, Bitbucket `x-token-auth` (#221) — and it defaults to the
28
+ * value this function used to hardcode, so a cloud that sends only `token` behaves as before.
29
+ *
30
+ * Only https carries a credential: git ignores userinfo on an ssh URL, so injecting there would
31
+ * be pure exposure for no effect. Both halves are percent-encoded — a secret containing `@`,
32
+ * `/` or `:` would otherwise re-parse the URL into a DIFFERENT host and send the credential
33
+ * there. GitHub's tokens contain none of those, so nothing changes for the existing provider.
34
+ */
35
+ export function authenticatedCloneUrl(cloneUrl, token, username) {
36
+ if (!token || !/^https:\/\//i.test(cloneUrl))
37
+ return cloneUrl;
38
+ const user = encodeURIComponent(username || "x-access-token");
39
+ return cloneUrl.replace(/^https:\/\//i, `https://${user}:${encodeURIComponent(token)}@`);
40
+ }
22
41
  /**
23
42
  * Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
24
- * — an existing checkout is left alone (no clobber). For private repos a GitHub
25
- * App installation token is injected as `x-access-token` into an https URL. The
26
- * coding CLI then runs in this directory.
43
+ * — an existing checkout is left alone (no clobber). For private repos the cloud
44
+ * sends a token, injected as the password half of an https URL. The coding CLI then
45
+ * runs in this directory.
46
+ *
47
+ * `tokenUsername` is the USERNAME half, and it is provider-specific: GitHub wants
48
+ * `x-access-token`, GitLab `oauth2`, Bitbucket `x-token-auth` (#221). It defaults to
49
+ * `x-access-token` — the value this function used to hardcode — so an older cloud that
50
+ * sends only `token` behaves exactly as before.
27
51
  *
28
52
  * Returns the absolute working directory. Throws on clone failure so the caller
29
53
  * can surface it (a session can't start without its repo).
@@ -50,10 +74,7 @@ export function ensureRepo(dir, opts = {}) {
50
74
  }
51
75
  rmSync(dir, { recursive: true, force: true });
52
76
  }
53
- let url = opts.cloneUrl;
54
- if (opts.token && /^https:\/\//.test(url)) {
55
- url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
56
- }
77
+ const url = authenticatedCloneUrl(opts.cloneUrl, opts.token, opts.tokenUsername);
57
78
  const args = ["clone", "--depth", "1"];
58
79
  if (opts.branch)
59
80
  args.push("--branch", opts.branch);
@@ -71,7 +71,7 @@ export class CodingRuntime {
71
71
  const workDir = input.workDir
72
72
  ? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
73
73
  : join(this.reposBaseDir, sanitizeSessionName(input.repoId));
74
- ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token });
74
+ ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername });
75
75
  session = new HeadlessSession({
76
76
  id: input.sessionId,
77
77
  workDir,
package/dist/index.js CHANGED
@@ -617,11 +617,57 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
617
617
 
618
618
  // src/commands/runner/command.ts
619
619
  import { spawn as spawn3 } from "child_process";
620
- import { randomUUID } from "crypto";
620
+ import { randomUUID as randomUUID2 } from "crypto";
621
621
  import { Command as Command6 } from "commander";
622
622
 
623
623
  // src/commands/runner/http.ts
624
- import { hostname } from "os";
624
+ import { hostname as hostname2 } from "os";
625
+
626
+ // src/machine.ts
627
+ import { randomUUID } from "crypto";
628
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
629
+ import { homedir as homedir2, hostname } from "os";
630
+ import { join as join5 } from "path";
631
+ var CONFIG_DIR2 = join5(homedir2(), ".config", "proagentstore");
632
+ var MACHINE_FILE = join5(CONFIG_DIR2, "machine.json");
633
+ var MAX_NAMES = 10;
634
+ function isValidMachineId(value) {
635
+ return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
636
+ }
637
+ function parseMachineFile(text) {
638
+ try {
639
+ const data = JSON.parse(text);
640
+ if (!isValidMachineId(data.id)) return null;
641
+ const names = Array.isArray(data.names) ? data.names.filter((n) => typeof n === "string" && n.trim().length > 0).map((n) => n.trim()) : [];
642
+ return { id: data.id, names: names.slice(0, MAX_NAMES) };
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
647
+ function withName(identity, name) {
648
+ const current = name.trim();
649
+ const prev = identity?.names ?? [];
650
+ const names = current ? [current, ...prev.filter((n) => n !== current)] : [...prev];
651
+ return { id: identity?.id ?? "", names: names.slice(0, MAX_NAMES) };
652
+ }
653
+ function loadMachineIdentity(now = hostname()) {
654
+ let stored = null;
655
+ try {
656
+ if (existsSync5(MACHINE_FILE)) stored = parseMachineFile(readFileSync4(MACHINE_FILE, "utf-8"));
657
+ } catch {
658
+ }
659
+ const next = withName(stored ?? { id: randomUUID(), names: [] }, now);
660
+ if (stored && stored.id === next.id && stored.names.join("\0") === next.names.join("\0")) return next;
661
+ try {
662
+ mkdirSync3(CONFIG_DIR2, { recursive: true });
663
+ writeFileSync3(MACHINE_FILE, JSON.stringify(next, null, 2));
664
+ return next;
665
+ } catch {
666
+ return stored ?? { id: "", names: [] };
667
+ }
668
+ }
669
+
670
+ // src/commands/runner/http.ts
625
671
  function clean(value) {
626
672
  const trimmed = value?.trim();
627
673
  return trimmed || void 0;
@@ -647,13 +693,17 @@ function apiPathSegment(value) {
647
693
  return encodeURIComponent(value);
648
694
  }
649
695
  function buildRuntimeRegistrationBody(opts, capabilities = []) {
696
+ const node = hostname2();
697
+ const machine = loadMachineIdentity(node);
650
698
  return {
651
699
  endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
652
700
  token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN),
653
701
  placement: opts.placement === "managed" ? "managed" : "local",
654
702
  capabilities,
655
703
  runnerVersion: clean(opts.runnerVersion) || "",
656
- runnerNode: hostname()
704
+ runnerNode: node,
705
+ machineId: machine.id,
706
+ machineNames: machine.names
657
707
  };
658
708
  }
659
709
  async function requestRunner(method, path, opts, body) {
@@ -708,7 +758,7 @@ function responseErrorMessage(data, text, statusText) {
708
758
 
709
759
  // src/commands/runner/process.ts
710
760
  import { spawn as spawn2 } from "child_process";
711
- import { existsSync as existsSync5 } from "fs";
761
+ import { existsSync as existsSync6 } from "fs";
712
762
  import { resolve as resolve4 } from "path";
713
763
  import { createServer as createServer2 } from "net";
714
764
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -745,7 +795,7 @@ function buildRunnerArgs(opts) {
745
795
  function findWorkspaceRoot() {
746
796
  let dir = process.cwd();
747
797
  for (let i = 0; i < 8; i++) {
748
- if (existsSync5(resolve4(dir, "pnpm-workspace.yaml"))) return dir;
798
+ if (existsSync6(resolve4(dir, "pnpm-workspace.yaml"))) return dir;
749
799
  const parent = resolve4(dir, "..");
750
800
  if (parent === dir) break;
751
801
  dir = parent;
@@ -763,10 +813,10 @@ function runnerSpawnSpec(opts) {
763
813
  let cwd = root;
764
814
  let command = "pags-browser-runner";
765
815
  let args = runnerArgs;
766
- if (existsSync5(localPackage)) {
816
+ if (existsSync6(localPackage)) {
767
817
  command = "pnpm";
768
818
  args = ["--filter", "@proagentstore/browser-runner", "dev", "--", ...runnerArgs];
769
- } else if (existsSync5(bundledPackage)) {
819
+ } else if (existsSync6(bundledPackage)) {
770
820
  cwd = process.cwd();
771
821
  command = process.execPath;
772
822
  args = [bundledPackage, ...runnerArgs];
@@ -804,19 +854,19 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
804
854
  }
805
855
 
806
856
  // src/commands/runner/relay.ts
807
- import { hostname as hostname2 } from "os";
857
+ import { hostname as hostname3 } from "os";
808
858
 
809
859
  // src/commands/runner/membership.ts
810
- function isEligible(inst, thisNode) {
860
+ function isEligible(inst, thisNode, alsoKnownAs = []) {
811
861
  if (inst.status !== "active") return false;
812
862
  if (inst.capabilities?.runtime == null) return false;
813
863
  const pin = inst.config?.runnerNode;
814
- if (pin && pin !== thisNode) return false;
864
+ if (pin && pin !== thisNode && !alsoKnownAs.includes(pin)) return false;
815
865
  return true;
816
866
  }
817
- function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set()) {
867
+ function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */ new Set(), alsoKnownAs = []) {
818
868
  const have = new Set(attached);
819
- const want = eligible.filter((i) => isEligible(i, thisNode));
869
+ const want = eligible.filter((i) => isEligible(i, thisNode, alsoKnownAs));
820
870
  const wantIds = new Set(want.map((i) => i.id));
821
871
  return {
822
872
  attach: want.filter((i) => !have.has(i.id) && !blocked.has(i.id)),
@@ -836,7 +886,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
836
886
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
837
887
  const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
838
888
  if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
839
- const runnerNode = hostname2();
889
+ const runnerNode = hostname3();
890
+ const machine = loadMachineIdentity(runnerNode);
840
891
  const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
841
892
  const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
842
893
  const registerRuntime = async (id) => {
@@ -848,6 +899,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
848
899
  capabilities: caps,
849
900
  runnerVersion: CLI_VERSION,
850
901
  runnerNode,
902
+ machineId: machine.id,
903
+ machineNames: machine.names,
851
904
  force
852
905
  });
853
906
  } catch (e) {
@@ -881,7 +934,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
881
934
  writeLine("Runtime registered with PAGS \u2713");
882
935
  writeLine("");
883
936
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
884
- writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname2()}`);
937
+ writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
885
938
  writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
886
939
  writeLine(" No cloudflared needed. Ctrl+C to disconnect.");
887
940
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
@@ -922,7 +975,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
922
975
  attached.keys(),
923
976
  res.instances ?? [],
924
977
  runnerNode,
925
- blocked
978
+ blocked,
979
+ // The names this machine has also worn. Without them a pin made under a
980
+ // previous hostname reads as "pinned to another machine", and this poll
981
+ // detaches the agent twenty seconds after startup attached it (#379).
982
+ machine.names
926
983
  );
927
984
  for (const inst of toAttach) {
928
985
  await registerRuntime(inst.id);
@@ -963,7 +1020,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
963
1020
  backoffMs = Math.min(backoffMs * 2, 3e4);
964
1021
  return;
965
1022
  }
966
- const params = new URLSearchParams({ token: relayToken, node: hostname2() });
1023
+ const params = new URLSearchParams({ token: relayToken, node: hostname3() });
967
1024
  if (force) params.set("force", "1");
968
1025
  const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
969
1026
  const ws = new WebSocket(url);
@@ -1069,7 +1126,7 @@ function createRunnerCommand() {
1069
1126
  await startRunnerForeground(opts);
1070
1127
  });
1071
1128
  command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
1072
- const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
1129
+ const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID2()}`;
1073
1130
  const host = clean(opts.host) || "127.0.0.1";
1074
1131
  const port = clean(opts.port) || String(await findFreePort2(49171));
1075
1132
  const localUrl = `http://${host}:${port}`;
@@ -1231,7 +1288,7 @@ import { Command as Command7 } from "commander";
1231
1288
 
1232
1289
  // src/tui.ts
1233
1290
  import chalk from "chalk";
1234
- import { hostname as hostname3 } from "os";
1291
+ import { hostname as hostname4 } from "os";
1235
1292
  import readline from "readline";
1236
1293
  var ACCENT = "#7c3aed";
1237
1294
  var c = chalk.hex(ACCENT);
@@ -1273,7 +1330,7 @@ function printStatus(state) {
1273
1330
  clearScreen();
1274
1331
  printLogo(state.version);
1275
1332
  const connected = state.runner === "online" && state.tunnel === "online" && state.registration === "registered";
1276
- console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname3()));
1333
+ console.log(pad + d("Signed in as ") + w(state.user) + d(" \xB7 agent: ") + w(state.activeInstance) + d(" \xB7 node: ") + w(hostname4()));
1277
1334
  console.log("");
1278
1335
  const row = (kind, s) => {
1279
1336
  const { icon, label, note } = describe(kind, s);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",