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 +1 -1
- package/lib/deploy.mjs +1 -1
- package/lib/main.mjs +22 -11
- package/lib/pair.mjs +1 -1
- package/lib/status.mjs +15 -1
- package/package.json +1 -1
- package/worker/src/index.ts +10 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +1 -1
- package/worker/test/hosted.test.ts +110 -3
- package/worker/test/pairing.test.ts +51 -9
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
|
|
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:
|
|
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.
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
133
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
package/worker/src/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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 });
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.2";
|
package/worker/src/wake.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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
|
|
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;
|