a2a-exposed 0.8.35 → 0.8.37
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 +17 -3
- package/lib/main.mjs +11 -3
- package/lib/status.mjs +12 -6
- package/package.json +1 -1
- package/worker/src/index.ts +11 -5
- package/worker/src/version.ts +1 -1
- package/worker/test/reply-delivery.test.ts +78 -5
package/lib/hosted.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { controlOrigin, deviceLogin } from "./account.mjs";
|
|
|
5
5
|
import { CliError, upstreamUrlProblem } from "./a2a.mjs";
|
|
6
6
|
|
|
7
7
|
export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test"];
|
|
8
|
+
const SETUP_UNSUPPORTED = "This service does not expose scoped hosted setup capabilities. Check the --control-url service origin. Use the service dashboard to create an agent and configure Wake settings, or hosted MCP create_agent/set_wake with human confirmation and secret entry. Do not run init or deploy to repair a hosted address; no setup approval was requested.";
|
|
8
9
|
const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
|
|
9
10
|
const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
|
|
10
11
|
const printable = value => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
|
|
@@ -34,14 +35,16 @@ function originFor(o = {}) {
|
|
|
34
35
|
|
|
35
36
|
async function request(origin, path, { token = "", method = "GET", body, fetchImpl = fetch } = {}) {
|
|
36
37
|
let response, data;
|
|
38
|
+
const discovery = method === "GET" && path === "/api/v1/setup/capabilities";
|
|
37
39
|
try {
|
|
38
40
|
response = await fetchImpl(`${origin}${path}`, {
|
|
39
41
|
method, redirect: "error", signal: AbortSignal.timeout(30_000),
|
|
40
42
|
headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
41
43
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
42
44
|
});
|
|
43
|
-
data = await response.json();
|
|
45
|
+
if (!(discovery && response.status === 404)) data = await response.json();
|
|
44
46
|
} catch { fail("unavailable", "Hosted setup did not answer with JSON. Check the service and try again. Do not repeat a create request until hosted list confirms its result."); }
|
|
47
|
+
if (discovery && response.status === 404) fail("unsupported", SETUP_UNSUPPORTED);
|
|
45
48
|
if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
|
|
46
49
|
if (!response.ok) {
|
|
47
50
|
if (response.status === 429 && data.error === "too_many_references") {
|
|
@@ -70,7 +73,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
|
|
|
70
73
|
|
|
71
74
|
export async function capabilities(o = {}) {
|
|
72
75
|
const data = await request(originFor(o), "/api/v1/setup/capabilities");
|
|
73
|
-
if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported",
|
|
76
|
+
if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", SETUP_UNSUPPORTED);
|
|
74
77
|
return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -194,6 +197,8 @@ export async function status(o = {}) {
|
|
|
194
197
|
|
|
195
198
|
export async function wake(sub, o = {}) {
|
|
196
199
|
if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
|
|
200
|
+
if (sub === "set" && C.deploymentKind() === "hosted" && !o.agent)
|
|
201
|
+
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
202
|
const removing = sub === "remove" || sub === "unset";
|
|
198
203
|
const preparingRemoval = sub === "prepare" && o.clear === true;
|
|
199
204
|
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 +271,16 @@ export async function wake(sub, o = {}) {
|
|
|
266
271
|
// Do not print proofs, arbitrary service text, webhook URLs or unexpected fields.
|
|
267
272
|
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
273
|
const ok = result.ok === true && (sub !== "set" || result.saved === true);
|
|
269
|
-
|
|
274
|
+
const recovery = {
|
|
275
|
+
"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.",
|
|
276
|
+
"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.",
|
|
277
|
+
"dns": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
|
|
278
|
+
"dns-missing": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
|
|
279
|
+
"tls": "The hosted service could not establish TLS to the webhook. Check the hostname and certificate; keep HTTPS verification enabled.",
|
|
280
|
+
"access-401": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
|
|
281
|
+
"access-403": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
|
|
282
|
+
};
|
|
283
|
+
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
284
|
}
|
|
271
285
|
|
|
272
286
|
export async function output(run, o = {}) {
|
package/lib/main.mjs
CHANGED
|
@@ -28,6 +28,7 @@ Setup options: --skip-install skips dependency installation when dependencies al
|
|
|
28
28
|
|
|
29
29
|
Hosted setup:
|
|
30
30
|
hosted capabilities --json list setup scopes, wake presets and service capabilities
|
|
31
|
+
unsupported/404: check --control-url; use dashboard or hosted MCP with human confirmation
|
|
31
32
|
login --machine NAME --scopes SCOPES [--control-url URL] --json
|
|
32
33
|
approve the explicit scopes using the printed website link once;
|
|
33
34
|
save the scoped grant locally (mode 600), never print it
|
|
@@ -131,7 +132,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
131
132
|
deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
|
|
132
133
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
133
134
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
134
|
-
save wake settings, upload wake secrets from env, and redeploy
|
|
135
|
+
self-hosted: save wake settings, upload wake secrets from env, and redeploy
|
|
136
|
+
hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
|
|
137
|
+
hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
|
|
135
138
|
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
136
139
|
the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
|
|
137
140
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
@@ -191,8 +194,11 @@ Status
|
|
|
191
194
|
read selected hosted agent wake configuration; select NAME with hosted list --json.
|
|
192
195
|
Uses setup approval or that agent's matching owner credential; no delivery test.
|
|
193
196
|
Hosted-only configs report deployment: hosted and hosted recovery guidance.
|
|
197
|
+
Hosted status lists capability/preview next steps; saved wake settings do not prove delivery.
|
|
198
|
+
Offer webhook or polling after checking hook support and hosted route compatibility.
|
|
199
|
+
A listed OpenClaw preset does not prove route reachability; check delivery preflight before tunnel setup.
|
|
194
200
|
status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
|
|
195
|
-
mode (webhook / tunnel / none =
|
|
201
|
+
mode (webhook / tunnel / none = no webhook), tunnel state, proxy mode (--upstream):
|
|
196
202
|
upstream card, Access credential and upstream bearer configured (separately), the
|
|
197
203
|
live upstream check, the last failed peer call, card-leak check; warnings for peer
|
|
198
204
|
token variables shadowing saved tokens; and the next step to run.
|
|
@@ -233,7 +239,9 @@ Inbox (owner side)
|
|
|
233
239
|
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
234
240
|
--json: rows on stdout, requests on stderr). Follows every page; past
|
|
235
241
|
10000 pages it errors instead of a short list. Reading marks returned replies
|
|
236
|
-
seen and clears matching pending wakes; task state is unchanged
|
|
242
|
+
seen and clears matching pending wakes; task state is unchanged.
|
|
243
|
+
Direct reply messages are completed without a new-work wake;
|
|
244
|
+
read them with history or inbox --all
|
|
237
245
|
show <taskId> full task JSON
|
|
238
246
|
working <taskId> mark task working
|
|
239
247
|
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
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
package/worker/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
|
@@ -1143,7 +1147,7 @@ async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base
|
|
|
1143
1147
|
try { baseUrl = new URL(base); target = new URL(url); } catch { throw new ToolError("peer endpoint is not a URL"); }
|
|
1144
1148
|
if (target.origin !== baseUrl.origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
|
|
1145
1149
|
const why = peerUrlProblem(target.href);
|
|
1146
|
-
if (why) throw new ToolError(`peer endpoint ${target.
|
|
1150
|
+
if (why) throw new ToolError(`peer endpoint ${target.host} refused: ${why}`);
|
|
1147
1151
|
const internal = await fetchTenant(ctx, target, { ...init, redirect: "manual" });
|
|
1148
1152
|
if (internal) {
|
|
1149
1153
|
if (internal.status >= 300 && internal.status < 400) throw new ToolError("peer endpoint refused: redirects are not allowed");
|
|
@@ -1559,10 +1563,12 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
|
|
|
1559
1563
|
const pushed = await notifyPeer(ctx, ectx, task, signal);
|
|
1560
1564
|
const failed = pushed.find((x: Json) => !x.ok);
|
|
1561
1565
|
const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
|
|
1562
|
-
const
|
|
1566
|
+
const info = String((failed || pushed[0])?.info || "");
|
|
1567
|
+
const http = /^HTTP (\d{3})$/.exec(info);
|
|
1568
|
+
const httpStatus = http && http[0] === info ? Number(http[1]) : undefined;
|
|
1563
1569
|
return {
|
|
1564
1570
|
status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
|
|
1565
|
-
...(target ? { target } : {}), ...(
|
|
1571
|
+
...(target ? { target } : {}), ...(httpStatus !== undefined && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}),
|
|
1566
1572
|
detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
|
|
1567
1573
|
};
|
|
1568
1574
|
}
|
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.37";
|
|
@@ -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
|
-
|
|
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).
|
|
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,69 @@ 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
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
test("push status only accepts an exact HTTP result in the output schema range", async (t) => {
|
|
265
|
+
const s = await fixture(t as never);
|
|
266
|
+
const originalFetch = globalThis.fetch;
|
|
267
|
+
t.after(() => { globalThis.fetch = originalFetch; });
|
|
268
|
+
for (const value of [200, 307, 401, 599, 99, 600, 999, "HTTP 999", "prefix HTTP 401 suffix", "HTTP 401\n"]) {
|
|
269
|
+
globalThis.fetch = (async () => {
|
|
270
|
+
if (typeof value === "string") throw value;
|
|
271
|
+
const response = new Response(null);
|
|
272
|
+
Object.defineProperty(response, "status", { value });
|
|
273
|
+
return response;
|
|
274
|
+
}) as typeof fetch;
|
|
275
|
+
const taskId = await s.sendToBob("status " + value, crypto.randomUUID(), { id: "cfg", url: "https://push.example.net/update" });
|
|
276
|
+
const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
|
|
277
|
+
const delivery = (result.body as any).structuredContent as Delivery;
|
|
278
|
+
assert.equal(delivery.httpStatus, typeof value === "number" && value >= 100 && value <= 599 ? value : undefined, String(value));
|
|
279
|
+
assert.equal(delivery.status, value === 200 ? "delivered" : "delivery-failed");
|
|
280
|
+
const stored = await s.dbB.prepare("SELECT state FROM tasks WHERE id = ?").bind(taskId).first() as { state: string };
|
|
281
|
+
assert.equal(stored.state, "completed");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("direct reply refusal reports only the endpoint host", async (t) => {
|
|
286
|
+
const s = await fixture(t as never);
|
|
287
|
+
await s.dbB.prepare("UPDATE outbound_peers SET url = 'http://alice.example.com/private-path?private-query=yes' WHERE alias = 'alice'").run();
|
|
288
|
+
const taskId = await s.sendToBob("refused endpoint", "ctx-refused-url");
|
|
289
|
+
const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
|
|
290
|
+
const delivery = (result.body as any).structuredContent;
|
|
291
|
+
assert.equal(delivery.status, "delivery-failed");
|
|
292
|
+
assert.match(delivery.detail, /peer endpoint alice\.example\.com refused/);
|
|
293
|
+
assert.doesNotMatch(JSON.stringify(result.body), /private-path|private-query|http:\/\//);
|
|
294
|
+
});
|