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.
- package/CLAUDE.md +7 -7
- package/README.md +10 -0
- package/bin/cli.mjs +1 -0
- package/bin/install.mjs +78 -99
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +43 -6
- package/bin/lib/doctor.mjs +222 -0
- package/bin/lib/fetch.mjs +14 -79
- package/bin/lib/install-args.mjs +67 -0
- package/bin/lib/sysenv.mjs +152 -0
- package/bin/lib/update-check.mjs +121 -0
- package/bin/lib/zip.mjs +139 -0
- package/bin/relay-core.mjs +43 -10
- package/bin/test/cache.test.mjs +44 -2
- package/bin/test/doctor.test.mjs +76 -0
- package/bin/test/fetch.test.mjs +0 -15
- package/bin/test/install-args.test.mjs +68 -0
- package/bin/test/session.test.mjs +5 -1
- package/bin/test/update-check.test.mjs +56 -0
- package/bin/test/zip.test.mjs +212 -0
- package/package.json +1 -1
- package/skill/SKILL.md +32 -19
package/bin/lib/zip.mjs
ADDED
|
@@ -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
|
+
}
|
package/bin/relay-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
224
|
-
// `fetch --session` calls the session API
|
|
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
|
);
|
package/bin/test/cache.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/test/fetch.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|