a2a-exposed 0.8.1 → 0.8.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.
package/lib/commands.mjs CHANGED
@@ -62,7 +62,7 @@ export async function inbox(o) {
62
62
  }
63
63
  }
64
64
 
65
- const PAIRING_NOTE = `Tell your human who is asking, the code and the link (details: ${CLI} 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.`;
65
+ const PAIRING_NOTE = `Tell your human who is asking, the code and the link (details: ${CLI} pair list). Never approve on your own: in human mode the owner approves with the owner token or on the link with the approval password; in agent mode run \`pair approve <code>\` only after they say yes.`;
66
66
 
67
67
  export const show = async (id) => out(await owner("GET", `/owner/tasks/${q(id)}`));
68
68
 
package/lib/deploy.mjs CHANGED
@@ -204,7 +204,7 @@ function applyFlags(o) {
204
204
  if (o["workers-dev-subdomain"] !== undefined && !WD.LABEL_RE.test(o["workers-dev-subdomain"]))
205
205
  die("--workers-dev-subdomain must be lowercase letters, digits and hyphens (a DNS label)");
206
206
  if (upd.PAIRING_APPROVAL !== undefined && !PAIRING_MODES.includes(upd.PAIRING_APPROVAL))
207
- die(`--pairing-approval must be one of ${PAIRING_MODES.join(" | ")} (human: approval password on the /device page; agent: also \`pair approve\`; off: no device-flow pairing)`);
207
+ die(`--pairing-approval must be one of ${PAIRING_MODES.join(" | ")} (human: the owner token via \`pair approve\`, or the approval password on the /device page; agent: the agent runs \`pair approve\` after the human says yes; off: no device-flow pairing)`);
208
208
  if (upd.A2A_PBKDF2_ITERATIONS !== undefined) {
209
209
  const n = Number(upd.A2A_PBKDF2_ITERATIONS);
210
210
  if (!Number.isInteger(n) || n < 50000 || n > 100000)
package/lib/main.mjs CHANGED
@@ -17,17 +17,20 @@ const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Clo
17
17
 
18
18
  Usage: npx -y a2a-exposed@latest <command> [options]
19
19
  (optional speed-up: npm i -g a2a-exposed, then a2a-exposed <command>)
20
+ --version, -v print the version
21
+ --help, -h print this help
20
22
 
21
23
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
22
24
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
23
- CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. With no Cloudflare profile, token, or account,
24
- or when deploy hits error 1042 after the short wait, a data-center or network block, or an account that
25
- cannot take the Worker, name the reason and say "Ask your human to set it up on https://a2a.exposed")
25
+ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the inbox and is open to everyone
26
+ (https://a2a.exposed/pricing). When to use it, and the sentence to say:
27
+ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
26
28
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
27
29
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
28
30
  [--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
29
31
  [--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
30
32
  [--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
33
+ [--rotate-owner-token]
31
34
  [--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
32
35
  deploy the Worker + D1 to your account; wake secrets are read from
33
36
  env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
@@ -39,13 +42,18 @@ cannot take the Worker, name the reason and say "Ask your human to set it up on
39
42
  (npx cf auth create NAME --no-browser); saved, used by every cf call
40
43
  --d1-name D1 database to create or reuse (default: worker name)
41
44
  --pairing-approval who approves device-flow pairing requests (default human:
42
- approval password on the /device page; agent: also \`pair approve\`;
43
- off: no pairing endpoints, \`token issue\` only)
45
+ the owner token via \`pair approve\`, or the approval password
46
+ on the /device page; agent: the agent runs \`pair approve\` after
47
+ the human says yes; off: no pairing endpoints, \`token issue\` only)
44
48
  --pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
45
49
  the Workers maximum; lower only if /device hits error 1102)
46
50
  --workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
47
51
  --mcp on|off remote MCP server at <url>/mcp for Claude and other MCP clients
48
- (default on; each client needs your approval password once)
52
+ (default on; each client needs your approval password once).
53
+ Plugin manifests leave MCP unset: the URL differs per deploy.
54
+ When GET /owner/mcp reports enabled and the agent card is reachable,
55
+ \`status\` prints \`claude mcp add --transport http a2a-exposed <url>/mcp\`
56
+ --rotate-owner-token on init, replace the owner token; other settings come from config.env
49
57
  --upstream URL proxy / expose mode: a public façade for an agent that already
50
58
  speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
51
59
  a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
@@ -65,8 +73,7 @@ cannot take the Worker, name the reason and say "Ask your human to set it up on
65
73
  --cli-command command shown in wake hints (default "npx -y a2a-exposed@latest";
66
74
  e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
67
75
  --worker-dir where the Worker project (template copy) lives (default
68
- <config dir>/worker; --dir is the old name). Not the config dir:
69
- that is --config-dir / A2A_CONFIG_DIR
76
+ <config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
70
77
  deploy [same flags] redeploy with saved settings (secrets persist)
71
78
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
72
79
  [--cli-command CMD] [--debounce S] [--max-per-hour N]
@@ -100,6 +107,9 @@ Status
100
107
  upstream card, Access credential and upstream bearer configured (separately), the
101
108
  live upstream check, the last failed peer call, card-leak check; warnings for peer
102
109
  token variables shadowing saved tokens; and the next step to run.
110
+ When GET /owner/mcp reports enabled and the agent card is reachable, prints
111
+ \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
112
+ A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
103
113
  Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
104
114
  upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
105
115
  (unauthenticated call -> 401), then the Worker calls the upstream with its stored
@@ -129,8 +139,8 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
129
139
  (never printed). --no-wait: print the code and exit; run the same command again to
130
140
  check (same code). An expired code exits 1 (nothing new is requested silently).
131
141
  An alias whose token still works is refused unless --replace (alias --force): the
132
- peer then swaps the old token for the new one under the same label (older peers
133
- keep the old token until their owner revokes it). --card-url: the card to name in
142
+ peer swaps the token under the same label when it accepts the replacement. When
143
+ it keeps the previous token active, that owner revokes it. --card-url: the card to name in
134
144
  the request (default: this deployment's); a *.ts.net / LAN card is sent as
135
145
  informational (flagged "not publicly reachable"), a non-https one is left out.
136
146
  The name sent is the card named in the request (--card-url, otherwise this
@@ -150,7 +160,8 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
150
160
  Leave the issuer unset and approval stays the password page
151
161
  pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode and
152
162
  when the approval password was set
153
- pair approve <code> approve (only with --pairing-approval agent, after your human said yes)
163
+ pair approve <code> approve with the owner token (human mode records the owner; in agent
164
+ mode, run it only after your human said yes)
154
165
  pair deny <code> deny (any mode; on the /device page deny needs no password)
155
166
 
156
167
  Inbound peer tokens (one per peer label; paired agents get one too)
package/lib/pair.mjs CHANGED
@@ -153,7 +153,7 @@ export async function list(o) {
153
153
  if (o.json) return console.log(JSON.stringify(d, null, 2));
154
154
  const how = d.mode === "off" ? "off (device-flow pairing disabled; `token issue` only)"
155
155
  : d.mode === "agent" ? `agent (\`${CLI} pair approve <code>\` after your human says yes, or the ${d.verificationUri} page)`
156
- : `human (your human approves on ${d.verificationUri} with the approval password)`;
156
+ : `human (the owner approves with the owner token, or on ${d.verificationUri} with the approval password)`;
157
157
  console.log(`approval: ${how}`);
158
158
  if (d.mode !== "off") {
159
159
  console.log(`approval password: ${d.passwordSet ? `set${d.passwordSetAt ? ` ${d.passwordSetAt}` : ""}${d.passwordSetVia ? ` (via ${d.passwordSetVia})` : ""}`
package/lib/status.mjs CHANGED
@@ -184,6 +184,7 @@ export async function collect() {
184
184
  upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
185
185
  upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
186
186
  peerTokenShadowed: shadowedPeerTokens(),
187
+ mcp: "unknown",
187
188
  };
188
189
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
189
190
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
@@ -209,6 +210,11 @@ export async function collect() {
209
210
  catch (e) { s.facade = { mode: "unknown", error: e.message }; }
210
211
  if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
211
212
  }
213
+ try {
214
+ const m = await ownerTry("GET", "/owner/mcp");
215
+ if (m.status === 200 && m.data && typeof m.data === "object" && typeof m.data.enabled === "boolean")
216
+ s.mcp = m.data.enabled ? "on" : "off";
217
+ } catch { /* probe failed: leave mcp unknown so status omits the add line */ }
212
218
  }
213
219
  }
214
220
  const dir = D.workerDir({});
@@ -250,6 +256,12 @@ export async function upstreamCheck(base) {
250
256
  return out;
251
257
  }
252
258
 
259
+ /** Exact `claude mcp add` line when GET /owner/mcp reports `enabled` and the agent card is reachable. */
260
+ export function mcpAddCommand(s) {
261
+ if (!s || !s.baseUrl || s.mcp !== "on" || !s.card?.ok || !s.ownerApi?.ok) return "";
262
+ return `claude mcp add --transport http a2a-exposed ${String(s.baseUrl).replace(/\/$/, "")}/mcp`;
263
+ }
264
+
253
265
  /** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
254
266
  export async function upstreamVerify(o) {
255
267
  if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
@@ -270,7 +282,8 @@ export async function upstreamVerify(o) {
270
282
  export async function status(o) {
271
283
  const s = await collect();
272
284
  const next = nextStep(s);
273
- if (o.json) console.log(JSON.stringify({ ...s, nextStep: next.text, also: next.also, ok: next.ok }, null, 2));
285
+ const mcpAdd = mcpAddCommand(s);
286
+ if (o.json) console.log(JSON.stringify({ ...s, mcpAdd, nextStep: next.text, also: next.also, ok: next.ok }, null, 2));
274
287
  else {
275
288
  const rows = [
276
289
  ["config", s.configFile],
@@ -305,6 +318,7 @@ export async function status(o) {
305
318
  if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
306
319
  : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
307
320
  }
321
+ if (mcpAdd) rows.push(["mcp", mcpAdd]);
308
322
  rows.push(["next step", next.text]);
309
323
  for (const a of next.also || []) rows.push(["also", a]);
310
324
  const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.1",
3
+ "version": "0.8.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": {
@@ -355,7 +355,7 @@ function securitySchemes(ctx: TenantContext): Json {
355
355
  description: "Get a bearer token without exchanging secrets in chat: OAuth 2.0 Device Authorization Grant (RFC 8628). " +
356
356
  "POST client_name (your agent's name) and agent_card_url (your card) form-encoded to deviceAuthorizationUrl; show the user_code and " +
357
357
  "verification_uri_complete to your human, who confirms the code with this agent's owner; poll tokenUrl with grant_type=" + P.DEVICE_GRANT +
358
- " every `interval` seconds. The owner approves every request" + (pairingMode(ctx) === "human" ? " on the verification page." : ".") +
358
+ " every `interval` seconds. The owner approves every request" + (pairingMode(ctx) === "human" ? " with the owner token or on the verification page." : ".") +
359
359
  " The access_token is a per-peer token for Authorization: Bearer (no refresh token; the owner can revoke it).",
360
360
  flows: { deviceCode: { deviceAuthorizationUrl: u.device, tokenUrl: u.token, scopes: { a2a: "Send messages to this agent and read the tasks you created" } } },
361
361
  oauth2MetadataUrl: u.metadata,
@@ -1716,11 +1716,16 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1716
1716
  if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
1717
1717
  const approve = seg[1] === "approve";
1718
1718
  // The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
1719
- // header on /owner/* does not approve and does not change who is logged.
1719
+ // header on /owner/* does not change who is logged.
1720
+ // handleOwner already returned 401 unless this request presented the owner token or is an in-process call.
1721
+ // The owner token approves and denies in human mode, and decide records that as the owner. An in-process
1722
+ // call that is not the human actor is the agent path: human mode still refuses its approve.
1720
1723
  const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
1721
- if (approve && mode !== "agent" && !dashboardHuman)
1722
- return json({ error: `approval mode is human: the owner approves on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password. \`pair approve\` works only after \`${cliCommand(ctx)} deploy --pairing-approval agent\`, and only if your human wants the agent to approve. \`pair deny\` works in every mode.` }, 403);
1723
- const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
1724
+ const ownerAuthed = !trusted;
1725
+ if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
1726
+ return json({ error: `approval mode is human: this call cannot approve. The owner approves with the owner token, or on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password.` }, 403);
1727
+ const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
1728
+ const d = await decide(ctx, code, approve, by);
1724
1729
  if (!d.ok) return json({ error: d.error }, 409);
1725
1730
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1726
1731
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.1";
2
+ export const VERSION = "0.8.2";
@@ -80,7 +80,7 @@ export function wakePayload(ev: WakeEvent, cli: string): Record<string, unknown>
80
80
  export function pairingInstructions(p: PairingInfo, cli: string): string {
81
81
  return p.approval === "human"
82
82
  ? `Ask your human; never approve on your own. Show them the code ${p.userCode} and give them this link: ${p.verificationUriComplete} . ` +
83
- `They approve or deny there with their approval password, after checking the code with the other agent's owner. Details: \`${cli} pair list\`.`
83
+ `They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.`
84
84
  : `Ask your human; never approve on your own. Show them the code ${p.userCode}, who is asking, and the link ${p.verificationUriComplete} . ` +
85
85
  `Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.`;
86
86
  }
@@ -708,17 +708,50 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
708
708
  assert.equal(pending.some((row) => row.userCode === code && row.clientName === "Barry Bot" && row.clientId === "barry-alias" && row.agentCardUrl === "https://barry.example/.well-known/agent-card.json"), true);
709
709
  const refused = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve` });
710
710
  assert.equal(refused.status, 403);
711
- const httpSpoof = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
711
+ assert.match(String((refused.body as { error?: string }).error || ""), /this call cannot approve/);
712
+ const httpNoAuth = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
712
713
  method: "POST",
713
- headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
714
+ headers: { "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
715
+ });
716
+ assert.equal(httpNoAuth.status, 401);
717
+ const httpPeer = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
718
+ method: "POST",
719
+ headers: { authorization: "Bearer a2aow_" + "b".repeat(43), "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
714
720
  });
715
- assert.equal(httpSpoof.status, 403);
721
+ assert.equal(httpPeer.status, 401);
716
722
  const approved = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve`, actor: "human" });
717
723
  assert.equal(approved.status, 200);
718
724
  assert.equal((approved.body as { decision?: string }).decision, "approved");
719
725
  assert.equal(await saw('"event":"pairing_approved"'), true);
720
726
  assert.equal(lines.some((line) => line.includes('"event":"pairing_approved"') && line.includes('"by":"human"')), true);
721
727
 
728
+ const ownerHttpStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
729
+ method: "POST",
730
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.24" },
731
+ body: "client_name=Owner+Http&client_id=owner-http",
732
+ });
733
+ const ownerHttpDevice = await ownerHttpStart.json() as { user_code?: string; device_code?: string };
734
+ assert.equal(ownerHttpStart.status, 200, JSON.stringify(ownerHttpDevice));
735
+ const ownerHttpAt = lines.length;
736
+ const ownerHttp = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${ownerHttpDevice.user_code}/approve`, {
737
+ method: "POST",
738
+ headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
739
+ });
740
+ const ownerHttpBody = await ownerHttp.json() as { decision?: string; error?: string };
741
+ assert.equal(ownerHttp.status, 200, JSON.stringify(ownerHttpBody));
742
+ assert.equal(ownerHttpBody.decision, "approved");
743
+ const ownerHttpLine = lines.slice(ownerHttpAt).find((line) => line.includes('"event":"pairing_approved"')) ?? "";
744
+ assert.match(ownerHttpLine, /"by":"owner"/);
745
+ assert.equal(ownerHttpLine.includes('"by":"human"'), false);
746
+ const ownerHttpPoll = await mf.dispatchFetch("https://carol.example.com/oauth/token", {
747
+ method: "POST",
748
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.25" },
749
+ body: `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant-type:device_code")}&device_code=${encodeURIComponent(ownerHttpDevice.device_code || "")}`,
750
+ });
751
+ const ownerHttpToken = await ownerHttpPoll.json() as { access_token?: string; error?: string };
752
+ assert.equal(ownerHttpPoll.status, 200, JSON.stringify(ownerHttpToken));
753
+ assert.match(ownerHttpToken.access_token || "", /^a2aow_/);
754
+
722
755
  const deniedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
723
756
  method: "POST",
724
757
  headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
@@ -817,3 +850,77 @@ test("TenantStore.deleteStorage wipes the object and a second call succeeds", as
817
850
  assert.equal(stayed.configured, false);
818
851
  assert.deepEqual(stayed.tables, []);
819
852
  });
853
+
854
+ test("self-host workerd: human mode owner token approves, the device poll returns the token, and agent calls are refused", async (t) => {
855
+ const base = "https://agent.example.com";
856
+ const mf = await start({
857
+ bindings: { PUBLIC_URL: base, OWNER_TOKEN: "owner-secret", PAIRING_APPROVAL: "human", AGENT_NAME: "Test Inbox" },
858
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
859
+ });
860
+ t.after(() => mf.dispose());
861
+ await mf.ready;
862
+ await migrateD1(mf);
863
+ const grant = "urn:ietf:params:oauth:grant-type:device_code";
864
+ const startDevice = async (ip: string, name: string) => {
865
+ const res = await mf.dispatchFetch(base + "/oauth/device_authorization", {
866
+ method: "POST",
867
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": ip },
868
+ body: `client_name=${encodeURIComponent(name)}&client_id=barry`,
869
+ });
870
+ const data = await res.json() as { user_code?: string; device_code?: string; error?: string };
871
+ assert.equal(res.status, 200, JSON.stringify(data));
872
+ return data;
873
+ };
874
+ const device = await startDevice("198.51.100.40", "Barry Bot");
875
+ const code = device.user_code || "";
876
+ const anon = await mf.dispatchFetch(`${base}/owner/pairing/${code}/approve`, {
877
+ method: "POST",
878
+ headers: { "x-a2a-dashboard-actor": "human" },
879
+ });
880
+ assert.equal(anon.status, 401);
881
+ const peer = await mf.dispatchFetch(`${base}/owner/pairing/${code}/approve`, {
882
+ method: "POST",
883
+ headers: { authorization: "Bearer a2aow_" + "c".repeat(43), "x-a2a-dashboard-actor": "human" },
884
+ });
885
+ assert.equal(peer.status, 401);
886
+ const approved = await mf.dispatchFetch(`${base}/owner/pairing/${code}/approve`, {
887
+ method: "POST",
888
+ headers: { authorization: "Bearer owner-secret", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
889
+ });
890
+ const approvedBody = await approved.json() as { decision?: string; error?: string };
891
+ assert.equal(approved.status, 200, JSON.stringify(approvedBody));
892
+ assert.equal(approvedBody.decision, "approved");
893
+ const row = await (await mf.getD1Database("DB")).prepare("SELECT decided_by, status FROM device_requests WHERE user_code = ?")
894
+ .bind(code.replace("-", "")).first<{ decided_by: string; status: string }>();
895
+ assert.equal(row?.decided_by, "owner");
896
+ assert.equal(row?.status, "approved");
897
+ const polled = await mf.dispatchFetch(base + "/oauth/token", {
898
+ method: "POST",
899
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.41" },
900
+ body: `grant_type=${encodeURIComponent(grant)}&device_code=${encodeURIComponent(device.device_code || "")}`,
901
+ });
902
+ const token = await polled.json() as { access_token?: string; token_type?: string; peer_label?: string; error?: string };
903
+ assert.equal(polled.status, 200, JSON.stringify(token));
904
+ assert.match(token.access_token || "", /^a2aow_/);
905
+ assert.equal(token.token_type, "Bearer");
906
+ assert.equal(token.peer_label, "Barry-Bot");
907
+
908
+ const second = await startDevice("198.51.100.42", "Deny Bot");
909
+ const denied = await mf.dispatchFetch(`${base}/owner/pairing/${second.user_code}/deny`, {
910
+ method: "POST",
911
+ headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
912
+ });
913
+ const deniedBody = await denied.json() as { decision?: string; error?: string };
914
+ assert.equal(denied.status, 200, JSON.stringify(deniedBody));
915
+ assert.equal(deniedBody.decision, "denied");
916
+ const denyRow = await (await mf.getD1Database("DB")).prepare("SELECT decided_by FROM device_requests WHERE user_code = ?")
917
+ .bind((second.user_code || "").replace("-", "")).first<{ decided_by: string }>();
918
+ assert.equal(denyRow?.decided_by, "owner");
919
+ const deniedPoll = await mf.dispatchFetch(base + "/oauth/token", {
920
+ method: "POST",
921
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.43" },
922
+ body: `grant_type=${encodeURIComponent(grant)}&device_code=${encodeURIComponent(second.device_code || "")}`,
923
+ });
924
+ const deniedToken = await deniedPoll.json() as { error?: string };
925
+ assert.equal(deniedToken.error, "access_denied");
926
+ });
@@ -2,8 +2,9 @@
2
2
  // wake webhook captured. Run: npm test
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import worker from "../src/index.ts";
5
+ import worker, { runOwnerRpc } from "../src/index.ts";
6
6
  import { DASHBOARD_ACTOR_HEADER } from "../src/owner-rpc.ts";
7
+ import { resolveTenant } from "../src/tenancy.ts";
7
8
  import * as P from "../src/pairing.ts";
8
9
  import { wakeSummary, renderWake, type WakeEvent } from "../src/wake.ts";
9
10
  import { d1 } from "./d1.ts";
@@ -244,7 +245,7 @@ test("PAIRING_APPROVAL=off: no pairing scheme, endpoints 404, plain 401", async
244
245
  assert.equal(a.status, 404);
245
246
  });
246
247
 
247
- test("human approval: pending, slow_down, page approve with the password, single-use token, owner approve refused", async (t) => {
248
+ test("human approval: pending, slow_down, page approve with the password, single-use token", async (t) => {
248
249
  const s = setup(); t.after(s.restore);
249
250
  const st = await s.start({ client_name: 'Barry <script>alert(1)</script>', client_id: "barry", agent_card_url: "https://barry.example.org/.well-known/agent-card.json" });
250
251
  assert.equal(st.status, 200);
@@ -281,13 +282,6 @@ test("human approval: pending, slow_down, page approve with the password, single
281
282
  advance(16);
282
283
  assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
283
284
 
284
- // human mode: the owner API (the agent) cannot approve, and the dashboard header does not change that
285
- const oa = await s.owner("POST", `/owner/pairing/${d.user_code}/approve`);
286
- assert.equal(oa.status, 403);
287
- assert.match(oa.data.error, /approval mode is human: the owner approves on https:\/\/agent\.example\.com\/device\?user_code=/);
288
- const spoofed = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
289
- assert.equal(spoofed.status, 403);
290
- assert.match(spoofed.data.error, /approval mode is human/);
291
285
  const list = await s.owner("GET", "/owner/pairing");
292
286
  assert.deepEqual([list.data.mode, list.data.passwordSet, list.data.pending.length, list.data.pending[0].userCode], ["human", false, 1, d.user_code]);
293
287
 
@@ -351,6 +345,54 @@ test("human approval: pending, slow_down, page approve with the password, single
351
345
  assert.equal((await s.call("POST", "/", { json: { jsonrpc: "2.0", id: 1, method: "GetTask", params: { id: "x" } }, headers: { authorization: `Bearer ${p.data.access_token}` } })).status, 401);
352
346
  });
353
347
 
348
+ test("human mode: the owner token approves and denies; the device poll returns the token; agent and anonymous calls are refused", async (t) => {
349
+ const lines: string[] = [];
350
+ const orig = console.log;
351
+ console.log = (...args: unknown[]) => { lines.push(args.map((part) => String(part)).join(" ")); orig(...args); };
352
+ t.after(() => { console.log = orig; });
353
+ const s = setup(); t.after(s.restore);
354
+ const st = await s.start({ client_name: "Barry Bot", client_id: "barry" }, "203.0.113.70");
355
+ assert.equal(st.status, 200, JSON.stringify(st.data));
356
+ const d = st.data;
357
+ const anon = await s.call("POST", `/owner/pairing/${d.user_code}/approve`);
358
+ assert.equal(anon.status, 401);
359
+ const peerTok = "a2aow_" + "c".repeat(43);
360
+ const agent = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { authorization: `Bearer ${peerTok}`, [DASHBOARD_ACTOR_HEADER]: "human" } });
361
+ assert.equal(agent.status, 401);
362
+ const headerOnly = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { [DASHBOARD_ACTOR_HEADER]: "human" } });
363
+ assert.equal(headerOnly.status, 401);
364
+ const rpc = await runOwnerRpc(resolveTenant(s.env), { waitUntil() {}, passThroughOnException() {} }, {
365
+ accountId: "acct_owner", method: "POST", path: `/owner/pairing/${d.user_code}/approve`, actor: "human",
366
+ });
367
+ assert.equal(rpc.status, 404);
368
+ assert.equal((await s.owner("GET", "/owner/pairing")).data.pending.length, 1, "an in-process host call does not approve a self-hosted Worker");
369
+
370
+ const approved = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, {
371
+ headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" },
372
+ });
373
+ assert.equal(approved.status, 200, JSON.stringify(approved.data));
374
+ assert.equal(approved.data.decision, "approved");
375
+ const decided = s.DB.db.prepare("SELECT decided_by, status FROM device_requests WHERE user_code = ?").get(d.user_code.replace("-", "")) as { decided_by: string; status: string };
376
+ assert.equal(decided.decided_by, "owner");
377
+ assert.equal(decided.status, "approved");
378
+ const logged = lines.find((line) => line.includes('"event":"pairing_approved"')) ?? "";
379
+ assert.match(logged, /"by":"owner"/);
380
+ assert.equal(logged.includes('"by":"human"'), false);
381
+ const polled = await s.poll(d.device_code);
382
+ assert.equal(polled.status, 200, JSON.stringify(polled.data));
383
+ assert.match(polled.data.access_token, /^a2aow_[A-Za-z0-9_-]{43}$/);
384
+ assert.equal(polled.data.token_type, "Bearer");
385
+ assert.equal(polled.data.peer_label, "Barry-Bot");
386
+
387
+ const deny = (await s.start(undefined, "203.0.113.71")).data;
388
+ const denied = await s.owner("POST", `/owner/pairing/${deny.user_code}/deny`);
389
+ assert.equal(denied.status, 200, JSON.stringify(denied.data));
390
+ assert.equal(denied.data.decision, "denied");
391
+ const denyRow = s.DB.db.prepare("SELECT decided_by FROM device_requests WHERE user_code = ?").get(deny.user_code.replace("-", "")) as { decided_by: string };
392
+ assert.equal(denyRow.decided_by, "owner");
393
+ assert.equal((await s.poll(deny.device_code)).data.error, "access_denied");
394
+ });
395
+
354
396
  test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
355
397
  const lines: string[] = [];
356
398
  const orig = console.log;