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/lib/main.mjs ADDED
@@ -0,0 +1,226 @@
1
+ import { parseArgs } from "node:util";
2
+ import fs from "node:fs";
3
+ import { CliError } from "./a2a.mjs";
4
+ import * as C from "./config.mjs";
5
+ import * as cmd from "./commands.mjs";
6
+ import * as dep from "./deploy.mjs";
7
+ import * as tun from "./tunnel.mjs";
8
+ import * as st from "./status.mjs";
9
+ import * as pair from "./pair.mjs";
10
+ import * as upd from "./update.mjs";
11
+
12
+ const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
+
14
+ const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Cloudflare Worker inbox + wake webhook)
15
+
16
+ Usage: a2a-exposed <command> [options] (or: npx a2a-exposed <command>)
17
+
18
+ Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx a2a-exposed ...\`, and a Cloudflare login:
19
+ npx cf auth login --no-browser)
20
+ init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
21
+ [--agent-name N] [--agent-description D] [--agent-skills JSON]
22
+ [--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
23
+ [--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
24
+ [--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
25
+ [--upstream URL [--upstream-card-url URL]] [--worker-dir DIR]
26
+ deploy the Worker + D1 to your account; wake secrets are read from
27
+ env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
28
+ no --hostname: serve on https://<worker>.<account subdomain>.workers.dev
29
+ --workers-dev move an existing custom-domain deployment to workers.dev (and
30
+ --hostname H moves it back); the old URL answers 301/410
31
+ --workers-dev-subdomain create the account's workers.dev subdomain if missing
32
+ --cf-profile cf auth profile for a separate Cloudflare login
33
+ (npx cf auth create NAME --no-browser); saved, used by every cf call
34
+ --d1-name D1 database to create or reuse (default: worker name)
35
+ --pairing-approval who approves device-flow pairing requests (default human:
36
+ approval password on the /device page; agent: also \`pair approve\`;
37
+ off: no pairing endpoints, \`token issue\` only)
38
+ --pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
39
+ the Workers maximum; lower only if /device hits error 1102)
40
+ --workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
41
+ --upstream URL proxy / expose mode: a public façade for an agent that already
42
+ speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
43
+ a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
44
+ address). The card is the upstream's, rewritten to this public
45
+ URL; paired peers' calls are forwarded with env UPSTREAM_TOKEN and
46
+ UPSTREAM_ACCESS_CLIENT_ID / _SECRET (never argv). --upstream none
47
+ switches back to the inbox
48
+ --upstream-card-url the upstream's card (default <upstream origin>/.well-known/
49
+ agent-card.json); must be on the --upstream origin (it is
50
+ fetched with the upstream credentials)
51
+ --cli-command command shown in wake hints (default "npx a2a-exposed";
52
+ e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
53
+ --worker-dir where the Worker project (template copy) lives (default
54
+ <config dir>/worker; --dir is the old name). Not the config dir:
55
+ that is --config-dir / A2A_CONFIG_DIR
56
+ deploy [same flags] redeploy with saved settings (secrets persist)
57
+ wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
58
+ [--cli-command CMD] [--debounce S] [--max-per-hour N]
59
+ save wake settings, upload wake secrets from env, and redeploy (one step)
60
+ wake preview rendered wake request (partially masked) + sha256 fingerprints of the
61
+ uploaded URL/key/HMAC secret; compared with local env values if exported
62
+ wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* in the local environment
63
+ wake test send a test wake now and print the HTTP status
64
+ wake unset remove wake secrets (fall back to polling)
65
+
66
+ Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
67
+ anywhere on the account: the inbox itself can be on workers.dev or a custom hostname)
68
+ tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p] [--show-token]
69
+ [--zero-trust-org TEAM]
70
+ named Cloudflare Tunnel + proxied DNS + Access app that admits ONE service token;
71
+ the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
72
+ Wake hostname: wake-<random>.<zone>, on the inbox hostname's zone, else the account's
73
+ only zone; with several zones pass --tunnel-zone. Safe to re-run (no duplicates).
74
+ Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
75
+ (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600)
76
+ tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
77
+ tunnel rm delete Worker wake secrets, DNS record, tunnel, Access app + service token
78
+ init ... --tunnel init, then tunnel create (also with --workers-dev)
79
+
80
+ Status
81
+ status [--json] deployment, base URL, agent card check (fetched by the CLI: no curl needed), wake
82
+ mode (webhook / tunnel / none = polling), tunnel state, proxy-mode upstream and
83
+ card-leak check (--upstream), and the next step to run.
84
+ Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
85
+ url print the public base URL (exit 1 if none is configured)
86
+ config print config (secrets masked) and its location
87
+
88
+ Inbox (owner side)
89
+ inbox [--context C] [--all] [--json] unhandled inbound tasks (peer text is UNTRUSTED), then pending pairing
90
+ requests (tell your human; --json: tasks on stdout, requests on stderr)
91
+ show <taskId> full task JSON
92
+ working <taskId> mark task working
93
+ reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
94
+ [--artifact] [--artifact-name N] [--force]
95
+ history <contextId> [-n 50] [--json] conversation log
96
+ contexts recent conversations
97
+
98
+ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
99
+ connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
100
+ ask another inbox for a token: prints a code + link for your human (who confirms the
101
+ code with that inbox's owner), waits for approval, stores the token as outbound peer A
102
+ (never printed). --no-wait: print the code and exit; run the same command again to
103
+ check (same code). An expired code exits 1 (nothing new is requested silently).
104
+ An alias whose token still works is refused unless --replace (alias --force): the
105
+ peer then swaps the old token for the new one under the same label (older peers
106
+ keep the old token until their owner revokes it). --card-url: the card to name in
107
+ the request (default: this deployment's); a *.ts.net / LAN card is sent as
108
+ informational (flagged "not publicly reachable"), a non-https one is left out
109
+ pair set-password --web [--ttl MIN] [--json]
110
+ one-time link (default 15 min, single use) where your human sets or changes the
111
+ approval password on a web page. Send it to them privately; never open or fill it
112
+ yourself. A new link invalidates the previous one
113
+ pair set-password the same in a terminal, no echo (the human runs it; never via argv/stdin)
114
+ pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode and
115
+ when the approval password was set
116
+ pair approve <code> approve (only with --pairing-approval agent, after your human said yes)
117
+ pair deny <code> deny (any mode; on the /device page deny needs no password)
118
+
119
+ Inbound peer tokens (one per peer label; paired agents get one too)
120
+ token issue <label> print a new token once (fails if the label is active); manual fallback to \`connect\`
121
+ token rotate <label> replace the label's token
122
+ token list | token revoke <label> list shows how each token was created (pairing: code + date);
123
+ revoke of an unknown label exits 1
124
+
125
+ Outbound (agents you call)
126
+ peers add <alias> <url> [--token-env VAR | --token-stdin]
127
+ peers list | peers rm <alias>
128
+ a token variable set in the environment overrides config.env; when a peer token
129
+ differs between the two, connect/send/poll/peers warn on stderr (values never shown)
130
+ send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
131
+ poll --to <alias|url> <taskId> [--proto 0.3|1.0]
132
+ outbound <taskId> stored state of a task you sent (incl. pushed updates)
133
+
134
+ Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
135
+ Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
136
+ Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
137
+
138
+ const S = { type: "string" }, B = { type: "boolean" };
139
+ const tunnelOpts = { "tunnel-hostname": S, "tunnel-zone": S, "tunnel-origin": S, "tunnel-path": S, "show-token": B, "zero-trust-org": S, dir: S, "worker-dir": S };
140
+ const deployOpts = {
141
+ ...Object.fromEntries(dep.DEPLOY_FLAGS.map((f) => [f, S])), dir: S, "worker-dir": S, cron: B, "skip-install": B, "rotate-owner-token": B,
142
+ "workers-dev": B, "workers-dev-subdomain": S,
143
+ };
144
+ const SPEC = {
145
+ init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
146
+ inbox: { context: S, all: B, json: B },
147
+ reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
148
+ history: { n: { type: "string", short: "n", default: "50" }, json: B },
149
+ token: { rotate: B },
150
+ peers: { "token-env": S, "token-stdin": B },
151
+ send: { to: S, text: S, context: S, task: S, push: B, proto: S },
152
+ poll: { to: S, proto: S },
153
+ status: { json: B },
154
+ connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
155
+ pair: { json: B, web: B, ttl: S },
156
+ };
157
+ const STATES = ["completed", "input-required", "failed", "rejected", "working"];
158
+
159
+ function need(v, usage) {
160
+ if (!v) throw new CliError(`usage: a2a-exposed ${usage}`);
161
+ return v;
162
+ }
163
+
164
+ export async function main(argv) {
165
+ const [name, ...rest] = argv;
166
+ if (!name || name === "help" || name === "--help" || name === "-h") return console.log(HELP);
167
+ if (name === "--version" || name === "-v") return console.log(VERSION);
168
+ if (rest.includes("--help") || rest.includes("-h")) return console.log(HELP);
169
+ if (name === "pair" && rest[0] === "set-password") {
170
+ // only --web [--ttl M] [--json]: the password itself never comes from argv, stdin or the environment
171
+ const extra = rest.slice(1);
172
+ const ok = extra.includes("--web") && extra.every((a, i) => ["--web", "--json", "--ttl"].includes(a) || /^--ttl=\d+$/.test(a) || (extra[i - 1] === "--ttl" && /^\d+$/.test(a)));
173
+ if (extra.length && !ok)
174
+ throw new CliError("`pair set-password` takes no password argument: it reads the password from the terminal, without echo (never from argv, stdin or the environment). For a one-time web link instead: `pair set-password --web [--ttl MIN] [--json]`");
175
+ }
176
+ upd.check(VERSION);
177
+ const { values: o, positionals: p } = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
178
+ if (o["worker-dir"] !== undefined) o.dir = o["worker-dir"];
179
+ switch (name) {
180
+ case "init":
181
+ if (o.tunnel) tun.preflight(o); // refuse early (hosted preset, missing --tunnel-path) before anything is deployed
182
+ await dep.init(o);
183
+ if (!o.tunnel) return;
184
+ try { return await tun.create(o); } catch (e) {
185
+ if (e instanceof CliError) e.message += "\n(the inbox is deployed and works; fix the above, then run `a2a-exposed tunnel create`, or check `a2a-exposed status`)";
186
+ throw e;
187
+ }
188
+ case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
189
+ case "deploy": return dep.deploy(o);
190
+ case "status": return st.status(o);
191
+ case "wake": return dep.wake(p[0], o);
192
+ case "url": {
193
+ const u = cmd.baseUrl();
194
+ if (!u) throw new CliError(`no base URL configured in ${C.CONFIG_FILE}: run \`a2a-exposed init\` (or pass --config-dir / A2A_CONFIG_DIR for another deployment)`);
195
+ return console.log(u);
196
+ }
197
+ case "config": {
198
+ const f = C.fileConfig();
199
+ console.log(`# ${C.CONFIG_FILE}`);
200
+ for (const [k, v] of Object.entries(f)) console.log(`${k}=${/TOKEN|KEY|SECRET/.test(k) ? "(set)" : v}`);
201
+ return;
202
+ }
203
+ case "inbox": return cmd.inbox(o);
204
+ case "show": return cmd.show(need(p[0], "show <taskId>"));
205
+ case "working": return cmd.working(need(p[0], "working <taskId>"));
206
+ case "reply":
207
+ if (!STATES.includes(o.state)) throw new CliError(`--state must be one of ${STATES.join(", ")}`);
208
+ return cmd.reply(need(p[0], "reply <taskId> --text ..."), o);
209
+ case "history": return cmd.history(need(p[0], "history <contextId>"), o);
210
+ case "contexts": return cmd.contexts();
211
+ case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
212
+ case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
213
+ case "connect": return pair.connect(need(p[0], "connect <base-or-card-url> [--alias A] [--no-wait] [--json]"), o);
214
+ case "pair": {
215
+ const sub = need(p[0], "pair set-password|list|approve <code>|deny <code>");
216
+ if (sub === "set-password") return pair.setPassword(o);
217
+ if (sub === "list") return pair.list(o);
218
+ if (sub === "approve" || sub === "deny") return pair.decide(sub, need(p[1], `pair ${sub} <code>`));
219
+ throw new CliError(`unknown pair action ${sub} (set-password|list|approve|deny)`);
220
+ }
221
+ case "send": return cmd.send(o);
222
+ case "poll": return cmd.poll(need(p[0], "poll --to <peer> <taskId>"), o);
223
+ case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
224
+ default: throw new CliError(`unknown command ${name} (see --help)`);
225
+ }
226
+ }
package/lib/pair.mjs ADDED
@@ -0,0 +1,349 @@
1
+ // Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628).
2
+ // Owner side (this inbox is the authorization server): pair set-password | list | approve | deny
3
+ // Client side (connect to another inbox): connect <base-or-card-url>
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import * as C from "./config.mjs";
8
+ import { describeHttp, die, fetchCard, httpJson, isPrivateHost, pickEndpoint } from "./a2a.mjs";
9
+ import { baseUrl, peerToken, warnPeerTokenEnv } from "./commands.mjs";
10
+
11
+ const CLI = "a2a-exposed";
12
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
13
+ export const PBKDF2_ITERATIONS = 100000; // default (the Workers runtime's maximum); the Worker advertises its own setting
14
+ export const MIN_PASSWORD_LENGTH = 12;
15
+ const MIGRATION_HINT = `(if this inbox was deployed before device-flow pairing existed, run \`${CLI} deploy\` first: it applies the new D1 migration)`;
16
+
17
+ /** Owner API call that returns { status, data } instead of dying, so errors can be explained. */
18
+ async function ownerCall(method, p, body) {
19
+ const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
20
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
21
+ return httpJson(base + p, { method, body, headers: { authorization: `Bearer ${tok}` } });
22
+ }
23
+ const ownerError = (r) => {
24
+ const msg = r.data && typeof r.data === "object" && typeof r.data.error === "string" ? r.data.error : describeHttp(r.status, r.data);
25
+ return r.status >= 500 ? `${msg} (HTTP ${r.status}) ${MIGRATION_HINT}` : msg;
26
+ };
27
+
28
+ // ---------------------------------------------------------------- approval password (human mode)
29
+ /** Read a line from the terminal without echo. Refuses anything but a real TTY. */
30
+ export function readHidden(prompt, input = process.stdin, output = process.stderr) {
31
+ if (!input.isTTY || typeof input.setRawMode !== "function") die("no terminal");
32
+ return new Promise((resolve, reject) => {
33
+ let buf = "";
34
+ output.write(prompt);
35
+ input.setRawMode(true);
36
+ input.setEncoding("utf8");
37
+ input.resume();
38
+ const done = (err) => {
39
+ input.setRawMode(false);
40
+ input.pause();
41
+ input.removeListener("data", onData);
42
+ output.write("\n");
43
+ err ? reject(err) : resolve(buf);
44
+ };
45
+ const onData = (chunk) => {
46
+ for (const ch of chunk) {
47
+ if (ch === "\r" || ch === "\n") return done();
48
+ if (ch === "\u0003" || ch === "\u0004") return done(new Error("cancelled"));
49
+ if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1);
50
+ else if (ch >= " ") buf += ch;
51
+ }
52
+ };
53
+ input.on("data", onData);
54
+ });
55
+ }
56
+
57
+ /** PBKDF2-SHA256 record for the Worker; the password itself never leaves this machine. */
58
+ export function passwordRecord(password, iterations = PBKDF2_ITERATIONS) {
59
+ const salt = crypto.randomBytes(16);
60
+ const hash = crypto.pbkdf2Sync(password, salt, iterations, 32, "sha256");
61
+ return { alg: "pbkdf2-sha256", iterations, salt: salt.toString("base64"), hash: hash.toString("base64") };
62
+ }
63
+
64
+ /** `pair set-password --web`: a one-time link for the human to set the password on a web page. */
65
+ async function passwordLink(o) {
66
+ let ttlSeconds;
67
+ if (o.ttl !== undefined) {
68
+ const m = Number(o.ttl);
69
+ if (!Number.isInteger(m) || m < 1 || m > 60) die("--ttl is the link lifetime in minutes, 1 to 60 (default 15)");
70
+ ttlSeconds = m * 60;
71
+ }
72
+ const r = await ownerCall("POST", "/owner/pairing/password-link", ttlSeconds ? { ttlSeconds } : {});
73
+ if (r.status === 404)
74
+ die(`this inbox's Worker predates \`pair set-password --web\`: run \`${CLI} deploy\` first, or let your human run \`${CLI} pair set-password\` in a terminal`);
75
+ if (r.status !== 200) die(`could not create a setup link: ${ownerError(r)}`);
76
+ const d = r.data, mins = Math.round(d.expiresIn / 60);
77
+ const tell = `Send this link to your human, privately (it lets whoever opens it ${d.passwordSet ? "change" : "set"} the approval password). They open it and type the password themselves. Never open the link, fill in the page, or ask for the password yourself. Creating a new link invalidates this one.`;
78
+ if (o.json) return console.log(JSON.stringify({ url: d.url, expiresAt: d.expiresAt, expiresIn: d.expiresIn, singleUse: true, passwordSet: d.passwordSet, instructions: tell }));
79
+ console.log(d.url);
80
+ console.error(`One-time link to ${d.passwordSet ? "change" : "set"} the approval password (valid ${mins} minute${mins === 1 ? "" : "s"}, until ${d.expiresAt}; single use).\n${tell}`);
81
+ }
82
+
83
+ export async function setPassword(o = {}) {
84
+ if (o.web) return passwordLink(o);
85
+ if (!process.stdin.isTTY)
86
+ die(`\`pair set-password\` reads the approval password from a terminal, without echo, and stdin is not a terminal.
87
+ The approval password must be known only to the human who approves pairing requests: the human runs this command themselves, in a terminal (never an agent, never from a script).
88
+ No terminal? \`${CLI} pair set-password --web\` prints a one-time link where your human sets it on a web page instead.`);
89
+ let a, b;
90
+ try {
91
+ a = await readHidden(`New approval password (at least ${MIN_PASSWORD_LENGTH} characters, not shown): `);
92
+ if (a.length < MIN_PASSWORD_LENGTH) die(`too short: use at least ${MIN_PASSWORD_LENGTH} characters (a passphrase of a few words works well)`);
93
+ b = await readHidden("Repeat it: ");
94
+ } catch (e) { if (e.message === "cancelled") die("cancelled; nothing changed"); throw e; }
95
+ if (a !== b) die("the two entries differ; nothing changed");
96
+ // the iteration count the Worker is configured for (an older Worker doesn't say: the default)
97
+ let iterations = PBKDF2_ITERATIONS;
98
+ try { const info = await ownerCall("GET", "/owner/pairing"); if (info.status === 200 && Number.isInteger(info.data.pbkdf2Iterations)) iterations = info.data.pbkdf2Iterations; } catch { /* default */ }
99
+ const r = await ownerCall("PUT", "/owner/pairing/password", passwordRecord(a, iterations));
100
+ if (r.status !== 200) die(`could not set the approval password: ${ownerError(r)}`);
101
+ console.error(`approval password set${r.data.setAt ? ` at ${r.data.setAt}` : ""} (stored on the Worker as a salted PBKDF2-SHA256 hash, ${iterations} iterations; the password itself was not sent).`);
102
+ console.error(`Approve pairing requests at ${baseUrl()}/device: a webhook wake carries each request's direct link; polling agents see requests in \`${CLI} inbox\` and \`${CLI} pair list\`.${r.data.mode === "human" ? "" : ` Note: approval mode is ${r.data.mode}.`}`);
103
+ }
104
+
105
+ // ---------------------------------------------------------------- owner: list / approve / deny
106
+ export async function list(o) {
107
+ const r = await ownerCall("GET", "/owner/pairing");
108
+ if (r.status !== 200) die(`could not list pairing requests: ${ownerError(r)}`);
109
+ const d = r.data;
110
+ if (o.json) return console.log(JSON.stringify(d, null, 2));
111
+ const how = d.mode === "off" ? "off (device-flow pairing disabled; `token issue` only)"
112
+ : d.mode === "agent" ? `agent (\`${CLI} pair approve <code>\` after your human says yes, or the ${d.verificationUri} page)`
113
+ : `human (your human approves on ${d.verificationUri} with the approval password)`;
114
+ console.log(`approval: ${how}`);
115
+ if (d.mode !== "off") {
116
+ console.log(`approval password: ${d.passwordSet ? `set${d.passwordSetAt ? ` ${d.passwordSetAt}` : ""}${d.passwordSetVia ? ` (via ${d.passwordSetVia})` : ""}`
117
+ : `NOT SET: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal)`}`);
118
+ if (d.setupLinkExpiresAt) console.log(`setup link: one is open until ${d.setupLinkExpiresAt} (a new \`pair set-password --web\` replaces it)`);
119
+ }
120
+ if (!d.pending.length) return console.log("(no pending pairing requests)");
121
+ for (const p of d.pending) {
122
+ console.log(`--- ${p.userCode} ${JSON.stringify(p.clientName || p.clientId || "(no name)")} (claimed, untrusted) expires ${p.expiresAt}${p.replacesLabel ? ` replaces the active token "${p.replacesLabel}"` : ""}`);
123
+ console.log(` card: ${p.agentCardUrl || "-"} from: ${[p.ip || "?", p.country].filter(Boolean).join(", ")}`);
124
+ console.log(` link: ${p.verificationUriComplete}`);
125
+ }
126
+ console.log(`Ask your human about each request; never approve on your own.`);
127
+ }
128
+
129
+ export async function decide(action, code) {
130
+ const r = await ownerCall("POST", `/owner/pairing/${encodeURIComponent(code)}/${action}`, {});
131
+ if (r.status !== 200) die(ownerError(r));
132
+ const d = r.data;
133
+ if (action === "deny") return console.log(`denied ${d.userCode}${d.clientName ? ` (${JSON.stringify(d.clientName)})` : ""}: no token is issued`);
134
+ console.log(`approved ${d.userCode}${d.clientName ? ` (${JSON.stringify(d.clientName)})` : ""}: the agent collects its token on its next poll, as peer "${d.label}"${d.replaced ? " (its old token stops working)" : ""}. Revoke any time: ${CLI} token revoke ${d.label}`);
135
+ }
136
+
137
+ // ---------------------------------------------------------------- client: connect to another inbox
138
+ const CARD_RE = /\/\.well-known\/agent(-card)?\.json$/;
139
+
140
+ /** Device-flow endpoints from an agent card (A2A 1.0 oauth2SecurityScheme.flows.deviceCode), or null. */
141
+ export function deviceEndpointsFromCard(card) {
142
+ for (const s of Object.values((card && card.securitySchemes) || {})) {
143
+ const f = s && s.oauth2SecurityScheme && s.oauth2SecurityScheme.flows && s.oauth2SecurityScheme.flows.deviceCode;
144
+ if (f && f.deviceAuthorizationUrl && f.tokenUrl) return { device: f.deviceAuthorizationUrl, token: f.tokenUrl };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ const slug = (s) => String(s || "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "").toLowerCase();
150
+ const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000 * Number(process.env.A2A_POLL_SCALE || 1)));
151
+
152
+ async function postForm(url, params, bearer = null) {
153
+ let r;
154
+ const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
155
+ if (bearer) headers.authorization = `Bearer ${bearer}`;
156
+ try {
157
+ r = await fetch(url, { method: "POST", headers,
158
+ body: new URLSearchParams(params).toString(), signal: AbortSignal.timeout(30000), redirect: "error" });
159
+ } catch (e) { die(`request to ${new URL(url).origin} failed: ${e?.cause?.code || e?.cause?.message || e?.message || e}`); }
160
+ const text = await r.text();
161
+ let data = null;
162
+ try { data = JSON.parse(text); } catch { /* not JSON */ }
163
+ return { status: r.status, data: data && typeof data === "object" ? data : {} };
164
+ }
165
+
166
+ const stateFile = (alias) => path.join(C.CONFIG_DIR, `pairing-${alias}.json`);
167
+ function saveState(alias, s) {
168
+ fs.mkdirSync(C.CONFIG_DIR, { recursive: true, mode: 0o700 });
169
+ fs.writeFileSync(stateFile(alias), JSON.stringify(s, null, 2) + "\n", { mode: 0o600 });
170
+ fs.chmodSync(stateFile(alias), 0o600);
171
+ }
172
+ const loadState = (alias) => { try { return JSON.parse(fs.readFileSync(stateFile(alias), "utf8")); } catch { return null; } };
173
+ const dropState = (alias) => fs.rmSync(stateFile(alias), { force: true });
174
+
175
+ /** agent_card_url for a pairing request: --card-url (e.g. an agent that already speaks A2A on a Tailnet and pairs out
176
+ * before it has a public façade), else this deployment's card. Only https is sent (inboxes reject anything else); a
177
+ * private-network card (*.ts.net, LAN) is sent as informational, and the peer's approval page flags it as not
178
+ * publicly reachable. */
179
+ export function cardUrlToSend(flag, own, quiet = false) {
180
+ const note = (m) => { if (!quiet) console.error(m); };
181
+ if (flag) {
182
+ let u;
183
+ try { u = new URL(flag); } catch { die(`--card-url must be a URL (your agent card, e.g. https://agent.example.com/.well-known/agent-card.json)`); }
184
+ if (u.protocol !== "https:" || u.username || u.password) { note(`note: --card-url ${u.origin} is not a plain https URL, so it is left out of the pairing request (inboxes accept only https cards)`); return ""; }
185
+ if (u.href.length > 300) { note("note: --card-url is longer than 300 characters, so it is left out of the pairing request"); return ""; }
186
+ if (isPrivateHost(u.hostname)) note(`note: ${u.hostname} is on a private network: the peer's owner sees the card as informational ("not publicly reachable"). Expose your agent through a public façade for others to reach it (setup skill: "Already have A2A on a Tailnet or LAN").`);
187
+ return u.href;
188
+ }
189
+ return /^https:\/\//.test(own || "") ? `${own}/.well-known/agent-card.json` : "";
190
+ }
191
+
192
+ /** Where to connect: the peer's base URL, card, and device-flow endpoints (card first, then RFC 8414 metadata). */
193
+ async function discover(target) {
194
+ let u;
195
+ try { u = new URL(target); } catch { die(`usage: ${CLI} connect <base-url-or-agent-card-url> [--alias A]`); }
196
+ if (!/^https?:$/.test(u.protocol)) die("the URL must be http(s)");
197
+ const isCard = CARD_RE.test(u.pathname);
198
+ const base = isCard ? u.origin : u.href.replace(/\/$/, "");
199
+ let card = null;
200
+ if (isCard) { const r = await httpJson(u.href); card = r.status === 200 && r.data && typeof r.data === "object" ? r.data : null; }
201
+ else card = await fetchCard(base);
202
+ let ep = deviceEndpointsFromCard(card);
203
+ if (!ep) {
204
+ const m = await httpJson(`${u.origin}/.well-known/oauth-authorization-server`);
205
+ if (m.status === 200 && m.data && m.data.device_authorization_endpoint && m.data.token_endpoint
206
+ && (!m.data.grant_types_supported || m.data.grant_types_supported.includes(DEVICE_GRANT)))
207
+ ep = { device: m.data.device_authorization_endpoint, token: m.data.token_endpoint };
208
+ }
209
+ if (!ep) die(`${base} does not offer device-flow pairing (no OAuth2 deviceCode flow on its agent card, no ${u.origin}/.well-known/oauth-authorization-server).
210
+ Ask its owner for a token instead: they run \`${CLI} token issue <label>\` and send it to you privately; then \`${CLI} peers add <alias> ${base} --token-stdin\`.`);
211
+ for (const x of [ep.device, ep.token]) {
212
+ const e = new URL(x);
213
+ if (e.protocol !== "https:" && !["127.0.0.1", "localhost", "[::1]"].includes(e.hostname)) die(`refusing a non-https pairing endpoint: ${x}`);
214
+ }
215
+ return { base, card, ep };
216
+ }
217
+
218
+ /** Shell-quote one argument for a copy-paste hint. */
219
+ const shq = (v) => (/^[A-Za-z0-9_./:@%+=,-]+$/.test(v) ? v : `'${String(v).replace(/'/g, `'\\''`)}'`);
220
+
221
+ /** Does `token` still work at the peer? A GetTask for a random id answers 401 for a bad token and a JSON-RPC
222
+ * "task not found" (or a task) for a good one. Unknown (network or odd answer): null. */
223
+ async function tokenWorks(base, card, token) {
224
+ const [url, version] = pickEndpoint(base, card);
225
+ const v1 = version.startsWith("1");
226
+ try {
227
+ const r = await fetch(url, { method: "POST", signal: AbortSignal.timeout(15000), redirect: "error",
228
+ headers: { "content-type": "application/json", accept: "application/json", "A2A-Version": v1 ? "1.0" : "0.3", authorization: `Bearer ${token}` },
229
+ body: JSON.stringify({ jsonrpc: "2.0", id: "pairing-probe", method: v1 ? "GetTask" : "tasks/get", params: { id: crypto.randomUUID() } }) });
230
+ if (r.status === 401 || r.status === 403) return false;
231
+ return r.status === 200 ? true : null;
232
+ } catch { return null; }
233
+ }
234
+
235
+ export async function connect(target, o) {
236
+ const emit = (obj, text) => (o.json ? console.log(JSON.stringify(obj)) : console.error(text));
237
+ if (o.alias !== undefined && !/^[A-Za-z0-9_-]{1,32}$/.test(o.alias)) die("--alias must be [A-Za-z0-9_-]{1,32}");
238
+ const replace = !!(o.replace || o.force);
239
+ const { base, card, ep } = await discover(target);
240
+ const alias = o.alias || slug(card && card.name) || slug(new URL(base).hostname.split(".")[0]) || "peer";
241
+ const peers = C.loadPeers();
242
+ const tokenEnv = (peers[alias] && peers[alias].token_env) || C.peerTokenVar(alias);
243
+ if (peers[alias] && peers[alias].url !== base) die(`peer alias "${alias}" is already used for ${peers[alias].url}; pass --alias <another>`);
244
+ const peerName = (card && card.name) || base;
245
+ // the exact command to run again: same target, same alias (also when it was derived), same flags
246
+ const rerun = (extra = {}) => {
247
+ const f = { "no-wait": !!o["no-wait"], ...extra };
248
+ return [CLI, "connect", shq(target), "--alias", alias, o.name ? `--name ${shq(o.name)}` : "", replace ? "--replace" : "", f["no-wait"] ? "--no-wait" : "", o.json ? "--json" : ""].filter(Boolean).join(" ");
249
+ };
250
+ const oldLabel = peers[alias] && peers[alias].paired && peers[alias].paired.label;
251
+
252
+ let st = loadState(alias);
253
+ if (st && st.base !== base) { dropState(alias); st = null; }
254
+ const resumed = !!st;
255
+ if (st && Date.now() >= st.expiresAt) {
256
+ // never start a new request silently: a new code means a new approval prompt for the peer's owner
257
+ dropState(alias);
258
+ die(`the earlier pairing request to ${peerName} (code ${st.userCode}) expired before it was approved; nothing was stored.
259
+ For a new code (a new approval request to its owner), run: ${rerun()}`);
260
+ }
261
+ const oldToken = peerToken(tokenEnv); // warns when the environment shadows a different saved token
262
+ if (!st && oldToken) {
263
+ const works = await tokenWorks(base, card, oldToken);
264
+ if (works !== false && !replace)
265
+ die(`peer "${alias}" already has a token${works ? " that works" : ""} (${tokenEnv}). Nothing to do: send with \`${CLI} send --to ${alias} ...\`.
266
+ To replace it anyway (for example after a leak), run: ${rerun({})} --replace
267
+ A different agent at the same URL? Pass another --alias.`);
268
+ if (works === false && !o.json) console.error(`note: peer "${alias}" rejects the stored token (HTTP 401: revoked or rotated); requesting a new one.`);
269
+ }
270
+ if (st) emit({ status: "authorization_pending", resumed: true, alias, user_code: st.userCode, verification_uri: st.verificationUri, verification_uri_complete: st.verificationUriComplete, expires_in: Math.round((st.expiresAt - Date.now()) / 1000) },
271
+ `Resuming the pairing request to ${peerName} (code ${st.userCode}, link ${st.verificationUriComplete}).`);
272
+ else {
273
+ const own = baseUrl();
274
+ const params = { client_name: o.name || C.get("A2A_AGENT_NAME") || C.get("A2A_WORKER_NAME") || "a2a-exposed agent", client_id: C.get("A2A_WORKER_NAME") || alias };
275
+ const card = cardUrlToSend(o["card-url"], own, o.json);
276
+ if (card) params.agent_card_url = card;
277
+ // replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
278
+ const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
279
+ if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
280
+ const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
281
+ die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token` : `pairing request to ${peerName} failed: ${why}`);
282
+ }
283
+ st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
284
+ verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
285
+ interval: Math.max(1, Number(r.data.interval) || 5), expiresAt: Date.now() + (Number(r.data.expires_in) || 600) * 1000,
286
+ replacesLabel: r.data.replaces_label || null, replacing: replace && !!oldToken };
287
+ saveState(alias, st); // chmod 600: the device code is a bearer secret until it is redeemed
288
+ const mins = Math.round((st.expiresAt - Date.now()) / 60000);
289
+ const swap = !st.replacing ? "" : st.replacesLabel
290
+ ? ` On approval the peer replaces our token "${st.replacesLabel}" (the old one stops working).`
291
+ : ` This peer does not swap tokens: our old token${oldLabel ? ` (label "${oldLabel}" on its side)` : ""} stays active until its owner revokes it${oldLabel ? ` (\`${CLI} token revoke ${oldLabel}\`)` : ""}; tell them.`;
292
+ emit({ status: "authorization_pending", alias, user_code: st.userCode, verification_uri: st.verificationUri, verification_uri_complete: st.verificationUriComplete, expires_in: Math.round((st.expiresAt - Date.now()) / 1000), interval: st.interval,
293
+ ...(st.replacing ? { replaces_label: st.replacesLabel } : {}),
294
+ instructions: `Show the code and the link to your human. They confirm the code with the owner of ${peerName}, who approves it on that page.${swap}` },
295
+ `Pairing with ${peerName} (${base}):
296
+ code: ${st.userCode}
297
+ link: ${st.verificationUriComplete}
298
+ Show this code and link to your human. They confirm the code with the owner of ${peerName}, who approves it on that page (expires in ${mins} minutes).${swap}`);
299
+ }
300
+
301
+ const finish = (r) => {
302
+ if (r.data.token_type && !/^bearer$/i.test(r.data.token_type)) die(`unexpected token_type ${r.data.token_type}`);
303
+ C.saveConfig({ [tokenEnv]: r.data.access_token });
304
+ // a stale PEER_<ALIAS>_TOKEN in the environment would keep winning over the token just saved (on stderr, also with --json)
305
+ const envOverrides = warnPeerTokenEnv(tokenEnv, true);
306
+ const label = r.data.peer_label || null;
307
+ peers[alias] = { url: base, token_env: tokenEnv, token_stored: true, paired: { userCode: st.userCode, at: new Date().toISOString(), ...(label ? { label } : {}) } };
308
+ C.savePeers(peers);
309
+ dropState(alias);
310
+ const orphan = st.replacing && !r.data.replaced ? (st.replacesLabel || oldLabel || null) : null;
311
+ const note = r.data.replaced ? ` It replaced our previous token there (label "${label}").`
312
+ : st.replacing ? ` Our previous token${orphan ? ` ("${orphan}")` : ""} is still active on the peer until its owner revokes it${orphan ? `: \`${CLI} token revoke ${orphan}\`` : ""}.` : "";
313
+ emit({ status: "connected", alias, url: base, token_env: tokenEnv, ...(label ? { peer_label: label } : {}), ...(st.replacing ? { replaced: !!r.data.replaced } : {}), ...(envOverrides ? { env_overrides_token: true } : {}) },
314
+ `connected: peer "${alias}" -> ${base}${label ? ` (we are "${label}" there)` : ""}; token stored in ${C.CONFIG_FILE} as ${tokenEnv} (not printed).${note}
315
+ Send a message: ${CLI} send --to ${alias} --text "..."`);
316
+ };
317
+ const fail = (r) => {
318
+ const err = r.data.error;
319
+ dropState(alias);
320
+ if (err === "access_denied") die(`the owner of ${peerName} denied the pairing request (code ${st.userCode})`);
321
+ if (err === "expired_token") die(`the code ${st.userCode} expired before it was approved; nothing was stored. For a new code, run: ${rerun()}`);
322
+ if (err === "invalid_grant") die(`the pairing request (code ${st.userCode}) is no longer valid (already used, or expired long ago); nothing was stored. For a new code, run: ${rerun()}`);
323
+ die(`pairing with ${peerName} failed: ${r.data.error_description || err || `HTTP ${r.status}`}`);
324
+ };
325
+
326
+ if (o["no-wait"]) {
327
+ // a resumed request checks once, so `--no-wait` can be re-run to see whether it was approved
328
+ if (resumed) {
329
+ const r = await postForm(st.token, { grant_type: DEVICE_GRANT, device_code: st.deviceCode });
330
+ if (r.status === 200 && r.data.access_token) return finish(r);
331
+ if (r.data.error && !["authorization_pending", "slow_down"].includes(r.data.error)) return fail(r);
332
+ }
333
+ emit({ status: "not_waiting", alias, user_code: st.userCode, expires_in: Math.round((st.expiresAt - Date.now()) / 1000), next: rerun() },
334
+ `Not waiting (--no-wait). Run \`${rerun()}\` again to check (same code), or \`${rerun({ "no-wait": false })}\` to wait for approval.`);
335
+ return;
336
+ }
337
+ if (!o.json) console.error("waiting for approval...");
338
+ let interval = st.interval;
339
+ for (;;) {
340
+ if (Date.now() >= st.expiresAt) { dropState(alias); die(`the code ${st.userCode} expired before it was approved; nothing was stored. For a new code, run: ${rerun()}`); }
341
+ await sleep(interval);
342
+ const r = await postForm(st.token, { grant_type: DEVICE_GRANT, device_code: st.deviceCode });
343
+ if (r.status === 200 && r.data.access_token) return finish(r);
344
+ const err = r.data.error;
345
+ if (err === "authorization_pending") continue;
346
+ if (err === "slow_down") { interval += 5; continue; }
347
+ fail(r);
348
+ }
349
+ }