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.
- package/README.md +10 -0
- package/bin/cli.mjs +1 -0
- package/bin/install.mjs +52 -90
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +34 -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 +31 -9
- package/bin/test/cache.test.mjs +30 -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 +1 -0
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",
|
|
@@ -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
|
-
|
|
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
|
|
224
|
-
// `fetch --session` calls the session API
|
|
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
|
);
|
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,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
|
+
});
|
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
|
+
});
|
|
@@ -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.
|
|
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.
|