agentic-relay 3.8.5 → 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/CLAUDE.md +1 -1
- package/README.md +11 -1
- package/bin/cli.mjs +5 -2
- package/bin/install.mjs +52 -90
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +41 -126
- 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/parse.mjs +0 -65
- 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 +50 -85
- 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/parse.test.mjs +1 -27
- 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 +3 -3
- package/skill/SKILL.md +3 -2
- package/bin/lib/deliverable.mjs +0 -70
- package/bin/lib/driver.mjs +0 -165
- package/bin/lib/pool.mjs +0 -26
- package/bin/test/driver.test.mjs +0 -49
|
@@ -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
|
+
});
|
package/bin/test/parse.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import { lenientParse
|
|
7
|
+
import { lenientParse } from "../lib/parse.mjs";
|
|
8
8
|
|
|
9
9
|
test("lenientParse: clean JSON parses strictly", () => {
|
|
10
10
|
const r = lenientParse('{"a":1,"b":"hi"}');
|
|
@@ -42,29 +42,3 @@ test("lenientParse: truly broken input fails gracefully (no throw)", () => {
|
|
|
42
42
|
assert.equal(r.ok, false);
|
|
43
43
|
assert.ok(r.error);
|
|
44
44
|
});
|
|
45
|
-
|
|
46
|
-
test("extractJsonObject: pulls a fenced ```json block out of prose", () => {
|
|
47
|
-
const msg =
|
|
48
|
-
"Sure! Here is your deliverable:\n```json\n{\"verdict\":\"use it\",\"install\":[\"npm i x\"]}\n```\nLet me know!";
|
|
49
|
-
const obj = extractJsonObject(msg);
|
|
50
|
-
assert.equal(obj.verdict, "use it");
|
|
51
|
-
assert.deepEqual(obj.install, ["npm i x"]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("extractJsonObject: pulls a bare embedded object", () => {
|
|
55
|
-
const msg = 'prefix {"a":1,"nested":{"b":2}} suffix';
|
|
56
|
-
const obj = extractJsonObject(msg);
|
|
57
|
-
assert.deepEqual(obj, { a: 1, nested: { b: 2 } });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("extractJsonObject: returns null when there's no object", () => {
|
|
61
|
-
assert.equal(extractJsonObject("just a question?"), null);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("extractFields: last-resort flat string extraction", () => {
|
|
65
|
-
const raw = '{"session_id":"abc-123","status":"active","message":"hi \\"there\\""}';
|
|
66
|
-
const got = extractFields(raw, ["session_id", "status", "message"]);
|
|
67
|
-
assert.equal(got.session_id, "abc-123");
|
|
68
|
-
assert.equal(got.status, "active");
|
|
69
|
-
assert.equal(got.message, 'hi "there"');
|
|
70
|
-
});
|
|
@@ -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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-relay",
|
|
3
|
-
"version": "
|
|
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
|
|
3
|
+
"version": "4.1.0",
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node --test
|
|
9
|
+
"test": "node --test bin/test/*.test.mjs"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"agent-relay",
|
package/skill/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ Agent Relay is a marketplace of **capability agents**: each is a live solutions
|
|
|
13
13
|
- Whenever you're **about to choose** a library/API/service, or **stuck integrating** one.
|
|
14
14
|
- **Before** web-searching or answering from your own knowledge about a tool — these agents are current and hand you working code, not approximate recollection.
|
|
15
15
|
|
|
16
|
-
Skip it only for genuinely self-contained work with no external dependency (a small algorithm or one-off script), or a question answerable without integration. For a trivial lookup ("find me X", "how do I install X"),
|
|
16
|
+
Skip it only for genuinely self-contained work with no external dependency (a small algorithm or one-off script), or a question answerable without integration. For a trivial lookup ("find me X", "how do I install X"), a single inline session (`session start` → a send or two → `session end`) is enough — skip the breadth→subagent→cache ceremony, but still settle any payment by the rules.
|
|
17
17
|
|
|
18
18
|
## How to call it
|
|
19
19
|
|
|
@@ -44,7 +44,7 @@ This loop keeps raw transcripts out of your main context (only compact summaries
|
|
|
44
44
|
3. **After search, use judgment on whether to pause.** When the choice of agents is consequential or ambiguous — several plausible tools, paid capabilities in the mix, an architecture fork — surface the shortlist to the user and suggest which to consult (bias toward broad coverage to maximize breadth), then proceed on their pick. When the picks are obvious, just start breadth autonomously. There's no mandatory gate; the cost of a wrong-but-cheap consult is low, so don't over-ask.
|
|
45
45
|
4. **Spawn one subagent per chosen agent** (Claude Code, Codex, OpenClaw, Cursor all support subagents). Run them concurrently and scaffold the project while they work. Each subagent follows the brief below.
|
|
46
46
|
|
|
47
|
-
> No subagents on your host?
|
|
47
|
+
> No subagents on your host? Drive the sessions inline yourself, one at a time, using the same brief, and bank each spec with `agentrelay cache put <slug>` the same way.
|
|
48
48
|
|
|
49
49
|
#### Subagent brief (hand each breadth subagent this)
|
|
50
50
|
|
|
@@ -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.
|
package/bin/lib/deliverable.mjs
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deliverable schema (spec §5) — the structured contract every consult resolves
|
|
3
|
-
* to. Agents are *prompted* to return this JSON (see driver.mjs output
|
|
4
|
-
* contract); this module validates/normalizes whatever comes back so the
|
|
5
|
-
* orchestrator always gets a predictable, compact object. Hand-rolled (no zod)
|
|
6
|
-
* to keep the package zero-dependency. Mirrors schema/deliverable.json.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
function asStringArray(v) {
|
|
10
|
-
if (Array.isArray(v)) return v.filter((x) => typeof x === "string");
|
|
11
|
-
if (typeof v === "string" && v.trim()) return [v.trim()];
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function asObjectArray(v) {
|
|
16
|
-
return Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : [];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** chars/4 token estimate (spec §6 step 7). */
|
|
20
|
-
export function estimateTokens(obj) {
|
|
21
|
-
try {
|
|
22
|
-
return Math.round(JSON.stringify(obj).length / 4);
|
|
23
|
-
} catch {
|
|
24
|
-
return 0;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Normalize a raw object (parsed from the agent's final message) into a
|
|
30
|
-
* Deliverable. `meta` carries CLI-known fields (slug, ids, turns, cached, raw).
|
|
31
|
-
*/
|
|
32
|
-
export function normalizeDeliverable(raw, meta = {}) {
|
|
33
|
-
const r = raw && typeof raw === "object" ? raw : {};
|
|
34
|
-
const d = {
|
|
35
|
-
slug: meta.slug || r.slug || "capability",
|
|
36
|
-
name: meta.name || r.name || meta.slug || "Capability",
|
|
37
|
-
capability_id: meta.capability_id || r.capability_id || null,
|
|
38
|
-
ad_unit_id: meta.ad_unit_id || r.ad_unit_id || null,
|
|
39
|
-
verdict: typeof r.verdict === "string" ? r.verdict : "",
|
|
40
|
-
install: asStringArray(r.install),
|
|
41
|
-
env: asObjectArray(r.env),
|
|
42
|
-
endpoints: asObjectArray(r.endpoints),
|
|
43
|
-
snippets: asObjectArray(r.snippets),
|
|
44
|
-
gotchas: asStringArray(r.gotchas),
|
|
45
|
-
free_tier: typeof r.free_tier === "string" ? r.free_tier : null,
|
|
46
|
-
docs: asStringArray(r.docs),
|
|
47
|
-
turns: meta.turns ?? 0,
|
|
48
|
-
cached: meta.cached ?? false,
|
|
49
|
-
ts: meta.ts || new Date().toISOString(),
|
|
50
|
-
};
|
|
51
|
-
d.tokens_estimate = estimateTokens(d);
|
|
52
|
-
if (meta.raw_excerpt) d.raw_excerpt = meta.raw_excerpt;
|
|
53
|
-
else d.raw_excerpt = null;
|
|
54
|
-
return d;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* True if the object carries enough substance to count as a real deliverable
|
|
59
|
-
* (so we don't cache an empty shell from a turn that only asked a question).
|
|
60
|
-
*/
|
|
61
|
-
export function hasSubstance(d) {
|
|
62
|
-
return Boolean(
|
|
63
|
-
(d.verdict && d.verdict.length > 0) ||
|
|
64
|
-
d.install.length ||
|
|
65
|
-
d.endpoints.length ||
|
|
66
|
-
d.snippets.length ||
|
|
67
|
-
d.gotchas.length ||
|
|
68
|
-
d.docs.length,
|
|
69
|
-
);
|
|
70
|
-
}
|
package/bin/lib/driver.mjs
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Conversation driver (spec §6) — runs ONE agent's full qualify→deliver loop in
|
|
3
|
-
* isolation and returns a compact Deliverable. The agent's raw transcript never
|
|
4
|
-
* leaves this module (only the distilled Deliverable does), which is the whole
|
|
5
|
-
* point: keep transcripts out of the orchestrator's context.
|
|
6
|
-
*
|
|
7
|
-
* Completion detection prefers the backend flags (spec §10:
|
|
8
|
-
* `deliverable_complete` / `awaiting_input`) and falls back to a prose heuristic
|
|
9
|
-
* when an older backend doesn't send them.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { sessionStart, sessionMessage, sessionEnd } from "./api.mjs";
|
|
13
|
-
import { extractJsonObject } from "./parse.mjs";
|
|
14
|
-
import { normalizeDeliverable, hasSubstance } from "./deliverable.mjs";
|
|
15
|
-
|
|
16
|
-
const OFFER_PHRASES = [
|
|
17
|
-
"reply with",
|
|
18
|
-
"let me know",
|
|
19
|
-
"want me to",
|
|
20
|
-
"which would you like",
|
|
21
|
-
"tell me",
|
|
22
|
-
"could you",
|
|
23
|
-
"can you provide",
|
|
24
|
-
"do you want",
|
|
25
|
-
"would you like",
|
|
26
|
-
"please share",
|
|
27
|
-
"please provide",
|
|
28
|
-
"to confirm",
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
/** Does this turn end expecting the user to respond before the agent proceeds? */
|
|
32
|
-
export function turnAwaitsInput(turn) {
|
|
33
|
-
if (turn.deliverable_complete === true) return false; // explicit: done
|
|
34
|
-
if (turn.awaiting_input === true) return true; // explicit: waiting
|
|
35
|
-
if (Array.isArray(turn.pending_fields) && turn.pending_fields.length > 0) return true;
|
|
36
|
-
// Heuristic fallback on prose (spec §6 step 3).
|
|
37
|
-
const msg = String(turn.message || "").trim().toLowerCase();
|
|
38
|
-
if (msg.endsWith("?")) return true;
|
|
39
|
-
return OFFER_PHRASES.some((p) => msg.includes(p));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Decide whether the loop can stop. `hasDeliverable` = we've already parsed a
|
|
44
|
-
* substantive Deliverable from this (or a prior) turn. Pure + unit-tested.
|
|
45
|
-
*/
|
|
46
|
-
export function assessTurn(turn, hasDeliverable) {
|
|
47
|
-
if (turn.deliverable_complete === true) {
|
|
48
|
-
return { complete: true, reason: "flag:deliverable_complete" };
|
|
49
|
-
}
|
|
50
|
-
const awaiting = turnAwaitsInput(turn);
|
|
51
|
-
if (!awaiting && hasDeliverable) {
|
|
52
|
-
return { complete: true, reason: "deliverable+not-awaiting" };
|
|
53
|
-
}
|
|
54
|
-
return { complete: false, reason: awaiting ? "awaiting-input" : "no-deliverable" };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const SCHEMA_LINE =
|
|
58
|
-
"{verdict, install:[], env:[{name,required,desc}], endpoints:[{name,method,url,notes}], " +
|
|
59
|
-
"snippets:[{lang,path,desc,code}], gotchas:[], free_tier, docs:[]}";
|
|
60
|
-
|
|
61
|
-
function buildPrimaryMessage(want, context, deliverNow) {
|
|
62
|
-
const ctx = context ? JSON.stringify(context) : "(none provided)";
|
|
63
|
-
return (
|
|
64
|
-
"You are replying to an automated coding agent, NOT a human — your reply is parsed by a program. " +
|
|
65
|
-
`Deliverable needed: ${want}. ` +
|
|
66
|
-
`Stack/context: ${ctx}. ` +
|
|
67
|
-
"Reply with ONLY one JSON object — no prose, no markdown, no ``` fences — matching this schema: " +
|
|
68
|
-
SCHEMA_LINE +
|
|
69
|
-
". Hard rules: keep the ENTIRE reply under ~450 words; each `snippets[].code` ≤ 25 lines " +
|
|
70
|
-
"(reference the docs URL instead of pasting full multi-file implementations); use real newlines in " +
|
|
71
|
-
"code, never literal \\n; assume sensible defaults for the stack and do NOT ask questions." +
|
|
72
|
-
(deliverNow ? " Deliver the complete JSON now." : "")
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function buildFollowup() {
|
|
77
|
-
return (
|
|
78
|
-
"Return the complete deliverable now as ONE JSON object only — no prose, no markdown, no fences — " +
|
|
79
|
-
"under ~450 words, each snippet ≤ 25 lines. Assume sensible defaults; do not ask anything further."
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Trim a prose message to a short excerpt for --raw / fallback verdict. */
|
|
84
|
-
function excerpt(msg, max = 1200) {
|
|
85
|
-
const s = String(msg || "").trim();
|
|
86
|
-
return s.length > max ? s.slice(0, max) + "…" : s;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Run the full loop for one capability.
|
|
91
|
-
*
|
|
92
|
-
* @param resolved { capabilityId, adUnitId, clearingPriceCents, slug, name }
|
|
93
|
-
* @param opts { want, context, deliverNow, raw, maxTurns, timeoutMs, apiKey }
|
|
94
|
-
* @returns { deliverable, sessionId, turns }
|
|
95
|
-
*/
|
|
96
|
-
export async function consultOne(resolved, opts) {
|
|
97
|
-
const { capabilityId, adUnitId, clearingPriceCents, slug, name } = resolved;
|
|
98
|
-
const { want, context, deliverNow, raw, maxTurns = 6, timeoutMs, apiKey } = opts;
|
|
99
|
-
const meta = { slug, name, capability_id: capabilityId, ad_unit_id: adUnitId };
|
|
100
|
-
|
|
101
|
-
let turns = 0;
|
|
102
|
-
let sessionId = null;
|
|
103
|
-
let bestDeliverable = null;
|
|
104
|
-
let lastMessage = "";
|
|
105
|
-
|
|
106
|
-
const consider = (turn) => {
|
|
107
|
-
const obj = extractJsonObject(turn.message);
|
|
108
|
-
if (obj) {
|
|
109
|
-
const d = normalizeDeliverable(obj, { ...meta, turns });
|
|
110
|
-
if (hasSubstance(d)) bestDeliverable = d;
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// 1. Start the session (context as initial_data so the greeting is relevant).
|
|
115
|
-
const start = await sessionStart(
|
|
116
|
-
{
|
|
117
|
-
capabilityId,
|
|
118
|
-
adUnitId,
|
|
119
|
-
clearingPriceCents,
|
|
120
|
-
initialData: context ? { ...context, task: want } : { task: want },
|
|
121
|
-
},
|
|
122
|
-
{ apiKey, timeoutMs },
|
|
123
|
-
);
|
|
124
|
-
sessionId = start.session_id;
|
|
125
|
-
turns = 1;
|
|
126
|
-
lastMessage = start.message || "";
|
|
127
|
-
consider(start);
|
|
128
|
-
|
|
129
|
-
// 2. Drive: send the want + output contract, then loop until complete.
|
|
130
|
-
let nextMessage = buildPrimaryMessage(want, context, deliverNow);
|
|
131
|
-
try {
|
|
132
|
-
while (turns < maxTurns) {
|
|
133
|
-
const resp = await sessionMessage({ sessionId, message: nextMessage }, { apiKey, timeoutMs });
|
|
134
|
-
turns += 1;
|
|
135
|
-
lastMessage = resp.message || lastMessage;
|
|
136
|
-
consider(resp);
|
|
137
|
-
|
|
138
|
-
const { complete } = assessTurn(resp, Boolean(bestDeliverable));
|
|
139
|
-
if (complete) break;
|
|
140
|
-
nextMessage = buildFollowup();
|
|
141
|
-
}
|
|
142
|
-
} finally {
|
|
143
|
-
// 3. Always release the session (best-effort; spec §6 step 6).
|
|
144
|
-
if (sessionId) {
|
|
145
|
-
try {
|
|
146
|
-
await sessionEnd({ sessionId }, { apiKey, timeoutMs });
|
|
147
|
-
} catch {
|
|
148
|
-
/* non-fatal */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// 4. Finalize. If no structured deliverable surfaced, fall back to a prose
|
|
154
|
-
// excerpt as the verdict (and raw_excerpt when --raw).
|
|
155
|
-
if (!bestDeliverable) {
|
|
156
|
-
bestDeliverable = normalizeDeliverable(
|
|
157
|
-
{ verdict: excerpt(lastMessage, 400) },
|
|
158
|
-
{ ...meta, turns, raw_excerpt: raw ? excerpt(lastMessage) : null },
|
|
159
|
-
);
|
|
160
|
-
} else if (raw) {
|
|
161
|
-
bestDeliverable.raw_excerpt = excerpt(lastMessage);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { deliverable: bestDeliverable, sessionId, turns };
|
|
165
|
-
}
|