agentradar-cli 0.1.9 → 0.2.1
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 +113 -10
- package/dist/runner.js +434 -8
- package/dist/setup.js +360 -20
- package/dist/skill-install.js +2 -0
- package/dist/tool-run.js +300 -0
- package/dist/vendor-runner/feeder.mjs +237 -30
- package/dist/vendor-runner/session-frame.mjs +37 -3
- package/dist/vendor-runner/supervisor/agentradar-agent@.service.template +31 -0
- package/dist/vendor-runner/supervisor/com.agentradar.agent.plist.template +62 -0
- package/dist/vendor-runner/supervisor/install-agent.sh +94 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +138 -37
- package/package.json +4 -2
package/dist/setup.js
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { homedir } from "node:os";
|
|
4
7
|
import * as tls from "node:tls";
|
|
8
|
+
import { promisify } from "node:util";
|
|
5
9
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
10
|
+
/** `ar_` is a vendor runner bearer; `pcp_` is the key an organisation's own agent claims through `join`. */
|
|
11
|
+
const BEARER_SHAPE = /^(?:ar|pcp)_[A-Za-z0-9_-]+$/;
|
|
12
|
+
/**
|
|
13
|
+
* R004 — the server's harness id, in the words `supervise-agent.sh` accepts.
|
|
14
|
+
*
|
|
15
|
+
* Two vocabularies, deliberately: the server stores `claude_code` (its internal id, the key of
|
|
16
|
+
* `HARNESS_PROFILES`, and what lands in `adapterConfig.harness`), while the supervisor's
|
|
17
|
+
* `AR_AGENT_HARNESS` takes `claude`. Translating here rather than widening either side keeps the
|
|
18
|
+
* refusal in one place — the supervisor dies by name on an unrecognised value, and so does this.
|
|
19
|
+
*/
|
|
20
|
+
const SUPERVISOR_HARNESS = {
|
|
21
|
+
claude: "claude",
|
|
22
|
+
claude_code: "claude",
|
|
23
|
+
codex: "codex",
|
|
24
|
+
};
|
|
6
25
|
async function postJson(fetchImpl, url, body) {
|
|
7
26
|
return fetchImpl(url, {
|
|
8
27
|
method: "POST",
|
|
@@ -10,9 +29,35 @@ async function postJson(fetchImpl, url, body) {
|
|
|
10
29
|
body: JSON.stringify(body),
|
|
11
30
|
});
|
|
12
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The server's own words for a refusal, when it sent any.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 THE BODY WAS BEING THROWN AWAY ON THE FAILURE PATH, one function above the `console.error` that
|
|
36
|
+
* would have printed it. `POST /vendor/agent-setups` answers a configuration problem with
|
|
37
|
+
* `{ error: "vendor_setup_not_configured", message: "Vendor setup is unavailable until the public
|
|
38
|
+
* MCP and vendor portal URLs are configured." }` (`server/src/routes/vendor.ts`), and the vendor read
|
|
39
|
+
* `AgentRadar setup request failed (503)` — on the FIRST door they touch in the product.
|
|
40
|
+
*
|
|
41
|
+
* Reads `message` before `error` because this door sends both and only `message` is a sentence;
|
|
42
|
+
* elsewhere the servers' convention is a bare `{ error: <sentence> }`, so both are tried. A non-JSON
|
|
43
|
+
* body (an HTML error page from a proxy, an empty 502) throws in `json()` and falls back to the
|
|
44
|
+
* status line — which is why the parse is inside the try and not beside it.
|
|
45
|
+
*/
|
|
46
|
+
async function failureMessage(response) {
|
|
47
|
+
const line = `AgentRadar setup request failed (${response.status})`;
|
|
48
|
+
try {
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
const said = [body?.message, body?.error]
|
|
51
|
+
.find((value) => typeof value === "string" && value.trim().length > 0);
|
|
52
|
+
return said ? `${line}: ${said.trim()}` : line;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return line;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
13
58
|
async function responseJson(response) {
|
|
14
59
|
if (!response.ok)
|
|
15
|
-
throw new Error(
|
|
60
|
+
throw new Error(await failureMessage(response));
|
|
16
61
|
return response.json();
|
|
17
62
|
}
|
|
18
63
|
/**
|
|
@@ -59,7 +104,7 @@ export function claudeConfigPath(home, env = process.env) {
|
|
|
59
104
|
*
|
|
60
105
|
* Returns the resolved project path so the caller can name it in the trust guidance.
|
|
61
106
|
*/
|
|
62
|
-
function trustedMcpEndpoint(value) {
|
|
107
|
+
export function trustedMcpEndpoint(value) {
|
|
63
108
|
const endpoint = new URL(value);
|
|
64
109
|
const loopbackHttp = endpoint.protocol === "http:" && (endpoint.hostname === "localhost" ||
|
|
65
110
|
endpoint.hostname === "127.0.0.1" ||
|
|
@@ -67,9 +112,10 @@ function trustedMcpEndpoint(value) {
|
|
|
67
112
|
endpoint.hostname.endsWith(".localhost"));
|
|
68
113
|
return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
|
|
69
114
|
}
|
|
115
|
+
/** `installation: null` (join) removes the project's `agentradar` entry: a joined agent acts through `agentradar run`. */
|
|
70
116
|
async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
71
|
-
const endpoint = trustedMcpEndpoint(installation.mcpEndpoint);
|
|
72
|
-
if (!endpoint ||
|
|
117
|
+
const endpoint = installation && trustedMcpEndpoint(installation.mcpEndpoint);
|
|
118
|
+
if (installation && (!endpoint || !BEARER_SHAPE.test(installation.bearer))) {
|
|
73
119
|
throw new Error("AgentRadar returned an invalid setup payload");
|
|
74
120
|
}
|
|
75
121
|
const projectPath = await realpath(cwd);
|
|
@@ -97,12 +143,17 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
|
97
143
|
const mcpServers = project.mcpServers && typeof project.mcpServers === "object" && !Array.isArray(project.mcpServers)
|
|
98
144
|
? project.mcpServers
|
|
99
145
|
: {};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
146
|
+
if (installation && endpoint) {
|
|
147
|
+
mcpServers.agentradar = {
|
|
148
|
+
type: "http",
|
|
149
|
+
url: endpoint.toString(),
|
|
150
|
+
headers: { Authorization: `Bearer ${installation.bearer}` },
|
|
151
|
+
timeout: 1_800_000,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
delete mcpServers.agentradar;
|
|
156
|
+
}
|
|
106
157
|
project.mcpServers = mcpServers;
|
|
107
158
|
// SET only, never clear. A later plain `setup` must not revoke a decision an earlier `--trust`
|
|
108
159
|
// run made — that would silently regress a re-onboarding vendor back into the launch prompt, and
|
|
@@ -117,10 +168,10 @@ async function installProjectMcp(configPath, cwd, installation, trust) {
|
|
|
117
168
|
await chmod(configPath, 0o600);
|
|
118
169
|
return projectPath;
|
|
119
170
|
}
|
|
120
|
-
function shellValue(value) {
|
|
171
|
+
export function shellValue(value) {
|
|
121
172
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
122
173
|
}
|
|
123
|
-
async function writePrivateFile(path, contents) {
|
|
174
|
+
export async function writePrivateFile(path, contents) {
|
|
124
175
|
try {
|
|
125
176
|
if ((await lstat(path)).isSymbolicLink())
|
|
126
177
|
throw new Error(`Refusing to replace a symlinked ${path}`);
|
|
@@ -134,12 +185,49 @@ async function writePrivateFile(path, contents) {
|
|
|
134
185
|
await rename(tempPath, path);
|
|
135
186
|
await chmod(path, 0o600);
|
|
136
187
|
}
|
|
137
|
-
|
|
188
|
+
/** The gateway env a session needs to reach its model. Never ANTHROPIC_API_KEY (OAuth or gateway only). */
|
|
189
|
+
const TOOL_ENV = /^(?:ANTHROPIC_(?!API_KEY$)[A-Z0-9_]+|CLAUDE_SECURESTORAGE_CONFIG_DIR)$/;
|
|
190
|
+
/**
|
|
191
|
+
* The service's Codex home: the vendor's config.toml without its MCP servers (the agent acts through the CLI,
|
|
192
|
+
* and Codex has no `--strict-mcp-config`), its approval rules copied, its login linked. auth.json is linked,
|
|
193
|
+
* never read. Returns the home and the env var names its model providers read their key from.
|
|
194
|
+
* ponytail: a Codex token refresh that rewrites auth.json by rename replaces the link; re-run join if it bites.
|
|
195
|
+
*/
|
|
196
|
+
async function codexServiceHome(home, runnerId, env) {
|
|
197
|
+
const source = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(home, ".codex");
|
|
198
|
+
const codexHome = join(home, ".agentradar", "codex", runnerId);
|
|
199
|
+
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
|
200
|
+
await chmod(codexHome, 0o700);
|
|
201
|
+
let inMcp = false;
|
|
202
|
+
let inTable = false;
|
|
203
|
+
// A restart would otherwise stop on Codex's "Update available" screen, with nobody there to press a key.
|
|
204
|
+
const config = ["check_for_update_on_startup = false", ...(await readFile(join(source, "config.toml"), "utf8").catch(() => "")).split("\n").filter((line) => {
|
|
205
|
+
const header = /^\s*\[\[?\s*([^\]]+)\]/.exec(line);
|
|
206
|
+
if (header) {
|
|
207
|
+
inMcp = /^"?mcp_servers\b/.test(header[1]);
|
|
208
|
+
inTable = true;
|
|
209
|
+
}
|
|
210
|
+
return !inMcp && !/^\s*mcp_servers\s*[.=]/.test(line) && (inTable || !/^\s*check_for_update_on_startup\s*=/.test(line));
|
|
211
|
+
})].join("\n");
|
|
212
|
+
await writePrivateFile(join(codexHome, "config.toml"), config);
|
|
213
|
+
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
214
|
+
if (error.code !== "ENOENT")
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
217
|
+
if (await lstat(join(source, "auth.json")).then(() => true, () => false)) {
|
|
218
|
+
await symlink(join(source, "auth.json"), join(codexHome, "auth.json")).catch((error) => {
|
|
219
|
+
if (error.code !== "EEXIST")
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
|
|
224
|
+
}
|
|
225
|
+
async function installRunner(home, projectPath, approved, service) {
|
|
138
226
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(approved.runnerId)) {
|
|
139
227
|
throw new Error("AgentRadar returned an invalid runner ID");
|
|
140
228
|
}
|
|
141
229
|
const endpoint = trustedMcpEndpoint(approved.installation.mcpEndpoint);
|
|
142
|
-
if (!endpoint ||
|
|
230
|
+
if (!endpoint || !BEARER_SHAPE.test(approved.installation.bearer)) {
|
|
143
231
|
throw new Error("AgentRadar returned an invalid setup payload");
|
|
144
232
|
}
|
|
145
233
|
const agentDir = join(home, ".agentradar", "agents");
|
|
@@ -148,9 +236,37 @@ async function installRunner(home, projectPath, approved) {
|
|
|
148
236
|
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
149
237
|
await chmod(agentDir, 0o700);
|
|
150
238
|
await chmod(configDir, 0o700);
|
|
239
|
+
const declared = (approved.harness ?? "").trim().toLowerCase();
|
|
240
|
+
const harness = declared ? SUPERVISOR_HARNESS[declared] : undefined;
|
|
241
|
+
if (declared && !harness)
|
|
242
|
+
throw new Error(`AgentRadar returned a runtime this runner cannot start: ${JSON.stringify(declared)}`);
|
|
151
243
|
const mcpConfig = join(configDir, "awp-only.json");
|
|
152
244
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
153
|
-
|
|
245
|
+
const joined = [];
|
|
246
|
+
if (service) {
|
|
247
|
+
joined.push(`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`);
|
|
248
|
+
let toolKeys = [];
|
|
249
|
+
if (harness === "codex") {
|
|
250
|
+
const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
|
|
251
|
+
joined.push(`CODEX_HOME=${shellValue(codexHome)}`);
|
|
252
|
+
toolKeys = keys;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
joined.push(`AR_RESUME_FLAGS=${shellValue(`--resume ${service.sessionId}`)}`, `AR_FRESH_FLAGS=${shellValue(`--session-id ${service.sessionId}`)}`);
|
|
256
|
+
const configDirEnv = service.env.CLAUDE_CONFIG_DIR?.trim();
|
|
257
|
+
if (configDirEnv)
|
|
258
|
+
joined.push(`CLAUDE_CONFIG_DIR=${shellValue(resolve(configDirEnv))}`);
|
|
259
|
+
}
|
|
260
|
+
const toolEnv = Object.entries(service.env)
|
|
261
|
+
.filter(([key, value]) => (TOOL_ENV.test(key) || toolKeys.includes(key)) && key !== "ANTHROPIC_API_KEY" && value && !value.includes("\n"))
|
|
262
|
+
.map(([key, value]) => `${key}=${value}`);
|
|
263
|
+
if (toolEnv.length) {
|
|
264
|
+
const toolEnvFile = join(configDir, "tool.env");
|
|
265
|
+
await writePrivateFile(toolEnvFile, `${toolEnv.join("\n")}\n`);
|
|
266
|
+
joined.push(`AGENT_TOOL_ENV_FILE=${shellValue(toolEnvFile)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
await writePrivateFile(mcpConfig, `${JSON.stringify(service ? { mcpServers: {} } : {
|
|
154
270
|
mcpServers: {
|
|
155
271
|
agentradar: {
|
|
156
272
|
type: "http",
|
|
@@ -165,6 +281,11 @@ async function installRunner(home, projectPath, approved) {
|
|
|
165
281
|
`AWP_MCP_URL=${shellValue(endpoint.toString())}`,
|
|
166
282
|
`AWP_BEARER=${shellValue(approved.installation.bearer)}`,
|
|
167
283
|
`MCP_CONFIG=${shellValue(mcpConfig)}`,
|
|
284
|
+
// Written only when the vendor declared a runtime. An absent variable is the supervisor's own
|
|
285
|
+
// default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
|
|
286
|
+
// every existing env file look as though a choice had been made.
|
|
287
|
+
...(harness ? [`AR_AGENT_HARNESS=${shellValue(harness)}`] : []),
|
|
288
|
+
...joined,
|
|
168
289
|
"",
|
|
169
290
|
].join("\n"));
|
|
170
291
|
return envPath;
|
|
@@ -183,7 +304,13 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
183
304
|
throw new Error("Run setup from the agent project folder");
|
|
184
305
|
const apiBase = deps.apiUrl ?? process.env.AGENTRADAR_API_URL ?? DEFAULT_API_URL;
|
|
185
306
|
const setupUrl = new URL("vendor/agent-setups", `${apiBase.replace(/\/+$/, "")}/`);
|
|
186
|
-
const
|
|
307
|
+
const requestedHarness = (deps.harness ?? process.env.AGENTRADAR_AGENT_HARNESS ?? "").trim();
|
|
308
|
+
const started = await responseJson(await postJson(fetchImpl, setupUrl, {
|
|
309
|
+
name: agentName,
|
|
310
|
+
// Sent only when asked for. The server refuses a value it cannot run — by name, in its own words —
|
|
311
|
+
// so an unrecognised `--harness` fails at the first door rather than at `runner start`, hours later.
|
|
312
|
+
...(requestedHarness ? { harness: requestedHarness } : {}),
|
|
313
|
+
}));
|
|
187
314
|
if (!/^ars_[A-Za-z0-9_-]+$/.test(started.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(started.userCode)) {
|
|
188
315
|
throw new Error("AgentRadar returned an invalid setup challenge");
|
|
189
316
|
}
|
|
@@ -191,9 +318,31 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
191
318
|
log("Waiting for approval…");
|
|
192
319
|
const tokenUrl = new URL("vendor/agent-setups/token", `${apiBase.replace(/\/+$/, "")}/`);
|
|
193
320
|
const expiresAt = Date.now() + started.expiresIn * 1000;
|
|
321
|
+
// 🔴 A DROPPED CONNECTION MUST NOT DISCARD AN APPROVAL A HUMAN ALREADY GAVE. This poll used to be a
|
|
322
|
+
// bare `await` in the loop, so one blip — a laptop sleeping, wifi switching, a proxy hiccup — threw
|
|
323
|
+
// straight out of setup and the vendor had to re-run it and approve a SECOND code while the first sat
|
|
324
|
+
// pending server-side. Only a definitive answer from the server ends this loop now: approved, denied,
|
|
325
|
+
// or expired. A transport failure is retried, on a backoff that grows with consecutive failures.
|
|
326
|
+
//
|
|
327
|
+
// It cannot spin forever, and the bound is not new: `Date.now() < expiresAt` is the code's own
|
|
328
|
+
// ten-minute TTL, which the server sent as `expiresIn`. If that TTL ever grows enough that a retry
|
|
329
|
+
// loop could outlive the code's validity, this needs its own deadline as well.
|
|
330
|
+
let consecutiveFailures = 0;
|
|
194
331
|
while (Date.now() < expiresAt) {
|
|
195
|
-
await sleep(Math.max(1, started.interval) * 1000);
|
|
196
|
-
|
|
332
|
+
await sleep(Math.max(1, started.interval) * 1000 * Math.min(consecutiveFailures + 1, 5));
|
|
333
|
+
let response;
|
|
334
|
+
try {
|
|
335
|
+
response = await postJson(fetchImpl, tokenUrl, { deviceCode: started.deviceCode });
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
consecutiveFailures += 1;
|
|
339
|
+
// Said out loud, and naming the HOST rather than the error: a silent retry is indistinguishable
|
|
340
|
+
// from a hang to the person watching, and `fetch` rejects with the two words "fetch failed",
|
|
341
|
+
// which name nothing. The host is the fact worth printing and we already hold it.
|
|
342
|
+
log(`Could not reach ${tokenUrl.host} — the code is still valid, retrying…`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
consecutiveFailures = 0;
|
|
197
346
|
if (response.status === 202)
|
|
198
347
|
continue;
|
|
199
348
|
if (response.status === 410)
|
|
@@ -212,10 +361,201 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
212
361
|
? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
|
|
213
362
|
: `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just this MCP. Re-run with --trust for unattended onboarding.`);
|
|
214
363
|
log(`Runner installed → ${runnerEnv}`);
|
|
364
|
+
if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
|
|
365
|
+
// Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
|
|
366
|
+
// think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
|
|
367
|
+
// `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
|
|
368
|
+
// a per-agent home the vendor supplies, and the supervisor fails closed without it.
|
|
369
|
+
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.`);
|
|
370
|
+
}
|
|
215
371
|
log(`Start it with: agentradar runner start ${approved.runnerId}`);
|
|
216
372
|
log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
|
|
217
373
|
return;
|
|
218
374
|
}
|
|
219
375
|
throw new Error("AgentRadar setup code expired; run setup again");
|
|
220
376
|
}
|
|
377
|
+
/** `install-agent.sh <id> --load` with the CLI that is running now, so the service does not depend on PATH. */
|
|
378
|
+
async function installAgentService(runnerId, home, env) {
|
|
379
|
+
const dist = dirname(fileURLToPath(import.meta.url));
|
|
380
|
+
await promisify(execFile)("bash", [join(dist, "vendor-runner", "supervisor", "install-agent.sh"), runnerId, "--load"], {
|
|
381
|
+
env: { ...env, HOME: home, NODE_BIN: process.execPath, AGENTRADAR_JS: join(dist, "index.js") },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Mode A — the organisation's own agent, on its own compute, joining through an enrolment link.
|
|
386
|
+
*
|
|
387
|
+
* accept → wait for a human to approve → claim the key once → install the runner and start it as a service
|
|
388
|
+
* (LaunchAgent / systemd user unit) that owns the agent's persistent session. The one-time claim secret is
|
|
389
|
+
* kept in a 0600 state file per link, so a re-run resumes the wait (or, once claimed, reinstalls and
|
|
390
|
+
* restarts the service) instead of filing a second request. The key is written only into the 0600 runner
|
|
391
|
+
* env file and is never printed.
|
|
392
|
+
*/
|
|
393
|
+
export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
394
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
395
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
396
|
+
const log = deps.log ?? console.log;
|
|
397
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
398
|
+
const home = deps.home ?? homedir();
|
|
399
|
+
const env = deps.env ?? process.env;
|
|
400
|
+
// Claude Code sets CLAUDECODE in its shell; a machine with both tools installed may export CODEX_HOME too.
|
|
401
|
+
const harness = deps.harness ?? (env.CLAUDECODE || !env.CODEX_HOME ? "claude" : "codex");
|
|
402
|
+
// The agent is a persistent session the service owns, not this one: no MCP entry in this folder (it acts
|
|
403
|
+
// through `agentradar run`), a runner env pinned to its own session, and a LaunchAgent / systemd unit.
|
|
404
|
+
const startJoined = async (requestId, agentId, installation, saved) => {
|
|
405
|
+
const runnerId = `join-${requestId}`;
|
|
406
|
+
// The service's Claude session id: a valid UUID derived from the request, so a re-run resumes the same one.
|
|
407
|
+
const hex = createHash("sha256").update(requestId).digest("hex");
|
|
408
|
+
const sessionId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
409
|
+
const projectPath = harness === "claude"
|
|
410
|
+
? await installProjectMcp(claudeConfigPath(home, env), cwd, null, deps.trust === true)
|
|
411
|
+
: await realpath(cwd);
|
|
412
|
+
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
|
|
413
|
+
await saved?.();
|
|
414
|
+
log(`Runner installed → ${runnerEnv}`);
|
|
415
|
+
try {
|
|
416
|
+
await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
log(`Could not start the service: ${error.message.split("\n")[0]}. Start it by hand with: agentradar runner start ${runnerId}`);
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
log(`Connected. Your agent now runs in its own ${harness === "claude" ? "Claude Code" : "Codex"} session in ${projectPath} (tmux session ${runnerId}): your welcome and every client message are typed into it, and it answers there. There is nothing more to do in this session. To stop it: agentradar leave ${runnerId}`);
|
|
423
|
+
log("Running as a service; it restarts on crash and reboot.");
|
|
424
|
+
return 0;
|
|
425
|
+
};
|
|
426
|
+
let link;
|
|
427
|
+
try {
|
|
428
|
+
link = new URL(enrolmentUrl);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
throw new Error("Pass the enrolment link your organisation gave you, e.g. https://…/invite/<token>");
|
|
432
|
+
}
|
|
433
|
+
const token = /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(link.pathname)?.[1];
|
|
434
|
+
if (!token || !trustedMcpEndpoint(link.origin)) {
|
|
435
|
+
throw new Error("That is not an AgentRadar enrolment link (expected https://…/invite/<token>)");
|
|
436
|
+
}
|
|
437
|
+
const api = (path) => new URL(`/api/${path}`, link.origin);
|
|
438
|
+
const mcpEndpoint = env.AWP_MCP_URL || api("mcp").toString();
|
|
439
|
+
const joinsDir = join(home, ".agentradar", "joins");
|
|
440
|
+
await mkdir(joinsDir, { recursive: true, mode: 0o700 });
|
|
441
|
+
const statePath = join(joinsDir, `${createHash("sha256").update(token).digest("hex")}.json`);
|
|
442
|
+
let state = null;
|
|
443
|
+
try {
|
|
444
|
+
state = JSON.parse(await readFile(statePath, "utf8"));
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (error.code !== "ENOENT")
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
if (state?.agentId) {
|
|
451
|
+
log("This enrolment link is already joined on this machine.");
|
|
452
|
+
const saved = await readFile(join(home, ".agentradar", "agents", `join-${state.requestId}.env`), "utf8").catch(() => "");
|
|
453
|
+
const { envValue } = await import("./tool-run.js");
|
|
454
|
+
const bearer = envValue(saved, "AWP_BEARER");
|
|
455
|
+
if (!bearer)
|
|
456
|
+
throw new Error("This machine left that organisation (agentradar leave). A key is issued once; ask your organisation for a new enrolment link.");
|
|
457
|
+
return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer });
|
|
458
|
+
}
|
|
459
|
+
if (!state) {
|
|
460
|
+
const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
|
|
461
|
+
if (!agentName)
|
|
462
|
+
throw new Error("Run join from the agent project folder");
|
|
463
|
+
// Codex has no env var for its model; its config's top-level `model` is the default a session starts on.
|
|
464
|
+
// A codex agent reporting "claude" is guessing (a gateway combo answers as Claude), so the config wins there too.
|
|
465
|
+
const codexModel = async () => !env.CODEX_HOME ? undefined : /^model\s*=\s*"([^"\n]+)"/m.exec((await readFile(join(env.CODEX_HOME, "config.toml"), "utf8").catch(() => "")).split(/^\[/m)[0])?.[1];
|
|
466
|
+
const model = (!deps.model || deps.model === "unknown" || /claude/i.test(deps.model)) && harness === "codex" ? (await codexModel()) ?? deps.model : deps.model;
|
|
467
|
+
const response = await postJson(fetchImpl, api(`invites/${encodeURIComponent(token)}/accept`), {
|
|
468
|
+
requestType: "agent", agentName, adapterType: "awp_remote",
|
|
469
|
+
agentDefaultsPayload: { harness, ...(model ? { model } : {}) },
|
|
470
|
+
});
|
|
471
|
+
if (!response.ok) {
|
|
472
|
+
throw new Error(response.status === 404
|
|
473
|
+
? "This join link has expired, was already used, or was withdrawn. Ask your organisation for a new link."
|
|
474
|
+
: `This join link cannot be used. ${await failureMessage(response)}`);
|
|
475
|
+
}
|
|
476
|
+
const accepted = await response.json();
|
|
477
|
+
if (typeof accepted.id !== "string" || typeof accepted.claimSecret !== "string") {
|
|
478
|
+
throw new Error("AgentRadar returned an invalid join response");
|
|
479
|
+
}
|
|
480
|
+
state = { requestId: accepted.id, claimSecret: accepted.claimSecret, ...(typeof accepted.approvalUrl === "string" ? { approvalUrl: accepted.approvalUrl } : {}) };
|
|
481
|
+
await writePrivateFile(statePath, JSON.stringify(state));
|
|
482
|
+
}
|
|
483
|
+
log("Join request sent. Waiting for your organisation to approve it…");
|
|
484
|
+
if (state.approvalUrl)
|
|
485
|
+
log(`Approval link: ${state.approvalUrl}\nSend it to your own human once; they approve after signing in. Never send anything else from this folder.`);
|
|
486
|
+
const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
|
|
487
|
+
// Named off the join request, which exists before the key does, so the server can record it at claim.
|
|
488
|
+
const runnerId = `join-${state.requestId}`;
|
|
489
|
+
for (let poll = 1;; poll++) {
|
|
490
|
+
if (poll > (deps.polls ?? 9)) {
|
|
491
|
+
log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
let response;
|
|
495
|
+
try {
|
|
496
|
+
response = await postJson(fetchImpl, claimUrl, { claimSecret: state.claimSecret, runnerId });
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
log(`Could not reach ${claimUrl.host}; retrying…`);
|
|
500
|
+
await sleep(10_000);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (response.status === 201) {
|
|
504
|
+
const claimed = await response.json();
|
|
505
|
+
if (typeof claimed.token !== "string" || typeof claimed.agentId !== "string") {
|
|
506
|
+
throw new Error("AgentRadar returned an invalid key claim");
|
|
507
|
+
}
|
|
508
|
+
log("Approved.");
|
|
509
|
+
const joined = { ...state, agentId: claimed.agentId };
|
|
510
|
+
return startJoined(state.requestId, claimed.agentId, { mcpEndpoint, bearer: claimed.token }, () => writePrivateFile(statePath, JSON.stringify(joined)));
|
|
511
|
+
}
|
|
512
|
+
const said = await failureMessage(response);
|
|
513
|
+
if (response.status === 409 && said.includes("must be approved")) {
|
|
514
|
+
await sleep(10_000);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (response.status === 409 && said.includes("rejected")) {
|
|
518
|
+
throw new Error("Your organisation declined this join request. Ask it for a new enrolment link.");
|
|
519
|
+
}
|
|
520
|
+
if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
|
|
521
|
+
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.");
|
|
522
|
+
}
|
|
523
|
+
throw new Error(said);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* `agentradar leave <runner-id>`: stop and remove the service, end the agent's session, and delete its env
|
|
528
|
+
* file, bearer and config. The folder the agent worked in stays, with the work.
|
|
529
|
+
*/
|
|
530
|
+
export async function leaveOrganisation(runnerId, deps = {}) {
|
|
531
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
|
|
532
|
+
throw new Error("Invalid runner ID");
|
|
533
|
+
const home = deps.home ?? homedir();
|
|
534
|
+
const log = deps.log ?? console.log;
|
|
535
|
+
const execute = deps.execute ?? ((command, args) => promisify(execFile)(command, args));
|
|
536
|
+
const envPath = join(home, ".agentradar", "agents", `${runnerId}.env`);
|
|
537
|
+
const saved = await readFile(envPath, "utf8").catch(() => null);
|
|
538
|
+
if (saved === null) {
|
|
539
|
+
log(`No agent ${runnerId} on this machine.`);
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
const { envValue } = await import("./tool-run.js");
|
|
543
|
+
if ((deps.platform ?? process.platform) === "darwin") {
|
|
544
|
+
const label = `com.agentradar.agent.${runnerId}`;
|
|
545
|
+
await execute("launchctl", ["bootout", `gui/${process.getuid?.()}/${label}`]).catch(() => undefined);
|
|
546
|
+
await rm(join(home, "Library", "LaunchAgents", `${label}.plist`), { force: true });
|
|
547
|
+
await rm(join(home, ".agentradar", "staged", `${label}.plist`), { force: true });
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
await execute("systemctl", ["--user", "disable", "--now", `agentradar-agent@${runnerId}`]).catch(() => undefined);
|
|
551
|
+
}
|
|
552
|
+
// The service stopping leaves the session running; `=` matches the tmux session name exactly.
|
|
553
|
+
await execute(envValue(saved, "TMUX_BIN") ?? "tmux", ["kill-session", "-t", `=${envValue(saved, "TMUX_SESSION") ?? runnerId}`]).catch(() => undefined);
|
|
554
|
+
for (const path of [envPath, join(home, ".agentradar", "agents", `${runnerId}.delivered.json`)])
|
|
555
|
+
await rm(path, { force: true });
|
|
556
|
+
for (const dir of ["run", "config", "codex"])
|
|
557
|
+
await rm(join(home, ".agentradar", dir, runnerId), { recursive: true, force: true });
|
|
558
|
+
log(`Left: the service is stopped and removed, and its key is deleted. The folder ${envValue(saved, "AR_BASE") ?? ""} stays, with the work.`);
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
221
561
|
//# 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
|