a2a-exposed 0.5.0 → 0.7.0

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.
Files changed (46) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +24 -15
  4. package/lib/deploy.mjs +20 -17
  5. package/lib/main.mjs +24 -9
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +51 -7
  12. package/worker/deploy.env.example +32 -1
  13. package/worker/migrations/0008_oidc_txns.sql +12 -0
  14. package/worker/package.json +6 -2
  15. package/worker/src/approval-oidc.ts +247 -0
  16. package/worker/src/backup.ts +186 -0
  17. package/worker/src/directory.ts +120 -0
  18. package/worker/src/export.ts +156 -0
  19. package/worker/src/hosted.ts +403 -0
  20. package/worker/src/index.ts +748 -584
  21. package/worker/src/mcp.ts +30 -12
  22. package/worker/src/migrations.ts +22 -0
  23. package/worker/src/pairing.ts +17 -4
  24. package/worker/src/sql-raw.d.ts +5 -0
  25. package/worker/src/storage.ts +100 -0
  26. package/worker/src/tenancy.ts +450 -0
  27. package/worker/src/wake-target.ts +202 -0
  28. package/worker/src/wake.ts +3 -2
  29. package/worker/test/alarms.test.ts +231 -0
  30. package/worker/test/approval-oidc.test.ts +331 -0
  31. package/worker/test/bridge-thread.mjs +40 -0
  32. package/worker/test/d1.ts +146 -2
  33. package/worker/test/directory.test.ts +90 -0
  34. package/worker/test/export.test.ts +544 -0
  35. package/worker/test/facade.test.ts +1 -1
  36. package/worker/test/harness-worker.ts +80 -0
  37. package/worker/test/hosted.test.ts +221 -0
  38. package/worker/test/migrations.test.ts +5 -5
  39. package/worker/test/pairing.test.ts +5 -4
  40. package/worker/test/register-hooks.mjs +4 -0
  41. package/worker/test/sql-raw-hook.mjs +22 -0
  42. package/worker/test/storage.test.ts +74 -0
  43. package/worker/test/tenancy.test.ts +217 -0
  44. package/worker/test/wake-retry.test.ts +24 -1
  45. package/worker/test/wake-target.test.ts +187 -0
  46. package/worker/test/wake.test.ts +3 -0
package/worker/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // metadata. Pure helpers live here; the routes (D1, approval password, owner API) are in index.ts.
4
4
  import { esc } from "./pairing.ts";
5
5
  import { isPrivateHost } from "./a2a.ts";
6
+ import { derivePeerKey } from "./tenancy.ts";
6
7
 
7
8
  export const SCOPE = "inbox";
8
9
  /** Modern (stateless, per-request _meta) versions: no initialize, server/discover, resultType, Mcp-Method / Mcp-Name headers. */
@@ -169,6 +170,10 @@ export type ConsentModel = {
169
170
  params: Record<string, string>; // the authorization request, posted back in hidden fields
170
171
  passwordSet: boolean;
171
172
  notice?: { kind: "ok" | "error" | "info"; text: string };
173
+ /** Set only when OpenID Connect approval is offered. Absent keeps the consent HTML unchanged. */
174
+ oidc?: { label: string };
175
+ /** False hides the password field. Absent means the password field is shown as before. */
176
+ passwordEnabled?: boolean;
172
177
  };
173
178
 
174
179
  export function consentBody(m: ConsentModel): string {
@@ -184,41 +189,54 @@ export function consentBody(m: ConsentModel): string {
184
189
  const idRow = m.clientIdHost ? `<tr><th>Client identity published at</th><td>${esc(m.clientIdHost)}</td></tr>` : "";
185
190
  const table = `<table><tr><th>Client (as it calls itself)</th><td>${esc(m.clientName || "(no name given)")}</td></tr>${idRow}<tr><th>Returns to</th><td>${esc(host)}</td></tr></table>`;
186
191
  const scope = `<h2>It will be able to</h2><ul>${can.map((x) => `<li>${esc(x)}</li>`).join("")}</ul><h2>It will not be able to</h2><ul>${cannot.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`;
187
- const form = m.passwordSet
192
+ const passwordOn = m.passwordEnabled !== false;
193
+ const form = passwordOn && m.passwordSet
188
194
  ? `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}
189
195
  <label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
190
196
  <div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div></form>`
191
- : `<p class="n info">No approval password is set yet: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and open the one-time link it sends you, then start connecting again.</p>
192
- <form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`;
197
+ : passwordOn
198
+ ? `<p class="n info">No approval password is set yet: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and open the one-time link it sends you, then start connecting again.</p>
199
+ <form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`
200
+ : `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`;
201
+ const oidcForm = m.oidc ? `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}
202
+ <div class="b"><button name="decision" value="oidc" type="submit">Continue with ${esc(m.oidc.label)}</button></div></form>` : "";
193
203
  return `${notice}<p>An MCP client asks to use the inbox of <b>${esc(m.agentName)}</b> as a connector.</p>${table}${loop}${scope}
194
- <p class="w">Approve only if you are adding this connector right now. The name above is claimed by the client. Revoke access any time: <code>${esc(m.cli)} token list</code>, then <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>${form}`;
204
+ <p class="w">Approve only if you are adding this connector right now. The name above is claimed by the client. Revoke access any time: <code>${esc(m.cli)} token list</code>, then <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>${form}${oidcForm}`;
195
205
  }
196
206
 
197
- // ------------------------------------------------------------------ outbound peer token encryption (AES-GCM, key from OWNER_TOKEN via HKDF)
207
+ // ------------------------------------------------------------------ outbound peer token encryption (AES-GCM)
208
+ // Self-host: HKDF(OWNER_TOKEN), salt "a2a-exposed", info "outbound-peer-token v1" (existing ciphertext still decrypts).
209
+ // Hosted: TenantContext.peerKey() uses the same HKDF with info bound to the tenant id and IKM TENANT_SECRETS_KEY.
198
210
  async function peerKey(ownerToken: string): Promise<CryptoKey> {
199
- const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ownerToken), "HKDF", false, ["deriveKey"]);
200
- return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("a2a-exposed"), info: new TextEncoder().encode("outbound-peer-token v1") },
201
- base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
211
+ return derivePeerKey(ownerToken);
202
212
  }
203
213
  const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));
204
214
  const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
205
215
 
206
- export async function sealPeerToken(ownerToken: string, alias: string, token: string): Promise<string> {
216
+ export async function sealPeerTokenWithKey(key: CryptoKey, alias: string, token: string): Promise<string> {
207
217
  const iv = crypto.getRandomValues(new Uint8Array(12));
208
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(alias) }, await peerKey(ownerToken), new TextEncoder().encode(token)));
218
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(alias) }, key, new TextEncoder().encode(token)));
209
219
  return `v1.${b64(iv)}.${b64(ct)}`;
210
220
  }
211
221
 
222
+ export async function sealPeerToken(ownerToken: string, alias: string, token: string): Promise<string> {
223
+ return sealPeerTokenWithKey(await peerKey(ownerToken), alias, token);
224
+ }
225
+
212
226
  /** The token, or null when it can't be decrypted (the owner token was rotated since `peers sync`). */
213
- export async function openPeerToken(ownerToken: string, alias: string, sealed: string): Promise<string | null> {
227
+ export async function openPeerTokenWithKey(key: CryptoKey, alias: string, sealed: string): Promise<string | null> {
214
228
  const [v, iv, ct] = String(sealed || "").split(".");
215
229
  if (v !== "v1" || !iv || !ct) return null;
216
230
  try {
217
- const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(alias) }, await peerKey(ownerToken), unb64(ct));
231
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(alias) }, key, unb64(ct));
218
232
  return new TextDecoder().decode(pt);
219
233
  } catch { return null; }
220
234
  }
221
235
 
236
+ export async function openPeerToken(ownerToken: string, alias: string, sealed: string): Promise<string | null> {
237
+ return openPeerTokenWithKey(await peerKey(ownerToken), alias, sealed);
238
+ }
239
+
222
240
  // ------------------------------------------------------------------ A2A client (outbound, from the Worker)
223
241
  /** JSON-RPC endpoint + version from a peer card (prefers 1.0), as the CLI's pickEndpoint. */
224
242
  export function pickEndpoint(base: string, card: any): [string, string] {
@@ -0,0 +1,22 @@
1
+ // Inbox migrations bundled into the Worker. Vite inlines each `?raw` import.
2
+ // A new file in worker/migrations/ must be added here; storage tests check the two lists match.
3
+ import m0001 from "../migrations/0001_init.sql?raw";
4
+ import m0002 from "../migrations/0002_wake_budget.sql?raw";
5
+ import m0003 from "../migrations/0003_device_pairing.sql?raw";
6
+ import m0004 from "../migrations/0004_pairing_replace.sql?raw";
7
+ import m0005 from "../migrations/0005_facade_owners.sql?raw";
8
+ import m0006 from "../migrations/0006_mcp.sql?raw";
9
+ import m0007 from "../migrations/0007_cimd.sql?raw";
10
+ import m0008 from "../migrations/0008_oidc_txns.sql?raw";
11
+ import type { Migration } from "./storage.ts";
12
+
13
+ export const MIGRATIONS: Migration[] = [
14
+ { name: "0001_init.sql", sql: m0001 },
15
+ { name: "0002_wake_budget.sql", sql: m0002 },
16
+ { name: "0003_device_pairing.sql", sql: m0003 },
17
+ { name: "0004_pairing_replace.sql", sql: m0004 },
18
+ { name: "0005_facade_owners.sql", sql: m0005 },
19
+ { name: "0006_mcp.sql", sql: m0006 },
20
+ { name: "0007_cimd.sql", sql: m0007 },
21
+ { name: "0008_oidc_txns.sql", sql: m0008 },
22
+ ];
@@ -2,6 +2,10 @@
2
2
  // in Workers and in Node's test runner. The HTTP handlers live in index.ts.
3
3
  import { isPrivateHost } from "./a2a.ts";
4
4
 
5
+ /** How every hint tells an agent to run the CLI: npx, latest release, no global install (agent sandboxes block global
6
+ * installs as untrusted code). WAKE_CLI_COMMAND overrides it for the owner's own wake hints only. */
7
+ export const DEFAULT_CLI = "npx -y a2a-exposed@latest";
8
+
5
9
  export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
6
10
  export const EXPIRES_S = 600; // device / user code lifetime
7
11
  export const INTERVAL_S = 5; // minimum polling interval (slow_down adds 5 s each time)
@@ -179,6 +183,10 @@ export type PageModel = {
179
183
  askCode?: boolean; // show the code entry form
180
184
  passwordSet: boolean;
181
185
  codeValue?: string;
186
+ /** Set only when OpenID Connect approval is offered. Absent keeps the page HTML unchanged. */
187
+ oidc?: { label: string };
188
+ /** False hides the password field. Absent means the password field is shown as before. */
189
+ passwordEnabled?: boolean;
182
190
  };
183
191
 
184
192
  export function devicePage(m: PageModel, nonce: string): string {
@@ -201,13 +209,18 @@ export function devicePage(m: PageModel, nonce: string): string {
201
209
  <div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div>
202
210
  </form>`;
203
211
  // Deny needs no password (formnovalidate skips the required field): denying grants nothing
204
- const form = r && m.passwordSet ? `<form method="post" action="/device">
212
+ const passwordOn = m.passwordEnabled !== false;
213
+ const form = r && passwordOn && m.passwordSet ? `<form method="post" action="/device">
205
214
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
206
215
  <label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
207
216
  <div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div>
208
- </form>` : r && !m.passwordSet ? noPassword + denyOnly : "";
217
+ </form>` : r && passwordOn && !m.passwordSet ? noPassword + denyOnly : r ? denyOnly : "";
218
+ const oidcForm = m.oidc && r ? `<form method="post" action="/device">
219
+ <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
220
+ <div class="b"><button name="decision" value="oidc" type="submit">Approve with ${esc(m.oidc.label)}</button></div>
221
+ </form>` : "";
209
222
  const ask = m.askCode ? `<form method="get" action="/device"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="WDJB-4827" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
210
- return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${ask}`);
223
+ return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
211
224
  }
212
225
 
213
226
  const STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
@@ -243,7 +256,7 @@ export function landingPage(m: LandingModel, nonce: string): string {
243
256
  ? `<h2>Skills</h2><ul>${m.skills.slice(0, 20).map((k) => `<li><b>${esc(k.name)}</b>${k.description ? ` &ndash; ${esc(k.description)}` : ""}</li>`).join("")}</ul>${m.skills.length > 20 ? `<p class="w">&hellip;and ${m.skills.length - 20} more in the agent card.</p>` : ""}`
244
257
  : "";
245
258
  const connect = m.pairing
246
- ? `<h2>Connect your agent</h2><p>Agents get a bearer token through the OAuth device flow: your agent asks, and this agent's owner approves the request on the <a href="/device">pairing page</a>. No secrets go through chat. With the a2a-exposed CLI, your agent runs:</p><p><code>npx a2a-exposed connect ${esc(m.base)}</code></p>`
259
+ ? `<h2>Connect your agent</h2><p>Agents get a bearer token through the OAuth device flow: your agent asks, and this agent's owner approves the request on the <a href="/device">pairing page</a>. No secrets go through chat. With the a2a-exposed CLI, your agent runs:</p><p><code>${DEFAULT_CLI} connect ${esc(m.base)}</code></p>`
247
260
  : `<h2>Connect your agent</h2><p>Pairing is turned off here: ask this agent's operator for a bearer token.</p>`;
248
261
  return pageShell(m.name, nonce, `${desc}
249
262
  <p class="n info">This is an <a href="https://a2a-protocol.org/">A2A (Agent2Agent)</a> endpoint, meant for agents rather than people.${m.proxy ? "" : " Messages land in its owner's inbox, which wakes the agent."}</p>
@@ -0,0 +1,5 @@
1
+ /** Vite (`?raw`) and the test loader both turn a `.sql?raw` import into the file's text. */
2
+ declare module "*.sql?raw" {
3
+ const sql: string;
4
+ export default sql;
5
+ }
@@ -0,0 +1,100 @@
1
+ // D1-shaped database over a Durable Object's SQLite (`ctx.storage.sql`).
2
+ // Handlers keep calling prepare().bind().first()/all()/run() and batch(). Self-host keeps the real D1 binding;
3
+ // a hosted tenant uses this adapter on its own object. `migrateDo` matches `cf d1 migrations apply`: the same
4
+ // `d1_migrations` table, unrecorded files in numeric order, one transaction per file.
5
+
6
+ /** The slice of the Workers `SqlStorage` API this adapter uses. Tests can implement it over node:sqlite. */
7
+ export interface SqlStorageLike {
8
+ exec(query: string, ...bindings: unknown[]): SqlCursor;
9
+ }
10
+
11
+ export interface SqlCursor {
12
+ toArray(): Record<string, unknown>[];
13
+ raw(): Iterable<unknown[]>;
14
+ rowsRead?: number;
15
+ rowsWritten?: number;
16
+ }
17
+
18
+ export type TxRunner = <T>(fn: () => T) => T;
19
+ export type Migration = { name: string; sql: string };
20
+
21
+ const WRITE = /^\s*(?:insert|update|delete|replace)\b/i;
22
+
23
+ /** D1 rejects undefined bindings and stores booleans as 1/0. Mirror both so the backends agree. */
24
+ function norm(v: unknown, i: number): unknown {
25
+ if (v === undefined) throw new Error(`D1_TYPE_ERROR: Type 'undefined' not supported for value at index ${i}`);
26
+ if (typeof v === "boolean") return v ? 1 : 0;
27
+ return v;
28
+ }
29
+
30
+ export interface DoSql {
31
+ prepare(query: string): DoStmt;
32
+ batch(statements: DoStmt[]): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }[]>;
33
+ exec(query: string): Promise<{ count: number; duration: number }>;
34
+ }
35
+
36
+ export interface DoStmt {
37
+ __sql: string;
38
+ __args: unknown[];
39
+ bind(...values: unknown[]): DoStmt;
40
+ first<T = unknown>(colName?: string): Promise<T | null>;
41
+ all<T = unknown>(): Promise<{ results?: T[]; meta: { changes?: number; last_row_id?: number } }>;
42
+ run(): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }>;
43
+ raw<T = unknown>(options?: { columnNames?: boolean }): Promise<T[]>;
44
+ }
45
+
46
+ /** A D1-shaped database over `SqlStorage`. `batch` is one transaction; DO SQL forbids BEGIN/COMMIT, so the caller passes `transactionSync`. */
47
+ export function doSqlD1(sql: SqlStorageLike, tx: TxRunner): DoSql {
48
+ const changesOf = (q: string) => {
49
+ let changes = 0, last_row_id = 0;
50
+ if (WRITE.test(q)) {
51
+ // `rowsWritten` also counts index writes. Ask SQLite for the D1-equivalent `meta.changes`.
52
+ const m = sql.exec("SELECT changes() AS c, last_insert_rowid() AS r").toArray()[0] as { c?: unknown; r?: unknown } | undefined;
53
+ changes = Number(m?.c ?? 0);
54
+ last_row_id = Number(m?.r ?? 0);
55
+ }
56
+ return { changes, last_row_id };
57
+ };
58
+ const result = (q: string, args: unknown[]) => {
59
+ const rows = sql.exec(q, ...args.map(norm)).toArray();
60
+ return { success: true as const, results: rows, meta: changesOf(q) };
61
+ };
62
+ const stmt = (q: string, args: unknown[] = []): DoStmt => ({
63
+ __sql: q,
64
+ __args: args,
65
+ bind: (...a: unknown[]) => stmt(q, a),
66
+ first: async (col?: string) => {
67
+ const r = result(q, args).results[0];
68
+ if (!r) return null;
69
+ if (col === undefined) return r as never;
70
+ if (!(col in r)) throw new Error(`D1_COLUMN_NOTFOUND: Column not found (${col})`);
71
+ return r[col] as never;
72
+ },
73
+ // `T` is the caller's row shape. The cursor only knows records, so the cast is the D1 generic.
74
+ all: async <T>() => result(q, args) as { results: T[]; meta: { changes: number; last_row_id: number } },
75
+ run: async () => result(q, args),
76
+ raw: async <T>() => [...sql.exec(q, ...args.map(norm)).raw()] as T[],
77
+ });
78
+ return {
79
+ prepare: (q: string) => stmt(q),
80
+ batch: async (stmts) => tx(() => stmts.map((s) => result(s.__sql, s.__args))),
81
+ exec: async (q: string) => { sql.exec(q); return { count: 1, duration: 0 }; },
82
+ };
83
+ }
84
+
85
+ const num = (n: string) => parseInt(n.split("_")[0]!, 10);
86
+ const byNumber = (a: Migration, b: Migration) => num(a.name) - num(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
87
+
88
+ /**
89
+ * Same bookkeeping as `cf d1 migrations apply`: create `d1_migrations` if needed, apply every unrecorded file in
90
+ * numeric order, each in its own transaction. A lower number added later still runs when a higher one is already
91
+ * recorded. Returns the names applied (empty once the object is up to date).
92
+ * Call this only after the tenant row exists. An unconfigured object must not create tables.
93
+ */
94
+ export function migrateDo(sql: SqlStorageLike, tx: TxRunner, migrations: Migration[]): string[] {
95
+ sql.exec("CREATE TABLE IF NOT EXISTS d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)");
96
+ const done = new Set(sql.exec("SELECT name FROM d1_migrations").toArray().map((r) => String(r.name)));
97
+ const todo = [...migrations].sort(byNumber).filter((m) => !done.has(m.name));
98
+ for (const m of todo) tx(() => { sql.exec(m.sql); sql.exec("INSERT INTO d1_migrations (name) VALUES (?)", m.name); });
99
+ return todo.map((m) => m.name);
100
+ }