agentic-relay 4.0.0 → 4.1.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.
@@ -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",
@@ -173,6 +183,9 @@ Payments (autonomous spend policy — server-canonical):
173
183
  [--require-human-over=<$>] [--per-session-cap=<$>]
174
184
  agentrelay connect-wallet (wallet connect is set up in the dashboard)
175
185
 
186
+ Diagnostics:
187
+ agentrelay doctor check node/key/API/npm-prefix/PATH health (--json)
188
+
176
189
  Deliverables (after a paid session returns a delivery object):
177
190
  agentrelay fetch --session <id> [--message "<m>"] [dest] ONE-STEP after payment:
178
191
  drives a turn to mint a fresh signed URL, then
@@ -204,14 +217,17 @@ async function main() {
204
217
  );
205
218
  }
206
219
  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`);
220
+ process.stdout.write(`agentrelay ${packageVersion()}\n`);
212
221
  process.exit(EXIT.OK);
213
222
  }
214
223
 
224
+ // Non-blocking: nag from cached state, refresh in a detached child ≤1×/24h.
225
+ try {
226
+ maybeNotifyUpdate({ currentVersion: packageVersion() });
227
+ } catch {
228
+ /* never let the update nag break a command */
229
+ }
230
+
215
231
  let shared;
216
232
  try {
217
233
  shared = buildShared(flags);
@@ -220,9 +236,9 @@ async function main() {
220
236
  }
221
237
  const json = shared.json;
222
238
 
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);
239
+ // cache, raw-URL fetch, and doctor don't need a key (doctor resolves and
240
+ // reports on the key itself). `fetch --session` calls the session API.
241
+ const keylessCmd = cmd === "cache" || cmd === "doctor" || (cmd === "fetch" && !flags.session);
226
242
  if (!keylessCmd && !shared.apiKey) {
227
243
  return fail(
228
244
  "no API key — set --api-key, $AGENT_RELAY_API_KEY, or ~/.config/agent-relay/credentials.json",
@@ -318,6 +334,12 @@ async function main() {
318
334
  });
319
335
  }
320
336
 
337
+ case "doctor": {
338
+ const payload = await cmdDoctor({ ...shared, apiKey: flags["api-key"] || null });
339
+ const exitCode = payload.exit_code;
340
+ return emit(payload, { json, ok: payload.doctor_ok, exitCode, human: fmtDoctor });
341
+ }
342
+
321
343
  case "cache": {
322
344
  const payload = cmdCache(positionals[0], positionals[1], { ...shared, file: flags.file || null });
323
345
  const human = Array.isArray(payload.specs)
@@ -333,7 +355,7 @@ async function main() {
333
355
 
334
356
  default:
335
357
  return fail(
336
- `unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch)`,
358
+ `unknown command "${cmd}" (session|search|feedback|cache|budget|connect-wallet|fetch|doctor)`,
337
359
  EXIT.USAGE,
338
360
  json,
339
361
  );
@@ -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,31 @@ 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
+ });
@@ -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
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tests for the pure-JS zip extractor (bin/lib/zip.mjs). Fixture zips are
3
+ * hand-assembled in memory (local headers + central directory + EOCD) so we
4
+ * can exercise shapes the system `zip` tool won't produce — data descriptors,
5
+ * zip64 markers, encrypted flags. Run: `node --test bin/test/zip.test.mjs`.
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, readFileSync, existsSync, lstatSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { deflateRawSync } from "node:zlib";
13
+ import {
14
+ findEndOfCentralDirectory,
15
+ listZipEntries,
16
+ readEntryData,
17
+ extractZip,
18
+ } from "../lib/zip.mjs";
19
+
20
+ /**
21
+ * Assemble a zip from entries: [{name, data, method?, dataDescriptor?, flags?}].
22
+ * method: 0 store (default), 8 deflate. dataDescriptor: local sizes zeroed,
23
+ * flag bit 3 set, 16-byte descriptor appended after the data (the shape
24
+ * streaming writers produce — central directory holds the truth).
25
+ */
26
+ function buildZip(entries, { zip64Eocd = false } = {}) {
27
+ const parts = [];
28
+ const central = [];
29
+ let offset = 0;
30
+
31
+ for (const e of entries) {
32
+ const name = Buffer.from(e.name, "utf-8");
33
+ const raw = Buffer.from(e.data ?? "", "utf-8");
34
+ const method = e.method ?? 0;
35
+ const payload = method === 8 ? deflateRawSync(raw) : raw;
36
+ const useDD = Boolean(e.dataDescriptor);
37
+ const flags = (e.flags ?? 0) | (useDD ? 0x8 : 0);
38
+
39
+ const local = Buffer.alloc(30);
40
+ local.writeUInt32LE(0x04034b50, 0);
41
+ local.writeUInt16LE(20, 4); // version needed
42
+ local.writeUInt16LE(flags, 6);
43
+ local.writeUInt16LE(method, 8);
44
+ local.writeUInt16LE(0, 10); // time
45
+ local.writeUInt16LE(0, 12); // date
46
+ local.writeUInt32LE(0, 14); // crc (not verified by extractor)
47
+ local.writeUInt32LE(useDD ? 0 : payload.length, 18);
48
+ local.writeUInt32LE(useDD ? 0 : raw.length, 22);
49
+ local.writeUInt16LE(name.length, 26);
50
+ local.writeUInt16LE(0, 28); // extra len
51
+
52
+ const localOffset = offset;
53
+ parts.push(local, name, payload);
54
+ offset += local.length + name.length + payload.length;
55
+
56
+ if (useDD) {
57
+ const dd = Buffer.alloc(16);
58
+ dd.writeUInt32LE(0x08074b50, 0); // optional descriptor signature
59
+ dd.writeUInt32LE(0, 4); // crc
60
+ dd.writeUInt32LE(payload.length, 8);
61
+ dd.writeUInt32LE(raw.length, 12);
62
+ parts.push(dd);
63
+ offset += dd.length;
64
+ }
65
+
66
+ const cen = Buffer.alloc(46);
67
+ cen.writeUInt32LE(0x02014b50, 0);
68
+ cen.writeUInt16LE(20, 4); // version made by
69
+ cen.writeUInt16LE(20, 6); // version needed
70
+ cen.writeUInt16LE(flags, 8);
71
+ cen.writeUInt16LE(method, 10);
72
+ cen.writeUInt16LE(0, 12); // time
73
+ cen.writeUInt16LE(0, 14); // date
74
+ cen.writeUInt32LE(0, 16); // crc
75
+ cen.writeUInt32LE(payload.length, 20); // TRUE sizes live here
76
+ cen.writeUInt32LE(raw.length, 24);
77
+ cen.writeUInt16LE(name.length, 28);
78
+ cen.writeUInt16LE(0, 30); // extra
79
+ cen.writeUInt16LE(0, 32); // comment
80
+ cen.writeUInt16LE(0, 34); // disk start
81
+ cen.writeUInt16LE(0, 36); // internal attrs
82
+ cen.writeUInt32LE(e.externalAttrs ?? 0, 38);
83
+ cen.writeUInt32LE(localOffset, 42);
84
+ central.push(Buffer.concat([cen, name]));
85
+ }
86
+
87
+ const cd = Buffer.concat(central);
88
+ const cdOffset = offset;
89
+ const eocd = Buffer.alloc(22);
90
+ eocd.writeUInt32LE(0x06054b50, 0);
91
+ eocd.writeUInt16LE(0, 4); // disk
92
+ eocd.writeUInt16LE(0, 6); // cd disk
93
+ eocd.writeUInt16LE(zip64Eocd ? 0xffff : entries.length, 8);
94
+ eocd.writeUInt16LE(zip64Eocd ? 0xffff : entries.length, 10);
95
+ eocd.writeUInt32LE(cd.length, 12);
96
+ eocd.writeUInt32LE(zip64Eocd ? 0xffffffff : cdOffset, 16);
97
+ eocd.writeUInt16LE(0, 20); // comment len
98
+ return Buffer.concat([...parts, cd, eocd]);
99
+ }
100
+
101
+ function tmpDest(prefix) {
102
+ return mkdtempSync(join(tmpdir(), prefix));
103
+ }
104
+
105
+ test("stored entry round-trips", () => {
106
+ const buf = buildZip([{ name: "a.txt", data: "hello stored" }]);
107
+ const root = tmpDest("zip-stored-");
108
+ try {
109
+ const placed = extractZip(buf, join(root, "out"));
110
+ assert.equal(placed.length, 1);
111
+ assert.equal(readFileSync(placed[0], "utf-8"), "hello stored");
112
+ } finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("deflated entry round-trips", () => {
118
+ const text = "deflate me ".repeat(100);
119
+ const buf = buildZip([{ name: "b.txt", data: text, method: 8 }]);
120
+ const root = tmpDest("zip-deflate-");
121
+ try {
122
+ const placed = extractZip(buf, join(root, "out"));
123
+ assert.equal(readFileSync(placed[0], "utf-8"), text);
124
+ } finally {
125
+ rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("data-descriptor zip (local sizes zeroed) extracts via central directory", () => {
130
+ const text = "streamed entry with data descriptor";
131
+ const buf = buildZip([{ name: "dd.txt", data: text, method: 8, dataDescriptor: true }]);
132
+ // Sanity: the local header really does carry zero sizes.
133
+ assert.equal(buf.readUInt32LE(18), 0);
134
+ const entries = listZipEntries(buf);
135
+ assert.equal(entries[0].compressedSize > 0, true);
136
+ const root = tmpDest("zip-dd-");
137
+ try {
138
+ const placed = extractZip(buf, join(root, "out"));
139
+ assert.equal(readFileSync(placed[0], "utf-8"), text);
140
+ } finally {
141
+ rmSync(root, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("nested directories are created; dir entries write no files", () => {
146
+ const buf = buildZip([
147
+ { name: "pkg/", data: "" },
148
+ { name: "pkg/deep/file.md", data: "# nested" },
149
+ ]);
150
+ const root = tmpDest("zip-nest-");
151
+ try {
152
+ const out = join(root, "out");
153
+ const placed = extractZip(buf, out);
154
+ assert.equal(placed.length, 1);
155
+ assert.ok(existsSync(join(out, "pkg", "deep", "file.md")));
156
+ } finally {
157
+ rmSync(root, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("zip-slip entry is rejected before any write", () => {
162
+ const buf = buildZip([
163
+ { name: "ok.txt", data: "fine" },
164
+ { name: "../evil.sh", data: "rm -rf" },
165
+ ]);
166
+ const root = tmpDest("zip-slip-");
167
+ try {
168
+ const out = join(root, "out");
169
+ assert.throws(() => extractZip(buf, out), /zip-slip|traversal/);
170
+ assert.equal(existsSync(join(out, "ok.txt")), false); // nothing written
171
+ } finally {
172
+ rmSync(root, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test("zip64 EOCD markers are rejected with a clear error", () => {
177
+ const buf = buildZip([{ name: "x.txt", data: "x" }], { zip64Eocd: true });
178
+ assert.throws(() => findEndOfCentralDirectory(buf), /zip64/);
179
+ });
180
+
181
+ test("encrypted entries are rejected with a clear error", () => {
182
+ const buf = buildZip([{ name: "secret.txt", data: "x", flags: 0x1 }]);
183
+ assert.throws(() => listZipEntries(buf), /encrypted/);
184
+ });
185
+
186
+ test("unsupported compression method is rejected", () => {
187
+ const buf = buildZip([{ name: "lzma.bin", data: "x", method: 14 }]);
188
+ const entries = listZipEntries(buf);
189
+ assert.throws(() => readEntryData(buf, entries[0]), /unsupported zip compression method 14/);
190
+ });
191
+
192
+ test("symlink-flagged entry is materialized as a regular file", () => {
193
+ const S_IFLNK = 0o120000;
194
+ const buf = buildZip([
195
+ { name: "link", data: "/etc/passwd", externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0 },
196
+ ]);
197
+ const root = tmpDest("zip-symlink-");
198
+ try {
199
+ const out = join(root, "out");
200
+ extractZip(buf, out);
201
+ const st = lstatSync(join(out, "link"));
202
+ assert.equal(st.isSymbolicLink(), false);
203
+ assert.equal(st.isFile(), true);
204
+ assert.equal(readFileSync(join(out, "link"), "utf-8"), "/etc/passwd");
205
+ } finally {
206
+ rmSync(root, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ test("garbage buffer fails with a clear error", () => {
211
+ assert.throws(() => listZipEntries(Buffer.from("definitely not a zip file")), /end-of-central-directory/);
212
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-relay",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Install Agent Relay AI search across 17+ agents — Claude Code, Codex, Cursor, Gemini CLI, Goose, Windsurf, Cline, BoltAI, Claude Desktop, VS Code, Amazon Q, Roo Code, Witsy, LibreChat, OpenClaw, Tome, Raycast — plus the `agentrelay` CLI for live agent sessions, payments, and deliverable fetch",
5
5
  "bin": {
6
6
  "agentrelay": "bin/cli.mjs"
package/skill/SKILL.md CHANGED
@@ -229,6 +229,7 @@ Score rubric: a confidently-wrong claim (a 404'd endpoint served as live, a fals
229
229
  ## Known gotchas (seeded from real runs)
230
230
 
231
231
  - **Don't truncate `--json` search output.** Piping `agentrelay search … --json | head -N` cuts off `payment_context`, which sits after the (long) capabilities array — so the agent never sees the live pay policy and falls back to a wrong default. Read the whole JSON.
232
+ - **A cached search's `payment_context` is a snapshot, not live.** When the search result has `from_cache: true`, its `payment_context` dates from `cached_at` (search cache TTL is 1h). For an actual pay decision, read `agentrelay budget` or re-run the search with `--refresh`.
232
233
  - **Never assert pay posture from memory.** Run `agentrelay budget` or read `payment_context` before any statement about whether autonomous pay is on. (A test agent claimed "off by default" while the live setting was on with a $5 auto-approve.)
233
234
  - **`deliverable_complete` is not a stop command.** Continue the session if you still need something it didn't include.
234
235
  - **Don't reconstruct the workflow from CLAUDE.md.** It's a thin map; this skill is the manual. If you're doing Agent Relay work, you should have loaded this file.