agentic-relay 4.0.0 → 4.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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pure-JS zip extraction — no system `unzip` dependency (Windows ships none).
3
+ *
4
+ * Deliberately minimal: reads the END-OF-CENTRAL-DIRECTORY record and walks the
5
+ * central directory (never trusting local headers for sizes — that's what makes
6
+ * archives written with data descriptors / flag bit 3 work), then inflates each
7
+ * entry with node:zlib. Supported: store (method 0) and deflate (method 8).
8
+ * Rejected with explicit errors: zip64, encrypted entries, other methods.
9
+ *
10
+ * Security posture (matches the old `unzip` path, or stricter):
11
+ * - zip-slip guard runs over ALL entry names before anything is written
12
+ * - symlink entries are materialized as regular files (no link-then-write-through)
13
+ * - nothing from the archive is ever executed
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync } from "fs";
17
+ import { dirname, join, resolve, sep } from "path";
18
+ import { inflateRawSync } from "zlib";
19
+
20
+ const EOCD_SIG = 0x06054b50; // end of central directory
21
+ const CEN_SIG = 0x02014b50; // central directory file header
22
+ const LOC_SIG = 0x04034b50; // local file header
23
+ const ZIP64_LOCATOR_SIG = 0x07064b50; // zip64 EOCD locator
24
+
25
+ /** Reject any archive entry that escapes the destination (absolute or ../). */
26
+ export function assertNoZipSlip(entryNames, destDir) {
27
+ const destRoot = resolve(destDir) + sep;
28
+ for (const name of entryNames) {
29
+ if (name.startsWith("/") || name.includes("\0")) {
30
+ throw new Error(`unsafe archive entry (absolute path): ${name}`);
31
+ }
32
+ const target = resolve(destDir, name);
33
+ if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
34
+ throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Locate the EOCD record (scan backwards over the max 22-byte record +
41
+ * 65535-byte comment tail). Throws on zip64 archives and missing records.
42
+ */
43
+ export function findEndOfCentralDirectory(buf) {
44
+ const min = Math.max(0, buf.length - 22 - 65535);
45
+ for (let i = buf.length - 22; i >= min; i--) {
46
+ if (buf.readUInt32LE(i) !== EOCD_SIG) continue;
47
+ const totalEntries = buf.readUInt16LE(i + 10);
48
+ const cdSize = buf.readUInt32LE(i + 12);
49
+ const cdOffset = buf.readUInt32LE(i + 16);
50
+ if (
51
+ totalEntries === 0xffff ||
52
+ cdSize === 0xffffffff ||
53
+ cdOffset === 0xffffffff ||
54
+ (i >= 20 && buf.readUInt32LE(i - 20) === ZIP64_LOCATOR_SIG)
55
+ ) {
56
+ throw new Error("zip64 archives are not supported");
57
+ }
58
+ return { cdOffset, cdSize, totalEntries };
59
+ }
60
+ throw new Error("invalid zip: end-of-central-directory record not found");
61
+ }
62
+
63
+ /**
64
+ * Walk the central directory. Sizes/offsets come from here, NEVER from local
65
+ * headers, so data-descriptor zips (local sizes = 0) extract correctly.
66
+ */
67
+ export function listZipEntries(buf) {
68
+ const { cdOffset, totalEntries } = findEndOfCentralDirectory(buf);
69
+ const entries = [];
70
+ let p = cdOffset;
71
+ for (let n = 0; n < totalEntries; n++) {
72
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== CEN_SIG) {
73
+ throw new Error("invalid zip: corrupt central directory");
74
+ }
75
+ const flags = buf.readUInt16LE(p + 8);
76
+ const method = buf.readUInt16LE(p + 10);
77
+ const compressedSize = buf.readUInt32LE(p + 20);
78
+ const uncompressedSize = buf.readUInt32LE(p + 24);
79
+ const nameLen = buf.readUInt16LE(p + 28);
80
+ const extraLen = buf.readUInt16LE(p + 30);
81
+ const commentLen = buf.readUInt16LE(p + 32);
82
+ const localHeaderOffset = buf.readUInt32LE(p + 42);
83
+ const name = buf.toString("utf-8", p + 46, p + 46 + nameLen);
84
+ if (flags & 0x1) {
85
+ throw new Error(`encrypted zip entries are not supported: ${name}`);
86
+ }
87
+ entries.push({
88
+ name,
89
+ method,
90
+ compressedSize,
91
+ uncompressedSize,
92
+ localHeaderOffset,
93
+ isDirectory: name.endsWith("/"),
94
+ });
95
+ p += 46 + nameLen + extraLen + commentLen;
96
+ }
97
+ return entries;
98
+ }
99
+
100
+ /** Decompress one entry's bytes (seeks via the local header it points at). */
101
+ export function readEntryData(buf, entry) {
102
+ const p = entry.localHeaderOffset;
103
+ if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) {
104
+ throw new Error(`invalid zip: bad local header for ${entry.name}`);
105
+ }
106
+ // The local header's OWN name/extra lengths govern where data starts (its
107
+ // extra field can differ from the central directory's).
108
+ const nameLen = buf.readUInt16LE(p + 26);
109
+ const extraLen = buf.readUInt16LE(p + 28);
110
+ const start = p + 30 + nameLen + extraLen;
111
+ const data = buf.subarray(start, start + entry.compressedSize);
112
+ if (entry.method === 0) return Buffer.from(data);
113
+ if (entry.method === 8) return inflateRawSync(data);
114
+ throw new Error(`unsupported zip compression method ${entry.method} for ${entry.name}`);
115
+ }
116
+
117
+ /**
118
+ * Extract an in-memory zip buffer into destDir. Returns the list of file paths
119
+ * written. Validates every entry name against zip-slip BEFORE any write.
120
+ */
121
+ export function extractZip(buf, destDir) {
122
+ const entries = listZipEntries(buf);
123
+ assertNoZipSlip(entries.map((e) => e.name), destDir);
124
+ mkdirSync(destDir, { recursive: true });
125
+ const placed = [];
126
+ for (const entry of entries) {
127
+ const target = join(destDir, entry.name);
128
+ if (entry.isDirectory) {
129
+ mkdirSync(target, { recursive: true });
130
+ continue;
131
+ }
132
+ mkdirSync(dirname(target), { recursive: true });
133
+ // Symlink entries (unix mode S_IFLNK in external attrs) are intentionally
134
+ // written as regular files containing the link target text.
135
+ writeFileSync(target, readEntryData(buf, entry));
136
+ placed.push(target);
137
+ }
138
+ return placed;
139
+ }
@@ -35,9 +35,19 @@ import {
35
35
  cmdFetchSession,
36
36
  } from "./lib/commands.mjs";
37
37
  import { cmdFetch } from "./lib/fetch.mjs";
38
+ import { cmdDoctor, fmtDoctor } from "./lib/doctor.mjs";
39
+ import { maybeNotifyUpdate } from "./lib/update-check.mjs";
38
40
 
39
41
  const EXIT = { OK: 0, PARTIAL: 1, USAGE: 2, AUTH: 3 };
40
42
 
43
+ function packageVersion() {
44
+ try {
45
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
41
51
  const FLAGS_WITH_VALUE = new Set([
42
52
  "--api-key",
43
53
  "--context",
@@ -129,7 +139,7 @@ function fail(message, exitCode, json) {
129
139
 
130
140
  // ── human formatters ──────────────────────────────────────────────────────
131
141
  const fmtSearch = (e) => {
132
- const lines = [`${e.count} capabilities for "${e.query}"`];
142
+ const lines = [`${e.count} capabilities for "${e.query}"${e.from_cache ? ` (cached ${e.cached_at})` : ""}`];
133
143
  for (const c of e.capabilities) {
134
144
  const price = c.clearing_price_cents ? `$${(c.clearing_price_cents / 100).toFixed(2)}` : "free";
135
145
  const rel = c.relevance != null ? c.relevance.toFixed(2) : "—";
@@ -138,6 +148,17 @@ const fmtSearch = (e) => {
138
148
  lines.push(` ${kind} ${c.slug} (rel ${rel}, ${price}) ${c.name}`);
139
149
  if (desc) lines.push(` ${desc}`);
140
150
  }
151
+ // Surface the spend policy in human output too — agents reading --json get
152
+ // the full payment_context object; humans shouldn't be blind to it.
153
+ const p = e.payment_context;
154
+ if (p) {
155
+ const dol = (c) => (c == null ? "—" : `$${(c / 100).toFixed(2)}`);
156
+ lines.push(
157
+ `pay policy: autonomous ${p.autonomous_enabled ? "ON" : "off"}, auto-approve under ${dol(p.auto_approve_under_cents)},` +
158
+ ` daily cap left ${dol(p.daily_cap_remaining_cents)}, wallet: ${p.wallet?.methods?.length ? p.wallet.methods.join(",") : "none"}` +
159
+ `${e.from_cache ? " (cached snapshot — `agentrelay budget` for live)" : ""}`,
160
+ );
161
+ }
141
162
  return lines.join("\n");
142
163
  };
143
164
 
@@ -173,6 +194,9 @@ Payments (autonomous spend policy — server-canonical):
173
194
  [--require-human-over=<$>] [--per-session-cap=<$>]
174
195
  agentrelay connect-wallet (wallet connect is set up in the dashboard)
175
196
 
197
+ Diagnostics:
198
+ agentrelay doctor check node/key/API/npm-prefix/PATH health (--json)
199
+
176
200
  Deliverables (after a paid session returns a delivery object):
177
201
  agentrelay fetch --session <id> [--message "<m>"] [dest] ONE-STEP after payment:
178
202
  drives a turn to mint a fresh signed URL, then
@@ -204,14 +228,17 @@ async function main() {
204
228
  );
205
229
  }
206
230
  if (cmd === "version" || flags.version) {
207
- let v = "unknown";
208
- try {
209
- v = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
210
- } catch { /* fall back to "unknown" */ }
211
- process.stdout.write(`agentrelay ${v}\n`);
231
+ process.stdout.write(`agentrelay ${packageVersion()}\n`);
212
232
  process.exit(EXIT.OK);
213
233
  }
214
234
 
235
+ // Non-blocking: nag from cached state, refresh in a detached child ≤1×/24h.
236
+ try {
237
+ maybeNotifyUpdate({ currentVersion: packageVersion() });
238
+ } catch {
239
+ /* never let the update nag break a command */
240
+ }
241
+
215
242
  let shared;
216
243
  try {
217
244
  shared = buildShared(flags);
@@ -220,9 +247,9 @@ async function main() {
220
247
  }
221
248
  const json = shared.json;
222
249
 
223
- // cache and raw-URL fetch are the only commands that don't need a key.
224
- // `fetch --session` calls the session API, so it does need one.
225
- const keylessCmd = cmd === "cache" || (cmd === "fetch" && !flags.session);
250
+ // cache, raw-URL fetch, and doctor don't need a key (doctor resolves and
251
+ // reports on the key itself). `fetch --session` calls the session API.
252
+ const keylessCmd = cmd === "cache" || cmd === "doctor" || (cmd === "fetch" && !flags.session);
226
253
  if (!keylessCmd && !shared.apiKey) {
227
254
  return fail(
228
255
  "no API key — set --api-key, $AGENT_RELAY_API_KEY, or ~/.config/agent-relay/credentials.json",
@@ -318,6 +345,12 @@ async function main() {
318
345
  });
319
346
  }
320
347
 
348
+ case "doctor": {
349
+ const payload = await cmdDoctor({ ...shared, apiKey: flags["api-key"] || null });
350
+ const exitCode = payload.exit_code;
351
+ return emit(payload, { json, ok: payload.doctor_ok, exitCode, human: fmtDoctor });
352
+ }
353
+
321
354
  case "cache": {
322
355
  const payload = cmdCache(positionals[0], positionals[1], { ...shared, file: flags.file || null });
323
356
  const human = Array.isArray(payload.specs)
@@ -333,7 +366,7 @@ async function main() {
333
366
 
334
367
  default:
335
368
  return fail(
336
- `unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch)`,
369
+ `unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch|doctor)`,
337
370
  EXIT.USAGE,
338
371
  json,
339
372
  );
@@ -6,11 +6,11 @@
6
6
  */
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { mkdtempSync, rmSync, existsSync } from "node:fs";
9
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { Cache } from "../lib/cache.mjs";
13
- import { resolveCapability } from "../lib/commands.mjs";
13
+ import { resolveCapability, cmdCache } from "../lib/commands.mjs";
14
14
 
15
15
  const ISO = "2026-05-24T00:00:00.000Z";
16
16
 
@@ -69,3 +69,45 @@ test("resolveCapability: falls back to the banked spec's ids (no network)", asyn
69
69
  assert.equal(r.name, "A Tool");
70
70
  });
71
71
  });
72
+
73
+ test("cache put: soft contract warnings (missing session_id/summary/features), still stores", () => {
74
+ withCache((dir, cache) => {
75
+ const f = join(dir, "min.json");
76
+ writeFileSync(f, JSON.stringify({ slug: "bare-tool", name: "Bare" }));
77
+ const r = cmdCache("put", "bare-tool", { cacheDir: dir, file: f });
78
+ assert.equal(r.stored, true);
79
+ assert.equal(r.warnings.length, 3);
80
+ assert.match(r.warnings.join(" "), /session_id/);
81
+ assert.match(r.warnings.join(" "), /summary/);
82
+ assert.match(r.warnings.join(" "), /features/);
83
+ assert.ok(cache.readSpec("bare-tool")); // warned, but banked anyway
84
+
85
+ // A contract-complete spec yields no warnings.
86
+ writeFileSync(
87
+ f,
88
+ JSON.stringify({
89
+ slug: "full-tool",
90
+ session_id: "s-1",
91
+ summary: "maps the surface",
92
+ features: [{ feature: "x", how: "y", status: "surface_only" }],
93
+ }),
94
+ );
95
+ const r2 = cmdCache("put", "full-tool", { cacheDir: dir, file: f });
96
+ assert.deepEqual(r2.warnings, []);
97
+ assert.ok(cache.readSpec("full-tool"));
98
+
99
+ // features[] present but entries missing status → one targeted warning.
100
+ writeFileSync(
101
+ f,
102
+ JSON.stringify({
103
+ slug: "statusless-tool",
104
+ session_id: "s-2",
105
+ summary: "mapped",
106
+ features: [{ feature: "x", how: "y" }, { feature: "z", how: "w", status: "coded" }],
107
+ }),
108
+ );
109
+ const r3 = cmdCache("put", "statusless-tool", { cacheDir: dir, file: f });
110
+ assert.equal(r3.warnings.length, 1);
111
+ assert.match(r3.warnings[0], /1 feature\(s\) missing status/);
112
+ });
113
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tests for `agentrelay doctor` (pure pieces) and the sysenv probes that are
3
+ * testable without touching the real npm install.
4
+ * Run: `node --test bin/test/doctor.test.mjs`.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { sep } from "node:path";
9
+ import { checkNodeVersion, summarize, fmtDoctor } from "../lib/doctor.mjs";
10
+ import { dirOnPath, npmGlobalBinDir, globalBinShimPath, isEphemeralBin } from "../lib/sysenv.mjs";
11
+
12
+ test("checkNodeVersion: >=18 ok, <18 fail", () => {
13
+ assert.equal(checkNodeVersion("18.0.0").status, "ok");
14
+ assert.equal(checkNodeVersion("23.11.0").status, "ok");
15
+ assert.equal(checkNodeVersion("16.20.2").status, "fail");
16
+ assert.match(checkNodeVersion("16.20.2").detail, /needs Node >= 18/);
17
+ });
18
+
19
+ test("summarize: warns don't fail the doctor, fails do", () => {
20
+ const okWarn = summarize([
21
+ { status: "ok" },
22
+ { status: "warn" },
23
+ ]);
24
+ assert.equal(okWarn.ok, true);
25
+ assert.equal(okWarn.exitCode, 0);
26
+
27
+ const withFail = summarize([{ status: "ok" }, { status: "fail" }]);
28
+ assert.equal(withFail.ok, false);
29
+ assert.equal(withFail.exitCode, 1);
30
+ });
31
+
32
+ test("fmtDoctor renders one line per check + a verdict line", () => {
33
+ const out = fmtDoctor({
34
+ checks: [
35
+ { status: "ok", label: "Node.js version", detail: "v20.0.0" },
36
+ { status: "warn", label: "API key", detail: "not found" },
37
+ ],
38
+ doctor_ok: true,
39
+ });
40
+ const lines = out.split("\n");
41
+ assert.equal(lines.length, 3);
42
+ assert.match(lines[0], /^✓ /);
43
+ assert.match(lines[1], /^! /);
44
+ assert.match(lines[2], /no blocking problems/);
45
+ });
46
+
47
+ test("dirOnPath: exact component match, trailing-slash tolerant", () => {
48
+ const delim = process.platform === "win32" ? ";" : ":";
49
+ const pathVar = ["/usr/bin", "/opt/homebrew/bin/"].join(delim);
50
+ assert.equal(dirOnPath("/opt/homebrew/bin", pathVar), true);
51
+ assert.equal(dirOnPath("/usr/bin", pathVar), true);
52
+ assert.equal(dirOnPath("/usr/local/bin", pathVar), false);
53
+ assert.equal(dirOnPath(null, pathVar), false);
54
+ });
55
+
56
+ test("npmGlobalBinDir/globalBinShimPath shape per platform", () => {
57
+ const prefix = process.platform === "win32" ? "C:\\npm" : "/opt/homebrew";
58
+ const binDir = npmGlobalBinDir(prefix);
59
+ const shim = globalBinShimPath(prefix, "agentrelay");
60
+ if (process.platform === "win32") {
61
+ assert.equal(binDir, prefix);
62
+ assert.ok(shim.endsWith("agentrelay.cmd"));
63
+ } else {
64
+ assert.equal(binDir, `${prefix}/bin`);
65
+ assert.ok(shim.endsWith("/bin/agentrelay"));
66
+ }
67
+ assert.equal(npmGlobalBinDir(null), null);
68
+ });
69
+
70
+ test("isEphemeralBin flags npx-cache paths and dangling symlinks", () => {
71
+ const npxBin = ["", "Users", "x", ".npm", "_npx", "ab12", "node_modules", ".bin", "agentrelay"].join(sep);
72
+ assert.equal(isEphemeralBin(npxBin, "/tmp/pkg"), true);
73
+ // A path that doesn't exist → realpath throws → treated as not-usable.
74
+ assert.equal(isEphemeralBin("/definitely/not/a/real/bin", "/tmp/pkg"), true);
75
+ assert.equal(isEphemeralBin(null, "/tmp/pkg"), false);
76
+ });
@@ -16,7 +16,6 @@ import {
16
16
  sha256Hex,
17
17
  inferSlug,
18
18
  uniqueDir,
19
- zipEntriesFromListing,
20
19
  assertNoZipSlip,
21
20
  cmdFetch,
22
21
  } from "../lib/fetch.mjs";
@@ -41,20 +40,6 @@ test("inferSlug from filename and URL", () => {
41
40
  assert.equal(inferSlug("not a url"), "deliverable");
42
41
  });
43
42
 
44
- test("zipEntriesFromListing parses unzip -l output", () => {
45
- const listing = [
46
- "Archive: x.zip",
47
- " Length Date Time Name",
48
- "--------- ---------- ----- ----",
49
- " 12 2026-06-03 10:00 skill/SKILL.md",
50
- " 0 2026-06-03 10:00 skill/",
51
- "--------- -------",
52
- " 12 2 files",
53
- ].join("\n");
54
- const entries = zipEntriesFromListing(listing);
55
- assert.deepEqual(entries, ["skill/SKILL.md", "skill/"]);
56
- });
57
-
58
43
  test("assertNoZipSlip rejects traversal and absolute paths", () => {
59
44
  const dest = join(tmpdir(), "dest");
60
45
  assert.throws(() => assertNoZipSlip(["../escape.sh"], dest), /zip-slip|traversal/);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for installer argument parsing (bin/lib/install-args.mjs) — focused on
3
+ * the `--api-key= <key>` space-typo rescue observed in the field.
4
+ * Run: `node --test bin/test/install-args.test.mjs`.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { parseInstallArgs } from "../lib/install-args.mjs";
9
+
10
+ const KEY = "am_live_abc123def456";
11
+
12
+ test("--api-key=KEY parses", () => {
13
+ const { flags, warnings } = parseInstallArgs([`--api-key=${KEY}`, "--all"]);
14
+ assert.equal(flags.apiKey, KEY);
15
+ assert.equal(flags.all, true);
16
+ assert.deepEqual(warnings, []);
17
+ });
18
+
19
+ test("--api-key KEY (space-separated) parses", () => {
20
+ const { flags, warnings } = parseInstallArgs(["--api-key", KEY]);
21
+ assert.equal(flags.apiKey, KEY);
22
+ assert.deepEqual(warnings, []);
23
+ });
24
+
25
+ test("--api-key= KEY (the space typo) rescues the bare key with a warning", () => {
26
+ const { flags, warnings } = parseInstallArgs(["--all", "--api-key=", KEY]);
27
+ assert.equal(flags.apiKey, KEY);
28
+ assert.equal(warnings.length, 1);
29
+ assert.match(warnings[0], /no space/);
30
+ });
31
+
32
+ test("bare am_live_* positional alone is rescued with a warning", () => {
33
+ const { flags, warnings } = parseInstallArgs([KEY, "--all"]);
34
+ assert.equal(flags.apiKey, KEY);
35
+ assert.equal(warnings.length, 1);
36
+ });
37
+
38
+ test("empty --api-key= with no rescue positional warns and stays null", () => {
39
+ const { flags, warnings } = parseInstallArgs(["--api-key="]);
40
+ assert.equal(flags.apiKey, "");
41
+ assert.equal(warnings.length, 1);
42
+ assert.match(warnings[0], /no value/);
43
+ });
44
+
45
+ test("non-key positionals are not adopted", () => {
46
+ const { flags, warnings } = parseInstallArgs(["hello", "--all"]);
47
+ assert.equal(flags.apiKey, null);
48
+ assert.deepEqual(warnings, []);
49
+ });
50
+
51
+ test("space-form value is not double-counted as a rescue positional", () => {
52
+ const { flags, warnings } = parseInstallArgs(["--api-key", KEY]);
53
+ assert.equal(flags.apiKey, KEY);
54
+ assert.deepEqual(warnings, []);
55
+ });
56
+
57
+ test("targets / librechat / legacy flags are unchanged", () => {
58
+ const { flags } = parseInstallArgs([
59
+ "--target=claude, codex",
60
+ "--librechat=/tmp/librechat.yaml",
61
+ "--claude",
62
+ "--project",
63
+ ]);
64
+ assert.deepEqual(flags.targets, ["claude", "codex"]);
65
+ assert.equal(flags.librechat, "/tmp/librechat.yaml");
66
+ assert.equal(flags.legacyClaude, true);
67
+ assert.equal(flags.project, true);
68
+ });
@@ -65,8 +65,12 @@ test("deliveryToFetchArgs: maps a delivery object to cmdFetch args (tolerant of
65
65
  filename: "x.zip",
66
66
  slug: "gstack",
67
67
  }),
68
- { url: "https://store/x.zip", checksum: "abc123", filename: "x.zip", dest: null },
68
+ // dest defaults to ./<delivery.slug>/ (the documented contract); an
69
+ // explicit dest always wins.
70
+ { url: "https://store/x.zip", checksum: "abc123", filename: "x.zip", dest: "./gstack" },
69
71
  );
72
+ // a hostile slug is slugified — it can't traverse out of the working dir
73
+ assert.equal(deliveryToFetchArgs({ download_url: "https://s/x", slug: "../../etc" }).dest, "./etc");
70
74
  // variant keys + dest override
71
75
  assert.deepEqual(
72
76
  deliveryToFetchArgs({ url: "https://store/y", checksum: "def" }, { dest: "./out" }),
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tests for the async update notifier (bin/lib/update-check.mjs) — the pure
3
+ * pieces: semver compare, state round-trip, suppression predicate.
4
+ * Run: `node --test bin/test/update-check.test.mjs`.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, sep } from "node:path";
11
+ import { compareSemver, shouldSuppress, readState, writeState } from "../lib/update-check.mjs";
12
+
13
+ test("compareSemver orders numerically, not lexically", () => {
14
+ assert.equal(compareSemver("1.2.3", "1.2.3"), 0);
15
+ assert.equal(compareSemver("1.10.0", "1.2.0"), 1); // lexical would say 1.10 < 1.2
16
+ assert.equal(compareSemver("4.0.0", "3.9.9"), 1);
17
+ assert.equal(compareSemver("3.8.5", "4.0.0"), -1);
18
+ assert.equal(compareSemver("1.2", "1.2.0"), 0);
19
+ });
20
+
21
+ test("state round-trips through a custom path", () => {
22
+ const root = mkdtempSync(join(tmpdir(), "agent-relay-update-"));
23
+ try {
24
+ const p = join(root, "nested", "update-check.json");
25
+ const state = { last_check_ts: "2026-06-11T00:00:00.000Z", latest_version: "9.9.9" };
26
+ assert.equal(writeState(state, p), true);
27
+ assert.deepEqual(readState(p), state);
28
+ } finally {
29
+ rmSync(root, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("readState returns null for missing/corrupt files", () => {
34
+ assert.equal(readState("/nonexistent/path/update.json"), null);
35
+ });
36
+
37
+ const TTY_BASE = { env: {}, stderrIsTTY: true, binPath: "/usr/local/bin/agentrelay" };
38
+
39
+ test("suppression: env kill-switches", () => {
40
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { AGENT_RELAY_NO_UPDATE_CHECK: "1" } }), true);
41
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { NO_UPDATE_NOTIFIER: "1" } }), true);
42
+ assert.equal(shouldSuppress({ ...TTY_BASE, env: { CI: "true" } }), true);
43
+ });
44
+
45
+ test("suppression: non-TTY stderr (piped/scripted runs)", () => {
46
+ assert.equal(shouldSuppress({ ...TTY_BASE, stderrIsTTY: false }), true);
47
+ });
48
+
49
+ test("suppression: running from an npx cache", () => {
50
+ const npxPath = ["", "Users", "x", ".npm", "_npx", "abc123", "node_modules", ".bin", "agentrelay"].join(sep);
51
+ assert.equal(shouldSuppress({ ...TTY_BASE, binPath: npxPath }), true);
52
+ });
53
+
54
+ test("not suppressed: interactive TTY run from a real install", () => {
55
+ assert.equal(shouldSuppress(TTY_BASE), false);
56
+ });