a2a-exposed 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ // Control-plane sign-in (RFC 8628). The CLI is a public client (`a2a-cli`).
2
+ // The session token is written to config.env and is never printed.
3
+ import * as C from "./config.mjs";
4
+ import { CLI, die } from "./a2a.mjs";
5
+
6
+ export const CLI_CLIENT_ID = "a2a-cli";
7
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
8
+
9
+ /** https origin with no userinfo, or "" when the value cannot be a control plane. */
10
+ export function controlOrigin(value) {
11
+ const raw = String(value || "").trim();
12
+ if (!raw) return "";
13
+ try {
14
+ const url = new URL(raw);
15
+ if (url.protocol !== "https:" || url.username || url.password) return "";
16
+ if (url.pathname !== "/" && url.pathname !== "") return "";
17
+ if (url.search || url.hash) return "";
18
+ return url.origin;
19
+ } catch {
20
+ return "";
21
+ }
22
+ }
23
+
24
+ function scale() {
25
+ const n = Number(process.env.A2A_POLL_SCALE);
26
+ return Number.isFinite(n) && n > 0 && n <= 1 ? n : 1;
27
+ }
28
+
29
+ async function readJson(response) {
30
+ const text = await response.text();
31
+ try { return text ? JSON.parse(text) : {}; } catch { return { error: "invalid_response" }; }
32
+ }
33
+
34
+ /**
35
+ * Run the device flow and store CONTROL_URL plus CONTROL_TOKEN.
36
+ * `fetchImpl` and `sleep` are for tests. The token is not included in the result.
37
+ */
38
+ export async function deviceLogin({
39
+ controlUrl,
40
+ intent = "login",
41
+ fetchImpl = fetch,
42
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
43
+ write = (line) => console.error(line),
44
+ save = (updates) => C.saveConfig(updates),
45
+ } = {}) {
46
+ const origin = controlOrigin(controlUrl);
47
+ if (!origin) die(`--control-url must be an https origin with no path (example: --control-url https://control.example)`);
48
+ const codeResponse = await fetchImpl(`${origin}/api/auth/device/code`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json", accept: "application/json" },
51
+ body: JSON.stringify({ client_id: CLI_CLIENT_ID }),
52
+ });
53
+ const started = await readJson(codeResponse);
54
+ if (!codeResponse.ok || !started.device_code || !started.user_code || !started.verification_uri_complete) {
55
+ const reason = typeof started.error === "string" ? started.error : `HTTP ${codeResponse.status}`;
56
+ die(reason === "not_found" || codeResponse.status === 404
57
+ ? `login is not turned on at ${origin}`
58
+ : `could not start sign-in at ${origin} (${reason})`);
59
+ }
60
+ const verb = intent === "signup" ? "Create an account and approve" : "Sign in and approve";
61
+ write(`${verb} this code:\n${started.user_code}\n${started.verification_uri_complete}`);
62
+ const interval = Math.max(1, Number(started.interval) || 5);
63
+ const deadline = Date.now() + Math.max(1, Number(started.expires_in) || 600) * 1000;
64
+ let wait = interval;
65
+ while (Date.now() < deadline) {
66
+ await sleep(wait * 1000 * scale());
67
+ const tokenResponse = await fetchImpl(`${origin}/api/auth/device/token`, {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json", accept: "application/json" },
70
+ body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: started.device_code, client_id: CLI_CLIENT_ID }),
71
+ });
72
+ const body = await readJson(tokenResponse);
73
+ if (tokenResponse.ok && typeof body.access_token === "string" && body.access_token) {
74
+ save({ CONTROL_URL: origin, CONTROL_TOKEN: body.access_token });
75
+ const sessionResponse = await fetchImpl(`${origin}/api/auth/get-session`, {
76
+ headers: { accept: "application/json", authorization: `Bearer ${body.access_token}` },
77
+ });
78
+ const session = await readJson(sessionResponse);
79
+ const email = typeof session?.user?.email === "string" ? session.user.email : "";
80
+ write(email ? `Signed in as ${email}.` : "Signed in.");
81
+ return { controlUrl: origin, email };
82
+ }
83
+ const error = typeof body.error === "string" ? body.error : "";
84
+ if (error === "authorization_pending") continue;
85
+ if (error === "slow_down") {
86
+ wait += 5;
87
+ continue;
88
+ }
89
+ if (error === "access_denied") die("sign-in was denied");
90
+ if (error === "expired_token") die("that code expired; run the command again");
91
+ die(`sign-in failed (${error || `HTTP ${tokenResponse.status}`})`);
92
+ }
93
+ die("that code expired; run the command again");
94
+ }
package/lib/main.mjs CHANGED
@@ -7,6 +7,7 @@ import * as dep from "./deploy.mjs";
7
7
  import * as tun from "./tunnel.mjs";
8
8
  import * as st from "./status.mjs";
9
9
  import * as pair from "./pair.mjs";
10
+ import * as account from "./account.mjs";
10
11
  import * as upd from "./update.mjs";
11
12
 
12
13
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -161,6 +162,18 @@ Outbound (agents you call)
161
162
  poll --to <alias|url> <taskId> [--proto 0.3|1.0]
162
163
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
163
164
 
165
+ Control plane account (only when that deployment's login is on)
166
+ signup --control-url https://control.example
167
+ login --control-url https://control.example
168
+ device-flow sign-in. Prints a code and a page. Type that code
169
+ on the page; the link alone does not approve. The session is saved
170
+ as CONTROL_TOKEN in config.env and is not printed. signup and login
171
+ are the same flow; signup is how the first account is created.
172
+ --json prints the control origin and email, not the token.
173
+ A browser sign-in that does not complete returns to /app?error=<code>.
174
+ The control Worker logs social_sign_in_failed before the provider
175
+ page, and oauth_callback_failed when the provider sends the browser back
176
+
164
177
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
165
178
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
166
179
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
@@ -184,6 +197,8 @@ const SPEC = {
184
197
  export: { sql: B },
185
198
  upstream: { json: B },
186
199
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
200
+ signup: { "control-url": S, json: B },
201
+ login: { "control-url": S, json: B },
187
202
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
188
203
  };
189
204
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
@@ -250,6 +265,12 @@ export async function main(argv) {
250
265
  case "export": return cmd.exportInbox(o);
251
266
  case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
252
267
  case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
268
+ case "signup":
269
+ case "login": {
270
+ const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
271
+ if (o.json) console.log(JSON.stringify({ control_url: result.controlUrl, email: result.email }));
272
+ return;
273
+ }
253
274
  case "connect": return pair.connect(need(p[0], "connect <base-or-card-url> [--alias A] [--no-wait] [--json]"), o);
254
275
  case "pair": {
255
276
  const sub = need(p[0], "pair set-password|set-oidc|list|approve <code>|deny <code>");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {