a2a-exposed 0.8.36 → 0.8.38
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 +5 -2
- package/lib/main.mjs +3 -0
- package/package.json +1 -1
- package/worker/src/index.ts +20 -9
- package/worker/src/mcp.ts +3 -0
- package/worker/src/owner-rpc.ts +5 -1
- package/worker/src/version.ts +1 -1
- package/worker/test/mcp.test.ts +1 -1
- package/worker/test/owner-mcp.test.ts +53 -2
- package/worker/test/owner-rpc.test.ts +5 -1
- package/worker/test/reply-delivery.test.ts +33 -0
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
|
|
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
|
|
@@ -314,6 +315,8 @@ Outbound (agents you call)
|
|
|
314
315
|
poll --to <alias|url> <taskId> [--proto 0.3|1.0]
|
|
315
316
|
send/poll need only peer config; owner history/task storage is optional.
|
|
316
317
|
Storage failures warn on stderr; the peer response still prints and exits 0
|
|
318
|
+
Self-hosted MCP: poll_outbound fetches current state; cancel_outbound asks the peer
|
|
319
|
+
to cancel a sent task. Ask the human before canceling consequential work
|
|
317
320
|
outbound <taskId> stored state of a task you sent (incl. pushed updates)
|
|
318
321
|
|
|
319
322
|
Owner
|
package/package.json
CHANGED
package/worker/src/index.ts
CHANGED
|
@@ -1147,7 +1147,7 @@ async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base
|
|
|
1147
1147
|
try { baseUrl = new URL(base); target = new URL(url); } catch { throw new ToolError("peer endpoint is not a URL"); }
|
|
1148
1148
|
if (target.origin !== baseUrl.origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
|
|
1149
1149
|
const why = peerUrlProblem(target.href);
|
|
1150
|
-
if (why) throw new ToolError(`peer endpoint ${target.
|
|
1150
|
+
if (why) throw new ToolError(`peer endpoint ${target.host} refused: ${why}`);
|
|
1151
1151
|
const internal = await fetchTenant(ctx, target, { ...init, redirect: "manual" });
|
|
1152
1152
|
if (internal) {
|
|
1153
1153
|
if (internal.status >= 300 && internal.status < 400) throw new ToolError("peer endpoint refused: redirects are not allowed");
|
|
@@ -1563,10 +1563,12 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
|
|
|
1563
1563
|
const pushed = await notifyPeer(ctx, ectx, task, signal);
|
|
1564
1564
|
const failed = pushed.find((x: Json) => !x.ok);
|
|
1565
1565
|
const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
|
|
1566
|
-
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;
|
|
1567
1569
|
return {
|
|
1568
1570
|
status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
|
|
1569
|
-
...(target ? { target } : {}), ...(
|
|
1571
|
+
...(target ? { target } : {}), ...(httpStatus !== undefined && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}),
|
|
1570
1572
|
detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
|
|
1571
1573
|
};
|
|
1572
1574
|
}
|
|
@@ -2195,7 +2197,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
2195
2197
|
}
|
|
2196
2198
|
}
|
|
2197
2199
|
if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
|
|
2198
|
-
if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address")) {
|
|
2200
|
+
if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address" || seg[1] === "poll_outbound" || seg[1] === "cancel_outbound")) {
|
|
2199
2201
|
const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
|
|
2200
2202
|
if (!checked.ok) return json({ error: checked.error, ...(checked.code ? { code: checked.code } : {}) }, 400);
|
|
2201
2203
|
try {
|
|
@@ -3565,8 +3567,15 @@ async function peerRpc(ctx: TenantContext, ectx: ExecutionContext, peer: { alias
|
|
|
3565
3567
|
const text = (await res.text()).slice(0, 1 << 20);
|
|
3566
3568
|
let data: Json = null;
|
|
3567
3569
|
try { data = JSON.parse(text); } catch { /* below */ }
|
|
3568
|
-
if (res.status !== 200 || !data || typeof data !== "object" || "error" in data)
|
|
3569
|
-
|
|
3570
|
+
if (res.status !== 200 || !data || typeof data !== "object" || "error" in data) {
|
|
3571
|
+
const rpc = data?.error;
|
|
3572
|
+
const code = Number.isSafeInteger(rpc?.code) ? `JSON-RPC ${rpc.code}` : "JSON-RPC error";
|
|
3573
|
+
const detail = typeof rpc?.message === "string" ? rpc.message.replace(/[\x00-\x1f\x7f]/g, " ") : "The peer returned an invalid response";
|
|
3574
|
+
const reason = rpc ? `${code}: ${detail}` : detail;
|
|
3575
|
+
const status = res.status === 200 ? "" : `HTTP ${res.status}: `;
|
|
3576
|
+
const safe = peer.token ? reason.split(peer.token).join("[redacted]") : reason;
|
|
3577
|
+
throw new ToolError(`peer "${peer.alias}" answered ${status}${safe.slice(0, 300)}`);
|
|
3578
|
+
}
|
|
3570
3579
|
return data.result;
|
|
3571
3580
|
}
|
|
3572
3581
|
|
|
@@ -3715,17 +3724,19 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
3715
3724
|
return M.toolText(tid ? `Sent to ${peer.alias}: task ${tid} (${M.plainState(obj)}), context ${sentContext}. Check it later with poll_outbound.${warn} ${UNTRUSTED}`
|
|
3716
3725
|
: `Sent to ${peer.alias}; it answered directly (context ${sentContext}). ${UNTRUSTED}`, obj);
|
|
3717
3726
|
}
|
|
3718
|
-
case "poll_outbound":
|
|
3727
|
+
case "poll_outbound":
|
|
3728
|
+
case "cancel_outbound": {
|
|
3729
|
+
const cancel = name === "cancel_outbound";
|
|
3719
3730
|
const tid = A.checkId(argStr(a, "taskId"), "taskId");
|
|
3720
3731
|
const rec = must(await ownerCall(ctx, ectx, "GET", `/owner/outbound/${enc(tid)}`));
|
|
3721
3732
|
const peer = await outboundPeer(ctx, rec.peer);
|
|
3722
3733
|
if (!rec.endpoint) throw new ToolError("this task has no recorded endpoint");
|
|
3723
|
-
const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", "tasks/get", "GetTask", { id: tid });
|
|
3734
|
+
const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", cancel ? "tasks/cancel" : "tasks/get", cancel ? "CancelTask" : "GetTask", { id: tid });
|
|
3724
3735
|
await ownerCall(ctx, ectx, "PUT", `/owner/outbound/${enc(tid)}`, { task: res });
|
|
3725
3736
|
let txt = A.textOf((res?.status || {}).message || {});
|
|
3726
3737
|
const arts = (res?.artifacts || []).map((x: Json) => A.textOf(x)).join("\n");
|
|
3727
3738
|
if (arts && arts !== txt) txt = (txt ? txt + "\n" : "") + arts;
|
|
3728
|
-
await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: "poll", state: M.plainState(res), text: txt });
|
|
3739
|
+
await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: cancel ? "cancel" : "poll", state: M.plainState(res), text: txt });
|
|
3729
3740
|
return M.toolText(`Task ${tid} at ${peer.alias}: ${M.plainState(res)}. ${UNTRUSTED}`, res);
|
|
3730
3741
|
}
|
|
3731
3742
|
case "list_peers": {
|
package/worker/src/mcp.ts
CHANGED
|
@@ -354,6 +354,9 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
354
354
|
{ name: "poll_outbound", title: "Check a sent task", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
355
355
|
description: `Fetch the current state of a task you sent to a peer (from send) and record it in the history. ${RULES}`,
|
|
356
356
|
inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
|
|
357
|
+
{ name: "cancel_outbound", title: "Cancel a sent task", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
358
|
+
description: `Ask the peer to cancel a task returned by send. Uses the recorded peer and endpoint, records the returned state in history, and does not cancel an inbound task. The peer can refuse cancellation. Ask the human before canceling consequential work. ${RULES}`,
|
|
359
|
+
inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
|
|
357
360
|
{ name: "list_peers", title: "List peers", annotations: ro,
|
|
358
361
|
description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this A2A endpoint (inbound; who can message you). Token values are never shown. An inbound peer with no card URL has no callback yet.",
|
|
359
362
|
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// PUT /owner/outbound-peers/:alias is the one call that accepts a peer token. handleOwner seals it.
|
|
5
5
|
// The response is { ok, alias, hasToken } and the token is not logged.
|
|
6
6
|
// GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
7
|
-
// POST /owner/mcp/send, reply, and
|
|
7
|
+
// POST /owner/mcp/send, reply, mark_working, poll_outbound, and cancel_outbound take a checked argument object. A peer token is not one of those arguments.
|
|
8
8
|
// Self-host never calls this.
|
|
9
9
|
|
|
10
10
|
import { ID_RE } from "./a2a.ts";
|
|
@@ -169,6 +169,8 @@ const ROUTES: RouteDef[] = [
|
|
|
169
169
|
{ method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
|
|
170
170
|
{ method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
|
|
171
171
|
{ method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
|
|
172
|
+
{ method: "POST", name: "POST /owner/mcp/poll_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "poll_outbound", search: noQuery, path: () => "/owner/mcp/poll_outbound" },
|
|
173
|
+
{ method: "POST", name: "POST /owner/mcp/cancel_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "cancel_outbound", search: noQuery, path: () => "/owner/mcp/cancel_outbound" },
|
|
172
174
|
{ method: "POST", name: "POST /owner/mcp/mark_working", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "mark_working", search: noQuery, path: () => "/owner/mcp/mark_working" },
|
|
173
175
|
{ method: "POST", name: "POST /owner/mcp/announce_address", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "announce_address", search: noQuery, path: () => "/owner/mcp/announce_address" },
|
|
174
176
|
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
@@ -204,6 +206,8 @@ const REPLY_STATES = new Set(["completed", "input-required", "working", "failed"
|
|
|
204
206
|
const MCP_ARGS: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
|
|
205
207
|
"POST /owner/mcp/send": { required: ["to", "text"], optional: ["contextId", "taskId"] },
|
|
206
208
|
"POST /owner/mcp/reply": { required: ["taskId", "text"], optional: ["state"] },
|
|
209
|
+
"POST /owner/mcp/poll_outbound": { required: ["taskId"], optional: [] },
|
|
210
|
+
"POST /owner/mcp/cancel_outbound": { required: ["taskId"], optional: [] },
|
|
207
211
|
"POST /owner/mcp/mark_working": { required: ["taskId"], optional: [] },
|
|
208
212
|
};
|
|
209
213
|
|
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.38";
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -329,7 +329,7 @@ test("scope: the MCP token is not an owner or peer token, and peer/owner tokens
|
|
|
329
329
|
assert.equal((await s.call("GET", "/mcp", { headers: { authorization: `Bearer ${a.access_token}` } })).status, 405);
|
|
330
330
|
// tools can't administer tokens or pairings
|
|
331
331
|
const names = (await s.mcp(a.access_token, "tools/list")).data.result.tools.map((x: any) => x.name);
|
|
332
|
-
assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
|
|
332
|
+
assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "cancel_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
|
|
333
333
|
assert.ok(!names.some((n: string) => /approve|deny|token|revoke|issue|password/.test(n)));
|
|
334
334
|
const unknown = await s.mcp(a.access_token, "tools/call", { name: "approve_pairing", arguments: { code: "X" } });
|
|
335
335
|
assert.equal(unknown.data.error.code, -32602);
|
|
@@ -21,17 +21,27 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
|
21
21
|
config: { AGENT_NAME: "Carol" },
|
|
22
22
|
});
|
|
23
23
|
const peerCalls: { host: string; auth: string | null; body: any }[] = [];
|
|
24
|
+
let modern = true;
|
|
25
|
+
let cancelRefused = false;
|
|
24
26
|
const origFetch = globalThis.fetch;
|
|
25
27
|
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
26
28
|
const u = new URL(String(input));
|
|
27
29
|
if (u.pathname === "/.well-known/agent-card.json")
|
|
28
|
-
return Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] });
|
|
30
|
+
return modern ? Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] }) : Response.json({ url: `https://${u.host}/a2a`, protocolVersion: "0.3" });
|
|
29
31
|
if (u.pathname === "/a2a") {
|
|
30
32
|
const body = JSON.parse(init.body);
|
|
31
33
|
const auth = new Headers(init.headers).get("authorization");
|
|
32
34
|
peerCalls.push({ host: u.host, auth, body });
|
|
33
35
|
if (auth !== `Bearer tok-${u.host.split(".")[0]}`) return new Response("no", { status: 401 });
|
|
34
|
-
if (body.method === "
|
|
36
|
+
if (body.method === "GetTask" || body.method === "tasks/get" || body.method === "CancelTask" || body.method === "tasks/cancel") {
|
|
37
|
+
assert.equal(new Headers(init.headers).get("a2a-version"), modern ? "1.0" : "0.3");
|
|
38
|
+
const cancel = body.method === "CancelTask" || body.method === "tasks/cancel";
|
|
39
|
+
if (cancel && cancelRefused) return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task cannot be canceled" } });
|
|
40
|
+
return Response.json({ jsonrpc: "2.0", id: body.id, result: { id: body.params.id, contextId: "ctx-out-1", status: { state: modern ? (cancel ? "TASK_STATE_CANCELED" : "TASK_STATE_WORKING") : (cancel ? "canceled" : "working") } } });
|
|
41
|
+
}
|
|
42
|
+
if (body.params?.message?.parts[0]?.text === "echo-token") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32603, message: "Rejected tok-alpha" } });
|
|
43
|
+
if (body.params?.message?.parts[0]?.text === "terminal") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task is in a terminal state" } });
|
|
44
|
+
if (body.method === "SendMessage" || body.method === "message/send") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}${modern ? "" : "-03"}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
|
|
35
45
|
}
|
|
36
46
|
return new Response("not found", { status: 404 });
|
|
37
47
|
}) as typeof fetch;
|
|
@@ -89,6 +99,47 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
|
89
99
|
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "status?");
|
|
90
100
|
assert.equal(peerCalls.at(-1)!.body.params.message.contextId, "ctx-hosted");
|
|
91
101
|
|
|
102
|
+
for (const version of [true, false]) {
|
|
103
|
+
modern = version;
|
|
104
|
+
const taskId = version ? "pt-alpha" : "pt-alpha-03";
|
|
105
|
+
await rpc("/owner/mcp/send", { to: "alpha", text: "status?" });
|
|
106
|
+
for (const tool of ["poll_outbound", "cancel_outbound"]) {
|
|
107
|
+
const before = peerCalls.length;
|
|
108
|
+
const missing = await rpc(`/owner/mcp/${tool}`, { taskId: "unknown" });
|
|
109
|
+
assert.equal((missing.body as any).isError, true);
|
|
110
|
+
assert.equal(peerCalls.length, before, "unknown task never calls a peer");
|
|
111
|
+
const denied = await http("POST", `/owner/mcp/${tool}`, "Bearer platform-owner", { taskId });
|
|
112
|
+
assert.equal(denied.status, 401);
|
|
113
|
+
const result = await rpc(`/owner/mcp/${tool}`, { taskId });
|
|
114
|
+
assert.equal(result.status, 200);
|
|
115
|
+
assert.equal((result.body as any).isError, false, JSON.stringify(result.body));
|
|
116
|
+
assert.match((result.body as any).text, tool === "poll_outbound" ? /working/ : /canceled/);
|
|
117
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
118
|
+
assert.equal(peerCalls.at(-1)!.body.method, version ? (tool === "poll_outbound" ? "GetTask" : "CancelTask") : (tool === "poll_outbound" ? "tasks/get" : "tasks/cancel"));
|
|
119
|
+
assert.deepEqual(peerCalls.at(-1)!.body.params, { id: taskId });
|
|
120
|
+
assert.equal(JSON.stringify(result.body).includes("tok-alpha"), false);
|
|
121
|
+
const stored = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
|
|
122
|
+
assert.match(JSON.stringify(stored.data), tool === "poll_outbound" ? /WORKING|working/ : /CANCELED|canceled/);
|
|
123
|
+
}
|
|
124
|
+
cancelRefused = true;
|
|
125
|
+
const beforeRefusal = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
|
|
126
|
+
const refused = await rpc("/owner/mcp/cancel_outbound", { taskId });
|
|
127
|
+
assert.equal((refused.body as any).isError, true);
|
|
128
|
+
assert.match((refused.body as any).text, /JSON-RPC -32002: Task cannot be canceled/);
|
|
129
|
+
assert.deepEqual((await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`)).data, beforeRefusal.data, "refused cancellation preserves the saved task");
|
|
130
|
+
cancelRefused = false;
|
|
131
|
+
const error = await rpc("/owner/mcp/send", { to: "alpha", text: "terminal" });
|
|
132
|
+
assert.equal((error.body as any).isError, true);
|
|
133
|
+
assert.match((error.body as any).text, /JSON-RPC -32002: Task is in a terminal state/);
|
|
134
|
+
assert.doesNotMatch((error.body as any).text, /HTTP 200/);
|
|
135
|
+
}
|
|
136
|
+
modern = true;
|
|
137
|
+
const echoed = await rpc("/owner/mcp/send", { to: "alpha", text: "echo-token" });
|
|
138
|
+
assert.equal((echoed.body as any).isError, true);
|
|
139
|
+
assert.doesNotMatch(JSON.stringify(echoed.body), /tok-alpha/);
|
|
140
|
+
const history = await http("GET", "/owner/history/ctx-out-1", `Bearer ${ownerToken}`);
|
|
141
|
+
assert.match(JSON.stringify(history.data), /"event":"cancel"/);
|
|
142
|
+
|
|
92
143
|
const viaHttp = await http("POST", "/owner/mcp/send", `Bearer ${ownerToken}`, { to: "alpha", text: "from the owner token" });
|
|
93
144
|
assert.equal(viaHttp.status, 200, viaHttp.text);
|
|
94
145
|
assert.equal(viaHttp.data.isError, false);
|
|
@@ -54,7 +54,11 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
54
54
|
assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
|
|
55
55
|
assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
|
|
56
56
|
assert.deepEqual(refused("POST", "/owner/mcp/send", { to: "alpha" }), { ok: false, reason: "query" });
|
|
57
|
-
|
|
57
|
+
for (const tool of ["poll_outbound", "cancel_outbound"]) {
|
|
58
|
+
assert.equal(ok("POST", `/owner/mcp/${tool}`)?.path, `/owner/mcp/${tool}`);
|
|
59
|
+
assert.deepEqual(ownerRpcBody(`POST /owner/mcp/${tool}`, { taskId: "task-1" }), { ok: true, value: { taskId: "task-1" } });
|
|
60
|
+
for (const body of [{}, { taskId: ".." }, { taskId: "task-1", to: "other" }, { taskId: "task-1", token: "secret" }]) assert.equal(ownerRpcBody(`POST /owner/mcp/${tool}`, body).ok, false);
|
|
61
|
+
}
|
|
58
62
|
assert.deepEqual(refused("PUT", "/owner/mcp/send"), { ok: false, reason: "path" });
|
|
59
63
|
assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
|
|
60
64
|
assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
|
|
@@ -259,3 +259,36 @@ for (const version of ["0.3", "1.0"]) {
|
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
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
|
+
});
|