agentic-relay 3.8.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.
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * agentrelay (CLI core) — token-cheap, parallel agent consulting for coding workflows.
5
+ *
6
+ * Wraps the AttentionMarket REST API so a coding agent can consult expert
7
+ * "solutions-engineer" capability agents without flooding its context with raw
8
+ * transcripts. The multi-turn qualify→deliver loop, fan-out, and caching run
9
+ * HERE (in this process), and only compact Deliverables come back.
10
+ *
11
+ * Commands:
12
+ * agentrelay search <query> broad discovery (cheap; safe in main context)
13
+ * agentrelay consult <slug> run one agent's full loop → a Deliverable
14
+ * agentrelay consult-batch fan-out, parallel, one consolidated result
15
+ * agentrelay cache ls|show|clear inspect/manage the on-disk spec cache
16
+ *
17
+ * Output: a `{ ok, ...payload, errors: [] }` envelope. --json forces machine
18
+ * output (auto-on when stdout is not a TTY). Exit codes: 0 ok, 1 partial,
19
+ * 2 usage, 3 auth/config. The API key is never printed.
20
+ *
21
+ * Dispatched from bin/cli.mjs (the single `agentrelay` bin). Install globally
22
+ * with `npm i -g agentic-relay` for a bare `agentrelay` command.
23
+ */
24
+
25
+ import { readFileSync } from "fs";
26
+ import { resolveApiKey } from "./lib/config.mjs";
27
+ import {
28
+ cmdSearch,
29
+ cmdConsult,
30
+ cmdConsultBatch,
31
+ cmdSession,
32
+ cmdFeedback,
33
+ cmdCache,
34
+ cmdBudget,
35
+ cmdConnectWallet,
36
+ } from "./lib/commands.mjs";
37
+ import { cmdFetch } from "./lib/fetch.mjs";
38
+
39
+ const EXIT = { OK: 0, PARTIAL: 1, USAGE: 2, AUTH: 3 };
40
+
41
+ const FLAGS_WITH_VALUE = new Set([
42
+ "--api-key",
43
+ "--context",
44
+ "--cache-dir",
45
+ "--timeout",
46
+ "--max-turns",
47
+ "--concurrency",
48
+ "--max",
49
+ "--user-context",
50
+ "--want",
51
+ "--want-file",
52
+ "--agents",
53
+ "--driver",
54
+ "--initial",
55
+ "--message",
56
+ "--data",
57
+ "--score",
58
+ "--text",
59
+ "--file",
60
+ "--auto-approve",
61
+ "--daily-cap",
62
+ "--require-human-over",
63
+ "--per-session-cap",
64
+ "--credential",
65
+ "--checksum",
66
+ "--filename",
67
+ ]);
68
+
69
+ function parseArgs(argv) {
70
+ const flags = {};
71
+ const positionals = [];
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i];
74
+ if (a.startsWith("--")) {
75
+ const eq = a.indexOf("=");
76
+ if (eq !== -1) {
77
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
78
+ } else if (FLAGS_WITH_VALUE.has(a) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) {
79
+ flags[a.slice(2)] = argv[++i];
80
+ } else {
81
+ flags[a.slice(2)] = true;
82
+ }
83
+ } else {
84
+ positionals.push(a);
85
+ }
86
+ }
87
+ return { flags, positionals };
88
+ }
89
+
90
+ function loadContext(path) {
91
+ if (!path) return null;
92
+ try {
93
+ return JSON.parse(readFileSync(path, "utf-8"));
94
+ } catch (err) {
95
+ const e = new Error(`could not read --context ${path}: ${err.message}`);
96
+ e.usage = true;
97
+ throw e;
98
+ }
99
+ }
100
+
101
+ function buildShared(flags) {
102
+ return {
103
+ apiKey: resolveApiKey(flags["api-key"] || null),
104
+ json: Boolean(flags.json) || !process.stdout.isTTY,
105
+ cacheDir: flags["cache-dir"] || ".agent-relay",
106
+ noCache: Boolean(flags["no-cache"]),
107
+ refresh: Boolean(flags.refresh),
108
+ timeoutMs: (Number(flags.timeout) || 120) * 1000,
109
+ maxTurns: Number(flags["max-turns"]) || 6,
110
+ concurrency: Number(flags.concurrency) || 5,
111
+ quiet: Boolean(flags.quiet),
112
+ verbose: Boolean(flags.verbose),
113
+ context: loadContext(flags.context),
114
+ };
115
+ }
116
+
117
+ // ── output ──────────────────────────────────────────────────────────────
118
+ function emit(payload, { ok = true, errors = [], json, exitCode = EXIT.OK, human } = {}) {
119
+ const envelope = { ok, ...payload, errors };
120
+ if (json) {
121
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
122
+ } else if (human) {
123
+ process.stdout.write(human(envelope) + "\n");
124
+ } else {
125
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
126
+ }
127
+ process.exit(exitCode);
128
+ }
129
+
130
+ function fail(message, exitCode, json) {
131
+ const envelope = { ok: false, errors: [message] };
132
+ if (json) process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
133
+ else process.stderr.write(`agentrelay: ${message}\n`);
134
+ process.exit(exitCode);
135
+ }
136
+
137
+ // ── human formatters ──────────────────────────────────────────────────────
138
+ const fmtSearch = (e) => {
139
+ const lines = [`${e.count} capabilities for "${e.query}"`];
140
+ for (const c of e.capabilities) {
141
+ const price = c.clearing_price_cents ? `$${(c.clearing_price_cents / 100).toFixed(2)}` : "free";
142
+ const rel = c.relevance != null ? c.relevance.toFixed(2) : "—";
143
+ const kind = c.type === "static_offer" ? "[offer]" : "[agent]";
144
+ const desc = c.description.length > 90 ? c.description.slice(0, 90) + "…" : c.description;
145
+ lines.push(` ${kind} ${c.slug} (rel ${rel}, ${price}) ${c.name}`);
146
+ if (desc) lines.push(` ${desc}`);
147
+ }
148
+ return lines.join("\n");
149
+ };
150
+
151
+ const fmtBudget = (e) => {
152
+ const p = e.policy || {};
153
+ const dol = (c) => (c == null ? "—" : `$${(c / 100).toFixed(2)}`);
154
+ return [
155
+ `autonomous pay: ${p.autonomous_enabled ? "ENABLED" : "disabled (ask every time)"}`,
156
+ ` auto-approve under: ${dol(p.auto_approve_under_cents)}`,
157
+ ` require human over: ${dol(p.require_human_over_cents)}`,
158
+ ` daily cap: ${dol(p.daily_cap_cents)}`,
159
+ ` per-session cap: ${dol(p.per_session_cap_cents)}`,
160
+ ` wallet methods: ${(p.wallet_methods && p.wallet_methods.length) ? p.wallet_methods.join(", ") : "none connected"}`,
161
+ ].join("\n");
162
+ };
163
+
164
+ const fmtDeliverable = (d) => {
165
+ const lines = [`▸ ${d.name} (${d.slug})${d.cached ? " [cached]" : ""}`];
166
+ if (d.verdict) lines.push(` verdict: ${d.verdict}`);
167
+ if (d.install?.length) lines.push(` install: ${d.install.join(" && ")}`);
168
+ if (d.endpoints?.length) lines.push(` endpoints: ${d.endpoints.map((x) => x.name || x.url).join(", ")}`);
169
+ if (d.snippets?.length) lines.push(` snippets: ${d.snippets.map((s) => s.path || s.desc || s.lang).join(", ")}`);
170
+ if (d.gotchas?.length) lines.push(` gotchas: ${d.gotchas.join("; ")}`);
171
+ if (d.free_tier) lines.push(` free tier: ${d.free_tier}`);
172
+ if (d.docs?.length) lines.push(` docs: ${d.docs.join(" ")}`);
173
+ lines.push(` (${d.turns} turns, ~${d.tokens_estimate} tokens)`);
174
+ return lines.join("\n");
175
+ };
176
+
177
+ const HELP = `agentrelay — converse with capability agents while you build.
178
+
179
+ Conversation (LLM-driven — drive these from a subagent, or directly when you're stuck):
180
+ agentrelay session start <slug> [--initial json] [--message "<first msg>"]
181
+ agentrelay session send <id> "<m>" [--data json]
182
+ agentrelay session pay <id> [--credential <mpp>] settle a payment_required
183
+ agentrelay session end <id>
184
+ agentrelay feedback <id> --score <0-100> --text "<s>"
185
+
186
+ Discovery + cache:
187
+ agentrelay search <query> [--max 25] [--user-context <s>] [--dynamic-only]
188
+ agentrelay cache ls | show <slug> | put <slug> [--file f.json] | clear
189
+
190
+ Payments (autonomous spend policy — server-canonical):
191
+ agentrelay budget show current policy
192
+ agentrelay budget --enable|--disable [--auto-approve=<$>] [--daily-cap=<$>]
193
+ [--require-human-over=<$>] [--per-session-cap=<$>]
194
+ agentrelay connect-wallet (wallet connect is set up in the dashboard)
195
+
196
+ Deliverables (after a paid session returns a delivery object):
197
+ agentrelay fetch <download_url> [dest] [--checksum <sha256>] [--filename <name>]
198
+ download + unpack into ./<slug>/ (never executes anything)
199
+
200
+ Template fallback (code-driven, no LLM — distilled result):
201
+ agentrelay consult <slug> --want <s> [--context f.json] [--deliver-now] [--refresh]
202
+ agentrelay consult-batch --agents a,b,c (--want <s> | --want-file w.json) [--concurrency 5]
203
+
204
+ Global: --api-key <k> --json --context <f> --cache-dir <d> --no-cache
205
+ --timeout <sec> --max-turns <n> --concurrency <n> --quiet --verbose
206
+
207
+ Key: --api-key | $AGENT_RELAY_API_KEY | ~/.config/agent-relay/credentials.json`;
208
+
209
+ // ── main ──────────────────────────────────────────────────────────────────
210
+ async function main() {
211
+ const argv = process.argv.slice(2);
212
+ const cmd = argv[0];
213
+ const { flags, positionals } = parseArgs(argv.slice(1));
214
+
215
+ if (!cmd || cmd === "help" || flags.help) {
216
+ process.stdout.write(HELP + "\n");
217
+ process.exit(cmd ? EXIT.OK : EXIT.USAGE);
218
+ }
219
+ if (cmd === "version" || flags.version) {
220
+ let v = "unknown";
221
+ try {
222
+ v = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
223
+ } catch { /* fall back to "unknown" */ }
224
+ process.stdout.write(`agentrelay ${v}\n`);
225
+ process.exit(EXIT.OK);
226
+ }
227
+
228
+ let shared;
229
+ try {
230
+ shared = buildShared(flags);
231
+ } catch (err) {
232
+ return fail(err.message, EXIT.USAGE, Boolean(flags.json) || !process.stdout.isTTY);
233
+ }
234
+ const json = shared.json;
235
+
236
+ // cache and fetch are the only commands that don't need a key.
237
+ if (cmd !== "cache" && cmd !== "fetch" && !shared.apiKey) {
238
+ return fail(
239
+ "no API key — set --api-key, $AGENT_RELAY_API_KEY, or ~/.config/agent-relay/credentials.json",
240
+ EXIT.AUTH,
241
+ json,
242
+ );
243
+ }
244
+
245
+ try {
246
+ switch (cmd) {
247
+ case "search": {
248
+ const query = positionals.join(" ").trim();
249
+ if (!query) return fail("usage: agentrelay search <query>", EXIT.USAGE, json);
250
+ const payload = await cmdSearch(query, {
251
+ ...shared,
252
+ max: Number(flags.max) || 25,
253
+ userContext: flags["user-context"] || null,
254
+ dynamicOnly: Boolean(flags["dynamic-only"]),
255
+ });
256
+ return emit(payload, { json, human: fmtSearch });
257
+ }
258
+
259
+ case "consult": {
260
+ const slug = positionals[0];
261
+ if (!slug) return fail("usage: agentrelay consult <slug> --want <str>", EXIT.USAGE, json);
262
+ const payload = await cmdConsult(slug, {
263
+ ...shared,
264
+ want: flags.want || null,
265
+ driver: flags.driver || null,
266
+ deliverNow: Boolean(flags["deliver-now"]),
267
+ raw: Boolean(flags.raw),
268
+ });
269
+ return emit(payload, { json, human: (e) => fmtDeliverable(e.deliverable) });
270
+ }
271
+
272
+ case "consult-batch": {
273
+ const payload = await cmdConsultBatch({
274
+ ...shared,
275
+ agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : null,
276
+ want: flags.want || null,
277
+ wantFile: flags["want-file"] || null,
278
+ driver: flags.driver || null,
279
+ deliverNow: Boolean(flags["deliver-now"]),
280
+ raw: Boolean(flags.raw),
281
+ });
282
+ const exitCode = payload.failures.length > 0 ? EXIT.PARTIAL : EXIT.OK;
283
+ return emit(payload, {
284
+ json,
285
+ ok: payload.failures.length === 0,
286
+ errors: payload.failures.map((f) => `${f.slug}: ${f.reason}`),
287
+ exitCode,
288
+ human: (e) =>
289
+ [
290
+ `consulted ${e.totals.consulted}: ${e.totals.ok} ok, ${e.totals.failed} failed ` +
291
+ `(${e.totals.wall_seconds}s, ~${e.totals.tokens_estimate} tokens)`,
292
+ ...e.deliverables.map(fmtDeliverable),
293
+ ...e.failures.map((f) => `✗ ${f.slug}: ${f.reason}`),
294
+ ].join("\n"),
295
+ });
296
+ }
297
+
298
+ case "session": {
299
+ const payload = await cmdSession(positionals[0], positionals.slice(1), {
300
+ ...shared,
301
+ initial: flags.initial || null,
302
+ message: flags.message || null,
303
+ data: flags.data || null,
304
+ credential: flags.credential || null,
305
+ });
306
+ return emit(payload, { json });
307
+ }
308
+
309
+ case "feedback": {
310
+ const payload = await cmdFeedback(positionals[0], {
311
+ ...shared,
312
+ score: flags.score != null ? flags.score : null,
313
+ text: flags.text || null,
314
+ });
315
+ return emit(payload, { json });
316
+ }
317
+
318
+ case "budget": {
319
+ const payload = await cmdBudget({
320
+ ...shared,
321
+ autoApprove: flags["auto-approve"] != null ? flags["auto-approve"] : null,
322
+ dailyCap: flags["daily-cap"] != null ? flags["daily-cap"] : null,
323
+ requireHumanOver: flags["require-human-over"] != null ? flags["require-human-over"] : null,
324
+ perSessionCap: flags["per-session-cap"] != null ? flags["per-session-cap"] : null,
325
+ enable: Boolean(flags.enable),
326
+ disable: Boolean(flags.disable),
327
+ });
328
+ return emit(payload, { json, human: fmtBudget });
329
+ }
330
+
331
+ case "connect-wallet": {
332
+ const payload = cmdConnectWallet();
333
+ return emit(payload, { json, human: (e) => e.message });
334
+ }
335
+
336
+ case "fetch": {
337
+ // Download a paid deliverable and unpack into ./<slug>/ (no API key needed).
338
+ // NEVER executes anything from the bundle.
339
+ const payload = await cmdFetch({
340
+ url: positionals[0],
341
+ dest: positionals[1] || null,
342
+ checksum: flags.checksum || null,
343
+ filename: flags.filename || null,
344
+ });
345
+ return emit(payload, {
346
+ json,
347
+ human: (e) =>
348
+ [`Fetched ${e.kind} → ${e.dest}`, ...e.placed.map((p) => ` ${p}`),
349
+ `Nothing was executed — inspect the files before use.`].join("\n"),
350
+ });
351
+ }
352
+
353
+ case "cache": {
354
+ const payload = cmdCache(positionals[0], positionals[1], { ...shared, file: flags.file || null });
355
+ const human = Array.isArray(payload.specs)
356
+ ? (e) =>
357
+ [`${e.specs.length} banked specs (${e.cache_dir})`, ...e.specs.map((s) => {
358
+ const feat = s.features != null ? ` (${s.features} features)` : "";
359
+ const sum = s.summary ? ` — ${s.summary.length > 100 ? s.summary.slice(0, 100) + "…" : s.summary}` : "";
360
+ return ` ${s.slug}${feat}${sum}`;
361
+ })].join("\n")
362
+ : undefined;
363
+ return emit(payload, { json, human });
364
+ }
365
+
366
+ default:
367
+ return fail(
368
+ `unknown command "${cmd}" (session|search|consult|consult-batch|feedback|cache|budget|connect-wallet|fetch)`,
369
+ EXIT.USAGE,
370
+ json,
371
+ );
372
+ }
373
+ } catch (err) {
374
+ const code = err.usage ? EXIT.USAGE : EXIT.PARTIAL;
375
+ return fail(err.message || String(err), code, json);
376
+ }
377
+ }
378
+
379
+ main().catch((err) => {
380
+ process.stderr.write(`agentrelay: fatal: ${err.message}\n`);
381
+ process.exit(EXIT.PARTIAL);
382
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Tests for the `budget` command's dollar→cents conversion + validation.
3
+ * Run: `node --test bin/test/`.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { dollarsToCents } from "../lib/commands.mjs";
8
+
9
+ test("dollarsToCents: whole dollars → cents", () => {
10
+ assert.equal(dollarsToCents("--daily-cap", 50), 5000);
11
+ assert.equal(dollarsToCents("--daily-cap", "50"), 5000);
12
+ });
13
+
14
+ test("dollarsToCents: fractional dollars round to nearest cent", () => {
15
+ assert.equal(dollarsToCents("--auto-approve", 2.5), 250);
16
+ assert.equal(dollarsToCents("--auto-approve", "0.99"), 99);
17
+ assert.equal(dollarsToCents("--auto-approve", 1.01), 101);
18
+ });
19
+
20
+ test("dollarsToCents: zero is allowed (e.g. auto-approve nothing)", () => {
21
+ assert.equal(dollarsToCents("--auto-approve", 0), 0);
22
+ });
23
+
24
+ test("dollarsToCents: negative is a usage error", () => {
25
+ assert.throws(() => dollarsToCents("--daily-cap", -5), (e) => e.usage === true);
26
+ });
27
+
28
+ test("dollarsToCents: non-numeric is a usage error", () => {
29
+ assert.throws(() => dollarsToCents("--daily-cap", "abc"), (e) => e.usage === true);
30
+ assert.throws(() => dollarsToCents("--daily-cap", "5dollars"), (e) => e.usage === true);
31
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests for the banked-cache contract: race-safe `listSpecHeadlines` (reads
3
+ * spec files, no index.json) and `resolveCapability`'s spec-file id fallback
4
+ * (so a depth session resolves from what a breadth subagent banked). No network.
5
+ * Run: `node --test bin/test/cache.test.mjs`.
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { Cache } from "../lib/cache.mjs";
13
+ import { resolveCapability } from "../lib/commands.mjs";
14
+
15
+ const ISO = "2026-05-24T00:00:00.000Z";
16
+
17
+ function withCache(fn) {
18
+ const dir = mkdtempSync(join(tmpdir(), "agent-relay-bank-"));
19
+ try {
20
+ return fn(dir, new Cache(dir));
21
+ } finally {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ test("listSpecHeadlines: returns slug+name+summary+feature-count, no index.json needed", () => {
27
+ withCache((dir, cache) => {
28
+ cache.writeSpec("a-tool", {
29
+ slug: "a-tool", name: "A", summary: "does A",
30
+ features: [{ feature: "x", how: "y" }], capability_id: "cap-a", ts: ISO,
31
+ });
32
+ cache.writeSpec("b-tool", { slug: "b-tool", name: "B", summary: "does B", features: [], ts: ISO });
33
+
34
+ const h = cache.listSpecHeadlines();
35
+ assert.equal(h.length, 2);
36
+ const a = h.find((x) => x.slug === "a-tool");
37
+ assert.equal(a.name, "A");
38
+ assert.equal(a.summary, "does A");
39
+ assert.equal(a.features, 1);
40
+ assert.equal(a.capability_id, "cap-a");
41
+ // Race-safe: the put path never wrote a shared index.json.
42
+ assert.equal(existsSync(join(dir, "index.json")), false);
43
+ });
44
+ });
45
+
46
+ test("listSpecHeadlines: tolerates a spec missing the optional fields", () => {
47
+ withCache((_dir, cache) => {
48
+ cache.writeSpec("bare", { slug: "bare", ts: ISO }); // no name/summary/features
49
+ const h = cache.listSpecHeadlines();
50
+ assert.equal(h.length, 1);
51
+ assert.equal(h[0].slug, "bare");
52
+ assert.equal(h[0].summary, null);
53
+ assert.equal(h[0].features, null);
54
+ });
55
+ });
56
+
57
+ test("resolveCapability: falls back to the banked spec's ids (no network)", async () => {
58
+ await withCache(async (dir, cache) => {
59
+ cache.writeSpec("a-tool", {
60
+ slug: "a-tool", name: "A Tool", summary: "s", features: [],
61
+ capability_id: "cap-a", ad_unit_id: "unit-a", clearing_price_cents: 130, ts: ISO,
62
+ });
63
+ // No index.json, refresh falsy → must resolve from the spec file alone.
64
+ const r = await resolveCapability("a-tool", { cacheDir: dir, noCache: false });
65
+ assert.equal(r.capabilityId, "cap-a");
66
+ assert.equal(r.adUnitId, "unit-a");
67
+ assert.equal(r.clearingPriceCents, 130);
68
+ assert.equal(r.slug, "a-tool");
69
+ assert.equal(r.name, "A Tool");
70
+ });
71
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tests for the completion logic in the conversation driver (spec §6/§10).
3
+ * Pure functions only — no network. Run: `node --test bin/test/driver.test.mjs`.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { turnAwaitsInput, assessTurn } from "../lib/driver.mjs";
8
+
9
+ test("backend flag deliverable_complete wins over prose", () => {
10
+ // Message ends in a question, but the backend says it's complete.
11
+ const turn = { message: "Anything else?", deliverable_complete: true };
12
+ assert.equal(turnAwaitsInput(turn), false);
13
+ assert.deepEqual(assessTurn(turn, false).complete, true);
14
+ });
15
+
16
+ test("backend flag awaiting_input forces another turn", () => {
17
+ const turn = { message: "Here is everything you need.", awaiting_input: true };
18
+ assert.equal(turnAwaitsInput(turn), true);
19
+ assert.equal(assessTurn(turn, true).complete, false);
20
+ });
21
+
22
+ test("heuristic: non-empty pending_fields means awaiting", () => {
23
+ const turn = { message: "Got it.", pending_fields: ["api_key"] };
24
+ assert.equal(turnAwaitsInput(turn), true);
25
+ });
26
+
27
+ test("heuristic: a question mark means awaiting (no flags)", () => {
28
+ const turn = { message: "Which framework — Next.js or Remix?" };
29
+ assert.equal(turnAwaitsInput(turn), true);
30
+ assert.equal(assessTurn(turn, false).complete, false);
31
+ });
32
+
33
+ test("heuristic: offer phrase means awaiting", () => {
34
+ const turn = { message: "I can do that. Let me know if you want the proxy too." };
35
+ assert.equal(turnAwaitsInput(turn), true);
36
+ });
37
+
38
+ test("complete when a substantive deliverable exists and not awaiting", () => {
39
+ const turn = { message: "Here is the typed fetcher and endpoints." };
40
+ assert.equal(turnAwaitsInput(turn), false);
41
+ assert.equal(assessTurn(turn, true).complete, true);
42
+ });
43
+
44
+ test("not complete when not awaiting but no deliverable yet", () => {
45
+ const turn = { message: "Sure, I can help with that." };
46
+ const a = assessTurn(turn, false);
47
+ assert.equal(a.complete, false);
48
+ assert.equal(a.reason, "no-deliverable");
49
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tests for `agent-relay fetch` — the deliverable download/unpack helper.
3
+ * Covers: sha256 verify, zip magic-byte detection, zip-slip rejection, slug
4
+ * inference, non-colliding dest, and "never execute" (we only write files).
5
+ * No network: a fake fetchImpl returns in-memory buffers.
6
+ * Run: `node --test bin/test/fetch.test.mjs`.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { execFileSync } from "node:child_process";
14
+ import {
15
+ isZip,
16
+ sha256Hex,
17
+ inferSlug,
18
+ uniqueDir,
19
+ zipEntriesFromListing,
20
+ assertNoZipSlip,
21
+ cmdFetch,
22
+ } from "../lib/fetch.mjs";
23
+
24
+ function fakeFetch(buf, ok = true, status = 200) {
25
+ return async () => ({ ok, status, arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) });
26
+ }
27
+
28
+ test("isZip detects PK magic bytes, not extension", () => {
29
+ assert.equal(isZip(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00])), true);
30
+ assert.equal(isZip(Buffer.from("hello world")), false);
31
+ });
32
+
33
+ test("sha256Hex matches known digest", () => {
34
+ // echo -n "" | sha256sum
35
+ assert.equal(sha256Hex(Buffer.from("")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
36
+ });
37
+
38
+ test("inferSlug from filename and URL", () => {
39
+ assert.equal(inferSlug("https://x/y/abc.zip", "My Cool Skill.zip"), "my-cool-skill");
40
+ assert.equal(inferSlug("https://x/storage/sign/Acme_Bundle.zip?token=1"), "acme-bundle");
41
+ assert.equal(inferSlug("not a url"), "deliverable");
42
+ });
43
+
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
+ test("assertNoZipSlip rejects traversal and absolute paths", () => {
59
+ const dest = join(tmpdir(), "dest");
60
+ assert.throws(() => assertNoZipSlip(["../escape.sh"], dest), /zip-slip|traversal/);
61
+ assert.throws(() => assertNoZipSlip(["/etc/passwd"], dest), /absolute/);
62
+ assert.doesNotThrow(() => assertNoZipSlip(["ok/file.txt", "nested/dir/x"], dest));
63
+ });
64
+
65
+ test("uniqueDir suffixes when the base exists", () => {
66
+ const root = mkdtempSync(join(tmpdir(), "agent-relay-fetch-uniq-"));
67
+ try {
68
+ const base = join(root, "thing");
69
+ const d1 = uniqueDir(base);
70
+ assert.equal(d1, base);
71
+ writeFileSync(base, "x"); // occupy it
72
+ const d2 = uniqueDir(base);
73
+ assert.equal(d2, `${base}-1`);
74
+ } finally {
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test("cmdFetch verifies checksum and aborts on mismatch (writes nothing)", async () => {
80
+ const buf = Buffer.from("plain artifact contents");
81
+ await assert.rejects(
82
+ () => cmdFetch({ url: "https://x/a.txt", checksum: "deadbeef", fetchImpl: fakeFetch(buf), dest: join(tmpdir(), "nope-" + Date.now()) }),
83
+ /checksum mismatch/,
84
+ );
85
+ });
86
+
87
+ test("cmdFetch drops a non-zip file into ./<slug>/ as-is", async () => {
88
+ const cwd = process.cwd();
89
+ const root = mkdtempSync(join(tmpdir(), "agent-relay-fetch-file-"));
90
+ process.chdir(root);
91
+ try {
92
+ const buf = Buffer.from("hello deliverable");
93
+ const r = await cmdFetch({ url: "https://x/y/readme.txt", filename: "readme.txt", checksum: sha256Hex(buf), fetchImpl: fakeFetch(buf) });
94
+ assert.equal(r.kind, "file");
95
+ assert.equal(r.placed.length, 1);
96
+ assert.equal(readFileSync(r.placed[0], "utf-8"), "hello deliverable");
97
+ } finally {
98
+ process.chdir(cwd);
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("cmdFetch extracts a real zip into ./<slug>/ (no execution)", async () => {
104
+ const cwd = process.cwd();
105
+ const root = mkdtempSync(join(tmpdir(), "agent-relay-fetch-zip-"));
106
+ process.chdir(root);
107
+ try {
108
+ // Build a tiny real zip with the system zip tool; skip if unavailable.
109
+ const srcDir = join(root, "src");
110
+ execFileSync("mkdir", ["-p", srcDir]);
111
+ writeFileSync(join(srcDir, "SKILL.md"), "# hi");
112
+ const zipPath = join(root, "bundle.zip");
113
+ try {
114
+ execFileSync("zip", ["-q", "-r", zipPath, "SKILL.md"], { cwd: srcDir });
115
+ } catch {
116
+ return; // no `zip` on PATH — skip this env
117
+ }
118
+ const buf = readFileSync(zipPath);
119
+ const r = await cmdFetch({ url: "https://x/cool-bundle.zip", filename: "cool-bundle.zip", checksum: sha256Hex(buf), fetchImpl: fakeFetch(buf) });
120
+ assert.equal(r.kind, "zip");
121
+ assert.ok(r.dest.endsWith("cool-bundle"));
122
+ assert.ok(r.placed.some((p) => p.endsWith("SKILL.md")));
123
+ assert.ok(existsSync(join(r.dest, "SKILL.md")));
124
+ } finally {
125
+ process.chdir(cwd);
126
+ rmSync(root, { recursive: true, force: true });
127
+ }
128
+ });