a2a-exposed 0.8.35 → 0.8.36

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/hosted.mjs CHANGED
@@ -194,6 +194,8 @@ export async function status(o = {}) {
194
194
 
195
195
  export async function wake(sub, o = {}) {
196
196
  if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
197
+ if (sub === "set" && C.deploymentKind() === "hosted" && !o.agent)
198
+ fail("hosted_config", "This is a hosted config. Use dashboard Wake settings or hosted MCP set_wake; the human enters wake secrets on the confirmation page. For scoped CLI setup, run hosted capabilities --json, then hosted list --json to select an explicit --agent NAME before wake set --agent NAME --url URL --preset P --json. No wake settings were changed.");
197
199
  const removing = sub === "remove" || sub === "unset";
198
200
  const preparingRemoval = sub === "prepare" && o.clear === true;
199
201
  if (o.clear && !preparingRemoval) fail("usage", "Use wake prepare --clear for an MCP removal reference, or wake remove --version N. Run wake preview --json to list the current version.");
@@ -266,7 +268,16 @@ export async function wake(sub, o = {}) {
266
268
  // Do not print proofs, arbitrary service text, webhook URLs or unexpected fields.
267
269
  const code = typeof result.code === "string" && /^(?:[1-5][0-9]{2}|access-40[13]|none|timeout|tls|private|dns|dns-missing|transport-delivery|transport-native|transport-socket|cloudflare-target-unsupported|connection|failed|unreadable|signing)$/.test(result.code) ? result.code : result.saved === true ? "saved" : "failed";
268
270
  const ok = result.ok === true && (sub !== "set" || result.saved === true);
269
- return { ok, code, saved: result.saved === true, next: ok ? sub === "set" ? "Check the receiver for the test wake." : "The draft passed. Run wake set with the same inputs to test and save." : code === "cloudflare-target-unsupported" ? "Use polling or a self-hosted Worker for this Cloudflare-proxied webhook." : "Saved settings were not changed. Check the receiver, credentials and draft, then test again." };
271
+ const recovery = {
272
+ "cloudflare-target-unsupported": "This hosted service cannot deliver to this Cloudflare-proxied webhook. Choose a supported public HTTPS route, polling, or an explicitly authorized self-hosted Worker; do not create another tunnel or weaken Access or hook authentication.",
273
+ "transport-socket": "The hosted service could not connect using its public-HTTPS socket transport. Check hosted route compatibility before changing the receiver. An owner-created Cloudflare Tunnel can pass local checks and still be unreachable by this transport; do not weaken Access or hook authentication.",
274
+ "dns": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
275
+ "dns-missing": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
276
+ "tls": "The hosted service could not establish TLS to the webhook. Check the hostname and certificate; keep HTTPS verification enabled.",
277
+ "access-401": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
278
+ "access-403": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
279
+ };
280
+ return { ok, code, saved: result.saved === true, next: ok ? sub === "set" ? "Check the receiver for the test wake." : "The draft passed. Run wake set with the same inputs to test and save." : `Saved settings were not changed. ${recovery[code] || "Check the receiver, credentials and draft, then test again."}` };
270
281
  }
271
282
 
272
283
  export async function output(run, o = {}) {
package/lib/main.mjs CHANGED
@@ -131,7 +131,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
131
131
  deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
132
132
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
133
133
  [--cli-command CMD] [--debounce S] [--max-per-hour N]
134
- save wake settings, upload wake secrets from env, and redeploy (one step)
134
+ self-hosted: save wake settings, upload wake secrets from env, and redeploy
135
+ hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
136
+ hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
135
137
  openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
136
138
  the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
137
139
  --max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
@@ -191,8 +193,11 @@ Status
191
193
  read selected hosted agent wake configuration; select NAME with hosted list --json.
192
194
  Uses setup approval or that agent's matching owner credential; no delivery test.
193
195
  Hosted-only configs report deployment: hosted and hosted recovery guidance.
196
+ Hosted status lists capability/preview next steps; saved wake settings do not prove delivery.
197
+ Offer webhook or polling after checking hook support and hosted route compatibility.
198
+ A listed OpenClaw preset does not prove route reachability; check delivery preflight before tunnel setup.
194
199
  status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
195
- mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
200
+ mode (webhook / tunnel / none = no webhook), tunnel state, proxy mode (--upstream):
196
201
  upstream card, Access credential and upstream bearer configured (separately), the
197
202
  live upstream check, the last failed peer call, card-leak check; warnings for peer
198
203
  token variables shadowing saved tokens; and the next step to run.
@@ -233,7 +238,9 @@ Inbox (owner side)
233
238
  requests (tell your human; the dashboard URL when MANAGE_URL is set;
234
239
  --json: rows on stdout, requests on stderr). Follows every page; past
235
240
  10000 pages it errors instead of a short list. Reading marks returned replies
236
- seen and clears matching pending wakes; task state is unchanged
241
+ seen and clears matching pending wakes; task state is unchanged.
242
+ Direct reply messages are completed without a new-work wake;
243
+ read them with history or inbox --all
237
244
  show <taskId> full task JSON
238
245
  working <taskId> mark task working
239
246
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
package/lib/status.mjs CHANGED
@@ -191,9 +191,12 @@ export async function checkCard(base) {
191
191
 
192
192
  /** One line describing how wakes reach the agent. */
193
193
  export function wakeLine(s) {
194
- if (!s.wake) return "unknown (owner API not reachable)";
194
+ if (!s.wake) return s.deployment === "hosted" && s.ownerApi?.ok
195
+ ? "unknown (hosted wake preview unavailable)" : "unknown (owner API not reachable)";
195
196
  const w = s.wake;
196
- if (!w.configured) return "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
197
+ if (!w.configured) return s.deployment === "hosted"
198
+ ? "no webhook configured; choose delivery after checking hosted route compatibility"
199
+ : "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
197
200
  const auth = [w.hasKey && "bearer/API key", w.hasHmacSecret && "HMAC signature", w.hasAccessServiceToken && "Access service token"].filter(Boolean);
198
201
  return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
199
202
  }
@@ -294,10 +297,13 @@ function nextStepCore(s) {
294
297
  if (s.card.urlProblem) return fail(`the hosted agent card has a different endpoint: ${s.card.urlProblem}. Check the address with the hosted service.`);
295
298
  if (!s.health?.ok) return fail("the hosted health check failed. Check the hosted service and run status again.");
296
299
  if (!s.ownerApi.ok) return fail("the hosted owner API refused or could not check the saved credential. Check the hosted agent access with its owner.");
297
- if (!s.wake) return done("the hosted endpoint is reachable and its owner credential is accepted. Wake preview is unavailable; check wake delivery through the hosted service.");
298
- return done(s.wake?.configured
299
- ? "none: the hosted endpoint is reachable and its owner credential is accepted. Check wake delivery through the hosted service."
300
- : "the hosted endpoint is reachable. Configure a public HTTPS webhook through the hosted service, or use polling. Cloudflare Tunnel delivery may be unavailable; do not provision a self-hosted tunnel for this address.");
300
+ const discover = `Run \`${CLI} hosted capabilities --json\` to list supported setup scopes and wake presets.`;
301
+ const fallback = "If unavailable, use hosted dashboard Wake settings or MCP set_wake.";
302
+ if (!s.wake) return done(`the hosted endpoint is reachable and its owner credential is accepted. Wake preview is unavailable; delivery is unverified. ${discover}`, [fallback]);
303
+ if (s.wake.configured) return done(`the hosted endpoint is reachable and wake settings are saved; status does not test delivery. ${discover}`, [fallback,
304
+ `When supported, read \`${CLI} wake preview --json\` for the saved agent before an authorized draft test, then verify an inbox-and-reply run.`]);
305
+ return done(`the hosted endpoint is reachable; no webhook is configured. ${discover}`, [fallback,
306
+ "Check hook support and hosted route compatibility, then offer webhook or polling with its interval and cost before configuring either. Do not provision a self-hosted tunnel for this address."]);
301
307
  }
302
308
  if (!s.deployed) return fail(`nothing is deployed from ${path.dirname(s.configFile)}. Run \`${CLI} init\` (see the quick start). Another deployment? Set A2A_CONFIG_DIR.`);
303
309
  if (s.baseUrlEnv)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
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": {
@@ -698,6 +698,7 @@ type Rpc = (p: Json, label: string, version: string, ctx: TenantContext, ectx: E
698
698
  const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
699
699
  const msg = A.normMessage(params.message);
700
700
  if (msg.role !== "user") throw new RpcError(-32602, "message.role must be user");
701
+ const directReply = directReplyParams(params);
701
702
  const conf = params.configuration || {};
702
703
  const push = conf.taskPushNotificationConfig || conf.pushNotificationConfig;
703
704
  const pushCfg = push ? normPushCfg(push, version) : null;
@@ -718,9 +719,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
718
719
  if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
719
720
  msg.contextId = contextId; msg.taskId = task.id;
720
721
  task.history.push(msg);
721
- task.status = { state: "submitted", timestamp: ts };
722
+ task.status = { state: directReply ? "completed" : "submitted", timestamp: ts };
722
723
  if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
723
724
  if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
725
+ // Keep the loop marker on the task even when it was supplied only on the message.
726
+ if (directReply) task.metadata = { ...(task.metadata || {}), [DIRECT_REPLY_META]: params.metadata?.[DIRECT_REPLY_META] || msg.metadata?.[DIRECT_REPLY_META] };
724
727
  const preview = A.textOf(msg);
725
728
  if (creating) {
726
729
  const stored = await ctx.DB.batch([
@@ -738,7 +741,8 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
738
741
  ]);
739
742
  }
740
743
  log("message_received", { peer: label, contextId, taskId: task.id, chars: preview.length, version });
741
- await wake(ctx, ectx, contextId, task.id, label, preview);
744
+ // Direct replies are stored for history, not scheduled as new work.
745
+ if (!directReply) await wake(ctx, ectx, contextId, task.id, label, preview);
742
746
  const pt = A.publicTask(task, version, conf.historyLength);
743
747
  return version.startsWith("1") ? { task: pt } : pt;
744
748
  };
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.35";
2
+ export const VERSION = "0.8.36";
@@ -55,8 +55,10 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
55
55
  byId.set("id-alice", a); byId.set("id-bob", b);
56
56
  store.set("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
57
57
  store.set("tenant:bob", JSON.stringify({ id: "id-bob", status: "active", region: "default", version: 1 }));
58
- const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
59
- runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
58
+ const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) => {
59
+ const url = new URL(path, ctx.PUBLIC_URL);
60
+ return runOwnerRpc(ctx, ectx as never, { accountId, method, path: url.pathname, query: Object.fromEntries(url.searchParams), ...(body === undefined ? {} : { body }) });
61
+ };
60
62
  const issue = async (ctx: TenantContext, ownerToken: string, label: string) => {
61
63
  const r = await dispatch(new Request(ctx.PUBLIC_URL + "/owner/peers", { method: "POST", headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, body: JSON.stringify({ label }) }), ctx, ectx as never);
62
64
  return (await r.json() as { token: string }).token;
@@ -71,7 +73,7 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
71
73
  assert.equal(r.status, 200, JSON.stringify(data));
72
74
  return data.result.id as string;
73
75
  };
74
- return { a, b, dbA, dbB, owner, sendToBob, cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
76
+ return { a, b, dbA, dbB, owner, sendToBob, bobAtAlice, otherAtAlice: () => issue(a, ownerA, "other"), cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
75
77
  let release!: () => void;
76
78
  hold = new Promise<void>((resolve) => { release = resolve; });
77
79
  const started = new Promise<void>((resolve) => { posted = resolve; });
@@ -91,15 +93,20 @@ test("reply delivers once to an exact paired peer in the same context and a dire
91
93
  assert.equal(JSON.stringify(first.body).includes("a2aow_"), false);
92
94
  assert.equal(s.directPosts, 1);
93
95
 
94
- const inbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox");
96
+ const inbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox?all=1");
95
97
  const delivered = (inbox.body as any[]).find((row) => row.text === "answer");
96
98
  assert.equal(delivered.contextId, "ctx-direct");
99
+ assert.equal(delivered.state, "completed");
100
+ assert.deepEqual((await s.dbA.prepare("SELECT * FROM wakes").all()).results, []);
101
+ const openInbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox");
102
+ assert.deepEqual(openInbox.body, []);
97
103
  const stored = await s.dbA.prepare("SELECT metadata_json FROM tasks WHERE id = ?").bind(delivered.taskId).first() as { metadata_json?: string };
98
104
  const metadata = JSON.parse(stored.metadata_json || "{}");
99
105
  assert.deepEqual(metadata["a2a.exposed/reply-delivery"].taskId, taskId);
100
106
 
101
107
  const noLoop = await s.owner(s.a, "acct-alice", "POST", "/owner/mcp/reply", { taskId: delivered.taskId, text: "ack", state: "completed" });
102
- assert.equal((noLoop.body as any).structuredContent.status, "stored-only");
108
+ assert.equal((noLoop.body as any).isError, true);
109
+ assert.match(String((noLoop.body as any).text), /already completed/);
103
110
  assert.equal(s.directPosts, 1, "a directly delivered reply is never delivered back");
104
111
 
105
112
  const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "answer", state: "completed" });
@@ -219,3 +226,36 @@ test("reply discovers one canonical card and uses the paired base when it is mis
219
226
  assert.deepEqual(s.cards, ["/.well-known/agent-card.json"]);
220
227
  assert.equal(s.directPosts, 1);
221
228
  });
229
+
230
+ for (const version of ["0.3", "1.0"]) {
231
+ for (const placement of ["request", "message"]) {
232
+ test(`direct reply metadata on ${placement} auto-completes without waking (${version})`, async (t) => {
233
+ const s = await fixture(t as never);
234
+ const contextId = "ctx-marker";
235
+ const marker = { "a2a.exposed/reply-delivery": { taskId: "remote-task", messageId: "remote-message", state: "working" } };
236
+ const send = async (metadata: unknown, taskId?: string, token = s.bobAtAlice) => {
237
+ const message = { role: version === "1.0" ? "ROLE_USER" : "user", messageId: crypto.randomUUID(), contextId,
238
+ parts: [{ kind: "text", text: "reply text" }], ...(taskId ? { taskId } : {}), ...(placement === "message" ? { metadata } : {}) };
239
+ const r = await dispatch(new Request(s.a.PUBLIC_URL, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
240
+ body: JSON.stringify({ jsonrpc: "2.0", id: "marker", method: version === "1.0" ? "SendMessage" : "message/send", params: { message, ...(placement === "request" ? { metadata } : {}) } }) }), s.a, ectx as never);
241
+ return await r.json() as any;
242
+ };
243
+ const result = await send(marker);
244
+ const task = version === "1.0" ? result.result.task : result.result;
245
+ assert.equal(task.status.state, version === "1.0" ? "TASK_STATE_COMPLETED" : "completed");
246
+ assert.deepEqual(task.metadata, marker);
247
+ assert.deepEqual((await s.owner(s.a, "acct-alice", "GET", "/owner/inbox")).body, []);
248
+ assert.deepEqual((await s.dbA.prepare("SELECT * FROM wakes").all()).results, []);
249
+ const history = await s.owner(s.a, "acct-alice", "GET", `/owner/history/${contextId}?from=bob`);
250
+ assert.match(JSON.stringify(history.body), /reply text/);
251
+ const unauthorized = await send(marker, task.id, await s.otherAtAlice());
252
+ assert.equal(unauthorized.error.code, -32001);
253
+ for (const metadata of [{ other: {} }, { "a2a.exposed/reply-delivery": "completed" }]) {
254
+ const normal = await send(metadata);
255
+ assert.equal((version === "1.0" ? normal.result.task : normal.result).status.state, version === "1.0" ? "TASK_STATE_SUBMITTED" : "submitted");
256
+ }
257
+ assert.equal(((await s.owner(s.a, "acct-alice", "GET", "/owner/inbox")).body as any[]).length, 2);
258
+ assert.equal((await s.dbA.prepare("SELECT COUNT(*) AS n FROM wakes").first() as any).n, 1);
259
+ });
260
+ }
261
+ }