a2a-exposed 0.7.1 → 0.7.2

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,17 @@ 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
+ and the control Worker logs oauth_callback_failed with that code
175
+
164
176
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
165
177
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
166
178
  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 +196,8 @@ const SPEC = {
184
196
  export: { sql: B },
185
197
  upstream: { json: B },
186
198
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
199
+ signup: { "control-url": S, json: B },
200
+ login: { "control-url": S, json: B },
187
201
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
188
202
  };
189
203
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
@@ -250,6 +264,12 @@ export async function main(argv) {
250
264
  case "export": return cmd.exportInbox(o);
251
265
  case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
252
266
  case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
267
+ case "signup":
268
+ case "login": {
269
+ const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
270
+ if (o.json) console.log(JSON.stringify({ control_url: result.controlUrl, email: result.email }));
271
+ return;
272
+ }
253
273
  case "connect": return pair.connect(need(p[0], "connect <base-or-card-url> [--alias A] [--no-wait] [--json]"), o);
254
274
  case "pair": {
255
275
  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.2",
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": {