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/admin.js +27 -7
- package/dist/agent-guard/scripts/compaction-report.mjs +13 -7
- package/dist/agent-guard/scripts/guard.mjs +9 -7
- package/dist/agent-guard/scripts/model-switch.mjs +1 -1
- package/dist/agent-guard/scripts/rm-guard.mjs +76 -4
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +11 -7
- package/dist/agentradar-vendor/SKILL.md +1 -1
- package/dist/asset-get.js +4 -0
- package/dist/channel.js +128 -34
- package/dist/github.js +209 -0
- package/dist/index.js +31 -11
- package/dist/runner.js +10 -1
- package/dist/setup.js +225 -38
- package/dist/skill-install.js +13 -23
- package/dist/tool-run.js +196 -54
- package/dist/vendor-runner/feeder.mjs +53 -12
- package/dist/vendor-runner/session-frame.mjs +91 -47
- package/dist/vendor-runner/supervisor/supervise-agent.sh +6 -1
- package/package.json +3 -2
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
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
|
|
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
|
|
77
|
-
return statSync(delivered).mtimeMs >=
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
skillSync = setInterval(() => {
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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> ...] [--
|
|
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
|
|
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
|
-
|
|
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> ...] [--
|
|
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)
|