@snowyroad/arp 0.1.0 → 0.1.1

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/cli.js +311 -17
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -46,11 +46,11 @@ async function redeemInvite(relayHttpUrl, code) {
46
46
  throw new Error(`Invite redemption failed (HTTP ${res.status}).`);
47
47
  }
48
48
  const data = await res.json();
49
- if (!data.ok || !data.token || !data.agentId || !data.agentUuid || !data.channelId) {
49
+ if (!data.ok || !data.agentKey || !data.agentId || !data.agentUuid) {
50
50
  throw new Error("Invite redemption returned an incomplete response.");
51
51
  }
52
52
  return {
53
- token: data.token,
53
+ agentKey: data.agentKey,
54
54
  agentId: data.agentId,
55
55
  agentName: data.agentName ?? data.agentId,
56
56
  agentUuid: data.agentUuid,
@@ -58,6 +58,218 @@ async function redeemInvite(relayHttpUrl, code) {
58
58
  };
59
59
  }
60
60
 
61
+ // src/keystore.ts
62
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, unlinkSync, chmodSync, openSync, closeSync, existsSync } from "fs";
63
+ import { homedir } from "os";
64
+ import { join, dirname } from "path";
65
+ function configDir(env = process.env) {
66
+ const override = env.ARP_CONFIG_DIR?.trim();
67
+ return override && override !== "" ? override : join(homedir(), ".arp");
68
+ }
69
+ function relayHostSegment(relayUrl) {
70
+ try {
71
+ const u = new URL(relayUrl);
72
+ return `${u.hostname}${u.port ? "_" + u.port : ""}`.replace(/[^a-zA-Z0-9._-]/g, "_");
73
+ } catch {
74
+ return relayUrl.replace(/[^a-zA-Z0-9._-]/g, "_");
75
+ }
76
+ }
77
+ function agentFilePath(dir, relayUrl, agentName) {
78
+ const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
79
+ return join(dir, "agents", relayHostSegment(relayUrl), `${safeName}.json`);
80
+ }
81
+ function saveAgent(dir, agent) {
82
+ const file = agentFilePath(dir, agent.relayUrl, agent.agentName);
83
+ mkdirSync(dirname(file), { recursive: true, mode: 448 });
84
+ const tmp = `${file}.tmp-${process.pid}`;
85
+ writeFileSync(tmp, JSON.stringify(agent, null, 2) + "\n", { mode: 384 });
86
+ renameSync(tmp, file);
87
+ try {
88
+ chmodSync(file, 384);
89
+ } catch {
90
+ }
91
+ return file;
92
+ }
93
+ function parseStoredAgent(file) {
94
+ let raw;
95
+ try {
96
+ raw = readFileSync(file, "utf8");
97
+ } catch {
98
+ throw new Error(`No saved credential at ${file}`);
99
+ }
100
+ let p;
101
+ try {
102
+ p = JSON.parse(raw);
103
+ } catch {
104
+ throw new Error(`Corrupt credential file at ${file}`);
105
+ }
106
+ const a = p;
107
+ for (const field of ["relayUrl", "agentId", "agentName", "agentUuid", "agentKey"]) {
108
+ if (typeof a[field] !== "string" || a[field].trim() === "") {
109
+ throw new Error(`Corrupt credential file at ${file} (missing "${field}")`);
110
+ }
111
+ }
112
+ return {
113
+ relayUrl: a.relayUrl.trim(),
114
+ agentId: a.agentId.trim(),
115
+ agentName: a.agentName.trim(),
116
+ agentUuid: a.agentUuid.trim(),
117
+ agentKey: a.agentKey.trim()
118
+ };
119
+ }
120
+ function listAgents(dir) {
121
+ const root = join(dir, "agents");
122
+ const out = [];
123
+ let hosts = [];
124
+ try {
125
+ hosts = readdirSync(root);
126
+ } catch {
127
+ return out;
128
+ }
129
+ for (const host of hosts) {
130
+ let files = [];
131
+ try {
132
+ files = readdirSync(join(root, host));
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const f of files) {
137
+ if (!f.endsWith(".json")) continue;
138
+ const file = join(root, host, f);
139
+ try {
140
+ out.push({ file, agent: parseStoredAgent(file) });
141
+ } catch {
142
+ }
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+ function loadAgent(dir, agentName) {
148
+ const all = listAgents(dir);
149
+ if (agentName && agentName.trim() !== "") {
150
+ const matches = all.filter((e) => e.agent.agentName === agentName.trim());
151
+ if (matches.length === 0) {
152
+ const safeName = agentName.trim().replace(/[^a-zA-Z0-9._-]/g, "_");
153
+ const root = join(dir, "agents");
154
+ let hosts = [];
155
+ try {
156
+ hosts = readdirSync(root);
157
+ } catch {
158
+ }
159
+ for (const host of hosts) {
160
+ const candidate = join(root, host, `${safeName}.json`);
161
+ if (existsSync(candidate)) parseStoredAgent(candidate);
162
+ }
163
+ throw new Error(
164
+ `No saved credential for "${agentName}". Run: npx @snowyroad/arp join <code>`
165
+ );
166
+ }
167
+ if (matches.length > 1) {
168
+ const relays = matches.map((m) => m.agent.relayUrl).join(", ");
169
+ throw new Error(
170
+ `Agent "${agentName}" has credentials for multiple relays (${relays}). Set ARP_CONFIG_DIR or remove the stale file.`
171
+ );
172
+ }
173
+ return matches[0];
174
+ }
175
+ if (all.length === 0) {
176
+ throw new Error("No saved credentials. Run: npx @snowyroad/arp join <code>");
177
+ }
178
+ if (all.length > 1) {
179
+ const names = all.map((e) => e.agent.agentName).join(", ");
180
+ throw new Error(`Multiple saved agents (${names}). Run: arp start <name>`);
181
+ }
182
+ return all[0];
183
+ }
184
+ function acquireAgentLock(agentFile) {
185
+ const lockFile = `${agentFile}.lock`;
186
+ const tryAcquire = () => {
187
+ try {
188
+ const fd = openSync(lockFile, "wx", 384);
189
+ writeFileSync(lockFile, String(process.pid));
190
+ closeSync(fd);
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ };
196
+ if (!tryAcquire()) {
197
+ let holderPid = NaN;
198
+ try {
199
+ holderPid = Number(readFileSync(lockFile, "utf8").trim());
200
+ } catch {
201
+ }
202
+ if (holderPid === process.pid) {
203
+ return () => {
204
+ try {
205
+ unlinkSync(lockFile);
206
+ } catch {
207
+ }
208
+ };
209
+ }
210
+ let holderAlive = false;
211
+ if (Number.isFinite(holderPid) && holderPid > 0) {
212
+ try {
213
+ process.kill(holderPid, 0);
214
+ holderAlive = true;
215
+ } catch (err) {
216
+ holderAlive = err.code === "EPERM";
217
+ }
218
+ }
219
+ if (holderAlive) {
220
+ throw new Error(
221
+ `This agent is already running on this machine (pid ${holderPid}). Running it twice would trip credential reuse detection.`
222
+ );
223
+ }
224
+ try {
225
+ unlinkSync(lockFile);
226
+ } catch {
227
+ }
228
+ if (!tryAcquire()) {
229
+ throw new Error("This agent is already running on this machine.");
230
+ }
231
+ }
232
+ return () => {
233
+ try {
234
+ if (existsSync(lockFile) && readFileSync(lockFile, "utf8").trim() === String(process.pid)) {
235
+ unlinkSync(lockFile);
236
+ }
237
+ } catch {
238
+ }
239
+ };
240
+ }
241
+
242
+ // src/token.ts
243
+ var RebootstrapError = class extends Error {
244
+ constructor() {
245
+ super(
246
+ "This agent's credential was revoked or invalidated. Re-issue a connection from the website."
247
+ );
248
+ this.name = "RebootstrapError";
249
+ }
250
+ };
251
+ async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch) {
252
+ const res = await fetchFn(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
253
+ method: "POST",
254
+ headers: { Authorization: `Bearer ${agentKey}` }
255
+ });
256
+ if (res.status === 401) {
257
+ throw new RebootstrapError();
258
+ }
259
+ if (!res.ok) {
260
+ throw new Error(`Token mint failed (HTTP ${res.status}).`);
261
+ }
262
+ const data = await res.json();
263
+ if (!data.ok || !data.accessToken || !data.agentKey) {
264
+ throw new Error("Token mint returned an incomplete response.");
265
+ }
266
+ return {
267
+ accessToken: data.accessToken,
268
+ expiresIn: typeof data.expiresIn === "number" ? data.expiresIn : 3600,
269
+ agentKey: data.agentKey
270
+ };
271
+ }
272
+
61
273
  // src/config.ts
62
274
  var DEFAULT_MODEL = "claude-opus-4-8";
63
275
  var DEFAULT_AGENT_MODE = "acp";
@@ -111,25 +323,71 @@ function loadConfig(env) {
111
323
  catchUpMaxMentions: positiveIntEnv(env.ARP_CATCHUP_MAX_MENTIONS, 3)
112
324
  };
113
325
  }
114
- async function loadConfigFromInvite(code, env) {
326
+ function wsToHttp(relayWsUrl) {
327
+ return relayWsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
328
+ }
329
+ async function buildFromStoredAgent(dir, stored, env) {
115
330
  const { agentMode, agent } = resolveAgentSelection(env);
116
- const inv = decodeInvite(code);
117
- const relayWsUrl = inv.relayUrl;
118
- const relayHttpUrl = relayWsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
119
- const bundle = await redeemInvite(relayHttpUrl, inv.code);
331
+ const relayWsUrl = stored.relayUrl;
332
+ const relayHttpUrl = wsToHttp(relayWsUrl);
333
+ const file = agentFilePath(dir, stored.relayUrl, stored.agentName);
334
+ const release = acquireAgentLock(file);
335
+ process.once("exit", release);
336
+ let current = stored;
337
+ let inflight = null;
338
+ const mintToken = () => {
339
+ if (inflight) return inflight;
340
+ inflight = (async () => {
341
+ try {
342
+ const r = await mintAccessToken(relayHttpUrl, current.agentKey);
343
+ current = { ...current, agentKey: r.agentKey };
344
+ saveAgent(dir, current);
345
+ return r.accessToken;
346
+ } finally {
347
+ inflight = null;
348
+ }
349
+ })();
350
+ return inflight;
351
+ };
352
+ const token = await mintToken();
120
353
  return {
121
354
  relayWsUrl,
122
355
  relayHttpUrl,
123
- token: bundle.token,
124
- agentId: bundle.agentId,
125
- agentName: bundle.agentName,
126
- agentUuid: bundle.agentUuid,
356
+ token,
357
+ agentId: stored.agentId,
358
+ agentName: stored.agentName,
359
+ agentUuid: stored.agentUuid,
127
360
  agentMode,
128
361
  agent,
129
362
  model: env.ARP_MODEL?.trim() || DEFAULT_MODEL,
130
363
  catchUpTtlMs: positiveIntEnv(env.ARP_CATCHUP_TTL_MS, 72e5),
131
- catchUpMaxMentions: positiveIntEnv(env.ARP_CATCHUP_MAX_MENTIONS, 3)
364
+ catchUpMaxMentions: positiveIntEnv(env.ARP_CATCHUP_MAX_MENTIONS, 3),
365
+ mintToken,
366
+ agentFile: file
367
+ };
368
+ }
369
+ async function loadConfigFromInvite(code, env) {
370
+ resolveAgentSelection(env);
371
+ const inv = decodeInvite(code);
372
+ const relayWsUrl = inv.relayUrl;
373
+ const relayHttpUrl = wsToHttp(relayWsUrl);
374
+ const bundle = await redeemInvite(relayHttpUrl, inv.code);
375
+ const dir = configDir(env);
376
+ const stored = {
377
+ relayUrl: relayWsUrl,
378
+ agentId: bundle.agentId,
379
+ agentName: bundle.agentName,
380
+ agentUuid: bundle.agentUuid,
381
+ agentKey: bundle.agentKey
132
382
  };
383
+ const file = saveAgent(dir, stored);
384
+ console.log(`[arp-bridge] credential saved to ${file} (restart later with: arp start ${bundle.agentName})`);
385
+ return buildFromStoredAgent(dir, stored, env);
386
+ }
387
+ async function loadConfigFromStore(agentName, env) {
388
+ const dir = configDir(env);
389
+ const entry = loadAgent(dir, agentName);
390
+ return buildFromStoredAgent(dir, entry.agent, env);
133
391
  }
134
392
  function getFlag(argv, name) {
135
393
  for (let i = 0; i < argv.length; i++) {
@@ -145,13 +403,17 @@ async function resolveConfig(argv, env) {
145
403
  if (!code || code.trim() === "") throw new Error("Missing value for join");
146
404
  return loadConfigFromInvite(code.trim(), env);
147
405
  }
406
+ if (argv[0] === "start") {
407
+ return loadConfigFromStore(argv[1]?.trim() || void 0, env);
408
+ }
148
409
  const argInvite = getFlag(argv, "--invite");
149
410
  if (argInvite !== void 0 && argInvite.trim() === "") {
150
411
  throw new Error("Missing value for --invite");
151
412
  }
152
413
  const invite = (argInvite ?? env.ARP_INVITE)?.trim();
153
414
  if (invite) return loadConfigFromInvite(invite, env);
154
- return loadConfig(env);
415
+ if (env.ARP_TOKEN && env.ARP_TOKEN.trim() !== "") return loadConfig(env);
416
+ return loadConfigFromStore(void 0, env);
155
417
  }
156
418
  function redactConfig(cfg) {
157
419
  const model = cfg.agentMode === "acp" ? `(provider default; ARP_MODEL ignored in acp mode)` : cfg.model;
@@ -314,12 +576,15 @@ var SEEN_CAP = 5e3;
314
576
  var RESUME_MAX_PAGES = 200;
315
577
  var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
316
578
  4001,
317
- // auth failed (bad/expired/tampered token)
579
+ // auth failed (bad/expired/tampered token) — recoverable via key re-mint when cfg.mintToken exists
318
580
  4002,
319
581
  // agent not found
320
- 4003
582
+ 4003,
321
583
  // duplicate connection
584
+ 4004
585
+ // credential revoked (family revoke) — operator must re-bootstrap
322
586
  ]);
587
+ var MAX_REMINT_ATTEMPTS = 3;
323
588
  var RelayClient = class {
324
589
  constructor(cfg, deps) {
325
590
  this.cfg = cfg;
@@ -336,6 +601,7 @@ var RelayClient = class {
336
601
  stableTimer = null;
337
602
  reconnectTimer = null;
338
603
  reconnectAttempts = 0;
604
+ remintAttempts = 0;
339
605
  stopped = false;
340
606
  seenByChannel = /* @__PURE__ */ new Map();
341
607
  cursors = /* @__PURE__ */ new Map();
@@ -401,6 +667,7 @@ var RelayClient = class {
401
667
  this.armWatchdog();
402
668
  this.stableTimer = setTimeout(() => {
403
669
  this.reconnectAttempts = 0;
670
+ this.remintAttempts = 0;
404
671
  }, STABLE_RESET_MS);
405
672
  if (this.graceTimer) clearTimeout(this.graceTimer);
406
673
  this.graceTimer = setTimeout(() => this.confirmReady(), AUTH_GRACE_MS);
@@ -495,6 +762,18 @@ var RelayClient = class {
495
762
  onClose(code, reason) {
496
763
  this.clearTimers();
497
764
  if (this.stopped) return;
765
+ if (code === 4001 && this.cfg.mintToken && this.remintAttempts < MAX_REMINT_ATTEMPTS) {
766
+ this.remintAttempts++;
767
+ console.log("[arp-bridge] access token rejected; re-minting from agent key");
768
+ void this.cfg.mintToken().then((token) => {
769
+ this.cfg.token = token;
770
+ if (!this.stopped) this.connect();
771
+ }).catch((err) => {
772
+ this.stopped = true;
773
+ this.fatalCb?.(code, err instanceof Error ? err.message : String(err));
774
+ });
775
+ return;
776
+ }
498
777
  if (FATAL_CLOSE_CODES.has(code)) {
499
778
  this.stopped = true;
500
779
  this.fatalCb?.(code, reason);
@@ -1865,14 +2144,29 @@ function installGracefulShutdown(bridge) {
1865
2144
  }
1866
2145
 
1867
2146
  // src/cli.ts
2147
+ function printList() {
2148
+ const entries = listAgents(configDir(process.env));
2149
+ if (entries.length === 0) {
2150
+ console.log("No saved agents. Run: npx @snowyroad/arp join <code>");
2151
+ return;
2152
+ }
2153
+ for (const e of entries) {
2154
+ console.log(`${e.agent.agentName} relay=${e.agent.relayUrl} uuid=${e.agent.agentUuid}`);
2155
+ }
2156
+ }
1868
2157
  async function main() {
1869
- const cfg = await resolveConfig(process.argv.slice(2), process.env);
2158
+ const argv = process.argv.slice(2);
2159
+ if (argv[0] === "list") {
2160
+ printList();
2161
+ return;
2162
+ }
2163
+ const cfg = await resolveConfig(argv, process.env);
1870
2164
  console.log("[arp-bridge] starting", redactConfig(cfg));
1871
2165
  const bridge = await createAndStartBridge(cfg);
1872
2166
  installGracefulShutdown(bridge);
1873
2167
  console.log("[arp-bridge] connected; routing per-channel sessions. Ctrl-C to stop.");
1874
2168
  }
1875
2169
  main().catch((err) => {
1876
- console.error("[arp-bridge] fatal:", err);
2170
+ console.error("[arp-bridge] fatal:", err instanceof Error ? err.message : err);
1877
2171
  process.exit(1);
1878
2172
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/arp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Connect your own coding agent (Claude Code, Codex, Gemini, Grok) to an Agent Relay Protocol channel and collaborate with other agents and humans.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "SnowyRoad",