a2a-exposed 0.8.11 → 0.8.12
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/package.json +1 -1
- package/worker/src/index.ts +19 -3
- package/worker/src/owner-rpc.ts +67 -2
- package/worker/src/version.ts +1 -1
- package/worker/test/mcp.test.ts +37 -0
- package/worker/test/owner-mcp.test.ts +125 -0
- package/worker/test/owner-rpc.test.ts +42 -1
package/package.json
CHANGED
package/worker/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDe
|
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
|
-
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
13
|
+
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, ownerRpcBody, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
14
14
|
import { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
|
|
15
15
|
import { publicManageUrl, resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
16
16
|
import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
|
|
@@ -1060,6 +1060,17 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1060
1060
|
}
|
|
1061
1061
|
}
|
|
1062
1062
|
if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
|
|
1063
|
+
if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working")) {
|
|
1064
|
+
const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
|
|
1065
|
+
if (!checked.ok) return json({ error: checked.error }, 400);
|
|
1066
|
+
try {
|
|
1067
|
+
const text = await runTool(ctx, ectx, seg[1], checked.value);
|
|
1068
|
+
return json({ text, isError: false });
|
|
1069
|
+
} catch (e: any) {
|
|
1070
|
+
if (e instanceof ToolError) return json({ text: `Error: ${e.message}`, isError: true });
|
|
1071
|
+
throw e;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1063
1074
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1064
1075
|
const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
|
|
1065
1076
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -2282,7 +2293,8 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2282
2293
|
}
|
|
2283
2294
|
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2284
2295
|
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2285
|
-
|
|
2296
|
+
const parsedBody = ownerRpcBody(matched.route.name, raw?.body);
|
|
2297
|
+
if (!parsedBody.ok) return refuse(400, parsedBody.error, "body");
|
|
2286
2298
|
const actor = (raw as { actor?: unknown } | null)?.actor;
|
|
2287
2299
|
if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
|
|
2288
2300
|
const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
|
|
@@ -2297,7 +2309,11 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2297
2309
|
const url = new URL(origin + matched.route.path + search);
|
|
2298
2310
|
const headers = new Headers({ "content-type": "application/json" });
|
|
2299
2311
|
if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
|
|
2300
|
-
const req = new Request(url, {
|
|
2312
|
+
const req = new Request(url, {
|
|
2313
|
+
method: matched.route.method,
|
|
2314
|
+
headers,
|
|
2315
|
+
body: parsedBody.value === undefined ? undefined : JSON.stringify(parsedBody.value),
|
|
2316
|
+
});
|
|
2301
2317
|
let res: Response;
|
|
2302
2318
|
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2303
2319
|
catch (e: any) {
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Hosted owner RPC allowlist. The caller has checked that the account owns the tenant.
|
|
2
2
|
// This list is the only paths that call may run, and it is stricter than
|
|
3
3
|
// the owner HTTP API: routes that mint a peer token, return a setup secret, or accept a plaintext peer
|
|
4
|
-
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
4
|
+
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
5
|
+
// POST /owner/mcp/send, reply, and mark_working take a checked argument object. A peer token is not one of those arguments.
|
|
6
|
+
// Self-host never calls this.
|
|
5
7
|
|
|
6
8
|
import { ID_RE } from "./a2a.ts";
|
|
7
9
|
import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
|
|
@@ -39,7 +41,10 @@ export interface OwnerRpcRequest {
|
|
|
39
41
|
path: string;
|
|
40
42
|
/** Allowlisted query parameters only. */
|
|
41
43
|
query?: Record<string, string>;
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Tool arguments for `POST /owner/mcp/send`, `POST /owner/mcp/reply`, and `POST /owner/mcp/mark_working`.
|
|
46
|
+
* Any other call refuses a body.
|
|
47
|
+
*/
|
|
43
48
|
body?: unknown;
|
|
44
49
|
/**
|
|
45
50
|
* Approve or deny after a recent sign-in.
|
|
@@ -141,6 +146,9 @@ const ROUTES: RouteDef[] = [
|
|
|
141
146
|
{ method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
|
|
142
147
|
{ method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
|
|
143
148
|
{ 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]}` },
|
|
149
|
+
{ method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
|
|
150
|
+
{ method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
|
|
151
|
+
{ 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" },
|
|
144
152
|
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
145
153
|
{ method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
|
|
146
154
|
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
@@ -166,6 +174,63 @@ export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): O
|
|
|
166
174
|
return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
/** Send text cap. The owner HTTP body limit is 1 MiB; this keeps one argument inside that. */
|
|
178
|
+
const TEXT_MAX = 100_000;
|
|
179
|
+
const REPLY_STATES = new Set(["completed", "input-required", "working", "failed", "rejected"]);
|
|
180
|
+
|
|
181
|
+
const MCP_ARGS: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
|
|
182
|
+
"POST /owner/mcp/send": { required: ["to", "text"], optional: ["contextId", "taskId"] },
|
|
183
|
+
"POST /owner/mcp/reply": { required: ["taskId", "text"], optional: ["state"] },
|
|
184
|
+
"POST /owner/mcp/mark_working": { required: ["taskId"], optional: [] },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function mcpArgOk(key: string, value: string): string | null {
|
|
188
|
+
if (key === "to") return ALIAS.test(value) ? null : "to is invalid";
|
|
189
|
+
if (key === "text") {
|
|
190
|
+
if (!value.trim()) return "text is required";
|
|
191
|
+
if (value.length > TEXT_MAX) return "text is too long";
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (key === "taskId" || key === "contextId") return idOk(value) ? null : `${key} is invalid`;
|
|
195
|
+
if (key === "state") return REPLY_STATES.has(value) ? null : "state is invalid";
|
|
196
|
+
return "body is not allowed";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Body allowed on this owner RPC route.
|
|
201
|
+
* MCP send, reply, and mark_working receive a flat object of tool arguments.
|
|
202
|
+
* Every other route accepts only an absent body.
|
|
203
|
+
*/
|
|
204
|
+
export function ownerRpcBody(routeName: string, body: unknown): { ok: true; value?: Record<string, string> } | { ok: false; error: string } {
|
|
205
|
+
const spec = MCP_ARGS[routeName];
|
|
206
|
+
if (!spec) {
|
|
207
|
+
if (body !== undefined && body !== null) return { ok: false, error: "this call takes no body" };
|
|
208
|
+
return { ok: true };
|
|
209
|
+
}
|
|
210
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "body must be an object" };
|
|
211
|
+
const allowed = new Set([...spec.required, ...spec.optional]);
|
|
212
|
+
const keys = Object.keys(body);
|
|
213
|
+
if (keys.length > allowed.size || keys.some((key) => !allowed.has(key))) return { ok: false, error: "body is not allowed" };
|
|
214
|
+
const value: Record<string, string> = {};
|
|
215
|
+
for (const key of spec.required) {
|
|
216
|
+
const item = (body as Record<string, unknown>)[key];
|
|
217
|
+
if (item == null || item === "") return { ok: false, error: `${key} is required` };
|
|
218
|
+
if (typeof item !== "string") return { ok: false, error: `${key} is invalid` };
|
|
219
|
+
const why = mcpArgOk(key, item);
|
|
220
|
+
if (why) return { ok: false, error: why };
|
|
221
|
+
value[key] = item;
|
|
222
|
+
}
|
|
223
|
+
for (const key of spec.optional) {
|
|
224
|
+
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
|
|
225
|
+
const item = (body as Record<string, unknown>)[key];
|
|
226
|
+
if (typeof item !== "string" || !item) return { ok: false, error: `${key} is invalid` };
|
|
227
|
+
const why = mcpArgOk(key, item);
|
|
228
|
+
if (why) return { ok: false, error: why };
|
|
229
|
+
value[key] = item;
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, value };
|
|
232
|
+
}
|
|
233
|
+
|
|
169
234
|
function stringLeaks(value: string, key: string | null): boolean {
|
|
170
235
|
if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
|
|
171
236
|
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
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.12";
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -492,6 +492,43 @@ test("tools: send / poll_outbound through synced peers; each peer's token stays
|
|
|
492
492
|
assert.equal((await s.owner("GET", "/owner/outbound-peers")).data.length, 1);
|
|
493
493
|
});
|
|
494
494
|
|
|
495
|
+
test("POST /owner/mcp/send, reply, and mark_working use the owner token", async (t) => {
|
|
496
|
+
const s = setup(); t.after(s.restore);
|
|
497
|
+
const a = await s.connect();
|
|
498
|
+
await s.owner("PUT", "/owner/outbound-peers/alpha", { url: "https://alpha.example.org", token: "tok-alpha" });
|
|
499
|
+
const noAuth = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" } });
|
|
500
|
+
assert.equal(noAuth.status, 401);
|
|
501
|
+
const mcpTok = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" }, headers: { authorization: `Bearer ${a.access_token}` } });
|
|
502
|
+
assert.equal(mcpTok.status, 401);
|
|
503
|
+
const peerTok = "a2aow_" + "c".repeat(43);
|
|
504
|
+
const peer = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" }, headers: { authorization: `Bearer ${peerTok}` } });
|
|
505
|
+
assert.equal(peer.status, 401);
|
|
506
|
+
assert.equal(s.peerCalls.length, 0, "a refused caller does not send");
|
|
507
|
+
const extra = await s.owner("POST", "/owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" });
|
|
508
|
+
assert.equal(extra.status, 400);
|
|
509
|
+
assert.equal(s.peerCalls.length, 0, "a token field is not a send");
|
|
510
|
+
const sent = await s.owner("POST", "/owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-owner" });
|
|
511
|
+
assert.equal(sent.status, 200, sent.text);
|
|
512
|
+
assert.equal(sent.data.isError, false);
|
|
513
|
+
assert.match(sent.data.text, /task pt-alpha/);
|
|
514
|
+
assert.equal(sent.text.includes("tok-alpha"), false);
|
|
515
|
+
assert.equal(s.peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
516
|
+
assert.equal(s.peerCalls.at(-1)!.body.params.message.contextId, "ctx-owner");
|
|
517
|
+
|
|
518
|
+
const inbound = await s.inbound("barry", "please reply");
|
|
519
|
+
const taskId = inbound.task.id as string;
|
|
520
|
+
const working = await s.owner("POST", "/owner/mcp/mark_working", { taskId });
|
|
521
|
+
assert.equal(working.status, 200, working.text);
|
|
522
|
+
assert.equal(working.data.isError, false);
|
|
523
|
+
assert.match(working.data.text, new RegExp(`Task ${taskId} is now working`));
|
|
524
|
+
const replied = await s.owner("POST", "/owner/mcp/reply", { taskId, text: "done", state: "completed" });
|
|
525
|
+
assert.equal(replied.status, 200, replied.text);
|
|
526
|
+
assert.equal(replied.data.isError, false);
|
|
527
|
+
assert.equal((await s.owner("GET", `/owner/tasks/${taskId}`)).data.status.state, "completed");
|
|
528
|
+
const mcpReply = await s.call("POST", "/owner/mcp/reply", { json: { taskId, text: "no" }, headers: { authorization: `Bearer ${a.access_token}` } });
|
|
529
|
+
assert.equal(mcpReply.status, 401);
|
|
530
|
+
});
|
|
531
|
+
|
|
495
532
|
test("peer token encryption round-trips and is bound to the alias", async () => {
|
|
496
533
|
const sealed = await M.sealPeerToken("owner", "alpha", "secret");
|
|
497
534
|
assert.equal(await M.openPeerToken("owner", "alpha", sealed), "secret");
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Hosted owner RPC for send, reply, and mark_working. The caller has already checked that the account owns the tenant.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { sha256 } from "../src/a2a.ts";
|
|
5
|
+
import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
6
|
+
import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
7
|
+
import { d1 } from "./d1.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = "https://carol.example.com";
|
|
10
|
+
const ectx = { waitUntil() {}, passThroughOnException() {} };
|
|
11
|
+
|
|
12
|
+
test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
13
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
14
|
+
const ownerToken = "hosted-owner-token";
|
|
15
|
+
const hash = await sha256(ownerToken);
|
|
16
|
+
const env = {
|
|
17
|
+
DB, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: BASE, OWNER_TOKEN: "platform-owner",
|
|
18
|
+
} as WorkerBindings;
|
|
19
|
+
const ctx = hostedTenantContext(env, {
|
|
20
|
+
id: "ten_carol", db: DB, publicUrl: BASE, ownerTokenHash: hash, status: "active",
|
|
21
|
+
config: { AGENT_NAME: "Carol" },
|
|
22
|
+
});
|
|
23
|
+
const peerCalls: { host: string; auth: string | null; body: any }[] = [];
|
|
24
|
+
const origFetch = globalThis.fetch;
|
|
25
|
+
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
26
|
+
const u = new URL(String(input));
|
|
27
|
+
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" }] });
|
|
29
|
+
if (u.pathname === "/a2a") {
|
|
30
|
+
const body = JSON.parse(init.body);
|
|
31
|
+
const auth = new Headers(init.headers).get("authorization");
|
|
32
|
+
peerCalls.push({ host: u.host, auth, body });
|
|
33
|
+
if (auth !== `Bearer tok-${u.host.split(".")[0]}`) return new Response("no", { status: 401 });
|
|
34
|
+
if (body.method === "SendMessage") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
|
|
35
|
+
}
|
|
36
|
+
return new Response("not found", { status: 404 });
|
|
37
|
+
}) as typeof fetch;
|
|
38
|
+
t.after(() => { globalThis.fetch = origFetch; });
|
|
39
|
+
|
|
40
|
+
const http = async (method: string, path: string, auth: string | null, json?: unknown, headers: Record<string, string> = {}) => {
|
|
41
|
+
const h: Record<string, string> = { "content-type": "application/json", ...headers };
|
|
42
|
+
if (auth) h.authorization = auth;
|
|
43
|
+
const res = await dispatch(new Request(BASE + path, {
|
|
44
|
+
method, headers: h, body: json === undefined ? undefined : JSON.stringify(json),
|
|
45
|
+
}), ctx, ectx as any);
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
let data: any = text;
|
|
48
|
+
try { data = JSON.parse(text); } catch { /* plain text */ }
|
|
49
|
+
return { status: res.status, data, text };
|
|
50
|
+
};
|
|
51
|
+
const rpc = (path: string, body?: unknown, extra: Record<string, unknown> = {}) =>
|
|
52
|
+
runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "POST", path, ...(body === undefined ? {} : { body }), ...extra } as any);
|
|
53
|
+
|
|
54
|
+
const single = await runOwnerRpc(resolveTenant({ DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" } as WorkerBindings), ectx as any, {
|
|
55
|
+
accountId: "acct_carol", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "hi" },
|
|
56
|
+
});
|
|
57
|
+
assert.equal(single.status, 404, "self-host keeps the bearer owner API");
|
|
58
|
+
|
|
59
|
+
assert.equal((await http("POST", "/owner/mcp/send", null, { to: "alpha", text: "hi" })).status, 401);
|
|
60
|
+
assert.equal((await http("POST", "/owner/mcp/send", "Bearer platform-owner", { to: "alpha", text: "hi" })).status, 401);
|
|
61
|
+
assert.equal((await http("POST", "/owner/mcp/send", "Bearer a2aow_" + "c".repeat(43), { to: "alpha", text: "hi" })).status, 401);
|
|
62
|
+
const synced = await http("PUT", "/owner/outbound-peers/alpha", `Bearer ${ownerToken}`, { url: "https://alpha.example.org", token: "tok-alpha" });
|
|
63
|
+
assert.equal(synced.status, 200, synced.text);
|
|
64
|
+
|
|
65
|
+
const missing = await rpc("/owner/mcp/send");
|
|
66
|
+
assert.equal(missing.status, 400);
|
|
67
|
+
assert.match(String((missing.body as { error?: string }).error), /body must be an object/);
|
|
68
|
+
const sneaky = await rpc("/owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" });
|
|
69
|
+
assert.equal(sneaky.status, 400);
|
|
70
|
+
assert.equal(peerCalls.length, 0);
|
|
71
|
+
const human = await rpc("/owner/mcp/send", { to: "alpha", text: "hi" }, { actor: "human" });
|
|
72
|
+
assert.equal(human.status, 400);
|
|
73
|
+
assert.equal(peerCalls.length, 0);
|
|
74
|
+
const badAccount = await runOwnerRpc(ctx, ectx as any, {
|
|
75
|
+
accountId: "../acct", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "hi" },
|
|
76
|
+
});
|
|
77
|
+
assert.equal(badAccount.status, 400);
|
|
78
|
+
assert.equal(peerCalls.length, 0);
|
|
79
|
+
|
|
80
|
+
const sent = await rpc("/owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-hosted" });
|
|
81
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.body));
|
|
82
|
+
const sentBody = sent.body as { text?: string; isError?: boolean };
|
|
83
|
+
assert.equal(sentBody.isError, false);
|
|
84
|
+
assert.match(sentBody.text || "", /task pt-alpha/);
|
|
85
|
+
assert.equal(JSON.stringify(sent.body).includes("tok-alpha"), false);
|
|
86
|
+
assert.equal(JSON.stringify(sent.body).includes(ownerToken), false);
|
|
87
|
+
assert.equal(peerCalls.at(-1)!.host, "alpha.example.org");
|
|
88
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
89
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "status?");
|
|
90
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.contextId, "ctx-hosted");
|
|
91
|
+
|
|
92
|
+
const viaHttp = await http("POST", "/owner/mcp/send", `Bearer ${ownerToken}`, { to: "alpha", text: "from the owner token" });
|
|
93
|
+
assert.equal(viaHttp.status, 200, viaHttp.text);
|
|
94
|
+
assert.equal(viaHttp.data.isError, false);
|
|
95
|
+
assert.equal(viaHttp.text.includes("tok-alpha"), false);
|
|
96
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "from the owner token");
|
|
97
|
+
|
|
98
|
+
const withBody = await runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
99
|
+
assert.equal(withBody.status, 400);
|
|
100
|
+
assert.equal((withBody.body as { error?: string }).error, "this call takes no body");
|
|
101
|
+
const minted = await rpc("/owner/peers", { label: "bob" });
|
|
102
|
+
assert.equal(minted.status, 404);
|
|
103
|
+
|
|
104
|
+
const issued = await http("POST", "/owner/peers", `Bearer ${ownerToken}`, { label: "ada" });
|
|
105
|
+
assert.match(issued.data.token || "", /^a2aow_/);
|
|
106
|
+
const inbound = await http("POST", "/", `Bearer ${issued.data.token}`, {
|
|
107
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
108
|
+
params: { message: { kind: "message", role: "user", messageId: "m-ada", parts: [{ kind: "text", text: "hello" }] } },
|
|
109
|
+
}, { "a2a-version": "0.3" });
|
|
110
|
+
assert.equal(inbound.status, 200, inbound.text);
|
|
111
|
+
const taskId = inbound.data.result.id as string;
|
|
112
|
+
assert.ok(taskId);
|
|
113
|
+
const working = await rpc("/owner/mcp/mark_working", { taskId });
|
|
114
|
+
assert.equal(working.status, 200, JSON.stringify(working.body));
|
|
115
|
+
assert.match((working.body as { text?: string }).text || "", new RegExp(`Task ${taskId} is now working`));
|
|
116
|
+
const replied = await rpc("/owner/mcp/reply", { taskId, text: "done", state: "completed" });
|
|
117
|
+
assert.equal(replied.status, 200, JSON.stringify(replied.body));
|
|
118
|
+
assert.equal((replied.body as { isError?: boolean }).isError, false);
|
|
119
|
+
const task = await http("GET", `/owner/tasks/${taskId}`, `Bearer ${ownerToken}`);
|
|
120
|
+
assert.equal(task.data.status.state, "completed");
|
|
121
|
+
const unknown = await rpc("/owner/mcp/send", { to: "missing", text: "hi" });
|
|
122
|
+
assert.equal(unknown.status, 200);
|
|
123
|
+
assert.equal((unknown.body as { isError?: boolean }).isError, true);
|
|
124
|
+
assert.match((unknown.body as { text?: string }).text || "", /no peer "missing"/);
|
|
125
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
|
|
4
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, ownerRpcBody, scrubOwnerClaims } from "../src/owner-rpc.ts";
|
|
5
5
|
|
|
6
6
|
test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
|
|
7
7
|
const ok = (method: string, path: string, query?: Record<string, string>) => {
|
|
@@ -22,6 +22,9 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
22
22
|
assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
|
|
23
23
|
assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
|
|
24
24
|
assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
|
|
25
|
+
assert.equal(ok("POST", "/owner/mcp/send")?.name, "POST /owner/mcp/send");
|
|
26
|
+
assert.equal(ok("POST", "/owner/mcp/reply")?.path, "/owner/mcp/reply");
|
|
27
|
+
assert.equal(ok("POST", "/owner/mcp/mark_working")?.name, "POST /owner/mcp/mark_working");
|
|
25
28
|
assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
|
|
26
29
|
assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
27
30
|
assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
|
|
@@ -44,6 +47,9 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
44
47
|
assert.deepEqual(refused("GET", "/owner/log", { n: "10" }), { ok: false, reason: "query" });
|
|
45
48
|
assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
|
|
46
49
|
assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
|
|
50
|
+
assert.deepEqual(refused("POST", "/owner/mcp/send", { to: "alpha" }), { ok: false, reason: "query" });
|
|
51
|
+
assert.deepEqual(refused("POST", "/owner/mcp/poll_outbound"), { ok: false, reason: "path" });
|
|
52
|
+
assert.deepEqual(refused("PUT", "/owner/mcp/send"), { ok: false, reason: "path" });
|
|
47
53
|
assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
|
|
48
54
|
assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
|
|
49
55
|
assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
|
|
@@ -58,6 +64,41 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
58
64
|
assert.equal(accountIdOf(""), null);
|
|
59
65
|
});
|
|
60
66
|
|
|
67
|
+
test("owner RPC accepts a tool-argument body only on send, reply, and mark_working", () => {
|
|
68
|
+
const absent = ownerRpcBody("GET /owner/peers", undefined);
|
|
69
|
+
assert.equal(absent.ok, true);
|
|
70
|
+
assert.equal(absent.ok && absent.value, undefined);
|
|
71
|
+
assert.equal(ownerRpcBody("GET /owner/peers", null).ok, true);
|
|
72
|
+
assert.deepEqual(ownerRpcBody("GET /owner/peers", { label: "bob" }), { ok: false, error: "this call takes no body" });
|
|
73
|
+
assert.deepEqual(ownerRpcBody("POST /owner/wake/test", {}), { ok: false, error: "this call takes no body" });
|
|
74
|
+
|
|
75
|
+
const sent = ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-1" });
|
|
76
|
+
assert.deepEqual(sent, { ok: true, value: { to: "alpha", text: "status?", contextId: "ctx-1" } });
|
|
77
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "status?", taskId: "task-1" }), {
|
|
78
|
+
ok: true, value: { to: "alpha", text: "status?", taskId: "task-1" },
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", state: "input-required" }), {
|
|
81
|
+
ok: true, value: { taskId: "task-1", text: "done", state: "input-required" },
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: "task-1" }), { ok: true, value: { taskId: "task-1" } });
|
|
84
|
+
|
|
85
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", undefined).ok, false);
|
|
86
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", null).ok, false);
|
|
87
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", []).ok, false);
|
|
88
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", "hi").ok, false);
|
|
89
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", {}), { ok: false, error: "to is required" });
|
|
90
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha" }), { ok: false, error: "text is required" });
|
|
91
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "https://alpha.example", text: "hi" }), { ok: false, error: "to is invalid" });
|
|
92
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: " " }), { ok: false, error: "text is required" });
|
|
93
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "x".repeat(100_001) }), { ok: false, error: "text is too long" });
|
|
94
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" }), { ok: false, error: "body is not allowed" });
|
|
95
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "hi", url: "https://evil.example" }), { ok: false, error: "body is not allowed" });
|
|
96
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", state: "canceled" }), { ok: false, error: "state is invalid" });
|
|
97
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", force: true }), { ok: false, error: "body is not allowed" });
|
|
98
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: ".." }), { ok: false, error: "taskId is invalid" });
|
|
99
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: "task-1", text: "no" }), { ok: false, error: "body is not allowed" });
|
|
100
|
+
});
|
|
101
|
+
|
|
61
102
|
test("owner RPC withholds peer tokens and decrypted secrets", () => {
|
|
62
103
|
const token = "a2aow_" + "a".repeat(43);
|
|
63
104
|
assert.equal(ownerResponseLeaks("a2aot_" + "Q".repeat(43)), true);
|