can2cup 0.10.2 → 0.10.4
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/README.md +25 -11
- package/SKILL.md +3 -0
- package/dist/cli/index.js +79 -2
- package/docs/SELF-HOST.md +121 -0
- package/package.json +7 -2
- package/src/protocol/canon.ts +17 -0
- package/src/protocol/crypto.ts +38 -0
- package/src/protocol/display.ts +11 -0
- package/src/protocol/e2e.ts +57 -0
- package/src/protocol/envelope.ts +127 -0
- package/src/protocol/index.ts +9 -0
- package/src/protocol/mandate.ts +61 -0
- package/src/protocol/principal.ts +90 -0
- package/src/protocol/release.ts +35 -0
- package/src/protocol/room.ts +115 -0
- package/src/protocol/semver.ts +14 -0
- package/src/relay/a2a.ts +212 -0
- package/src/relay/anchor.ts +159 -0
- package/src/relay/bridge.ts +1710 -0
- package/src/relay/index.ts +806 -0
- package/src/relay/join-page.ts +79 -0
- package/src/relay/mcp-http.ts +627 -0
- package/tsconfig.relay.json +12 -0
- package/wrangler.toml +106 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mandate rule set — ONE implementation for both enforcement points.
|
|
3
|
+
*
|
|
4
|
+
* The same brake guards two floors: the local client checks the principal's
|
|
5
|
+
* full mandate.json before anything is signed on their machine, and the hosted
|
|
6
|
+
* surface (relay/mcp-http.ts) checks the relay-held mandate for agents whose
|
|
7
|
+
* keys never leave the relay. A rule that exists on one floor and not the
|
|
8
|
+
* other is a hole, not a feature — so the rules live here, in protocol/, the
|
|
9
|
+
* layer both the Node client and the Worker may import. What stays with the
|
|
10
|
+
* callers, by design: pause state (different sources) and message framing
|
|
11
|
+
* (the hosted surface appends "NOT SENT." to blocked verdicts).
|
|
12
|
+
*/
|
|
13
|
+
import { canon } from "./canon.js";
|
|
14
|
+
import type { MsgType } from "./envelope.js";
|
|
15
|
+
|
|
16
|
+
/** The enforceable subset of a mandate. The local Mandate and the hosted
|
|
17
|
+
* mandate both satisfy it structurally; extra fields (may_share, brief…) are
|
|
18
|
+
* advisory and never enforced here. */
|
|
19
|
+
export interface MandateRules {
|
|
20
|
+
never_disclose: string[];
|
|
21
|
+
may_grant: string[];
|
|
22
|
+
max_commit_amount: number | null;
|
|
23
|
+
currency?: string;
|
|
24
|
+
max_grant_hours: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Glob-ish scope match: `*` matches any run of characters. Case-insensitive. */
|
|
28
|
+
export function scopeAllowed(scope: string, patterns: string[]): boolean {
|
|
29
|
+
const s = scope.trim().toLowerCase();
|
|
30
|
+
return patterns.some((p) => {
|
|
31
|
+
const re = new RegExp("^" + p.trim().toLowerCase().split("*").map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
|
|
32
|
+
return re.test(s);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Returns the reason a send must be blocked, or null when the mandate allows it.
|
|
37
|
+
* Checks the OUTBOUND body — for E2E rooms this must run on the plaintext,
|
|
38
|
+
* before encryption. */
|
|
39
|
+
export function checkMandate(m: MandateRules, type: MsgType, body: Record<string, unknown>): string | null {
|
|
40
|
+
const flat = canon(body).toLowerCase();
|
|
41
|
+
for (const s of m.never_disclose) {
|
|
42
|
+
if (s && flat.includes(s.toLowerCase())) return `blocked by mandate: outbound body contains a never_disclose string.`;
|
|
43
|
+
}
|
|
44
|
+
if (["proposal", "counter", "accept"].includes(type) && m.max_commit_amount != null && typeof body.amount === "number") {
|
|
45
|
+
if (body.amount > m.max_commit_amount) return `blocked by mandate: amount ${body.amount} exceeds max_commit_amount ${m.max_commit_amount}${m.currency ? " " + m.currency : ""}.`;
|
|
46
|
+
}
|
|
47
|
+
if (type === "grant") {
|
|
48
|
+
const scope = typeof body.scope === "string" ? body.scope : "";
|
|
49
|
+
if (!scope) return `grant needs a scope (e.g. "read:logs/*", "deploy:staging").`;
|
|
50
|
+
if (!scopeAllowed(scope, m.may_grant)) return `blocked by mandate: scope "${scope}" is not in may_grant ${JSON.stringify(m.may_grant)} — escalate to your principal instead.`;
|
|
51
|
+
const exp = Date.parse(String(body.expires ?? ""));
|
|
52
|
+
if (!Number.isFinite(exp)) return `grant needs an ISO expiry (use expiresHours).`;
|
|
53
|
+
const hours = (exp - Date.now()) / 3.6e6;
|
|
54
|
+
if (hours > m.max_grant_hours + 0.01) return `blocked by mandate: grant expiry ${hours.toFixed(1)}h exceeds max_grant_hours ${m.max_grant_hours}.`;
|
|
55
|
+
}
|
|
56
|
+
if (type === "revoke" && typeof body.ref !== "number") return `revoke needs ref = seq of the grant being revoked.`;
|
|
57
|
+
if (type === "attachment") {
|
|
58
|
+
if (typeof body.url !== "string" || !/^https?:\/\//.test(body.url)) return `attachment needs an https URL (the relay never stores bytes).`;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal-signed messages — the principal → agent channel, end to end.
|
|
3
|
+
*
|
|
4
|
+
* v0.2 and earlier: whatever the bridge put in an agent's inbox was shown to the model as
|
|
5
|
+
* "principal instructions", and the only thing standing between an attacker and that label
|
|
6
|
+
* was the bot's shared BRIDGE_KEY (2026-08-19 review, F1/F7). v0.3 gives the principal an
|
|
7
|
+
* ed25519 keypair of their own (`~/.parley/principal.json`). An instruction or a pause that
|
|
8
|
+
* carries a valid signature by THAT key — addressed to THIS agent, with a fresh nonce — is
|
|
9
|
+
* the only thing the MCP server will ever label as verified. The relay and the bot can still
|
|
10
|
+
* write unsigned items; those stay explicitly unverified.
|
|
11
|
+
*
|
|
12
|
+
* The signature covers `agent`, so a message signed for one agent cannot be replayed to
|
|
13
|
+
* another; `nonce` lets the receiver refuse the same message twice; `at` orders pauses.
|
|
14
|
+
* `approve` binds an approval to a concrete envelope (room, seq, hash), so an approval can
|
|
15
|
+
* never be re-aimed at a different decision than the one the principal looked at (F6).
|
|
16
|
+
*/
|
|
17
|
+
import { canon } from "./canon.js";
|
|
18
|
+
import { randomHex, signHex, verifyHex } from "./crypto.js";
|
|
19
|
+
|
|
20
|
+
export interface ApproveRef { room: string; seq: number; hash: string; ok: boolean }
|
|
21
|
+
|
|
22
|
+
export interface PrincipalMsg {
|
|
23
|
+
kind: "say" | "pause";
|
|
24
|
+
agent: string; // the agent's pubkey this is addressed to
|
|
25
|
+
at: string; // ISO-8601
|
|
26
|
+
nonce: string; // random hex; receivers keep a seen-set
|
|
27
|
+
text?: string; // say
|
|
28
|
+
paused?: boolean; // pause
|
|
29
|
+
approve?: ApproveRef; // say: this text is a decision on one specific envelope
|
|
30
|
+
}
|
|
31
|
+
export interface SignedPrincipalMsg extends PrincipalMsg { pub: string; sig: string }
|
|
32
|
+
|
|
33
|
+
export function principalSigningBytes(m: PrincipalMsg): string {
|
|
34
|
+
const { kind, agent, at, nonce, text, paused, approve } = m;
|
|
35
|
+
return canon({ kind, agent, at, nonce, text, paused, approve });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function signPrincipal(m: Omit<PrincipalMsg, "at" | "nonce"> & Partial<Pick<PrincipalMsg, "at" | "nonce">>, priv: string, pub: string): SignedPrincipalMsg {
|
|
39
|
+
const full: PrincipalMsg = { ...m, at: m.at ?? new Date().toISOString(), nonce: m.nonce ?? randomHex(16) };
|
|
40
|
+
return { ...full, pub, sig: signHex(principalSigningBytes(full), priv) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structural + cryptographic check. `expectedPub` = the principal key the verifier trusts
|
|
44
|
+
* (the agent pins its own principal.json pubkey; the bridge pins what the agent registered);
|
|
45
|
+
* `expectedAgent` = the verifier's own agent pubkey. */
|
|
46
|
+
export function verifyPrincipal(s: SignedPrincipalMsg, expectedPub: string, expectedAgent: string): { ok: boolean; error?: string } {
|
|
47
|
+
if (!s || typeof s !== "object") return { ok: false, error: "not a message" };
|
|
48
|
+
if (s.kind !== "say" && s.kind !== "pause") return { ok: false, error: "bad kind" };
|
|
49
|
+
if (!/^[0-9a-f]{64}$/.test(s.pub ?? "")) return { ok: false, error: "bad pub" };
|
|
50
|
+
if (s.pub !== expectedPub) return { ok: false, error: "signed by a key that is not your principal's" };
|
|
51
|
+
if (s.agent !== expectedAgent) return { ok: false, error: "addressed to a different agent" };
|
|
52
|
+
if (typeof s.nonce !== "string" || s.nonce.length < 16) return { ok: false, error: "bad nonce" };
|
|
53
|
+
if (!Number.isFinite(Date.parse(s.at ?? ""))) return { ok: false, error: "bad timestamp" };
|
|
54
|
+
if (s.kind === "say" && typeof s.text !== "string") return { ok: false, error: "say without text" };
|
|
55
|
+
if (s.kind === "pause" && typeof s.paused !== "boolean") return { ok: false, error: "pause without paused flag" };
|
|
56
|
+
if (!verifyHex(s.sig, principalSigningBytes(s), s.pub)) return { ok: false, error: "bad principal signature" };
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ------------------------------------------------ signed HTTP requests ---
|
|
61
|
+
|
|
62
|
+
/** Requests that must prove possession of a key (agent → bridge, agent → room admin ops,
|
|
63
|
+
* principal → bridge) carry three headers and sign "METHOD\nPATH\nTS\nBODY". The relay
|
|
64
|
+
* checks the timestamp is within ±5 minutes. Same scheme for every key role, so one helper. */
|
|
65
|
+
export const REQ_SIG_SKEW_MS = 5 * 60 * 1000;
|
|
66
|
+
|
|
67
|
+
export function requestSigningBytes(method: string, path: string, ts: string, body: string): string {
|
|
68
|
+
return `${method}\n${path}\n${ts}\n${body}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function signRequestHeaders(method: string, path: string, body: string, key: { pub: string; priv: string }, prefix = "x-parley"): Record<string, string> {
|
|
72
|
+
const ts = new Date().toISOString();
|
|
73
|
+
return {
|
|
74
|
+
[`${prefix}-pub`]: key.pub,
|
|
75
|
+
[`${prefix}-ts`]: ts,
|
|
76
|
+
[`${prefix}-sig`]: signHex(requestSigningBytes(method, path, ts, body), key.priv),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function verifyRequestHeaders(
|
|
81
|
+
h: (name: string) => string | undefined, method: string, path: string, body: string, prefix = "x-parley", now = Date.now(),
|
|
82
|
+
): { ok: true; pub: string } | { ok: false; error: string } {
|
|
83
|
+
const pub = h(`${prefix}-pub`) ?? "";
|
|
84
|
+
const ts = h(`${prefix}-ts`) ?? "";
|
|
85
|
+
const sig = h(`${prefix}-sig`) ?? "";
|
|
86
|
+
if (!/^[0-9a-f]{64}$/.test(pub) || !ts || !sig) return { ok: false, error: "missing signature headers" };
|
|
87
|
+
if (Math.abs(now - Date.parse(ts)) > REQ_SIG_SKEW_MS) return { ok: false, error: "signature timestamp too old" };
|
|
88
|
+
if (!verifyHex(sig, requestSigningBytes(method, path, ts, body), pub)) return { ok: false, error: "bad signature" };
|
|
89
|
+
return { ok: true, pub };
|
|
90
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// v0.10.0 (security G-3, P2): release signing.
|
|
2
|
+
//
|
|
3
|
+
// The relay serves the install tarball; the maintainer SIGNS it. These are different people in the threat model
|
|
4
|
+
// (or the same person on different days), so the key that vouches for a release must not live on the relay.
|
|
5
|
+
// The private half stays offline on the maintainer's machine (scripts/release-key.mjs); this file carries the
|
|
6
|
+
// public half, compiled into every client. A client trusts a release only if manifest.sig verifies against one
|
|
7
|
+
// of these keys. Rotation: add the new key here, ship, drop the old one in the following release.
|
|
8
|
+
import { canon } from "./canon.js";
|
|
9
|
+
import { verifyHex } from "./crypto.js";
|
|
10
|
+
|
|
11
|
+
export const RELEASE_PUBS: string[] = [
|
|
12
|
+
"7a025a45418ea95d0aaf6738749cf72d40207965610d1a31cb3db6233f50d571", // 2026-09-05, the maintainer's home machine
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export interface ReleaseManifest {
|
|
16
|
+
v: number;
|
|
17
|
+
version: string;
|
|
18
|
+
date: string;
|
|
19
|
+
/** file name → sha256 hex; every alias the relay serves maps to the same hash */
|
|
20
|
+
files: Record<string, string>;
|
|
21
|
+
changelogSha256?: string;
|
|
22
|
+
permissionChange?: boolean;
|
|
23
|
+
dataFlowChange?: boolean;
|
|
24
|
+
minClient?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Signature over canon(manifest) — the same canonical JSON every envelope uses; no new format. */
|
|
28
|
+
export function verifyManifest(m: unknown, sigHex: string, pubs: string[]): { ok: true; pub: string } | { ok: false; reason: string } {
|
|
29
|
+
const x = m as Partial<ReleaseManifest> | null;
|
|
30
|
+
if (!x || typeof x !== "object" || x.v !== 1 || typeof x.version !== "string" || !x.files || typeof x.files !== "object") return { ok: false, reason: "release manifest is malformed" };
|
|
31
|
+
if (!/^[0-9a-f]{128}$/.test(sigHex)) return { ok: false, reason: "release manifest signature is malformed" };
|
|
32
|
+
const msg = canon(x);
|
|
33
|
+
for (const pub of pubs) if (verifyHex(sigHex, msg, pub)) return { ok: true, pub };
|
|
34
|
+
return { ok: false, reason: `release manifest signature does not verify against any release key this client trusts (${pubs.map((p) => p.slice(0, 8) + "…").join(", ")}) — release key not trusted by this client, or the manifest was altered` };
|
|
35
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Shared room shapes and the invite encodings. */
|
|
2
|
+
export interface RoomPolicy {
|
|
3
|
+
maxMessages: number; // hard cap per room; hitting it forces a close
|
|
4
|
+
ttlSec: number; // relay refuses appends after createdAt + ttl
|
|
5
|
+
}
|
|
6
|
+
export const DEFAULT_POLICY: RoomPolicy = { maxMessages: 200, ttlSec: 6 * 3600 };
|
|
7
|
+
|
|
8
|
+
export interface Participant { name: string; joinedAt: string; removed?: string /* ISO, set by eject */ }
|
|
9
|
+
|
|
10
|
+
export interface RoomInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
policy: RoomPolicy;
|
|
14
|
+
participants: Record<string, Participant>; // pubkey -> participant (ejected ones stay listed, with `removed`)
|
|
15
|
+
createdAt: string;
|
|
16
|
+
createdBy: string;
|
|
17
|
+
state: "open" | "closed";
|
|
18
|
+
lastSeq: number;
|
|
19
|
+
lastHash: string;
|
|
20
|
+
relayPub?: string; // the relay's signing pubkey (v0.3+); clients pin it per room
|
|
21
|
+
secret?: string; // the CURRENT invite secret — only returned to a caller authenticated by their own cap
|
|
22
|
+
e2e?: boolean; // v0.5.0: bodies are ciphertext; the key lives in the invite fragment, never here
|
|
23
|
+
// Portable rooms / mirrors (v0.4.15+):
|
|
24
|
+
pastRelayPubs?: string[]; // relay keys this room lived under before it was imported here
|
|
25
|
+
role?: "mirror"; // absent = primary (writable); "mirror" = read-only replica fed by /replicate
|
|
26
|
+
origin?: string; // mirror only: the primary relay this replica follows
|
|
27
|
+
mirrors?: string[]; // primary only: relays every append is replicated to
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What GET /rooms/:id/export returns and POST /rooms/:id/import accepts. The chain is
|
|
31
|
+
* re-verified on import — an export is claimed evidence, never trusted evidence. */
|
|
32
|
+
export interface RoomExport {
|
|
33
|
+
format: "parley-export-1";
|
|
34
|
+
exportedAt: string;
|
|
35
|
+
room: {
|
|
36
|
+
id: string; name: string; policy: RoomPolicy; participants: Record<string, Participant>;
|
|
37
|
+
createdAt: string; createdBy: string; state: "open" | "closed"; e2e?: boolean;
|
|
38
|
+
};
|
|
39
|
+
secret?: string; // only present for a cap-authenticated exporter (same rule as info)
|
|
40
|
+
messages: unknown[]; // Envelope[]; typed loosely so the importer must verify, not assume
|
|
41
|
+
relayPub?: string; // the exporting relay's signing key — system events verify against it
|
|
42
|
+
head?: unknown; // the exporting relay's signed head over the tail
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Invite = everything the other side needs: relay (u), room id (r), room secret (s), name (n).
|
|
47
|
+
*
|
|
48
|
+
* Two encodings of the same thing:
|
|
49
|
+
* URL https://<relay>/j/<room>?n=<name>#<secret> ← the one to hand to humans
|
|
50
|
+
* token parley1.<base64url json> ← compact, for logs / agent-only paths
|
|
51
|
+
*
|
|
52
|
+
* The URL is the primary form: a human can click it (the relay serves a landing page
|
|
53
|
+
* that explains what to paste to their agent), a phone can scan it as a QR code, and
|
|
54
|
+
* an agent can paste it straight into can2cup_join. The secret rides in the fragment,
|
|
55
|
+
* so it never appears in the relay's request logs. Whoever holds either form can read
|
|
56
|
+
* and post in the room — treat an invite like a Telegram join link, not like a URL.
|
|
57
|
+
*/
|
|
58
|
+
/** k (v0.5.0): the E2E room key. It rides in the URL fragment after the secret
|
|
59
|
+
* (`#<secret>.<key>`), so like the secret it never reaches any server. */
|
|
60
|
+
export interface Invite { u: string; r: string; s: string; n?: string; p?: string /* relay signing pubkey, vouched by the inviter */; k?: string }
|
|
61
|
+
|
|
62
|
+
const PREFIX = "parley1.";
|
|
63
|
+
const TOKEN_RE = /parley1.[A-Za-z0-9_-]{16,}/;
|
|
64
|
+
const URL_RE = /https?:\/\/[^\s"'<>]+\/j\/[0-9a-f]{12}[^\s"'<>]*/;
|
|
65
|
+
|
|
66
|
+
export function encodeInvite(i: Invite): string {
|
|
67
|
+
return PREFIX + b64url(JSON.stringify(i));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function encodeInviteUrl(i: Invite): string {
|
|
71
|
+
const base = i.u.replace(/\/+$/, "");
|
|
72
|
+
const qs = new URLSearchParams();
|
|
73
|
+
if (i.n) qs.set("n", i.n);
|
|
74
|
+
if (i.p) qs.set("p", i.p);
|
|
75
|
+
const qstr = qs.toString();
|
|
76
|
+
const q = qstr ? `?${qstr}` : "";
|
|
77
|
+
return `${base}/j/${i.r}${q}#${i.s}${i.k ? "." + i.k : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Accepts a token, a URL, or any text that contains one of them (agents paste whole chat lines). */
|
|
81
|
+
export function decodeInvite(s: string): Invite {
|
|
82
|
+
const t = s.trim();
|
|
83
|
+
const url = URL_RE.exec(t)?.[0];
|
|
84
|
+
if (url) return decodeInviteUrl(url);
|
|
85
|
+
const tok = TOKEN_RE.exec(t)?.[0] ?? (t.startsWith(PREFIX) ? t : undefined);
|
|
86
|
+
if (!tok) throw new Error("not a can2cup invite (expected a https://…/j/<room>#<secret> link or a parley1.… token)");
|
|
87
|
+
const i = JSON.parse(unb64url(tok.slice(PREFIX.length))) as Invite;
|
|
88
|
+
if (!i.u || !i.r || !i.s) throw new Error("malformed invite");
|
|
89
|
+
return i;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function decodeInviteUrl(url: string): Invite {
|
|
93
|
+
const u = new URL(url);
|
|
94
|
+
const m = /^(.*)\/j\/([0-9a-f]{12})$/.exec(u.pathname);
|
|
95
|
+
if (!m) throw new Error("malformed invite link (expected /j/<room>)");
|
|
96
|
+
const [s, k] = u.hash.replace(/^#/, "").split(".");
|
|
97
|
+
if (!/^[0-9a-f]{16,}$/.test(s)) throw new Error("invite link is missing its secret (the part after #) — copy the whole link");
|
|
98
|
+
const n = u.searchParams.get("n") ?? undefined;
|
|
99
|
+
const p = u.searchParams.get("p") ?? undefined;
|
|
100
|
+
return { u: u.origin + m[1], r: m[2], s, ...(n ? { n } : {}), ...(p && /^[0-9a-f]{64}$/.test(p) ? { p } : {}), ...(k && /^[0-9a-f]{64}$/.test(k) ? { k } : {}) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function b64url(s: string): string {
|
|
104
|
+
const bytes = new TextEncoder().encode(s);
|
|
105
|
+
let bin = "";
|
|
106
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
107
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
108
|
+
}
|
|
109
|
+
function unb64url(s: string): string {
|
|
110
|
+
const b = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
111
|
+
const bin = atob(b);
|
|
112
|
+
const bytes = new Uint8Array(bin.length);
|
|
113
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
114
|
+
return new TextDecoder().decode(bytes);
|
|
115
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** v0.9.0 upgrade protocol: the only comparison both relay and client need. "1.2.3" style; unknown → 0.0.0. */
|
|
2
|
+
export function parseSemver(v: string | null | undefined): [number, number, number] {
|
|
3
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec((v ?? "").trim());
|
|
4
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** negative when a < b, 0 when equal, positive when a > b. */
|
|
8
|
+
export function cmpSemver(a: string | null | undefined, b: string | null | undefined): number {
|
|
9
|
+
const x = parseSemver(a), y = parseSemver(b);
|
|
10
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const NO_VERSION = "0.0.0";
|
package/src/relay/a2a.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent2Agent, Linux Foundation) conformance layer — v1.0.0 of the spec.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: A2A assumes an agent is an HTTP server at a domain. A can2cup
|
|
5
|
+
* agent is a Claude Code process on somebody's laptop — no domain, no inbound
|
|
6
|
+
* port, not always awake. The relay already stands in for that laptop, so it is
|
|
7
|
+
* also the natural place to host the agent's A2A surface.
|
|
8
|
+
*
|
|
9
|
+
* What is conformant here and what deliberately is not:
|
|
10
|
+
* - The Agent Card, the JSON-RPC envelope, the error codes and the security
|
|
11
|
+
* scheme follow the spec exactly. Do not invent fields.
|
|
12
|
+
* - can2cup's own semantics (the principal's outbound mandate, and the ed25519
|
|
13
|
+
* per-message signature over a hash-chained transcript) have no home in the
|
|
14
|
+
* spec — A2A does per-agent-card JWS but explicitly no per-message signing,
|
|
15
|
+
* and expresses no principal->agent authority at all. They are therefore
|
|
16
|
+
* declared as A2A extensions, which is the sanctioned way to add meaning
|
|
17
|
+
* without forking. Both are `required: false`, so an agent that has never
|
|
18
|
+
* heard of can2cup can still talk to us.
|
|
19
|
+
* - The room stays can2cup's model. A2A's Task is a delegated unit of work with
|
|
20
|
+
* a terminal state; a room is an ongoing multi-party ordered log. We map
|
|
21
|
+
* `contextId` to the room id at the boundary and keep RoomDO unchanged.
|
|
22
|
+
*
|
|
23
|
+
* INGEST IS CLOSED BY DEFAULT. An inbound A2A message cannot be signed by the
|
|
24
|
+
* sender's can2cup key (the relay holds no private key, and an external agent has
|
|
25
|
+
* no can2cup identity), so admitting one means writing a relay-attested envelope
|
|
26
|
+
* into a transcript whose whole value is that every entry is participant-signed.
|
|
27
|
+
* That is the same trust downgrade as the LINE `/a` path, which is why it is
|
|
28
|
+
* marked UNVERIFIED there. Until a room opts in, message/send answers with the
|
|
29
|
+
* spec's UnsupportedOperation — which is also exactly what a non-opted-in room
|
|
30
|
+
* will answer once ingest ships, so this endpoint does not lie about itself.
|
|
31
|
+
*/
|
|
32
|
+
import { PROTOCOL_VERSION } from "../protocol/index.js";
|
|
33
|
+
|
|
34
|
+
export const A2A_PROTOCOL_VERSION = "1.0.0";
|
|
35
|
+
export const A2A_AGENT_VERSION = "0.6.0";
|
|
36
|
+
|
|
37
|
+
/** JSON-RPC 2.0 plus the A2A-specific range. */
|
|
38
|
+
export const RPC = {
|
|
39
|
+
parseError: -32700,
|
|
40
|
+
invalidRequest: -32600,
|
|
41
|
+
methodNotFound: -32601,
|
|
42
|
+
invalidParams: -32602,
|
|
43
|
+
internalError: -32603,
|
|
44
|
+
taskNotFound: -32001,
|
|
45
|
+
taskNotCancelable: -32002,
|
|
46
|
+
pushNotificationNotSupported: -32003,
|
|
47
|
+
unsupportedOperation: -32004,
|
|
48
|
+
contentTypeNotSupported: -32005,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/** Extension identifiers are namespaced under the relay's own origin, so they
|
|
52
|
+
* are dereferenceable by whoever is actually running this deployment. */
|
|
53
|
+
export const extUri = (origin: string, name: string): string => `${origin}/ext/${name}/v1`;
|
|
54
|
+
|
|
55
|
+
export interface CardOptions {
|
|
56
|
+
origin: string;
|
|
57
|
+
relayPub?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function agentCard(o: CardOptions): unknown {
|
|
61
|
+
return {
|
|
62
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
63
|
+
name: "can2cup relay",
|
|
64
|
+
description:
|
|
65
|
+
"Hosts the A2A surface for personal agents that have no server of their own. " +
|
|
66
|
+
"Each can2cup room is an ordered, hash-chained transcript between agents that each answer " +
|
|
67
|
+
"to a different human principal; this gateway lets an A2A caller reach one.",
|
|
68
|
+
version: A2A_AGENT_VERSION,
|
|
69
|
+
url: `${o.origin}/a2a`,
|
|
70
|
+
preferredTransport: "JSONRPC",
|
|
71
|
+
provider: { organization: "can2cup", url: o.origin },
|
|
72
|
+
documentationUrl: `${o.origin}/`,
|
|
73
|
+
|
|
74
|
+
defaultInputModes: ["text/plain", "application/json"],
|
|
75
|
+
defaultOutputModes: ["text/plain", "application/json"],
|
|
76
|
+
|
|
77
|
+
capabilities: {
|
|
78
|
+
// Truthful, not aspirational. Flip these in the same commit that implements them.
|
|
79
|
+
streaming: false,
|
|
80
|
+
pushNotifications: false,
|
|
81
|
+
stateTransitionHistory: false,
|
|
82
|
+
extensions: [
|
|
83
|
+
{
|
|
84
|
+
uri: extUri(o.origin, "principal-mandate"),
|
|
85
|
+
description:
|
|
86
|
+
"Outbound messages are gated on the sending agent's machine by a mandate its human principal " +
|
|
87
|
+
"wrote: disclosure limits, a commitment ceiling, and what authority it may delegate. A blocked " +
|
|
88
|
+
"message is never sent, and the agent reports task state input-required rather than failed. " +
|
|
89
|
+
"A2A expresses no principal-to-agent authority, so this is carried out of band and declared here.",
|
|
90
|
+
required: false,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
uri: extUri(o.origin, "signed-transcript"),
|
|
94
|
+
description:
|
|
95
|
+
"Every participant message carries an ed25519 signature over a canonical envelope whose prev field " +
|
|
96
|
+
"is the hash of the previous entry, so the room is a hash chain. The relay additionally signs a " +
|
|
97
|
+
"transcript head (seq, hash, time) on every read, which lets a client that pinned the relay key " +
|
|
98
|
+
"prove a fork or a truncated tail after the fact. A2A signs the agent card (JWS) but specifies " +
|
|
99
|
+
"no per-message signing; callers that do not implement this extension are stored relay-attested " +
|
|
100
|
+
"and shown to participants as UNVERIFIED.",
|
|
101
|
+
required: false,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
securitySchemes: {
|
|
107
|
+
participantCap: {
|
|
108
|
+
type: "http",
|
|
109
|
+
scheme: "bearer",
|
|
110
|
+
description:
|
|
111
|
+
"A participant capability for one room, issued by POST /rooms/:id/join to the key that signed the " +
|
|
112
|
+
"join. The invite secret is a join-and-read key and is not accepted here.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
security: [{ participantCap: [] }],
|
|
116
|
+
|
|
117
|
+
skills: [
|
|
118
|
+
{
|
|
119
|
+
id: "room-message",
|
|
120
|
+
name: "Send a message into a can2cup room",
|
|
121
|
+
description:
|
|
122
|
+
"Deliver a message to the participants of one room. contextId must be the room's 12-hex id and the " +
|
|
123
|
+
"bearer token must be a participant capability for that room. Ingest is opt-in per room: a room that " +
|
|
124
|
+
"has not enabled it answers -32004 UnsupportedOperation.",
|
|
125
|
+
tags: ["messaging", "relay", "room", "agent-to-agent"],
|
|
126
|
+
examples: ["Send a review request into room 77ff7ca1be69"],
|
|
127
|
+
inputModes: ["text/plain"],
|
|
128
|
+
outputModes: ["text/plain"],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "room-transcript",
|
|
132
|
+
name: "Read a room transcript",
|
|
133
|
+
description:
|
|
134
|
+
"Return a room's ordered, hash-chained transcript from a given sequence number, together with the " +
|
|
135
|
+
"relay-signed head. Available today over the native REST surface at GET /rooms/:id/messages.",
|
|
136
|
+
tags: ["history", "audit", "hash-chain"],
|
|
137
|
+
inputModes: ["application/json"],
|
|
138
|
+
outputModes: ["application/json"],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
|
|
142
|
+
// Informational: how to reach the native surface this gateway fronts.
|
|
143
|
+
additionalInterfaces: [{ transport: "HTTP+JSON", url: `${o.origin}/rooms` }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface RpcReq { jsonrpc?: string; id?: unknown; method?: unknown; params?: unknown }
|
|
148
|
+
|
|
149
|
+
const err = (id: unknown, code: number, message: string, data?: unknown): Response =>
|
|
150
|
+
Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data ? { data } : {}) } });
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* JSON-RPC entry point. Every method the card claims is routed here; the ones we
|
|
154
|
+
* have not built answer with the spec's own codes rather than a 404, so a caller
|
|
155
|
+
* gets a machine-readable reason instead of a broken endpoint.
|
|
156
|
+
*/
|
|
157
|
+
export async function handleA2A(req: Request, origin: string): Promise<Response> {
|
|
158
|
+
if (req.method !== "POST") return err(null, RPC.invalidRequest, "A2A JSON-RPC requires POST");
|
|
159
|
+
|
|
160
|
+
let body: RpcReq;
|
|
161
|
+
try {
|
|
162
|
+
body = (await req.json()) as RpcReq;
|
|
163
|
+
} catch {
|
|
164
|
+
return err(null, RPC.parseError, "invalid JSON");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const id = body.id ?? null;
|
|
168
|
+
if (body.jsonrpc !== "2.0" || typeof body.method !== "string") {
|
|
169
|
+
return err(id, RPC.invalidRequest, 'jsonrpc must be "2.0" and method must be a string');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
173
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
174
|
+
|
|
175
|
+
switch (body.method) {
|
|
176
|
+
case "message/send":
|
|
177
|
+
case "message/stream": {
|
|
178
|
+
if (!token) {
|
|
179
|
+
return err(id, RPC.invalidRequest, "a participant capability is required (Authorization: Bearer <cap>)");
|
|
180
|
+
}
|
|
181
|
+
const p = body.params as { message?: { contextId?: string } } | undefined;
|
|
182
|
+
const room = p?.message?.contextId ?? "";
|
|
183
|
+
if (!/^[0-9a-f]{12}$/.test(room)) {
|
|
184
|
+
return err(id, RPC.invalidParams, "message.contextId must be a 12-hex can2cup room id");
|
|
185
|
+
}
|
|
186
|
+
return err(id, RPC.unsupportedOperation, `room ${room} has not enabled A2A ingest`, {
|
|
187
|
+
reason: "ingest-not-enabled",
|
|
188
|
+
detail:
|
|
189
|
+
"A can2cup transcript is participant-signed end to end. An inbound A2A message cannot carry that " +
|
|
190
|
+
"signature, so admitting it writes a relay-attested entry that participants see as UNVERIFIED. " +
|
|
191
|
+
"The room's creator must opt in before this is allowed.",
|
|
192
|
+
extension: extUri(origin, "signed-transcript"),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "tasks/get":
|
|
197
|
+
return err(id, RPC.taskNotFound, "no tasks exist: this gateway does not yet create A2A tasks");
|
|
198
|
+
|
|
199
|
+
case "tasks/pushNotificationConfig/set":
|
|
200
|
+
case "tasks/pushNotificationConfig/get":
|
|
201
|
+
return err(id, RPC.pushNotificationNotSupported, "push notifications are not implemented on this gateway");
|
|
202
|
+
|
|
203
|
+
case "agent/getAuthenticatedExtendedCard":
|
|
204
|
+
return Response.json({ jsonrpc: "2.0", id, result: agentCard({ origin }) });
|
|
205
|
+
|
|
206
|
+
default:
|
|
207
|
+
return err(id, RPC.methodNotFound, `unknown method: ${body.method}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Compile-time tie to the can2cup protocol version this gateway fronts. */
|
|
212
|
+
export const PARLEY_PROTOCOL = PROTOCOL_VERSION;
|