a2a-exposed 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Telegraphic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # a2a-exposed (CLI)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line companion for [a2a-exposed](https://github.com/telegraphic-dev/a2a-exposed). It gives any AI agent a public A2A endpoint: a Cloudflare Worker inbox plus a wake webhook.
4
+
5
+ ```bash
6
+ npm i -g a2a-exposed # or one-off: npx -y a2a-exposed@latest --help
7
+ a2a-exposed --help
8
+ ```
9
+
10
+ The agent skills (`npx --yes skills add telegraphic-dev/a2a-exposed`) document the workflows; they do not install this CLI.
11
+
12
+ - Node 22.18+ and zero dependencies. `init` installs Cloudflare's `cf` CLI into the Worker folder and uses that; you only need a one-time login (`npx cf auth login --no-browser`). No domain? Omit `--hostname` and `init` deploys to `https://<worker>.<account-subdomain>.workers.dev` (`--workers-dev-subdomain <name>` registers the account subdomain if missing).
13
+ - **Setup:** `init`, `deploy`, `wake set|unset|test|preview|fingerprint`, `tunnel create|status|rm` (secure Cloudflare Tunnel + Access for local-only webhooks; needs any zone on the account, the inbox can be on workers.dev), `status` (read-only setup check with a next step), `url`, `config`. Use `--cli-command` to set the command shown in wake hints (default `npx a2a-exposed`; e.g. a `node <checkout>/cli/bin/a2a-exposed.mjs` path for development).
14
+ - **Inbox:** `inbox`, `show`, `working`, `reply`, `history`, `contexts`
15
+ - **Pairing (OAuth 2.0 device flow, RFC 8628):** `connect <url>` (ask another inbox for a token; its owner approves), `pair set-password|list|approve|deny` (approve agents that connect to you; human approval by default)
16
+ - **Peer tokens:** `token issue|list|revoke|rotate` (manual fallback; `list` also shows tokens created by pairing)
17
+ - **Outbound:** `peers add|list|rm`, `send`, `poll`, `outbound`
18
+ - **Config:** `~/.config/a2a-exposed/config.env` (chmod 600; override the directory with `A2A_CONFIG_DIR`). Environment variables override file values; a peer token (`PEER_<ALIAS>_TOKEN`) that differs between the two prints a warning on stderr.
19
+
20
+ See the repository README and the skills in `skills/` for full documentation. License: MIT.
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // `--config-dir DIR` (anywhere on the command line) is the same as A2A_CONFIG_DIR=DIR; it must be set before the
3
+ // config module loads, so main is imported afterwards.
4
+ const argv = process.argv.slice(2);
5
+ for (let i = 0; i < argv.length; i++) {
6
+ const a = argv[i];
7
+ if (a === "--config-dir" || a.startsWith("--config-dir=")) {
8
+ const v = a === "--config-dir" ? argv[i + 1] : a.slice("--config-dir=".length);
9
+ if (!v || v.startsWith("--")) { console.error("error: --config-dir needs a directory"); process.exit(1); }
10
+ process.env.A2A_CONFIG_DIR = v;
11
+ argv.splice(i, a === "--config-dir" ? 2 : 1);
12
+ break;
13
+ }
14
+ }
15
+ const { main } = await import("../lib/main.mjs");
16
+
17
+ main(argv).catch((e) => {
18
+ const known = e && (e.name === "CliError" || e.constructor?.name === "CliError" || e.code?.startsWith?.("ERR_PARSE_ARGS"));
19
+ console.error(`error: ${known ? e.message : e?.stack || e}`);
20
+ process.exit(1);
21
+ });
package/lib/a2a.mjs ADDED
@@ -0,0 +1,187 @@
1
+ // Minimal A2A client helpers (0.3 + 1.0) and HTTP utilities.
2
+ import crypto from "node:crypto";
3
+
4
+ export const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
5
+
6
+ export class CliError extends Error {}
7
+ export const die = (msg) => { throw new CliError(msg); };
8
+
9
+ export function checkId(v, what = "id") {
10
+ if (typeof v !== "string" || !ID_RE.test(v)) die(`invalid ${what}`);
11
+ return v;
12
+ }
13
+
14
+ export const newId = () => crypto.randomUUID();
15
+
16
+ /** Hosts that only resolve on a private network (Tailnet, LAN, loopback). Same rules as the Worker's isPrivateHost. */
17
+ export function isPrivateHost(host) {
18
+ const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
19
+ if (!h.includes(".") && !h.includes(":")) return true; // single-label names (localhost, MagicDNS short names)
20
+ if (/(^|\.)(localhost|local|lan|home|internal|intranet|corp|home\.arpa|ts\.net)$/.test(h)) return true;
21
+ const m = h.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
22
+ if (m) {
23
+ const [a, b] = [Number(m[1]), Number(m[2])];
24
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
25
+ }
26
+ if (!h.includes(":")) return false;
27
+ if (h === "::" || h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h) || /^fec/.test(h)) return true; // unspecified, loopback, ULA, link-/site-local
28
+ // IPv4 embedded in IPv6 (mapped ::ffff:a.b.c.d, NAT64 64:ff9b::, deprecated compatible ::a.b.c.d): judge the IPv4.
29
+ // URL parsing normalizes the dotted tail to hex (::ffff:7f00:1), so both spellings are handled.
30
+ const e = h.match(/^(?:::ffff:|64:ff9b::|::)(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/);
31
+ if (e) {
32
+ const v4 = e[1] || [parseInt(e[2], 16) >> 8, parseInt(e[2], 16) & 255, parseInt(e[3], 16) >> 8, parseInt(e[3], 16) & 255].join(".");
33
+ return isPrivateHost(v4);
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /** Problem with an upstream URL for proxy mode ("" when fine): the Worker calls it from Cloudflare, so it must be https
39
+ * on a public hostname (a Cloudflare Tunnel hostname behind Access), never a Tailnet / LAN / localhost address. */
40
+ export function upstreamUrlProblem(v) {
41
+ let u;
42
+ try { u = new URL(v); } catch { return "is not a URL"; }
43
+ if (u.protocol !== "https:") return "must be https";
44
+ if (u.username || u.password) return "must not contain credentials (export UPSTREAM_TOKEN instead)";
45
+ if (isPrivateHost(u.hostname))
46
+ return `is a private-network address (${u.hostname}) that the Worker cannot reach: publish the agent through a Cloudflare Tunnel hostname behind Access and pass that (setup skill: "Already have A2A on a Tailnet or LAN")`;
47
+ return "";
48
+ }
49
+ /** Problem with an upstream card URL ("" when fine): an upstream URL (see above) on the same origin (scheme, host,
50
+ * port) as the upstream endpoint, because the Worker fetches the card with UPSTREAM_TOKEN and the Access service
51
+ * token; a card on any other origin would receive those credentials. */
52
+ export function upstreamCardUrlProblem(card, upstream) {
53
+ const why = upstreamUrlProblem(card);
54
+ if (why) return why;
55
+ let up;
56
+ try { up = new URL(upstream).origin; } catch { return ""; } // the upstream URL's own problem is reported for --upstream
57
+ const got = new URL(card).origin;
58
+ if (got !== up)
59
+ return `must be on the upstream's origin (${up}), not ${got}: the Worker fetches the card with UPSTREAM_TOKEN and the Access service token, which only ever go to the upstream itself`;
60
+ return "";
61
+ }
62
+ export const randomToken = (bytes = 32) => crypto.randomBytes(bytes).toString("base64url");
63
+
64
+ export async function httpJson(url, { method, body, headers = {}, timeout = 30000 } = {}) {
65
+ const init = { method: method || (body === undefined ? "GET" : "POST"), headers: { accept: "application/json", ...headers }, signal: AbortSignal.timeout(timeout) };
66
+ if (body !== undefined) {
67
+ init.headers["content-type"] = "application/json";
68
+ init.body = JSON.stringify(body);
69
+ }
70
+ let r;
71
+ try { r = await fetch(url, init); }
72
+ catch (e) { die(`request to ${new URL(url).origin} failed: ${e?.cause?.code || e?.cause?.message || e?.message || e}`); }
73
+ const text = await r.text();
74
+ let data = text;
75
+ try { data = text ? JSON.parse(text) : null; } catch { /* keep text */ }
76
+ return { status: r.status, data };
77
+ }
78
+
79
+ // Cloudflare edge errors (HTML or "error code: NNNN" bodies, not the Worker's own JSON): code -> what it means here
80
+ const CF_ERRORS = {
81
+ 1042: "no Worker answers on this workers.dev host yet: a new deployment takes up to ~30 s to propagate; try again shortly",
82
+ 1101: "the Worker threw an exception (see the Worker's logs)",
83
+ 1102: "the Worker exceeded its CPU or memory limit",
84
+ 1015: "rate limited by Cloudflare",
85
+ 1033: "the Cloudflare Tunnel has no running connector",
86
+ 1016: "origin DNS error",
87
+ };
88
+
89
+ /** Cloudflare edge error code (1042, 1101, ...) in a response body, or null. */
90
+ export function cfErrorCode(data) {
91
+ if (typeof data !== "string") return null;
92
+ const m = /error(?:\s+code)?[:\s]+(1\d{3})\b/i.exec(data);
93
+ return m ? Number(m[1]) : null;
94
+ }
95
+
96
+ /** One readable line for a failed HTTP response: the Worker's JSON error, a Cloudflare error code and title, or a short
97
+ * excerpt (never a raw HTML page). */
98
+ export function describeHttp(status, data) {
99
+ const code = cfErrorCode(data);
100
+ if (code) return `HTTP ${status}, Cloudflare error ${code}${CF_ERRORS[code] ? `: ${CF_ERRORS[code]}` : ""}`;
101
+ if (data && typeof data === "object") {
102
+ const e = data.error;
103
+ const msg = typeof e === "string" ? e : e && typeof e === "object" ? `${e.code !== undefined ? `${e.code} ` : ""}${e.message || ""}`.trim() : "";
104
+ if (msg) return `HTTP ${status}: ${msg}${data.error_description ? ` (${data.error_description})` : ""}`;
105
+ return `HTTP ${status}: ${JSON.stringify(data).slice(0, 200)}`;
106
+ }
107
+ const text = String(data ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
108
+ return `HTTP ${status}${text ? `: ${text.slice(0, 160)}${text.length > 160 ? "..." : ""}` : ""}`;
109
+ }
110
+
111
+ /** Lowercase A2A task state ("completed", "input-required", ...) from a 0.3 or 1.0 task. */
112
+ export const plainState = (t) => {
113
+ const s = t && t.status && t.status.state;
114
+ return typeof s === "string" && s.startsWith("TASK_STATE_") ? s.slice(11).toLowerCase().replace(/_/g, "-") : s;
115
+ };
116
+
117
+ /** First 12 hex chars of sha256(value), or null when unset (same as the Worker's `wake preview` fingerprints). */
118
+ export const fingerprint = (v) => (v ? crypto.createHash("sha256").update(String(v), "utf8").digest("hex").slice(0, 12) : null);
119
+
120
+ export function textOf(msgOrParts) {
121
+ const parts = Array.isArray(msgOrParts) ? msgOrParts : (msgOrParts && msgOrParts.parts) || [];
122
+ const out = [];
123
+ for (const x of parts) {
124
+ if (!x || typeof x !== "object") continue;
125
+ if ("text" in x) out.push(String(x.text));
126
+ else if ("data" in x) out.push(JSON.stringify(x.data));
127
+ else if (x.kind === "file" || "url" in x || "raw" in x) {
128
+ const f = x.file || {};
129
+ out.push(`[file ${f.name || x.filename || f.uri || x.url || ""}]`);
130
+ }
131
+ }
132
+ return out.join("\n");
133
+ }
134
+
135
+ export async function fetchCard(base) {
136
+ for (const p of ["/.well-known/agent-card.json", "/.well-known/agent.json"]) {
137
+ try {
138
+ const { status, data } = await httpJson(base + p, { timeout: 15000 });
139
+ if (status === 200 && data && typeof data === "object") return data;
140
+ } catch { /* try next */ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** Does an agent card send peers to `base`? "" when it does, else what it advertises instead. */
146
+ export function cardUrlProblem(card, base) {
147
+ const norm = (u) => { try { const x = new URL(u); return x.origin + x.pathname.replace(/\/+$/, ""); } catch { return null; } };
148
+ const want = norm(base);
149
+ const urls = [...new Set([...((card && card.supportedInterfaces) || []).map((i) => i && i.url), card && card.url].filter(Boolean))];
150
+ if (!urls.length) return "it advertises no endpoint URL (supportedInterfaces[].url)";
151
+ const wrong = urls.filter((u) => norm(u) !== want);
152
+ return wrong.length ? `it advertises ${wrong.join(", ")} instead of ${base.replace(/\/$/, "")}/` : "";
153
+ }
154
+
155
+ /** Choose the JSON-RPC endpoint + protocol version from an agent card (prefers 1.0). */
156
+ export function pickEndpoint(base, card, force) {
157
+ const ifaces = ((card && card.supportedInterfaces) || []).filter((i) => String(i.protocolBinding || "").toUpperCase() === "JSONRPC");
158
+ if (force) {
159
+ const hit = ifaces.find((i) => String(i.protocolVersion || "").startsWith(force));
160
+ if (hit) return [hit.url, force];
161
+ return [(card && card.url) || base + "/", force];
162
+ }
163
+ const v1 = ifaces.find((i) => String(i.protocolVersion || "").startsWith("1"));
164
+ if (v1) return [v1.url, "1.0"];
165
+ if (card && card.url) return [card.url, "0.3"];
166
+ if (ifaces.length) return [ifaces[0].url, String(ifaces[0].protocolVersion || "0.3")];
167
+ return [base + "/", "0.3"];
168
+ }
169
+
170
+ /** JSON-RPC call to a peer. `peer` ({ alias, base }) makes a 401 say how to re-pair with that peer. */
171
+ export async function rpc(url, version, token, method03, method1, params, peer = null) {
172
+ const v1 = version.startsWith("1");
173
+ const headers = { "A2A-Version": v1 ? "1.0" : "0.3" };
174
+ if (token) headers.authorization = `Bearer ${token}`;
175
+ const body = { jsonrpc: "2.0", id: newId(), method: v1 ? method1 : method03, params };
176
+ const { status, data } = await httpJson(url, { body, headers });
177
+ if (status === 401) {
178
+ const who = peer && peer.alias ? `peer "${peer.alias}"` : new URL(url).origin;
179
+ const base = (peer && peer.base) || new URL(url).origin;
180
+ die(token
181
+ ? `${who} rejected our token (HTTP 401: revoked, rotated, or never valid there). Re-pair: a2a-exposed connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves), or ask its owner for a new token`
182
+ : `${who} needs a token (HTTP 401): a2a-exposed connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves)`);
183
+ }
184
+ if (status !== 200 || !data || typeof data !== "object" || "error" in data)
185
+ die(`peer returned ${data && typeof data === "object" && data.error ? `JSON-RPC error ${describeHttp(status, data).replace(/^HTTP \d+: /, "")} (HTTP ${status})` : describeHttp(status, data)}`);
186
+ return data.result;
187
+ }
@@ -0,0 +1,262 @@
1
+ // Day-to-day commands: inbox / reply / history (owner API) and outbound send / poll.
2
+ import fs from "node:fs";
3
+ import * as C from "./config.mjs";
4
+ import { checkId, describeHttp, die, fetchCard, httpJson, newId, pickEndpoint, plainState, randomToken, rpc, textOf } from "./a2a.mjs";
5
+
6
+ const q = encodeURIComponent;
7
+ const out = (obj) => console.log(JSON.stringify(obj, null, 2));
8
+ export const baseUrl = () => C.get("A2A_BASE_URL").replace(/\/$/, "");
9
+
10
+ export async function owner(method, path, body) {
11
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
12
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`a2a-exposed init\` or edit ${C.CONFIG_FILE})`);
13
+ const { status, data } = await httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` } });
14
+ if (status < 200 || status >= 300) die(`worker ${method} ${path} -> ${describeHttp(status, data)}`);
15
+ return data;
16
+ }
17
+
18
+ /** Owner API call that returns { status, data } instead of dying, so callers can explain errors themselves. */
19
+ export async function ownerTry(method, path, body) {
20
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
21
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`a2a-exposed init\` or edit ${C.CONFIG_FILE})`);
22
+ return httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` } });
23
+ }
24
+
25
+ function printPush(res) {
26
+ for (const r of (res && res.push) || []) console.error(`push -> ${r.url}: ${r.ok ? "ok" : "FAILED"} (${r.info})`);
27
+ }
28
+
29
+ const readStdin = () => fs.readFileSync(0, "utf8");
30
+
31
+ // ---------------------------------------------------------------- inbound side
32
+ export async function inbox(o) {
33
+ const qs = [];
34
+ if (o.context) qs.push("context=" + q(o.context));
35
+ if (o.all) qs.push("all=1");
36
+ const rows = await owner("GET", "/owner/inbox" + (qs.length ? "?" + qs.join("&") : ""));
37
+ // pending pairing requests: polling agents get no pairing wake, so the inbox shows them too (not with --context:
38
+ // that is one conversation). An older Worker without pairing: nothing to show.
39
+ let pairing = [];
40
+ if (!o.context) {
41
+ try { const r = await ownerTry("GET", "/owner/pairing"); if (r.status === 200 && r.data && Array.isArray(r.data.pending)) pairing = r.data.pending.map((p) => ({ ...p, approval: r.data.mode })); } catch { /* unreachable: the inbox call above already worked */ }
42
+ }
43
+ if (o.json) {
44
+ out(rows); // unchanged shape (an array of tasks); pairing requests go to stderr as one JSON line
45
+ if (pairing.length) console.error(JSON.stringify({ pendingPairingRequests: pairing.length, requests: pairing.map(({ userCode, clientName, clientId, verificationUriComplete, expiresAt, approval }) => ({ userCode, clientName: clientName || clientId || null, verificationUriComplete, expiresAt, approval })), instructions: PAIRING_NOTE }));
46
+ return;
47
+ }
48
+ if (!rows.length) console.log(pairing.length || o.context ? "(no unhandled tasks)" : "(no unhandled tasks, no pending pairing requests)");
49
+ for (const r of rows) {
50
+ console.log(`--- task ${r.taskId} [${r.state}] from=${r.from} context=${r.contextId} at=${r.updated}`);
51
+ console.log("UNTRUSTED PEER MESSAGE >>>");
52
+ console.log(r.text);
53
+ console.log("<<< END PEER MESSAGE");
54
+ }
55
+ if (pairing.length) {
56
+ console.log(`=== ${pairing.length} pending pairing request${pairing.length === 1 ? "" : "s"} (another agent asks to connect; ${pairing[0].approval} approval)`);
57
+ for (const p of pairing) {
58
+ console.log(`--- code ${p.userCode} ${JSON.stringify(p.clientName || p.clientId || "(no name)")} (claimed, untrusted) expires ${p.expiresAt}${p.replacesLabel ? ` replaces token "${p.replacesLabel}"` : ""}`);
59
+ console.log(` link: ${p.verificationUriComplete}`);
60
+ }
61
+ console.log(PAIRING_NOTE);
62
+ }
63
+ }
64
+
65
+ const PAIRING_NOTE = "Tell your human who is asking, the code and the link (details: a2a-exposed pair list). Never approve on your own: in human mode they approve on the link with their approval password; in agent mode run `pair approve <code>` only after they say yes.";
66
+
67
+ export const show = async (id) => out(await owner("GET", `/owner/tasks/${q(id)}`));
68
+
69
+ export async function working(id) {
70
+ printPush(await owner("POST", `/owner/tasks/${q(id)}/working`, {}));
71
+ console.log(`${id} -> working`);
72
+ }
73
+
74
+ export async function reply(id, o) {
75
+ const text = o.text ?? (o.stdin || !process.stdin.isTTY ? readStdin() : undefined);
76
+ if (!text) die("--text or --stdin required");
77
+ const res = await owner("POST", `/owner/tasks/${q(id)}/reply`, {
78
+ text, state: o.state, artifact: !!o.artifact, artifactName: o["artifact-name"], force: !!o.force,
79
+ });
80
+ printPush(res);
81
+ console.log(`${id} -> ${o.state}`);
82
+ }
83
+
84
+ export async function history(ctx, o) {
85
+ const rows = await owner("GET", `/owner/history/${q(ctx)}?n=${Number(o.n || 50)}`);
86
+ if (o.json) return out(rows);
87
+ for (const e of rows) {
88
+ const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
89
+ console.log(`${e.ts} ${(e.dir || "").padEnd(5)} ${(e.peer || "-").padEnd(10)} ${e.task_id || "-"} ${tag}: ${(e.text || "").slice(0, 400)}`);
90
+ }
91
+ }
92
+
93
+ export async function contexts() {
94
+ for (const r of await owner("GET", "/owner/contexts")) console.log(`${r.last} ${r.context_id} (${r.entries} entries)`);
95
+ }
96
+
97
+ // ---------------------------------------------------------------- inbound peer tokens (one per peer label)
98
+ export async function token(action, label, o) {
99
+ if (action === "list") {
100
+ for (const r of await owner("GET", "/owner/peers")) {
101
+ const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}` : "";
102
+ console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
103
+ }
104
+ return;
105
+ }
106
+ if (!label) die("label required");
107
+ if (action === "issue" || action === "rotate") {
108
+ // rotate replaces an existing label's token; a typo must not silently issue a token under a new label
109
+ const res = await owner("POST", "/owner/peers", { label, rotate: action === "rotate" || !!o.rotate, mustExist: action === "rotate" });
110
+ console.log(res.token);
111
+ console.error(`# label=${res.label} agent card: ${res.card} (token shown once; hand it to the peer over a private channel)`);
112
+ return;
113
+ }
114
+ if (action === "revoke") {
115
+ const r = await ownerTry("DELETE", `/owner/peers/${q(label)}`);
116
+ // an older Worker answers 200 {"revoked": false} for an unknown label: a typo must never look like success
117
+ if (r.status === 200 && r.data && r.data.revoked === true) {
118
+ if (o.json) return out({ revoked: true, label });
119
+ return console.log(`revoked ${label}: its token gets HTTP 401 from now on`);
120
+ }
121
+ if (r.status === 404 || (r.status === 200 && r.data && r.data.revoked === false))
122
+ die((r.data && r.data.error) || `no active token with label ${label} (see: a2a-exposed token list)`);
123
+ die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
124
+ }
125
+ die(`unknown token action ${action} (issue|list|revoke|rotate)`);
126
+ }
127
+
128
+ // ---------------------------------------------------------------- outbound peers (agents we call)
129
+ /** Peer tokens follow the usual rule (the environment overrides config.env). When a peer token is set in both and the
130
+ * two differ, say so on stderr, naming only the variable (never a value). `justSaved`: the CLI has just written a new
131
+ * token to config.env (connect, peers add --token-stdin) that the environment value will keep shadowing. */
132
+ export function warnPeerTokenEnv(key, justSaved = false) {
133
+ if (!key || !C.envOverridesFile(key)) return false;
134
+ console.error(justSaved
135
+ ? `warning: ${key} is set in the environment and overrides the token just saved in ${C.CONFIG_FILE}; the next send or poll would still use the environment value. To use the new token: unset ${key} (in the shell or service that sets it)`
136
+ : `warning: ${key} is set in the environment and overrides the different peer token saved in ${C.CONFIG_FILE}; using the environment value. To use the saved token: unset ${key} (in the shell or service that sets it)`);
137
+ return true;
138
+ }
139
+
140
+ /** A peer's bearer token from the environment or config.env, warning when the environment shadows a different saved one. */
141
+ export function peerToken(key) {
142
+ if (!key) return "";
143
+ warnPeerTokenEnv(key);
144
+ return C.get(key);
145
+ }
146
+
147
+ function resolvePeer(to) {
148
+ const peers = C.loadPeers();
149
+ if (peers[to]) {
150
+ const pe = peers[to];
151
+ return [to, pe.url.replace(/\/$/, ""), peerToken(pe.token_env)];
152
+ }
153
+ if (/^https?:\/\//.test(to)) {
154
+ for (const [alias, pe] of Object.entries(peers)) if (pe.url.replace(/\/$/, "") === to.replace(/\/$/, "")) return resolvePeer(alias);
155
+ return [to, to.replace(/\/$/, ""), peerToken("A2A_PEER_TOKEN")];
156
+ }
157
+ die(`unknown peer alias ${JSON.stringify(to)} (see: a2a-exposed peers list)`);
158
+ }
159
+
160
+ export function peers(sub, args, o) {
161
+ const all = C.loadPeers();
162
+ if (sub === "add") {
163
+ const [alias, url] = args;
164
+ if (!alias || !url) die("usage: peers add <alias> <url> [--token-env VAR | --token-stdin]");
165
+ if (!/^https?:\/\//.test(url)) die("URL must be http(s)");
166
+ const tokenEnv = o["token-env"] || C.peerTokenVar(alias);
167
+ // token_stored is always recorded (true/false); entries without it come from an older CLI (see `peers rm`)
168
+ all[alias] = { url: url.replace(/\/$/, ""), token_env: tokenEnv, token_stored: false };
169
+ if (o["token-stdin"]) {
170
+ const t = readStdin().trim();
171
+ if (!t) die("no token on stdin");
172
+ C.saveConfig({ [tokenEnv]: t });
173
+ all[alias].token_stored = true; // written to config.env by us: `peers rm` removes it again
174
+ warnPeerTokenEnv(tokenEnv, true);
175
+ } else if (warnPeerTokenEnv(tokenEnv)) { /* both set and different: warned */ }
176
+ else if (!C.get(tokenEnv)) console.error(`note: no token stored; set ${tokenEnv} in the environment or ${C.CONFIG_FILE} (or re-run with --token-stdin)`);
177
+ C.savePeers(all);
178
+ return console.log(`peer ${alias} -> ${all[alias].url}`);
179
+ }
180
+ if (sub === "rm") {
181
+ const [alias] = args;
182
+ if (!alias) die("usage: peers rm <alias>");
183
+ const pe = all[alias];
184
+ // Tokens the CLI stored in config.env go with the peer: the default PEER_<ALIAS>_TOKEN, a variable stored
185
+ // with --token-stdin (token_stored: true), and, for entries written by an older CLI (no token_stored
186
+ // field), whatever --token-env variable they reference, since that CLI stored --token-stdin tokens there
187
+ // too. Never a variable another peer still uses, never the CLI's own settings, and only from config.env
188
+ // (CLI-owned): a value that lives only in the process environment is the user's and stays.
189
+ const vars = new Set([C.peerTokenVar(alias)]);
190
+ if (pe && pe.token_env && (pe.token_stored === true || !("token_stored" in pe))) vars.add(pe.token_env);
191
+ delete all[alias];
192
+ const inUse = new Set(Object.values(all).map((x) => x.token_env));
193
+ const reserved = (k) => /^(A2A_|CLOUDFLARE_|WAKE_|CF_PROFILE$)/.test(k);
194
+ const drop = [...vars].filter((k) => !inUse.has(k) && !reserved(k) && k in C.fileConfig());
195
+ if (!pe && !drop.length) die(`unknown peer alias ${JSON.stringify(alias)} (see: a2a-exposed peers list)`);
196
+ if (pe) C.savePeers(all);
197
+ if (drop.length) C.saveConfig(Object.fromEntries(drop.map((k) => [k, null])));
198
+ return console.log(`removed ${alias}${drop.length ? ` (and ${drop.join(", ")} from ${C.CONFIG_FILE})` : ""}`);
199
+ }
200
+ for (const [k, v] of Object.entries(all)) {
201
+ const te = v.token_env || "";
202
+ warnPeerTokenEnv(te);
203
+ console.log(`${k}\t${v.url}\ttoken_env=${te || "-"}\t${te && C.get(te) ? "(set)" : "(missing)"}`);
204
+ }
205
+ }
206
+
207
+ async function logHistory(ctx, entry) {
208
+ try { await owner("POST", "/owner/history", { contextId: ctx, ...entry }); }
209
+ catch { console.error("warning: could not log history to the worker"); }
210
+ }
211
+
212
+ export async function send(o) {
213
+ if (!o.to) die("--to required");
214
+ const [alias, base, tok] = resolvePeer(o.to);
215
+ const card = await fetchCard(base);
216
+ const [url, version] = pickEndpoint(base, card, o.proto);
217
+ const text = o.text ?? readStdin();
218
+ const v1 = version.startsWith("1");
219
+ const msg = { messageId: newId(), role: v1 ? "ROLE_USER" : "user", parts: v1 ? [{ text }] : [{ kind: "text", text }] };
220
+ if (!v1) msg.kind = "message";
221
+ if (o.context) msg.contextId = checkId(o.context, "contextId");
222
+ if (o.task) msg.taskId = checkId(o.task, "taskId");
223
+ const conf = v1 ? { returnImmediately: true } : { blocking: false };
224
+ let pushToken = null;
225
+ if (o.push) {
226
+ if (!baseUrl()) die("--push needs A2A_BASE_URL (your Worker)");
227
+ pushToken = randomToken(24);
228
+ conf[v1 ? "taskPushNotificationConfig" : "pushNotificationConfig"] = { url: baseUrl() + "/push", token: pushToken };
229
+ }
230
+ const res = await rpc(url, version, tok, "message/send", "SendMessage", { message: msg, configuration: conf }, { alias: C.loadPeers()[alias] ? alias : null, base });
231
+ // printed and stored exactly as the peer returned it (1.0: TASK_STATE_* / ROLE_*; 0.3: lowercase)
232
+ const obj = res && typeof res === "object" && ("task" in res || "message" in res) ? res.task || res.message : res;
233
+ const isTask = obj && typeof obj === "object" && "status" in obj;
234
+ const ctx = (obj && obj.contextId) || o.context || newId();
235
+ const tid = isTask ? obj.id : null;
236
+ await logHistory(ctx, { dir: "out", peer: alias, taskId: tid, role: "user", text, data: { messageId: msg.messageId, endpoint: url, protocol: version } });
237
+ if (tid) await owner("POST", "/owner/outbound", { taskId: tid, contextId: ctx, peer: alias, endpoint: url, protocol: version, pushToken, task: obj });
238
+ else if (obj && obj.parts) await logHistory(ctx, { dir: "in", peer: alias, role: "agent", event: "direct_message", text: textOf(obj) });
239
+ out(obj);
240
+ if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check: a2a-exposed poll --to ${alias} ${tid})`);
241
+ }
242
+
243
+ export async function poll(taskId, o) {
244
+ if (!o.to) die("--to required");
245
+ const [alias, base, tok] = resolvePeer(o.to);
246
+ const tid = checkId(taskId, "taskId");
247
+ let rec = {};
248
+ try { rec = await owner("GET", `/owner/outbound/${q(tid)}`); } catch { rec = {}; }
249
+ const [url, version] = rec.endpoint ? [rec.endpoint, rec.protocol || "0.3"] : pickEndpoint(base, await fetchCard(base), o.proto);
250
+ const res = await rpc(url, version, tok, "tasks/get", "GetTask", { id: tid }, { alias: C.loadPeers()[alias] ? alias : null, base });
251
+ if (rec.endpoint) {
252
+ await owner("PUT", `/owner/outbound/${q(tid)}`, { task: res });
253
+ let txt = textOf((res.status || {}).message || {});
254
+ const arts = (res.artifacts || []).map((x) => textOf(x)).join("\n");
255
+ if (arts && arts !== txt) txt = (txt ? txt + "\n" : "") + arts;
256
+ await logHistory(rec.contextId, { dir: "in", peer: alias, taskId: tid, event: "poll", state: plainState(res), text: txt });
257
+ }
258
+ out(res);
259
+ console.error(`# task ${tid}: ${plainState(res)}`);
260
+ }
261
+
262
+ export const outbound = async (id) => out(await owner("GET", `/owner/outbound/${q(id)}`));
package/lib/config.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Config storage: ~/.config/a2a-exposed/config.env (chmod 600) + peers.json. Before the rename the directory was
2
+ // ~/.config/a2a-over-webhook: when only that one exists it is used as is (nothing is moved or copied).
3
+ // Environment variables always override values from config.env.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ /** <base>/a2a-exposed, or the pre-rename <base>/a2a-over-webhook when only that exists (an existing deployment). */
9
+ export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), exists = fs.existsSync) {
10
+ const dir = path.join(base, "a2a-exposed"), legacy = path.join(base, "a2a-over-webhook");
11
+ return !exists(dir) && exists(legacy) ? legacy : dir;
12
+ }
13
+
14
+ export const CONFIG_DIR = process.env.A2A_CONFIG_DIR || defaultConfigDir();
15
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.env");
16
+ export const PEERS_FILE = path.join(CONFIG_DIR, "peers.json");
17
+
18
+ export function parseEnv(text) {
19
+ const out = {};
20
+ for (const raw of text.split(/\r?\n/)) {
21
+ const line = raw.trim();
22
+ if (!line || line.startsWith("#")) continue;
23
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
24
+ if (!m) continue;
25
+ let v = m[2];
26
+ if (v.startsWith('"')) {
27
+ try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
28
+ } else if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
29
+ out[m[1]] = v;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function serializeEnv(obj) {
35
+ const lines = ["# a2a-exposed config (contains secrets: keep chmod 600, never commit)"];
36
+ for (const [k, v] of Object.entries(obj)) {
37
+ if (v === undefined || v === null) continue;
38
+ const s = String(v);
39
+ lines.push(`${k}=${/^[A-Za-z0-9_./:@+,-]*$/.test(s) ? s : JSON.stringify(s)}`);
40
+ }
41
+ return lines.join("\n") + "\n";
42
+ }
43
+
44
+ let cache = null;
45
+ export function fileConfig() {
46
+ if (cache) return cache;
47
+ try { cache = parseEnv(fs.readFileSync(CONFIG_FILE, "utf8")); } catch { cache = {}; }
48
+ return cache;
49
+ }
50
+
51
+ /** Value from the environment, else config.env, else default. */
52
+ export function get(key, dflt = "") {
53
+ const e = process.env[key];
54
+ if (e !== undefined && e !== "") return e;
55
+ const f = fileConfig()[key];
56
+ return f !== undefined && f !== "" ? f : dflt;
57
+ }
58
+
59
+ function ensureDir() {
60
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
61
+ }
62
+
63
+ function writePrivate(file, data) {
64
+ ensureDir();
65
+ const tmp = `${file}.${process.pid}.tmp`;
66
+ fs.writeFileSync(tmp, data, { mode: 0o600 });
67
+ fs.chmodSync(tmp, 0o600);
68
+ fs.renameSync(tmp, file);
69
+ }
70
+
71
+ /** Merge updates into config.env (undefined values are left alone, null removes the key). */
72
+ export function saveConfig(updates) {
73
+ const cur = { ...fileConfig() };
74
+ for (const [k, v] of Object.entries(updates)) {
75
+ if (v === undefined) continue;
76
+ if (v === null) delete cur[k];
77
+ else cur[k] = String(v);
78
+ }
79
+ writePrivate(CONFIG_FILE, serializeEnv(cur));
80
+ cache = cur;
81
+ }
82
+
83
+ export function loadPeers() {
84
+ try { return JSON.parse(fs.readFileSync(PEERS_FILE, "utf8")) || {}; } catch { return {}; }
85
+ }
86
+
87
+ export function savePeers(peers) {
88
+ writePrivate(PEERS_FILE, JSON.stringify(peers, null, 2) + "\n");
89
+ }
90
+
91
+ /** Set (non-empty) both in the environment and in config.env, with different values: get() returns the environment's. */
92
+ export function envOverridesFile(key) {
93
+ const e = process.env[key], f = fileConfig()[key];
94
+ return !!e && !!f && e !== f;
95
+ }
96
+
97
+ export const peerTokenVar = (alias) => `PEER_${alias.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_TOKEN`;