agentradar-cli 0.3.9 → 0.3.10

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/github.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Vadym 18123: an agent someone gave GitHub access uses plain `gh` and `git` as itself, with nothing to set up.
3
+ *
4
+ * setup writes the runner's `gh` shim and a git config naming this CLI as git's credential helper for the agent's
5
+ * GitHub host: the AgentRadar proxy host for a linked account (remotes of the client's orgs are rewritten to it;
6
+ * any other github.com remote stays plain git), else
7
+ * github.com for a GitHub App token. The supervisor runs it at launch and puts both on the agent's PATH and
8
+ * GIT_CONFIG; `gh` and `git` then sign in as the agent.
9
+ * credential git's credential helper: mints a fresh `github_token` for each request and hands it to git on its pipe.
10
+ * gh the shim's target: mints a token and runs the real `gh` with it in the environment. A command whose repositories
11
+ * all belong to someone else (a public repo, an open-source checkout, the agent's own account) is refused, with why,
12
+ * except `gh api`: the proxy serves GET on public repositories of any owner (GH-PROXY-PUBLIC-READS).
13
+ * A token is never written to disk or put on a command line. Without one, gh runs as it would have (the machine's login).
14
+ */
15
+ import { execFile, spawn } from "node:child_process";
16
+ import { constants } from "node:fs";
17
+ import { access, chmod, mkdir, realpath, rename, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { delimiter, join } from "node:path";
20
+ import { awpRequest, RUNNER_ID } from "./tool-run.js";
21
+ export const GITHUB_USAGE = "Usage: agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get";
22
+ const HOST = /^[A-Za-z0-9.-]+(:\d+)?$/;
23
+ const OWNER = /^[A-Za-z0-9-]+$/;
24
+ export const githubDir = (runnerId, deps) => join(deps.home ?? homedir(), ".agentradar", "run", "github", runnerId);
25
+ const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
26
+ /** One `github_token` call with the runner's own key. */
27
+ export async function mintGithub(runnerId, deps) {
28
+ const said = await awpRequest(runnerId, "tools/call", { name: "github_token", arguments: {} }, deps);
29
+ const text = said?.result?.content?.[0]?.text ?? "";
30
+ let body;
31
+ try {
32
+ body = JSON.parse(text);
33
+ }
34
+ catch {
35
+ return { error: text || "AgentRadar did not answer" };
36
+ }
37
+ if (said?.result?.isError || !body.ok || !body.token)
38
+ return { error: body.error ?? text };
39
+ if (body.host !== undefined && !HOST.test(body.host))
40
+ return { error: "AgentRadar returned an invalid GitHub host" };
41
+ const owners = Array.isArray(body.owners) ? body.owners.filter((o) => typeof o === "string" && OWNER.test(o)) : undefined;
42
+ return { token: body.token, host: body.host, owners, ...(typeof body.login === "string" && OWNER.test(body.login) ? { login: body.login } : {}) };
43
+ }
44
+ /** The include file the supervisor points GIT_CONFIG at; written whole and renamed, so git never reads half of it. */
45
+ async function writeGitconfig(runnerId, { host, owners }, bin, deps) {
46
+ const dir = githubDir(runnerId, deps);
47
+ const helper = `!${sq(bin)} github credential ${runnerId}`.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
48
+ // The empty helper drops the machine's own helpers (a keychain login) for this host, so git signs in as the agent.
49
+ const lines = [`[credential "https://${host ?? "github.com"}"]`, "\thelper =", `\thelper = "${helper}"`];
50
+ // Only the client's orgs go through the proxy; a public repo or the vendor's own stays plain git. An older AgentRadar
51
+ // that names no owners gets the old whole-github.com rewrite.
52
+ // ponytail: insteadOf is a case-sensitive prefix; a remote spelling the org in other capitals than GitHub's stays plain git.
53
+ if (host)
54
+ for (const owner of owners ? [...new Set(owners.flatMap((o) => [o, o.toLowerCase()]))].map((o) => `${o}/`) : [""]) {
55
+ lines.push(`[url "https://${host}/${owner}"]`, `\tinsteadOf = https://github.com/${owner}`, `\tinsteadOf = git@github.com:${owner}`, `\tinsteadOf = ssh://git@github.com/${owner}`);
56
+ }
57
+ await mkdir(dir, { recursive: true });
58
+ const tmp = join(dir, `gitconfig.${process.pid}`);
59
+ await writeFile(tmp, lines.join("\n") + "\n", { mode: 0o600 });
60
+ await rename(tmp, join(dir, "gitconfig"));
61
+ }
62
+ export async function githubSetup(runnerId, deps) {
63
+ const logError = deps.logError ?? console.error;
64
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
65
+ if (!bin) {
66
+ logError("AR_AGENTRADAR_BIN is not set.");
67
+ return 1;
68
+ }
69
+ const shim = join(githubDir(runnerId, deps), "bin", "gh");
70
+ await mkdir(join(githubDir(runnerId, deps), "bin"), { recursive: true });
71
+ await writeFile(shim, `#!/bin/sh\nexec ${sq(bin)} github gh ${runnerId} -- "$@"\n`);
72
+ await chmod(shim, 0o755);
73
+ const minted = await mintGithub(runnerId, deps);
74
+ // A failed mint keeps the last config: no GitHub access yet, or a passing outage, is not a reason to drop it.
75
+ if ("error" in minted) {
76
+ logError(`No GitHub token for this agent: ${minted.error}`);
77
+ return 1;
78
+ }
79
+ await writeGitconfig(runnerId, minted, bin, deps);
80
+ return 0;
81
+ }
82
+ /** git's `credential.helper` protocol: answer `get` for the agent's own host, stay silent for any other. */
83
+ export async function githubCredential(runnerId, op, deps) {
84
+ if (op !== "get")
85
+ return 0;
86
+ const input = await (deps.stdin ?? (async () => { const chunks = []; for await (const chunk of process.stdin)
87
+ chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); }))();
88
+ const asked = Object.fromEntries(input.split("\n").filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
89
+ const minted = await mintGithub(runnerId, deps);
90
+ if ("error" in minted) {
91
+ (deps.logError ?? console.error)(`agentradar: no GitHub token for this agent (${minted.error}).`);
92
+ return 0;
93
+ }
94
+ // A link or unlink since launch moves the host: rewrite the config so the next git call goes to the right one.
95
+ const bin = (deps.env ?? process.env).AR_AGENTRADAR_BIN;
96
+ if (bin)
97
+ await writeGitconfig(runnerId, minted, bin, deps).catch(() => { });
98
+ if (asked.protocol === "https" && asked.host === (minted.host ?? "github.com")) {
99
+ (deps.log ?? console.log)(`username=x-access-token\npassword=${minted.token}`);
100
+ }
101
+ return 0;
102
+ }
103
+ /** The real `gh` on PATH, skipping this runner's own shim however PATH spells it (a HOME ending in "/", a symlink). */
104
+ async function realGh(shim, env) {
105
+ const self = await realpath(shim).catch(() => shim);
106
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
107
+ if (!dir || !(await access(join(dir, "gh"), constants.X_OK).then(() => true, () => false)))
108
+ continue;
109
+ if (await realpath(join(dir, "gh")).catch(() => "") !== self)
110
+ return join(dir, "gh");
111
+ }
112
+ return null;
113
+ }
114
+ const IN_URL = /(?:^|[/@])github\.com[/:]([A-Za-z0-9-]+)(?=\/|$)/gim;
115
+ const ownerOf = (value = "") => value.match(new RegExp(IN_URL.source, "i"))?.[1] ?? value.match(/^([A-Za-z0-9-]+)\/[A-Za-z0-9._-]+$/)?.[1];
116
+ /** The repository owners a gh command names: -R/--repo, `gh repo <verb> OWNER/REPO`, github.com URLs, `gh api` repos|orgs|users paths. */
117
+ export function ghTargets(args) {
118
+ return args.flatMap((arg, i) => {
119
+ const named = arg === "-R" || arg === "--repo" ? ownerOf(args[i + 1]) : arg.startsWith("--repo=") ? ownerOf(arg.slice(7))
120
+ : /^-R./.test(arg) ? ownerOf(arg.slice(2)) : new RegExp(IN_URL.source, "i").test(arg) ? ownerOf(arg)
121
+ : args[0] === "api" ? arg.match(/^\/?(?:repos|orgs|users)\/([A-Za-z0-9-]+)/)?.[1]
122
+ : args[0] === "repo" && i === 2 && !arg.startsWith("-") ? ownerOf(arg) : undefined;
123
+ return named ? [named] : [];
124
+ });
125
+ }
126
+ /**
127
+ * The owner gh must not run for, or null for the proxy. Named owners decide; without any, the checkout's remotes do
128
+ * (a client's remote is rewritten to the proxy host). Nothing to go on (no repo, `gh api user`) stays on the proxy.
129
+ */
130
+ async function outsideOwner(args, host, owners, env, cwd) {
131
+ let targets = ghTargets(args);
132
+ if (!targets.length) {
133
+ const remotes = await new Promise((resolve) => execFile("git", ["remote", "-v"], { env, cwd, timeout: 5000 }, (_e, out) => resolve(String(out ?? ""))));
134
+ if (remotes.includes(`://${host}/`))
135
+ return null;
136
+ targets = [...remotes.matchAll(IN_URL)].map((m) => m[1]);
137
+ }
138
+ const client = new Set(owners.map((o) => o.toLowerCase()));
139
+ return targets.length && !targets.some((t) => client.has(t.toLowerCase())) ? targets[0] : null;
140
+ }
141
+ export async function githubGh(runnerId, args, deps) {
142
+ const logError = deps.logError ?? console.error;
143
+ const env = deps.env ?? process.env;
144
+ const gh = await realGh(join(githubDir(runnerId, deps), "bin", "gh"), env);
145
+ if (!gh) {
146
+ logError("agentradar: gh is not installed on this machine.");
147
+ return 127;
148
+ }
149
+ // Already inside the shim (gh running gh, or a PATH the check above still missed): the token is in the environment,
150
+ // so run gh as is and mint nothing. Without this a loop mints a token at every level.
151
+ if (env.AR_GH_SHIMMED === "1")
152
+ return await run(gh, args, env, deps.cwd);
153
+ const minted = await mintGithub(runnerId, deps);
154
+ const childEnv = { ...env, AR_GH_SHIMMED: "1" };
155
+ if ("error" in minted) {
156
+ logError(`agentradar: no GitHub token for this agent (${minted.error}); running gh with this machine's own login.`);
157
+ }
158
+ else {
159
+ if (env.AR_AGENTRADAR_BIN)
160
+ await writeGitconfig(runnerId, minted, env.AR_AGENTRADAR_BIN, deps).catch(() => { });
161
+ const outside = minted.host && minted.owners && args[0] !== "api" ? await outsideOwner(args, minted.host, minted.owners, env, deps.cwd) : null;
162
+ if (outside) {
163
+ // Not run at all: gh signed out refuses every command ("gh auth login", exit 4), and the agent's token reaches only
164
+ // the client's orgs. Anonymous git still clones public repositories.
165
+ const clients = minted.owners.join(", ");
166
+ const reply = "run `agentradar run $AR_CLI_RUNNER_ID reply` in Slack, or `agentradar run $AR_CLI_RUNNER_ID telegram_reply`, `agentradar run $AR_CLI_RUNNER_ID email_reply`, " +
167
+ "`agentradar run $AR_CLI_RUNNER_ID whatsapp_reply` or `agentradar run $AR_CLI_RUNNER_ID discord_reply` for the conversation you work in";
168
+ logError(outside.toLowerCase() === minted.login?.toLowerCase()
169
+ ? `agentradar: ${outside} is your own GitHub account, so gh did not run: repositories under your own account are read-only by design. ` +
170
+ `Read public ones with \`gh api repos/${outside}/<repo>\` or git clone; private ones there are not reachable. Your work belongs in your client's GitHub orgs (${clients}). ` +
171
+ `If you need a repository there, ask your client to create it: ${reply}.`
172
+ : `agentradar: gh works only on your client's GitHub orgs (${clients}), so it did not run for ${outside}. To read a public repository there, ` +
173
+ `use \`gh api repos/${outside}/<repo>\` (reads only) or git clone https://github.com/${outside}/<repo>. If ${outside} is your client's, ask your client to add it: ${reply}.`);
174
+ return 1;
175
+ }
176
+ if (minted.host)
177
+ Object.assign(childEnv, { GH_HOST: minted.host, GH_ENTERPRISE_TOKEN: minted.token });
178
+ else
179
+ childEnv.GH_TOKEN = minted.token;
180
+ }
181
+ return await run(gh, args, childEnv, deps.cwd);
182
+ }
183
+ const run = (bin, args, env, cwd) => new Promise((resolve) => {
184
+ const child = spawn(bin, args, { stdio: "inherit", env, cwd });
185
+ child.on("error", () => resolve(127));
186
+ child.on("exit", (code) => resolve(code ?? 1));
187
+ });
188
+ export async function githubOverCli(args, deps = {}) {
189
+ const [verb, runnerId, ...rest] = args;
190
+ if (!runnerId || !RUNNER_ID.test(runnerId)) {
191
+ (deps.logError ?? console.error)(GITHUB_USAGE);
192
+ return 2;
193
+ }
194
+ try {
195
+ if (verb === "setup" && !rest.length)
196
+ return await githubSetup(runnerId, deps);
197
+ if (verb === "credential" && rest.length === 1)
198
+ return await githubCredential(runnerId, rest[0], deps);
199
+ if (verb === "gh" && rest[0] === "--")
200
+ return await githubGh(runnerId, rest.slice(1), deps);
201
+ }
202
+ catch (error) {
203
+ (deps.logError ?? console.error)(error instanceof Error ? error.message : String(error));
204
+ return 1;
205
+ }
206
+ (deps.logError ?? console.error)(GITHUB_USAGE);
207
+ return 2;
208
+ }
209
+ //# sourceMappingURL=github.js.map
package/dist/index.js CHANGED
@@ -4,14 +4,15 @@ import { homedir } from "node:os";
4
4
  import { installVendorSkill } from "./skill-install.js";
5
5
  import { startChannel } from "./channel.js";
6
6
  import { runRestartSelftest, startVendorRunner } from "./runner.js";
7
- import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
- import { callAwpTool, callToolOverCli, EXIT, parseCallArgs } from "./tool-run.js";
7
+ import { cancelJoin, CODEX_GUARD_REFUSAL, dropStaleMcp, ensureCodexGuard, joinOrganisation, joinRuntime, leaveOrganisation, setupVendorAgent } from "./setup.js";
8
+ import { callAwpTool, callToolOverCli, EXIT, parseCallArgs, schemaOverCli } from "./tool-run.js";
9
9
  import { installSyncTimer, syncAgentDir } from "./sync.js";
10
10
  import { importVaultOverCli } from "./vault-import.js";
11
11
  import { cloneVaultOverCli } from "./vault-clone.js";
12
12
  import { ASSET_USAGE, getAssetOverCli } from "./asset-get.js";
13
13
  import { secretsOverCli } from "./secrets.js";
14
14
  import { adminOverCli } from "./admin.js";
15
+ import { githubOverCli } from "./github.js";
15
16
  // `run` is the one prefix every agent-facing command shares, so one harness "always allow agentradar run"
16
17
  // covers a whole session: `run <link>` (or `run join <link>`) joins, `run <runner-id> <tool>` calls a tool.
17
18
  export const runArgs = (raw) => raw[0] !== "run" ? raw
@@ -29,22 +30,24 @@ const USAGE = [
29
30
  " The runner is $AR_CLI_RUNNER_ID, or --runner <id>. --dry-run prints the exact call and sends nothing.",
30
31
  " Exit codes: 0 done; 1 refused or unreachable (the reason is on stderr); 2 the command was wrong.",
31
32
  "Local commands:",
32
- " agentradar join <join-link> [--name <n>] [--model <m>] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
33
+ " agentradar join <join-link> [--name <n>] [--model <m>] [--runtime claude|codex|other] [--trust] [--attach-tmux <pane> | --attach-current] [--session <id>] [--no-service | --service]",
33
34
  " agentradar join cancel <join-link|request-id>",
34
35
  " agentradar setup [--trust] [--harness claude|codex]",
35
36
  " agentradar leave <runner-id>",
36
37
  " agentradar channel <runner-id>",
37
38
  " agentradar selftest <runner-id> [--status|--restart]",
38
39
  " agentradar runner start <runner-id>",
39
- " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>']",
40
+ " agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->]",
40
41
  " agentradar vault import <runner-id> <dir> [--vault <vault-id>] [--source <name>] [--write]",
41
42
  " agentradar vault clone <runner-id> <vault-id> [dir]",
42
43
  " agentradar asset get <runner-id> <asset-id> [file]",
43
44
  " agentradar secrets pull|renew|env <runner-id> | agentradar secrets exec <runner-id> -- <command>",
44
45
  " agentradar secrets upload|rotate <runner-id> --env-file <path> | --from-env <NAME,...> | --prompt <NAME> [--replace]",
46
+ " agentradar github setup <runner-id> | agentradar github gh <runner-id> -- <gh args> | agentradar github credential <runner-id> get",
45
47
  " agentradar skill install | agentradar sync [dir] | agentradar sync install [dir] [--interval <s>]",
46
48
  " agentradar admin broadcast --dry-run|--send --target all --text <text> --cta <next step>",
47
- " agentradar upgrade | agentradar --version",
49
+ " agentradar admin notice --dry-run|--send --agent <id> --ref <key> --file <text file> --cta <next step> [--wake]",
50
+ " agentradar schema <command> | agentradar upgrade | agentradar version [--json]",
48
51
  ].join("\n");
49
52
  async function main() {
50
53
  const args = runArgs(process.argv.slice(2));
@@ -80,8 +83,9 @@ async function main() {
80
83
  process.exitCode = await leaveOrganisation(args[1]);
81
84
  return;
82
85
  }
83
- if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
84
- console.log(createRequire(import.meta.url)("../package.json").version);
86
+ if ((args.length === 1 && (args[0] === "--version" || args[0] === "-v")) || (args[0] === "version" && (args.length === 1 || (args.length === 2 && args[1] === "--json")))) {
87
+ const version = createRequire(import.meta.url)("../package.json").version;
88
+ console.log(args[1] === "--json" ? JSON.stringify({ version }) : version);
85
89
  return;
86
90
  }
87
91
  if (args[0] === "channel" && args.length === 2) {
@@ -126,18 +130,25 @@ async function main() {
126
130
  const named = {};
127
131
  const urls = [];
128
132
  for (let i = 1; i < args.length; i++) {
129
- if (["--name", "--model", "--attach-tmux", "--session"].includes(args[i]))
133
+ if (["--name", "--model", "--attach-tmux", "--session", "--runtime"].includes(args[i]))
130
134
  named[args[i].slice(2)] = args[++i] ?? "-";
131
135
  else if (!["--trust", "--attach-current", "--allow-bypass", "--revoke-bypass", "--feeder", "--service", "--no-service"].includes(args[i]))
132
136
  urls.push(args[i]);
133
137
  }
134
138
  if (urls.length !== 1 || [urls[0], ...Object.values(named)].some((value) => value.startsWith("-")) || (named["attach-tmux"] && args.includes("--attach-current")) || (args.includes("--service") && args.includes("--no-service"))) {
135
- console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
139
+ console.error("Usage: agentradar join <join-link> [--name <your name>] [--model <your model id>] [--runtime <claude|codex|other>] [--trust] [--attach-tmux <session[:window.pane]> | --attach-current (Codex and other CLIs)] [--session <claude session id>] [--revoke-bypass] [--no-service | --service]");
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ // --revoke-bypass only edits what an earlier join wrote, so it needs no runtime.
144
+ const runtime = args.includes("--revoke-bypass") ? { harness: undefined } : joinRuntime(named.runtime, process.env);
145
+ if ("refusal" in runtime) {
146
+ console.error(runtime.refusal);
136
147
  process.exitCode = 1;
137
148
  return;
138
149
  }
139
150
  process.exitCode = await joinOrganisation(urls[0], {
140
- trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
151
+ harness: runtime.harness, trust: args.includes("--trust"), agentName: named.name, model: named.model, session: named.session,
141
152
  attach: args.includes("--attach-current") ? "current" : named["attach-tmux"],
142
153
  revokeBypass: args.includes("--revoke-bypass"), feeder: args.includes("--feeder"), service: args.includes("--service"),
143
154
  });
@@ -157,7 +168,7 @@ async function main() {
157
168
  const parsed = parseCallArgs(args.slice(1));
158
169
  const [runnerId, ...words] = parsed?.words ?? [];
159
170
  if (!parsed || !runnerId || words.length > 3 || (!words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
160
- console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--args '<json>'] | agentradar call <runner-id> [<command>] --help");
171
+ console.error("Usage: agentradar call <runner-id> <tool | command> [--<flag> <value> ...] [--input <json|file|->] | agentradar call <runner-id> [<command>] --help");
161
172
  process.exitCode = EXIT.usage;
162
173
  return;
163
174
  }
@@ -198,6 +209,11 @@ async function main() {
198
209
  process.exitCode = await secretsOverCli(args.slice(1));
199
210
  return;
200
211
  }
212
+ // Vadym 18123: the supervisor's launch-time setup, the `gh` shim and git's credential helper; `github <server command>` stays the server's.
213
+ if (args[0] === "github" && ["setup", "credential", "gh"].includes(args[1] ?? "") && args[2] && !args[2].startsWith("-")) {
214
+ process.exitCode = await githubOverCli(args.slice(1));
215
+ return;
216
+ }
201
217
  if (args[0] === "admin") {
202
218
  process.exitCode = await adminOverCli(args.slice(1));
203
219
  return;
@@ -227,6 +243,10 @@ async function main() {
227
243
  // #1871: every other command is the server's, `<noun> [<sub-noun>] <verb>`, run as this shell's runner.
228
244
  const parsed = parseCallArgs(args);
229
245
  const runner = parsed?.flags.find(([flag]) => flag === "runner")?.[1] ?? process.env.AR_CLI_RUNNER_ID;
246
+ if (parsed && runner && parsed.words[0] === "schema" && parsed.words.length > 1 && parsed.words.length <= 4) {
247
+ process.exitCode = await schemaOverCli(runner, parsed.words.slice(1).join(" "));
248
+ return;
249
+ }
230
250
  if (!parsed || !runner || parsed.words.length > 3 || (!parsed.words.length && !parsed.flags.some(([flag]) => flag === "help"))) {
231
251
  console.error(USAGE);
232
252
  if (parsed && !runner)
package/dist/setup.js CHANGED
@@ -702,6 +702,26 @@ async function callerSession(deps, env, home, projectPath) {
702
702
  * restarts the service) instead of filing a second request. The key is written only into the 0600 runner
703
703
  * env file and is never printed.
704
704
  */
705
+ /**
706
+ * UAT-30 (Vadym 13405): a join names the runtime it runs on. `--runtime claude|codex|other`, else what the shell
707
+ * itself proves (AGENTRADAR_AGENT_BIN, CLAUDECODE, CODEX_HOME). Nothing is assumed: an unknown or unsupported one is
708
+ * refused before any request, with the command to run next.
709
+ */
710
+ export function joinRuntime(flag, env) {
711
+ const map = { claude: "claude", codex: "codex", other: "generic" };
712
+ if (flag !== undefined) {
713
+ const key = flag.trim().toLowerCase();
714
+ const harness = Object.hasOwn(map, key) ? map[key] : undefined;
715
+ return harness ? { harness } : { refusal: `Unsupported runtime ${JSON.stringify(flag)}: AgentRadar joins Claude Code (claude), Codex (codex) or another CLI you launch yourself (other). Nothing was requested. Now: run the same command again with --runtime other and AGENTRADAR_AGENT_BIN set to your CLI's command, or ask your company which runtime it expects.` };
716
+ }
717
+ if (env.AGENTRADAR_AGENT_BIN?.trim())
718
+ return { harness: "generic" };
719
+ if (env.CLAUDECODE)
720
+ return { harness: "claude" };
721
+ if (env.CODEX_HOME)
722
+ return { harness: "codex" };
723
+ return { refusal: "AgentRadar cannot tell which runtime you run on, so nothing was requested. Now: run the same command again with --runtime claude (Claude Code), --runtime codex (Codex) or --runtime other (any other CLI; set AGENTRADAR_AGENT_BIN to its command first). Use the one that is true for you; do not guess." };
724
+ }
705
725
  export async function joinOrganisation(enrolmentUrl, deps = {}) {
706
726
  const fetchImpl = deps.fetch ?? fetch;
707
727
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -858,6 +878,12 @@ and AR_CLI_RUNNER_ID=${runnerId} in its environment. Accept the one-time develop
858
878
  return startJoined(state.requestId, state.agentId, { mcpEndpoint: envValue(saved, "AWP_MCP_URL") ?? mcpEndpoint, bearer }, approvedBypass, bypass);
859
879
  }
860
880
  if (!state) {
881
+ // index.ts skips the runtime gate for --revoke-bypass (it edits an earlier join); a NEW join still needs a proven runtime.
882
+ if (deps.revokeBypass && !deps.harness) {
883
+ const proven = joinRuntime(undefined, env);
884
+ if ("refusal" in proven)
885
+ throw new Error(proven.refusal);
886
+ }
861
887
  const agentName = (deps.agentName ?? process.env.AGENTRADAR_AGENT_NAME ?? basename(cwd)).trim();
862
888
  if (!agentName)
863
889
  throw new Error("Run join from the agent project folder");
@@ -1,6 +1,6 @@
1
1
  import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { basename, dirname, join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { awpRequest, envValue } from "./tool-run.js";
5
5
  const MANAGED_MARK = ".agentradar-managed";
6
6
  async function ensureDirectory(path) {
@@ -45,8 +45,8 @@ export async function installVendorSkill(deps = {}) {
45
45
  }
46
46
  const runnerSkills = () => import(new URL(import.meta.url.endsWith(".ts")
47
47
  ? "../../../templates/vendor-runner/feeder.mjs" : "./vendor-runner/feeder.mjs", import.meta.url).href);
48
- export async function writeManagedSkills(skillsDir, skills) {
49
- return (await runnerSkills()).writeManagedSkills(skillsDir, skills);
48
+ export async function writeManagedSkills(skillsDir, skills, runner) {
49
+ return (await runnerSkills()).writeManagedSkills(skillsDir, skills, runner);
50
50
  }
51
51
  /**
52
52
  * R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
@@ -54,17 +54,6 @@ export async function writeManagedSkills(skillsDir, skills) {
54
54
  * Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
55
55
  * feeder; where the service runs, its feeder keeps them current.
56
56
  */
57
- const BRIEF_MARK = "<!-- Written by AgentRadar and replaced on every sync; edits here are lost. -->";
58
- /** Replaces the file itself, never a symlink's target, and never a file it did not write (a vendor's AGENTS.md). */
59
- async function writeBrief(file, text) {
60
- const current = await lstat(file).catch(() => null);
61
- if (current && (!current.isFile() || !(await readFile(file, "utf8")).startsWith(BRIEF_MARK)))
62
- return;
63
- await mkdir(dirname(file), { recursive: true });
64
- const temporary = `${file}.${randomUUID()}.tmp`;
65
- await writeFile(temporary, `${BRIEF_MARK}\n${text.trim()}\n`);
66
- await rename(temporary, file);
67
- }
68
57
  /** Where each CLI reads skills. The same table as templates/vendor-runner/feeder.mjs; skill-install.test.ts pins them equal. */
69
58
  export const SKILL_DIRS = {
70
59
  claude: "$AR_BASE/.claude/skills",
@@ -89,23 +78,24 @@ export async function syncJoinedSkills(runnerId, deps) {
89
78
  const skillsDir = skillsDirFor(harness, (name) => name === "HOME" ? deps.home : value(name));
90
79
  if (!skillsDir)
91
80
  return;
92
- const codex = harness === "codex";
93
- const root = (codex ? value("CODEX_HOME") : value("AR_BASE")) ?? "";
81
+ const runner = value("AR_CLI_RUNNER_ID") ?? undefined;
94
82
  // Vadym 9988: the server's start instructions with this agent's brief, before its first session starts.
95
83
  // Same file and mark as the runner's feeder, which keeps it fresh from then on.
96
84
  const init = await awpRequest(runnerId, "initialize", {
97
85
  protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
98
- }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
99
- const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
100
- if (root && init?.result?.instructions)
101
- await writeBrief(briefFile, init.result.instructions);
102
- const have = (await runnerSkills()).heldSkillZips(skillsDir);
103
- const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
86
+ }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync", signal: deps.signal });
87
+ // The feeder's own brief path and writer: one file per runner (MULTI-ORG-PUSH §0).
88
+ const skills = await runnerSkills();
89
+ const briefFile = skills.briefFileFor(harness ?? "claude", { AR_BASE: value("AR_BASE") ?? undefined, CODEX_HOME: value("CODEX_HOME") ?? undefined, AR_CLI_RUNNER_ID: runner });
90
+ if (briefFile && init?.result?.instructions)
91
+ skills.writeBrief(briefFile, init.result.instructions);
92
+ const have = skills.heldSkillZips(skillsDir);
93
+ const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync", signal: deps.signal });
104
94
  const text = body?.result?.content?.find((c) => c.type === "text")?.text;
105
95
  const answer = (text ? JSON.parse(text) : {});
106
96
  if (!answer.ok || !Array.isArray(answer.skills))
107
97
  throw new Error("AgentRadar did not return your skills");
108
- const written = await writeManagedSkills(skillsDir, answer.skills);
98
+ const written = await writeManagedSkills(skillsDir, answer.skills, runner);
109
99
  deps.log(`Installed ${written.length} AgentRadar skill(s) in ${skillsDir}.`);
110
100
  }
111
101
  //# sourceMappingURL=skill-install.js.map