agentradar-cli 0.3.9 → 0.3.11

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/channel.js CHANGED
@@ -6,40 +6,65 @@ import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
- import { awpEndpoint } from "./tool-run.js";
9
+ import { awpEndpoint, envValue } from "./tool-run.js";
10
10
  import { noteCliUpdate } from "./update.js";
11
11
  import { trustedMcpEndpoint } from "./setup.js";
12
12
  import { syncJoinedSkills } from "./skill-install.js";
13
- export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Receive the events with `agentradar run $AR_CLI_RUNNER_ID inbox next`; the next call acknowledges the batch you read. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn. Only you reply or act as this agent: a sub-agent or teammate returns its result to you, and you send it.";
13
+ export const CHANNEL_GUIDANCE = "AgentRadar channel messages carry your new events in full, in order, one per line, each with its own next step: act on each line as it says. Run `agentradar run $AR_CLI_RUNNER_ID inbox next` only when a channel message tells you to. Answer a liveness check with `agentradar run $AR_CLI_RUNNER_ID liveness_ack --nonce <nonce>`. Never acknowledge an event you have not read. Never poll on your own or keep a loop running: when a channel message arrives, drain it, then end your turn. Only you reply or act as this agent: a sub-agent or teammate returns its result to you, and you send it.";
14
14
  // Vadym 16706: what a teammate (sub-agent) reads from the same channel, which never wakes it.
15
15
  export const TEAMMATE_GUIDANCE = "You are a sub-agent of this AgentRadar agent. AgentRadar delivers the agent's messages to its main session, not to you, and refuses any reply, send, approval or task change you make as the agent. Finish your task and return your result to the main session; it replies and acts.";
16
+ export const CHANNEL_WAITING = "AgentRadar: your join request is waiting for your company to approve it. Nothing to run: once they approve, the AgentRadar service claims your key and your welcome arrives here as a channel message. End your turn now.";
16
17
  export const CHANNEL_BLOCKED = "AgentRadar has not seen this session run `agentradar run $AR_CLI_RUNNER_ID inbox next`. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Your company sees this agent as not receiving messages until the channel works.";
17
18
  // The runner's frame text (templates/vendor-runner, shipped beside this file as dist/vendor-runner), so both say a restart one way.
18
19
  const runnerFrame = () => import(new URL(import.meta.url.endsWith(".ts")
19
20
  ? "../../../templates/vendor-runner/session-frame.mjs" : "./vendor-runner/session-frame.mjs", import.meta.url).href);
20
- /** The channel: a local stdio MCP with no tools, only wake signals. The agent acts through the CLI; wake signals never consume the durable queue. */
21
- /** The wake line. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
22
- export function wakeText(runnerId, count, payload) {
21
+ /** The channel: a local stdio MCP with no tools. It types each event into the wake as the feeder did; the agent acts through the CLI. */
22
+ /** The wake line with no events. Zero events never says "run inbox next": that call has nothing to return. Each reason names its own tool. */
23
+ export function wakeText(runnerId, payload) {
23
24
  const run = `agentradar run ${runnerId}`;
24
- if (count)
25
- return `${payload.firstStep ? `${payload.firstStep} ` : ""}${count} new AgentRadar events; run \`${run} inbox next\`${payload.livenessProbe ? " to receive your liveness check" : ""}.`;
26
25
  return [
27
26
  payload.onboarding && `AgentRadar session started, no messages waiting. Run \`${run} get_my_context\` now for your identity, peers and rules.`,
28
27
  payload.livenessProbe && `AgentRadar liveness check, no messages waiting. Run \`${run} liveness_ack --nonce ${payload.livenessProbe.nonce}\` now.`,
29
28
  ].filter(Boolean).join(" ");
30
29
  }
30
+ /**
31
+ * ACK-CHANNEL-EVENT-INLINE: the wake lists every event in order, each in the feeder's own frame (fenced sender text, the read
32
+ * step for a preview, the reply command), so the agent answers without first running `inbox next` (#2749 cost a turn).
33
+ * Claude Code wraps the content in a <channel> tag, so a sender cannot write one to close it early.
34
+ */
35
+ export function eventsText(frame, runnerId, events, payload) {
36
+ const lines = events.map((event, i) => frame.formatInjectionFrame(i === 0 && payload.firstStep ? { ...event, firstStep: payload.firstStep } : event, runnerId));
37
+ if (payload.livenessProbe)
38
+ lines.push(frame.formatProbeFrame(payload.livenessProbe, runnerId));
39
+ // Each frame ends "Then stop." (it was written for one event per wake): only the last line may stop the agent.
40
+ return lines.map((line, i) => i < lines.length - 1 ? line.replace(/ Then stop\.$/, " Then handle the next line.") : line).join("\n").replace(/<(\/?)channel/gi, "‹$1channel");
41
+ }
42
+ let ownHostIsTeammate;
43
+ let psFailed = false;
31
44
  /**
32
45
  * True when this channel runs under a Claude Code teammate (started with --agent-id). Claude Code gives every teammate
33
46
  * pane the session's --mcp-config, so each teammate ran its own channel under a new sessionId: Hormozi's 2 teammates and
34
47
  * his main session flipped the runner session on every poll and starved his queue for hours (10-23).
35
48
  */
36
- export function hostIsTeammate(pid = process.ppid) {
49
+ export function hostIsTeammate(pid) {
50
+ // This process's own host never changes and the walk is up to 8 blocking ps spawns: asked once, not on every request.
51
+ // A walk whose ps failed (a timeout under load) is not remembered, or a false would stick for the process's life.
52
+ if (pid === undefined) {
53
+ if (ownHostIsTeammate !== undefined)
54
+ return ownHostIsTeammate;
55
+ psFailed = false;
56
+ const own = hostIsTeammate(process.ppid);
57
+ if (!psFailed)
58
+ ownHostIsTeammate = own;
59
+ return own;
60
+ }
37
61
  for (let hops = 0; pid > 1 && hops < 8; hops++) {
38
62
  let row;
39
63
  try {
40
64
  row = execFileSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 }).trim();
41
65
  }
42
66
  catch {
67
+ psFailed = true;
43
68
  return false;
44
69
  }
45
70
  const [, parent, args] = row.match(/^(\d+)\s+(.*)$/) ?? [];
@@ -57,11 +82,24 @@ export async function startChannel(runnerId, deps = {}) {
57
82
  const home = deps.home ?? homedir();
58
83
  const output = deps.output ?? process.stdout;
59
84
  const logError = deps.logError ?? console.error;
60
- // The runner file, else a hosted session's own PAPERCLIP_API_* (hosted-session-start.mjs puts them in this server's env).
61
- const target = await awpEndpoint(runnerId, { home, logError });
62
- const endpoint = trustedMcpEndpoint(target?.url ?? "");
63
- const bearer = target?.bearer;
64
- if (!endpoint || !bearer)
85
+ // UAT-17-INPLACE: an in-place session restarted before its company approved the join. The runner env has no key yet;
86
+ // the service writes it there on approval, so the channel connects, says so once, and starts polling when the key lands.
87
+ const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
88
+ const joinState = async () => {
89
+ const saved = await readFile(envPath, "utf8").catch(() => "");
90
+ return envValue(saved, "AWP_BEARER") ? undefined : envValue(saved, "AR_JOIN_STATE") ?? undefined;
91
+ };
92
+ const awaited = await joinState();
93
+ let endpoint = null;
94
+ let bearer;
95
+ const resolveTarget = async () => {
96
+ // The runner file, else a hosted session's own PAPERCLIP_API_* (hosted-session-start.mjs puts them in this server's env).
97
+ const target = await awpEndpoint(runnerId, { home, logError, ...(awaited ? { env: {} } : {}) }); // a joining runner is never hosted
98
+ endpoint = target ? trustedMcpEndpoint(target.url) : null;
99
+ bearer = target?.bearer;
100
+ return Boolean(endpoint && bearer);
101
+ };
102
+ if (!awaited && !await resolveTarget())
65
103
  throw new Error("Runner configuration requires a trusted AWP_MCP_URL and AWP_BEARER");
66
104
  // TRC-RESTART-REASON: the hosted bridge leaves why the last workspace stopped; the first wake with work says it, once.
67
105
  const notice = join(home, ".agentradar", "run", "restart-notice.json");
@@ -71,20 +109,26 @@ export async function startChannel(runnerId, deps = {}) {
71
109
  let polling = false;
72
110
  let agentPolled = false;
73
111
  const startedAt = Date.now();
74
- // The CLI rewrites <runner>.delivered.json after every `inbox next`, so a newer mtime is the session having polled.
112
+ // The CLI rewrites <runner>.delivered.json after every `inbox next` and touches it after every other call, so a newer
113
+ // mtime is the session having acted.
75
114
  const delivered = join(home, ".agentradar", "agents", `${runnerId}.delivered.json`);
76
- const polled = () => agentPolled ||= (() => { try {
77
- return statSync(delivered).mtimeMs >= startedAt;
115
+ const calledSince = (t) => { try {
116
+ return statSync(delivered).mtimeMs >= t;
78
117
  }
79
118
  catch {
80
119
  return false;
81
- } })();
120
+ } };
121
+ const polled = () => agentPolled ||= calledSince(startedAt);
82
122
  // Vadym 10342: an unconfirmed channel is reported to AgentRadar (runnerState channel_unconfirmed), so the owner sees
83
123
  // the agent is not receiving messages; "ready" again once the session does run `inbox next`.
84
124
  let unconfirmed = null;
85
125
  let warning;
86
126
  let skillSync;
87
127
  let teammateNotices;
128
+ // Detached sync and notice passes: the channel drains them on stop, so none still writes into the home dir after it returns.
129
+ const pending = new Set();
130
+ const syncAbort = new AbortController(); // aborts a sync request still hung when the drain bound passes
131
+ const track = (work) => { pending.add(work); void work.finally(() => pending.delete(work)); };
88
132
  const send = (value) => new Promise((resolve, reject) => {
89
133
  output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
90
134
  });
@@ -110,9 +154,9 @@ export async function startChannel(runnerId, deps = {}) {
110
154
  const next = (args = {}) => remote("tools/call", { name: "next_event", arguments: { ...args, sessionId } });
111
155
  // AGENT-GUARD-ACTIVE: the supervisor's marker, written by agent-guard's SessionStart hook in this session.
112
156
  const guardLoaded = () => !process.env.AR_GUARD_MARKER || existsSync(process.env.AR_GUARD_MARKER);
113
- const pause = (until) => new Promise((resolve) => {
157
+ const pause = (ms, until) => new Promise((resolve) => {
114
158
  const done = () => { clearTimeout(timer); clearInterval(watch); stop.signal.removeEventListener("abort", done); resolve(); };
115
- const timer = setTimeout(done, deps.retryMs ?? 3_000);
159
+ const timer = setTimeout(done, ms);
116
160
  // COLD-START-WARMUP: a booting session's work is held until it polls ready, so poll the moment the marker lands.
117
161
  const watch = until && setInterval(() => { if (until())
118
162
  done(); }, 100);
@@ -125,6 +169,18 @@ export async function startChannel(runnerId, deps = {}) {
125
169
  let lastSent = 0;
126
170
  let selftestStarted = false;
127
171
  let guarded = true;
172
+ // Inlined events, acked once the session has made a call after the wake that carried them (the feeder acked on pane receipt).
173
+ let unacked = [];
174
+ // NEXT-EVENT-POLL-BACKOFF: a session that never acts got the same wake re-pushed every 30 s. Each re-push of the SAME
175
+ // unchanged set doubles that gap up to 5 min; a new event, a probe or a session call resets it. The 3 s poll stays, so a
176
+ // new event is pushed at the next poll; the server already parks served-unacked messages (redelivery.ts).
177
+ let resends = 0;
178
+ const resendMs = deps.resendMs ?? 30_000;
179
+ // NEXT-EVENT-POLL-BACKOFF: a failing poll (server down, key refused) retried every 3 s forever. Each consecutive failure
180
+ // doubles the pause up to 20x (60 s), jittered to 50-100% so runners cut off together do not return in step; any poll
181
+ // that gets through resets it.
182
+ const retryMs = deps.retryMs ?? 3_000;
183
+ let failures = 0;
128
184
  while (!stop.signal.aborted) {
129
185
  try {
130
186
  guarded = guardLoaded(); // first, so a call that throws after the marker lands still gets the full pause
@@ -145,33 +201,45 @@ export async function startChannel(runnerId, deps = {}) {
145
201
  if (unconfirmed === "reported" && polled())
146
202
  unconfirmed = null;
147
203
  // While reported, no runnerState: the stored state stands, and the poll still drains for wake signals.
148
- const response = await next(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" });
204
+ // Receipt only: a preview still needs read_message before any reply (server gate). A "starting" poll cannot ack.
205
+ const ack = guarded && unacked.length && calledSince(lastSent) ? unacked : null;
206
+ const response = await next({ ...(unconfirmed ? {} : { runnerState: guarded ? "ready" : "starting" }), ...(ack ? { ackEventIds: ack } : {}) });
149
207
  if (response.error || response.result?.isError)
150
208
  throw new Error("AgentRadar refused next_event; check the runner key and its poll permission");
151
209
  const content = response.result?.content;
152
210
  const payload = JSON.parse(content?.find((item) => item.type === "text")?.text ?? "{}");
211
+ if (ack)
212
+ unacked = [];
153
213
  const events = payload.events ?? [];
154
214
  const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
155
- if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
156
- const restart = events.length ? (await runnerFrame()).restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
215
+ const same = wake === lastWake;
216
+ if (!same || calledSince(lastSent))
217
+ resends = 0;
218
+ if ((events.length || payload.livenessProbe || payload.onboarding) && (!same || Date.now() - lastSent >= Math.min(resendMs * 2 ** resends, resendMs * 10))) {
219
+ const frame = await runnerFrame();
220
+ const restart = events.length ? frame.restartLine(await readFile(notice, "utf8").then(JSON.parse, () => null), runnerId) : "";
157
221
  await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
158
- content: `${restart ? `${restart} ` : ""}${wakeText(runnerId, events.length, payload)}`,
222
+ content: `${restart ? `${restart}\n` : ""}${events.length ? eventsText(frame, runnerId, events, payload) : wakeText(runnerId, payload)}`,
159
223
  meta: { count: String(events.length) },
160
224
  } });
161
225
  if (restart)
162
226
  await rm(notice, { force: true });
227
+ if (same)
228
+ resends += 1;
163
229
  lastWake = wake;
164
230
  lastSent = Date.now();
231
+ unacked = events.flatMap((event) => event.eventId ? [event.eventId] : []);
165
232
  }
166
- // Never ack a count-only wake: only the agent's tool call confirms receipt of event content.
233
+ failures = 0;
167
234
  }
168
235
  catch (error) {
236
+ failures += 1;
169
237
  const said = error.message;
170
238
  // Below the server's minimum CLI the retry cannot succeed: say how to upgrade instead.
171
239
  if (!stop.signal.aborted)
172
240
  logError(/^upgrade required|below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
173
241
  }
174
- await pause(guarded ? undefined : guardLoaded);
242
+ await pause(failures ? Math.min(retryMs * 2 ** failures, retryMs * 20) * (0.5 + Math.random() / 2) : retryMs, guarded ? undefined : guardLoaded);
175
243
  }
176
244
  };
177
245
  let teammate;
@@ -189,6 +257,20 @@ export async function startChannel(runnerId, deps = {}) {
189
257
  // Only the main session polls, so one agent keeps one runner session; a teammate's channel stays connected and silent.
190
258
  if (!polling && !isTeammate()) {
191
259
  polling = true;
260
+ if (awaited) {
261
+ await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: CHANNEL_WAITING, meta: { count: "0" } } });
262
+ while (!stop.signal.aborted && await joinState())
263
+ await pause(deps.keyWaitMs ?? 10_000);
264
+ if (stop.signal.aborted)
265
+ return;
266
+ if (!await resolveTarget()) {
267
+ // The service removed the join (declined, withdrawn, already claimed) and recorded why in the join state.
268
+ const refused = await readFile(awaited, "utf8").then((text) => JSON.parse(text).refused, () => undefined);
269
+ await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { meta: { count: "0" },
270
+ content: `AgentRadar: this join did not complete. ${refused ?? "Its key is not on this machine."} This session receives no AgentRadar messages. Tell your human exactly this, then end your turn.` } });
271
+ return;
272
+ }
273
+ }
192
274
  warning = setTimeout(() => { if (!polled()) {
193
275
  logError(CHANNEL_BLOCKED);
194
276
  unconfirmed = "pending";
@@ -196,19 +278,28 @@ export async function startChannel(runnerId, deps = {}) {
196
278
  void poll();
197
279
  // Vadym 11038: a push-mode session runs no feeder, so the channel keeps its skills and brief current as the feeder
198
280
  // does: at connect and every 10 minutes, dropping skills no longer linked. stdout is the MCP stream; logs go to stderr.
199
- const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError })
281
+ const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError, signal: syncAbort.signal })
200
282
  .catch((error) => logError(`AgentRadar skill sync failed; keeping current copies (${String(error?.message ?? error).split("\n")[0]}).`));
201
- void sync();
202
- skillSync = setInterval(() => { void sync(); }, deps.skillsSyncMs ?? 600_000);
283
+ track(sync());
284
+ skillSync = setInterval(() => { track(sync()); }, deps.skillsSyncMs ?? 600_000);
203
285
  skillSync.unref?.();
204
- // TEAMMATE-IDLE-STOP: the hosted bridge leaves teammate-stopped-<name>.txt for each idle teammate it stopped; each is said once.
286
+ // TEAMMATE-IDLE-STOP: the hosted bridge leaves teammate-stopped-<name>.txt for each idle teammate it stopped or teammate that ended; each is said once.
205
287
  const run = join(home, ".agentradar", "run");
206
- teammateNotices = setInterval(() => void (async () => {
207
- for (const file of readdirSync(run).filter((name) => /^teammate-stopped-.+\.txt$/.test(name))) {
208
- await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: await readFile(join(run, file), "utf8"), meta: { count: "0" } } });
209
- await rm(join(run, file), { force: true });
288
+ let noticing = false; // a slow pass is not started again before it has removed its file, or the notice is said twice
289
+ teammateNotices = setInterval(() => track((async () => {
290
+ if (noticing)
291
+ return;
292
+ noticing = true;
293
+ try {
294
+ for (const file of readdirSync(run).filter((name) => /^teammate-stopped-.+\.txt$/.test(name))) {
295
+ await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: { content: await readFile(join(run, file), "utf8"), meta: { count: "0" } } });
296
+ await rm(join(run, file), { force: true });
297
+ }
298
+ }
299
+ finally {
300
+ noticing = false;
210
301
  }
211
- })().catch(() => { }), deps.teammateNoticeMs ?? 5_000);
302
+ })().catch(() => { })), deps.teammateNoticeMs ?? 5_000);
212
303
  teammateNotices.unref?.();
213
304
  }
214
305
  }
@@ -239,6 +330,9 @@ export async function startChannel(runnerId, deps = {}) {
239
330
  clearTimeout(warning);
240
331
  clearInterval(skillSync);
241
332
  clearInterval(teammateNotices);
333
+ // Bounded wait, so a hung fetch cannot hold the exit; then its request is aborted so it cannot write after we return.
334
+ await Promise.race([Promise.allSettled([...pending]), new Promise((resolve) => setTimeout(resolve, deps.drainMs ?? 2_000).unref())]);
335
+ syncAbort.abort();
242
336
  stop.abort();
243
337
  }
244
338
  }
package/dist/github.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Vadym 18123: an agent someone gave GitHub access uses plain `gh` and `git` as itself, with nothing to set up.
3
+ *
4
+ * setup writes the runner's `gh` shim and a git config naming this CLI as git's credential helper for the agent's
5
+ * GitHub host: the AgentRadar proxy host for a linked account (remotes of the client's orgs are rewritten to it;
6
+ * any other github.com remote stays plain git), else
7
+ * github.com for a GitHub App token. The supervisor runs it at launch and puts both on the agent's PATH and
8
+ * GIT_CONFIG; `gh` and `git` then sign in as the agent.
9
+ * credential git's credential helper: mints a fresh `github_token` for each request and hands it to git on its pipe.
10
+ * gh the shim's target: mints a token and runs the real `gh` with it in the environment. A command whose repositories
11
+ * all belong to someone else (a public repo, an open-source checkout, the agent's own account) is refused, with why,
12
+ * except `gh api`: the proxy serves GET on public repositories of any owner (GH-PROXY-PUBLIC-READS).
13
+ * A token is never written to disk or put on a command line. Without one, gh runs as it would have (the machine's login).
14
+ */
15
+ import { execFile, spawn } from "node:child_process";
16
+ import { constants } from "node:fs";
17
+ import { access, chmod, mkdir, realpath, rename, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { delimiter, join } from "node:path";
20
+ import { awpRequest, RUNNER_ID } from "./tool-run.js";
21
+ export const GITHUB_USAGE = "Usage: agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get";
22
+ const HOST = /^[A-Za-z0-9.-]+(:\d+)?$/;
23
+ const OWNER = /^[A-Za-z0-9-]+$/;
24
+ export const githubDir = (runnerId, deps) => join(deps.home ?? homedir(), ".agentradar", "run", "github", runnerId);
25
+ const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
26
+ /** One `github_token` call with the runner's own key. */
27
+ export async function mintGithub(runnerId, deps) {
28
+ const said = await awpRequest(runnerId, "tools/call", { name: "github_token", arguments: {} }, deps);
29
+ const text = said?.result?.content?.[0]?.text ?? "";
30
+ let body;
31
+ try {
32
+ body = JSON.parse(text);
33
+ }
34
+ catch {
35
+ return { error: text || "AgentRadar did not answer" };
36
+ }
37
+ if (said?.result?.isError || !body.ok || !body.token)
38
+ return { error: body.error ?? text };
39
+ if (body.host !== undefined && !HOST.test(body.host))
40
+ return { error: "AgentRadar returned an invalid GitHub host" };
41
+ const owners = Array.isArray(body.owners) ? body.owners.filter((o) => typeof o === "string" && OWNER.test(o)) : undefined;
42
+ return { token: body.token, host: body.host, owners, ...(typeof body.login === "string" && OWNER.test(body.login) ? { login: body.login } : {}) };
43
+ }
44
+ /** The include file the supervisor points GIT_CONFIG at; written whole and renamed, so git never reads half of it. */
45
+ async function writeGitconfig(runnerId, { host, owners }, bin, deps) {
46
+ const dir = githubDir(runnerId, deps);
47
+ const helper = `!${sq(bin)} github credential ${runnerId}`.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
48
+ // The empty helper drops the machine's own helpers (a keychain login) for this host, so git signs in as the agent.
49
+ const lines = [`[credential "https://${host ?? "github.com"}"]`, "\thelper =", `\thelper = "${helper}"`];
50
+ // Only the client's orgs go through the proxy; a public repo or the vendor's own stays plain git. An older AgentRadar
51
+ // that names no owners gets the old whole-github.com rewrite.
52
+ // ponytail: insteadOf is a case-sensitive prefix; a remote spelling the org in other capitals than GitHub's stays plain git.
53
+ if (host)
54
+ for (const owner of owners ? [...new Set(owners.flatMap((o) => [o, o.toLowerCase()]))].map((o) => `${o}/`) : [""]) {
55
+ lines.push(`[url "https://${host}/${owner}"]`, `\tinsteadOf = https://github.com/${owner}`, `\tinsteadOf = git@github.com:${owner}`, `\tinsteadOf = ssh://git@github.com/${owner}`);
56
+ }
57
+ await mkdir(dir, { recursive: true });
58
+ const tmp = join(dir, `gitconfig.${process.pid}`);
59
+ await writeFile(tmp, lines.join("\n") + "\n", { mode: 0o600 });
60
+ await rename(tmp, join(dir, "gitconfig"));
61
+ }
62
+ export async function githubSetup(runnerId, deps) {
63
+ const logError = deps.logError ?? console.error;
64
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
65
+ if (!bin) {
66
+ logError("AR_AGENTRADAR_BIN is not set.");
67
+ return 1;
68
+ }
69
+ const shim = join(githubDir(runnerId, deps), "bin", "gh");
70
+ await mkdir(join(githubDir(runnerId, deps), "bin"), { recursive: true });
71
+ await writeFile(shim, `#!/bin/sh\nexec ${sq(bin)} github gh ${runnerId} -- "$@"\n`);
72
+ await chmod(shim, 0o755);
73
+ const minted = await mintGithub(runnerId, deps);
74
+ // A failed mint keeps the last config: no GitHub access yet, or a passing outage, is not a reason to drop it.
75
+ if ("error" in minted) {
76
+ logError(`No GitHub token for this agent: ${minted.error}`);
77
+ return 1;
78
+ }
79
+ await writeGitconfig(runnerId, minted, bin, deps);
80
+ return 0;
81
+ }
82
+ /** git's `credential.helper` protocol: answer `get` for the agent's own host, stay silent for any other. */
83
+ export async function githubCredential(runnerId, op, deps) {
84
+ if (op !== "get")
85
+ return 0;
86
+ const input = await (deps.stdin ?? (async () => { const chunks = []; for await (const chunk of process.stdin)
87
+ chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); }))();
88
+ const asked = Object.fromEntries(input.split("\n").filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
89
+ const minted = await mintGithub(runnerId, deps);
90
+ if ("error" in minted) {
91
+ (deps.logError ?? console.error)(`agentradar: no GitHub token for this agent (${minted.error}).`);
92
+ return 0;
93
+ }
94
+ // A link or unlink since launch moves the host: rewrite the config so the next git call goes to the right one.
95
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
96
+ if (bin)
97
+ await writeGitconfig(runnerId, minted, bin, deps).catch(() => { });
98
+ if (asked.protocol === "https" && asked.host === (minted.host ?? "github.com")) {
99
+ (deps.log ?? console.log)(`username=x-access-token\npassword=${minted.token}`);
100
+ }
101
+ return 0;
102
+ }
103
+ /** The real `gh` on PATH, skipping this runner's own shim however PATH spells it (a HOME ending in "/", a symlink). */
104
+ async function realGh(shim, env) {
105
+ const self = await realpath(shim).catch(() => shim);
106
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
107
+ if (!dir || !(await access(join(dir, "gh"), constants.X_OK).then(() => true, () => false)))
108
+ continue;
109
+ if (await realpath(join(dir, "gh")).catch(() => "") !== self)
110
+ return join(dir, "gh");
111
+ }
112
+ return null;
113
+ }
114
+ const IN_URL = /(?:^|[/@])github\.com[/:]([A-Za-z0-9-]+)(?=\/|$)/gim;
115
+ const ownerOf = (value = "") => value.match(new RegExp(IN_URL.source, "i"))?.[1] ?? value.match(/^([A-Za-z0-9-]+)\/[A-Za-z0-9._-]+$/)?.[1];
116
+ /** The repository owners a gh command names: -R/--repo, `gh repo <verb> OWNER/REPO`, github.com URLs, `gh api` repos|orgs|users paths. */
117
+ export function ghTargets(args) {
118
+ return args.flatMap((arg, i) => {
119
+ const named = arg === "-R" || arg === "--repo" ? ownerOf(args[i + 1]) : arg.startsWith("--repo=") ? ownerOf(arg.slice(7))
120
+ : /^-R./.test(arg) ? ownerOf(arg.slice(2)) : new RegExp(IN_URL.source, "i").test(arg) ? ownerOf(arg)
121
+ : args[0] === "api" ? arg.match(/^\/?(?:repos|orgs|users)\/([A-Za-z0-9-]+)/)?.[1]
122
+ : args[0] === "repo" && i === 2 && !arg.startsWith("-") ? ownerOf(arg) : undefined;
123
+ return named ? [named] : [];
124
+ });
125
+ }
126
+ /**
127
+ * The owner gh must not run for, or null for the proxy. Named owners decide; without any, the checkout's remotes do
128
+ * (a client's remote is rewritten to the proxy host). Nothing to go on (no repo, `gh api user`) stays on the proxy.
129
+ */
130
+ async function outsideOwner(args, host, owners, env, cwd) {
131
+ let targets = ghTargets(args);
132
+ if (!targets.length) {
133
+ const remotes = await new Promise((resolve) => execFile("git", ["remote", "-v"], { env, cwd, timeout: 5000 }, (_e, out) => resolve(String(out ?? ""))));
134
+ if (remotes.includes(`://${host}/`))
135
+ return null;
136
+ targets = [...remotes.matchAll(IN_URL)].map((m) => m[1]);
137
+ }
138
+ const client = new Set(owners.map((o) => o.toLowerCase()));
139
+ return targets.length && !targets.some((t) => client.has(t.toLowerCase())) ? targets[0] : null;
140
+ }
141
+ export async function githubGh(runnerId, args, deps) {
142
+ const logError = deps.logError ?? console.error;
143
+ const env = deps.env ?? process.env;
144
+ const gh = await realGh(join(githubDir(runnerId, deps), "bin", "gh"), env);
145
+ if (!gh) {
146
+ logError("agentradar: gh is not installed on this machine.");
147
+ return 127;
148
+ }
149
+ // Already inside the shim (gh running gh, or a PATH the check above still missed): the token is in the environment,
150
+ // so run gh as is and mint nothing. Without this a loop mints a token at every level.
151
+ if (env.AR_GH_SHIMMED === "1")
152
+ return await run(gh, args, env, deps.cwd);
153
+ const minted = await mintGithub(runnerId, deps);
154
+ const childEnv = { ...env, AR_GH_SHIMMED: "1" };
155
+ if ("error" in minted) {
156
+ logError(`agentradar: no GitHub token for this agent (${minted.error}); running gh with this machine's own login.`);
157
+ }
158
+ else {
159
+ if (env.AR_AGENTRADAR_BIN)
160
+ await writeGitconfig(runnerId, minted, env.AR_AGENTRADAR_BIN, deps).catch(() => { });
161
+ const outside = minted.host && minted.owners && args[0] !== "api" ? await outsideOwner(args, minted.host, minted.owners, env, deps.cwd) : null;
162
+ if (outside) {
163
+ // Not run at all: gh signed out refuses every command ("gh auth login", exit 4), and the agent's token reaches only
164
+ // the client's orgs. Anonymous git still clones public repositories.
165
+ const clients = minted.owners.join(", ");
166
+ const reply = "run `agentradar run $AR_CLI_RUNNER_ID reply` in Slack, or `agentradar run $AR_CLI_RUNNER_ID telegram_reply`, `agentradar run $AR_CLI_RUNNER_ID email_reply`, " +
167
+ "`agentradar run $AR_CLI_RUNNER_ID whatsapp_reply` or `agentradar run $AR_CLI_RUNNER_ID discord_reply` for the conversation you work in";
168
+ logError(outside.toLowerCase() === minted.login?.toLowerCase()
169
+ ? `agentradar: ${outside} is your own GitHub account, so gh did not run: repositories under your own account are read-only by design. ` +
170
+ `Read public ones with \`gh api repos/${outside}/<repo>\` or git clone; private ones there are not reachable. Your work belongs in your client's GitHub orgs (${clients}). ` +
171
+ `If you need a repository there, ask your client to create it: ${reply}.`
172
+ : `agentradar: gh works only on your client's GitHub orgs (${clients}), so it did not run for ${outside}. To read a public repository there, ` +
173
+ `use \`gh api repos/${outside}/<repo>\` (reads only) or git clone https://github.com/${outside}/<repo>. If ${outside} is your client's, ask your client to add it: ${reply}.`);
174
+ return 1;
175
+ }
176
+ if (minted.host)
177
+ Object.assign(childEnv, { GH_HOST: minted.host, GH_ENTERPRISE_TOKEN: minted.token });
178
+ else
179
+ childEnv.GH_TOKEN = minted.token;
180
+ }
181
+ return await run(gh, args, childEnv, deps.cwd);
182
+ }
183
+ const run = (bin, args, env, cwd) => new Promise((resolve) => {
184
+ const child = spawn(bin, args, { stdio: "inherit", env, cwd });
185
+ child.on("error", () => resolve(127));
186
+ child.on("exit", (code) => resolve(code ?? 1));
187
+ });
188
+ export async function githubOverCli(args, deps = {}) {
189
+ const [verb, runnerId, ...rest] = args;
190
+ if (!runnerId || !RUNNER_ID.test(runnerId)) {
191
+ (deps.logError ?? console.error)(GITHUB_USAGE);
192
+ return 2;
193
+ }
194
+ try {
195
+ if (verb === "setup" && !rest.length)
196
+ return await githubSetup(runnerId, deps);
197
+ if (verb === "credential" && rest.length === 1)
198
+ return await githubCredential(runnerId, rest[0], deps);
199
+ if (verb === "gh" && rest[0] === "--")
200
+ return await githubGh(runnerId, rest.slice(1), deps);
201
+ }
202
+ catch (error) {
203
+ (deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));
204
+ return 1;
205
+ }
206
+ (deps.logError ?? console.error)(GITHUB_USAGE);
207
+ return 2;
208
+ }
209
+ //# sourceMappingURL=github.js.map
package/dist/index.js CHANGED
@@ -4,14 +4,15 @@ import { homedir } from "node:os";
4
4
  import { installVendorSkill } from "./skill-install.js";
5
5
  import { startChannel } from "./channel.js";
6
6
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
7
- import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
- import { callAwpTool, callToolOverCli, EXIT, parseCallArgs } from "./tool-run.js";
7
+ import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, joinRuntime, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
+ import { callAwpTool, callToolOverCli, EXIT, parseCallArgs, schemaOverCli } from "./tool-run.js";
9
9
  import { installSyncTimer, syncAgentDir } from "./sync.js";
10
10
  import { importVaultOverCli } from "./vault-import.js";
11
11
  import { cloneVaultOverCli } from "./vault-clone.js";
12
12
  import { ASSET_USAGE, getAssetOverCli } from "./asset-get.js";
13
13
  import { secretsOverCli } from "./secrets.js";
14
14
  import { adminOverCli } from "./admin.js";
15
+ import { githubOverCli } from "./github.js";
15
16
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
16
17
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
17
18
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -29,22 +30,24 @@ const USAGE = [
29
30
  " The runner is $AR_CLI_RUNNER_ID, or --runner <id>. --dry-run prints the exact call and sends nothing.",
30
31
  " Exit codes: 0 done; 1 refused or unreachable (the reason is on stderr); 2 the command was wrong.",
31
32
  "Local commands:",
32
- " agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
33
+ " agentradar join <join-link> [--name <n>] [--model <m>] [--runtime claude|codex|other] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
33
34
  " agentradar join cancel <join-link|request-id>",
34
35
  " agentradar setup [--trust] [--harness claude|codex]",
35
36
  " agentradar leave <runner-id>",
36
37
  " agentradar channel <runner-id>",
37
38
  " agentradar selftest <runner-id> [--status|--restart]",
38
39
  " agentradar runner start <runner-id>",
39
- " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>']",
40
+ " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->]",
40
41
  " agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write]",
41
42
  " agentradar vault clone <runner-id> <vault-id> [dir]",
42
43
  " agentradar asset get <runner-id> <asset-id> [file]",
43
44
  " agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command>",
44
45
  " agentradar secrets upload|rotate <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace]",
46
+ " agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get",
45
47
  " agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]",
46
48
  " agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>",
47
- " agentradar upgrade | agentradar --version",
49
+ " agentradar admin notice --dry-run|--send --agent <id> --ref <key> --file <text file> --cta <next step> [--wake]",
50
+ " agentradar schema <command> | agentradar upgrade | agentradar version [--json]",
48
51
  ].join("\n");
49
52
  async function main() {
50
53
  const args = runArgs(process.argv.slice(2));
@@ -80,8 +83,9 @@ async function main() {
80
83
  process.exitCode = await leaveOrganisation(args[1]);
81
84
  return;
82
85
  }
83
- if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
84
- console.log(createRequire(import.meta.url)("../package.json").version);
86
+ if ((args.length === 1 && (args[0] === "--version" || args[0] === "-v")) || (args[0] === "version" && (args.length === 1 || (args.length === 2 && args[1] === "--json")))) {
87
+ const version = createRequire(import.meta.url)("../package.json").version;
88
+ console.log(args[1] === "--json" ? JSON.stringify({ version }) : version);
85
89
  return;
86
90
  }
87
91
  if (args[0] === "channel" && args.length === 2) {
@@ -126,18 +130,25 @@ async function main() {
126
130
  const named = {};
127
131
  const urls = [];
128
132
  for (let i = 1; i < args.length; i++) {
129
- if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
133
+ if (["--name", "--model", "--attach-tmux", "--session", "--runtime"].includes(args[i]))
130
134
  named[args[i].slice(2)] = args[++i] ?? "-";
131
135
  else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder", "--service", "--no-service"].includes(args[i]))
132
136
  urls.push(args[i]);
133
137
  }
134
138
  if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current")) || (args.includes("--service") && args.includes("--no-service"))) {
135
- console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
139
+ console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--runtime <claude|codex|other>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ // --revoke-bypass only edits what an earlier join wrote, so it needs no runtime.
144
+ const runtime = args.includes("--revoke-bypass") ? { harness: undefined } : joinRuntime(named.runtime, process.env);
145
+ if ("refusal" in runtime) {
146
+ console.error(runtime.refusal);
136
147
  process.exitCode = 1;
137
148
  return;
138
149
  }
139
150
  process.exitCode = await joinOrganisation(urls[0], {
140
- trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
151
+ harness: runtime.harness, trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
141
152
  attach: args.includes("--attach-current") ? "current" : named["attach-tmux"],
142
153
  revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"), service: args.includes("--service"),
143
154
  });
@@ -157,7 +168,7 @@ async function main() {
157
168
  const parsed = parseCallArgs(args.slice(1));
158
169
  const [runnerId, ...words] = parsed?.words ?? [];
159
170
  if (!parsed || !runnerId || words.length > 3 || (!words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
160
- console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>'] | agentradar call <runner-id> [<command>] --help");
171
+ console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->] | agentradar call <runner-id> [<command>] --help");
161
172
  process.exitCode = EXIT.usage;
162
173
  return;
163
174
  }
@@ -198,6 +209,11 @@ async function main() {
198
209
  process.exitCode = await secretsOverCli(args.slice(1));
199
210
  return;
200
211
  }
212
+ // Vadym 18123: the supervisor's launch-time setup, the `gh` shim and git's credential helper; `github <server command>` stays the server's.
213
+ if (args[0] === "github" && ["setup", "credential", "gh"].includes(args[1] ?? "") && args[2] && !args[2].startsWith("-")) {
214
+ process.exitCode = await githubOverCli(args.slice(1));
215
+ return;
216
+ }
201
217
  if (args[0] === "admin") {
202
218
  process.exitCode = await adminOverCli(args.slice(1));
203
219
  return;
@@ -227,6 +243,10 @@ async function main() {
227
243
  // #1871: every other command is the server's, `<noun> [<sub-noun>] <verb>`, run as this shell's runner.
228
244
  const parsed = parseCallArgs(args);
229
245
  const runner = parsed?.flags.find(([flag]) => flag === "runner")?.[1] ?? process.env.AR_CLI_RUNNER_ID;
246
+ if (parsed && runner && parsed.words[0] === "schema" && parsed.words.length > 1 && parsed.words.length <= 4) {
247
+ process.exitCode = await schemaOverCli(runner, parsed.words.slice(1).join(" "));
248
+ return;
249
+ }
230
250
  if (!parsed || !runner || parsed.words.length > 3 || (!parsed.words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
231
251
  console.error(USAGE);
232
252
  if (parsed && !runner)