a2a-exposed 0.8.17 → 0.8.19

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
@@ -55,6 +55,7 @@ async function inboxRows(o) {
55
55
  return followInbox(async (cursor) => {
56
56
  const qs = [];
57
57
  if (o.context) qs.push("context=" + q(o.context));
58
+ if (o.from) qs.push("from=" + q(o.from));
58
59
  if (o.all) qs.push("all=1");
59
60
  if (cursor) qs.push("cursor=" + q(cursor));
60
61
  const path = "/owner/inbox" + (qs.length ? "?" + qs.join("&") : "");
@@ -125,7 +126,9 @@ export async function reply(id, o) {
125
126
  }
126
127
 
127
128
  export async function history(ctx, o) {
128
- const rows = await owner("GET", `/owner/history/${q(ctx)}?n=${Number(o.n || 50)}`);
129
+ const qs = new URLSearchParams({ n: String(Number(o.n || 50)) });
130
+ if (o.from) qs.set("from", o.from);
131
+ const rows = await owner("GET", `/owner/history/${q(ctx)}?${qs}`);
129
132
  if (o.json) return out(rows);
130
133
  for (const e of rows) {
131
134
  const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
package/lib/deploy.mjs CHANGED
@@ -25,7 +25,7 @@ export const CLAUDE_CODE_NOTE = [
25
25
  " Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
26
26
  ].join("\n");
27
27
 
28
- const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
28
+ const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
29
29
  // WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
30
30
  // by hand for your own Access-protected endpoint)
31
31
  export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
package/lib/main.mjs CHANGED
@@ -92,9 +92,11 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
92
92
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
93
93
  [--cli-command CMD] [--debounce S] [--max-per-hour N]
94
94
  save wake settings, upload wake secrets from env, and redeploy (one step)
95
+ openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
95
96
  --max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
96
97
  which defaults to 25 when this is unset
97
98
  wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
99
+ openbot includes a sample delivery ID and canonical signing-input description
98
100
  + sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
99
101
  local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
100
102
  are exported in this shell. match: the exported value is the one on the Worker.
@@ -116,7 +118,7 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
116
118
  creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
117
119
  The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
118
120
  The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
119
- Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
121
+ Export WAKE_WEBHOOK_KEY (hermes/openbot: WAKE_HMAC_SECRET) so tunnel create uploads it.
120
122
  The connector machine needs cloudflared installed
121
123
  (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
122
124
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
@@ -165,9 +167,11 @@ Status
165
167
  config print config (secrets masked) and its location
166
168
 
167
169
  Inbox (owner side)
168
- inbox [--context C] [--all] [--json] unhandled inbound tasks, and a reply to a message you sent until
169
- this list is read (peer text is UNTRUSTED). A reply line says to
170
- read it with history and continue with send. Then pending pairing
170
+ inbox [--context C] [--from PEER] [--all] [--json]
171
+ unhandled inbound tasks, and a reply to a message you sent until
172
+ this list is read (peer text is UNTRUSTED). --from is one peer
173
+ (the wake hint includes it). A reply line says to read it with
174
+ history and continue with send. Then pending pairing
171
175
  requests (tell your human; the dashboard URL when MANAGE_URL is set;
172
176
  --json: rows on stdout, requests on stderr). Follows every page; past
173
177
  10000 pages it errors instead of a short list
@@ -175,7 +179,8 @@ Inbox (owner side)
175
179
  working <taskId> mark task working
176
180
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
177
181
  [--artifact] [--artifact-name N] [--force]
178
- history <contextId> [-n 50] [--json] conversation log
182
+ history <contextId> [--from PEER] [-n 50] [--json]
183
+ conversation log. --from is one peer (the wake hint includes it)
179
184
  contexts recent conversations
180
185
  export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
181
186
  encrypted peer tokens are in the dump; the owner token stays
@@ -264,7 +269,7 @@ Feedback
264
269
  polling), and hosted or self-hosted. --connection overrides
265
270
  that detection. Remove tokens from the message and from --logs.
266
271
 
267
- Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
272
+ Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
268
273
  Which clients are tested end to end is in the client table:
269
274
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
270
275
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
@@ -312,9 +317,9 @@ const deployOpts = {
312
317
  };
313
318
  const SPEC = {
314
319
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
315
- inbox: { context: S, all: B, json: B },
320
+ inbox: { context: S, from: S, all: B, json: B },
316
321
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
317
- history: { n: { type: "string", short: "n", default: "50" }, json: B },
322
+ history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
318
323
  token: { rotate: B },
319
324
  peers: { "token-env": S, "token-stdin": B },
320
325
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
package/lib/status.mjs CHANGED
@@ -171,8 +171,8 @@ function nextStepCore(s) {
171
171
  if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
172
172
  if (t && !t.tokenFileOk)
173
173
  return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
174
- const agentSecret = s.wake.preset === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
175
- const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
174
+ const agentSecret = ["hermes", "openbot"].includes(s.wake.preset) ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
175
+ const needsAgentSecret = s.wake.preset === "openbot" ? !s.wake.hasHmacSecret : LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
176
176
  const now = typeof s.now === "number" ? s.now : Date.now();
177
177
  if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
178
178
  if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
@@ -184,7 +184,7 @@ function nextStepCore(s) {
184
184
  if (t && t.connections === 0)
185
185
  return fail(`the tunnel has ${t.state === "inactive" ? "never connected" : "no running connector"}: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
186
186
  if (s.wake.configured && needsAgentSecret)
187
- return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset === "hermes" ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
187
+ return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${["hermes", "openbot"].includes(s.wake.preset) ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
188
188
  // not broken, but peers can't pair until the human sets the approval password
189
189
  const also = [];
190
190
  if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
@@ -195,7 +195,7 @@ function nextStepCore(s) {
195
195
  if (!s.wake.configured) {
196
196
  const poll = "the agent must check the A2A endpoint on a schedule (Hermes and OpenClaw have copy-paste commands in the setup references)";
197
197
  if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
198
- return done2(`no wake webhook: export WAKE_WEBHOOK_URL and WAKE_WEBHOOK_KEY, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
198
+ return done2(`no wake webhook: export WAKE_WEBHOOK_URL and ${agentSecret}, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
199
199
  const z = s.zones;
200
200
  const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
201
201
  : !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@ const plain: Record<string, string> = {
28
28
  PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
29
29
  PROVIDER_URL: v("A2A_PROVIDER_URL"),
30
30
  DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
31
- WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
31
+ WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
32
32
  WAKE_AGENT_ID: v("WAKE_AGENT_ID"), // openclaw presets (default "main")
33
33
  WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
34
34
  WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
@@ -0,0 +1,13 @@
1
+ -- One row per peer. The same context id can be a separate conversation for each peer.
2
+ -- 0011's single-column primary key made a second peer's insert fail, which is how a caller
3
+ -- could tell a fresh id from one another peer already held.
4
+ CREATE TABLE conversation_owners_peer (
5
+ context_id TEXT NOT NULL,
6
+ peer TEXT NOT NULL,
7
+ created_at TEXT NOT NULL,
8
+ PRIMARY KEY (context_id, peer)
9
+ );
10
+ INSERT INTO conversation_owners_peer (context_id, peer, created_at)
11
+ SELECT context_id, peer, created_at FROM conversation_owners;
12
+ DROP TABLE conversation_owners;
13
+ ALTER TABLE conversation_owners_peer RENAME TO conversation_owners;
@@ -1,4 +1,4 @@
1
- // Write worker/src/version.ts from cli/package.json. Publish runs `npm version` in cli/
1
+ // Write worker/src/version.ts from cli/package.json. Release preparation runs `npm run version:set`
2
2
  // before the Worker tests and build, and prepack bundles this tree into the npm package.
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
@@ -67,17 +67,12 @@ function histStmt(ctx: TenantContext, contextId: string, e: Json) {
67
67
  e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
68
68
  }
69
69
 
70
- /** Claim an unused context for this peer. `conversation_owners.context_id` is unique, and the insert does nothing
71
- * when another peer already holds it or already has a task, history row, or outbound task on that id. */
70
+ /** Claim this peer's conversation. The primary key is (context_id, peer): another peer's row is a
71
+ * different conversation and is not read or replaced. The insert does nothing when this peer already holds the id. */
72
72
  function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
73
73
  return ctx.DB.prepare(
74
- `INSERT INTO conversation_owners (context_id, peer, created_at)
75
- SELECT ?1, ?2, ?3
76
- WHERE NOT EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer != ?2)
77
- AND NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
78
- AND NOT EXISTS (SELECT 1 FROM history WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
79
- AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
80
- ON CONFLICT(context_id) DO NOTHING`,
74
+ `INSERT INTO conversation_owners (context_id, peer, created_at) VALUES (?1, ?2, ?3)
75
+ ON CONFLICT(context_id, peer) DO NOTHING`,
81
76
  ).bind(contextId, peer, ts);
82
77
  }
83
78
 
@@ -86,9 +81,9 @@ function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
86
81
  return ctx.DB.prepare(
87
82
  `INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
88
83
  SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
89
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3
84
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
90
85
  ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
91
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?2) = ?3`,
86
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
92
87
  ).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
93
88
  JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
94
89
  }
@@ -97,7 +92,7 @@ function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
97
92
  return ctx.DB.prepare(
98
93
  `INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
99
94
  SELECT ?1, ?2, ?3, ?4, ?5, ?6
100
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?3) = ?7`,
95
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
101
96
  ).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
102
97
  }
103
98
 
@@ -105,7 +100,7 @@ function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner:
105
100
  return ctx.DB.prepare(
106
101
  `INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
107
102
  SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
108
- WHERE (SELECT peer FROM conversation_owners WHERE context_id = ?1) = ?11`,
103
+ WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
109
104
  ).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
110
105
  e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
111
106
  }
@@ -635,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
635
630
  const V1: Record<string, string> = {
636
631
  SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
637
632
  SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
633
+ ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
634
+ DeleteTaskPushNotificationConfig: "pushdel",
638
635
  SendStreamingMessage: "stream", SubscribeToTask: "stream",
639
636
  };
640
637
  const V03: Record<string, string> = {
641
638
  "message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
642
- "tasks/pushNotificationConfig/get": "pushget", "message/stream": "stream", "tasks/resubscribe": "stream",
639
+ "tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
640
+ "tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
643
641
  };
644
- // Methods only the façade forwards (the inbox has one push config per task and no delete).
645
- const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
646
- const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
647
642
  const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
648
643
 
649
644
  /** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
@@ -659,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
659
654
  return { raw, version: null };
660
655
  }
661
656
 
662
- function rpcOperation(method: string, proxy: boolean): { op: string; version: string } | null {
657
+ function rpcOperation(method: string): { op: string; version: string } | null {
663
658
  if (V1[method]) return { op: V1[method], version: "1.0" };
664
659
  if (V03[method]) return { op: V03[method], version: "0.3" };
665
- if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
666
- if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
667
660
  return null;
668
661
  }
669
662
 
@@ -710,10 +703,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
710
703
  }
711
704
  const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
712
705
  const ts = A.nowIso();
713
- // A client-provided id nobody owns is still this peer's new conversation (single-peer and outbound send
714
- // keep passing one). An id another peer already has is not. A2A forbids minting a replacement id when the
715
- // client id is refused, so this is the same error the façade returns for an unknown context, not a new id.
716
- // The claim and the first task are one batch: a second peer cannot pass a read and then write its own task.
706
+ // A2A lets an agent accept a client-supplied contextId and forbids minting a replacement when it will not.
707
+ // This inbox accepts the id for the caller. A peer that already has it continues that conversation. An id
708
+ // this agent has never seen, and an id another peer already has, are the same for the caller: a new
709
+ // conversation of their own. The other peer's tasks, history, and wake are not read or written. The claim
710
+ // and the first task are one batch, so the task is stored only when this peer's owner row is present.
717
711
  const creating = !task;
718
712
  if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
719
713
  msg.contextId = contextId; msg.taskId = task.id;
@@ -728,11 +722,9 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
728
722
  saveTaskIfOwnerStmt(ctx, task),
729
723
  msgIfOwnerStmt(ctx, task, msg),
730
724
  histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
731
- ctx.DB.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").bind(contextId),
732
725
  ]);
733
- const owner = (stored[4]?.results?.[0] as Json | undefined)?.peer;
734
726
  const wrote = stored[1]?.meta?.changes ?? 0;
735
- if (owner !== label || wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
727
+ if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
736
728
  } else {
737
729
  await ctx.DB.batch([
738
730
  saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
@@ -774,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
774
766
  throw new RpcError(-32001, "Push notification config not found");
775
767
  };
776
768
 
769
+ /** Config id for list/delete. 1.0 sends `{ taskId, id }`. 0.3 sends `{ id: taskId, pushNotificationConfigId }`. */
770
+ function pushConfigId(p: Json): string | undefined {
771
+ const id = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
772
+ return typeof id === "string" && id ? id : undefined;
773
+ }
774
+
775
+ /** Cursor over this task's configs. The token is an index, not a config id, url, or token. */
776
+ function pushListStart(raw: unknown): number {
777
+ if (raw === undefined || raw === null || raw === "") return 0;
778
+ if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
779
+ let text = "";
780
+ try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
781
+ const m = /^cfg\n(\d{1,6})$/.exec(text);
782
+ if (!m) throw new RpcError(-32602, "Invalid params: pageToken");
783
+ return Number(m[1]);
784
+ }
785
+
786
+ const rpcPushList: Rpc = async (p, label, version, ctx) => {
787
+ const t = await ownedTask(ctx, p.taskId || p.id, label);
788
+ const all = (t._push || []).map((c: Json) => pubCfg(t.id, c, version));
789
+ if (!version.startsWith("1")) return all;
790
+ const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
791
+ const start = pushListStart(p.pageToken);
792
+ const configs = all.slice(start, start + size);
793
+ const next = start + configs.length;
794
+ return { configs, nextPageToken: next < all.length ? b64url(`cfg\n${next}`) : "" };
795
+ };
796
+
797
+ const rpcPushDel: Rpc = async (p, label, version, ctx) => {
798
+ const t = await ownedTask(ctx, p.taskId || p.id, label);
799
+ const id = pushConfigId(p);
800
+ if (!id) throw new RpcError(-32602, "Invalid params: push notification config id");
801
+ const kept = (t._push || []).filter((c: Json) => c.id !== id);
802
+ if (kept.length !== (t._push || []).length) {
803
+ t._push = kept;
804
+ await saveTaskStmt(ctx, t).run();
805
+ }
806
+ // Idempotent: a missing config on this peer's task is the same as a config already removed.
807
+ return version.startsWith("1") ? {} : null;
808
+ };
809
+
810
+ /** Keyset cursor for ListTasks, newest update first. Empty means the first page. A bad token is rejected
811
+ * without echoing it. The query still filters by the caller, so the cursor cannot select another peer's tasks. */
812
+ function listCursor(raw: unknown): { updatedAt: string; id: string } | null {
813
+ if (raw === undefined || raw === null || raw === "") return null;
814
+ if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
815
+ let text = "";
816
+ try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
817
+ const nl = text.indexOf("\n");
818
+ if (nl <= 0) throw new RpcError(-32602, "Invalid params: pageToken");
819
+ const updatedAt = text.slice(0, nl);
820
+ const id = text.slice(nl + 1);
821
+ if (!INBOX_STAMP.test(updatedAt) || !A.ID_RE.test(id) || id.includes("\n")) throw new RpcError(-32602, "Invalid params: pageToken");
822
+ return { updatedAt, id };
823
+ }
824
+
777
825
  const rpcList: Rpc = async (p, label, version, ctx) => {
778
826
  const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
779
- const where = p.contextId ? "peer = ? AND context_id = ?" : "peer = ?";
780
- const args = p.contextId ? [label, p.contextId] : [label];
781
- const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${where}`).bind(...args).first();
782
- const rows = await ctx.DB.prepare(`SELECT id FROM tasks WHERE ${where} ORDER BY updated_at DESC LIMIT ?`).bind(...args, size).all();
827
+ const cursor = listCursor(p.pageToken);
828
+ const where = ["peer = ?"];
829
+ const args: Json[] = [label];
830
+ if (p.contextId !== undefined && p.contextId !== "") {
831
+ if (typeof p.contextId !== "string" || !A.ID_RE.test(p.contextId)) throw new RpcError(-32602, "Invalid params: invalid contextId");
832
+ where.push("context_id = ?");
833
+ args.push(p.contextId);
834
+ }
835
+ const whereSql = where.join(" AND ");
836
+ const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${whereSql}`).bind(...args).first();
837
+ const pageWhere = [...where];
838
+ const pageArgs = [...args];
839
+ if (cursor) {
840
+ pageWhere.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
841
+ pageArgs.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
842
+ }
843
+ const rows = await ctx.DB.prepare(
844
+ `SELECT id, updated_at FROM tasks WHERE ${pageWhere.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
845
+ ).bind(...pageArgs, size + 1).all();
846
+ const found = (rows.results || []) as Json[];
847
+ const page = found.slice(0, size);
783
848
  const tasks = [];
784
- for (const r of rows.results as Json[]) {
849
+ for (const r of page) {
785
850
  const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
786
851
  if (!p.includeArtifacts) delete t.artifacts;
787
852
  tasks.push(t);
788
853
  }
789
- return { tasks, nextPageToken: "", pageSize: size, totalSize: total?.n ?? tasks.length };
854
+ let nextPageToken = "";
855
+ if (found.length > size && page.length) {
856
+ const updatedAt = String(page[page.length - 1].updated_at);
857
+ const id = String(page[page.length - 1].id);
858
+ if (INBOX_STAMP.test(updatedAt) && A.ID_RE.test(id)) nextPageToken = b64url(`${updatedAt}\n${id}`);
859
+ }
860
+ return { tasks, nextPageToken, pageSize: size, totalSize: total?.n ?? tasks.length };
790
861
  };
791
862
 
792
- const HANDLERS: Record<string, Rpc> = { send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet, list: rpcList };
863
+ const HANDLERS: Record<string, Rpc> = {
864
+ send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
865
+ pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
866
+ };
793
867
 
794
868
  /** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
795
869
  * The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
@@ -836,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
836
910
  const declared = declaredA2AVersion(req);
837
911
  if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
838
912
  if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
839
- const found = rpcOperation(method, proxy);
913
+ const found = rpcOperation(method);
840
914
  if (!found) return err(-32601, "Method not found");
841
915
  const version = declared.version || found.version;
842
916
  const op = found.op;
@@ -1088,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
1088
1162
 
1089
1163
  // ------------------------------------------------------------------ owner API (local CLI)
1090
1164
  const INBOX_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
1165
+ /** Inbound labels and outbound aliases. Used to scope a conversation read to one peer. */
1166
+ const PEER_FILTER = /^[A-Za-z0-9_.-]{1,64}$/;
1091
1167
 
1092
1168
  function inboxLimit(raw: string | null): number {
1093
1169
  if (raw === null) return M.INBOX_MAX;
@@ -1274,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1274
1350
  }
1275
1351
  if (seg[0] === "inbox" && m === "GET") {
1276
1352
  const contextFilter = url.searchParams.get("context");
1353
+ const fromFilter = url.searchParams.get("from");
1354
+ if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) throw new HttpError(400, "from is invalid");
1277
1355
  const all = url.searchParams.get("all") === "1";
1278
1356
  const limit = inboxLimit(url.searchParams.get("limit"));
1279
1357
  const cursor = inboxCursor(url.searchParams.get("cursor"));
@@ -1281,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1281
1359
  const args: Json[] = [];
1282
1360
  if (!all) q += " AND state IN ('submitted','working')";
1283
1361
  if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
1362
+ if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
1284
1363
  if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
1285
1364
  q += " ORDER BY updated_at, id LIMIT ?";
1286
1365
  args.push(limit + 1);
@@ -1302,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1302
1381
  const oargs: Json[] = [];
1303
1382
  if (!all) oq += " AND updates_seen = 0";
1304
1383
  if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
1384
+ if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
1305
1385
  oq += " ORDER BY sent_at LIMIT ?";
1306
1386
  oargs.push(limit);
1307
1387
  const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
@@ -1369,9 +1449,22 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1369
1449
  }
1370
1450
  if (seg[0] === "history" && seg[1] && m === "GET") {
1371
1451
  const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
1372
- // Rows for this conversation: this context, and a peer that owns it (a task or an outbound task).
1373
- // A null peer is a local note. When nothing owns the id yet, the rows that name it are the conversation.
1374
- const rows = await ctx.DB.prepare(`SELECT * FROM (
1452
+ const fromFilter = url.searchParams.get("from");
1453
+ if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
1454
+ // Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
1455
+ // With `from`, one peer's rows only, so another peer's conversation is not in this read.
1456
+ const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
1457
+ SELECT * FROM history WHERE context_id = ? AND (
1458
+ peer = ?
1459
+ OR (
1460
+ (peer IS NULL OR peer = '')
1461
+ AND (
1462
+ task_id IN (SELECT id FROM tasks WHERE context_id = ? AND peer = ?)
1463
+ OR task_id IN (SELECT task_id FROM outbound WHERE context_id = ? AND peer = ?)
1464
+ )
1465
+ )
1466
+ ) ORDER BY seq DESC LIMIT ?
1467
+ ) ORDER BY seq` : `SELECT * FROM (
1375
1468
  SELECT * FROM history WHERE context_id = ? AND (
1376
1469
  peer IS NULL OR peer = ''
1377
1470
  OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
@@ -1381,7 +1474,9 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1381
1474
  AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
1382
1475
  )
1383
1476
  ) ORDER BY seq DESC LIMIT ?
1384
- ) ORDER BY seq`).bind(seg[1], seg[1], seg[1], seg[1], seg[1], n).all();
1477
+ ) ORDER BY seq`).bind(...(fromFilter
1478
+ ? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
1479
+ : [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
1385
1480
  return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
1386
1481
  }
1387
1482
  if (seg[0] === "history" && !seg[1] && m === "POST") {
@@ -1524,6 +1619,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1524
1619
  fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
1525
1620
  hmacSecret: await A.fingerprint(ctx.WAKE_HMAC_SECRET),
1526
1621
  accessClientId: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_SECRET) },
1622
+ ...(preset(ctx) === "openbot" ? { signingInput: "<timestamp>.<delivery-id>.<exact-body-bytes>" } : {}),
1527
1623
  request: req ? redact(req) : null });
1528
1624
  }
1529
1625
  if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
@@ -2823,6 +2919,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2823
2919
  case "inbox": {
2824
2920
  const qs = new URLSearchParams();
2825
2921
  if (a.contextId !== undefined) qs.set("context", A.checkId(argStr(a, "contextId"), "contextId"));
2922
+ if (a.from !== undefined) {
2923
+ const from = argStr(a, "from");
2924
+ if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
2925
+ qs.set("from", from);
2926
+ }
2826
2927
  if (a.all === true) qs.set("all", "1");
2827
2928
  if (a.cursor !== undefined) {
2828
2929
  const cursor = argStr(a, "cursor");
@@ -2850,7 +2951,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2850
2951
  case "history": {
2851
2952
  const n = a.n === undefined ? 50 : Number(a.n);
2852
2953
  if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
2853
- return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`)));
2954
+ let histPath = `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`;
2955
+ if (a.from !== undefined) {
2956
+ const from = argStr(a, "from");
2957
+ if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
2958
+ histPath += `&from=${enc(from)}`;
2959
+ }
2960
+ return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", histPath)));
2854
2961
  }
2855
2962
  case "mark_working": {
2856
2963
  const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/working`));
package/worker/src/mcp.ts CHANGED
@@ -323,6 +323,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
323
323
  description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
324
324
  inputSchema: { type: "object", properties: {
325
325
  contextId: str("Only this conversation"),
326
+ from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
326
327
  all: { type: "boolean", description: "Include finished tasks on this page" },
327
328
  cursor: str("Cursor from the previous inbox page, when more tasks are waiting"),
328
329
  limit: { type: "integer", minimum: 1, maximum: INBOX_MAX, description: `Tasks in this page (default ${INBOX_PAGE}, at most ${INBOX_MAX})` },
@@ -332,7 +333,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
332
333
  inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
333
334
  { name: "history", title: "Conversation history", annotations: ro,
334
335
  description: `The log of one conversation (both directions), oldest first. ${RULES}`,
335
- inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
336
+ inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), from: str("Only this peer (the wake's from)"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
336
337
  { name: "mark_working", title: "Mark task working", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
337
338
  description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
338
339
  inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
@@ -11,6 +11,7 @@ import m0008 from "../migrations/0008_oidc_txns.sql?raw";
11
11
  import m0009 from "../migrations/0009_outbound_seen.sql?raw";
12
12
  import m0010 from "../migrations/0010_conversation_peer.sql?raw";
13
13
  import m0011 from "../migrations/0011_conversation_owner.sql?raw";
14
+ import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
14
15
  import type { Migration } from "./storage.ts";
15
16
 
16
17
  export const MIGRATIONS: Migration[] = [
@@ -25,4 +26,5 @@ export const MIGRATIONS: Migration[] = [
25
26
  { name: "0009_outbound_seen.sql", sql: m0009 },
26
27
  { name: "0010_conversation_peer.sql", sql: m0010 },
27
28
  { name: "0011_conversation_owner.sql", sql: m0011 },
29
+ { name: "0012_conversation_peer_owner.sql", sql: m0012 },
28
30
  ];
@@ -105,25 +105,31 @@ function noQuery(q: Record<string, string>): string | null {
105
105
  }
106
106
 
107
107
  function inboxQuery(q: Record<string, string>): string | null {
108
- for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor") return null;
108
+ for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor" && k !== "from") return null;
109
109
  if (q.context !== undefined && !idOk(q.context)) return null;
110
110
  if (q.all !== undefined && q.all !== "1") return null;
111
111
  if (q.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(q.limit) || Number(q.limit) > 200)) return null;
112
112
  if (q.cursor !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(q.cursor)) return null;
113
+ if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
113
114
  const sp = new URLSearchParams();
114
115
  if (q.context) sp.set("context", q.context);
115
116
  if (q.all) sp.set("all", "1");
116
117
  if (q.limit) sp.set("limit", q.limit);
117
118
  if (q.cursor) sp.set("cursor", q.cursor);
119
+ if (q.from) sp.set("from", q.from);
118
120
  const s = sp.toString();
119
121
  return s ? `?${s}` : "";
120
122
  }
121
123
 
122
124
  function historyQuery(q: Record<string, string>): string | null {
123
- for (const k of Object.keys(q)) if (k !== "n") return null;
124
- if (q.n === undefined) return "";
125
- if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
126
- return `?n=${q.n}`;
125
+ for (const k of Object.keys(q)) if (k !== "n" && k !== "from") return null;
126
+ if (q.n !== undefined && (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000)) return null;
127
+ if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
128
+ const sp = new URLSearchParams();
129
+ if (q.n) sp.set("n", q.n);
130
+ if (q.from) sp.set("from", q.from);
131
+ const s = sp.toString();
132
+ return s ? `?${s}` : "";
127
133
  }
128
134
 
129
135
  /** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.17";
2
+ export const VERSION = "0.8.19";