a2a-exposed 0.0.0-stage → 0.4.1

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 CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
- "name": "a2a-exposed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "a2a-exposed",
3
+ "version": "0.4.1",
4
+ "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
+ "type": "module",
6
+ "bin": {
7
+ "a2a-exposed": "bin/a2a-exposed.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "worker/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "prepack": "node scripts/sync-worker.mjs && node -e \"require('fs').copyFileSync('../LICENSE','LICENSE')\"",
18
+ "test": "node --test test/*.test.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">=22.18"
22
+ },
23
+ "keywords": [
24
+ "a2a",
25
+ "agent2agent",
26
+ "ai-agent",
27
+ "webhook",
28
+ "cloudflare-workers",
29
+ "d1",
30
+ "claude-code",
31
+ "openclaw",
32
+ "hermes",
33
+ "cli"
34
+ ],
35
+ "homepage": "https://github.com/telegraphic-dev/a2a-exposed#readme",
36
+ "bugs": "https://github.com/telegraphic-dev/a2a-exposed/issues",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/telegraphic-dev/a2a-exposed.git",
40
+ "directory": "cli"
41
+ },
42
+ "license": "MIT",
43
+ "author": "Telegraphic Developer",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
@@ -0,0 +1,75 @@
1
+ // a2a-exposed Worker configuration. Everything deployment-specific comes from environment
2
+ // variables at deploy time (set by `npx a2a-exposed init|deploy`, or export them yourself;
3
+ // see deploy.env.example). Nothing account- or owner-specific is committed here.
4
+ import { bindings, defineConfig, triggers } from "cf/config";
5
+ import * as entrypoint from "./src/index.ts" with { type: "cf-worker" };
6
+
7
+ const e: Record<string, string | undefined> = (globalThis as any).process?.env ?? {};
8
+ const v = (k: string) => (e[k] ?? "").trim();
9
+
10
+ const name = v("A2A_WORKER_NAME") || "a2a-exposed";
11
+ const hostname = v("A2A_HOSTNAME"); // e.g. agent.example.com (on a zone in your account)
12
+ // No hostname: serve on <name>.<account subdomain>.workers.dev instead of a custom domain (and the other way round:
13
+ // with a hostname, the workers.dev route is switched off).
14
+ const workersDev = !hostname;
15
+ const wdSubdomain = v("A2A_WORKERS_DEV_SUBDOMAIN"); // the account's workers.dev subdomain, once known
16
+ // Public base URL for the agent card, derived only from where the Worker is served (no override: a local webhook,
17
+ // Tailnet or tunnel URL must never end up in the card). If still unknown, the Worker uses the request's origin.
18
+ const publicUrl = hostname ? `https://${hostname}` : wdSubdomain ? `https://${name}.${wdSubdomain}.workers.dev` : "";
19
+
20
+ // Plain (non-secret) vars exposed to the Worker; empty values are omitted.
21
+ const plain: Record<string, string> = {
22
+ PUBLIC_URL: publicUrl,
23
+ AGENT_NAME: v("A2A_AGENT_NAME"),
24
+ AGENT_DESCRIPTION: v("A2A_AGENT_DESCRIPTION"),
25
+ AGENT_VERSION: v("A2A_AGENT_VERSION"),
26
+ AGENT_SKILLS: v("A2A_AGENT_SKILLS"), // JSON array of A2A AgentSkill objects
27
+ PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
28
+ PROVIDER_URL: v("A2A_PROVIDER_URL"),
29
+ DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
30
+ WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
31
+ WAKE_AGENT_ID: v("WAKE_AGENT_ID"), // openclaw presets (default "main")
32
+ WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
33
+ WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
34
+ WAKE_BODY_TEMPLATE: v("WAKE_BODY_TEMPLATE"), // generic preset JSON template
35
+ WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx a2a-exposed")
36
+ WAKE_DEBOUNCE_SECONDS: v("WAKE_DEBOUNCE_SECONDS"),
37
+ WAKE_MAX_PER_HOUR: v("WAKE_MAX_PER_HOUR"),
38
+ MAX_BODY: v("A2A_MAX_BODY"),
39
+ RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
40
+ RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
41
+ PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
42
+ PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
43
+ // proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
44
+ // Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
45
+ UPSTREAM_URL: v("A2A_UPSTREAM_URL"),
46
+ UPSTREAM_CARD_URL: v("A2A_UPSTREAM_CARD_URL"), // default <upstream origin>/.well-known/agent-card.json
47
+ };
48
+ const envBindings: Record<string, any> = {
49
+ DB: bindings.d1(v("A2A_D1_ID") ? { name: v("A2A_D1_NAME") || name, id: v("A2A_D1_ID") } : { name: v("A2A_D1_NAME") || name }),
50
+ };
51
+ for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY_PREFIX" && "WAKE_KEY_PREFIX" in e)) envBindings[k] = bindings.text(val);
52
+
53
+ // Secrets are uploaded separately (never in this file): OWNER_TOKEN, WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, WAKE_HMAC_SECRET,
54
+ // and in proxy mode UPSTREAM_TOKEN, UPSTREAM_ACCESS_CLIENT_ID, UPSTREAM_ACCESS_CLIENT_SECRET.
55
+ // They are declared only in development mode (`cf dev`), so local dev reads them from .dev.vars while
56
+ // production deploys never fail because an optional wake secret is unset.
57
+ const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
58
+
59
+ export default defineConfig((ctx) => ({
60
+ ...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
61
+ worker: {
62
+ name,
63
+ compatibilityDate: "2026-10-06",
64
+ entrypoint,
65
+ ...(hostname ? { domains: [hostname] } : {}),
66
+ workersDev,
67
+ // Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
68
+ // invocation logs with CPU time. Query strings are redacted, so one-time setup links never reach the logs.
69
+ ...(v("A2A_WORKERS_LOGS") === "1" ? { observability: { enabled: true, redactQueryString: true, logs: { enabled: true, invocationLogs: true } } } : {}),
70
+ env: ctx.mode === "development" ? { ...envBindings, ...Object.fromEntries(SECRETS.map((k) => [k, bindings.secret()])) } : envBindings,
71
+ // Optional cron flush of debounced wakes (the Worker also flushes opportunistically on every request).
72
+ // Requires a workers.dev subdomain on the account (always the case for workers.dev deployments).
73
+ ...(v("A2A_ENABLE_CRON") === "1" ? { triggers: [triggers.scheduled({ schedule: "* * * * *" })] } : {}),
74
+ },
75
+ }));
@@ -0,0 +1,33 @@
1
+ # Deploy-time settings for a manual `cf deploy` (the CLI's `init`/`deploy` commands set these for you).
2
+ # Usage: cp deploy.env.example deploy.env && edit && set -a && . ./deploy.env && set +a && cf deploy
3
+ CLOUDFLARE_ACCOUNT_ID=
4
+ # Separate Cloudflare login? Add `--profile <name>` to the cf commands (CLI: --cf-profile <name>).
5
+ A2A_WORKER_NAME=a2a-exposed
6
+ A2A_HOSTNAME=agent.example.com # leave empty to serve on <worker>.<account-subdomain>.workers.dev
7
+ # A2A_RETIRED_HOSTNAMES= # old custom domains (comma-separated): card 301 -> PUBLIC_URL, else 410
8
+ # A2A_WORKERS_DEV_SUBDOMAIN= # the account's workers.dev subdomain, so PUBLIC_URL can be set at deploy time
9
+ A2A_D1_ID=
10
+ # A2A_D1_NAME=a2a-exposed # D1 database name (CLI: --d1-name; default: the worker name)
11
+ A2A_AGENT_NAME=My Agent
12
+ A2A_AGENT_DESCRIPTION=An AI agent reachable over A2A. Replies asynchronously.
13
+ # A2A_AGENT_SKILLS=[{"id":"general","name":"General requests","description":"...","tags":["assistant"]}]
14
+ # A2A_PROVIDER_ORGANIZATION=
15
+ # A2A_PROVIDER_URL=
16
+ WAKE_PRESET=generic
17
+ # WAKE_AGENT_ID=main
18
+ # WAKE_KEY_HEADER=authorization
19
+ # WAKE_KEY_PREFIX=Bearer
20
+ # WAKE_BODY_TEMPLATE={"text":"{{summary}}"}
21
+ # Command shown in wake hints (CLI: --cli-command; default "npx a2a-exposed"). Until the CLI is on npm:
22
+ # WAKE_CLI_COMMAND=node /path/to/a2a-exposed/cli/bin/a2a-exposed.mjs
23
+ # WAKE_DEBOUNCE_SECONDS=10
24
+ # WAKE_MAX_PER_HOUR=0
25
+ # A2A_ENABLE_CRON=1
26
+ # Proxy / expose mode (CLI: --upstream, --upstream-card-url): front an agent that already speaks A2A on a private
27
+ # network. The upstream must be reachable from Cloudflare: a Tunnel hostname behind Access, not a Tailnet/LAN URL.
28
+ # A2A_UPSTREAM_URL=https://agent-upstream.example.com/a2a
29
+ # A2A_UPSTREAM_CARD_URL=https://agent-upstream.example.com/.well-known/agent-card.json # same origin as A2A_UPSTREAM_URL (credentials ride with the fetch)
30
+ # Secrets (OWNER_TOKEN, WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, WAKE_HMAC_SECRET, and for a wake URL behind
31
+ # Cloudflare Access WAKE_ACCESS_CLIENT_ID / WAKE_ACCESS_CLIENT_SECRET; in proxy mode UPSTREAM_TOKEN,
32
+ # UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET) go in a separate chmod-600
33
+ # file passed with `cf deploy --secrets-file <file>`; never commit them.
@@ -0,0 +1,74 @@
1
+ -- a2a-over-webhook schema
2
+ CREATE TABLE IF NOT EXISTS peers (
3
+ label TEXT PRIMARY KEY,
4
+ token_hash TEXT NOT NULL UNIQUE,
5
+ created_at TEXT NOT NULL,
6
+ revoked_at TEXT
7
+ );
8
+ CREATE TABLE IF NOT EXISTS tasks (
9
+ id TEXT PRIMARY KEY,
10
+ context_id TEXT NOT NULL,
11
+ peer TEXT NOT NULL,
12
+ state TEXT NOT NULL,
13
+ protocol TEXT NOT NULL,
14
+ created_at TEXT NOT NULL,
15
+ updated_at TEXT NOT NULL,
16
+ status_json TEXT NOT NULL, -- TaskStatus (internal 0.3 shape)
17
+ artifacts_json TEXT NOT NULL DEFAULT '[]',
18
+ metadata_json TEXT,
19
+ push_configs_json TEXT NOT NULL DEFAULT '[]'
20
+ );
21
+ CREATE INDEX IF NOT EXISTS tasks_state ON tasks(state, updated_at);
22
+ CREATE INDEX IF NOT EXISTS tasks_ctx ON tasks(context_id);
23
+ CREATE INDEX IF NOT EXISTS tasks_peer ON tasks(peer, updated_at);
24
+ CREATE TABLE IF NOT EXISTS messages (
25
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ message_id TEXT NOT NULL,
27
+ task_id TEXT NOT NULL,
28
+ context_id TEXT NOT NULL,
29
+ role TEXT NOT NULL,
30
+ ts TEXT NOT NULL,
31
+ message_json TEXT NOT NULL
32
+ );
33
+ CREATE INDEX IF NOT EXISTS messages_task ON messages(task_id, seq);
34
+ CREATE TABLE IF NOT EXISTS history (
35
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ context_id TEXT NOT NULL,
37
+ ts TEXT NOT NULL,
38
+ dir TEXT NOT NULL, -- in | out | local
39
+ peer TEXT,
40
+ task_id TEXT,
41
+ role TEXT,
42
+ state TEXT,
43
+ event TEXT,
44
+ text TEXT,
45
+ data_json TEXT
46
+ );
47
+ CREATE INDEX IF NOT EXISTS history_ctx ON history(context_id, seq);
48
+ CREATE TABLE IF NOT EXISTS outbound (
49
+ task_id TEXT PRIMARY KEY,
50
+ context_id TEXT NOT NULL,
51
+ peer TEXT NOT NULL,
52
+ endpoint TEXT,
53
+ protocol TEXT,
54
+ push_token_hash TEXT,
55
+ sent_at TEXT NOT NULL,
56
+ task_json TEXT,
57
+ updates_json TEXT NOT NULL DEFAULT '[]'
58
+ );
59
+ CREATE TABLE IF NOT EXISTS wakes (
60
+ context_id TEXT PRIMARY KEY,
61
+ last_sent_ms INTEGER NOT NULL DEFAULT 0,
62
+ pending_json TEXT,
63
+ pending_since_ms INTEGER
64
+ );
65
+ CREATE TABLE IF NOT EXISTS rate (
66
+ peer TEXT NOT NULL,
67
+ minute INTEGER NOT NULL,
68
+ count INTEGER NOT NULL,
69
+ PRIMARY KEY (peer, minute)
70
+ );
71
+ CREATE TABLE IF NOT EXISTS wake_budget (
72
+ hour INTEGER PRIMARY KEY, -- unix hour
73
+ count INTEGER NOT NULL
74
+ );
@@ -0,0 +1,8 @@
1
+ -- wake_budget (hourly wake cap) for databases whose 0001_init.sql predates the table.
2
+ -- D1 records migrations by file name, so a database created by an earlier build with its own
3
+ -- 0001_init.sql (e.g. the s2a2a prototype) never gets wake_budget from the current 0001.
4
+ -- Idempotent: a no-op on databases created from the current 0001_init.sql.
5
+ CREATE TABLE IF NOT EXISTS wake_budget (
6
+ hour INTEGER PRIMARY KEY, -- unix hour
7
+ count INTEGER NOT NULL
8
+ );
@@ -0,0 +1,39 @@
1
+ -- Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628): a peer agent asks for a token, the inbox
2
+ -- owner approves it (human: /device page + approval password; agent: owner API), the peer redeems it once.
3
+ -- Numbered 0003 so it never collides with a 0002 migration from another branch; D1 applies unapplied files by name.
4
+ CREATE TABLE IF NOT EXISTS device_requests (
5
+ device_hash TEXT PRIMARY KEY, -- sha256(device_code); the device code itself is never stored
6
+ user_code TEXT NOT NULL UNIQUE, -- normalised (no dash), e.g. WDJB4827
7
+ client_id TEXT,
8
+ client_name TEXT,
9
+ agent_card_url TEXT,
10
+ ip TEXT,
11
+ country TEXT,
12
+ status TEXT NOT NULL, -- pending | approved | denied (| redeemed, just before the row is deleted)
13
+ created_ms INTEGER NOT NULL,
14
+ expires_ms INTEGER NOT NULL,
15
+ interval_s INTEGER NOT NULL,
16
+ last_poll_ms INTEGER NOT NULL DEFAULT 0,
17
+ failed_attempts INTEGER NOT NULL DEFAULT 0,
18
+ label TEXT, -- peer label chosen at approval
19
+ decided_ms INTEGER,
20
+ decided_by TEXT -- human | agent | owner | lockout
21
+ );
22
+ CREATE INDEX IF NOT EXISTS device_requests_status ON device_requests(status, expires_ms);
23
+ -- fixed-window counters: device requests per IP / globally, wrong approval passwords per IP / globally, token polls
24
+ CREATE TABLE IF NOT EXISTS pairing_rate (
25
+ key TEXT NOT NULL,
26
+ window_start INTEGER NOT NULL,
27
+ count INTEGER NOT NULL,
28
+ expires_ms INTEGER NOT NULL,
29
+ PRIMARY KEY (key, window_start)
30
+ );
31
+ -- owner settings; approval_password = {"alg":"pbkdf2-sha256","iterations":..,"salt":..,"hash":..,"setAt":..}
32
+ CREATE TABLE IF NOT EXISTS settings (
33
+ key TEXT PRIMARY KEY,
34
+ value TEXT NOT NULL
35
+ );
36
+ -- where a peer token came from: NULL = `token issue`, 'pairing' = device flow (with the user code it was approved under)
37
+ ALTER TABLE peers ADD COLUMN source TEXT;
38
+ ALTER TABLE peers ADD COLUMN user_code TEXT;
39
+ ALTER TABLE peers ADD COLUMN client_name TEXT;
@@ -0,0 +1,6 @@
1
+ -- Re-pairing: a requester that presents its current active token (Authorization: Bearer) replaces that token on
2
+ -- approval. The new token takes over the label, so no orphan token or "-2" label is left. replaces_hash binds the
3
+ -- request to the token that was presented: if the owner rotates or revokes that token before redemption, the swap
4
+ -- is refused and a fresh label is used instead (a holder of a compromised old token cannot overwrite a rotated one).
5
+ ALTER TABLE device_requests ADD COLUMN replaces_label TEXT;
6
+ ALTER TABLE device_requests ADD COLUMN replaces_hash TEXT;
@@ -0,0 +1,9 @@
1
+ -- Proxy / expose mode (UPSTREAM_URL): which peer label created each upstream task and context, so peers that share
2
+ -- the façade's one upstream credential can't read, cancel or continue each other's tasks. Unused in inbox mode.
3
+ CREATE TABLE IF NOT EXISTS facade_owners (
4
+ kind TEXT NOT NULL, -- 'task' | 'context'
5
+ id TEXT NOT NULL, -- the upstream's task / context id
6
+ peer TEXT NOT NULL, -- peer label (peers.label) that created it through the façade
7
+ created_at TEXT NOT NULL,
8
+ PRIMARY KEY (kind, id)
9
+ );
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "a2a-exposed-worker",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "Cloudflare Worker for a2a-exposed: public A2A endpoint, D1 inbox, wake webhooks, owner API",
6
+ "license": "MIT",
7
+ "scripts": {
8
+ "build": "cf build",
9
+ "deploy": "cf deploy",
10
+ "test": "node --test test/*.test.ts",
11
+ "typecheck": "cf workers types && tsc"
12
+ },
13
+ "engines": { "node": ">=22.18" },
14
+ "devDependencies": {
15
+ "@cloudflare/vite-plugin": "beta",
16
+ "cf": "^1.0.0-beta.13",
17
+ "typescript": "^7.0.2",
18
+ "vite": "^8.3.0"
19
+ }
20
+ }
@@ -0,0 +1,216 @@
1
+ // A2A data-model helpers. Internal storage uses the A2A 0.3 JSON shape
2
+ // (lowercase states, role user/agent, parts with "kind"); converters emit A2A 1.0.
3
+
4
+ export type Json = any;
5
+ export const TERMINAL = new Set(["completed", "failed", "canceled", "rejected"]);
6
+ export const OPEN = new Set(["submitted", "working"]);
7
+ export const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
8
+
9
+ export class InvalidParams extends Error {}
10
+
11
+ /** Response for a request on a hostname this agent moved away from (RETIRED_HOSTNAMES, comma-separated), else null.
12
+ * Custom domains are not detached by a deploy, so after `--workers-dev` (or a hostname change) the old hostname
13
+ * would keep serving: instead it redirects agent-card discovery (301) and answers everything else with 410 Gone. */
14
+ export function movedResponse(reqUrl: string, publicUrl: string, retiredCsv: string | undefined): { status: number; headers: Record<string, string>; body: string } | null {
15
+ const retired = (retiredCsv || "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
16
+ if (!retired.length) return null;
17
+ const u = new URL(reqUrl);
18
+ if (!retired.includes(u.hostname.toLowerCase())) return null;
19
+ const base = (publicUrl || "").replace(/\/$/, "");
20
+ if (base && new URL(base).hostname.toLowerCase() === u.hostname.toLowerCase()) return null; // never retire the live URL
21
+ const card = base ? `${base}/.well-known/agent-card.json` : "";
22
+ if (card && (u.pathname === "/.well-known/agent-card.json" || u.pathname === "/.well-known/agent.json"))
23
+ return { status: 301, headers: { location: card, "cache-control": "no-store" }, body: "" };
24
+ return {
25
+ status: 410, headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({ error: "moved", message: `this agent moved${base ? ` to ${base}` : ""}; update the URL you use for it`, ...(card ? { agentCard: card } : {}) }),
27
+ };
28
+ }
29
+
30
+ /** Hosts that only resolve on a private network (Tailnet, LAN, loopback): never a public agent URL. */
31
+ export function isPrivateHost(host: string): boolean {
32
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
33
+ if (!h.includes(".") && !h.includes(":")) return true; // single-label names (localhost, MagicDNS short names)
34
+ if (/(^|\.)(localhost|local|lan|home|internal|intranet|corp|home\.arpa|ts\.net)$/.test(h)) return true;
35
+ const m = h.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
36
+ if (m) {
37
+ const [a, b] = [Number(m[1]), Number(m[2])];
38
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
39
+ }
40
+ if (!h.includes(":")) return false;
41
+ if (h === "::" || h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h) || /^fec/.test(h)) return true; // unspecified, loopback, ULA, link-/site-local
42
+ // IPv4 embedded in IPv6 (mapped ::ffff:a.b.c.d, NAT64 64:ff9b::, deprecated compatible ::a.b.c.d): judge the IPv4.
43
+ // URL parsing normalizes the dotted tail to hex (::ffff:7f00:1), so both spellings are handled.
44
+ const e = h.match(/^(?:::ffff:|64:ff9b::|::)(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/);
45
+ if (e) {
46
+ const v4 = e[1] || [parseInt(e[2], 16) >> 8, parseInt(e[2], 16) & 255, parseInt(e[3], 16) >> 8, parseInt(e[3], 16) & 255].join(".");
47
+ return isPrivateHost(v4);
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /** The base URL the agent card advertises: the deployment's PUBLIC_URL (custom hostname or workers.dev, set at deploy
53
+ * time) when it is a public https origin, else the origin the request reached the Worker on. A local, Tailnet or
54
+ * plain-http URL is never advertised to peers. */
55
+ export function publicOrigin(configured: string | undefined, reqUrl: string): string {
56
+ try {
57
+ const u = new URL((configured || "").trim());
58
+ if (u.protocol === "https:" && !u.username && !u.password && !isPrivateHost(u.hostname)) return u.origin;
59
+ } catch { /* empty or not a URL */ }
60
+ return new URL(reqUrl).origin;
61
+ }
62
+
63
+ export const nowIso = () => new Date().toISOString();
64
+ export const newId = () => crypto.randomUUID();
65
+
66
+ export function checkId(v: unknown, what = "id"): string {
67
+ if (typeof v !== "string" || !ID_RE.test(v)) throw new InvalidParams(`invalid ${what}`);
68
+ return v;
69
+ }
70
+
71
+ const v1State = (s: string) => "TASK_STATE_" + s.toUpperCase().replace(/-/g, "_");
72
+ export const fromV1State = (s: string) =>
73
+ typeof s === "string" && s.startsWith("TASK_STATE_") ? s.slice(11).toLowerCase().replace(/_/g, "-") : s;
74
+ const normRole = (r: unknown) => (String(r || "").toLowerCase().includes("agent") ? "agent" : "user");
75
+
76
+ export function normPart(part: Json): Json {
77
+ if (!part || typeof part !== "object" || Array.isArray(part)) throw new InvalidParams("part must be an object");
78
+ const kind = part.kind;
79
+ let out: Json;
80
+ if (kind === "text" || (kind === undefined && "text" in part)) {
81
+ if (typeof part.text !== "string") throw new InvalidParams("text part needs string text");
82
+ out = { kind: "text", text: part.text };
83
+ } else if (kind === "data" || (kind === undefined && "data" in part)) {
84
+ out = { kind: "data", data: part.data };
85
+ } else if (kind === "file" || (kind === undefined && ("raw" in part || "url" in part))) {
86
+ const f: Json = { ...(part.file || {}) };
87
+ if ("raw" in part) f.bytes = part.raw;
88
+ if ("url" in part) f.uri = part.url;
89
+ if (part.filename) f.name = part.filename;
90
+ if (part.mediaType) f.mimeType = part.mediaType;
91
+ out = { kind: "file", file: f };
92
+ } else throw new InvalidParams("unsupported part type");
93
+ if (part.metadata !== undefined) out.metadata = part.metadata;
94
+ return out;
95
+ }
96
+
97
+ export function normMessage(msg: Json): Json {
98
+ if (!msg || typeof msg !== "object") throw new InvalidParams("message must be an object");
99
+ if (!Array.isArray(msg.parts) || msg.parts.length === 0) throw new InvalidParams("message.parts must be a non-empty array");
100
+ const out: Json = {
101
+ kind: "message",
102
+ role: normRole(msg.role),
103
+ messageId: typeof msg.messageId === "string" && msg.messageId ? msg.messageId.slice(0, 200) : newId(),
104
+ parts: msg.parts.slice(0, 100).map(normPart),
105
+ };
106
+ for (const k of ["contextId", "taskId", "metadata", "referenceTaskIds", "extensions"])
107
+ if (msg[k] !== undefined && msg[k] !== null) out[k] = msg[k];
108
+ return out;
109
+ }
110
+
111
+ function partToV1(p: Json): Json {
112
+ let out: Json;
113
+ if (p.kind === "text") out = { text: p.text };
114
+ else if (p.kind === "data") out = { data: p.data };
115
+ else {
116
+ const f = p.file || {};
117
+ out = {};
118
+ if ("bytes" in f) out.raw = f.bytes;
119
+ if ("uri" in f) out.url = f.uri;
120
+ if (f.name) out.filename = f.name;
121
+ if (f.mimeType) out.mediaType = f.mimeType;
122
+ }
123
+ if (p.metadata !== undefined) out.metadata = p.metadata;
124
+ return out;
125
+ }
126
+
127
+ export function messageToV1(m: Json): Json {
128
+ const { kind, parts, role, ...rest } = m;
129
+ return { ...rest, role: role === "agent" ? "ROLE_AGENT" : "ROLE_USER", parts: (parts || []).map(partToV1) };
130
+ }
131
+
132
+ /** Serialise an internal task for the given protocol version. */
133
+ export function publicTask(task: Json, version: string, historyLength?: number | null): Json {
134
+ const t: Json = { kind: "task", id: task.id, contextId: task.contextId, status: task.status, artifacts: task.artifacts || [] };
135
+ if (task.metadata) t.metadata = task.metadata;
136
+ let hist: Json[] = task.history || [];
137
+ if (historyLength !== undefined && historyLength !== null) hist = historyLength > 0 ? hist.slice(-historyLength) : [];
138
+ if (hist.length) t.history = hist;
139
+ if (version.startsWith("1")) {
140
+ delete t.kind;
141
+ const st: Json = { ...t.status, state: v1State(t.status.state) };
142
+ if (st.message) st.message = messageToV1(st.message);
143
+ t.status = st;
144
+ if (t.history) t.history = t.history.map(messageToV1);
145
+ t.artifacts = t.artifacts.map((a: Json) => ({ ...a, parts: a.parts.map(partToV1) }));
146
+ }
147
+ return t;
148
+ }
149
+
150
+ /** Peer task (0.3 or 1.0) -> internal-ish shape (used only to read state/text; store and show the wire shape). */
151
+ export function taskFromAny(t: Json): Json {
152
+ if (!t || typeof t !== "object") return t;
153
+ const out = { ...t };
154
+ if (out.status && typeof out.status === "object") {
155
+ const st = { ...out.status, state: fromV1State(out.status.state) };
156
+ if (st.message) try { st.message = normMessage(st.message); } catch { /* keep */ }
157
+ out.status = st;
158
+ }
159
+ return out;
160
+ }
161
+
162
+ export function textOf(m: Json): string {
163
+ const parts = Array.isArray(m) ? m : (m && m.parts) || [];
164
+ const out: string[] = [];
165
+ for (const x of parts) {
166
+ if (!x || typeof x !== "object") continue;
167
+ if ("text" in x) out.push(String(x.text));
168
+ else if ("data" in x) out.push(JSON.stringify(x.data));
169
+ else {
170
+ const f = x.file || {};
171
+ out.push(`[file ${f.name || x.filename || f.uri || x.url || ""}]`);
172
+ }
173
+ }
174
+ return out.join("\n");
175
+ }
176
+
177
+ export async function sha256(s: string): Promise<string> {
178
+ const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
179
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
180
+ }
181
+
182
+ /** First 12 hex chars of sha256(value), or null when unset: lets the owner compare secrets without revealing them. */
183
+ export async function fingerprint(v: string | undefined | null): Promise<string | null> {
184
+ return v ? (await sha256(v)).slice(0, 12) : null;
185
+ }
186
+
187
+ /** Peer token: "a2aow_" + 43 base64url chars (32 random bytes). Only the SHA-256 hash is stored and
188
+ * lookups are by hash, so tokens issued with the earlier "s2a_" prefix keep working. */
189
+ export const PEER_TOKEN_PREFIX = "a2aow_";
190
+
191
+ export function randomToken(prefix = PEER_TOKEN_PREFIX): string {
192
+ const b = new Uint8Array(32);
193
+ crypto.getRandomValues(b);
194
+ return prefix + btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
195
+ }
196
+
197
+ export function timingSafeEqualStr(a: string, b: string): boolean {
198
+ const ea = new TextEncoder().encode(a), eb = new TextEncoder().encode(b);
199
+ if (ea.length !== eb.length) return false;
200
+ let r = 0;
201
+ for (let i = 0; i < ea.length; i++) r |= ea[i] ^ eb[i];
202
+ return r === 0;
203
+ }
204
+
205
+ /** Push URLs must be https and not obvious private/loopback literals. */
206
+ export function pushUrlAllowed(url: string): [boolean, string] {
207
+ let u: URL;
208
+ try { u = new URL(url); } catch { return [false, "bad url"]; }
209
+ if (u.protocol !== "https:") return [false, "https required"];
210
+ const h = u.hostname.replace(/^\[|\]$/g, "");
211
+ if (/^(localhost|.*\.local|.*\.internal)$/i.test(h)) return [false, "private host not allowed"];
212
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(h) && /^(10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(h))
213
+ return [false, "private address not allowed"];
214
+ if (h.includes(":") && /^(::1?$|fc|fd|fe80|::ffff:)/i.test(h)) return [false, "private address not allowed"];
215
+ return [true, ""];
216
+ }