a2a-exposed 0.8.2 → 0.8.4
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/deploy.mjs +59 -10
- package/lib/main.mjs +2 -1
- package/lib/tunnel.mjs +5 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +2 -1
- package/worker/src/index.ts +3 -3
- package/worker/src/version.ts +1 -1
- package/worker/test/mcp.test.ts +1 -1
- package/worker/test/pairing.test.ts +3 -2
package/lib/deploy.mjs
CHANGED
|
@@ -103,10 +103,13 @@ function syncTemplate(dir) {
|
|
|
103
103
|
fs.cpSync(src, dir, { recursive: true, force: true, filter: (p) => !skip.has(path.basename(p)) });
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function run(cmd, args, { cwd, env, capture = false, allowFail = false } = {}) {
|
|
107
|
-
const r = spawnSync(cmd, args, { cwd, env: env || process.env, stdio: ["ignore", capture ? "pipe" : "inherit", "inherit"], encoding: "utf8" });
|
|
106
|
+
function run(cmd, args, { cwd, env, capture = false, captureStderr = false, allowFail = false } = {}) {
|
|
107
|
+
const r = spawnSync(cmd, args, { cwd, env: env || process.env, stdio: ["ignore", capture ? "pipe" : "inherit", capture && captureStderr ? "pipe" : "inherit"], encoding: "utf8" });
|
|
108
108
|
if (r.error) die(`${cmd}: ${r.error.message}`);
|
|
109
|
-
if (r.status !== 0 && !allowFail)
|
|
109
|
+
if (r.status !== 0 && !allowFail) {
|
|
110
|
+
if (captureStderr && r.stderr) process.stderr.write(r.stderr.endsWith("\n") ? r.stderr : r.stderr + "\n");
|
|
111
|
+
die(`${cmd} ${args[0] || ""} ${args[1] || ""} failed (exit ${r.status})`);
|
|
112
|
+
}
|
|
110
113
|
return r;
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -298,26 +301,61 @@ async function verifyDeployedCard(base) {
|
|
|
298
301
|
console.error(`warning: agent card not reachable yet at ${base} (${r.lastError || "no answer"}); ${C.get("A2A_HOSTNAME") ? "a new custom domain" : "a new workers.dev deployment or subdomain"} can take a few minutes. Check again with: ${CLI} status`);
|
|
299
302
|
}
|
|
300
303
|
|
|
304
|
+
/** Drop cf's non-interactive confirm line. The migrations still apply; the question is answered yes. */
|
|
305
|
+
export function migrationStderr(text) {
|
|
306
|
+
return String(text || "").split(/\r?\n/).filter((line) => {
|
|
307
|
+
const t = line.trim();
|
|
308
|
+
if (!t) return false;
|
|
309
|
+
if (/Using fallback value in non-interactive context/i.test(t)) return false;
|
|
310
|
+
if (/^\? About to apply \d+ migration/.test(t)) return false;
|
|
311
|
+
if (/may not be available to serve requests during the migration, continue\?/.test(t)) return false;
|
|
312
|
+
return true;
|
|
313
|
+
}).join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
301
316
|
/** Apply D1 migrations quietly: cf prints a bare JSON array (often `[]`) when stdout is not a TTY. */
|
|
302
317
|
function applyMigrations(dir, env) {
|
|
303
318
|
step("applying D1 migrations");
|
|
304
|
-
const r = runCf(dir, ["d1", "migrations", "apply", C.get("A2A_D1_ID"), "--dir", "migrations"], { env, capture: true });
|
|
319
|
+
const r = runCf(dir, ["d1", "migrations", "apply", C.get("A2A_D1_ID"), "--dir", "migrations"], { env, capture: true, captureStderr: true, allowFail: true });
|
|
320
|
+
const err = migrationStderr(r.stderr);
|
|
321
|
+
if (err) console.error(err);
|
|
322
|
+
if (r.status !== 0) die(`cf d1 migrations failed (exit ${r.status})`);
|
|
305
323
|
let applied = null;
|
|
306
324
|
try { applied = JSON.parse(r.stdout); } catch { /* not JSON: show as is */ }
|
|
307
325
|
if (Array.isArray(applied)) console.error(applied.length ? ` applied: ${applied.map((m) => m.name || m.id || JSON.stringify(m)).join(", ")}` : " (none pending)");
|
|
308
326
|
else if (r.stdout.trim()) console.error(r.stdout.trim());
|
|
309
327
|
}
|
|
310
328
|
|
|
329
|
+
/** cf deploy sometimes names a Wrangler file. This project configures the Worker in cloudflare.config.ts. */
|
|
330
|
+
export function withoutWranglerConfigMention(text) {
|
|
331
|
+
return String(text || "").split(/\n/).filter((line) => !/Wrangler config file|Wrangler file/i.test(line)).join("\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
311
334
|
/** Run a command, streaming its output to stderr while collecting it. */
|
|
312
335
|
function runTee(cmd, args, { cwd, env }) {
|
|
313
336
|
return new Promise((resolve, reject) => {
|
|
314
337
|
const ch = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
315
|
-
let out = "";
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
338
|
+
let out = "", pending = "";
|
|
339
|
+
const take = (d) => {
|
|
340
|
+
pending += d;
|
|
341
|
+
const lines = pending.split("\n");
|
|
342
|
+
pending = lines.pop();
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (withoutWranglerConfigMention(line) !== line) continue;
|
|
345
|
+
process.stderr.write(line + "\n");
|
|
346
|
+
out += line + "\n";
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
ch.stdout.on("data", take);
|
|
350
|
+
ch.stderr.on("data", take);
|
|
319
351
|
ch.on("error", reject);
|
|
320
|
-
ch.on("close", (code) =>
|
|
352
|
+
ch.on("close", (code) => {
|
|
353
|
+
if (pending && withoutWranglerConfigMention(pending) === pending) {
|
|
354
|
+
process.stderr.write(pending);
|
|
355
|
+
out += pending;
|
|
356
|
+
}
|
|
357
|
+
resolve({ status: code ?? 1, out });
|
|
358
|
+
});
|
|
321
359
|
});
|
|
322
360
|
}
|
|
323
361
|
|
|
@@ -421,6 +459,7 @@ export async function init(o) {
|
|
|
421
459
|
applyMigrations(dir, env);
|
|
422
460
|
|
|
423
461
|
let ownerToken = C.fileConfig().A2A_OWNER_TOKEN;
|
|
462
|
+
const rotated = !!(ownerToken && o["rotate-owner-token"]);
|
|
424
463
|
if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
|
|
425
464
|
C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
|
|
426
465
|
|
|
@@ -432,6 +471,7 @@ export async function init(o) {
|
|
|
432
471
|
console.log(base);
|
|
433
472
|
reportSwitch(switched);
|
|
434
473
|
proxyNote(secrets);
|
|
474
|
+
if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this inbox is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
|
|
435
475
|
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
|
|
436
476
|
printStar();
|
|
437
477
|
}
|
|
@@ -594,7 +634,16 @@ export async function wake(sub, o) {
|
|
|
594
634
|
if (sub === "unset") {
|
|
595
635
|
const dir = workerDir(o), name = workerName();
|
|
596
636
|
const env = cfEnv();
|
|
597
|
-
|
|
637
|
+
const problems = [];
|
|
638
|
+
for (const k of WAKE_SECRETS) {
|
|
639
|
+
const r = runCf(dir, ["workers", "secrets", "delete", k, "--worker", name, "--force"], { env, allowFail: true, capture: true, captureStderr: true });
|
|
640
|
+
if (r.status === 0) continue;
|
|
641
|
+
const err = `${r.stderr || ""}\n${r.stdout || ""}`;
|
|
642
|
+
if (/404|not found|does not exist|could not be found/i.test(err)) continue; // already unset
|
|
643
|
+
const line = (err.match(/\[\d+\][^\n]*/) || [err.trim().split("\n").filter(Boolean).pop() || "cf failed"])[0];
|
|
644
|
+
problems.push(`${k}: ${line.slice(0, 300)}`);
|
|
645
|
+
}
|
|
646
|
+
if (problems.length) die(`could not remove wake secrets:\n ${problems.join("\n ")}`);
|
|
598
647
|
return console.error("wake secrets removed (wake is now a no-op; use polling)");
|
|
599
648
|
}
|
|
600
649
|
die("usage: wake set|unset|test|preview|fingerprint");
|
package/lib/main.mjs
CHANGED
|
@@ -53,7 +53,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
53
53
|
Plugin manifests leave MCP unset: the URL differs per deploy.
|
|
54
54
|
When GET /owner/mcp reports enabled and the agent card is reachable,
|
|
55
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
|
|
56
|
+
--rotate-owner-token on init, replace the owner token; other settings come from config.env.
|
|
57
|
+
Then update A2A_OWNER_TOKEN wherever this inbox is called, and run \`peers sync\`
|
|
57
58
|
--upstream URL proxy / expose mode: a public façade for an agent that already
|
|
58
59
|
speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
|
|
59
60
|
a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
|
package/lib/tunnel.mjs
CHANGED
|
@@ -428,8 +428,11 @@ export async function remove(o, { quiet = false } = {}) {
|
|
|
428
428
|
// 2. DNS before Access, so the hostname never resolves without its Access app
|
|
429
429
|
await attempt("DNS record", "A2A_TUNNEL_DNS_ID", () => cfCall(dir, ["dns", "records", "delete", C.get("A2A_TUNNEL_DNS_ID"), "-z", C.get("A2A_TUNNEL_ZONE_ID"), "--force"], { allowNotFound: true }));
|
|
430
430
|
await attempt("tunnel", "A2A_TUNNEL_ID", () => {
|
|
431
|
-
|
|
432
|
-
|
|
431
|
+
const id = C.get("A2A_TUNNEL_ID");
|
|
432
|
+
// Already deleted (a previous rm stopped on the confirm prompt, or it was removed by hand): clear the saved id.
|
|
433
|
+
if (!cfCall(dir, ["tunnels", "get", id], { allowNotFound: true })) return;
|
|
434
|
+
cfCall(dir, ["tunnels", "connections", "cleanup", id, "--force"], { allowNotFound: true });
|
|
435
|
+
cfCall(dir, ["tunnels", "delete", id, "--force"], { allowNotFound: true });
|
|
433
436
|
});
|
|
434
437
|
await attempt("Access application (and its policy)", "A2A_TUNNEL_ACCESS_APP_ID", () => cfCall(dir, ["zero-trust", "access", "applications", "delete", C.get("A2A_TUNNEL_ACCESS_APP_ID"), "--force"], { allowNotFound: true }));
|
|
435
438
|
await attempt("Access service token", "A2A_TUNNEL_ACCESS_TOKEN_ID", () => {
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ const v = (k: string) => (e[k] ?? "").trim();
|
|
|
11
11
|
const name = v("A2A_WORKER_NAME") || "a2a-exposed";
|
|
12
12
|
const hostname = v("A2A_HOSTNAME"); // e.g. agent.example.com (on a zone in your account)
|
|
13
13
|
// No hostname: serve on <name>.<account subdomain>.workers.dev instead of a custom domain (and the other way round:
|
|
14
|
-
// with a hostname, the workers.dev route is switched off).
|
|
14
|
+
// with a hostname, the workers.dev route is switched off). Preview URLs follow that same switch.
|
|
15
15
|
const workersDev = !hostname;
|
|
16
16
|
const wdSubdomain = v("A2A_WORKERS_DEV_SUBDOMAIN"); // the account's workers.dev subdomain, once known
|
|
17
17
|
// Public base URL for the agent card, derived only from where the Worker is served (no override: a local webhook,
|
|
@@ -112,6 +112,7 @@ export default defineConfig((ctx) => ({
|
|
|
112
112
|
...(hosted ? { exports: { TenantStore: exports.durableObject({ storage: "sqlite" }) } } : {}),
|
|
113
113
|
...(hostname ? { domains: [hostname] } : {}),
|
|
114
114
|
workersDev,
|
|
115
|
+
previewUrls: workersDev,
|
|
115
116
|
// Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
|
|
116
117
|
// invocation logs with CPU time. Query strings are redacted, so one-time setup links never reach the logs.
|
|
117
118
|
...(v("A2A_WORKERS_LOGS") === "1" ? { observability: { enabled: true, redactQueryString: true, logs: { enabled: true, invocationLogs: true } } } : {}),
|
package/worker/src/index.ts
CHANGED
|
@@ -1312,7 +1312,7 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
|
|
|
1312
1312
|
if (now >= r.expires_ms) return oauthError("expired_token", "the device_code expired; start again");
|
|
1313
1313
|
if (r.status === "denied") {
|
|
1314
1314
|
await ctx.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
|
|
1315
|
-
return oauthError("access_denied", "the
|
|
1315
|
+
return oauthError("access_denied", "the agent owner denied this request");
|
|
1316
1316
|
}
|
|
1317
1317
|
if (r.status === "pending") {
|
|
1318
1318
|
// RFC 8628 §3.5: polling faster than the interval -> slow_down, and the interval grows by 5 s for good
|
|
@@ -1321,7 +1321,7 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
|
|
|
1321
1321
|
return oauthError("slow_down", `polling too fast; wait at least ${r.interval_s + 5} seconds between requests`);
|
|
1322
1322
|
}
|
|
1323
1323
|
await ctx.DB.prepare("UPDATE device_requests SET last_poll_ms = ? WHERE device_hash = ?").bind(now, hash).run();
|
|
1324
|
-
return oauthError("authorization_pending", "waiting for the
|
|
1324
|
+
return oauthError("authorization_pending", "waiting for the agent owner to approve");
|
|
1325
1325
|
}
|
|
1326
1326
|
// approved: single use. Only the request that flips the row gets the token; the row is then deleted.
|
|
1327
1327
|
const claimed = await ctx.DB.prepare("UPDATE device_requests SET status = 'redeemed' WHERE device_hash = ? AND status = 'approved'").bind(hash).run();
|
|
@@ -2029,7 +2029,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head =
|
|
|
2029
2029
|
return show(403, { kind: "error", text: "This form expired or came from another site. Start connecting again from your MCP client." });
|
|
2030
2030
|
if (form.decision === "deny") {
|
|
2031
2031
|
log("mcp_authorization_denied", { ip, clientName: c.client!.client_name });
|
|
2032
|
-
return redirectWith(ctx, c.redirect!, { error: "access_denied", error_description: "the
|
|
2032
|
+
return redirectWith(ctx, c.redirect!, { error: "access_denied", error_description: "the agent owner denied access", state: c.params.state });
|
|
2033
2033
|
}
|
|
2034
2034
|
if (form.decision === "oidc") {
|
|
2035
2035
|
const started = await O.startOidc(ctx, {
|
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.4";
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -231,7 +231,7 @@ test("authorization: consent page, CSP allows the redirect origin, password requ
|
|
|
231
231
|
c = await s.consent(q, "", "deny");
|
|
232
232
|
assert.equal(c.post.status, 302);
|
|
233
233
|
const den = new URL(c.post.headers.get("location")!);
|
|
234
|
-
assert.deepEqual([den.origin + den.pathname, den.searchParams.get("error"), den.searchParams.get("state"), den.searchParams.get("iss")], [CLAUDE_CB, "access_denied", "xyz", BASE]);
|
|
234
|
+
assert.deepEqual([den.origin + den.pathname, den.searchParams.get("error"), den.searchParams.get("error_description"), den.searchParams.get("state"), den.searchParams.get("iss")], [CLAUDE_CB, "access_denied", "the agent owner denied access", "xyz", BASE]);
|
|
235
235
|
// CSRF: a POST without the cookie is refused
|
|
236
236
|
const forged = await s.call("POST", "/oauth/authorize", { form: { ...q, csrf: "x".repeat(24), password: PASSWORD, decision: "approve" }, headers: { origin: BASE } });
|
|
237
237
|
assert.equal(forged.status, 403);
|
|
@@ -271,7 +271,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
271
271
|
assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
|
|
272
272
|
|
|
273
273
|
let p = await s.poll(d.device_code);
|
|
274
|
-
assert.deepEqual([p.status, p.data.error], [400, "authorization_pending"]);
|
|
274
|
+
assert.deepEqual([p.status, p.data.error, p.data.error_description], [400, "authorization_pending", "waiting for the agent owner to approve"]);
|
|
275
275
|
p = await s.poll(d.device_code);
|
|
276
276
|
assert.deepEqual([p.status, p.data.error], [400, "slow_down"], "polled again at once");
|
|
277
277
|
assert.match(p.data.error_description, /at least 10 seconds/);
|
|
@@ -390,7 +390,8 @@ test("human mode: the owner token approves and denies; the device poll returns t
|
|
|
390
390
|
assert.equal(denied.data.decision, "denied");
|
|
391
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
392
|
assert.equal(denyRow.decided_by, "owner");
|
|
393
|
-
|
|
393
|
+
const deniedPoll = await s.poll(deny.device_code);
|
|
394
|
+
assert.deepEqual([deniedPoll.data.error, deniedPoll.data.error_description], ["access_denied", "the agent owner denied this request"]);
|
|
394
395
|
});
|
|
395
396
|
|
|
396
397
|
test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
|