agentradar-cli 0.3.1 → 0.3.3
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/agent-guard/skills/agentradar-discipline/SKILL.md +1 -1
- package/dist/agentradar-vendor/SKILL.md +4 -4
- package/dist/channel.js +23 -8
- package/dist/index.js +45 -5
- package/dist/runner.js +45 -4
- package/dist/secrets.js +248 -0
- package/dist/setup.js +43 -14
- package/dist/skill-install.js +23 -6
- package/dist/tool-run.js +11 -3
- package/dist/update.js +23 -1
- package/dist/vendor-runner/feeder.mjs +24 -6
- package/dist/vendor-runner/session-frame.mjs +3 -2
- package/dist/vendor-runner/supervisor/supervise-agent.sh +34 -2
- package/package.json +2 -1
|
@@ -24,7 +24,7 @@ The Stop hook runs the same check and will not let you stop while something is o
|
|
|
24
24
|
|
|
25
25
|
- Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
|
|
26
26
|
- Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
|
|
27
|
-
- Write to
|
|
27
|
+
- Write to humans so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a list of ideas), no ids, paths, tool names or internal terms unless asked. Say money in dollars, never cents; give every link with what it opens. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
|
|
28
28
|
- Never ask for, print or pass on a credential. AgentRadar holds the keys.
|
|
29
29
|
- When you ask another agent or a human something, call `expect_reply` on that thread with the time you need the answer by. If nobody answers, `next_event` wakes you with kind `followup`: chase them in the thread. If they stay silent, you may escalate to the next human up the chain from the person you wait on; that is your call, never required.
|
|
30
30
|
- Only a human accepts your work. Asked to "mark it done": record the final state with `append_task_log`, then reply that it is finished and ready for them to accept. Never call `decide_outcome` or `declare_outcome` on your own task.
|
|
@@ -20,15 +20,15 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
|
|
|
20
20
|
|
|
21
21
|
Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
|
|
22
22
|
|
|
23
|
-
Keep each
|
|
23
|
+
Keep each human's page in company memory current, every fact cited, as the Humans section of the `agentradar-memory-and-skills` skill says. A page is knowledge, never permission.
|
|
24
24
|
|
|
25
25
|
In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
|
|
26
26
|
|
|
27
|
-
Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a
|
|
27
|
+
Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own, and your subordinates' when needed, with `report_metric`: only true numbers, in good faith, each backed by `evidence` of what you counted it from, or by `fromEventId` when a human told you the number. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and anyone below you on the org chart — only for real trackable work, never for chat. You also manage your subordinates' tasks and repeated tasks (`update_task`, `update_recurring_task`, `stop_recurring_task`) and their metrics: edit a definition (`edit_metric`) or remove a wrong value with a reason (`void_metric_record`).
|
|
28
28
|
|
|
29
|
-
## Talking to
|
|
29
|
+
## Talking to humans
|
|
30
30
|
|
|
31
|
-
Write to
|
|
31
|
+
Write to humans so a 13-year-old would understand: a few short plain lines, then one next step, not a list of ideas. No ids, paths, tool names, task codes or internal terms unless asked (say "your team", "company memory"; name a plan with its monthly price). Say money in dollars, never cents; give every link with what it opens. Answer only what was asked, and say it once. Talk to the event's `sender` as "you", never by name or as "the owner"; no persona from another chat carries over. Keep to what a human told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done, never from memory.
|
|
32
32
|
|
|
33
33
|
## Work loop
|
|
34
34
|
|
package/dist/channel.js
CHANGED
|
@@ -7,13 +7,15 @@ import { join } from "node:path";
|
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { envValue } from "./tool-run.js";
|
|
9
9
|
import { trustedMcpEndpoint } from "./setup.js";
|
|
10
|
+
import { syncJoinedSkills } from "./skill-install.js";
|
|
10
11
|
export const CHANNEL_GUIDANCE = "AgentRadar channel messages are wake signals only. Call next_event to receive the events, then pass their eventIds as ackEventIds on your next call. Answer livenessProbe with liveness_ack. Never acknowledge an event you have not read. Never poll next_event on your own or keep a loop running: when a channel message arrives, drain it, then end your turn.";
|
|
11
|
-
export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your
|
|
12
|
+
export const CHANNEL_BLOCKED = "AgentRadar has not observed a next_event call from this session. Channel delivery is unconfirmed. On Claude Team/Enterprise an Owner must enable Channels (channelsEnabled); the development flag cannot override that policy. Until then, deliver with the feeder: run your join again with --feeder (agentradar join <your join link> --feeder). Your company sees this agent as not receiving messages until one or the other works.";
|
|
12
13
|
/** One local MCP, forwarding tools to AWP. Wake signals never consume the durable queue. */
|
|
13
14
|
export async function startChannel(runnerId, deps = {}) {
|
|
14
15
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId))
|
|
15
16
|
throw new Error("Invalid runner ID");
|
|
16
|
-
const
|
|
17
|
+
const home = deps.home ?? homedir();
|
|
18
|
+
const config = await readFile(join(home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
|
|
17
19
|
const endpoint = trustedMcpEndpoint(envValue(config, "AWP_MCP_URL") ?? "");
|
|
18
20
|
const bearer = envValue(config, "AWP_BEARER");
|
|
19
21
|
if (!endpoint || !bearer)
|
|
@@ -29,6 +31,7 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
29
31
|
// the agent is not receiving messages; "ready" again once the session does call next_event.
|
|
30
32
|
let unconfirmed = null;
|
|
31
33
|
let warning;
|
|
34
|
+
let skillSync;
|
|
32
35
|
const send = (value) => new Promise((resolve, reject) => {
|
|
33
36
|
output.write(`${JSON.stringify(value)}\n`, (error) => error ? reject(error) : resolve());
|
|
34
37
|
});
|
|
@@ -36,12 +39,14 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
36
39
|
const response = await (deps.fetch ?? fetch)(endpoint, {
|
|
37
40
|
method: "POST",
|
|
38
41
|
redirect: "error",
|
|
39
|
-
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
42
|
+
headers: { "x-agentradar-cli": createRequire(import.meta.url)("../package.json").version, authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
40
43
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++sequence, method, params }),
|
|
41
44
|
signal: AbortSignal.any([stop.signal, AbortSignal.timeout(230_000)]),
|
|
42
45
|
});
|
|
43
|
-
if (!response.ok)
|
|
44
|
-
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
|
|
48
|
+
throw new Error(said ?? `AgentRadar refused the channel call (${response.status})`);
|
|
49
|
+
}
|
|
45
50
|
const text = await response.text();
|
|
46
51
|
const json = text.trim().startsWith("{") ? text : text.split("\n").find((line) => line.startsWith("data:"))?.slice(5);
|
|
47
52
|
if (!json)
|
|
@@ -90,7 +95,7 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
90
95
|
const wake = JSON.stringify([events.map((event) => event.eventId), payload.livenessProbe?.nonce, Boolean(payload.onboarding)]);
|
|
91
96
|
if ((events.length || payload.livenessProbe || payload.onboarding) && (wake !== lastWake || Date.now() - lastSent >= 30_000)) {
|
|
92
97
|
await send({ jsonrpc: "2.0", method: "notifications/claude/channel", params: {
|
|
93
|
-
content: `${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
|
|
98
|
+
content: `${payload.firstStep ? `${payload.firstStep} ` : ""}${events.length} new AgentRadar events; call next_event${payload.livenessProbe ? " to receive your liveness check" : ""}.`,
|
|
94
99
|
meta: { count: String(events.length) },
|
|
95
100
|
} });
|
|
96
101
|
lastWake = wake;
|
|
@@ -98,9 +103,11 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
98
103
|
}
|
|
99
104
|
// Never ack a count-only wake: only the agent's tool call confirms receipt of event content.
|
|
100
105
|
}
|
|
101
|
-
catch {
|
|
106
|
+
catch (error) {
|
|
107
|
+
const said = error.message;
|
|
108
|
+
// Below the server's minimum CLI the retry cannot succeed: say how to upgrade instead.
|
|
102
109
|
if (!stop.signal.aborted)
|
|
103
|
-
logError("AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
|
|
110
|
+
logError(/below the minimum/.test(said) ? said : "AgentRadar channel poll failed; retrying. Check connectivity and the runner key.");
|
|
104
111
|
}
|
|
105
112
|
await pause();
|
|
106
113
|
}
|
|
@@ -122,6 +129,13 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
122
129
|
unconfirmed = "pending";
|
|
123
130
|
} }, deps.confirmMs ?? 30_000);
|
|
124
131
|
void poll();
|
|
132
|
+
// Vadym 11038: a push-mode session runs no feeder, so the channel keeps its skills and brief current as the feeder
|
|
133
|
+
// does: at connect and every 10 minutes, dropping skills no longer linked. stdout is the MCP stream; logs go to stderr.
|
|
134
|
+
const sync = () => syncJoinedSkills(runnerId, { home, fetch: deps.fetch, log: logError })
|
|
135
|
+
.catch((error) => logError(`AgentRadar skill sync failed; keeping current copies (${String(error?.message ?? error).split("\n")[0]}).`));
|
|
136
|
+
void sync();
|
|
137
|
+
skillSync = setInterval(() => { void sync(); }, deps.skillsSyncMs ?? 600_000);
|
|
138
|
+
skillSync.unref?.();
|
|
125
139
|
}
|
|
126
140
|
}
|
|
127
141
|
else if (message.id !== undefined) {
|
|
@@ -159,6 +173,7 @@ export async function startChannel(runnerId, deps = {}) {
|
|
|
159
173
|
}
|
|
160
174
|
finally {
|
|
161
175
|
clearTimeout(warning);
|
|
176
|
+
clearInterval(skillSync);
|
|
162
177
|
stop.abort();
|
|
163
178
|
}
|
|
164
179
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,12 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { installVendorSkill } from "./skill-install.js";
|
|
4
4
|
import { startChannel } from "./channel.js";
|
|
5
5
|
import { runRestartSelftest, startVendorRunner } from "./runner.js";
|
|
6
|
-
import { cancelJoin, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
|
|
7
|
-
import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
|
|
6
|
+
import { cancelJoin, CODEX_GUARD_REFUSAL, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
|
|
7
|
+
import { callAwpTool, callToolOverCli, createTaskOverCli, INBOX_SEARCH_USAGE, METRIC_REPORT_USAGE, PROPOSE_HUMAN_USAGE, inboxSearchOverCli, LISTING_DESCRIBE_USAGE, listServiceToolsOverCli, parseCallArgs, PERMISSION_VERB_TOOLS, runServiceToolOverCli, TASK_VERB_TOOLS } from "./tool-run.js";
|
|
8
8
|
import { installSyncTimer, syncAgentDir } from "./sync.js";
|
|
9
9
|
import { importVaultOverCli } from "./vault-import.js";
|
|
10
10
|
import { cloneVaultOverCli } from "./vault-clone.js";
|
|
11
|
+
import { secretsOverCli } from "./secrets.js";
|
|
11
12
|
// `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
|
|
12
13
|
// covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
|
|
13
14
|
export const runArgs = (raw) => raw[0] !== "run" ? raw
|
|
@@ -17,7 +18,7 @@ export const runArgs = (raw) => raw[0] !== "run" ? raw
|
|
|
17
18
|
async function main() {
|
|
18
19
|
const args = runArgs(process.argv.slice(2));
|
|
19
20
|
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
|
20
|
-
console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
21
|
+
console.log("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] [--harness claude|codex] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write] | agentradar vault clone <runner-id> <vault-id> [dir] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
21
22
|
console.log([
|
|
22
23
|
"agentradar run <runner-id> <noun> <verb> [--<argument> <value> ...] [--dry-run] [--json]",
|
|
23
24
|
" Every agent tool, grouped in AgentRadar terms: inbox, thread, message, task, metric, vault, ...",
|
|
@@ -41,6 +42,19 @@ async function main() {
|
|
|
41
42
|
console.log(installed ? `agentradar upgraded to ${installed}` : `agentradar ${await runningVersion()} is the latest`);
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
45
|
+
// The supervisor asks before every Codex launch (CODEX_HOME and CODEX_BIN from its env): exit 1 keeps Codex down.
|
|
46
|
+
if (args.length === 1 && args[0] === "codex-guard") {
|
|
47
|
+
try {
|
|
48
|
+
if (!process.env.CODEX_HOME)
|
|
49
|
+
throw new Error(CODEX_GUARD_REFUSAL);
|
|
50
|
+
await ensureCodexGuard(process.env.CODEX_HOME, process.env);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
console.error(error.message);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
44
58
|
if (args[0] === "leave" && args.length === 2) {
|
|
45
59
|
process.exitCode = await leaveOrganisation(args[1]);
|
|
46
60
|
return;
|
|
@@ -164,6 +178,28 @@ async function main() {
|
|
|
164
178
|
process.exitCode = await inboxSearchOverCli(parsed.runnerId, parsed.flags, args.includes("--json"));
|
|
165
179
|
return;
|
|
166
180
|
}
|
|
181
|
+
if (args[0] === "metric" && args[1] === "report") {
|
|
182
|
+
// report_metric over the same endpoint and key: an agent's own metric, a subordinate's, or a person's below it.
|
|
183
|
+
const parsed = parseCallArgs([...args.slice(2), "report_metric"]);
|
|
184
|
+
if (!parsed || !parsed.flags.some(([flag]) => flag === "metric-id")) {
|
|
185
|
+
console.error(METRIC_REPORT_USAGE);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (args[0] === "propose-human") {
|
|
193
|
+
// R013: the Chief invites a human it found into a vacant role, the MCP `propose_human` over the same endpoint and key.
|
|
194
|
+
const parsed = parseCallArgs([...args.slice(1), "propose_human"]);
|
|
195
|
+
if (!parsed || !["name", "email", "role-id"].every((k) => parsed.flags.some(([flag]) => flag === k))) {
|
|
196
|
+
console.error(PROPOSE_HUMAN_USAGE);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
process.exitCode = await callToolOverCli(parsed.runnerId, parsed.tool, undefined, {}, parsed.flags);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
167
203
|
if (args[0] === "listing" && args[1] === "describe") {
|
|
168
204
|
// UAT U1: the agent's public catalog description, the MCP `set_listing_description` over the same endpoint and key.
|
|
169
205
|
const parsed = parseCallArgs([...args.slice(2), "set_listing_description"]);
|
|
@@ -179,13 +215,17 @@ async function main() {
|
|
|
179
215
|
const tool = PERMISSION_VERB_TOOLS[args[1]];
|
|
180
216
|
const parsed = parseCallArgs([...args.slice(2), tool]);
|
|
181
217
|
if (!parsed || parsed.tool !== tool) {
|
|
182
|
-
console.error("Usage: agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]");
|
|
218
|
+
console.error("Usage: agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]");
|
|
183
219
|
process.exitCode = 1;
|
|
184
220
|
return;
|
|
185
221
|
}
|
|
186
222
|
process.exitCode = await callToolOverCli(parsed.runnerId, tool, parsed.json, {}, parsed.flags);
|
|
187
223
|
return;
|
|
188
224
|
}
|
|
225
|
+
if (args[0] === "secrets") {
|
|
226
|
+
process.exitCode = await secretsOverCli(args.slice(1));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
189
229
|
if (args[0] === "task" && args[1] && Object.hasOwn(TASK_VERB_TOOLS, args[1])) {
|
|
190
230
|
if (args.length !== 5 || args[3] !== "--args") {
|
|
191
231
|
console.error("Usage: agentradar task create|repeat|make-recurring <runner-id> --args '<json>'");
|
|
@@ -217,7 +257,7 @@ async function main() {
|
|
|
217
257
|
return;
|
|
218
258
|
}
|
|
219
259
|
const help = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
|
|
220
|
-
(help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
260
|
+
(help ? console.log : console.error)("Usage: agentradar run <join-link> [--name <n>] [--model <m>] | agentradar run <runner-id> <tool> [--<argument> <value> | --<argument>-file <path> ...] | agentradar setup [--trust] | agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--feeder] | agentradar join cancel <join-link|request-id> | agentradar leave <runner-id> | agentradar channel <runner-id> | agentradar selftest <runner-id> [--status|--restart] | agentradar runner start <runner-id> | agentradar tool list <runner-id> | agentradar tool run <runner-id> <tool-key> [--args '<json>'] | agentradar call <runner-id> <tool> [--<argument> <value> ...] [--args '<json>'] | agentradar task create|repeat|make-recurring <runner-id> --args '<json>' | agentradar inbox search <runner-id> [--query <text>] [--<filter> <value> ...] [--json] | agentradar listing describe <runner-id> --capabilities <text> | agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...] | agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] | agentradar secrets pull|renew <runner-id> | agentradar secrets exec <runner-id> -- <command> | agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id> | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace] | agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]");
|
|
221
261
|
if (!help)
|
|
222
262
|
process.exitCode = 1;
|
|
223
263
|
}
|
package/dist/runner.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { shellValue, trustedMcpEndpoint, writePrivateFile } from "./setup.js";
|
|
8
8
|
import { callAwpTool, envValue } from "./tool-run.js";
|
|
9
|
-
import { installedVersion, runningVersion, selfUpdate } from "./update.js";
|
|
9
|
+
import { installedVersion, revertCli, runningVersion, selfUpdate } from "./update.js";
|
|
10
10
|
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
11
11
|
const ENGAGEMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
12
12
|
// Per-hire bearers are ordinary agent keys (`pcp_…`); the runner's own is an AWP key (`ar_…`).
|
|
@@ -461,14 +461,36 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
|
|
|
461
461
|
// waits for every pane to finish its turn, for at most an hour.
|
|
462
462
|
let nextCheck = 0;
|
|
463
463
|
let told = "";
|
|
464
|
+
let failing = "";
|
|
465
|
+
let failedChecks = 0;
|
|
466
|
+
// Self-release card 4: AgentRadar can hold every runner's updates while a bad release is fixed. Only an explicit
|
|
467
|
+
// hold counts: a runner whose key cannot read get_my_context (a vendor agent nobody hired yet) still updates.
|
|
468
|
+
const held = deps.held ?? (async () => {
|
|
469
|
+
let said = {};
|
|
470
|
+
const code = await callAwpTool(runnerId, "get_my_context", {}, {
|
|
471
|
+
home, log: (text) => { try {
|
|
472
|
+
said = JSON.parse(text);
|
|
473
|
+
}
|
|
474
|
+
catch { /* not JSON: hold */ } }, logError: () => { },
|
|
475
|
+
});
|
|
476
|
+
return code === 0 && said.cli?.hold === true;
|
|
477
|
+
});
|
|
478
|
+
// Self-release card 3: before restarting onto a new version, prove it starts and reaches AgentRadar with this
|
|
479
|
+
// runner's key. One that cannot is put aside (`current` back, marked bad) and this runner stays as it is.
|
|
480
|
+
const probe = deps.probe ?? ((version) => run(process.execPath, [join(home, ".agentradar", "cli", version, "dist", "index.js"), "call", runnerId, "get_self"], { timeout: 60_000 })
|
|
481
|
+
.then(() => true, () => false));
|
|
464
482
|
const autoUpdate = async () => {
|
|
465
483
|
try {
|
|
466
484
|
if (running && !(await optedOut())) {
|
|
467
485
|
if (Date.now() >= nextCheck) {
|
|
468
486
|
nextCheck = Date.now() + (deps.updateEveryMs ?? 86_400_000);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
487
|
+
if (await held())
|
|
488
|
+
log(`AgentRadar is holding CLI updates; staying on ${running}`);
|
|
489
|
+
else {
|
|
490
|
+
const fresh = await (deps.update ?? selfUpdate)();
|
|
491
|
+
if (fresh)
|
|
492
|
+
log(`AgentRadar CLI ${fresh} installed (was ${running})`);
|
|
493
|
+
}
|
|
472
494
|
}
|
|
473
495
|
const onDisk = await installed();
|
|
474
496
|
if (onDisk !== running && !serviced) {
|
|
@@ -476,6 +498,25 @@ export async function startVendorRunner(runnerId, home = homedir(), deps = {}) {
|
|
|
476
498
|
log(`AgentRadar CLI ${onDisk} is installed; restart this runner to use it`);
|
|
477
499
|
told = onDisk;
|
|
478
500
|
}
|
|
501
|
+
else if (onDisk !== running && await held()) {
|
|
502
|
+
// A hold also stops a restart onto a version already on disk (another runner may have installed it).
|
|
503
|
+
if (told !== `held ${onDisk}`)
|
|
504
|
+
log(`AgentRadar is holding CLI updates; not restarting onto ${onDisk}`);
|
|
505
|
+
told = `held ${onDisk}`;
|
|
506
|
+
}
|
|
507
|
+
else if (onDisk !== running && !(await probe(onDisk))) {
|
|
508
|
+
// One failed check can be the network; two in a row, a minute apart, put the version aside.
|
|
509
|
+
failedChecks = failing === onDisk ? failedChecks + 1 : 1;
|
|
510
|
+
failing = onDisk;
|
|
511
|
+
if (failedChecks >= 2) {
|
|
512
|
+
await revertCli(home, running, onDisk);
|
|
513
|
+
// Counted afresh: when the day is up and it is installed again, one blip must not put it aside again.
|
|
514
|
+
failedChecks = 0;
|
|
515
|
+
log(`AgentRadar CLI ${onDisk} failed its check twice (start + get_self); staying on ${running} and skipping ${onDisk} for a day`);
|
|
516
|
+
}
|
|
517
|
+
else
|
|
518
|
+
log(`AgentRadar CLI ${onDisk} failed its check once; checking again before restarting`);
|
|
519
|
+
}
|
|
479
520
|
else if (onDisk !== running) {
|
|
480
521
|
for (let i = 0; i < 240 && !stopped && await midTurn(); i += 1) {
|
|
481
522
|
await new Promise((resolveWait) => setTimeout(resolveWait, deps.idlePollMs ?? 15_000));
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bible #40 (Vadym 10582), mode 3: `agentradar secrets pull | renew | exec`.
|
|
3
|
+
*
|
|
4
|
+
* pull writes the role's leasable secrets, ENCRYPTED, to `.agentradar/secrets.enc` (0600) in the workspace and
|
|
5
|
+
* prints only their names. The key stays with AgentRadar under a 24 h lease.
|
|
6
|
+
* renew extends the lease while this agent still holds the role; a retired or replaced holder is refused.
|
|
7
|
+
* exec fetches the key while the lease is live, decrypts in this process, and runs a command with the values in
|
|
8
|
+
* its env. Nothing is written in plaintext, and the command's output passes through a value matcher.
|
|
9
|
+
* env prints `export KEY='value'` lines for the supervisor's launch wrapper to eval into the agent's env at start.
|
|
10
|
+
* Nothing is written to disk; with nothing pulled it prints nothing.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createCipheriv, createDecipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
|
|
14
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { awpRequest, envValue } from "./tool-run.js";
|
|
18
|
+
export const SECRETS_FILE = join(".agentradar", "secrets.enc");
|
|
19
|
+
export const SECRETS_USAGE = "Usage: agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command> [args...]"
|
|
20
|
+
+ " | agentradar secrets upload <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--only <NAME,...>] [--replace --approval-details '<json>'] [--token <token>]";
|
|
21
|
+
/** The names the supervisor reserves for itself (supervise-agent.sh); a role secret never overrides one. */
|
|
22
|
+
const RESERVED = /^(HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_[A-Z]+|TMPDIR|NODE_OPTIONS|CLAUDE_[A-Z_]+|CODEX_HOME|AR_[A-Z_]+|ANTHROPIC_API_KEY|AWP_[A-Z_]+|TMUX_SESSION|AGENT_PID_PATTERN|DISABLE_AUTOUPDATER)$/;
|
|
23
|
+
/** The runner's own origin and key: its env file, else a hosted agent's run env (as tool-run.ts awpRequest). */
|
|
24
|
+
async function credentials(runnerId, deps) {
|
|
25
|
+
const contents = await readFile(join(deps.home ?? homedir(), ".agentradar", "agents", `${runnerId}.env`), "utf8").catch(() => null);
|
|
26
|
+
const env = deps.env ?? process.env;
|
|
27
|
+
const url = contents !== null ? envValue(contents, "AWP_MCP_URL") : env.PAPERCLIP_API_URL ? `${env.PAPERCLIP_API_URL}/api/mcp` : null;
|
|
28
|
+
const bearer = contents !== null ? envValue(contents, "AWP_BEARER") : env.PAPERCLIP_API_KEY ?? null;
|
|
29
|
+
if (!url || !bearer)
|
|
30
|
+
throw new Error(`No runner named ${runnerId}. Run agentradar setup first.`);
|
|
31
|
+
return { origin: new URL(url).origin, bearer };
|
|
32
|
+
}
|
|
33
|
+
async function post(runnerId, path, body, deps) {
|
|
34
|
+
const { origin, bearer } = await credentials(runnerId, deps);
|
|
35
|
+
const res = await (deps.fetch ?? fetch)(`${origin}/api/agent/${path}`, {
|
|
36
|
+
method: "POST", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, body: JSON.stringify(body ?? {}),
|
|
37
|
+
});
|
|
38
|
+
const json = await res.json().catch(() => ({}));
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(json.error ?? `AgentRadar answered ${res.status}`);
|
|
41
|
+
return json;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Replace every value in a text with [secret], however short (Bible #40: no value in any output). A short value also
|
|
45
|
+
* masks the same characters in ordinary words; that over-redaction is the price of never printing it.
|
|
46
|
+
*/
|
|
47
|
+
export function redactValues(text, values) {
|
|
48
|
+
return values.filter((v) => v.length > 0).sort((a, b) => b.length - a.length)
|
|
49
|
+
.reduce((out, value) => out.split(value).join("[secret]"), text);
|
|
50
|
+
}
|
|
51
|
+
export async function secretsPull(runnerId, deps = {}) {
|
|
52
|
+
const pulled = await post(runnerId, "role-secrets/pull", {}, deps);
|
|
53
|
+
const dir = join(deps.cwd ?? process.cwd(), ".agentradar");
|
|
54
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
const file = join(deps.cwd ?? process.cwd(), SECRETS_FILE);
|
|
56
|
+
await writeFile(`${file}.tmp`, JSON.stringify({ runnerId, ...pulled }), { mode: 0o600 });
|
|
57
|
+
await rename(`${file}.tmp`, file);
|
|
58
|
+
(deps.log ?? console.log)(`Pulled ${pulled.names.length} secret${pulled.names.length === 1 ? "" : "s"} (${pulled.names.join(", ")}), encrypted, to ${file}. `
|
|
59
|
+
+ `Use them with: agentradar secrets exec ${runnerId} -- <command>. The lease ends ${pulled.expiresAt} unless renewed.`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
async function readPulled(deps) {
|
|
63
|
+
const text = await readFile(join(deps.cwd ?? process.cwd(), SECRETS_FILE), "utf8").catch(() => null);
|
|
64
|
+
if (!text)
|
|
65
|
+
throw new Error("No pulled secrets here. Run agentradar secrets pull <runner-id> first.");
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
export async function secretsRenew(runnerId, deps = {}) {
|
|
69
|
+
const { leaseId } = await readPulled(deps);
|
|
70
|
+
const renewed = await post(runnerId, "role-secrets/renew", { leaseId }, deps);
|
|
71
|
+
(deps.log ?? console.log)(`Lease renewed until ${renewed.expiresAt}.`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
/** The decrypted values, in memory only. */
|
|
75
|
+
export async function decryptPulled(runnerId, deps = {}) {
|
|
76
|
+
const pulled = await readPulled(deps);
|
|
77
|
+
const { key } = await post(runnerId, "role-secrets/unlock", { leaseId: pulled.leaseId }, deps);
|
|
78
|
+
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(key, "base64"), Buffer.from(pulled.iv, "base64"));
|
|
79
|
+
decipher.setAuthTag(Buffer.from(pulled.tag, "base64"));
|
|
80
|
+
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(pulled.ciphertext, "base64")), decipher.final()]).toString("utf8"));
|
|
81
|
+
}
|
|
82
|
+
export async function secretsExec(runnerId, command, deps = {}) {
|
|
83
|
+
if (!command.length)
|
|
84
|
+
throw new Error(SECRETS_USAGE);
|
|
85
|
+
const values = await decryptPulled(runnerId, deps);
|
|
86
|
+
const secrets = Object.values(values);
|
|
87
|
+
const child = spawn(command[0], command.slice(1), { env: { ...(deps.env ?? process.env), ...values }, stdio: ["inherit", "pipe", "pipe"] });
|
|
88
|
+
// Whole lines only, so a value split across two chunks is still matched.
|
|
89
|
+
// ponytail: a value printed inside one very long unterminated line waits for the close to be flushed.
|
|
90
|
+
const pipe = (from, to) => {
|
|
91
|
+
let held = "";
|
|
92
|
+
from.on("data", (chunk) => {
|
|
93
|
+
held += chunk.toString("utf8");
|
|
94
|
+
const cut = held.lastIndexOf("\n") + 1;
|
|
95
|
+
if (cut) {
|
|
96
|
+
to.write(redactValues(held.slice(0, cut), secrets));
|
|
97
|
+
held = held.slice(cut);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
from.on("end", () => { if (held)
|
|
101
|
+
to.write(redactValues(held, secrets)); });
|
|
102
|
+
};
|
|
103
|
+
pipe(child.stdout, process.stdout);
|
|
104
|
+
pipe(child.stderr, process.stderr);
|
|
105
|
+
return new Promise((resolve) => child.on("close", (code) => resolve(code ?? 1)));
|
|
106
|
+
}
|
|
107
|
+
/** `KEY=value` lines of a .env file; comments, blanks and `export ` prefixes skipped, one pair of quotes stripped. */
|
|
108
|
+
export function parseEnvFile(text) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const line of text.split(/\r?\n/)) {
|
|
111
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
112
|
+
if (!m || line.trimStart().startsWith("#"))
|
|
113
|
+
continue;
|
|
114
|
+
const v = m[2];
|
|
115
|
+
out[m[1]] = /^(['"]).*\1$/.test(v) && v.length >= 2 ? v.slice(1, -1) : v;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Seal values to a slot token (`<slot>.<server public key>`): X25519, HKDF-SHA256, AES-256-GCM, AAD = slot hash and names. */
|
|
120
|
+
export function sealForSlot(token, values) {
|
|
121
|
+
const [slot, serverKey] = token.split(".");
|
|
122
|
+
if (!slot || !serverKey)
|
|
123
|
+
throw new Error("That is not an upload token.");
|
|
124
|
+
const slotHash = createHash("sha256").update(slot).digest("hex");
|
|
125
|
+
const mine = generateKeyPairSync("x25519");
|
|
126
|
+
const shared = diffieHellman({ privateKey: mine.privateKey,
|
|
127
|
+
publicKey: createPublicKey({ key: Buffer.from(serverKey, "base64url"), format: "der", type: "spki" }) });
|
|
128
|
+
const key = Buffer.from(hkdfSync("sha256", shared, Buffer.from(slotHash), "agentradar-secret-upload-v1", 32));
|
|
129
|
+
const iv = randomBytes(12);
|
|
130
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
131
|
+
cipher.setAAD(Buffer.from(`${slotHash}\n${Object.keys(values).sort().join(",")}`));
|
|
132
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(values), "utf8"), cipher.final()]);
|
|
133
|
+
return { slot, body: { ephemeralPublicKey: mine.publicKey.export({ type: "spki", format: "der" }).toString("base64url"),
|
|
134
|
+
iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), ciphertext: ciphertext.toString("base64url") } };
|
|
135
|
+
}
|
|
136
|
+
/** A value typed at a hidden prompt: nothing is echoed, nothing reaches shell history. */
|
|
137
|
+
async function promptHidden(name) {
|
|
138
|
+
const stdin = process.stdin;
|
|
139
|
+
if (!stdin.isTTY)
|
|
140
|
+
throw new Error("--prompt needs a terminal.");
|
|
141
|
+
process.stderr.write(`${name}: `);
|
|
142
|
+
stdin.setRawMode(true);
|
|
143
|
+
stdin.resume();
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
let value = "";
|
|
146
|
+
const onData = (chunk) => {
|
|
147
|
+
for (const ch of chunk.toString("utf8")) {
|
|
148
|
+
if (ch === "\r" || ch === "\n" || ch === "\u0003") {
|
|
149
|
+
stdin.setRawMode(false);
|
|
150
|
+
stdin.pause();
|
|
151
|
+
stdin.off("data", onData);
|
|
152
|
+
process.stderr.write("\n");
|
|
153
|
+
return ch === "\u0003" ? reject(new Error("Cancelled.")) : resolve(value);
|
|
154
|
+
}
|
|
155
|
+
value = ch === "\u007f" ? value.slice(0, -1) : value + ch;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
stdin.on("data", onData);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Vadym 11074: `agentradar secrets upload`. Values come from this machine (a .env file, the environment, a hidden prompt)
|
|
163
|
+
* and never from the command line. Mints a slot with the `upload_secret` tool unless given its token, seals, posts once.
|
|
164
|
+
*/
|
|
165
|
+
export async function secretsUpload(runnerId, flags, deps = {}) {
|
|
166
|
+
const opts = new Map();
|
|
167
|
+
for (let i = 0; i < flags.length; i++) {
|
|
168
|
+
const flag = flags[i];
|
|
169
|
+
if (flag === "--replace") {
|
|
170
|
+
opts.set("replace", "1");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!["--env-file", "--from-env", "--prompt", "--only", "--approval-details", "--token"].includes(flag) || flags[i + 1] === undefined) {
|
|
174
|
+
throw new Error(`${flag.startsWith("--") ? `Unknown option ${flag}` : "Values are never taken on the command line"}. ${SECRETS_USAGE}`);
|
|
175
|
+
}
|
|
176
|
+
opts.set(flag.slice(2), flags[++i]);
|
|
177
|
+
}
|
|
178
|
+
const list = (v) => (v ?? "").split(",").map((n) => n.trim()).filter(Boolean);
|
|
179
|
+
const env = deps.env ?? process.env;
|
|
180
|
+
let values = {};
|
|
181
|
+
if (opts.has("env-file"))
|
|
182
|
+
values = parseEnvFile(await readFile(opts.get("env-file"), "utf8"));
|
|
183
|
+
for (const name of list(opts.get("from-env"))) {
|
|
184
|
+
if (!env[name])
|
|
185
|
+
throw new Error(`${name} is not set in this environment.`);
|
|
186
|
+
values[name] = env[name];
|
|
187
|
+
}
|
|
188
|
+
if (opts.has("prompt"))
|
|
189
|
+
values[opts.get("prompt")] = await promptHidden(opts.get("prompt"));
|
|
190
|
+
const only = list(opts.get("only"));
|
|
191
|
+
if (only.length)
|
|
192
|
+
values = Object.fromEntries(only.map((n) => {
|
|
193
|
+
if (!values[n])
|
|
194
|
+
throw new Error(`${n} has no value in what you gave.`);
|
|
195
|
+
return [n, values[n]];
|
|
196
|
+
}));
|
|
197
|
+
values = Object.fromEntries(Object.entries(values).filter(([, v]) => v.length > 0));
|
|
198
|
+
if (!Object.keys(values).length)
|
|
199
|
+
throw new Error(`No values to upload. ${SECRETS_USAGE}`);
|
|
200
|
+
let token = opts.get("token");
|
|
201
|
+
if (!token) {
|
|
202
|
+
const approvalDetails = opts.has("approval-details") ? JSON.parse(opts.get("approval-details")) : undefined;
|
|
203
|
+
const said = await awpRequest(runnerId, "tools/call", { name: "upload_secret",
|
|
204
|
+
arguments: { names: Object.keys(values), replace: opts.has("replace") || undefined, approvalDetails } }, deps);
|
|
205
|
+
const minted = JSON.parse(said?.result?.content?.[0]?.text ?? "{}");
|
|
206
|
+
if (!minted.ok || !minted.token)
|
|
207
|
+
throw new Error(minted.error ?? "AgentRadar did not open an upload.");
|
|
208
|
+
token = minted.token;
|
|
209
|
+
}
|
|
210
|
+
const { slot, body } = sealForSlot(token, values);
|
|
211
|
+
const { outcomes } = await post(runnerId, `secret-uploads/${encodeURIComponent(slot)}`, body, deps);
|
|
212
|
+
for (const o of outcomes) {
|
|
213
|
+
(deps.log ?? console.log)(`${o.name}: ${o.status.replace("_", " ")}${o.reason ? ` (${o.reason})` : ""}${o.approvalId ? ` (approval ${o.approvalId})` : ""}`);
|
|
214
|
+
}
|
|
215
|
+
return outcomes.some((o) => o.status === "refused") ? 1 : 0;
|
|
216
|
+
}
|
|
217
|
+
export async function secretsEnv(runnerId, deps = {}) {
|
|
218
|
+
const text = await readFile(join(deps.cwd ?? process.cwd(), SECRETS_FILE), "utf8").catch(() => null);
|
|
219
|
+
if (!text)
|
|
220
|
+
return 0;
|
|
221
|
+
const values = await decryptPulled(runnerId, deps);
|
|
222
|
+
const quote = (v) => `'${v.replace(/'/g, "'\\''")}'`;
|
|
223
|
+
(deps.log ?? console.log)(Object.entries(values).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && !RESERVED.test(k))
|
|
224
|
+
.map(([k, v]) => `export ${k}=${quote(v)}`).join("\n"));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
export async function secretsOverCli(args, deps = {}) {
|
|
228
|
+
const [verb, runnerId, sep, ...command] = args;
|
|
229
|
+
try {
|
|
230
|
+
if (verb === "pull" && runnerId && !sep)
|
|
231
|
+
return await secretsPull(runnerId, deps);
|
|
232
|
+
if (verb === "renew" && runnerId && !sep)
|
|
233
|
+
return await secretsRenew(runnerId, deps);
|
|
234
|
+
if (verb === "env" && runnerId && !sep)
|
|
235
|
+
return await secretsEnv(runnerId, deps);
|
|
236
|
+
if (verb === "exec" && runnerId && sep === "--")
|
|
237
|
+
return await secretsExec(runnerId, command, deps);
|
|
238
|
+
if (verb === "upload" && runnerId)
|
|
239
|
+
return await secretsUpload(runnerId, args.slice(2), deps);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
(deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
(deps.logError ?? console.error)(SECRETS_USAGE);
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/setup.js
CHANGED
|
@@ -289,6 +289,8 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
289
289
|
await writePrivateFile(join(codexHome, "config.toml"), `${config.replace(/\n*$/, "\n")}${trusted
|
|
290
290
|
.map(({ key, hash }) => `\n[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(hash)}\n`).join("")}`);
|
|
291
291
|
}
|
|
292
|
+
if (!(await codexGuardTrusted(codexHome, env)))
|
|
293
|
+
throw new Error(CODEX_GUARD_REFUSAL);
|
|
292
294
|
await cp(join(source, "rules"), join(codexHome, "rules"), { recursive: true, force: true }).catch((error) => {
|
|
293
295
|
if (error.code !== "ENOENT")
|
|
294
296
|
throw error;
|
|
@@ -301,11 +303,38 @@ async function codexServiceHome(home, runnerId, env) {
|
|
|
301
303
|
}
|
|
302
304
|
return { codexHome, keys: [...config.matchAll(/^\s*env_key\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/gm)].map((match) => match[1]) };
|
|
303
305
|
}
|
|
306
|
+
export const CODEX_GUARD_REFUSAL = "Codex did not accept AgentRadar's guard hooks, and the agent does not start without its guard. Fix: run `codex update` (an older Codex: `npm install -g @openai/codex@latest`). A connected agent then starts by itself within a minute; during join, run join again.";
|
|
307
|
+
/**
|
|
308
|
+
* `agentradar codex-guard`, which the supervisor runs before every Codex launch. Trust Codex dropped (an upgrade
|
|
309
|
+
* can) is recorded again exactly as join records it, from the vendor's ~/.codex, and Codex is asked once more;
|
|
310
|
+
* only a Codex that still refuses keeps the agent down, with CODEX_GUARD_REFUSAL naming the fix.
|
|
311
|
+
* ponytail: a vendor who joined with a custom CODEX_HOME gets ~/.codex's config on repair; re-run join if it bites.
|
|
312
|
+
*/
|
|
313
|
+
export async function ensureCodexGuard(codexHome, env, home = homedir()) {
|
|
314
|
+
if (await codexGuardTrusted(codexHome, env))
|
|
315
|
+
return;
|
|
316
|
+
const runnerId = basename(codexHome);
|
|
317
|
+
// Only a service home join made; never write trust into any other Codex home.
|
|
318
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(runnerId) || resolve(codexHome) !== join(home, ".agentradar", "codex", runnerId)) {
|
|
319
|
+
throw new Error(CODEX_GUARD_REFUSAL);
|
|
320
|
+
}
|
|
321
|
+
const { CODEX_HOME: _serviceHome, ...vendorEnv } = env;
|
|
322
|
+
await codexServiceHome(home, runnerId, vendorEnv);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Retire-legacy step 1 (Vadym 11054): Codex starts only with AgentRadar's guard. True only when Codex itself lists
|
|
326
|
+
* both guard hooks from `<codexHome>/hooks.json` as trusted. Asked at join and, through `agentradar codex-guard`,
|
|
327
|
+
* by the supervisor before every launch, since a Codex upgrade can drop the trust it recorded.
|
|
328
|
+
*/
|
|
329
|
+
export async function codexGuardTrusted(codexHome, env) {
|
|
330
|
+
const hooks = await codexHookHashes(codexHome, env).catch(() => []);
|
|
331
|
+
return hooks.length === 2 && hooks.every((hook) => hook.trusted);
|
|
332
|
+
}
|
|
304
333
|
/**
|
|
305
334
|
* Codex runs a user hook only once its hash is recorded as trusted (`[hooks.state."<key>"] trusted_hash`),
|
|
306
335
|
* which is what its /hooks review writes. Asks Codex itself for the keys and hashes of the hooks in
|
|
307
336
|
* `<codexHome>/hooks.json` (app-server `hooks/list`), so the hash is never recomputed here. Any failure
|
|
308
|
-
* leaves them untrusted
|
|
337
|
+
* leaves them untrusted, and `codexGuardTrusted` then refuses to start the agent.
|
|
309
338
|
*/
|
|
310
339
|
async function codexHookHashes(codexHome, env) {
|
|
311
340
|
const child = spawn(env.CODEX_BIN || "codex", ["app-server"], { cwd: codexHome, env: { ...env, CODEX_HOME: codexHome }, stdio: ["pipe", "pipe", "ignore"] });
|
|
@@ -332,7 +361,7 @@ async function codexHookHashes(codexHome, env) {
|
|
|
332
361
|
continue;
|
|
333
362
|
return (message.result?.data ?? []).flatMap((entry) => entry.hooks ?? [])
|
|
334
363
|
.filter((hook) => hook.source === "user" && basename(hook.sourcePath) === "hooks.json")
|
|
335
|
-
.map((hook) => ({ key: hook.key, hash: hook.currentHash }));
|
|
364
|
+
.map((hook) => ({ key: hook.key, hash: hook.currentHash, trusted: hook.trustStatus === "trusted" }));
|
|
336
365
|
}
|
|
337
366
|
return [];
|
|
338
367
|
}
|
|
@@ -702,14 +731,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
702
731
|
if (attach) {
|
|
703
732
|
log(`Connected. Your welcome and every client message are typed into this session (tmux ${attach}), and you answer here. Nothing else was started. To stop: agentradar leave ${runnerId}`);
|
|
704
733
|
if (approvedBypass)
|
|
705
|
-
log("Your
|
|
734
|
+
log("Your company allowed bypass-permissions mode; this session keeps the permission mode it already runs in.");
|
|
706
735
|
log("Running as a service; the delivery restarts on crash and reboot.");
|
|
707
736
|
return 0;
|
|
708
737
|
}
|
|
709
|
-
const mode = bypass ? ", in bypass-permissions mode as your
|
|
738
|
+
const mode = bypass ? ", in bypass-permissions mode as your company allowed" : "";
|
|
710
739
|
if (channel) {
|
|
711
740
|
log(`Connected through the AgentRadar channel. This conversation now continues as a service in ${projectPath} (tmux session ${runnerId}${mode}): client messages are pushed into it and it answers there, with no restart from you. Exit this session now (/exit) so only the service carries on; watch it with: tmux attach -t ${runnerId}. To stop it: agentradar leave ${runnerId}`);
|
|
712
|
-
log(`If your
|
|
741
|
+
log(`If your company's Claude plan blocks channels, deliver with the feeder instead: agentradar join ${enrolmentUrl} --feeder`);
|
|
713
742
|
log("Running as a service; it restarts on crash and reboot.");
|
|
714
743
|
return 0;
|
|
715
744
|
}
|
|
@@ -725,7 +754,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
725
754
|
link = new URL(enrolmentUrl);
|
|
726
755
|
}
|
|
727
756
|
catch {
|
|
728
|
-
throw new Error("Pass the join link your
|
|
757
|
+
throw new Error("Pass the join link your company gave you, e.g. https://…/invite/<token>");
|
|
729
758
|
}
|
|
730
759
|
const token = /^\/(?:api\/invites|invite)\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(link.pathname)?.[1];
|
|
731
760
|
if (!token || !trustedMcpEndpoint(link.origin)) {
|
|
@@ -751,7 +780,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
751
780
|
const { envValue } = await import("./tool-run.js");
|
|
752
781
|
const bearer = envValue(saved, "AWP_BEARER");
|
|
753
782
|
if (!bearer)
|
|
754
|
-
throw new Error("This machine left that
|
|
783
|
+
throw new Error("This machine left that company (agentradar leave). A key is issued once; ask your company for a new join link.");
|
|
755
784
|
// `--revoke-bypass` is remembered on this machine, so a later re-run keeps the prompts on.
|
|
756
785
|
if (deps.revokeBypass && state.bypassConfirmed !== false) {
|
|
757
786
|
state = { ...state, bypassConfirmed: false };
|
|
@@ -776,7 +805,7 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
776
805
|
});
|
|
777
806
|
if (!response.ok) {
|
|
778
807
|
throw new Error(response.status === 404
|
|
779
|
-
? "This join link has expired, was already used, or was withdrawn. Ask your
|
|
808
|
+
? "This join link has expired, was already used, or was withdrawn. Ask your company for a new link."
|
|
780
809
|
: `This join link cannot be used. ${await failureMessage(response)}`);
|
|
781
810
|
}
|
|
782
811
|
const accepted = await response.json();
|
|
@@ -786,14 +815,14 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
786
815
|
state = { requestId: accepted.id, claimSecret: accepted.claimSecret, ...(typeof accepted.approvalUrl === "string" ? { approvalUrl: accepted.approvalUrl } : {}) };
|
|
787
816
|
await writePrivateFile(statePath, JSON.stringify(state));
|
|
788
817
|
}
|
|
789
|
-
log("Join request sent. Waiting for your
|
|
818
|
+
log("Join request sent. Waiting for your company to approve it…");
|
|
790
819
|
if (state.approvalUrl)
|
|
791
820
|
log(`Approval link: ${state.approvalUrl}\nSend it to your own human once; they approve after signing in. Never send anything else from this folder.`);
|
|
792
821
|
const claimUrl = api(`join-requests/${encodeURIComponent(state.requestId)}/claim-api-key`);
|
|
793
822
|
// Named off the join request, which exists before the key does, so the server can record it at claim.
|
|
794
823
|
const runnerId = `join-${state.requestId}`;
|
|
795
824
|
for (let poll = 1;; poll++) {
|
|
796
|
-
if (poll > (deps.polls ??
|
|
825
|
+
if (poll > (deps.polls ?? Infinity)) {
|
|
797
826
|
log(`Still waiting for approval. Run this same command again: agentradar run ${enrolmentUrl}`);
|
|
798
827
|
return 0;
|
|
799
828
|
}
|
|
@@ -824,13 +853,13 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
824
853
|
continue;
|
|
825
854
|
}
|
|
826
855
|
if (response.status === 409 && said.includes("rejected")) {
|
|
827
|
-
throw new Error("Your
|
|
856
|
+
throw new Error("Your company declined this join request. Ask it for a new join link.");
|
|
828
857
|
}
|
|
829
858
|
if (response.status === 409 && said.includes("withdrawn")) {
|
|
830
|
-
throw new Error("This join request was withdrawn (agentradar join cancel). Ask your
|
|
859
|
+
throw new Error("This join request was withdrawn (agentradar join cancel). Ask your company for a new join link.");
|
|
831
860
|
}
|
|
832
861
|
if (response.status === 409 && /already (?:used|claimed)/.test(said)) {
|
|
833
|
-
throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your
|
|
862
|
+
throw new Error("This join link's key was already claimed. A key is issued once and cannot be claimed again; ask your company for a new join link.");
|
|
834
863
|
}
|
|
835
864
|
throw new Error(said);
|
|
836
865
|
}
|
|
@@ -863,7 +892,7 @@ export async function cancelJoin(target, deps = {}) {
|
|
|
863
892
|
return 1;
|
|
864
893
|
}
|
|
865
894
|
if (state.agentId) {
|
|
866
|
-
log(`That join was already approved. To leave the
|
|
895
|
+
log(`That join was already approved. To leave the company: agentradar leave join-${state.requestId}`);
|
|
867
896
|
return 1;
|
|
868
897
|
}
|
|
869
898
|
// The secret goes only back to the AgentRadar host that issued it.
|
package/dist/skill-install.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
4
4
|
import { awpRequest, envValue } from "./tool-run.js";
|
|
5
5
|
const MANAGED_MARK = ".agentradar-managed";
|
|
6
6
|
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
@@ -132,20 +132,37 @@ async function writeBrief(file, text) {
|
|
|
132
132
|
await writeFile(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
|
|
133
133
|
await rename(temporary, file);
|
|
134
134
|
}
|
|
135
|
+
/** Where each CLI reads skills. The same table as templates/vendor-runner/feeder.mjs; skill-install.test.ts pins them equal. */
|
|
136
|
+
export const SKILL_DIRS = {
|
|
137
|
+
claude: "$AR_BASE/.claude/skills",
|
|
138
|
+
codex: "$CODEX_HOME/skills",
|
|
139
|
+
gemini: "$HOME/.gemini/skills",
|
|
140
|
+
"cursor-agent": "$HOME/.cursor/skills",
|
|
141
|
+
agent: "$HOME/.cursor/skills",
|
|
142
|
+
pi: "$HOME/.pi/agent/skills",
|
|
143
|
+
hermes: "$HOME/.hermes/skills",
|
|
144
|
+
opencode: "$HOME/.claude/skills",
|
|
145
|
+
};
|
|
146
|
+
export function skillsDirFor(harness, env) {
|
|
147
|
+
const name = harness === "generic" ? basename(env("AR_AGENT_BIN") ?? "") : harness ?? "claude";
|
|
148
|
+
const dir = (SKILL_DIRS[name] ?? SKILL_DIRS.claude).replace(/\$(\w+)/, (_, v) => env(v) ?? "\0");
|
|
149
|
+
return dir.includes("\0") ? null : join(dir);
|
|
150
|
+
}
|
|
135
151
|
export async function syncJoinedSkills(runnerId, deps) {
|
|
136
152
|
const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
|
|
137
|
-
const
|
|
138
|
-
|
|
153
|
+
const harness = envValue(env, "AR_AGENT_HARNESS");
|
|
154
|
+
const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : envValue(env, name));
|
|
155
|
+
if (!skillsDir)
|
|
139
156
|
return;
|
|
140
|
-
const codex =
|
|
141
|
-
const
|
|
157
|
+
const codex = harness === "codex";
|
|
158
|
+
const root = (codex ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE")) ?? "";
|
|
142
159
|
// Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
|
|
143
160
|
// Same file and mark as the runner's feeder, which keeps it fresh from then on.
|
|
144
161
|
const init = await awpRequest(runnerId, "initialize", {
|
|
145
162
|
protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
|
|
146
163
|
}, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
147
164
|
const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
|
|
148
|
-
if (init?.result?.instructions)
|
|
165
|
+
if (root && init?.result?.instructions)
|
|
149
166
|
await writeBrief(briefFile, init.result.instructions);
|
|
150
167
|
const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
151
168
|
const text = body?.result?.content?.find((c) => c.type === "text")?.text;
|
package/dist/tool-run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
|
|
5
|
-
import { newer, runningVersion } from "./update.js";
|
|
5
|
+
import { cliHeader, newer, runningVersion } from "./update.js";
|
|
6
6
|
/**
|
|
7
7
|
* One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
|
|
8
8
|
* Nothing here is printed: the file holds the runner's bearer.
|
|
@@ -238,6 +238,10 @@ function coerce(type, value) {
|
|
|
238
238
|
return type === undefined ? value : undefined;
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
+
// The parity test (server human-metrics-ext-realpg) fails when report_metric gains an argument this line does not name.
|
|
242
|
+
export const METRIC_REPORT_USAGE = "Usage: agentradar metric report <runner-id> --metric-id <id> --value <n> --period-start <iso> --period-end <iso> [--evidence <text>] [--from-event-id <id>] [--comment <text>]";
|
|
243
|
+
// The parity test (server r013-propose-human-realpg) fails when propose_human gains an argument this line does not name.
|
|
244
|
+
export const PROPOSE_HUMAN_USAGE = "Usage: agentradar propose-human <runner-id> --name <name> --email <email> --role-id <id>";
|
|
241
245
|
// The parity test (server listing-description-realpg) fails when set_listing_description gains an argument this line does not name.
|
|
242
246
|
export const LISTING_DESCRIBE_USAGE = "Usage: agentradar listing describe <runner-id> --capabilities <text>";
|
|
243
247
|
// The parity test (server inbox-search-realpg) fails when inbox_search gains an argument this line does not name.
|
|
@@ -267,7 +271,7 @@ function inboxLines(text) {
|
|
|
267
271
|
return [...results.map((r) => `${r.at} ${r.channel} ${r.sender?.name ?? r.from ?? ""}\n thread ${r.threadId} message ${r.messageId}\n ${r.snippet}`), ...pages].join("\n");
|
|
268
272
|
}
|
|
269
273
|
/**
|
|
270
|
-
* Vadym 10719 — `agentradar permissions mine|roles|set|request <runner-id> [--<argument> <value> ...]`: the MCP permission
|
|
274
|
+
* Vadym 10719 — `agentradar permissions mine|roles|set|request|grant-tool <runner-id> [--<argument> <value> ...]`: the MCP permission
|
|
271
275
|
* tools on the same endpoint with the same key. The parity test (server permission-tools-realpg) fails when one drifts.
|
|
272
276
|
*/
|
|
273
277
|
export const PERMISSION_VERB_TOOLS = {
|
|
@@ -275,6 +279,7 @@ export const PERMISSION_VERB_TOOLS = {
|
|
|
275
279
|
roles: "read_role_permissions",
|
|
276
280
|
set: "set_role_permission",
|
|
277
281
|
request: "request_permission",
|
|
282
|
+
"grant-tool": "propose_tool_assignment",
|
|
278
283
|
};
|
|
279
284
|
/**
|
|
280
285
|
* Ruling 49 — `agentradar task create|repeat|make-recurring <runner-id> --args '<json>'`: the agent's own
|
|
@@ -402,6 +407,7 @@ async function postAwp(url, bearer, method, params, deps) {
|
|
|
402
407
|
const response = await (deps.fetch ?? fetch)(url, {
|
|
403
408
|
method: "POST",
|
|
404
409
|
headers: {
|
|
410
|
+
...(await cliHeader()),
|
|
405
411
|
authorization: `Bearer ${bearer}`,
|
|
406
412
|
"content-type": "application/json",
|
|
407
413
|
// Both, because the endpoint answers JSON only when the client says it takes JSON.
|
|
@@ -415,7 +421,9 @@ async function postAwp(url, bearer, method, params, deps) {
|
|
|
415
421
|
}),
|
|
416
422
|
});
|
|
417
423
|
if (!response.ok) {
|
|
418
|
-
|
|
424
|
+
// 426: this CLI is below the server's minimum; its sentence says how to upgrade.
|
|
425
|
+
const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
|
|
426
|
+
logError(said ?? `AgentRadar refused the tool call (${response.status}).`);
|
|
419
427
|
return null;
|
|
420
428
|
}
|
|
421
429
|
const body = await response.json();
|
package/dist/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -8,12 +8,16 @@ import { installCli } from "./setup.js";
|
|
|
8
8
|
const LATEST = "https://registry.npmjs.org/agentradar-cli/latest";
|
|
9
9
|
const TARBALL = /^https:\/\/registry\.npmjs\.org\/agentradar-cli\/-\/agentradar-cli-\d+\.\d+\.\d+\.tgz$/;
|
|
10
10
|
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
11
|
+
/** How long a version that failed its check twice is skipped by the daily install. */
|
|
12
|
+
const BAD_FOR_MS = 24 * 3_600_000;
|
|
11
13
|
/** This package's folder: the CLI, the runner scripts and the agent-guard plugin all live in it. */
|
|
12
14
|
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
15
|
/** A service's copy, made by `agentradar join` at ~/.agentradar/cli/<version>; the service runs ~/.agentradar/cli/current. */
|
|
14
16
|
const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
|
|
15
17
|
/** The version this process runs, whatever `current` points at now. */
|
|
16
18
|
export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
19
|
+
/** Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2). */
|
|
20
|
+
export const cliHeader = async () => ({ "x-agentradar-cli": await runningVersion().catch(() => "") });
|
|
17
21
|
/** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
|
|
18
22
|
export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
|
|
19
23
|
export const newer = (a, b) => {
|
|
@@ -45,6 +49,12 @@ export async function selfUpdate(deps = {}) {
|
|
|
45
49
|
const version = latest.version ?? "";
|
|
46
50
|
if (!newer(version, current))
|
|
47
51
|
return null;
|
|
52
|
+
// A version a runner on this machine already reverted (self-release card 3) is not installed again.
|
|
53
|
+
// For a day: the failure may have been ours (the network), so the version gets another chance after that, and
|
|
54
|
+
// its check runs again before any restart.
|
|
55
|
+
const bad = await stat(join(dirname(root), `${version}.bad`)).catch(() => null);
|
|
56
|
+
if (serviceCopy(root) && bad && Date.now() - bad.mtimeMs < BAD_FOR_MS)
|
|
57
|
+
return null;
|
|
48
58
|
const tarballUrl = latest.dist?.tarball ?? "";
|
|
49
59
|
const integrity = latest.dist?.integrity ?? "";
|
|
50
60
|
if (!TARBALL.test(tarballUrl) || !tarballUrl.endsWith(`-${version}.tgz`) || !integrity.startsWith("sha512-")) {
|
|
@@ -91,4 +101,16 @@ export async function selfUpdate(deps = {}) {
|
|
|
91
101
|
}
|
|
92
102
|
return version;
|
|
93
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Self-release card 3: put a failed version aside. `current` points back at the version still running (its folder
|
|
106
|
+
* is untouched), and `<version>.bad` stops the daily check installing the failed one again.
|
|
107
|
+
*/
|
|
108
|
+
export async function revertCli(home, to, bad) {
|
|
109
|
+
const cli = join(home, ".agentradar", "cli");
|
|
110
|
+
const link = join(cli, `current.${process.pid}.revert`);
|
|
111
|
+
await rm(link, { force: true });
|
|
112
|
+
await symlink(to, link);
|
|
113
|
+
await rename(link, join(cli, "current"));
|
|
114
|
+
await writeFile(join(cli, `${bad}.bad`), `${new Date().toISOString()} failed its check; reverted to ${to}\n`);
|
|
115
|
+
}
|
|
94
116
|
//# sourceMappingURL=update.js.map
|
|
@@ -25,7 +25,7 @@ import fs from "node:fs";
|
|
|
25
25
|
import http from "node:http";
|
|
26
26
|
import https from "node:https";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
|
-
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
28
|
+
import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
|
|
31
31
|
|
|
@@ -96,10 +96,28 @@ const PANE_MCP_TIMEOUT_MS = Number(process.env.PANE_MCP_TIMEOUT_MS ?? "10000");
|
|
|
96
96
|
// R650 — the agent's AgentRadar skills (bundled + linked) are written into its CLI's skills directory on this
|
|
97
97
|
// cadence. Only directories this feeder created (they carry MANAGED_MARK) are ever replaced or removed; a
|
|
98
98
|
// skill the vendor put there by hand is left alone. 0 disables.
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Where each CLI reads skills, the same paths the heartbeat adapters use. Claude (and Grok, which reads the
|
|
101
|
+
* workspace's `.claude/skills`) is the default; Codex reads `$CODEX_HOME/skills`, the per-agent home the supervisor
|
|
102
|
+
* requires. A `generic` runner is looked up by its binary's name. packages/agentradar-cli/src/skill-install.ts
|
|
103
|
+
* keeps the same table (skill-install.test.ts pins them equal).
|
|
104
|
+
* ponytail: gemini/cursor/pi/opencode/hermes read $HOME, so two joined agents on one box share one directory; add a
|
|
105
|
+
* per-agent HOME when that happens.
|
|
106
|
+
*/
|
|
107
|
+
export const SKILL_DIRS = {
|
|
108
|
+
claude: "$AR_BASE/.claude/skills",
|
|
109
|
+
codex: "$CODEX_HOME/skills",
|
|
110
|
+
gemini: "$HOME/.gemini/skills",
|
|
111
|
+
"cursor-agent": "$HOME/.cursor/skills",
|
|
112
|
+
agent: "$HOME/.cursor/skills",
|
|
113
|
+
pi: "$HOME/.pi/agent/skills",
|
|
114
|
+
hermes: "$HOME/.hermes/skills",
|
|
115
|
+
opencode: "$HOME/.claude/skills",
|
|
116
|
+
};
|
|
100
117
|
export function skillsDirFor(harness, env) {
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
const name = harness === "generic" ? basename(env.AR_AGENT_BIN ?? "") : harness;
|
|
119
|
+
const dir = (SKILL_DIRS[name] ?? SKILL_DIRS.claude).replace(/\$(\w+)/, (_, v) => env[v] ?? "\0");
|
|
120
|
+
return dir.includes("\0") ? null : join(dir);
|
|
103
121
|
}
|
|
104
122
|
const SKILLS_DIR = skillsDirFor(AR_AGENT_HARNESS, process.env);
|
|
105
123
|
/**
|
|
@@ -974,7 +992,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
974
992
|
// The server treats non-ready calls as status-only heartbeats: no drain and no ack. Keep any pending ack
|
|
975
993
|
// until readiness returns, and defensively ignore events from an older server that does not know the gate.
|
|
976
994
|
const canWork = state === "ready";
|
|
977
|
-
const { events = [], livenessProbe, welcome, modelSwitch } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
|
|
995
|
+
const { events = [], livenessProbe, welcome, modelSwitch, firstStep } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
|
|
978
996
|
_nextSwitch = modelSwitch ?? null;
|
|
979
997
|
if (!canWork) {
|
|
980
998
|
await sleep(IDLE_DELAY_MS);
|
|
@@ -997,7 +1015,7 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
997
1015
|
for (const e of events) {
|
|
998
1016
|
// Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
|
|
999
1017
|
// eslint-disable-next-line no-await-in-loop
|
|
1000
|
-
if (await deliverEvent(e)) delivered.push(e.eventId);
|
|
1018
|
+
if (await deliverEvent(firstStep ? { ...e, firstStep } : e)) delivered.push(e.eventId);
|
|
1001
1019
|
// eslint-disable-next-line no-await-in-loop
|
|
1002
1020
|
else break; // pane went away mid-batch → stop; unacked events re-deliver in order.
|
|
1003
1021
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -23,7 +23,7 @@ function oneLine(s) {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Build the single-line injection frame for one drained next_event event.
|
|
26
|
-
* event: { eventId, source, channel?, conversationId?, text }
|
|
26
|
+
* event: { eventId, source, channel?, conversationId?, text, firstStep? }
|
|
27
27
|
* cliRunnerId: set when the session acts through `agentradar run <id> <tool>` (a joined agent) rather than an
|
|
28
28
|
* MCP connection — the command its human already chose "always allow" for, where an MCP tool would prompt.
|
|
29
29
|
* Returns a one-line string safe to `tmux send-keys -l` then Enter.
|
|
@@ -35,7 +35,8 @@ export function formatInjectionFrame(event, cliRunnerId) {
|
|
|
35
35
|
if (e.conversationId) tags.push(`conversationId=${e.conversationId}`);
|
|
36
36
|
if (e.taskId) tags.push(`taskId=${e.taskId}`); // assignment events: the agent works THIS task, not one dug out of its log
|
|
37
37
|
if (e.eventId) tags.push(`eventId=${e.eventId}`);
|
|
38
|
-
|
|
38
|
+
// Vadym 11025: the server's `firstStep` (get_my_context first) opens every frame that carries work.
|
|
39
|
+
const header = `[AgentRadar inbound · ${tags.join(" · ")}]${e.firstStep ? ` ${oneLine(e.firstStep)} Message:` : ""}`;
|
|
39
40
|
const body = oneLine(e.text) || "(no text)";
|
|
40
41
|
const evId = e.eventId ? String(e.eventId) : null;
|
|
41
42
|
// The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
|
|
@@ -317,8 +317,25 @@ ensure_agent() {
|
|
|
317
317
|
"${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
|
|
318
318
|
fi
|
|
319
319
|
[ -n "${AR_GUARD_MARKER}" ] && rm -f "${AR_GUARD_MARKER}" # this launch must prove the guard afresh
|
|
320
|
+
# Retire-legacy step 1 (Vadym 11054): Codex starts only with AgentRadar's guard. Its SessionStart hook does not
|
|
321
|
+
# fire at an idle start, so there is no marker to wait for; Codex is asked, before every launch, whether it
|
|
322
|
+
# trusts both guard hooks (a Codex upgrade can drop that trust). No answer, no start.
|
|
323
|
+
# The guard records lost trust again itself; what it still refuses, its message names the fix for.
|
|
324
|
+
if [ "${AR_AGENT_HARNESS}" = "codex" ] && ! _guard_out=$(CODEX_HOME="${CODEX_HOME}" CODEX_BIN="${AGENT_BIN}" node "${AR_AGENTRADAR_BIN:-${HOME}/.agentradar/cli/current/dist/index.js}" codex-guard 2>&1); then
|
|
325
|
+
die "not starting Codex: ${_guard_out:-the AgentRadar guard check gave no answer}"
|
|
326
|
+
fi
|
|
320
327
|
_resume_now="${AR_RESUME_FLAGS}"
|
|
321
|
-
if [ "${_FORCE_FRESH:-0}" = 1 ]; then
|
|
328
|
+
if [ "${_FORCE_FRESH:-0}" = 1 ]; then # poisoned history: do not resume it
|
|
329
|
+
_resume_now="${AR_FRESH_FLAGS}"
|
|
330
|
+
# `agentradar join` writes AR_FRESH_FLAGS as the same `--resume <id>` as AR_RESUME_FLAGS, so a pinned session
|
|
331
|
+
# (either flag names an id) is healed onto a NEW id, generated now; later restarts resume that one, never the poisoned id.
|
|
332
|
+
case "${AR_AGENT_HARNESS}:${AR_RESUME_FLAGS} ${AR_FRESH_FLAGS}" in
|
|
333
|
+
claude:*--resume\ *|claude:*--session-id\ *)
|
|
334
|
+
_new_sid="$(node -e 'process.stdout.write(require("crypto").randomUUID())')" || die "could not generate a fresh session id"
|
|
335
|
+
_resume_now="--session-id ${_new_sid}"; AR_RESUME_FLAGS="--resume ${_new_sid}"; AR_FRESH_FLAGS="--session-id ${_new_sid}" ;;
|
|
336
|
+
esac
|
|
337
|
+
_FORCE_FRESH=0
|
|
338
|
+
fi
|
|
322
339
|
ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
|
|
323
340
|
if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
|
|
324
341
|
log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
|
|
@@ -348,6 +365,8 @@ ensure_agent() {
|
|
|
348
365
|
# #166: pass the tool creds as a FILE PATH (AR_TOOL_ENV_FILE), never inline KEY=VALUE on env -i.
|
|
349
366
|
_tool_env=()
|
|
350
367
|
[ -n "${_TOOL_ENV_FILE}" ] && _tool_env=( AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" )
|
|
368
|
+
# Bible #40 mode 3: role secrets pulled into ${AR_BASE}/.agentradar/secrets.enc are decrypted by
|
|
369
|
+
# `agentradar secrets env` inside the wrapper and exported in memory, never written in plaintext.
|
|
351
370
|
# #1: do NOT inherit TERM. Under launchd the supervisor's TERM is `dumb` (or absent), and `dumb` is
|
|
352
371
|
# neither unset nor empty, so the old `${TERM:-xterm-256color}` default never fired and the agent's
|
|
353
372
|
# claude ran with TERM=dumb. chalk/ink then disable colour ENTIRELY, so the TUI emitted zero SGR —
|
|
@@ -382,7 +401,7 @@ ensure_agent() {
|
|
|
382
401
|
CODEX_HOME="${CODEX_HOME:-}" AR_RESUME_FLAGS="${_resume_now}" AR_FRESH_FLAGS="${AR_FRESH_FLAGS}" \
|
|
383
402
|
DISABLE_AUTOUPDATER=1 CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF=1 \
|
|
384
403
|
${_claude_cfg[@]+"${_claude_cfg[@]}"} ${_tool_env[@]+"${_tool_env[@]}"} \
|
|
385
|
-
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_GUARD_MARKER|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
|
|
404
|
+
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC|CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF|CODEX_HOME|AR_CLI_RUNNER_ID|AR_AGENTRADAR_BIN|AR_DISCIPLINE_EVERY|CLAUDE_CODE_PLUGIN_CACHE_DIR|AR_GUARD_MARKER|AR_RESUME_FLAGS|AR_FRESH_FLAGS|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; if [ -n "${AR_CLI_RUNNER_ID:-}" ] && [ -n "${AR_AGENTRADAR_BIN:-}" ] && [ -f .agentradar/secrets.enc ]; then _e=$("$AR_AGENTRADAR_BIN" secrets env "$AR_CLI_RUNNER_ID") && eval "$_e"; unset _e; fi; RF="${AR_RESUME_FLAGS:-}"; FF="${AR_FRESH_FLAGS:-}"; unset AR_RESUME_FLAGS AR_FRESH_FLAGS; CB="$1"; shift; S=$(date +%s); "$CB" $RF "$@" || [ $(( $(date +%s) - S )) -ge 30 ] || exec "$CB" $FF "$@"' sh "${AGENT_BIN}" "${_launch_flags[@]}" || {
|
|
386
405
|
log "tmux could not start agent session '${TMUX_SESSION}'"
|
|
387
406
|
return 1
|
|
388
407
|
}
|
|
@@ -465,7 +484,20 @@ start_bridge
|
|
|
465
484
|
# launchd/systemd remains the parent-level/reboot safety net. The feeder self-pauses while the owned pane
|
|
466
485
|
# next_event heartbeat cannot paint the runner Live during recovery. Restart the bridge after a new pane is
|
|
467
486
|
# created because tmux pipe-pane was bound to the dead pane.
|
|
487
|
+
# Bible #40: a pulled role-secret lease lives 24 h; renew it every 12 h while the agent still holds the role.
|
|
488
|
+
SECRETS_RENEW_SEC="${SECRETS_RENEW_SEC:-43200}"
|
|
489
|
+
SECRETS_RETRY_SEC="${SECRETS_RETRY_SEC:-600}"
|
|
490
|
+
_secrets_renewed_at=0
|
|
468
491
|
while true; do
|
|
492
|
+
if [ -n "${AR_CLI_RUNNER_ID:-}" ] && [ -n "${AR_AGENTRADAR_BIN:-}" ] && [ -f "${AR_BASE}/.agentradar/secrets.enc" ] \
|
|
493
|
+
&& [ $(( $(date +%s) - _secrets_renewed_at )) -ge "${SECRETS_RENEW_SEC}" ]; then
|
|
494
|
+
if (cd "${AR_BASE}" && "${AR_AGENTRADAR_BIN}" secrets renew "${AR_CLI_RUNNER_ID}") >/dev/null 2>&1; then
|
|
495
|
+
_secrets_renewed_at=$(date +%s)
|
|
496
|
+
else
|
|
497
|
+
log "WARN role-secret lease renewal refused or failed; retrying in ${SECRETS_RETRY_SEC}s"
|
|
498
|
+
_secrets_renewed_at=$(( $(date +%s) - SECRETS_RENEW_SEC + SECRETS_RETRY_SEC ))
|
|
499
|
+
fi
|
|
500
|
+
fi
|
|
469
501
|
if [ -n "${FEEDER_PID}" ] && ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
|
|
470
502
|
log "feeder exited — exiting so the service manager restarts the whole unit"
|
|
471
503
|
exit 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=20"
|
|
6
6
|
},
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dist/update.js",
|
|
22
22
|
"dist/commands.js",
|
|
23
23
|
"dist/vault-clone.js",
|
|
24
|
+
"dist/secrets.js",
|
|
24
25
|
"dist/vendor-runner/**",
|
|
25
26
|
"dist/agentradar-vendor/SKILL.md",
|
|
26
27
|
"dist/agent-guard",
|