a2a-exposed 0.8.20 → 0.8.22
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/deploy.mjs +9 -1
- package/lib/main.mjs +3 -2
- package/lib/status.mjs +23 -1
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/src/index.ts +32 -13
- package/worker/src/version.ts +1 -1
- package/worker/test/hosted.test.ts +5 -0
- package/worker/test/outbound-push.test.ts +190 -0
- package/worker/test/tenancy.test.ts +2 -1
package/lib/deploy.mjs
CHANGED
|
@@ -11,6 +11,14 @@ import { readHidden } from "./pair.mjs";
|
|
|
11
11
|
import * as WD from "./workersdev.mjs";
|
|
12
12
|
import { printStar } from "./feedback.mjs";
|
|
13
13
|
|
|
14
|
+
export const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
15
|
+
|
|
16
|
+
/** Arguments shared by deploy attempts, including workers.dev registration retries. */
|
|
17
|
+
export function deployArgs(version, isoDate) {
|
|
18
|
+
if (!/^[A-Za-z0-9._-]+$/.test(version)) throw new Error("invalid Worker version tag");
|
|
19
|
+
return ["deploy", "--message", `a2a-exposed ${version} ${isoDate}`, "--tag", `a2a-exposed-${version}`];
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
23
|
/** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
|
|
16
24
|
export const CLAUDE_CODE_NOTE = [
|
|
@@ -392,7 +400,7 @@ function runTee(cmd, args, { cwd, env }) {
|
|
|
392
400
|
/** cf deploy (secrets via a temporary chmod-600 file). If the account has no workers.dev subdomain yet and
|
|
393
401
|
* `register` names one, retry through cf's own registration prompt. Returns cf's output. */
|
|
394
402
|
async function cfDeploy(dir, secrets, { register } = {}) {
|
|
395
|
-
const base =
|
|
403
|
+
const base = deployArgs(VERSION, new Date().toISOString());
|
|
396
404
|
const go = async (file) => {
|
|
397
405
|
const args = cfArgs(file ? [...base, "--secrets-file", file] : base);
|
|
398
406
|
let r = await runTee(cfBin(dir), args, { cwd: dir, env: deployEnv() });
|
package/lib/main.mjs
CHANGED
|
@@ -88,7 +88,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
88
88
|
e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
|
|
89
89
|
--worker-dir where the Worker project (template copy) lives (default
|
|
90
90
|
<config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
|
|
91
|
-
deploy [same flags] redeploy with saved settings (secrets persist)
|
|
91
|
+
deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
|
|
92
92
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
93
93
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
94
94
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
@@ -145,11 +145,12 @@ Remove
|
|
|
145
145
|
--dir and --worker-dir are the Worker project directory (default <config dir>/worker)
|
|
146
146
|
|
|
147
147
|
Status
|
|
148
|
-
status [--json] deployment, base URL, agent card check (fetched by the CLI
|
|
148
|
+
status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
|
|
149
149
|
mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
|
|
150
150
|
upstream card, Access credential and upstream bearer configured (separately), the
|
|
151
151
|
live upstream check, the last failed peer call, card-leak check; warnings for peer
|
|
152
152
|
token variables shadowing saved tokens; and the next step to run.
|
|
153
|
+
Older Workers need deploy (exit 1); npm latest hints use the local update cache.
|
|
153
154
|
When GET /owner/mcp reports enabled and the agent card is reachable, prints
|
|
154
155
|
\`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
|
|
155
156
|
A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
|
package/lib/status.mjs
CHANGED
|
@@ -8,6 +8,17 @@ import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJ
|
|
|
8
8
|
import { baseUrl, owner, ownerTry } from "./commands.mjs";
|
|
9
9
|
import * as D from "./deploy.mjs";
|
|
10
10
|
import * as T from "./tunnel.mjs";
|
|
11
|
+
import { cacheFile, readCache, newer } from "./update.mjs";
|
|
12
|
+
|
|
13
|
+
/** Public health probe. Only a valid health answer establishes missing version reporting. */
|
|
14
|
+
export async function checkHealth(base) {
|
|
15
|
+
try {
|
|
16
|
+
const { status, data } = await httpJson(`${base}/health`, { timeout: 15000 });
|
|
17
|
+
if (status === 200 && data?.ok === true)
|
|
18
|
+
return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null };
|
|
19
|
+
} catch { /* unreachable: version stays unknown */ }
|
|
20
|
+
return { ok: false, version: null };
|
|
21
|
+
}
|
|
11
22
|
|
|
12
23
|
/** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
|
|
13
24
|
export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
|
|
@@ -111,6 +122,9 @@ export function secretsPropagating(tunnel, now = Date.now()) {
|
|
|
111
122
|
* as separate lines (so the next step stays one short instruction). */
|
|
112
123
|
export function nextStep(s) {
|
|
113
124
|
const n = nextStepCore(s);
|
|
125
|
+
const version = s.cliVersion || D.VERSION;
|
|
126
|
+
if (s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
|
|
127
|
+
n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
|
|
114
128
|
const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
|
|
115
129
|
return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
|
|
116
130
|
}
|
|
@@ -133,6 +147,11 @@ function nextStepCore(s) {
|
|
|
133
147
|
return fail(/HTTP 401/.test(s.ownerApi.error)
|
|
134
148
|
? `the owner token in ${s.configFile} does not match the Worker: run \`${CLI} init --rotate-owner-token\` from the machine that owns this deployment`
|
|
135
149
|
: `the owner API failed (${s.ownerApi.error}): run \`${CLI} deploy\``);
|
|
150
|
+
const version = s.cliVersion || D.VERSION;
|
|
151
|
+
if (s.workerVersion && newer(version, s.workerVersion))
|
|
152
|
+
return fail(`the deployed Worker is ${s.workerVersion}, older than this CLI (${version}): run \`${CLI} deploy\``);
|
|
153
|
+
if (s.health?.ok && s.health.version === null)
|
|
154
|
+
return fail(`the deployed Worker predates version reporting (older than ${version}): run \`${CLI} deploy\``);
|
|
136
155
|
// proxy / expose mode: the upstream answers peers, so wakes are optional (pairing notifications only)
|
|
137
156
|
const fc = s.facade;
|
|
138
157
|
if (fc) {
|
|
@@ -220,12 +239,14 @@ export async function collect() {
|
|
|
220
239
|
upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
|
|
221
240
|
upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
|
|
222
241
|
peerTokenShadowed: shadowedPeerTokens(),
|
|
223
|
-
mcp: "unknown",
|
|
242
|
+
mcp: "unknown", health: null, workerVersion: null, cliVersion: D.VERSION, latestVersion: readCache(cacheFile())?.latest || null,
|
|
224
243
|
};
|
|
225
244
|
const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
|
|
226
245
|
if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
|
|
227
246
|
if (!s.deployed || !s.baseUrl) return s;
|
|
228
247
|
s.card = await checkCard(s.baseUrl);
|
|
248
|
+
s.health = await checkHealth(s.baseUrl);
|
|
249
|
+
s.workerVersion = s.health.version;
|
|
229
250
|
if (s.hasOwnerToken) {
|
|
230
251
|
try {
|
|
231
252
|
const p = await owner("GET", "/owner/wake/preview");
|
|
@@ -335,6 +356,7 @@ export async function status(o) {
|
|
|
335
356
|
if (s.deployed && s.baseUrl) {
|
|
336
357
|
rows.push(["agent card", !s.card.ok ? `FAILED: ${s.card.error}` : s.card.urlProblem ? `WRONG URL: "${s.card.name}", but ${s.card.urlProblem}`
|
|
337
358
|
: `OK: "${s.card.name}" (A2A ${s.card.versions.join(", ") || "?"})`]);
|
|
359
|
+
rows.push(["worker version", `${s.workerVersion || "unknown"} (CLI ${s.cliVersion})`]);
|
|
338
360
|
rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
|
|
339
361
|
if (s.upstream) {
|
|
340
362
|
const f = s.facade || {};
|
package/lib/update.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function notice(current, cache) {
|
|
|
36
36
|
|
|
37
37
|
export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
|
|
38
38
|
|
|
39
|
-
function readCache(file) {
|
|
39
|
+
export function readCache(file) {
|
|
40
40
|
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
41
41
|
}
|
|
42
42
|
|
package/package.json
CHANGED
package/worker/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Worker serves a rewritten agent card and device-flow pairing, and forwards authenticated JSON-RPC to the upstream.
|
|
5
5
|
// Handlers take a TenantContext (tenancy.ts) built from the Worker bindings. With the hosted gates unset, that
|
|
6
6
|
// context is the single self-host tenant and behaviour matches a Worker that read env.* directly.
|
|
7
|
+
import { VERSION } from "./version.ts";
|
|
7
8
|
import * as A from "./a2a.ts";
|
|
8
9
|
import * as F from "./facade.ts";
|
|
9
10
|
import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
@@ -1338,22 +1339,31 @@ function changed(res: { meta?: { changes?: number } } | null | undefined): numbe
|
|
|
1338
1339
|
return Number(res?.meta?.changes ?? 0);
|
|
1339
1340
|
}
|
|
1340
1341
|
|
|
1342
|
+
/** Outbound rows whose peer label is still active. A row with no inbound label stays eligible, because send does not require one. */
|
|
1343
|
+
const LIVE_OUTBOUND = "SELECT o.* FROM outbound o LEFT JOIN peers p ON p.label = o.peer WHERE p.revoked_at IS NULL";
|
|
1344
|
+
|
|
1341
1345
|
/** The outbound row this push may update. A callback that arrives before SendMessage returns matches the
|
|
1342
1346
|
* pending row by token hash and claims the peer's task id. A row already stored under that id must
|
|
1343
|
-
* present the same hash. */
|
|
1347
|
+
* present the same hash. A revoked peer matches neither, so the handler returns 401 and stores nothing. */
|
|
1344
1348
|
async function outboundForPush(ctx: TenantContext, taskId: string, givenHash: string): Promise<Json | null> {
|
|
1345
1349
|
if (!givenHash) return null;
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
+
const same = (row: Json | null) => row?.push_token_hash && A.timingSafeEqualStr(row.push_token_hash, givenHash) ? row : null;
|
|
1351
|
+
const rec: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
|
|
1352
|
+
if (rec) return same(rec);
|
|
1353
|
+
const pending: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.push_token_hash = ? AND o.task_id LIKE 'pend.%'`).bind(givenHash).first();
|
|
1354
|
+
if (!same(pending)) return null;
|
|
1350
1355
|
try {
|
|
1351
|
-
const renamed = await ctx.DB.prepare(
|
|
1352
|
-
|
|
1353
|
-
|
|
1356
|
+
const renamed = await ctx.DB.prepare(
|
|
1357
|
+
`UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?
|
|
1358
|
+
AND NOT EXISTS (SELECT 1 FROM peers WHERE label = outbound.peer AND revoked_at IS NOT NULL)`,
|
|
1359
|
+
).bind(taskId, pending.task_id, givenHash).run();
|
|
1360
|
+
if (changed(renamed) === 1) {
|
|
1361
|
+
const claimed: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
|
|
1362
|
+
return same(claimed);
|
|
1363
|
+
}
|
|
1354
1364
|
} catch { /* the send path already stored this task id */ }
|
|
1355
|
-
const now: Json = await ctx.DB.prepare(
|
|
1356
|
-
return
|
|
1365
|
+
const now: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
|
|
1366
|
+
return same(now);
|
|
1357
1367
|
}
|
|
1358
1368
|
|
|
1359
1369
|
/** Move a pending push row onto the peer's task id without dropping an update that already arrived. */
|
|
@@ -1690,8 +1700,17 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1690
1700
|
}
|
|
1691
1701
|
if (m === "DELETE" && seg[1]) {
|
|
1692
1702
|
const label = decodeURIComponent(seg[1]);
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1703
|
+
const now = A.nowIso();
|
|
1704
|
+
// Drop push hashes only while this label is still active, in the same transaction as the revoke.
|
|
1705
|
+
// An unknown or already-revoked label leaves every outbound row alone, including an alias of the same name.
|
|
1706
|
+
const [, revoked] = await ctx.DB.batch([
|
|
1707
|
+
ctx.DB.prepare(
|
|
1708
|
+
`UPDATE outbound SET push_token_hash = NULL
|
|
1709
|
+
WHERE peer = ? AND EXISTS (SELECT 1 FROM peers WHERE label = ? AND revoked_at IS NULL)`,
|
|
1710
|
+
).bind(label, label),
|
|
1711
|
+
ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(now, label),
|
|
1712
|
+
]);
|
|
1713
|
+
if (changed(revoked) !== 1) {
|
|
1695
1714
|
const ex: Json = await ctx.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
|
|
1696
1715
|
return json({ revoked: false, error: ex ? `the token with label ${label} was already revoked (${ex.revoked_at})` : `no active token with label ${label} (see token list)` }, 404);
|
|
1697
1716
|
}
|
|
@@ -3404,7 +3423,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
3404
3423
|
// legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
|
|
3405
3424
|
if (req.method === "GET" && path === "/.well-known/agent.json")
|
|
3406
3425
|
return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
|
|
3407
|
-
if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
|
|
3426
|
+
if (req.method === "GET" && path === "/health") return json({ ok: true, version: VERSION, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
|
|
3408
3427
|
if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this A2A endpoint (MCP=off)" }, 404);
|
|
3409
3428
|
if (mcpOn(ctx)) {
|
|
3410
3429
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
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.22";
|
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
10
|
+
import { VERSION } from "../src/version.ts";
|
|
10
11
|
import worker from "../src/index.ts";
|
|
11
12
|
import { sha256 } from "../src/a2a.ts";
|
|
12
13
|
import { sealWakeSecrets } from "../src/wake-secrets.ts";
|
|
@@ -81,6 +82,7 @@ test("gates unset: the hosted entry matches the self-host handler, including TEN
|
|
|
81
82
|
const base = "https://agent.example.com";
|
|
82
83
|
const nodeEnv = { DB: d1(MIGRATIONS), PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" };
|
|
83
84
|
const nodeHealth = await text(await worker.fetch(new Request(base + "/health"), nodeEnv as never, ectx as never));
|
|
85
|
+
assert.deepEqual(JSON.parse(nodeHealth), { ok: true, version: VERSION, publicUrl: base, mode: "inbox", mcp: true });
|
|
84
86
|
const nodeDenied = await text(await worker.fetch(new Request(base + "/", {
|
|
85
87
|
method: "POST", headers: { "content-type": "application/json" },
|
|
86
88
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
|
|
@@ -180,6 +182,9 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
|
|
|
180
182
|
assert.equal(bobPush.result.applied, true, JSON.stringify(bobPush.result));
|
|
181
183
|
await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
|
|
182
184
|
|
|
185
|
+
assert.deepEqual(await (await mf.dispatchFetch("https://alice.example.com/health")).json(),
|
|
186
|
+
{ ok: true, version: VERSION, publicUrl: "https://alice.example.com", mode: "inbox", mcp: true });
|
|
187
|
+
|
|
183
188
|
const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
|
|
184
189
|
const aliceCard = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json");
|
|
185
190
|
assert.equal(aliceCard.headers.get("cache-control"), "public, max-age=60");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// A connector send attaches a push config only when the peer card advertises it, and stores the token
|
|
3
3
|
// hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
|
|
4
4
|
// snapshot that response returned, on every storage backend this file runs against.
|
|
5
|
+
// Revoking the peer label clears that peer's push hashes. A later push with the old token is 401.
|
|
5
6
|
import { test } from "node:test";
|
|
6
7
|
import assert from "node:assert/strict";
|
|
7
8
|
import { sha256 } from "../src/a2a.ts";
|
|
@@ -340,3 +341,192 @@ test("message and artifactUpdate pushes supply inbox text", async () => {
|
|
|
340
341
|
assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
|
|
341
342
|
}
|
|
342
343
|
});
|
|
344
|
+
|
|
345
|
+
type OutSnap = {
|
|
346
|
+
task_id?: string;
|
|
347
|
+
context_id?: string;
|
|
348
|
+
peer?: string;
|
|
349
|
+
task_json?: string | null;
|
|
350
|
+
updates_json?: string;
|
|
351
|
+
updates_seen?: number;
|
|
352
|
+
hash_cleared?: number;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
test("revoking a peer rejects its old push token on the self-hosted and hosted paths", async (t) => {
|
|
356
|
+
const logs: string[] = [];
|
|
357
|
+
const origLog = console.log;
|
|
358
|
+
console.log = (...args: unknown[]) => {
|
|
359
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
360
|
+
origLog(...args);
|
|
361
|
+
};
|
|
362
|
+
const pushByHost = new Map<string, string>();
|
|
363
|
+
const origFetch = globalThis.fetch;
|
|
364
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
365
|
+
const url = new URL(reqUrl(input));
|
|
366
|
+
if (init?.method !== "POST") {
|
|
367
|
+
return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
|
|
368
|
+
}
|
|
369
|
+
const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
|
|
370
|
+
const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
|
|
371
|
+
pushByHost.set(url.host, token);
|
|
372
|
+
const taskId = url.host.startsWith("bea.") ? "task-bea" : "task-ada";
|
|
373
|
+
const contextId = url.host.startsWith("bea.") ? "ctx-bea" : "ctx-ada";
|
|
374
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
|
|
375
|
+
}) as typeof fetch;
|
|
376
|
+
t.after(() => {
|
|
377
|
+
globalThis.fetch = origFetch;
|
|
378
|
+
console.log = origLog;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const secrets: string[] = [];
|
|
382
|
+
const outputs: string[] = [];
|
|
383
|
+
const hides = (blob: string, where: string) => {
|
|
384
|
+
for (const secret of secrets) assert.equal(secret.length > 0 && blob.includes(secret), false, `${where} contains a token`);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
for (const mode of ["single", "host"] as const) {
|
|
388
|
+
pushByHost.clear();
|
|
389
|
+
const db = d1(MIGRATIONS);
|
|
390
|
+
const publicUrl = "https://owner.example.net";
|
|
391
|
+
const ctx = mode === "single"
|
|
392
|
+
? resolveTenant({ DB: db, PUBLIC_URL: publicUrl, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings)
|
|
393
|
+
: hostedTenantContext({
|
|
394
|
+
DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: publicUrl, OWNER_TOKEN: "platform-owner",
|
|
395
|
+
} as WorkerBindings, {
|
|
396
|
+
id: "ten_revoke_push", db, publicUrl, ownerTokenHash: await sha256(OWNER), status: "active", config: { AGENT_NAME: "Owner" },
|
|
397
|
+
});
|
|
398
|
+
const http = (method: string, path: string, body?: unknown) => owner(ctx, method, path, body);
|
|
399
|
+
const call = async (method: string, path: string, body?: unknown) => {
|
|
400
|
+
if (mode === "single") return http(method, path, body);
|
|
401
|
+
const res = await runOwnerRpc(ctx, ectx as never, {
|
|
402
|
+
accountId: "acct_owner", method, path, ...(body === undefined ? {} : { body }),
|
|
403
|
+
});
|
|
404
|
+
const text = JSON.stringify(res.body);
|
|
405
|
+
return { status: res.status, data: res.body, text };
|
|
406
|
+
};
|
|
407
|
+
const note = (res: { text: string }) => { outputs.push(res.text); return res; };
|
|
408
|
+
|
|
409
|
+
const issue = async (label: string) => {
|
|
410
|
+
const issued = await http("POST", "/owner/peers", { label });
|
|
411
|
+
assert.equal(issued.status, 200, issued.text);
|
|
412
|
+
const token = (issued.data as { token?: string }).token || "";
|
|
413
|
+
assert.match(token, /^a2aow_/);
|
|
414
|
+
secrets.push(token);
|
|
415
|
+
return token;
|
|
416
|
+
};
|
|
417
|
+
const beaBearer = await issue("bea");
|
|
418
|
+
const adaBearer = await issue("ada");
|
|
419
|
+
for (const [alias, bearer] of [["bea", beaBearer], ["ada", adaBearer]] as const) {
|
|
420
|
+
const synced = note(await call("PUT", `/owner/outbound-peers/${alias}`, { url: `https://${alias}.example.net`, token: bearer }));
|
|
421
|
+
assert.equal(synced.status, 200, synced.text);
|
|
422
|
+
assert.equal(JSON.stringify(synced.data).includes(bearer), false);
|
|
423
|
+
}
|
|
424
|
+
for (const alias of ["bea", "ada"] as const) {
|
|
425
|
+
const sent = note(await call("POST", "/owner/mcp/send", { to: alias, text: `hello ${alias}`, contextId: `ctx-${alias}` }));
|
|
426
|
+
assert.equal(sent.status, 200, sent.text);
|
|
427
|
+
assert.equal((sent.data as SendBody).isError, false, sent.text);
|
|
428
|
+
assert.match((sent.data as SendBody).text || "", new RegExp(`task task-${alias}`));
|
|
429
|
+
}
|
|
430
|
+
const beaPush = pushByHost.get("bea.example.net") || "";
|
|
431
|
+
const adaPush = pushByHost.get("ada.example.net") || "";
|
|
432
|
+
assert.equal(beaPush.length > 0 && adaPush.length > 0 && beaPush !== adaPush, true);
|
|
433
|
+
secrets.push(beaPush, adaPush);
|
|
434
|
+
const pendingToken = "pending-push-token";
|
|
435
|
+
secrets.push(pendingToken);
|
|
436
|
+
await db.prepare(
|
|
437
|
+
`INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
|
|
438
|
+
VALUES ('pend.revoked', 'ctx-bea', 'bea', 'https://bea.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', '[]', 0)`,
|
|
439
|
+
).bind(await sha256(pendingToken)).run();
|
|
440
|
+
|
|
441
|
+
const missed = note(await call("DELETE", "/owner/peers/nobody"));
|
|
442
|
+
assert.equal(missed.status, 404, missed.text);
|
|
443
|
+
const beforeRevoke = await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind("task-bea").first() as { cleared?: number };
|
|
444
|
+
assert.equal(Number(beforeRevoke.cleared), 0);
|
|
445
|
+
|
|
446
|
+
const revoked = note(await call("DELETE", "/owner/peers/bea"));
|
|
447
|
+
assert.equal(revoked.status, 200, revoked.text);
|
|
448
|
+
assert.deepEqual(revoked.data, { revoked: true, label: "bea" });
|
|
449
|
+
const again = note(await call("DELETE", "/owner/peers/bea"));
|
|
450
|
+
assert.equal(again.status, 404, again.text);
|
|
451
|
+
|
|
452
|
+
const cleared = async (taskId: string) => Number((await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind(taskId).first() as { cleared?: number }).cleared);
|
|
453
|
+
assert.equal(await cleared("task-bea"), 1);
|
|
454
|
+
assert.equal(await cleared("pend.revoked"), 1);
|
|
455
|
+
assert.equal(await cleared("task-ada"), 0);
|
|
456
|
+
|
|
457
|
+
const snap = async (taskId: string) => await db.prepare(
|
|
458
|
+
`SELECT task_id, context_id, peer, task_json, updates_json, updates_seen, push_token_hash IS NULL AS hash_cleared
|
|
459
|
+
FROM outbound WHERE task_id = ?`,
|
|
460
|
+
).bind(taskId).first() as OutSnap;
|
|
461
|
+
const histCount = async (contextId: string) => Number((await db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = ?").bind(contextId).first() as { n: number }).n);
|
|
462
|
+
const taskCount = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n);
|
|
463
|
+
const beaBefore = await snap("task-bea");
|
|
464
|
+
const beaHist = await histCount("ctx-bea");
|
|
465
|
+
const tasksBefore = await taskCount();
|
|
466
|
+
const push = async (token: string, taskId: string, text: string) => {
|
|
467
|
+
const res = await dispatch(new Request("https://owner.example.net/push", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "content-type": "application/json", "x-a2a-notification-token": token },
|
|
470
|
+
body: JSON.stringify({ task: { id: taskId, contextId: "ctx-bea", status: { state: "completed", message: { parts: [{ text }] } } } }),
|
|
471
|
+
}), ctx, ectx as never);
|
|
472
|
+
const body = await res.text();
|
|
473
|
+
outputs.push(body);
|
|
474
|
+
return { status: res.status, body };
|
|
475
|
+
};
|
|
476
|
+
const rejected = await push(beaPush, "task-bea", "revoked reply");
|
|
477
|
+
assert.equal(rejected.status, 401);
|
|
478
|
+
assert.equal(rejected.body, '{"error":"unauthorized"}');
|
|
479
|
+
assert.deepEqual(await snap("task-bea"), beaBefore);
|
|
480
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
481
|
+
assert.equal(await taskCount(), tasksBefore);
|
|
482
|
+
const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-bea").first() as { pending_json?: string | null } | null;
|
|
483
|
+
assert.equal(wake?.pending_json ?? null, null);
|
|
484
|
+
|
|
485
|
+
const pending = await push(pendingToken, "task-claimed", "pending reply");
|
|
486
|
+
assert.equal(pending.status, 401);
|
|
487
|
+
assert.equal(pending.body, '{"error":"unauthorized"}');
|
|
488
|
+
assert.equal(await cleared("pend.revoked"), 1);
|
|
489
|
+
const claimed = await db.prepare("SELECT task_id FROM outbound WHERE task_id = ?").bind("task-claimed").first();
|
|
490
|
+
assert.equal(claimed, null);
|
|
491
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
492
|
+
assert.deepEqual(await snap("task-bea"), beaBefore);
|
|
493
|
+
|
|
494
|
+
const standin = "restored-push-token";
|
|
495
|
+
secrets.push(standin);
|
|
496
|
+
await db.prepare("UPDATE outbound SET push_token_hash = ? WHERE task_id = ?").bind(await sha256(standin), "task-bea").run();
|
|
497
|
+
const restored = await push(standin, "task-bea", "restored reply");
|
|
498
|
+
assert.equal(restored.status, 401);
|
|
499
|
+
assert.equal(restored.body, '{"error":"unauthorized"}');
|
|
500
|
+
const afterRestore = await snap("task-bea");
|
|
501
|
+
assert.equal(Number(afterRestore.hash_cleared), 0);
|
|
502
|
+
assert.equal(afterRestore.task_json, beaBefore.task_json);
|
|
503
|
+
assert.equal(afterRestore.updates_json, beaBefore.updates_json);
|
|
504
|
+
assert.equal(afterRestore.updates_seen, beaBefore.updates_seen);
|
|
505
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
506
|
+
|
|
507
|
+
const accepted = await dispatch(new Request("https://owner.example.net/push", {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: { "content-type": "application/json", "x-a2a-notification-token": adaPush },
|
|
510
|
+
body: JSON.stringify({ task: { id: "task-ada", contextId: "ctx-ada", status: { state: "completed", message: { parts: [{ text: "ada reply" }] } } } }),
|
|
511
|
+
}), ctx, ectx as never);
|
|
512
|
+
const acceptedText = await accepted.text();
|
|
513
|
+
outputs.push(acceptedText);
|
|
514
|
+
assert.equal(accepted.status, 200, acceptedText);
|
|
515
|
+
const adaRow = await snap("task-ada");
|
|
516
|
+
assert.match(String(adaRow.updates_json), /ada reply/);
|
|
517
|
+
assert.equal(Number(adaRow.hash_cleared), 0);
|
|
518
|
+
const adaHist = note(await call("GET", "/owner/history/ctx-ada"));
|
|
519
|
+
assert.equal((adaHist.data as Hist[]).some((r) => r.event === "push" && r.text === "ada reply"), true, adaHist.text);
|
|
520
|
+
const beaHistory = note(await call("GET", "/owner/history/ctx-bea"));
|
|
521
|
+
assert.equal((beaHistory.data as Hist[]).some((r) => r.text === "revoked reply" || r.text === "pending reply" || r.text === "restored reply"), false, beaHistory.text);
|
|
522
|
+
assert.deepEqual(await snap("task-bea"), afterRestore);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
hides(outputs.join("\n"), "response");
|
|
526
|
+
hides(logs.join("\n"), "log");
|
|
527
|
+
for (const secret of secrets) {
|
|
528
|
+
const hash = await sha256(secret);
|
|
529
|
+
assert.equal(outputs.join("\n").includes(hash), false, "response contains a token hash");
|
|
530
|
+
assert.equal(logs.join("\n").includes(hash), false, "log contains a token hash");
|
|
531
|
+
}
|
|
532
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tenant context: gates default off, self-host owner auth and peer-token key stay compatible, hosted uses a hash
|
|
2
2
|
// and TENANT_SECRETS_KEY. Run: npm test
|
|
3
|
+
import { VERSION } from "../src/version.ts";
|
|
3
4
|
import { test } from "node:test";
|
|
4
5
|
import assert from "node:assert/strict";
|
|
5
6
|
import worker from "../src/index.ts";
|
|
@@ -258,7 +259,7 @@ test("fetch path: gates set or unset, the self-host Worker still answers from OW
|
|
|
258
259
|
const plain = await call(base, "/health");
|
|
259
260
|
const reserved = await call({ ...base, ...gates }, "/health");
|
|
260
261
|
const empty = await call({ ...base, TENANCY: "", QUOTAS: "", DATA_REGION: "", SIGNUP_URL: "" }, "/health");
|
|
261
|
-
assert.deepEqual(plain.data, { ok: true, publicUrl: BASE, mode: "inbox", mcp: true });
|
|
262
|
+
assert.deepEqual(plain.data, { ok: true, version: VERSION, publicUrl: BASE, mode: "inbox", mcp: true });
|
|
262
263
|
assert.deepEqual(reserved.data, plain.data);
|
|
263
264
|
assert.deepEqual(empty.data, plain.data);
|
|
264
265
|
|