agentradar-cli 0.1.8 → 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/agentradar-vendor/SKILL.md +6 -0
- package/dist/channel.js +150 -0
- package/dist/index.js +133 -10
- package/dist/runner.js +431 -8
- package/dist/setup.js +245 -13
- package/dist/skill-install.js +2 -0
- package/dist/sync.js +89 -0
- package/dist/tool-run.js +300 -0
- package/dist/vendor-runner/feeder.mjs +192 -17
- package/dist/vendor-runner/supervisor/supervise-agent.sh +85 -25
- package/package.json +4 -1
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(
|
|
58
|
+
throw new Error(await failureMessage(response));
|
|
16
59
|
return response.json();
|
|
17
60
|
}
|
|
18
61
|
/**
|
|
@@ -59,9 +102,17 @@ 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
|
-
|
|
63
|
-
const endpoint = new URL(
|
|
64
|
-
|
|
105
|
+
export function trustedMcpEndpoint(value) {
|
|
106
|
+
const endpoint = new URL(value);
|
|
107
|
+
const loopbackHttp = endpoint.protocol === "http:" && (endpoint.hostname === "localhost" ||
|
|
108
|
+
endpoint.hostname === "127.0.0.1" ||
|
|
109
|
+
endpoint.hostname === "[::1]" ||
|
|
110
|
+
endpoint.hostname.endsWith(".localhost"));
|
|
111
|
+
return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
|
|
112
|
+
}
|
|
113
|
+
async function installProjectMcp(configPath, cwd, installation, trust, channelRunnerId) {
|
|
114
|
+
const endpoint = trustedMcpEndpoint(installation.mcpEndpoint);
|
|
115
|
+
if (!endpoint || !BEARER_SHAPE.test(installation.bearer)) {
|
|
65
116
|
throw new Error("AgentRadar returned an invalid setup payload");
|
|
66
117
|
}
|
|
67
118
|
const projectPath = await realpath(cwd);
|
|
@@ -89,7 +140,11 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
|
89
140
|
const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
|
|
90
141
|
? project.mcpServers
|
|
91
142
|
: {};
|
|
92
|
-
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
|
+
} : {
|
|
93
148
|
type: "http",
|
|
94
149
|
url: endpoint.toString(),
|
|
95
150
|
headers: { Authorization: `Bearer ${installation.bearer}` },
|
|
@@ -109,10 +164,10 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
|
109
164
|
await chmod(configPath, 0o600);
|
|
110
165
|
return projectPath;
|
|
111
166
|
}
|
|
112
|
-
function shellValue(value) {
|
|
167
|
+
export function shellValue(value) {
|
|
113
168
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
114
169
|
}
|
|
115
|
-
async function writePrivateFile(path, contents) {
|
|
170
|
+
export async function writePrivateFile(path, contents) {
|
|
116
171
|
try {
|
|
117
172
|
if ((await lstat(path)).isSymbolicLink())
|
|
118
173
|
throw new Error(`Refusing to replace a symlinked ${path}`);
|
|
@@ -130,8 +185,8 @@ async function installRunner(home, projectPath, approved) {
|
|
|
130
185
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
|
|
131
186
|
throw new Error("AgentRadar returned an invalid runner ID");
|
|
132
187
|
}
|
|
133
|
-
const endpoint =
|
|
134
|
-
if (endpoint
|
|
188
|
+
const endpoint = trustedMcpEndpoint(approved.installation.mcpEndpoint);
|
|
189
|
+
if (!endpoint || !BEARER_SHAPE.test(approved.installation.bearer)) {
|
|
135
190
|
throw new Error("AgentRadar returned an invalid setup payload");
|
|
136
191
|
}
|
|
137
192
|
const agentDir = join(home, ".agentradar", "agents");
|
|
@@ -140,6 +195,10 @@ async function installRunner(home, projectPath, approved) {
|
|
|
140
195
|
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
141
196
|
await chmod(agentDir, 0o700);
|
|
142
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)}`);
|
|
143
202
|
const mcpConfig = join(configDir, "awp-only.json");
|
|
144
203
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
145
204
|
await writePrivateFile(mcpConfig, `${JSON.stringify({
|
|
@@ -157,6 +216,10 @@ async function installRunner(home, projectPath, approved) {
|
|
|
157
216
|
`AWP_MCP_URL=${shellValue(endpoint.toString())}`,
|
|
158
217
|
`AWP_BEARER=${shellValue(approved.installation.bearer)}`,
|
|
159
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)}`] : []),
|
|
160
223
|
"",
|
|
161
224
|
].join("\n"));
|
|
162
225
|
return envPath;
|
|
@@ -175,7 +238,13 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
175
238
|
throw new Error("Run setup from the agent project folder");
|
|
176
239
|
const apiBase = deps.apiUrl ?? process.env.AGENTRADAR_API_URL ?? DEFAULT_API_URL;
|
|
177
240
|
const setupUrl = new URL("vendor/agent-setups", `${apiBase.replace(/\/+$/, "")}/`);
|
|
178
|
-
const
|
|
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
|
+
}));
|
|
179
248
|
if (!/^ars_[A-Za-z0-9_-]+$/.test(started.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(started.userCode)) {
|
|
180
249
|
throw new Error("AgentRadar returned an invalid setup challenge");
|
|
181
250
|
}
|
|
@@ -183,9 +252,31 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
183
252
|
log("Waiting for approval…");
|
|
184
253
|
const tokenUrl = new URL("vendor/agent-setups/token", `${apiBase.replace(/\/+$/, "")}/`);
|
|
185
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;
|
|
186
265
|
while (Date.now() < expiresAt) {
|
|
187
|
-
await sleep(Math.max(1, started.interval) * 1000);
|
|
188
|
-
|
|
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;
|
|
189
280
|
if (response.status === 202)
|
|
190
281
|
continue;
|
|
191
282
|
if (response.status === 410)
|
|
@@ -204,10 +295,151 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
204
295
|
? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
|
|
205
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.`);
|
|
206
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
|
+
}
|
|
207
305
|
log(`Start it with: agentradar runner start ${approved.runnerId}`);
|
|
208
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.");
|
|
209
307
|
return;
|
|
210
308
|
}
|
|
211
309
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
212
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
|
+
}
|
|
213
445
|
//# sourceMappingURL=setup.js.map
|
package/dist/skill-install.js
CHANGED
|
@@ -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
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* `agentradar sync [dir]` — fast-forward a git-scoped agent project dir to its remote branch.
|
|
9
|
+
*
|
|
10
|
+
* Safe by construction:
|
|
11
|
+
* - refuses when tracked files are locally MODIFIED (vendor edits are never clobbered);
|
|
12
|
+
* - `checkout -f -B <branch> <remote>/<branch>` rewrites tracked paths only, so untracked runtime
|
|
13
|
+
* state (sessions, .env, caches) survives;
|
|
14
|
+
* - uses the vendor's own git credentials — the CLI never carries any.
|
|
15
|
+
*
|
|
16
|
+
* Config (optional, via `git config`): `agentradar.syncRemote` (default `origin`),
|
|
17
|
+
* `agentradar.syncBranch` (default `main`).
|
|
18
|
+
*/
|
|
19
|
+
export async function syncAgentDir(dir = process.cwd()) {
|
|
20
|
+
const git = (...args) => run("git", ["-C", dir, ...args]);
|
|
21
|
+
try {
|
|
22
|
+
await git("rev-parse", "--git-dir");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
console.error(`Not a git repository: ${dir}`);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const remote = (await git("config", "--get", "agentradar.syncRemote").catch(() => ({ stdout: "origin" }))).stdout.trim() || "origin";
|
|
29
|
+
const branch = (await git("config", "--get", "agentradar.syncBranch").catch(() => ({ stdout: "main" }))).stdout.trim() || "main";
|
|
30
|
+
const dirty = (await git("status", "--porcelain")).stdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.filter((l) => l && !l.startsWith("??"));
|
|
33
|
+
if (dirty.length > 0) {
|
|
34
|
+
console.error(`Refusing to sync: ${dirty.length} tracked file(s) locally modified in ${dir}. Commit, stash, or revert them first.`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
await git("fetch", remote, branch);
|
|
38
|
+
const target = (await git("rev-parse", `${remote}/${branch}`)).stdout.trim();
|
|
39
|
+
const head = (await git("rev-parse", "HEAD").catch(() => ({ stdout: "" }))).stdout.trim();
|
|
40
|
+
if (head === target) {
|
|
41
|
+
console.log(`Up to date at ${target.slice(0, 8)}.`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
await git("checkout", "-f", "-B", branch, `${remote}/${branch}`);
|
|
45
|
+
console.log(`Synced ${dir} to ${remote}/${branch} @ ${target.slice(0, 8)}.`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `agentradar sync install [dir] [--interval <seconds>]` — install a background timer that runs
|
|
50
|
+
* `agentradar sync <dir>` periodically. launchd LaunchAgent on macOS, systemd user timer on Linux.
|
|
51
|
+
*/
|
|
52
|
+
export async function installSyncTimer(dir = process.cwd(), intervalSeconds = 600) {
|
|
53
|
+
const abs = dir;
|
|
54
|
+
const cli = process.argv[1] ?? "agentradar";
|
|
55
|
+
const node = process.execPath;
|
|
56
|
+
if (platform() === "darwin") {
|
|
57
|
+
const label = `com.agentradar.sync.${abs.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(-40)}`;
|
|
58
|
+
const plistDir = join(homedir(), "Library", "LaunchAgents");
|
|
59
|
+
await mkdir(plistDir, { recursive: true });
|
|
60
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
61
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
62
|
+
<plist version="1.0"><dict>
|
|
63
|
+
<key>Label</key><string>${label}</string>
|
|
64
|
+
<key>ProgramArguments</key><array><string>${node}</string><string>${cli}</string><string>sync</string><string>${abs}</string></array>
|
|
65
|
+
<key>StartInterval</key><integer>${intervalSeconds}</integer>
|
|
66
|
+
<key>RunAtLoad</key><true/>
|
|
67
|
+
<key>StandardOutPath</key><string>${join(homedir(), "Library", "Logs", `${label}.log`)}</string>
|
|
68
|
+
<key>StandardErrorPath</key><string>${join(homedir(), "Library", "Logs", `${label}.log`)}</string>
|
|
69
|
+
</dict></plist>
|
|
70
|
+
`;
|
|
71
|
+
const plistPath = join(plistDir, `${label}.plist`);
|
|
72
|
+
await writeFile(plistPath, plist);
|
|
73
|
+
await run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${label}`]).catch(() => undefined);
|
|
74
|
+
await run("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, plistPath]);
|
|
75
|
+
console.log(`Installed launchd timer ${label} (every ${intervalSeconds}s) for ${abs}.`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
// Linux: systemd user service + timer.
|
|
79
|
+
const unit = `agentradar-sync-${abs.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(-40)}`;
|
|
80
|
+
const unitDir = join(homedir(), ".config", "systemd", "user");
|
|
81
|
+
await mkdir(unitDir, { recursive: true });
|
|
82
|
+
await writeFile(join(unitDir, `${unit}.service`), `[Unit]\nDescription=AgentRadar sync ${abs}\n\n[Service]\nType=oneshot\nExecStart=${node} ${cli} sync ${abs}\n`);
|
|
83
|
+
await writeFile(join(unitDir, `${unit}.timer`), `[Unit]\nDescription=AgentRadar sync timer ${abs}\n\n[Timer]\nOnBootSec=60\nOnUnitActiveSec=${intervalSeconds}\n\n[Install]\nWantedBy=timers.target\n`);
|
|
84
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
85
|
+
await run("systemctl", ["--user", "enable", "--now", `${unit}.timer`]);
|
|
86
|
+
console.log(`Installed systemd user timer ${unit} (every ${intervalSeconds}s) for ${abs}.`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=sync.js.map
|