agentradar-cli 0.1.9 → 0.2.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/dist/setup.js CHANGED
@@ -1,8 +1,25 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { chmod, lstat, mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
2
- import { basename, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { homedir } from "node:os";
4
6
  import * as tls from "node:tls";
5
7
  const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
8
+ /** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
9
+ const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
10
+ /**
11
+ * R004 — the server's harness id, in the words `supervise-agent.sh` accepts.
12
+ *
13
+ * Two vocabularies, deliberately: the server stores `claude_code` (its internal id, the key of
14
+ * `HARNESS_PROFILES`, and what lands in `adapterConfig.harness`), while the supervisor's
15
+ * `AR_AGENT_HARNESS` takes `claude`. Translating here rather than widening either side keeps the
16
+ * refusal in one place — the supervisor dies by name on an unrecognised value, and so does this.
17
+ */
18
+ const SUPERVISOR_HARNESS = {
19
+ claude: "claude",
20
+ claude_code: "claude",
21
+ codex: "codex",
22
+ };
6
23
  async function postJson(fetchImpl, url, body) {
7
24
  return fetchImpl(url, {
8
25
  method: "POST",
@@ -10,9 +27,35 @@ async function postJson(fetchImpl, url, body) {
10
27
  body: JSON.stringify(body),
11
28
  });
12
29
  }
30
+ /**
31
+ * The server's own words for a refusal, when it sent any.
32
+ *
33
+ * 🔴 THE BODY WAS BEING THROWN AWAY ON THE FAILURE PATH, one function above the `console.error` that
34
+ * would have printed it. `POST /vendor/agent-setups` answers a configuration problem with
35
+ * `{ error: "vendor_setup_not_configured", message: "Vendor setup is unavailable until the public
36
+ * MCP and vendor portal URLs are configured." }` (`server/src/routes/vendor.ts`), and the vendor read
37
+ * `AgentRadar setup request failed (503)` — on the FIRST door they touch in the product.
38
+ *
39
+ * Reads `message` before `error` because this door sends both and only `message` is a sentence;
40
+ * elsewhere the servers' convention is a bare `{ error: <sentence> }`, so both are tried. A non-JSON
41
+ * body (an HTML error page from a proxy, an empty 502) throws in `json()` and falls back to the
42
+ * status line — which is why the parse is inside the try and not beside it.
43
+ */
44
+ async function failureMessage(response) {
45
+ const line = `AgentRadar setup request failed (${response.status})`;
46
+ try {
47
+ const body = await response.json();
48
+ const said = [body?.message, body?.error]
49
+ .find((value) => typeof value === "string" && value.trim().length > 0);
50
+ return said ? `${line}: ${said.trim()}` : line;
51
+ }
52
+ catch {
53
+ return line;
54
+ }
55
+ }
13
56
  async function responseJson(response) {
14
57
  if (!response.ok)
15
- throw new Error(`AgentRadar setup request failed (${response.status})`);
58
+ throw new Error(await failureMessage(response));
16
59
  return response.json();
17
60
  }
18
61
  /**
@@ -59,7 +102,7 @@ export function claudeConfigPath(home, env = process.env) {
59
102
  *
60
103
  * Returns the resolved project path so the caller can name it in the trust guidance.
61
104
  */
62
- function trustedMcpEndpoint(value) {
105
+ export function trustedMcpEndpoint(value) {
63
106
  const endpoint = new URL(value);
64
107
  const loopbackHttp = endpoint.protocol === "http:" && (endpoint.hostname === "localhost" ||
65
108
  endpoint.hostname === "127.0.0.1" ||
@@ -67,9 +110,9 @@ function trustedMcpEndpoint(value) {
67
110
  endpoint.hostname.endsWith(".localhost"));
68
111
  return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
69
112
  }
70
- async function installProjectMcp(configPath, cwd, installation, trust) {
113
+ async function installProjectMcp(configPath, cwd, installation, trust, channelRunnerId) {
71
114
  const endpoint = trustedMcpEndpoint(installation.mcpEndpoint);
72
- if (!endpoint || !/^ar_[A-Za-z0-9_-]+$/.test(installation.bearer)) {
115
+ if (!endpoint || !BEARER_SHAPE.test(installation.bearer)) {
73
116
  throw new Error("AgentRadar returned an invalid setup payload");
74
117
  }
75
118
  const projectPath = await realpath(cwd);
@@ -97,7 +140,11 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
97
140
  const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
98
141
  ? project.mcpServers
99
142
  : {};
100
- mcpServers.agentradar = {
143
+ mcpServers.agentradar = channelRunnerId ? {
144
+ type: "stdio",
145
+ command: process.execPath,
146
+ args: [join(dirname(fileURLToPath(import.meta.url)), "index.js"), "channel", channelRunnerId],
147
+ } : {
101
148
  type: "http",
102
149
  url: endpoint.toString(),
103
150
  headers: { Authorization: `Bearer ${installation.bearer}` },
@@ -117,10 +164,10 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
117
164
  await chmod(configPath, 0o600);
118
165
  return projectPath;
119
166
  }
120
- function shellValue(value) {
167
+ export function shellValue(value) {
121
168
  return `'${value.replaceAll("'", "'\\''")}'`;
122
169
  }
123
- async function writePrivateFile(path, contents) {
170
+ export async function writePrivateFile(path, contents) {
124
171
  try {
125
172
  if ((await lstat(path)).isSymbolicLink())
126
173
  throw new Error(`Refusing to replace a symlinked ${path}`);
@@ -139,7 +186,7 @@ async function installRunner(home, projectPath, approved) {
139
186
  throw new Error("AgentRadar returned an invalid runner ID");
140
187
  }
141
188
  const endpoint = trustedMcpEndpoint(approved.installation.mcpEndpoint);
142
- if (!endpoint || !/^ar_[A-Za-z0-9_-]+$/.test(approved.installation.bearer)) {
189
+ if (!endpoint || !BEARER_SHAPE.test(approved.installation.bearer)) {
143
190
  throw new Error("AgentRadar returned an invalid setup payload");
144
191
  }
145
192
  const agentDir = join(home, ".agentradar", "agents");
@@ -148,6 +195,10 @@ async function installRunner(home, projectPath, approved) {
148
195
  await mkdir(configDir, { recursive: true, mode: 0o700 });
149
196
  await chmod(agentDir, 0o700);
150
197
  await chmod(configDir, 0o700);
198
+ const declared = (approved.harness ?? "").trim().toLowerCase();
199
+ const harness = declared ? SUPERVISOR_HARNESS[declared] : undefined;
200
+ if (declared && !harness)
201
+ throw new Error(`AgentRadar returned a runtime this runner cannot start: ${JSON.stringify(declared)}`);
151
202
  const mcpConfig = join(configDir, "awp-only.json");
152
203
  const envPath = join(agentDir, `${approved.runnerId}.env`);
153
204
  await writePrivateFile(mcpConfig, `${JSON.stringify({
@@ -165,6 +216,10 @@ async function installRunner(home, projectPath, approved) {
165
216
  `AWP_MCP_URL=${shellValue(endpoint.toString())}`,
166
217
  `AWP_BEARER=${shellValue(approved.installation.bearer)}`,
167
218
  `MCP_CONFIG=${shellValue(mcpConfig)}`,
219
+ // Written only when the vendor declared a runtime. An absent variable is the supervisor's own
220
+ // default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
221
+ // every existing env file look as though a choice had been made.
222
+ ...(harness ? [`AR_AGENT_HARNESS=${shellValue(harness)}`] : []),
168
223
  "",
169
224
  ].join("\n"));
170
225
  return envPath;
@@ -183,7 +238,13 @@ export async function setupVendorAgent(deps = {}) {
183
238
  throw new Error("Run setup from the agent project folder");
184
239
  const apiBase = deps.apiUrl ?? process.env.AGENTRADAR_API_URL ?? DEFAULT_API_URL;
185
240
  const setupUrl = new URL("vendor/agent-setups", `${apiBase.replace(/\/+$/, "")}/`);
186
- const started = await responseJson(await postJson(fetchImpl, setupUrl, { name: agentName }));
241
+ const requestedHarness = (deps.harness ?? process.env.AGENTRADAR_AGENT_HARNESS ?? "").trim();
242
+ const started = await responseJson(await postJson(fetchImpl, setupUrl, {
243
+ name: agentName,
244
+ // Sent only when asked for. The server refuses a value it cannot run — by name, in its own words —
245
+ // so an unrecognised `--harness` fails at the first door rather than at `runner start`, hours later.
246
+ ...(requestedHarness ? { harness: requestedHarness } : {}),
247
+ }));
187
248
  if (!/^ars_[A-Za-z0-9_-]+$/.test(started.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(started.userCode)) {
188
249
  throw new Error("AgentRadar returned an invalid setup challenge");
189
250
  }
@@ -191,9 +252,31 @@ export async function setupVendorAgent(deps = {}) {
191
252
  log("Waiting for approval…");
192
253
  const tokenUrl = new URL("vendor/agent-setups/token", `${apiBase.replace(/\/+$/, "")}/`);
193
254
  const expiresAt = Date.now() + started.expiresIn * 1000;
255
+ // 🔴 A DROPPED CONNECTION MUST NOT DISCARD AN APPROVAL A HUMAN ALREADY GAVE. This poll used to be a
256
+ // bare `await` in the loop, so one blip — a laptop sleeping, wifi switching, a proxy hiccup — threw
257
+ // straight out of setup and the vendor had to re-run it and approve a SECOND code while the first sat
258
+ // pending server-side. Only a definitive answer from the server ends this loop now: approved, denied,
259
+ // or expired. A transport failure is retried, on a backoff that grows with consecutive failures.
260
+ //
261
+ // It cannot spin forever, and the bound is not new: `Date.now() < expiresAt` is the code's own
262
+ // ten-minute TTL, which the server sent as `expiresIn`. If that TTL ever grows enough that a retry
263
+ // loop could outlive the code's validity, this needs its own deadline as well.
264
+ let consecutiveFailures = 0;
194
265
  while (Date.now() < expiresAt) {
195
- await sleep(Math.max(1, started.interval) * 1000);
196
- const response = await postJson(fetchImpl, tokenUrl, { deviceCode: started.deviceCode });
266
+ await sleep(Math.max(1, started.interval) * 1000 * Math.min(consecutiveFailures + 1, 5));
267
+ let response;
268
+ try {
269
+ response = await postJson(fetchImpl, tokenUrl, { deviceCode: started.deviceCode });
270
+ }
271
+ catch {
272
+ consecutiveFailures += 1;
273
+ // Said out loud, and naming the HOST rather than the error: a silent retry is indistinguishable
274
+ // from a hang to the person watching, and `fetch` rejects with the two words "fetch failed",
275
+ // which name nothing. The host is the fact worth printing and we already hold it.
276
+ log(`Could not reach ${tokenUrl.host} — the code is still valid, retrying…`);
277
+ continue;
278
+ }
279
+ consecutiveFailures = 0;
197
280
  if (response.status === 202)
198
281
  continue;
199
282
  if (response.status === 410)
@@ -212,10 +295,151 @@ export async function setupVendorAgent(deps = {}) {
212
295
  ? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
213
296
  : `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just this MCP. Re-run with --trust for unattended onboarding.`);
214
297
  log(`Runner installed → ${runnerEnv}`);
298
+ if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
299
+ // Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
300
+ // think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
301
+ // `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
302
+ // a per-agent home the vendor supplies, and the supervisor fails closed without it.
303
+ log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares ONLY the AgentRadar MCP server; the runner refuses to start without it.`);
304
+ }
215
305
  log(`Start it with: agentradar runner start ${approved.runnerId}`);
216
306
  log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
217
307
  return;
218
308
  }
219
309
  throw new Error("AgentRadar setup code expired; run setup again");
220
310
  }
311
+ /**
312
+ * Mode A — the organisation's own agent, on its own compute, joining through an enrolment link.
313
+ *
314
+ * accept → wait for a human to approve → claim the key once → install the same MCP config and runner
315
+ * `setup` installs. The one-time claim secret is kept in a 0600 state file per link, so a re-run
316
+ * resumes the wait (or, once claimed, just starts the runner) instead of filing a second request.
317
+ * The key itself is written only into the two 0600 config files and is never printed.
318
+ */
319
+ export async function joinOrganisation(enrolmentUrl, deps = {}) {
320
+ const fetchImpl = deps.fetch ?? fetch;
321
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
322
+ const log = deps.log ?? console.log;
323
+ const cwd = deps.cwd ?? process.cwd();
324
+ const home = deps.home ?? homedir();
325
+ // Claude Code sets CLAUDECODE in its shell; a machine with both tools installed may export CODEX_HOME too.
326
+ const harness = deps.harness ?? (process.env.CLAUDECODE || !process.env.CODEX_HOME ? "claude" : "codex");
327
+ const startJoined = async (runnerId, firstPoll = false) => {
328
+ if (!firstPoll) {
329
+ log(`Connected. Runner: ${runnerId}. Now, in this same session, poll for your welcome and work: agentradar run ${runnerId} next_event`);
330
+ return 0;
331
+ }
332
+ // The first poll is the same call the agent makes next, so the welcome arrives without a second harness
333
+ // prompt and the agent's next bare poll acknowledges it. The key is already saved: a failed poll only says so.
334
+ log("Connected. Your welcome and first instructions:");
335
+ const { callToolOverCli } = await import("./tool-run.js");
336
+ await callToolOverCli(runnerId, "next_event", undefined, { home, fetch: fetchImpl, log })
337
+ .catch(() => log("Could not fetch them yet."));
338
+ log(`Runner: ${runnerId}. Do what the instructions above say. Then stay listening: make agentradar run ${runnerId} listen the last step of every turn, ${harness === "claude" ? "in the background (run_in_background), and end your turn; you are woken when it exits" : "in the foreground, and wait for it"}. It prints the next client message as soon as one arrives, or a note after about 9 minutes; answer what it printed, then run it again.`);
339
+ return 0;
340
+ };
341
+ let link;
342
+ try {
343
+ link = new URL(enrolmentUrl);
344
+ }
345
+ catch {
346
+ throw new Error("Pass the enrolment link your organisation gave you, e.g. https://…/invite/<token>");
347
+ }
348
+ const token = /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(link.pathname)?.[1];
349
+ if (!token || !trustedMcpEndpoint(link.origin)) {
350
+ throw new Error("That is not an AgentRadar enrolment link (expected https://…/invite/<token>)");
351
+ }
352
+ const api = (path) => new URL(`/api/${path}`, link.origin);
353
+ const mcpEndpoint = process.env.AWP_MCP_URL || api("mcp").toString();
354
+ const joinsDir = join(home, ".agentradar", "joins");
355
+ await mkdir(joinsDir, { recursive: true, mode: 0o700 });
356
+ const statePath = join(joinsDir, `${createHash("sha256").update(token).digest("hex")}.json`);
357
+ let state = null;
358
+ try {
359
+ state = JSON.parse(await readFile(statePath, "utf8"));
360
+ }
361
+ catch (error) {
362
+ if (error.code !== "ENOENT")
363
+ throw error;
364
+ }
365
+ if (state?.agentId) {
366
+ log("This enrolment link is already joined on this machine.");
367
+ const runnerId = `join-${state.requestId}`;
368
+ const env = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
369
+ const { envValue } = await import("./tool-run.js");
370
+ await installProjectMcp(claudeConfigPath(home), cwd, {
371
+ mcpEndpoint: envValue(env, "AWP_MCP_URL") ?? "", bearer: envValue(env, "AWP_BEARER") ?? "",
372
+ }, deps.trust === true, harness === "claude" ? runnerId : undefined);
373
+ return startJoined(runnerId);
374
+ }
375
+ if (!state) {
376
+ const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
377
+ if (!agentName)
378
+ throw new Error("Run join from the agent project folder");
379
+ // Codex has no env var for its model; its config's top-level `model` is the default a session starts on.
380
+ // A codex agent reporting "claude" is guessing (a gateway combo answers as Claude), so the config wins there too.
381
+ const codexModel = async () => !process.env.CODEX_HOME ? undefined : /^model\s*=\s*"([^"\n]+)"/m.exec((await readFile(join(process.env.CODEX_HOME, "config.toml"), "utf8").catch(() => "")).split(/^\[/m)[0])?.[1];
382
+ const model = (!deps.model || deps.model === "unknown" || /claude/i.test(deps.model)) && harness === "codex" ? (await codexModel()) ?? deps.model : deps.model;
383
+ const response = await postJson(fetchImpl, api(`invites/${encodeURIComponent(token)}/accept`), {
384
+ requestType: "agent", agentName, adapterType: "awp_remote",
385
+ agentDefaultsPayload: { harness, ...(model ? { model } : {}) },
386
+ });
387
+ if (!response.ok) {
388
+ throw new Error(response.status === 404
389
+ ? "This join link has expired, was already used, or was withdrawn. Ask your organisation for a new link."
390
+ : `This join link cannot be used. ${await failureMessage(response)}`);
391
+ }
392
+ const accepted = await response.json();
393
+ if (typeof accepted.id !== "string" || typeof accepted.claimSecret !== "string") {
394
+ throw new Error("AgentRadar returned an invalid join response");
395
+ }
396
+ state = { requestId: accepted.id, claimSecret: accepted.claimSecret, ...(typeof accepted.approvalUrl === "string" ? { approvalUrl: accepted.approvalUrl } : {}) };
397
+ await writePrivateFile(statePath, JSON.stringify(state));
398
+ }
399
+ log("Join request sent. Waiting for your organisation to approve it…");
400
+ if (state.approvalUrl)
401
+ log(`Approval link: ${state.approvalUrl}\nSend it to your own human once; they approve after signing in. Never send anything else from this folder.`);
402
+ const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
403
+ // Named off the join request, which exists before the key does, so the server can record it at claim.
404
+ const runnerId = `join-${state.requestId}`;
405
+ for (let poll = 1;; poll++) {
406
+ if (poll > (deps.polls ?? 9)) {
407
+ log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
408
+ return 0;
409
+ }
410
+ let response;
411
+ try {
412
+ response = await postJson(fetchImpl, claimUrl, { claimSecret: state.claimSecret, runnerId });
413
+ }
414
+ catch {
415
+ log(`Could not reach ${claimUrl.host}; retrying…`);
416
+ await sleep(10_000);
417
+ continue;
418
+ }
419
+ if (response.status === 201) {
420
+ const claimed = await response.json();
421
+ if (typeof claimed.token !== "string" || typeof claimed.agentId !== "string") {
422
+ throw new Error("AgentRadar returned an invalid key claim");
423
+ }
424
+ const installation = { mcpEndpoint, bearer: claimed.token };
425
+ const projectPath = await installProjectMcp(claudeConfigPath(home), cwd, installation, deps.trust === true, harness === "claude" ? runnerId : undefined);
426
+ const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId: claimed.agentId, runnerId, installation, harness });
427
+ await writePrivateFile(statePath, JSON.stringify({ ...state, agentId: claimed.agentId }));
428
+ log(`Approved. AgentRadar MCP installed for this project, runner installed → ${runnerEnv}`);
429
+ return startJoined(runnerId, true);
430
+ }
431
+ const said = await failureMessage(response);
432
+ if (response.status === 409 && said.includes("must be approved")) {
433
+ await sleep(10_000);
434
+ continue;
435
+ }
436
+ if (response.status === 409 && said.includes("rejected")) {
437
+ throw new Error("Your organisation declined this join request. Ask it for a new enrolment link.");
438
+ }
439
+ if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
440
+ throw new Error("This enrolment link's key was already claimed. A key is issued once and cannot be claimed again; ask your organisation for a new enrolment link.");
441
+ }
442
+ throw new Error(said);
443
+ }
444
+ }
221
445
  //# sourceMappingURL=setup.js.map
@@ -37,6 +37,8 @@ export async function installVendorSkill(deps = {}) {
37
37
  throw error;
38
38
  });
39
39
  }
40
+ // The runner keeps this skill current from AgentRadar; the marker says the folder is AgentRadar's to replace.
41
+ await writeFile(join(skillDir, ".agentradar-managed"), "Written by AgentRadar; replaced on every sync.\n");
40
42
  (deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
41
43
  }
42
44
  //# sourceMappingURL=skill-install.js.map
@@ -0,0 +1,300 @@
1
+ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ /**
5
+ * One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
6
+ * Nothing here is printed: the file holds the runner's bearer.
7
+ */
8
+ export function envValue(contents, key) {
9
+ const line = contents.split("\n").find((row) => row.startsWith(`${key}=`));
10
+ if (!line)
11
+ return null;
12
+ const raw = line.slice(key.length + 1).trim();
13
+ const quoted = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2;
14
+ return (quoted ? raw.slice(1, -1).replaceAll("'\\''", "'") : raw) || null;
15
+ }
16
+ export async function runServiceToolOverCli(runnerId, toolKey, argumentsJson, deps = {}) {
17
+ const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
18
+ if (!parameters)
19
+ return 1;
20
+ return await callAwpTool(runnerId, "run_service_tool", { toolKey, arguments: parameters, entryPoint: "cli" }, deps);
21
+ }
22
+ /** `agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>']`: any AWP tool from a shell, so a just-joined agent works before its harness reloads MCP tools. */
23
+ export async function callToolOverCli(runnerId, tool, argumentsJson, deps = {}, flags = []) {
24
+ const parameters = argumentsJson === undefined ? {} : parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
25
+ if (!parameters)
26
+ return 1;
27
+ if (flags.length) {
28
+ const typed = await flagArguments(runnerId, tool, flags, deps);
29
+ if (!typed)
30
+ return 1;
31
+ Object.assign(parameters, typed);
32
+ }
33
+ if (tool !== "next_event" || !RUNNER_ID.test(runnerId))
34
+ return await callAwpTool(runnerId, tool, parameters, deps);
35
+ // A bare poll acknowledges the batch the previous poll printed, so the command stays byte-identical and
36
+ // an operator can allow it once. Asking for the next event means the last one was read; a lost or
37
+ // failed poll leaves the file as it was, so delivery stays at-least-once.
38
+ const delivered = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.delivered.json`);
39
+ if (parameters.ackEventIds === undefined) {
40
+ const ids = await readFile(delivered, "utf8").then((text) => JSON.parse(text), () => []);
41
+ if (Array.isArray(ids) && ids.length)
42
+ parameters.ackEventIds = ids;
43
+ }
44
+ let said = "";
45
+ const code = await callAwpTool(runnerId, tool, parameters, { ...deps, log: (text) => { said = text; (deps.log ?? console.log)(text); } });
46
+ if (code !== 0)
47
+ return code;
48
+ let events = [];
49
+ try {
50
+ events = JSON.parse(said).events;
51
+ }
52
+ catch { /* not a batch: nothing to acknowledge */ }
53
+ const ids = Array.isArray(events) ? events.map((event) => event?.eventId).filter((id) => typeof id === "string") : [];
54
+ await writeFile(delivered, JSON.stringify(ids), { mode: 0o600 }).catch(() => undefined);
55
+ return 0;
56
+ }
57
+ /**
58
+ * `agentradar run <runner-id> listen`: bare `next_event` polls (each held by the server, each acking the
59
+ * batch before it) until work, a liveness probe or the welcome arrives, or until the next hold could run
60
+ * past `budgetMs`. The agent runs it as the last step of every turn, so the harness keeps waiting. Nine
61
+ * minutes stays under Claude Code's 10-minute Bash cap; Codex waits on it in 5-minute chunks.
62
+ */
63
+ export async function listenOverCli(runnerId, deps = {}, budgetMs = 540_000) {
64
+ const now = deps.now ?? Date.now;
65
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
66
+ const start = now();
67
+ let longest = 0;
68
+ for (;;) {
69
+ const began = now();
70
+ let said = "";
71
+ const code = await callToolOverCli(runnerId, "next_event", undefined, { ...deps, log: (text) => { said = text; } });
72
+ if (code !== 0)
73
+ return code;
74
+ let batch = null;
75
+ try {
76
+ batch = JSON.parse(said);
77
+ }
78
+ catch { /* not a batch: keep waiting */ }
79
+ // Wake on work, a liveness probe or the welcome; anything else (events: null, an unknown key) waits.
80
+ if ((Array.isArray(batch?.events) && batch.events.length) || batch?.livenessProbe || batch?.welcome) {
81
+ (deps.log ?? console.log)(said);
82
+ return 0;
83
+ }
84
+ // A caller the server does not hold (not yet hired, a run JWT) answers at once; do not spin on it.
85
+ if (now() - began < 10_000)
86
+ await sleep(10_000 - (now() - began));
87
+ longest = Math.max(longest, now() - began);
88
+ if (now() - start + longest > budgetMs) {
89
+ (deps.log ?? console.log)(`No client message in ${Math.round((now() - start) / 60_000)} minutes. Run agentradar run ${runnerId} listen again.`);
90
+ return 0;
91
+ }
92
+ }
93
+ }
94
+ /** Split `<runner-id> <tool> [--args '<json>'] [--<argument> <value> ...]`; null when it is not that shape. */
95
+ export function parseCallArgs(rest) {
96
+ const positional = [];
97
+ const flags = [];
98
+ let json;
99
+ for (let i = 0; i < rest.length; i++) {
100
+ const arg = rest[i];
101
+ if (!arg.startsWith("--")) {
102
+ positional.push(arg);
103
+ continue;
104
+ }
105
+ const value = rest[++i];
106
+ if (value === undefined)
107
+ return null;
108
+ if (arg === "--args")
109
+ json = value;
110
+ else
111
+ flags.push([arg.slice(2), value]);
112
+ }
113
+ return positional.length === 2 ? { runnerId: positional[0], tool: positional[1], json, flags } : null;
114
+ }
115
+ /**
116
+ * `--text "hi" --conversation-id X` as the tool's own arguments, typed by the inputSchema the server
117
+ * advertises, so an agent never types JSON (braces and quotes) into a shell command.
118
+ */
119
+ async function flagArguments(runnerId, tool, flags, deps) {
120
+ const logError = deps.logError ?? console.error;
121
+ const listed = await awpRequest(runnerId, "tools/list", {}, deps);
122
+ if (!listed)
123
+ return null;
124
+ const schema = listed.result?.tools?.find((entry) => entry.name === tool)?.inputSchema?.properties ?? {};
125
+ const out = {};
126
+ const camel = (flag) => flag.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
127
+ for (let [flag, value] of flags) {
128
+ // --body-file page.md: the argument read from a file, so a multi-line page never sits in a quoted
129
+ // shell argument (Claude Code prompts on a newline followed by # inside one).
130
+ if (flag.endsWith("-file") && !(flag in schema) && !(camel(flag) in schema)) {
131
+ const text = await readArgumentFile(value, deps.cwd ?? process.cwd(), deps.home ?? homedir()).catch((error) => error);
132
+ if (text instanceof Error) {
133
+ logError(`--${flag}: ${text.message}`);
134
+ return null;
135
+ }
136
+ [flag, value] = [flag.slice(0, -"-file".length), text];
137
+ }
138
+ // --text is what agents reach for on append_task_log, whose argument is --entry.
139
+ const key = flag in schema ? flag : flag === "text" && "entry" in schema ? "entry" : camel(flag);
140
+ if (!(key in schema)) {
141
+ logError(`${tool} has no argument --${flag}. Its arguments: ${Object.keys(schema).map((name) => `--${name}`).join(" ") || "none"}`);
142
+ return null;
143
+ }
144
+ const type = [schema[key].type].flat()[0];
145
+ const typed = coerce(type === "array" ? [schema[key].items?.type].flat()[0] : type, value);
146
+ if (typed === undefined) {
147
+ logError(`--${flag} must be ${type === "array" ? "a list of " + schema[key].items?.type : "a " + type}`);
148
+ return null;
149
+ }
150
+ // A repeated flag builds a list: --message-ids a --message-ids b.
151
+ out[key] = type === "array" ? [...(out[key] ?? []), typed] : typed;
152
+ }
153
+ return out;
154
+ }
155
+ /**
156
+ * `agentradar run` is always-allowed, so a file flag must not read and upload any file the agent names:
157
+ * only a regular file inside the working folder (after symlinks), and at most 256 KB — and never one in
158
+ * ~/.agentradar, which holds the runner's own key even when the working folder is $HOME.
159
+ * ponytail: a hard link to the key is not caught (same inode, different path); making one is the agent's own act.
160
+ */
161
+ async function readArgumentFile(path, cwd, home) {
162
+ const real = await realpath(resolve(cwd, path)).catch(() => { throw new Error(`cannot read ${path}`); });
163
+ const within = (dir) => { const inside = relative(dir, real); return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); };
164
+ const own = await realpath(join(home, ".agentradar")).catch(() => null);
165
+ if (own && within(own))
166
+ throw new Error(`${path} is refused: a file flag never reads AgentRadar's own folder (~/.agentradar), which holds this agent's key`);
167
+ const info = await stat(real);
168
+ if (!info.isFile() || !within(await realpath(cwd)) || info.size > 256 * 1024) {
169
+ throw new Error(`${path} is refused: a file flag reads only a regular file inside the current folder, up to 256 KB`);
170
+ }
171
+ return await readFile(real, "utf8");
172
+ }
173
+ function coerce(type, value) {
174
+ if (type === "string")
175
+ return value;
176
+ if (type === "number" || type === "integer") {
177
+ const number = value.trim() === "" ? NaN : Number(value);
178
+ return Number.isFinite(number) && (type === "number" || Number.isInteger(number)) ? number : undefined;
179
+ }
180
+ if (type === "boolean")
181
+ return value === "true" ? true : value === "false" ? false : undefined;
182
+ try {
183
+ return JSON.parse(value);
184
+ }
185
+ catch {
186
+ return type === undefined ? value : undefined;
187
+ }
188
+ }
189
+ /**
190
+ * Ruling 49 — `agentradar task create|repeat <runner-id> --args '<json>'`: the agent's own `create_task`
191
+ * or `create_recurring_task`, on the same AWP endpoint with the same key, so who it may assign to is the
192
+ * server's decision exactly as it is over MCP.
193
+ */
194
+ export async function createTaskOverCli(runnerId, recurring, argumentsJson, deps = {}) {
195
+ const parameters = parseArgumentsObject(argumentsJson, deps.logError ?? console.error);
196
+ if (!parameters)
197
+ return 1;
198
+ return await callAwpTool(runnerId, recurring ? "create_recurring_task" : "create_task", parameters, deps);
199
+ }
200
+ function parseArgumentsObject(argumentsJson, logError) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(argumentsJson);
204
+ }
205
+ catch {
206
+ logError("--args must be a JSON object, e.g. --args '{\"title\":\"a real issue\"}'");
207
+ return null;
208
+ }
209
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
210
+ logError("--args must be a JSON object");
211
+ return null;
212
+ }
213
+ return parsed;
214
+ }
215
+ /**
216
+ * R251 — `agentradar tool list <runner-id>`: the server's own `list_service_tools`, printed as it
217
+ * answered. The listing is the policy's preview for this agent, so the CLI computes nothing — a second
218
+ * copy of the decision here could only drift from the one the MCP tool and every real call make.
219
+ */
220
+ export async function listServiceToolsOverCli(runnerId, deps = {}) {
221
+ return await callAwpTool(runnerId, "list_service_tools", {}, deps);
222
+ }
223
+ /** One `tools/call` on the runner's AWP MCP endpoint with the runner's own key; the answer is printed verbatim. */
224
+ export async function callAwpTool(runnerId, name, toolArguments, deps) {
225
+ const log = deps.log ?? console.log;
226
+ const logError = deps.logError ?? console.error;
227
+ const body = await awpRequest(runnerId, "tools/call", { name, arguments: toolArguments }, deps);
228
+ if (!body)
229
+ return 1;
230
+ const text = body.result?.content?.[0]?.text;
231
+ const said = typeof text === "string" ? text : JSON.stringify(body.result ?? {});
232
+ // A refusal is the server's sentence and belongs on stderr with a non-zero exit, or a script that
233
+ // pipes stdout reads "the tool ran" from a body that says it was refused.
234
+ if (body.result?.isError) {
235
+ logError(said);
236
+ return 1;
237
+ }
238
+ log(said);
239
+ return 0;
240
+ }
241
+ const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
242
+ /** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
243
+ async function awpRequest(runnerId, method, params, deps) {
244
+ const logError = deps.logError ?? console.error;
245
+ if (!RUNNER_ID.test(runnerId)) {
246
+ logError("Invalid runner ID");
247
+ return null;
248
+ }
249
+ const envPath = join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`);
250
+ let url;
251
+ let bearer;
252
+ const contents = await readFile(envPath, "utf8").catch(() => null);
253
+ if (contents !== null) {
254
+ url = envValue(contents, "AWP_MCP_URL");
255
+ bearer = envValue(contents, "AWP_BEARER");
256
+ if (!url || !bearer) {
257
+ logError(`${envPath} is missing AWP_MCP_URL or AWP_BEARER. Run agentradar setup again.`);
258
+ return null;
259
+ }
260
+ }
261
+ else {
262
+ // Ruling 41 — a hosted agent has no runner file. Its run already carries the server's origin and its
263
+ // per-run JWT (`PAPERCLIP_API_URL`/`PAPERCLIP_API_KEY`), which the AWP MCP admits for a vendorless
264
+ // hosted agent (`server/src/awp/resolve.ts`). The runner file, when present, still wins.
265
+ const env = deps.env ?? process.env;
266
+ const origin = env.PAPERCLIP_API_URL?.trim().replace(/\/+$/, "");
267
+ bearer = env.PAPERCLIP_API_KEY?.trim() || null;
268
+ if (!origin || !bearer) {
269
+ logError(`No runner named ${runnerId}: ${envPath} does not exist. Run agentradar setup first.`);
270
+ return null;
271
+ }
272
+ url = `${origin}/api/mcp`;
273
+ }
274
+ const response = await (deps.fetch ?? fetch)(url, {
275
+ method: "POST",
276
+ headers: {
277
+ authorization: `Bearer ${bearer}`,
278
+ "content-type": "application/json",
279
+ // Both, because the endpoint answers JSON only when the client says it takes JSON.
280
+ accept: "application/json, text/event-stream",
281
+ },
282
+ body: JSON.stringify({
283
+ jsonrpc: "2.0",
284
+ id: 1,
285
+ method,
286
+ params,
287
+ }),
288
+ });
289
+ if (!response.ok) {
290
+ logError(`AgentRadar refused the tool call (${response.status}).`);
291
+ return null;
292
+ }
293
+ const body = await response.json();
294
+ if (body.error) {
295
+ logError(typeof body.error.message === "string" ? body.error.message : "AgentRadar returned an error");
296
+ return null;
297
+ }
298
+ return body;
299
+ }
300
+ //# sourceMappingURL=tool-run.js.map