From 420063705eb3c7cfed8cabc5d8981c41d6b8f322 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 19:54:12 -0700 Subject: [PATCH 1/6] feat(shared): add the @org/handle address grammar (#307 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive only. `formatAddress` and `parseKeyAddress` land the registry-key grammar with round-trip and rejection tests; the outgoing `handle@host` `parseAddress` stays live and wired up so nothing downstream moves yet. An address is a key, not a locator. A hostname appears in an address for one reason — federation — and cross-organization routing is a permanent non-goal, so nothing ever resolves an AgentCall address. The grammar is deliberately unable to express a host: dots are absent from both bodies, and the rejection tests assert that every DNS-shaped form fails to parse. `ORG_RE` tightens from 63 characters to 20 in the same change, because the two are one decision: at 63 an address could be `@acme-corporation-platform- engineering/ken`, trading a vendor domain for a self-inflicted one. All three patterns now derive from one pair of shared bodies so they cannot drift. Why this slice stops here: `packages/shared/src/keys.ts` holds a second, independent `handle@host` `ADDRESS_RE`, and the host inside it is load-bearing crypto. `identityTranscript` and `encryptionKeyTranscript` both sign the address, and the relay origin inside it is what stops a record published on one relay from being presented as valid on another. Cutting the format over without first making `relay_origin` an explicit signed field would silently drop that binding. The CLI builds envelopes validated by that grammar, so the layers are coupled and the crypto slice has to go first. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md --- .../2026-08-05-address-as-registry-key.md | 199 ++++++++++++++++++ packages/shared/src/protocol.ts | 57 ++++- packages/shared/test/protocol.test.ts | 49 ++++- 3 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-05-address-as-registry-key.md diff --git a/docs/superpowers/specs/2026-08-05-address-as-registry-key.md b/docs/superpowers/specs/2026-08-05-address-as-registry-key.md new file mode 100644 index 00000000..d43dd489 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-address-as-registry-key.md @@ -0,0 +1,199 @@ +# The address is a registry key, not a DNS locator + +> **Historical document — not current documentation.** This is a dated +> decision record that describes the repository state on 2026-08-05 and is +> deliberately *not* updated when behavior changes. + +**Date:** 2026-08-05 + +**Status:** Decided + +**Issue:** [#307](https://github.com/KenTaniguchi-R/agentcall/issues/307) + +## Decision + +The canonical address becomes `@/` — `@acme/ken`. Inside an +organization the bare handle `ken` is the everyday form. No hostname appears in +an address in any form. + +## Why a hostname was there, and why it should not be + +A hostname appears in an address for exactly one reason: federation. `ken@acme.com` +carries a host because a stranger's mail server must be told where to deliver. It +is a routing instruction aimed at someone who does not already know you. + +AgentCall has no strangers. Every caller is authenticated to one relay, may only +reach their own organization ([federation non-goal](./2026-08-02-cross-organization-federation-non-goal.md), +#189), and already holds the relay URL in `cfg.relay`. Nothing resolves an +AgentCall address. There is no lookup step for a hostname to serve. + +The grammar has always agreed. `HANDLE_RE` and `ORG_RE` +(`packages/shared/src/protocol.ts:3-4`) both forbid dots, so neither has ever +been able to hold a hostname, and the comment on `HOSTED_RELAY_HOST` already +says "This is deployment configuration, not protocol." The DNS wrapper was a +costume over a key that was already flat. + +## Why `@acme/ken` rather than `ken@acme` + +Both drop the vendor domain. The npm-scope shape is chosen because: + +1. It is shaped like what it is — a key in a namespace, not an address at a host. + Email shape is the one form that misrepresents the value. +2. The leading `@` reads as "an addressable entity" across Slack, GitHub, Discord + and Twitter. That is learned behaviour we get for free. +3. It inherits an allocation policy. If `acme` is a registry key rather than a + domain, someone must allocate it and ICANN no longer does that job. npm's + answers transfer directly: scopes are first-come within a registry, one + organization may hold several, published names are immutable. + +Rejected: `ken@agents.acme.com` on a customer-verified subdomain. Cosmetically +ideal and vendor-free, but it promises resolution semantics we do not implement. +A key dressed as a locator is a trap regardless of whose domain it is. + +## What this deletes + +The hostname in the address is not merely redundant; it manufactures a class of +bug. `relayHostWarning` (`packages/cli/src/contacts.ts:100`) exists because an +address names a relay while the call is dialled on the calling line's relay, so +"calling a hosted address from a line registered elsewhere actually reaches +whichever `ken` is on that other relay." The function warns; its twenty-line +comment explains why it warns rather than rejects, and records that a merge once +reinstated the rejection and re-broke local development and self-hosting. + +With no host in the address that situation cannot be constructed. The hazard, the +warning, the explanation, and the regression history all go. + +| Deleted | Lines | +| --- | --- | +| `contacts.ts::relayHostWarning` + its comment | ~35 | +| `contacts.ts::addressTenant` | 6 | +| `tenant.ts::registrationAddressHost` | 6 | +| `tenant.ts::requestOrg` hostname branch | ~7 | +| `config.ts::addressHost`, `relayAddressHost` | 7 | +| `RegisterResponse.address` | 1 field | + +Plus 63 occurrences of the host string across 13 test files. + +The cross-tenant rejection (#66) gets *stronger*, not weaker. It currently derives +the target org by string-parsing a hostname (`addressTenant`); under `@org/handle` +it reads the org directly from the parsed address. A security boundary stops +depending on a suffix match. + +## Address as a rendering + +No composed address is stored or transmitted. Storage and the wire carry +`(org, handle)` and `agent_id`; the address is a pure function of those plus the +context it appears in: + +``` +render(org, handle, context) -> "ken" // within the organization + "@acme/ken" // sharing, rosters, audit, export +``` + +`RegisterResponse` currently returns `{ org, token, address }`. The `address` +field is the composed string and is what forces `registrationAddressHost` to +exist. It is removed; registration returns `org` and `handle` and the client +renders. + +This is what makes the format cheap to change again: one function, not a +migration. + +## Consequences accepted + +**CSV escaping.** A leading `@` is a spreadsheet formula prefix. `csvCell` +(`packages/cli/src/commands/audit-export.ts:25`) already escapes `^\s*[=+\-@]` +with a leading apostrophe, so exports are safe, but every address in every audit +CSV will render as `'@acme/ken`. Accepted: correct escaping beats export +cosmetics. `acme/ken` without the leading `@` was the CSV-clean alternative and +was rejected for losing the entity signal in prose. + +**Addresses are relay-scoped, not global.** A self-hosted deployment and the +hosted relay may each mint `@acme/ken` and they are different people. Acceptable +under the federation non-goal — the two can never meet — but it means the address +cannot double as a durable global identifier. `agent_id` is that identifier +(#154). + +**Handles remain reassignable.** `0020_cards_by_identity` moves card, task and +grant ownership to the stable subject so reassignment cannot inherit policy. The +system is safe; a human reading an eight-month-old audit export is not. Anywhere +an address is written for later human reading, render `agent_id` alongside it. + +**Org length.** `ORG_RE` permits 63 characters, so +`@acme-corporation-platform-engineering/ken` is currently legal and would trade a +vendor domain for a self-inflicted one. Cap near 20. + +## Out of scope + +`AGENTCALL_POLICY_EXT` (`packages/shared/src/a2a/card.ts:10`) is vendor-branded +and appears in machine surfaces, but it is a namespace identifier and changing it +is a protocol break. Separate decision. + +The relay *endpoint* hostname is unaffected by this document. The relay still +lives somewhere and that somewhere is still named in `cfg.relay`, the +`wrangler.jsonc` route, and the docs — which is [#310](https://github.com/KenTaniguchi-R/agentcall/issues/310) +and its open PR #312. This decision removes the host from *addresses* only. + +## Sequencing + +PR #312 (#310) renames the host across 25 files, most of them address fixtures +this change rewrites again. The endpoint rename is still required — the relay +must live somewhere that is not `benree.tech` — so #312 is not wasted, but the +fixture churn is paid twice. + +Recommendation: land #312 first because it is written and reviewable, then this +change on top. The double touch is a mechanical fixture sweep and is cheaper than +holding a finished PR. + +## The address is inside signed transcripts + +Found while implementing, and it changes the sequencing. `packages/shared/src/keys.ts` +holds a **second, independent** `ADDRESS_RE` — `handle@host` — and its comment +states a security property: + +> The relay origin is part of the signed identity so a record published on one +> relay cannot be presented as valid on another. + +`identityTranscript` signs `[..., address, identity_pub]` and +`encryptionKeyTranscript` signs `[..., address, key_id, suite, pub, epoch, ...]`. +The host inside `address` is therefore **load-bearing cryptographic binding**, not +decoration. Removing it without replacement would make `@acme/ken` signed on a +self-hosted relay indistinguishable from `@acme/ken` signed on the hosted relay, +and a key record published on one could be replayed against the other. + +The fix is to make the binding explicit rather than smuggled inside a string: +`IdentityRecord` and `EncryptionKeyRecord` gain a `relay_origin` field, and both +transcripts cover it. That is strictly better than the status quo — a signed field +rather than a substring convention — but it changes transcript shape, which breaks +signatures and the `prev` chain links between encryption-key epochs. Free at zero +users, but it is a protocol change and deserves its own review. + +The E2EE envelopes are already fine: `HpkeEnvelopeHeader` and `InnerBase` carry +`relay_origin` **explicitly** alongside host-shaped `from`/`to`, so there the host +in the address is already redundant. + +Because the CLI builds envelopes whose `from`/`to` are validated by the keys.ts +grammar, the CLI cannot cut over to `@org/handle` before the crypto layer does. +The layers are coupled and must move in that order. + +## Plan + +1. **Grammar, additive.** `formatAddress`, `parseKeyAddress`, and a private + `ADDRESS_RE` in `packages/shared/src/protocol.ts`, with round-trip and + rejection tests. The outgoing `parseAddress` stays live and wired up so the + tree remains green. **Done in this change.** `ORG_RE` also tightens from 63 to + 20 characters here. +2. **Relay origin becomes an explicit signed field.** Add `relay_origin` to + `IdentityRecord` and `EncryptionKeyRecord`; include it in both transcripts; + re-point the keys.ts `ADDRESS_RE` at the key grammar. Signature-breaking, + reviewed on its own. +3. **Wire and relay.** Remove `RegisterResponse.address` and + `registrationAddressHost`; `requestOrg` loses its hostname branch. +4. **CLI.** Delete `addressHost`, `relayAddressHost`, `relayHostWarning`, + `addressTenant`; `resolveAddress` branches on `/`; the cross-tenant check reads + the parsed org. Delete the outgoing `parseAddress`. +5. **Fixtures and docs.** + +Each step keeps `pnpm -r build && pnpm -r typecheck && pnpm -r test` green. Step 1 +deliberately leaves two address grammars in the tree; that is the drift hazard +this document otherwise warns about, and it is tolerable only because step 2 +follows immediately and deletes one of them. diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 0eaa6952..098ad108 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -1,15 +1,39 @@ import { z } from "zod"; -export const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,30}$/; -export const ORG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/; -// The hosted deployment's DNS host, and the single place it is written. The -// relay derives tenant orgs and registration addresses from it, and the CLI -// derives its default relay URL and expected address host — each of those used -// to be its own string literal, in four files. +// One source of truth for the three patterns, because they must agree: an org +// that registers must also be spellable in an address, and a drifting copy +// would let one be created that the other cannot name. +const ORG_BODY = "[a-z0-9][a-z0-9-]{1,19}"; +const HANDLE_BODY = "[a-z0-9][a-z0-9-]{1,30}"; + +export const HANDLE_RE = new RegExp(`^${HANDLE_BODY}$`); +// 20 characters, not the 63 this allowed while orgs were DNS labels. The +// address is meant to be short enough to say out loud, and +// `@acme-corporation-platform-engineering/ken` would trade a vendor domain for +// a self-inflicted one. +export const ORG_RE = new RegExp(`^${ORG_BODY}$`); + +// `@/` — a registry key, not a locator. See +// docs/superpowers/specs/2026-08-05-address-as-registry-key.md. +// +// Deliberately unable to express a hostname: dots are absent from both bodies, +// so no DNS-shaped address can parse. That is the point rather than an +// oversight — nothing resolves an AgentCall address, and a key dressed as a +// locator invites tooling to try. +// +// Not exported. `keys.ts` still owns a separate, host-shaped `ADDRESS_RE` for +// signed identity and encryption-key records, where the host currently carries +// the cross-relay binding. Exporting a second name-alike from here would +// collide, and worse, would invite a caller to validate a signed record against +// the wrong grammar. Use `parseAddress`/`formatAddress`. +const ADDRESS_RE = new RegExp(`^@(${ORG_BODY})/(${HANDLE_BODY})$`); + +// The hosted deployment's DNS host, and the single place it is written. It is +// the relay *endpoint* only: the CLI derives its default relay URL from it. +// Addresses no longer contain it, so nothing parses it back out. // // This is deployment configuration, not protocol: a self-hosted relay sets its -// own host and never reads this. It lives here because both sides must agree -// on how a hosted address is spelled. Notably NOT the source of +// own host and never reads this. Notably NOT the source of // AGENTCALL_POLICY_EXT — see the comment there. export const HOSTED_RELAY_HOST = "agentcall.benree.tech"; export const TASK_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; @@ -208,6 +232,23 @@ export type RecoveryStatusResponseType = z.infer; export type RecoveryRedeemRequestType = z.infer; export type RecoveryReceiptType = z.infer; +export function formatAddress(org: string, handle: string): string { + return `@${org}/${handle}`; +} + +// Returns the pair, not a host. Callers that need to know which relay to dial +// read `cfg.relay`; an address never carried that information usefully, because +// a caller only ever reaches its own organization's relay. +export function parseKeyAddress(addr: string): { org: string; handle: string } | null { + const m = ADDRESS_RE.exec(addr); + return m ? { org: m[1]!, handle: m[2]! } : null; +} + +// The outgoing `handle@host` grammar. Still live because signed identity and +// encryption-key records currently carry the relay binding inside the address +// (see keys.ts), so the cutover cannot happen in the CLI alone — it needs +// `relay_origin` to become an explicit signed field first. Deleted in that +// slice; until then both grammars exist and only this one is wired up. export function parseAddress(addr: string): { handle: string; host: string } | null { const at = addr.indexOf("@"); if (at <= 0) return null; diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index 0109355c..317115a6 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { E2EECallerFrame, E2EEListenerToRelayFrame, E2EERelayToCallerFrame, E2EERelayToListenerFrame, E2EERequestPayload, E2EEOutcome, - HANDLE_RE, MAX_MESSAGE_BYTES, parseAddress, safeParseFrame, + formatAddress, HANDLE_RE, MAX_MESSAGE_BYTES, parseAddress, parseKeyAddress, safeParseFrame, RegisterRequest, MAX_DETAIL_LENGTH, sanitizeDetail, sanitizeTerminalOutput, sanitizeTerminalCell, stringifyTerminalSafeJson, CallAccepted, CallStarted, CancelCall, CallCancelled, CallNotCancelled, @@ -78,14 +78,49 @@ describe("task id bounds", () => { }); }); -describe("parseAddress", () => { - it("splits handle@host", () => { - expect(parseAddress("ken@agentcall.benree.tech")).toEqual({ handle: "ken", host: "agentcall.benree.tech" }); +describe("address grammar", () => { + it("splits @org/handle", () => { + expect(parseKeyAddress("@acme/ken")).toEqual({ org: "acme", handle: "ken" }); }); + + it("round-trips through formatAddress", () => { + expect(parseKeyAddress(formatAddress("acme", "ken"))).toEqual({ org: "acme", handle: "ken" }); + expect(formatAddress("acme", "ken")).toBe("@acme/ken"); + }); + + // The whole point of the format: an address is a registry key, so nothing + // that looks like a host may parse. A DNS-shaped address promises resolution + // this system does not implement. + it("rejects every host-shaped form", () => { + expect(parseKeyAddress("ken@agentcall.benree.tech")).toBeNull(); + expect(parseKeyAddress("ken@acme.agentcall.agent-call.app")).toBeNull(); + expect(parseKeyAddress("ken@acme")).toBeNull(); + expect(parseKeyAddress("@acme.corp/ken")).toBeNull(); + expect(parseKeyAddress("@acme/ken.tech")).toBeNull(); + }); + it("rejects garbage", () => { - expect(parseAddress("ken")).toBeNull(); - expect(parseAddress("KEN@x.y")).toBeNull(); - expect(parseAddress("ken@")).toBeNull(); + expect(parseKeyAddress("ken")).toBeNull(); + expect(parseKeyAddress("@acme/")).toBeNull(); + expect(parseKeyAddress("@/ken")).toBeNull(); + expect(parseKeyAddress("acme/ken")).toBeNull(); + expect(parseKeyAddress("@ACME/ken")).toBeNull(); + expect(parseKeyAddress("@acme/KEN")).toBeNull(); + expect(parseKeyAddress("@acme/ken/extra")).toBeNull(); + expect(parseKeyAddress("")).toBeNull(); + }); + + // Leading and trailing whitespace is the paste hazard: addresses are copied + // out of chat and docs. Reject rather than trim, so a mis-scoped address can + // never be silently normalised into a valid one. + it("rejects surrounding whitespace rather than trimming", () => { + expect(parseKeyAddress(" @acme/ken")).toBeNull(); + expect(parseKeyAddress("@acme/ken ")).toBeNull(); + }); + + it("enforces the org length cap so the address stays short", () => { + expect(parseKeyAddress(`@${"a".repeat(20)}/ken`)).not.toBeNull(); + expect(parseKeyAddress(`@${"a".repeat(21)}/ken`)).toBeNull(); }); }); From f04afcfac96b12db4038e9d9027b4a71c0b51e06 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 20:25:49 -0700 Subject: [PATCH 2/6] feat(shared)!: bind key records to their relay with an explicit signed field (#307 slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-relay binding used to ride inside `address` as the host part, which tied it to addresses being DNS-shaped. keys.ts said so directly: The relay origin is part of the signed identity so a record published on one relay cannot be presented as valid on another. `identityTranscript` and `encryptionKeyTranscript` both sign the address, so that host is load-bearing crypto rather than decoration. Removing it in the address cutover (#307) without doing this first would have silently dropped the property: `@acme/ken` signed on a self-hosted relay would be indistinguishable from `@acme/ken` on the hosted relay, and a published key record could be replayed across the two. `relay_origin` is now an explicit field on `IdentityRecord` and `EncryptionKeyRecord`, covered by both transcripts. It is populated with the same org-scoped host the E2EE envelopes already put in their own `relay_origin`, so the signed binding and the wire binding name the same thing. Two tests assert the property directly: records differing only by relay must not share a transcript. BREAKING: both records go to v2 and both transcript labels to `agentcall/{identity,encryption-key}/v2`, because the file's own rule is that adding a field means a new record version. Signatures and the `prev` chain links between encryption-key epochs do not survive. Free at zero users, and the version bump means a v1 verifier refuses rather than mis-verifies. The known-peer trust store gains `relay_origin` too, and that is correctness rather than plumbing: lines may sit on different relays while the store is per-machine, so a pin is only meaningful together with its origin — and the identity transcript now covers it, so a stored peer without it cannot have its fingerprint recomputed. Existing local `known_peers.json` and published keys will not load; re-run setup. RELAY_ORIGIN_RE moves from e2ee.ts to keys.ts, which is the direction the existing import already runs. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md --- apps/relay/src/keys.ts | 5 ++-- apps/relay/test/keys.test.ts | 18 +++++++++----- packages/cli/src/api.ts | 9 +++++-- packages/cli/src/known-peers.ts | 11 ++++++++- packages/cli/test/api.test.ts | 8 +++++-- packages/cli/test/call-client.test.ts | 9 +++++-- packages/cli/test/cli-actions.test.ts | 17 +++++++++---- packages/cli/test/doctor.test.ts | 19 ++++++++------- packages/cli/test/known-peers.test.ts | 10 +++++--- packages/cli/test/listener-stages.test.ts | 5 ++-- packages/cli/test/listener.test.ts | 5 ++-- packages/shared/src/e2ee.ts | 3 +-- packages/shared/src/keys.ts | 28 +++++++++++++++++----- packages/shared/test/keys.test.ts | 29 +++++++++++++++++++++-- 14 files changed, 132 insertions(+), 44 deletions(-) diff --git a/apps/relay/src/keys.ts b/apps/relay/src/keys.ts index 36cf1e6f..e549e88d 100644 --- a/apps/relay/src/keys.ts +++ b/apps/relay/src/keys.ts @@ -178,10 +178,11 @@ export function mountKeys(app: Hono): void { // reconstructing at all.) const address = addressFor(c, identity.org, target); return c.json({ - identity: { v: 1, address, identity_pub: identityPub }, + identity: { v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, identity_pub: identityPub }, encryption: { record: { - v: 1, address, key_id: row.key_id, suite: row.suite, pub: row.pub, + v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + key_id: row.key_id, suite: row.suite, pub: row.pub, epoch: row.epoch, not_before: row.not_before, not_after: row.not_after, prev: row.prev, }, signature: row.signature, diff --git a/apps/relay/test/keys.test.ts b/apps/relay/test/keys.test.ts index 8b7b4cbb..f80ad2b4 100644 --- a/apps/relay/test/keys.test.ts +++ b/apps/relay/test/keys.test.ts @@ -18,7 +18,8 @@ async function newIdentity(handle: string) { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const record = { - v: 1 as const, + v: 2 as const, + relay_origin: HOST, address: `${handle}@${HOST}`, identity_pub: await exportPublicKey(idKp.publicKey), }; @@ -47,7 +48,8 @@ async function encRecord(who: Awaited>, epoch: nu const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const record = { - v: 1 as const, + v: 2 as const, + relay_origin: (address ?? `${who.handle}@${HOST}`).split("@")[1]!, address: address ?? `${who.handle}@${HOST}`, key_id: await keyIdFor(pub), suite: HPKE_SUITE, @@ -218,7 +220,8 @@ describe("key publication endpoints", () => { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 1 as const, address, identity_pub: await exportPublicKey(idKp.publicKey), + v: 2 as const, relay_origin: address.split("@")[1]!, + address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { "content-type": "application/json", @@ -240,7 +243,8 @@ describe("key publication endpoints", () => { const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const encryption = { - v: 1 as const, + v: 2 as const, + relay_origin: address.split("@")[1]!, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, @@ -281,7 +285,8 @@ describe("key publication endpoints", () => { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 1 as const, address, identity_pub: await exportPublicKey(idKp.publicKey), + v: 2 as const, relay_origin: address.split("@")[1]!, + address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { "content-type": "application/json", @@ -303,7 +308,8 @@ describe("key publication endpoints", () => { const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const encryption = { - v: 1 as const, + v: 2 as const, + relay_origin: address.split("@")[1]!, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 824853b6..28034869 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -386,7 +386,11 @@ export async function publishIdentityKey( relay: string, auth: Auth, keys: StoredKeys, host: string, ): Promise { const record: IdentityRecordType = IdentityRecord.parse({ - v: 1, address: `${auth.handle}@${host}`, identity_pub: keys.identity_pub, + // `host` is already the org-scoped address host, which is exactly what the + // envelopes put in `relay_origin`. Keeping the two identical is the point: + // the signed binding and the wire binding must name the same thing. + v: 2, relay_origin: host, address: `${auth.handle}@${host}`, + identity_pub: keys.identity_pub, }); // Self-signed: the record is signed by the very key it publishes. The relay // has no way to check an identity key against anything else, so possession of @@ -418,7 +422,8 @@ export async function publishEncryptionKey( if (!publication) { const pub = keys.encryption_pub; const record: EncryptionKeyRecordType = EncryptionKeyRecord.parse({ - v: 1, + v: 2, + relay_origin: host, address: `${auth.handle}@${host}`, key_id: await keyIdFor(pub), suite: HPKE_SUITE, diff --git a/packages/cli/src/known-peers.ts b/packages/cli/src/known-peers.ts index 84fd959f..50be0cc9 100644 --- a/packages/cli/src/known-peers.ts +++ b/packages/cli/src/known-peers.ts @@ -11,6 +11,11 @@ import type { MachinePaths } from "./paths.js"; export const MAX_KNOWN_PEERS = 10_000; const KnownPeerSchema = z.object({ + // Which relay this key was trusted on. Lines may sit on different relays and + // the trust store is per-machine, so the pin is only meaningful together with + // its origin — and the identity transcript covers it, so a stored peer + // without it cannot have its fingerprint recomputed. + relay_origin: z.string().regex(/^[a-z0-9.-]{1,253}$/), address: z.string().regex(/^[a-z0-9][a-z0-9-]{1,30}@[a-z0-9.-]{1,253}$/), identity_pub: z.string().regex(/^[A-Za-z0-9_-]+$/).max(256), fingerprint: z.string().regex(/^SHA256:[0-9a-f]{32}$/), @@ -75,7 +80,10 @@ export async function verifyAndPinPeer( const peers = loadKnownPeers(machine); const existing = peers.find((peer) => peer.address === address); const servedFingerprint = await fingerprint(identityTranscript(bundle.identity)); - const storedIdentity = existing && { v: 1 as const, address: existing.address, identity_pub: existing.identity_pub }; + const storedIdentity = existing && { + v: 2 as const, relay_origin: existing.relay_origin, + address: existing.address, identity_pub: existing.identity_pub, + }; const storedFingerprint = storedIdentity && await fingerprint(identityTranscript(storedIdentity)); if (existing && existing.fingerprint !== storedFingerprint) { throw new Error(`Corrupt known-peer trust store at ${machine.knownPeersFile}: fingerprint does not match ${address}.`); @@ -104,6 +112,7 @@ export async function verifyAndPinPeer( highest_encryption_epoch: Math.max(existing.highest_encryption_epoch, bundle.encryption.record.epoch), call_count: existing.call_count + 1, } : { + relay_origin: bundle.identity.relay_origin, address, identity_pub: bundle.identity.identity_pub, fingerprint: servedFingerprint, diff --git a/packages/cli/test/api.test.ts b/packages/cli/test/api.test.ts index 394c6380..977265e5 100644 --- a/packages/cli/test/api.test.ts +++ b/packages/cli/test/api.test.ts @@ -469,10 +469,14 @@ async function previousTranscriptHash(record: EncryptionKeyRecordType): Promise< async function buildValidKeysResponse( keys: StoredKeys, address: string, ): Promise<{ identity: IdentityRecordType; encryption: { record: EncryptionKeyRecordType; signature: string } }> { - const identity: IdentityRecordType = { v: 1, address, identity_pub: keys.identity_pub }; + const identity: IdentityRecordType = { + v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + identity_pub: keys.identity_pub, + }; const now = 1_754_000_000_000; const record: EncryptionKeyRecordType = { - v: 1, + v: 2, + relay_origin: address.slice(address.indexOf("@") + 1), address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, diff --git a/packages/cli/test/call-client.test.ts b/packages/cli/test/call-client.test.ts index 9fe246e6..5d876002 100644 --- a/packages/cli/test/call-client.test.ts +++ b/packages/cli/test/call-client.test.ts @@ -77,7 +77,8 @@ async function identity(name: string): Promise<{ keys: StoredKeys; paths: Return async function encryptionRecord(address: string, keys: StoredKeys): Promise { return { - v: 1, address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, + v: 2, relay_origin: address.slice(address.indexOf("@") + 1), + address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, pub: keys.encryption_pub, epoch: keys.epoch, not_before: 1, not_after: Date.now() + 1_000_000, prev: null, }; @@ -97,10 +98,14 @@ async function fixture(relay: string, overrides: Partial = {}) { ...overrides, keyDeps: { fetchKeys: async () => ({ - identity: { v: 1, address: toAddress, identity_pub: recipient.keys.identity_pub }, + identity: { + v: 2, relay_origin: toAddress.slice(toAddress.indexOf("@") + 1), + address: toAddress, identity_pub: recipient.keys.identity_pub, + }, encryption: { record: recipientRecord, signature: "unused" }, }), verifyAndPinPeer: async () => ({ + relay_origin: toAddress.slice(toAddress.indexOf("@") + 1), address: toAddress, identity_pub: recipient.keys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: recipient.keys.epoch, call_count: 1, diff --git a/packages/cli/test/cli-actions.test.ts b/packages/cli/test/cli-actions.test.ts index 11e9e8e2..8ea620e1 100644 --- a/packages/cli/test/cli-actions.test.ts +++ b/packages/cli/test/cli-actions.test.ts @@ -113,6 +113,7 @@ describe("trust CLI", () => { const testHome = home(); const machine = getMachinePaths(testHome, testHome); writeJsonAtomic(machine.knownPeersFile, { peers: [{ + relay_origin: "relay.example", address: "peer@relay.example", identity_pub: "abc", fingerprint: "SHA256:0123456789abcdef0123456789abcdef", first_seen_at: 1, highest_encryption_epoch: 1, call_count: 1, @@ -130,10 +131,14 @@ describe("trust CLI", () => { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record = { - v: 1 as const, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, + v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, }; - const identityRecord = { v: 1 as const, address, identity_pub: identityPub }; + const identityRecord = { + v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + address, identity_pub: identityPub, + }; return { expected: await fingerprint(identityTranscript(identityRecord)), response: { @@ -212,9 +217,13 @@ async function startCallRelay( const remote = await testKeys(); const relayOrigin = "127.0.0.1"; const remoteAddress = `sota@${relayOrigin}`; - const identity = { v: 1 as const, address: remoteAddress, identity_pub: remote.identity_pub }; + const identity = { + v: 2 as const, relay_origin: remoteAddress.slice(remoteAddress.indexOf("@") + 1), + address: remoteAddress, identity_pub: remote.identity_pub, + }; const record = { - v: 1 as const, address: remoteAddress, key_id: await keyIdFor(remote.encryption_pub), + v: 2 as const, relay_origin: remoteAddress.slice(remoteAddress.indexOf("@") + 1), + address: remoteAddress, key_id: await keyIdFor(remote.encryption_pub), suite: HPKE_SUITE, pub: remote.encryption_pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, }; diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index 61f83a43..a3fd0b0e 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -257,11 +257,12 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 1, address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + v: 2, relay_origin: "relay.example", + address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 1, address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks).toEqual([ @@ -277,11 +278,12 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 1, address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + v: 2, relay_origin: "relay.example", + address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch + 1, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 1, address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks.at(-1)).toMatchObject({ name: "published identity keys", ok: false }); @@ -308,13 +310,14 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 1, address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + v: 2, relay_origin: "relay.example", + address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 1, address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, encryption: { record, signature: "invalid" }, }), ); @@ -334,13 +337,13 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const values = await fields(local); const record: EncryptionKeyRecordType = { - v: 1, address: "ken@relay.example", suite: HPKE_SUITE, pub: local.encryption_pub, + v: 2, relay_origin: "ken@relay.example".slice("ken@relay.example".indexOf("@") + 1), address: "ken@relay.example", suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, prev: null, ...values, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 1, address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, }), ); diff --git a/packages/cli/test/known-peers.test.ts b/packages/cli/test/known-peers.test.ts index 4896c219..c36c6966 100644 --- a/packages/cli/test/known-peers.test.ts +++ b/packages/cli/test/known-peers.test.ts @@ -30,13 +30,17 @@ async function bundle(identity?: CryptoKeyPair, epoch = 1, address = PEER) { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record: EncryptionKeyRecordType = { - v: 1, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch, + v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch, not_before: 1, not_after: 1_000, prev: null, }; return { identityKey: identity, value: { - identity: { v: 1 as const, address, identity_pub: identityPub }, + identity: { + v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + address, identity_pub: identityPub, + }, encryption: { record, signature: await signTranscript(identity.privateKey, encryptionKeyTranscript(record)) }, }, }; @@ -130,7 +134,7 @@ describe("known-peer identity pins", () => { it("refuses to grow beyond the fixed peer cap", async () => { writeJsonAtomic(machine.knownPeersFile, { peers: Array.from({ length: MAX_KNOWN_PEERS }, (_, index) => ({ - address: `p${index}@r.test`, identity_pub: "abc", + relay_origin: "r.test", address: `p${index}@r.test`, identity_pub: "abc", fingerprint: "SHA256:0123456789abcdef0123456789abcdef", first_seen_at: 1, highest_encryption_epoch: 1, call_count: 1, })) }); diff --git a/packages/cli/test/listener-stages.test.ts b/packages/cli/test/listener-stages.test.ts index 433f883e..41a71bbc 100644 --- a/packages/cli/test/listener-stages.test.ts +++ b/packages/cli/test/listener-stages.test.ts @@ -56,18 +56,19 @@ function seedTask(paths: LinePaths, id: string, frontmatter: string[], body = "d async function callerBundleFor(handle: string) { const record: EncryptionKeyRecordType = { - v: 1, address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), + v: 2, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), suite: HPKE_SUITE, pub: callerKeys.encryption_pub, epoch: callerKeys.epoch, not_before: 1, not_after: Date.now() + RELAY_CALL_TIMEOUT_MS, prev: null, }; return { - identity: { v: 1 as const, address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, + identity: { v: 2 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, encryption: { record, signature: "unused" }, }; } function fakePeer(address: string) { return { + relay_origin: address.slice(address.indexOf("@") + 1), address, identity_pub: callerKeys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: callerKeys.epoch, call_count: 1, }; diff --git a/packages/cli/test/listener.test.ts b/packages/cli/test/listener.test.ts index 1397af00..7284f9d6 100644 --- a/packages/cli/test/listener.test.ts +++ b/packages/cli/test/listener.test.ts @@ -160,16 +160,17 @@ function baseDeps(relay: string) { codexToolTelemetryEnabled: () => true, fetchKeys: async (_relay: string, _auth: unknown, handle: string) => { const record: EncryptionKeyRecordType = { - v: 1, address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), + v: 2, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), suite: HPKE_SUITE, pub: callerKeys.encryption_pub, epoch: callerKeys.epoch, not_before: 1, not_after: Date.now() + RELAY_CALL_TIMEOUT_MS, prev: null, }; return { - identity: { v: 1 as const, address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, + identity: { v: 2 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, encryption: { record, signature: "unused" }, }; }, verifyAndPinPeer: async (_machine: MachinePaths, address: string) => ({ + relay_origin: address.slice(address.indexOf("@") + 1), address, identity_pub: callerKeys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: callerKeys.epoch, call_count: 1, }), diff --git a/packages/shared/src/e2ee.ts b/packages/shared/src/e2ee.ts index 5cdf2528..8bfad4be 100644 --- a/packages/shared/src/e2ee.ts +++ b/packages/shared/src/e2ee.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { canonicalEncode } from "./canonical.js"; -import { ADDRESS_RE } from "./keys.js"; +import { ADDRESS_RE, RELAY_ORIGIN_RE } from "./keys.js"; import { CallAccepted, CallCancelled, CallNotCancelled, CallRejected, CallStarted, CallStatus, CancelCall, CorrelationId, CONTEXT_ID_RE, MAX_DETAIL_LENGTH, MAX_MESSAGE_BYTES, @@ -12,7 +12,6 @@ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; const KEY_ID_RE = /^[0-9a-f]{32}$/; const HASH_RE = /^[0-9a-f]{64}$/; const REQUEST_ID_RE = /^[0-9a-f]{32}$/; -const RELAY_ORIGIN_RE = /^[a-z0-9.-]{1,253}$/; function isWellFormedUnicode(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const unit = value.charCodeAt(index); diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 2760929c..2426e66a 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -7,9 +7,17 @@ export const HPKE_SUITE = "DHKEM(P-256,HKDF-SHA256)/HKDF-SHA256/AES-128-GCM" as /** 30 days. A record claiming a longer window is rejected, not clamped. */ export const MAX_ENCRYPTION_KEY_VALIDITY_MS = 2_592_000_000; -// handle@host. The relay origin is part of the signed identity so a record -// published on one relay cannot be presented as valid on another. +// handle@host, for now. The relay binding no longer depends on this shape: +// `relay_origin` below is a signed field of its own, so the address is free to +// become a bare registry key (`@org/handle`) without dropping the property. +// See docs/superpowers/specs/2026-08-05-address-as-registry-key.md. export const ADDRESS_RE = /^[a-z0-9][a-z0-9-]{1,30}@[a-z0-9.-]{1,253}$/; + +// Which relay a record was published on. Lives here rather than in e2ee.ts +// because both signed key records and the envelopes need it, and e2ee.ts +// already imports from this module — defining it the other way round would be +// a cycle. +export const RELAY_ORIGIN_RE = /^[a-z0-9.-]{1,253}$/; const KEY_ID_RE = /^[0-9a-f]{32}$/; // Same width as a key id but a different quantity: the digest of the previous // epoch's transcript. It gets its own pattern so that widening one can never @@ -18,14 +26,20 @@ const PREV_RE = /^[0-9a-f]{32}$/; const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; export const IdentityRecord = z.object({ - v: z.literal(1), + // v2: `relay_origin` became an explicit signed field. It previously rode + // inside `address` as the host part, which tied the cross-relay binding to + // addresses being DNS-shaped. + v: z.literal(2), + relay_origin: z.string().regex(RELAY_ORIGIN_RE), address: z.string().regex(ADDRESS_RE), identity_pub: z.string().regex(BASE64URL_RE).max(256), }); export type IdentityRecordType = z.infer; export const EncryptionKeyRecord = z.object({ - v: z.literal(1), + // v2: see IdentityRecord. + v: z.literal(2), + relay_origin: z.string().regex(RELAY_ORIGIN_RE), address: z.string().regex(ADDRESS_RE), key_id: z.string().regex(KEY_ID_RE), suite: z.literal(HPKE_SUITE), @@ -56,12 +70,14 @@ export type EncryptionKeyRecordType = z.infer; // Field order is part of the signature. Never reorder these lists; adding a // field means a new record version. export function identityTranscript(r: IdentityRecordType): Uint8Array { - return canonicalEncode(["agentcall/identity/v1", r.v, r.address, r.identity_pub]); + return canonicalEncode([ + "agentcall/identity/v2", r.v, r.relay_origin, r.address, r.identity_pub, + ]); } export function encryptionKeyTranscript(r: EncryptionKeyRecordType): Uint8Array { return canonicalEncode([ - "agentcall/encryption-key/v1", r.v, r.address, r.key_id, r.suite, r.pub, + "agentcall/encryption-key/v2", r.v, r.relay_origin, r.address, r.key_id, r.suite, r.pub, r.epoch, r.not_before, r.not_after, r.prev, ]); } diff --git a/packages/shared/test/keys.test.ts b/packages/shared/test/keys.test.ts index 0f2821b0..e3e4d050 100644 --- a/packages/shared/test/keys.test.ts +++ b/packages/shared/test/keys.test.ts @@ -5,13 +5,15 @@ import { } from "../src/keys.js"; const identity = { - v: 1 as const, + v: 2 as const, + relay_origin: "agentcall.benree.tech", address: "ken@agentcall.benree.tech", identity_pub: "BASE64URLPUBLICKEY", }; const encKey = { - v: 1 as const, + v: 2 as const, + relay_origin: "agentcall.benree.tech", address: "ken@agentcall.benree.tech", key_id: "0123456789abcdef0123456789abcdef", suite: HPKE_SUITE, @@ -94,6 +96,29 @@ describe("transcripts", () => { expect(Array.from(a)).not.toEqual(Array.from(b)); }); + // The cross-relay binding. It used to ride inside `address` as the host part, + // which meant it survived only as long as addresses were DNS-shaped. It is an + // explicit signed field now, so a record published on one relay still cannot + // be presented as valid on another once the address is a bare registry key. + it("binds an identity record to its relay", () => { + const a = identityTranscript(identity); + const b = identityTranscript({ ...identity, relay_origin: "relay.other.example" }); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it("binds an encryption key record to its relay", () => { + const a = encryptionKeyTranscript(encKey); + const b = encryptionKeyTranscript({ ...encKey, relay_origin: "relay.other.example" }); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); + + it("requires relay_origin on both records", () => { + const { relay_origin: _i, ...identityWithout } = identity; + const { relay_origin: _e, ...encWithout } = encKey; + expect(IdentityRecord.safeParse(identityWithout).success).toBe(false); + expect(EncryptionKeyRecord.safeParse(encWithout).success).toBe(false); + }); + it("changes when the encryption epoch changes", () => { const a = encryptionKeyTranscript(encKey); const b = encryptionKeyTranscript({ ...encKey, epoch: 2, prev: "a".repeat(32) }); From 79717fbce3096d6c9c41fc651e6b6d0f9f0b7198 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 21:38:35 -0700 Subject: [PATCH 3/6] feat!: cut addresses over to `@org/handle` (#307 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address is a registry key now. `@acme/ken` is canonical; nothing composes a hostname, nothing parses one back out, and no composed address is stored or sent. Deleted rather than rewritten: - `contacts.ts::relayHostWarning` and its twenty-line comment. It existed because an address named a relay while the call was dialled on the calling line's relay, so "calling a hosted address from a line registered elsewhere actually reaches whichever `ken` is on that other relay." With no host in an address that situation cannot be constructed. The hazard, the warning, the explanation and the regression history all go. - `contacts.ts::addressTenant`, `tenant.ts::registrationAddressHost`, `config.ts::addressHost` and `relayAddressHost`, `RegisterResponse.address`, and the hostname branch of `requestOrg`. Two boundaries get stronger, not weaker: - The cross-tenant rejection (#66) read the target org by matching a DNS suffix. It reads the parsed org now, so it no longer depends on how a host is spelled. - `requestOrg` had two sources for one boundary: an `X-AgentCall-Org` header and the request hostname. The credential already settles it — `authenticatedHandle` scopes its lookup by (org, handle), so a token from one org cannot authenticate against another. The hostname fallback is gone and a request that names a tenant only in its hostname now resolves to nothing. `a2a-card` and `self-host` assert that rejection where they used to assert the derivation. - `fetchKeys` checked `address.split("@")[0] !== handle`, which only worked while an address was `handle@host`. It parses now and also binds the org, so a relay cannot answer with a same-named handle from another tenant. `pickOutboundLine` selects by organization instead of relay host. That is the rule the host match was approximating: a line may only call inside its own org, and with the host gone from addresses there is nothing else to match on. The e2ee golden transcripts are regenerated. Checked rather than accepted: the new bytes contain `@acme/alice` and still contain the relay hostname, which is the shape this change intends — relay_origin stays a host, addresses become keys. BREAKING: `/v1/register` no longer returns `address`; envelope `from`/`to`, published key records, and the known-peer trust store all use the new grammar. Spec: docs/superpowers/specs/2026-08-05-address-as-registry-key.md --- apps/relay/src/do.ts | 12 +-- apps/relay/src/index.ts | 6 +- apps/relay/src/keys.ts | 16 ++- apps/relay/src/tenant.ts | 21 ++-- apps/relay/test/a2a-card.test.ts | 8 +- apps/relay/test/a2a-task.test.ts | 2 +- apps/relay/test/callflow.test.ts | 12 +-- apps/relay/test/helpers.ts | 12 +-- apps/relay/test/keys.test.ts | 33 +++--- apps/relay/test/register.test.ts | 23 +++-- apps/relay/test/self-host.test.ts | 13 ++- apps/relay/test/ws.test.ts | 2 +- packages/cli/src/api.ts | 28 +++--- packages/cli/src/call-client.ts | 10 +- packages/cli/src/commands/call.ts | 3 +- packages/cli/src/commands/card.ts | 1 - packages/cli/src/commands/keys.ts | 9 +- packages/cli/src/commands/line.ts | 24 ++--- packages/cli/src/commands/peer.ts | 6 +- packages/cli/src/commands/search.ts | 5 +- packages/cli/src/commands/status.ts | 3 +- packages/cli/src/config.ts | 14 ++- packages/cli/src/contacts.ts | 97 +++++------------- packages/cli/src/doctor.ts | 4 +- packages/cli/src/known-peers.ts | 4 +- packages/cli/src/listener-stages.ts | 10 +- packages/cli/src/outbound.ts | 21 ++-- packages/cli/src/search.ts | 5 +- packages/cli/src/setup.ts | 4 +- packages/cli/test/api.test.ts | 54 +++++----- packages/cli/test/call-client.test.ts | 10 +- packages/cli/test/cli-actions.test.ts | 41 ++++---- packages/cli/test/contacts.test.ts | 111 ++++++++------------- packages/cli/test/doctor.test.ts | 16 +-- packages/cli/test/e2ee.test.ts | 2 +- packages/cli/test/known-peers.test.ts | 27 ++--- packages/cli/test/line-cmd.test.ts | 25 +++-- packages/cli/test/listener-stages.test.ts | 14 +-- packages/cli/test/listener.test.ts | 4 +- packages/cli/test/outbound.test.ts | 83 ++++++++------- packages/cli/test/search.test.ts | 26 ++--- packages/cli/test/telemetry.test.ts | 2 +- packages/shared/src/keys.ts | 13 +-- packages/shared/src/protocol.ts | 32 ++---- packages/shared/test/e2ee.test.ts | 19 +--- packages/shared/test/keys.test.ts | 6 +- packages/shared/test/protocol.test.ts | 44 ++++---- packages/shared/test/task-protocol.test.ts | 2 +- 48 files changed, 428 insertions(+), 511 deletions(-) diff --git a/apps/relay/src/do.ts b/apps/relay/src/do.ts index 55e29bad..7ceb53db 100644 --- a/apps/relay/src/do.ts +++ b/apps/relay/src/do.ts @@ -1,5 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import { +import { formatAddress, a2aError, E2EECallerFrame, E2EEListenerToRelayFrame, MAX_E2EE_WIRE_BYTES, RATE_LIMIT_PER_HOUR, RELAY_CALL_TIMEOUT_MS, safeParseFrame, standardError, type CallStatusType, type OrgAuditEvent, type RelayOperationalErrorCodeType, @@ -499,8 +499,8 @@ export class HandleDO extends DurableObject { if ( !att.relayOrigin || !att.to || frame.envelope.relay_origin !== att.relayOrigin || - frame.envelope.from !== `${att.from}@${att.relayOrigin}` || - frame.envelope.to !== `${att.to}@${att.relayOrigin}` + frame.envelope.from !== formatAddress(att.org, att.from) || + frame.envelope.to !== formatAddress(att.org, att.to) ) return this.fail(ws, "protocol_error"); const correlation_id = frame.correlation_id!; @@ -593,10 +593,10 @@ export class HandleDO extends DurableObject { } if (frame.type === "call_outcome") { if ( - !att.relayOrigin || !att.handle || + !att.relayOrigin || !att.handle || !att.org || frame.envelope.relay_origin !== att.relayOrigin || - frame.envelope.from !== `${att.handle}@${att.relayOrigin}` || - frame.envelope.to !== `${record.from}@${att.relayOrigin}` + frame.envelope.from !== formatAddress(att.org, att.handle) || + frame.envelope.to !== formatAddress(att.org, record.from) ) return; const terminal = frame.terminal === "completed" ? "TASK_STATE_COMPLETED" as const diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index d68f6338..746201a3 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -9,7 +9,7 @@ import { mountPresence } from "./presence.js"; import { mountRoster } from "./roster.js"; import { generateToken, sha256Hex } from "./auth.js"; import { generateAgentId, resolveAgentId } from "./identity.js"; -import { deploymentOrgAllows, identityObjectName, registrationAddressHost, +import { deploymentOrgAllows, identityObjectName, type DeploymentMode } from "./tenant.js"; import { sharedRosterIds } from "./groups.js"; import { checkLimit, NATIVE_CARD, NATIVE_READ, REGISTER, type RateLimitEnv } from "./ratelimit/index.js"; @@ -124,7 +124,7 @@ app.post("/v1/register", async (c) => { { "Retry-After": "5" }, ); } - return c.json({ org, token, address: `${handle}@${registrationAddressHost(org, c.req.url)}` }); + return c.json({ org, token }); }); // Until this existed, a leaked token was permanent: register was the only @@ -239,7 +239,7 @@ app.get("/v1/ws", async (c) => { fwd.headers.set("X-Verified-Org", org); fwd.headers.set("X-Verified-Target", target); fwd.headers.set("X-Verified-Credential-Generation", String(identity.recoveryGeneration)); - fwd.headers.set("X-Verified-Relay-Origin", registrationAddressHost(org, c.req.url)); + fwd.headers.set("X-Verified-Relay-Origin", new URL(c.req.url).hostname); fwd.headers.set("X-Verified-Groups", JSON.stringify(groups)); fwd.headers.set("X-Verified-Actor-IP", c.req.header("cf-connecting-ip") ?? ""); const country = c.req.raw.cf?.country; diff --git a/apps/relay/src/keys.ts b/apps/relay/src/keys.ts index e549e88d..cf6c71e2 100644 --- a/apps/relay/src/keys.ts +++ b/apps/relay/src/keys.ts @@ -4,7 +4,7 @@ import { importIdentityPublicKey, verifyTranscript, } from "@benree/agentcall-shared"; import type { Env } from "./index.js"; -import { registrationAddressHost } from "./tenant.js"; +import { formatAddress } from "@benree/agentcall-shared"; import { NATIVE_CARD, NATIVE_READ } from "./ratelimit/index.js"; import { rateLimit, type RelayAppEnv } from "./middleware.js"; @@ -25,8 +25,14 @@ async function storedIdentity( * address a CLI has ever seen — deriving it any other way here would sign one * address and serve another, and these records are permanent. */ -function addressFor(c: Context, org: string, handle: string): string { - return `${handle}@${registrationAddressHost(org, c.req.url)}`; +// The relay's own hostname. The org used to be glued on as a subdomain; it now +// travels in the address, so this names only the relay. +function relayOriginFor(c: Context): string { + return new URL(c.req.url).hostname; +} + +function addressFor(_c: Context, org: string, handle: string): string { + return formatAddress(org, handle); } export function mountKeys(app: Hono): void { @@ -178,10 +184,10 @@ export function mountKeys(app: Hono): void { // reconstructing at all.) const address = addressFor(c, identity.org, target); return c.json({ - identity: { v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, identity_pub: identityPub }, + identity: { v: 2, relay_origin: relayOriginFor(c), address, identity_pub: identityPub }, encryption: { record: { - v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + v: 2, relay_origin: relayOriginFor(c), address, key_id: row.key_id, suite: row.suite, pub: row.pub, epoch: row.epoch, not_before: row.not_before, not_after: row.not_after, prev: row.prev, }, diff --git a/apps/relay/src/tenant.ts b/apps/relay/src/tenant.ts index a174cc83..a33bfab7 100644 --- a/apps/relay/src/tenant.ts +++ b/apps/relay/src/tenant.ts @@ -1,4 +1,4 @@ -import { HOSTED_RELAY_HOST, ORG_RE, type OrgRoleType } from "@benree/agentcall-shared"; +import { ORG_RE, type OrgRoleType } from "@benree/agentcall-shared"; import { authenticatedHandle } from "./auth.js"; type RequestLike = { header(name: string): string | undefined; url: string }; @@ -28,13 +28,12 @@ export function requestOrg(req: RequestLike, mode?: string, configuredOrg?: stri return configuredOrg; } if (mode !== "hosted" || configuredOrg !== undefined) return ""; - if (ORG_RE.test(header)) return header; - - const host = new URL(req.url).hostname; - const suffix = `.${HOSTED_RELAY_HOST}`; - if (!host.endsWith(suffix)) return ""; - const org = host.slice(0, -suffix.length); - return ORG_RE.test(org) ? org : ""; + // Header only. The hostname used to be a fallback source of the org, which + // made the tenant boundary depend on two things that could disagree; the + // credential settles it either way, because `authenticatedHandle` scopes its + // lookup by (org, handle) and a token from one org cannot authenticate + // against another. One source, and it is the one that is actually proven. + return ORG_RE.test(header) ? header : ""; } export async function authenticateRequest( @@ -67,9 +66,3 @@ export function identityObjectName(identity: { org: string; agentId: string }): return `${identity.org}:${identity.agentId}`; } -export function registrationAddressHost(org: string, requestUrl: string): string { - const host = new URL(requestUrl).hostname; - return host === HOSTED_RELAY_HOST || host.endsWith(`.${HOSTED_RELAY_HOST}`) - ? `${org}.${HOSTED_RELAY_HOST}` - : host; -} diff --git a/apps/relay/test/a2a-card.test.ts b/apps/relay/test/a2a-card.test.ts index f4db8fef..1fee94fb 100644 --- a/apps/relay/test/a2a-card.test.ts +++ b/apps/relay/test/a2a-card.test.ts @@ -180,11 +180,15 @@ describe("GET /v1/a2a/:handle/agent-card.json", () => { expect(res.status).toBe(200); }); - it("derives the tenant from the hosted request hostname", async () => { + // Was: the hostname derives the tenant. That fallback is gone — the org now + // comes only from the authenticated credential path, so a request that names + // the tenant in its hostname and nowhere else must not authenticate. Two + // sources for one boundary is the hazard this removes. + it("refuses to derive the tenant from the request hostname", async () => { const res = await SELF.fetch("https://acme.agentcall.benree.tech/v1/a2a/ken/agent-card.json", { headers: { Authorization: `Bearer ${viewerToken}`, "X-AgentCall-Handle": "viewer" }, }); - expect(res.status).toBe(200); + expect(res.status).toBe(401); }); it("401s an anonymous per-agent card read", async () => { diff --git a/apps/relay/test/a2a-task.test.ts b/apps/relay/test/a2a-task.test.ts index 77f9c1f1..4f62b36a 100644 --- a/apps/relay/test/a2a-task.test.ts +++ b/apps/relay/test/a2a-task.test.ts @@ -442,7 +442,7 @@ describe("A2A task store", () => { `/v1/ws?role=call&to=${callee}`, wsAuth(caller, alphaCallerToken, "alpha-org"), ); - socket.send(JSON.stringify(encryptedCallRequest(caller, callee))); + socket.send(JSON.stringify(encryptedCallRequest(caller, callee, { org: "alpha-org" }))); const ringing = await nextFrame(socket); await nextFrame(listener); diff --git a/apps/relay/test/callflow.test.ts b/apps/relay/test/callflow.test.ts index 30a17588..f4bcded6 100644 --- a/apps/relay/test/callflow.test.ts +++ b/apps/relay/test/callflow.test.ts @@ -61,12 +61,12 @@ describe("call flow", () => { ...wsAuth("audit-caller", callerToken, org), "cf-connecting-ip": "203.0.113.10", }); - caller.send(JSON.stringify(encryptedCallRequest("audit-caller", "audit-callee"))); + caller.send(JSON.stringify(encryptedCallRequest("audit-caller", "audit-callee", { org }))); const ringing = await nextFrame(caller); const incoming = await nextFrame(listener); listener.send(JSON.stringify({ type: "call_accepted", call_id: incoming.call_id })); await nextFrame(caller); - listener.send(JSON.stringify(encryptedCallOutcome(incoming.call_id, "audit-callee", "audit-caller"))); + listener.send(JSON.stringify(encryptedCallOutcome(incoming.call_id, "audit-callee", "audit-caller", "completed", org))); await nextFrame(caller); const { results } = await env.DB.prepare( @@ -114,7 +114,7 @@ describe("call flow", () => { "/v1/ws?role=call&to=retry-callee", wsAuth("retry-caller", callerToken, org), ); - caller.send(JSON.stringify(encryptedCallRequest("retry-caller", "retry-callee"))); + caller.send(JSON.stringify(encryptedCallRequest("retry-caller", "retry-callee", { org }))); const ringing = await nextFrame(caller); const incoming = await nextFrame(listener); expect(ringing).toMatchObject({ type: "call_status", state: "ringing" }); @@ -130,7 +130,7 @@ describe("call flow", () => { } expect(delivered).toEqual({ event: "call.submit" }); - const outcome = encryptedCallOutcome(incoming.call_id, "retry-callee", "retry-caller"); + const outcome = encryptedCallOutcome(incoming.call_id, "retry-callee", "retry-caller", "completed", org); listener.send(JSON.stringify(outcome)); expect(await nextFrame(caller)).toEqual(outcome); } finally { @@ -157,10 +157,10 @@ describe("call flow", () => { `/v1/ws?role=call&to=${callee}`, wsAuth(callerHandle, callerToken, org), ); - caller.send(JSON.stringify(encryptedCallRequest(callerHandle, callee))); + caller.send(JSON.stringify(encryptedCallRequest(callerHandle, callee, { org }))); await nextFrame(caller); const incoming = await nextFrame(listener); - listener.send(JSON.stringify(encryptedCallOutcome(incoming.call_id, callee, callerHandle))); + listener.send(JSON.stringify(encryptedCallOutcome(incoming.call_id, callee, callerHandle, "completed", org))); await nextFrame(caller); } await env.DB.exec("DROP TRIGGER fail_backlog_call_audit"); diff --git a/apps/relay/test/helpers.ts b/apps/relay/test/helpers.ts index ac21502c..c392f489 100644 --- a/apps/relay/test/helpers.ts +++ b/apps/relay/test/helpers.ts @@ -45,31 +45,31 @@ export function wsAuth(handle: string, token: string, org = "acme"): Record>, epoch: nu const pub = await exportPublicKey(encKp.publicKey); const record = { v: 2 as const, - relay_origin: (address ?? `${who.handle}@${HOST}`).split("@")[1]!, - address: address ?? `${who.handle}@${HOST}`, + relay_origin: HOST, + address: address ?? `@acme/${who.handle}`, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, @@ -85,7 +85,7 @@ describe("key publication endpoints", () => { it("rejects an identity record whose address is not the caller", async () => { const who = await newIdentity("kp-two"); - const record = { ...who.identity, address: `someone-else@${HOST}` }; + const record = { ...who.identity, address: "@acme/someone-else" }; const res = await putIdentity(who, { record, signature: await signTranscript(who.idKp.privateKey, identityTranscript(record)), }); @@ -97,7 +97,7 @@ describe("key publication endpoints", () => { // another. Accepting this would store a record whose address the GET below // rewrites to this host — permanently unverifiable against its signature. const who = await newIdentity("kp-foreign-id"); - const record = { ...who.identity, address: `${who.handle}@evil.example` }; + const record = { ...who.identity, address: `@evil/${who.handle}` }; const res = await putIdentity(who, { record, signature: await signTranscript(who.idKp.privateKey, identityTranscript(record)), }); @@ -207,20 +207,17 @@ describe("key publication endpoints", () => { }); it("serves the org-prefixed apex address, not URL.host verbatim (regression)", async () => { - // The test above uses relay.test, where registrationAddressHost and a - // naive `new URL(url).host` reconstruction happen to agree — org and host - // collapse to the same string, so a reverted fix still passes it. They - // diverge on the real hosted relay's apex host: registrationAddressHost - // prefixes the org (`acme.agentcall.benree.tech`) while `new - // URL(url).host` does not (`agentcall.benree.tech`). This is the case the - // bug actually shipped in. + // Was: the served address must be org-prefixed rather than URL.host + // verbatim. Addresses no longer contain a host at all, so the stronger + // invariant this becomes is that the served address does not vary with the + // request URL — the apex, a port, or a subdomain all yield `@acme/`. const APEX = "agentcall.benree.tech"; const handle = "kp-apex2"; - const address = `${handle}@acme.${APEX}`; + const address = `@acme/${handle}`; const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 2 as const, relay_origin: address.split("@")[1]!, + v: 2 as const, relay_origin: HOST, address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { @@ -244,7 +241,7 @@ describe("key publication endpoints", () => { const pub = await exportPublicKey(encKp.publicKey); const encryption = { v: 2 as const, - relay_origin: address.split("@")[1]!, + relay_origin: HOST, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, @@ -280,12 +277,12 @@ describe("key publication endpoints", () => { // so neither exercises this half of the divergence. const APEX = "agentcall.benree.tech"; const handle = "kp-apex-port"; - const address = `${handle}@acme.${APEX}`; + const address = `@acme/${handle}`; const origin = `https://${APEX}:8443`; const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 2 as const, relay_origin: address.split("@")[1]!, + v: 2 as const, relay_origin: HOST, address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { @@ -309,7 +306,7 @@ describe("key publication endpoints", () => { const pub = await exportPublicKey(encKp.publicKey); const encryption = { v: 2 as const, - relay_origin: address.split("@")[1]!, + relay_origin: HOST, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, diff --git a/apps/relay/test/register.test.ts b/apps/relay/test/register.test.ts index 1ff015c7..8b6e6633 100644 --- a/apps/relay/test/register.test.ts +++ b/apps/relay/test/register.test.ts @@ -138,13 +138,15 @@ describe("POST /v1/register", () => { }); }); - it("registers a handle and returns token + address", async () => { + it("registers a handle and returns org + token", async () => { const invite = await issueInvite("acme", "successful-registration"); const res = await register({ invite, handle: "ken", agent_kind: "claude" }, "203.0.113.10"); expect(res.status).toBe(200); - const json = await res.json<{ token: string; address: string }>(); + const json = await res.json<{ org: string; token: string }>(); expect(json.token.length).toBeGreaterThanOrEqual(40); - expect(json.address).toBe("ken@relay.test"); + // No address on the wire: it is formatAddress(org, handle) and the caller + // already holds both. + expect(json).not.toHaveProperty("address"); const inviteRow = await env.DB.prepare("SELECT used_at, used_by FROM invites WHERE org = ? AND used_by = ?") .bind("acme", "ken").first<{ used_at: number | null; used_by: string | null }>(); expect(inviteRow?.used_at).toEqual(expect.any(Number)); @@ -160,7 +162,7 @@ describe("POST /v1/register", () => { const invite = await issueInvite("invite-org", "tenant-proof"); const first = await register({ invite, org: "attacker-choice", handle: "invited" }, "203.0.113.161"); expect(first.status).toBe(200); - expect(await first.json()).toMatchObject({ org: "invite-org", address: "invited@relay.test" }); + expect(await first.json()).toMatchObject({ org: "invite-org" }); const replay = await register({ invite, handle: "replay" }, "203.0.113.162"); expect(replay.status).toBe(404); }); @@ -257,9 +259,9 @@ describe("POST /v1/register", () => { it("registers caller-only (no agent_kind) and stores NULL", async () => { const res = await register({ org: "acme", handle: "solo" }, "203.0.113.13"); expect(res.status).toBe(200); - const json = await res.json<{ token: string; address: string }>(); + const json = await res.json<{ org: string; token: string }>(); expect(json.token.length).toBeGreaterThanOrEqual(40); - expect(json.address).toBe("solo@relay.test"); + const row = await env.DB.prepare("SELECT agent_kind FROM handles WHERE org = ? AND handle = ?") .bind("acme", "solo").first<{ agent_kind: string | null }>(); expect(row?.agent_kind).toBeNull(); @@ -299,14 +301,17 @@ describe("POST /v1/register", () => { })).status).toBe(401); }); - it("returns the tenant hostname on the hosted relay", async () => { + // Was: the response carries `person@hosted.agentcall.benree.tech`. There is + // no address on the wire now, and the tenant comes from the invite rather + // than from any hostname, so what is asserted is the org. + it("returns the invite's tenant on the hosted relay", async () => { const invite = await issueInvite("hosted", "hosted-address"); const res = await SELF.fetch("https://agentcall.benree.tech/v1/register", { method: "POST", headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.153" }, body: JSON.stringify({ invite, handle: "person", agent_kind: "claude" }), }); - expect((await res.json<{ address: string }>()).address).toBe("person@hosted.agentcall.benree.tech"); + expect(await res.json()).toMatchObject({ org: "hosted" }); }); it("uses the invite tenant rather than a conflicting hosted tenant subdomain", async () => { @@ -316,7 +321,7 @@ describe("POST /v1/register", () => { headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.213" }, body: JSON.stringify({ invite, handle: "person" }), }); - expect((await res.json<{ address: string }>()).address).toBe("person@bob.agentcall.benree.tech"); + expect(await res.json()).toMatchObject({ org: "bob" }); }); }); diff --git a/apps/relay/test/self-host.test.ts b/apps/relay/test/self-host.test.ts index 8704d47c..a9e10722 100644 --- a/apps/relay/test/self-host.test.ts +++ b/apps/relay/test/self-host.test.ts @@ -34,8 +34,11 @@ describe("single-organization self-host boundary", () => { expect(deploymentOrgAllows("self-hosted", "Not Valid", "Not Valid")).toBe(false); }); - it("preserves hosted tenant-subdomain routing when self-host mode is absent", () => { - expect(requestOrg(requestLike("acme.agentcall.benree.tech"), "hosted")).toBe("acme"); + // Was: a hosted request derives its org from the tenant subdomain. That + // fallback is deleted — the org comes only from the credential path, so a + // hostname that names a tenant and carries no org header resolves to nothing. + it("no longer derives the org from a tenant subdomain", () => { + expect(requestOrg(requestLike("acme.agentcall.benree.tech"), "hosted")).toBe(""); expect(requestOrg(requestLike("relay.example.com"), "hosted")).toBe(""); }); @@ -58,9 +61,11 @@ describe("single-organization self-host boundary", () => { headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.231" }, body: JSON.stringify({ invite, handle: "customer-user" }), }, selfHostEnv("acme")); - const body = await registered.json<{ token: string; address: string }>(); + const body = await registered.json<{ org: string; token: string }>(); expect(registered.status).toBe(200); - expect(body.address).toBe("customer-user@agents.acme.example"); + // Self-hosted or hosted, the org is the same value and the address is + // rendered from it: the customer hostname does not appear. + expect(body.org).toBe("acme"); const firstRotation = await app.request("https://agents.acme.example/v1/token/rotate", { method: "POST", headers: wsAuth("customer-user", body.token, "acme"), diff --git a/apps/relay/test/ws.test.ts b/apps/relay/test/ws.test.ts index 78a581a2..db5b5f34 100644 --- a/apps/relay/test/ws.test.ts +++ b/apps/relay/test/ws.test.ts @@ -210,7 +210,7 @@ describe("listener attach + status", () => { const acmeCaller = await registerHandle("caller", "claude", "acme-do"); const incoming = nextFrame(acmeListener); const caller = await openWs("/v1/ws?role=call&to=same-person", wsAuth("caller", acmeCaller, "acme-do")); - caller.send(JSON.stringify(encryptedCallRequest("caller", "same-person"))); + caller.send(JSON.stringify(encryptedCallRequest("caller", "same-person", { org: "acme-do" }))); expect(await incoming).toMatchObject({ type: "incoming_call", from: "caller" }); expect(await incoming).not.toHaveProperty("message"); }); diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 28034869..3bce988c 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -4,7 +4,7 @@ import { ListOrgInvitesResponse, ListRosterJoinKeysResponse, RegisterResponse, RevokeOrgInviteResponse, RecoveryIssueResponse, RecoveryReceipt, RecoveryStatusResponse, RevokeRosterJoinKeyResponse, RosterBundle, - EncryptionKeyRecord, IdentityRecord, HPKE_SUITE, MAX_ENCRYPTION_KEY_VALIDITY_MS, + EncryptionKeyRecord, formatAddress, IdentityRecord, HPKE_SUITE, MAX_ENCRYPTION_KEY_VALIDITY_MS, parseAddress, encryptionKeyTranscript, encryptionKeyTranscriptHash, fromBase64Url, identityTranscript, keyIdFor, signTranscript, // AgentKind is ours: registerHandle takes it, and it is the shared type that // replaced the inline "claude" | "codex" unions. @@ -113,7 +113,7 @@ const relayError = (message: string, code: ApiError["code"] = "network"): RelayE export async function registerHandle( relay: string, invite: string, handle: string, agentKind?: AgentKind, opts: { timeoutMs?: number } = {}, -): Promise<{ org: string; token: string; address: string }> { +): Promise<{ org: string; token: string }> { if (!invite) throw new ApiError("An organization invite is required.", "invite_invalid"); assertValidHandle(handle); return relayCall({ relay, path: "/v1/register", method: "POST", @@ -383,13 +383,13 @@ async function importIdentityPrivateKey(pkcs8B64url: string): Promise } export async function publishIdentityKey( - relay: string, auth: Auth, keys: StoredKeys, host: string, + relay: string, auth: Auth, keys: StoredKeys, ): Promise { const record: IdentityRecordType = IdentityRecord.parse({ - // `host` is already the org-scoped address host, which is exactly what the - // envelopes put in `relay_origin`. Keeping the two identical is the point: - // the signed binding and the wire binding must name the same thing. - v: 2, relay_origin: host, address: `${auth.handle}@${host}`, + // Both bindings are derived, never passed in pre-composed: the address is + // a registry key over (org, handle), and the relay origin is the endpoint. + v: 2, relay_origin: new URL(relay).hostname, + address: formatAddress(auth.org, auth.handle), identity_pub: keys.identity_pub, }); // Self-signed: the record is signed by the very key it publishes. The relay @@ -405,7 +405,7 @@ export async function publishIdentityKey( } export async function publishEncryptionKey( - relay: string, auth: Auth, paths: LinePaths, host: string, now: number = Date.now(), + relay: string, auth: Auth, paths: LinePaths, now: number = Date.now(), ): Promise { let keys = loadKeys(paths); let publication = loadPendingEncryptionPublication(paths, keys); @@ -423,8 +423,8 @@ export async function publishEncryptionKey( const pub = keys.encryption_pub; const record: EncryptionKeyRecordType = EncryptionKeyRecord.parse({ v: 2, - relay_origin: host, - address: `${auth.handle}@${host}`, + relay_origin: new URL(relay).hostname, + address: formatAddress(auth.org, auth.handle), key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, @@ -484,9 +484,13 @@ export async function fetchKeys( "invalid", ); } - if (identity.data.address.split("@")[0] !== handle) { + // Was `address.split("@")[0]`, which only worked while an address was + // `handle@host`. Parsing it also binds the organization, so a relay cannot + // answer with a same-named handle from another tenant. + const served = parseAddress(identity.data.address); + if (!served || served.handle !== handle || served.org !== auth.org) { throw new ApiError( - `The relay returned keys for ${identity.data.address} when asked for ${handle}.`, + `The relay returned keys for ${identity.data.address} when asked for ${formatAddress(auth.org, handle)}.`, "invalid", ); } diff --git a/packages/cli/src/call-client.ts b/packages/cli/src/call-client.ts index 734ec91f..d13e9334 100644 --- a/packages/cli/src/call-client.ts +++ b/packages/cli/src/call-client.ts @@ -1,6 +1,6 @@ import WebSocket, { type RawData } from "ws"; import { randomBytes } from "node:crypto"; -import { +import { formatAddress, CORRELATION_ID_RE, E2EERelayToCallerFrame, MAX_E2EE_WIRE_BYTES, RELAY_CALL_TIMEOUT_MS, keyIdFor, normalizeTraceparent, requestTranscript, safeParseFrame, sanitizeDetail, transcriptHash, type CallStatusType, type E2EERequestPayloadType, type ErrorCodeType, @@ -10,7 +10,7 @@ import { openE2EEResponse, sealE2EERequest } from "./e2ee.js"; import { loadKeys } from "./keys.js"; import { verifyAndPinPeer } from "./known-peers.js"; import type { LinePaths } from "./paths.js"; -import { relayAddressHost } from "./config.js"; +import { relayHostOf } from "./config.js"; export class CallError extends Error { constructor( @@ -95,9 +95,9 @@ export async function callAgent(opts: CallOpts): Promise { ? opts.correlationId : createCorrelationId(); const traceparent = normalizeTraceparent(correlationId, opts.traceparent); - const relayOrigin = relayAddressHost(opts.relay, opts.org); - const fromAddress = `${opts.from}@${relayOrigin}`; - const toAddress = `${opts.to}@${relayOrigin}`; + const relayOrigin = relayHostOf(opts.relay); + const fromAddress = formatAddress(opts.org, opts.from); + const toAddress = formatAddress(opts.org, opts.to); const auth = { org: opts.org, handle: opts.from, token: opts.token }; try { assertValidHandle(opts.to); diff --git a/packages/cli/src/commands/call.ts b/packages/cli/src/commands/call.ts index 1865d2cb..65f79c02 100644 --- a/packages/cli/src/commands/call.ts +++ b/packages/cli/src/commands/call.ts @@ -35,7 +35,7 @@ export function register(program: Command): void { } let ctx: LineContext; try { - ctx = pickOutboundLine(machine, `https://${firstPass.host}`, { as: o.as }); + ctx = pickOutboundLine(machine, firstPass.org, { as: o.as }); } catch (e) { console.error(String(e instanceof Error ? e.message : e)); process.exitCode = 1; @@ -48,7 +48,6 @@ export function register(program: Command): void { process.exitCode = 1; return; } - if (parsed.warning) console.error(parsed.warning); const message = messageParts.join(" "); let contextId = o.context; let task = o.task; diff --git a/packages/cli/src/commands/card.ts b/packages/cli/src/commands/card.ts index ec79a2cf..7f4806a0 100644 --- a/packages/cli/src/commands/card.ts +++ b/packages/cli/src/commands/card.ts @@ -79,7 +79,6 @@ export function registerCard(program: Command): void { process.exitCode = 1; return; } - if (parsed.warning) console.error(parsed.warning); try { const card = await fetchCard( relayUrl(cfg), parsed.handle, diff --git a/packages/cli/src/commands/keys.ts b/packages/cli/src/commands/keys.ts index ae985f23..b0e3e04a 100644 --- a/packages/cli/src/commands/keys.ts +++ b/packages/cli/src/commands/keys.ts @@ -1,5 +1,5 @@ import { publishEncryptionKey, publishIdentityKey } from "../api.js"; -import { addressHost, relayUrl } from "../config.js"; +import { lineAddress, relayUrl } from "../config.js"; import { getMachinePaths } from "../paths.js"; import { resolveLine } from "../line-context.js"; import { loadKeys } from "../keys.js"; @@ -16,10 +16,9 @@ export function register(program: { command(name: string): any }): void { const cfg = ctx.config; const stored = loadKeys(ctx.paths); const auth = { org: cfg.org, handle: cfg.handle, token: cfg.token }; - const relayHost = addressHost(cfg); - await publishIdentityKey(relayUrl(cfg), auth, stored, relayHost); - await publishEncryptionKey(relayUrl(cfg), auth, ctx.paths, relayHost); - console.log(`Published identity and encryption key for ${cfg.handle}@${relayHost}.`); + await publishIdentityKey(relayUrl(cfg), auth, stored); + await publishEncryptionKey(relayUrl(cfg), auth, ctx.paths); + console.log(`Published identity and encryption key for ${lineAddress(cfg)}.`); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/packages/cli/src/commands/line.ts b/packages/cli/src/commands/line.ts index 77b9ba9f..1451c6c0 100644 --- a/packages/cli/src/commands/line.ts +++ b/packages/cli/src/commands/line.ts @@ -1,9 +1,9 @@ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { AgentKind } from "@benree/agentcall-shared"; +import { formatAddress, type AgentKind } from "@benree/agentcall-shared"; import { publishEncryptionKey, publishIdentityKey, registerHandle } from "../api.js"; import { publishCard } from "../card.js"; -import { addressHost, resolveLineWorkdir, type LineConfig } from "../config.js"; +import { lineAddress, resolveLineWorkdir, type LineConfig } from "../config.js"; import { assertValidLineName, listLines, readyLines, saveLineConfig } from "../lines.js"; import { listenerPathDirs } from "../listener-path.js"; import { host } from "../outbound.js"; @@ -63,9 +63,8 @@ export async function publishStoredKeys( fns: { identity?: typeof publishIdentityKey; encryption?: typeof publishEncryptionKey } = {}, ): Promise { const auth = { org: line.org, handle: line.handle, token: line.token }; - const canonicalHost = addressHost(line); - await (fns.identity ?? publishIdentityKey)(line.relay, auth, stored, canonicalHost); - await (fns.encryption ?? publishEncryptionKey)(line.relay, auth, paths, canonicalHost); + await (fns.identity ?? publishIdentityKey)(line.relay, auth, stored); + await (fns.encryption ?? publishEncryptionKey)(line.relay, auth, paths); } // A handle that is `-` is guessable from an address the @@ -137,7 +136,7 @@ export async function addLine(m: MachinePaths, opts: AddLineOpts): Promise<{ add rmSync(paths.dir, { recursive: true, force: true }); throw error; } - const { org, token, address } = registration; + const { org, token } = registration; // Registration succeeded, so the handle is spent and unreclaimable (#16). // config.json is therefore the first post-registration write — the key file @@ -200,7 +199,7 @@ export async function addLine(m: MachinePaths, opts: AddLineOpts): Promise<{ add // person.json is written LAST, and only for the first line, so a failed // first setup never leaves primary_line pointing at a broken line. if (!existsSync(m.personFile)) savePerson(m, { primary_line: opts.name }); - return { address }; + return { address: formatAddress(org, opts.handle) }; }); } @@ -313,12 +312,11 @@ export function listLinesReport( } return listLines(m).map((l) => ({ name: l.name, - // host() (shared with outbound.ts) falls back to the raw string on an - // unparseable relay instead of throwing — a broken line must still show - // up in the listing (marked broken below), same contract listLines - // itself already guarantees. A bare `new URL(...).host` here would take - // down the whole `line list` command over one bad config.json. - address: l.config ? `${l.config.handle}@${host(l.config.relay)}` : "—", + // The relay no longer appears here: an address is (org, handle), and the + // relay is shown in its own column below. That also removes the reason + // this had to tolerate an unparseable relay URL — a broken line still + // lists, and its address still renders. + address: l.config ? formatAddress(l.config.org, l.config.handle) : "—", relay: l.config?.relay ?? "—", state: !l.ok ? "broken" : !l.config!.agent_kind ? "caller-only" : presence(l.config!) ? "online" : "offline", primary: l.name === primary, diff --git a/packages/cli/src/commands/peer.ts b/packages/cli/src/commands/peer.ts index eedfa68c..5f56eca1 100644 --- a/packages/cli/src/commands/peer.ts +++ b/packages/cli/src/commands/peer.ts @@ -16,14 +16,14 @@ export function register(program: { command(name: string): any }): void { try { const first = resolveAddress(machine, address); if (!first.ok) throw new Error(first.error); - const ctx = pickOutboundLine(machine, `https://${first.host}`, { as: o.as }); + const ctx = pickOutboundLine(machine, first.org, { as: o.as }); const cfg = ctx.config; const resolved = resolveAddress(machine, address, relayUrl(cfg), cfg.org); if (!resolved.ok) throw new Error(resolved.error); const bundle = await fetchKeys( relayUrl(cfg), { org: cfg.org, handle: cfg.handle, token: cfg.token }, resolved.handle, ); - const peer = await verifyAndPinPeer(machine, `${resolved.handle}@${resolved.host}`, bundle); + const peer = await verifyAndPinPeer(machine, resolved.address, bundle); console.log(`${peer.address}\nPinned fingerprint: ${peer.fingerprint}\nServed fingerprint: ${peer.fingerprint}`); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); @@ -40,7 +40,7 @@ export function register(program: { command(name: string): any }): void { const machine = getMachinePaths(); const resolved = resolveAddress(machine, o.reset); if (!resolved.ok) throw new Error(resolved.error); - const address = `${resolved.handle}@${resolved.host}`; + const address = resolved.address; await resetPeerTrust(machine, address); console.log(`Removed the identity pin for ${address}. The next verified contact will establish a new pin.`); } catch (error) { diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 5635aa50..dd00d210 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { addressHost, relayUrl } from "../config.js"; +import { relayUrl } from "../config.js"; import { loadMemberships } from "../rosters.js"; import { refreshRoster } from "../search-refresh.js"; import { allRostersFailed, DEFAULT_SEARCH_LIMIT, rank, renderResults, sanitize, toEntries, type RosterStatus, type SearchEntry } from "../search.js"; @@ -37,13 +37,12 @@ export function register(program: Command, lineFor: LineResolver): void { return; } - const host = addressHost(cfg); const entries: SearchEntry[] = []; const statuses: RosterStatus[] = []; for (const m of memberships) { try { const out = await refreshRoster(ctx.paths, m.name, m.roster_id, identity, { org: cfg.org, handle: cfg.handle, token: cfg.token }, { offline: o.offline }); - entries.push(...toEntries(m.name, host, out.entries)); + entries.push(...toEntries(m.name, cfg.org, out.entries)); statuses.push({ name: m.name, ageSeconds: out.ageSeconds, stale: out.stale }); } catch (e) { console.error(`${m.name}: ${e instanceof Error ? e.message : String(e)}`); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 3045ce0b..258070b7 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -21,7 +21,7 @@ export function register(program: { command(name: string): any }): void { } let ctx: LineContext; try { - ctx = pickOutboundLine(machine, `https://${firstPass.host}`, { as: o.as }); + ctx = pickOutboundLine(machine, firstPass.org, { as: o.as }); } catch (e) { console.error(String(e instanceof Error ? e.message : e)); process.exitCode = 1; @@ -35,7 +35,6 @@ export function register(program: { command(name: string): any }): void { process.exitCode = 1; return; } - if (parsed.warning) console.error(parsed.warning); try { const { online } = await getStatus(cfgRelay, parsed.handle, { org: cfg.org, handle: cfg.handle, token: cfg.token }); console.log(online ? "online" : "offline"); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 399c8981..f436aa27 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from "node:fs"; import { isAbsolute } from "node:path"; -import { HOSTED_RELAY_HOST, type AgentKind } from "@benree/agentcall-shared"; +import { formatAddress, HOSTED_RELAY_HOST, type AgentKind } from "@benree/agentcall-shared"; import type { LinePaths } from "./paths.js"; // Per-line credentials and settings. Config (and the flat Paths it paired @@ -77,8 +77,16 @@ export function relayUrl(cfg?: LineConfig): string { return normalizeRelay(envRelay ?? cfg?.relay ?? DEFAULT_RELAY); } -export function addressHost(cfg: LineConfig): string { - return relayAddressHost(relayUrl(cfg), cfg.org); +// The relay's hostname, for the `relay_origin` binding. The org used to be +// glued on as a subdomain; it travels in the address now. +export function relayHostOf(relay: string): string { + return new URL(relay).hostname; +} + +// The line's own address. Formatted from (org, handle), never composed from a +// host and never stored — see the spec on address-as-rendering. +export function lineAddress(cfg: LineConfig): string { + return formatAddress(cfg.org, cfg.handle); } export function relayAddressHost(relay: string, org: string): string { diff --git a/packages/cli/src/contacts.ts b/packages/cli/src/contacts.ts index 059fc4f7..64cf0794 100644 --- a/packages/cli/src/contacts.ts +++ b/packages/cli/src/contacts.ts @@ -1,11 +1,11 @@ import { mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { z } from "zod"; -import { HOSTED_RELAY_HOST, parseAddress } from "@benree/agentcall-shared"; +import { parseAddress } from "@benree/agentcall-shared"; import type { MachinePaths } from "./paths.js"; import { readJsonStore } from "./json-store.js"; -// Never matches anything containing "@", so a contact name can never be -// mistaken for a handle@host address during resolution. +// Never matches "@" or "/", so a contact name can never be mistaken for an +// `@org/handle` address during resolution. export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/i; const ContactSchema = z.object({ @@ -73,53 +73,9 @@ export function removeContact(p: MachinePaths, name: string): void { } export type Resolved = - | { ok: true; handle: string; host: string; address: string; warning?: string } + | { ok: true; org: string; handle: string; address: string } | { ok: false; error: string }; -// An address names a relay, but a call is dialled on the calling LINE's relay -// and only the handle travels — so calling a hosted address from a line -// registered elsewhere actually reaches whichever "ken" is on that other -// relay. This surfaces the divergence instead of letting it happen silently. -// -// A WARNING rather than a rejection, deliberately. The relay builds every -// address from HOSTED_RELAY_HOST (packages/shared/src/protocol.ts), so a -// self-hosted or `wrangler dev` relay hands out hosted-host addresses that can -// never match its own host; refusing those breaks local development and -// self-hosting for a mismatch that is currently normal. The merge of -// origin/main briefly reinstated the rejection — main had never made this -// change — which would have re-broken both. Note this is distinct from the -// cross-tenant check below, which stays a hard REJECTION: that one is a -// security boundary (#66), this one is a diagnostic. -// -// `org` still participates, from main: on the hosted relay a tenant's addresses -// are `@.${HOSTED_RELAY_HOST}`, so naming the expected host without -// the org prefix would make the warning itself wrong. -// -// An unparseable relay URL yields no warning — a diagnostic must not become a -// second failure mode. -function relayHostWarning(address: string, host: string, relay?: string, org?: string): string | undefined { - if (!relay) return; - let relayHost: string; - try { - relayHost = new URL(relay).host; - } catch { - return; - } - const expected = relayHost === HOSTED_RELAY_HOST && org ? `${org}.${relayHost}` : relayHost; - if (!expected || expected === host) return; - return ( - `Warning: ${address} names the relay ${host}, but this line is registered on ${expected}. ` + - `The call goes to "${address.slice(0, address.indexOf("@"))}" on ${expected}, which may be a different agent.` - ); -} - -function addressTenant(host: string): string | undefined { - const suffix = `.${HOSTED_RELAY_HOST}`; - if (!host.endsWith(suffix)) return undefined; - const org = host.slice(0, -suffix.length); - return org && !org.includes(".") ? org : undefined; -} - // The single resolution path shared by `call`, `status`, and `card`, so the // three commands cannot drift: "@" means a literal address, anything else is // a contact-book lookup. `relay` is the URL the caller will actually dial; @@ -127,33 +83,30 @@ function addressTenant(host: string): string | undefined { // `org` is the calling LINE's tenant, not the machine's: the contact book is // shared across lines (person-scoped) but the tenant check is per-call, so the // caller passes the org of whichever line is placing this call. -export function resolveAddress(p: MachinePaths, arg: string, relay?: string, org?: string): Resolved { - if (arg.includes("@")) { - const parsed = parseAddress(arg); - if (!parsed) return { ok: false, error: `Invalid address: ${arg} (expected handle@host)` }; - const targetOrg = addressTenant(parsed.host); - if (org && targetOrg && targetOrg !== org) { - return { ok: false, error: `Address ${arg} belongs to organization "${targetOrg}", but this install belongs to "${org}".` }; +export function resolveAddress(p: MachinePaths, arg: string, _relay?: string, org?: string): Resolved { + const check = (address: string, label?: string): Resolved => { + const parsed = parseAddress(address); + if (!parsed) { + return label + ? { ok: false, error: `Contact "${label}" has an invalid address: ${address}` } + : { ok: false, error: `Invalid address: ${address} (expected @org/handle)` }; } - const warning = relayHostWarning(arg, parsed.host, relay, org); - return warning ? { ok: true, ...parsed, address: arg, warning } : { ok: true, ...parsed, address: arg }; - } + // The cross-tenant boundary (#66). It used to derive the target org by + // string-matching a DNS suffix; the org is now a field of the address, so + // this reads it instead of parsing it out of a hostname. + if (org && parsed.org !== org) { + const who = label ? `Contact "${label}"` : `Address ${address}`; + return { ok: false, error: `${who} belongs to organization "${parsed.org}", but this install belongs to "${org}".` }; + } + return { ok: true, ...parsed, address }; + }; + + if (arg.includes("/") || arg.startsWith("@")) return check(arg); + const { contacts } = loadContacts(p); const hit = contacts.find((c) => c.name.toLowerCase() === arg.toLowerCase()); if (!hit) { - return { ok: false, error: `No contact named "${arg}" — run \`agentcall contacts list\`, or use a full handle@host address.` }; - } - const parsed = parseAddress(hit.address); - if (!parsed) return { ok: false, error: `Contact "${hit.name}" has an invalid address: ${hit.address}` }; - const targetOrg = addressTenant(parsed.host); - if (org && targetOrg && targetOrg !== org) { - return { - ok: false, - error: `Contact "${hit.name}" belongs to organization "${targetOrg}", but this install belongs to "${org}".`, - }; + return { ok: false, error: `No contact named "${arg}" — run \`agentcall contacts list\`, or use a full @org/handle address.` }; } - const warning = relayHostWarning(hit.address, parsed.host, relay, org); - return warning - ? { ok: true, ...parsed, address: hit.address, warning: `Contact "${hit.name}": ${warning}` } - : { ok: true, ...parsed, address: hit.address }; + return check(hit.address, hit.name); } diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 8347c839..8a22fbb9 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -3,7 +3,7 @@ import { lstatSync, readFileSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { encryptionKeyTranscript, importIdentityPublicKey, keyIdFor, verifyTranscript } from "@benree/agentcall-shared"; import { callAgent } from "./call-client.js"; -import { addressHost, relayUrl, resolveLineWorkdir, type LineConfig, type Workdir } from "./config.js"; +import { lineAddress, relayUrl, resolveLineWorkdir, type LineConfig, type Workdir } from "./config.js"; import { inspectListenerService, type ListenerServiceStatus, @@ -184,7 +184,7 @@ export async function checkLineKeyHealth( const remote = await fetchFn( relayUrl(cfg), { org: cfg.org, handle: cfg.handle, token: cfg.token }, cfg.handle, ); - const expectedAddress = `${cfg.handle}@${addressHost(cfg)}`; + const expectedAddress = lineAddress(cfg); const signatureValid = await verifyTranscript( await importIdentityPublicKey(remote.identity.identity_pub), encryptionKeyTranscript(remote.encryption.record), diff --git a/packages/cli/src/known-peers.ts b/packages/cli/src/known-peers.ts index 50be0cc9..4d81670d 100644 --- a/packages/cli/src/known-peers.ts +++ b/packages/cli/src/known-peers.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { z } from "zod"; -import { +import { ADDRESS_RE, encryptionKeyTranscript, fingerprint, identityTranscript, importIdentityPublicKey, verifyTranscript, type EncryptionKeyRecordType, type IdentityRecordType, } from "@benree/agentcall-shared"; @@ -16,7 +16,7 @@ const KnownPeerSchema = z.object({ // its origin — and the identity transcript covers it, so a stored peer // without it cannot have its fingerprint recomputed. relay_origin: z.string().regex(/^[a-z0-9.-]{1,253}$/), - address: z.string().regex(/^[a-z0-9][a-z0-9-]{1,30}@[a-z0-9.-]{1,253}$/), + address: z.string().regex(ADDRESS_RE), identity_pub: z.string().regex(/^[A-Za-z0-9_-]+$/).max(256), fingerprint: z.string().regex(/^SHA256:[0-9a-f]{32}$/), first_seen_at: z.number().int().nonnegative(), diff --git a/packages/cli/src/listener-stages.ts b/packages/cli/src/listener-stages.ts index 75522cda..049eda0e 100644 --- a/packages/cli/src/listener-stages.ts +++ b/packages/cli/src/listener-stages.ts @@ -6,8 +6,8 @@ // handler for the sequencing: envelope opened and peer verified BEFORE // policy resolution; policy resolved BEFORE any agent spawn. import type { AgentKind, E2EEOutcomeType, E2EEResponsePayloadType, E2EERequestPayloadType } from "@benree/agentcall-shared"; -import { keyIdFor } from "@benree/agentcall-shared"; -import { relayAddressHost, type Workdir } from "./config.js"; +import { formatAddress, keyIdFor } from "@benree/agentcall-shared"; +import { relayHostOf, type Workdir } from "./config.js"; import { openE2EERequest, sealE2EEResponse } from "./e2ee.js"; import { fetchKeys } from "./api.js"; import { verifyAndPinPeer, type KnownPeer } from "./known-peers.js"; @@ -80,9 +80,9 @@ export async function openInboundEnvelope( }, io: OpenEnvelopeIo, ): Promise { - const relayOrigin = relayAddressHost(input.relay, input.org); - const fromAddress = `${input.from}@${relayOrigin}`; - const toAddress = `${input.handle}@${relayOrigin}`; + const relayOrigin = relayHostOf(input.relay); + const fromAddress = formatAddress(input.org, input.from); + const toAddress = formatAddress(input.org, input.handle); try { const callerBundle = await io.fetchKeys( input.relay, { org: input.org, handle: input.handle, token: input.token }, input.from, diff --git a/packages/cli/src/outbound.ts b/packages/cli/src/outbound.ts index e7802595..88fed57d 100644 --- a/packages/cli/src/outbound.ts +++ b/packages/cli/src/outbound.ts @@ -18,21 +18,24 @@ export function host(relay: string): string { // (callClient.ts:36), so "one identity outbound" can only mean one identity // per relay. With every line on the same relay — the common case — this is // always the primary and the user never sees it. +// Selects by organization, not by relay host. The address used to name a relay +// and this matched against it; an address is a registry key now and names an +// org instead. That is the rule this was always approximating — a line may only +// call inside its own organization, so the relay host was a proxy for the org. export function pickOutboundLine( - m: MachinePaths, destinationRelay: string, opts: { as?: string } = {}, + m: MachinePaths, want: string, opts: { as?: string } = {}, ): LineContext { const lines = readyLines(m); - const want = host(destinationRelay); if (opts.as !== undefined && opts.as !== "") { const chosen = lines.find((l) => l.name === opts.as); if (!chosen) { throw new Error(`No line named "${opts.as}". This machine has: ${lines.map((l) => l.name).join(", ") || "none"}.`); } - if (host(chosen.config.relay) !== want) { + if (chosen.config.org !== want) { throw new Error( - `Line "${opts.as}" is registered on ${host(chosen.config.relay)}, but that address is on ${want}. ` + - `A line can only call within its own relay.`, + `Line "${opts.as}" belongs to organization "${chosen.config.org}", but that address is in "${want}". ` + + `A line can only call within its own organization.`, ); } return { machine: m, ...chosen }; @@ -46,11 +49,11 @@ export function pickOutboundLine( if (lines.length === 0) { throw new Error("No agentcall config found. Run `agentcall setup` first."); } - const candidates = lines.filter((l) => host(l.config.relay) === want); + const candidates = lines.filter((l) => l.config.org === want); if (candidates.length === 0) { - const relays = [...new Set(lines.map((l) => host(l.config.relay)))]; + const orgs = [...new Set(lines.map((l) => l.config.org))]; throw new Error( - `No line on ${want}. This machine has lines on: ${relays.join(", ") || "no relays"}. ` + + `No line in organization "${want}". This machine has lines in: ${orgs.join(", ") || "no organizations"}. ` + `Add one with \`agentcall line add --relay \`.`, ); } @@ -66,7 +69,7 @@ export function pickOutboundLine( if (chosen) return { machine: m, ...chosen }; throw new Error( - `Several lines can call ${want} (${candidates.map((l) => l.name).join(", ")}) and the primary is not among them. ` + + `Several lines can call into "${want}" (${candidates.map((l) => l.name).join(", ")}) and the primary is not among them. ` + `Pick one with --as .`, ); } diff --git a/packages/cli/src/search.ts b/packages/cli/src/search.ts index 039e4d11..5a63ee9f 100644 --- a/packages/cli/src/search.ts +++ b/packages/cli/src/search.ts @@ -8,6 +8,7 @@ // what makes "the query never leaves your machine" true rather than aspirational; // all network lives in searchRefresh.ts/api.ts instead. +import { formatAddress } from "@benree/agentcall-shared"; import type { BundleEntryType } from "@benree/agentcall-shared"; type SearchField = "keywords" | "name" | "description"; @@ -127,12 +128,12 @@ export function sanitize(text: string, max = 200): string { return stripped.length > max ? stripped.slice(0, max) : stripped; } -export function toEntries(roster: string, host: string, entries: BundleEntryType[]): SearchEntry[] { +export function toEntries(roster: string, org: string, entries: BundleEntryType[]): SearchEntry[] { return entries.flatMap((e) => e.tasks.map((t) => ({ roster, handle: e.handle, - address: `${e.handle}@${host}`, + address: formatAddress(org, e.handle), task: t.id, name: t.name, description: t.description, diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 5794b195..f7bb5482 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -6,7 +6,7 @@ import { listLines } from "./lines.js"; import { resolveLine } from "./line-context.js"; import { getMachinePaths } from "./paths.js"; import { canPrompt, ask as ttyAsk } from "./tty.js"; -import { addressHost, relayUrl, resolveLineWorkdir, type LineConfig } from "./config.js"; +import { lineAddress, relayUrl, resolveLineWorkdir, type LineConfig } from "./config.js"; import { defaultResolveBin, listenerPathDirs } from "./listener-path.js"; import { isEphemeralDir } from "./bin.js"; import { host } from "./outbound.js"; @@ -299,7 +299,7 @@ export async function runSetup(opts: SetupOpts): Promise<{ ready: boolean }> { ` Relay: ${cfg.relay}\n` + ` Address: ${address}\n\n` + `You can call other agents:\n` + - ` agentcall call ken@${addressHost(cfg)} "hello"\n\n` + + ` agentcall call ken "hello"\n\n` + // NOT "re-run `agentcall setup`" any more: setup is first-run only, so // a re-run prints the line list and changes nothing. Before lines, a // re-run genuinely upgraded a caller-only install in place, keeping the diff --git a/packages/cli/test/api.test.ts b/packages/cli/test/api.test.ts index 977265e5..94e87af9 100644 --- a/packages/cli/test/api.test.ts +++ b/packages/cli/test/api.test.ts @@ -72,8 +72,8 @@ function serveCapturing(status: number, body: unknown, captured: unknown[]): Pro describe("api client", () => { it("registers", async () => { - const relay = await serve(200, { org: "acme", token: "tok", address: "ken@acme.agentcall.benree.tech" }); - expect(await registerHandle(relay, "valid-invite", "ken", "claude")).toEqual({ org: "acme", token: "tok", address: "ken@acme.agentcall.benree.tech" }); + const relay = await serve(200, { org: "acme", token: "tok" }); + expect(await registerHandle(relay, "valid-invite", "ken", "claude")).toEqual({ org: "acme", token: "tok" }); }); it("rejects a malformed handle locally, without hitting the relay", async () => { // Point at a port nothing is listening on: if validation didn't run @@ -197,8 +197,8 @@ describe("api client", () => { it("registers caller-only: omits agent_kind from the request body entirely", async () => { const captured: unknown[] = []; - const relay = await serveCapturing(200, { org: "acme", token: "tok", address: "solo@acme.agentcall.benree.tech" }, captured); - expect(await registerHandle(relay, "valid-invite", "solo")).toEqual({ org: "acme", token: "tok", address: "solo@acme.agentcall.benree.tech" }); + const relay = await serveCapturing(200, { org: "acme", token: "tok" }, captured); + expect(await registerHandle(relay, "valid-invite", "solo")).toEqual({ org: "acme", token: "tok" }); expect(captured).toEqual([{ invite: "valid-invite", handle: "solo" }]); }); it("creates an invite with tenant credentials", async () => { @@ -470,13 +470,13 @@ async function buildValidKeysResponse( keys: StoredKeys, address: string, ): Promise<{ identity: IdentityRecordType; encryption: { record: EncryptionKeyRecordType; signature: string } }> { const identity: IdentityRecordType = { - v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + v: 2, relay_origin: "relay.test", address, identity_pub: keys.identity_pub, }; const now = 1_754_000_000_000; const record: EncryptionKeyRecordType = { v: 2, - relay_origin: address.slice(address.indexOf("@") + 1), + relay_origin: "relay.test", address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, @@ -510,11 +510,11 @@ describe("key publication", () => { }); vi.stubGlobal("fetch", fetchMock); - await publishIdentityKey("https://relay.test", auth, keys, "relay.test"); + await publishIdentityKey("https://relay.test", auth, keys); expect(seen?.url).toBe("https://relay.test/v1/keys/identity"); const body = JSON.parse(seen!.body) as { record: IdentityRecordType; signature: string }; - expect(body.record.address).toBe("ken@relay.test"); + expect(body.record.address).toBe("@acme/ken"); expect(body.record.identity_pub).toBe(keys.identity_pub); // The record must be self-signed by the very key it publishes — that is @@ -542,7 +542,7 @@ describe("key publication", () => { return new Response(JSON.stringify({ ok: true }), { status: 200 }); })); - await publishEncryptionKey("https://relay.test", auth, linePaths(home), "relay.test", 1_754_000_000_000); + await publishEncryptionKey("https://relay.test", auth, linePaths(home), 1_754_000_000_000); const body = JSON.parse(seen!) as { record: EncryptionKeyRecordType; signature: string }; expect(body.record.epoch).toBe(keys.epoch); @@ -578,11 +578,11 @@ describe("key publication", () => { return new Response(JSON.stringify({ ok: true }), { status: 200 }); })); - await publishEncryptionKey("https://relay.test", auth, paths, "relay.test", 1_754_000_000_000); + await publishEncryptionKey("https://relay.test", auth, paths, 1_754_000_000_000); keys = await rotateEncryptionKey(paths); - await publishEncryptionKey("https://relay.test", auth, paths, "relay.test", 1_754_001_000_000); + await publishEncryptionKey("https://relay.test", auth, paths, 1_754_001_000_000); keys = await rotateEncryptionKey(paths); - await publishEncryptionKey("https://relay.test", auth, paths, "relay.test", 1_754_002_000_000); + await publishEncryptionKey("https://relay.test", auth, paths, 1_754_002_000_000); const records = publications.map(({ record }) => record); expect(records.map((record) => record.epoch)).toEqual([1, 2, 3]); @@ -612,7 +612,7 @@ describe("key publication", () => { await generateIdentityKeys(paths); vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 503 }))); await expect(publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_754_000_000_000, + "https://relay.test", auth, paths, 1_754_000_000_000, )).rejects.toThrow(/could not publish/i); await expect(rotateEncryptionKey(paths)).rejects.toThrow(/not been published/i); } finally { @@ -635,14 +635,14 @@ describe("key publication", () => { })); await expect(publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_754_000_000_000, + "https://relay.test", auth, paths, 1_754_000_000_000, )).rejects.toMatchObject({ code: "network" }); expect(loadPendingEncryptionPublication(paths)).toBeDefined(); // A different `now` proves retry reuses persisted signed bytes rather // than constructing a conflicting record at the same epoch. await publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_755_000_000_000, + "https://relay.test", auth, paths, 1_755_000_000_000, ); expect(bodies[1]).toBe(bodies[0]); expect(loadPendingEncryptionPublication(paths)).toBeDefined(); @@ -676,8 +676,8 @@ describe("key publication", () => { })); const results = await Promise.allSettled([ - publishEncryptionKey("https://relay.test", auth, paths, "relay.test", 1_754_000_000_000), - publishEncryptionKey("https://relay.test", auth, paths, "relay.test", 1_755_000_000_000), + publishEncryptionKey("https://relay.test", auth, paths, 1_754_000_000_000), + publishEncryptionKey("https://relay.test", auth, paths, 1_755_000_000_000), ]); expect(results.map(({ status }) => status).sort()).toEqual(["fulfilled", "rejected"]); @@ -714,17 +714,17 @@ describe("key publication", () => { })); const oldPublisher = publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_754_000_000_000, + "https://relay.test", auth, paths, 1_754_000_000_000, ); await started; // An exact concurrent publisher receives the acknowledgement and records // epoch 1 while the first process remains paused after its PUT. await publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_755_000_000_000, + "https://relay.test", auth, paths, 1_755_000_000_000, ); const second = await rotateEncryptionKey(paths); await publishEncryptionKey( - "https://relay.test", auth, paths, "relay.test", 1_756_000_000_000, + "https://relay.test", auth, paths, 1_756_000_000_000, ); const third = await rotateEncryptionKey(paths); expect(second.epoch).toBe(2); @@ -753,7 +753,7 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "ken@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/ken"); vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(response), { status: 200 }))); const result = await fetchKeys("https://relay.test", auth, "ken"); @@ -771,7 +771,7 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "ken@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/ken"); const brokenIdentity: Record = { ...response.identity }; delete brokenIdentity.identity_pub; const malformed = { ...response, identity: brokenIdentity }; @@ -788,7 +788,7 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "ken@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/ken"); const malformed = { ...response, encryption: { ...response.encryption, record: { ...response.encryption.record, key_id: "not-32-hex-chars" } }, @@ -810,7 +810,7 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "sarah@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/sarah"); vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(response), { status: 200 }))); await expect(fetchKeys("https://relay.test", auth, "ken")).rejects.toMatchObject({ code: "invalid" }); @@ -827,8 +827,8 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "ken@relay.test"); - const other = await buildValidKeysResponse(keys, "sarah@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/ken"); + const other = await buildValidKeysResponse(keys, "@acme/sarah"); const mixed = { identity: response.identity, encryption: other.encryption }; vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(mixed), { status: 200 }))); @@ -843,7 +843,7 @@ describe("key publication", () => { const home = mkdtempSync(join(tmpdir(), "agentcall-api-")); try { const keys = await generateIdentityKeys(linePaths(home)); - const response = await buildValidKeysResponse(keys, "ken@relay.test"); + const response = await buildValidKeysResponse(keys, "@acme/ken"); const malformed = { ...response, encryption: { ...response.encryption, signature: 12345 } }; vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(malformed), { status: 200 }))); diff --git a/packages/cli/test/call-client.test.ts b/packages/cli/test/call-client.test.ts index 5d876002..04e2051d 100644 --- a/packages/cli/test/call-client.test.ts +++ b/packages/cli/test/call-client.test.ts @@ -77,7 +77,7 @@ async function identity(name: string): Promise<{ keys: StoredKeys; paths: Return async function encryptionRecord(address: string, keys: StoredKeys): Promise { return { - v: 2, relay_origin: address.slice(address.indexOf("@") + 1), + v: 2, relay_origin: "relay.test", address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, pub: keys.encryption_pub, epoch: keys.epoch, not_before: 1, not_after: Date.now() + 1_000_000, prev: null, @@ -90,8 +90,8 @@ async function fixture(relay: string, overrides: Partial = {}) { const origin = new URL(relay).hostname; const from = overrides.from ?? "me"; const to = overrides.to ?? "ken"; - const fromAddress = `${from}@${origin}`; - const toAddress = `${to}@${origin}`; + const fromAddress = `@acme/${from}`; + const toAddress = `@acme/${to}`; const recipientRecord = await encryptionRecord(toAddress, recipient.keys); const opts: CallOpts = { relay, org: "acme", from, token: "tok", to, message: "hi", paths: sender.paths, @@ -99,13 +99,13 @@ async function fixture(relay: string, overrides: Partial = {}) { keyDeps: { fetchKeys: async () => ({ identity: { - v: 2, relay_origin: toAddress.slice(toAddress.indexOf("@") + 1), + v: 2, relay_origin: origin, address: toAddress, identity_pub: recipient.keys.identity_pub, }, encryption: { record: recipientRecord, signature: "unused" }, }), verifyAndPinPeer: async () => ({ - relay_origin: toAddress.slice(toAddress.indexOf("@") + 1), + relay_origin: origin, address: toAddress, identity_pub: recipient.keys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: recipient.keys.epoch, call_count: 1, diff --git a/packages/cli/test/cli-actions.test.ts b/packages/cli/test/cli-actions.test.ts index 8ea620e1..887661c9 100644 --- a/packages/cli/test/cli-actions.test.ts +++ b/packages/cli/test/cli-actions.test.ts @@ -22,21 +22,20 @@ import { openE2EERequest, sealE2EEResponse } from "../src/e2ee.js"; import type { StoredKeys } from "../src/keys.js"; import { tempDir } from "./helpers.js"; -// The "local-sota" contact stands in for an address on whichever relay the -// current test spun up. pickOutboundLine (src/outbound.ts) now matches the -// destination's host against a LINE's own configured relay before placing a -// call, so a fixed placeholder host could never match a real seeded line. -// routing.host lets each test point the mocked resolution at its own -// ephemeral relay's host; vi.hoisted keeps the mutable ref safe against -// vi.mock's hoisting to the top of the module. -const routing = vi.hoisted(() => ({ host: "local.test" })); +// The "local-sota" contact stands in for a colleague in the caller's own +// organization. pickOutboundLine (src/outbound.ts) matches the destination's +// ORG against a LINE's configured org — it used to match relay hosts, which is +// why this stub used to carry one. routing.org lets a test point the mocked +// resolution at whatever org it seeded; vi.hoisted keeps the mutable ref safe +// against vi.mock's hoisting to the top of the module. +const routing = vi.hoisted(() => ({ host: "local.test", org: "acme" })); vi.mock("../src/contacts.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveAddress: (...args: Parameters) => args[1] === "local-sota" - ? { ok: true as const, handle: "sota", host: routing.host, address: `sota@${routing.host}` } + ? { ok: true as const, org: routing.org, handle: "sota", address: `@${routing.org}/sota` } : actual.resolveAddress(...args), }; }); @@ -114,13 +113,13 @@ describe("trust CLI", () => { const machine = getMachinePaths(testHome, testHome); writeJsonAtomic(machine.knownPeersFile, { peers: [{ relay_origin: "relay.example", - address: "peer@relay.example", identity_pub: "abc", + address: "@acme/peer", identity_pub: "abc", fingerprint: "SHA256:0123456789abcdef0123456789abcdef", first_seen_at: 1, highest_encryption_epoch: 1, call_count: 1, }] }); - const result = await runCommand(testHome, ["trust", "--reset", "peer@relay.example"]); + const result = await runCommand(testHome, ["trust", "--reset", "@acme/peer"]); expect(result.code, result.stderr).toBe(0); - expect(result.stdout).toContain("Removed the identity pin for peer@relay.example"); + expect(result.stdout).toContain("Removed the identity pin for @acme/peer"); expect(loadKnownPeers(machine)).toEqual([]); }); @@ -131,12 +130,12 @@ describe("trust CLI", () => { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record = { - v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + v: 2 as const, relay_origin: "relay.test", address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, }; const identityRecord = { - v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + v: 2 as const, relay_origin: "relay.test", address, identity_pub: identityPub, }; return { @@ -151,7 +150,7 @@ describe("trust CLI", () => { const relay = "https://local.test"; routing.host = "local.test"; vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(response), { status: 200 }))); - const address = "sota@local.test"; + const address = "@acme/sota"; const firstIdentity = await identityBundle(address); response = firstIdentity.response; const testHome = home(); @@ -216,13 +215,13 @@ async function startCallRelay( ): Promise<{ relay: string; connections: () => number }> { const remote = await testKeys(); const relayOrigin = "127.0.0.1"; - const remoteAddress = `sota@${relayOrigin}`; + const remoteAddress = "@acme/sota"; const identity = { - v: 2 as const, relay_origin: remoteAddress.slice(remoteAddress.indexOf("@") + 1), + v: 2 as const, relay_origin: relayOrigin, address: remoteAddress, identity_pub: remote.identity_pub, }; const record = { - v: 2 as const, relay_origin: remoteAddress.slice(remoteAddress.indexOf("@") + 1), + v: 2 as const, relay_origin: relayOrigin, address: remoteAddress, key_id: await keyIdFor(remote.encryption_pub), suite: HPKE_SUITE, pub: remote.encryption_pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, @@ -262,7 +261,7 @@ async function startCallRelay( const request = await openE2EERequest( outer.envelope, remote.encryption_pkcs8, local.identity_pub, { - relay_origin: relayOrigin, from: `ken@${relayOrigin}`, to: remoteAddress, + relay_origin: relayOrigin, from: "@acme/ken", to: remoteAddress, key_id: record.key_id, epoch: record.epoch, }, ); @@ -270,7 +269,7 @@ async function startCallRelay( const issuedAt = Date.now(); const response: E2EEResponsePayloadType = { v: 1, direction: "response", relay_origin: relayOrigin, - from: remoteAddress, to: `ken@${relayOrigin}`, request_id: request.request_id, + from: remoteAddress, to: "@acme/ken", request_id: request.request_id, sender_identity_key_id: await keyIdFor(remote.identity_pub), recipient_encryption_key_id: await keyIdFor(local.encryption_pub), recipient_epoch: local.epoch, issued_at: issuedAt, @@ -481,7 +480,7 @@ describe.sequential("CLI command actions", () => { }); it("requires setup before fetching another agent's card", async () => { - const out = await runCommand(home(), ["card", "ken@acme.agentcall.benree.tech"]); + const out = await runCommand(home(), ["card", "@acme/ken"]); expect(out.code).toBe(1); expect(out.stderr).toMatch(/agentcall setup/); }); diff --git a/packages/cli/test/contacts.test.ts b/packages/cli/test/contacts.test.ts index 9795d465..11fe8235 100644 --- a/packages/cli/test/contacts.test.ts +++ b/packages/cli/test/contacts.test.ts @@ -17,7 +17,7 @@ describe("contacts store", () => { it("round-trips and sets 0600/0700 perms", () => { const p = getMachinePaths(tempHome()); - const book = { contacts: [{ name: "ken", address: "ken@agentcall.benree.tech", note: "coworker" }] }; + const book = { contacts: [{ name: "ken", address: "@acme/ken", note: "coworker" }] }; saveContacts(p, book); expect(loadContacts(p)).toEqual(book); expect(statSync(p.contactsFile).mode & 0o777).toBe(0o600); @@ -33,24 +33,24 @@ describe("contacts store", () => { it("addContact adds, then upserts case-insensitively", () => { const p = getMachinePaths(tempHome()); - expect(addContact(p, "Ken", "ken@agentcall.benree.tech", "coworker")).toBe("added"); - expect(addContact(p, "ken", "ken2@agentcall.benree.tech")).toBe("updated"); + expect(addContact(p, "Ken", "@acme/ken", "coworker")).toBe("added"); + expect(addContact(p, "ken", "@acme/ken2")).toBe("updated"); const { contacts } = loadContacts(p); expect(contacts).toHaveLength(1); - expect(contacts[0].address).toBe("ken2@agentcall.benree.tech"); + expect(contacts[0].address).toBe("@acme/ken2"); }); it("upsert without --note preserves the existing note", () => { const p = getMachinePaths(tempHome()); - addContact(p, "ken", "ken@agentcall.benree.tech", "coworker, owns relay infra"); - addContact(p, "ken", "ken2@agentcall.benree.tech"); + addContact(p, "ken", "@acme/ken", "coworker, owns relay infra"); + addContact(p, "ken", "@acme/ken2"); expect(loadContacts(p).contacts[0].note).toBe("coworker, owns relay infra"); }); it("rejects invalid names and invalid addresses without writing", () => { const p = getMachinePaths(tempHome()); - expect(() => addContact(p, "ken@home", "ken@agentcall.benree.tech")).toThrow(/Invalid contact name/); - expect(() => addContact(p, "-ken", "ken@agentcall.benree.tech")).toThrow(/Invalid contact name/); + expect(() => addContact(p, "ken@home", "@acme/ken")).toThrow(/Invalid contact name/); + expect(() => addContact(p, "-ken", "@acme/ken")).toThrow(/Invalid contact name/); expect(() => addContact(p, "ken", "not-an-address")).toThrow(/handle@host/); expect(loadContacts(p)).toEqual({ contacts: [] }); }); @@ -62,7 +62,7 @@ describe("contacts store", () => { it("removeContact deletes case-insensitively and rejects unknown names", () => { const p = getMachinePaths(tempHome()); - addContact(p, "ken", "ken@agentcall.benree.tech"); + addContact(p, "ken", "@acme/ken"); removeContact(p, "KEN"); expect(loadContacts(p)).toEqual({ contacts: [] }); expect(() => removeContact(p, "ken")).toThrow(/No contact named "ken"/); @@ -74,12 +74,12 @@ describe("contacts store", () => { writeFileSync( p.contactsFile, JSON.stringify({ - contacts: [{ name: "ken", address: "ken@agentcall.benree.tech" }], + contacts: [{ name: "ken", address: "@acme/ken" }], future_field: "x", }), ); loadContacts(p); - addContact(p, "amy", "amy@agentcall.benree.tech"); + addContact(p, "amy", "@acme/amy"); const raw = JSON.parse(readFileSync(p.contactsFile, "utf8")); expect(raw).not.toHaveProperty("future_field"); }); @@ -88,22 +88,29 @@ describe("contacts store", () => { describe("resolveAddress", () => { it("passes a full address through unchanged", () => { const p = getMachinePaths(tempHome()); - expect(resolveAddress(p, "ken@agentcall.benree.tech")).toEqual({ - ok: true, handle: "ken", host: "agentcall.benree.tech", address: "ken@agentcall.benree.tech", + expect(resolveAddress(p, "@acme/ken")).toEqual({ + ok: true, org: "acme", handle: "ken", address: "@acme/ken", }); }); - it("rejects a malformed @-containing address", () => { - const r = resolveAddress(getMachinePaths(tempHome()), "ken@"); + it("rejects a malformed address", () => { + const r = resolveAddress(getMachinePaths(tempHome()), "@acme/"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("@org/handle"); + }); + + // A DNS-shaped address must not resolve: nothing looks it up, so accepting + // one would promise routing this system does not implement. + it("rejects a host-shaped address outright", () => { + const r = resolveAddress(getMachinePaths(tempHome()), "ken@agentcall.benree.tech"); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toContain("handle@host"); }); it("resolves a saved name case-insensitively", () => { const p = getMachinePaths(tempHome()); - addContact(p, "Ken", "ken@agentcall.benree.tech", "coworker"); + addContact(p, "Ken", "@acme/ken", "coworker"); expect(resolveAddress(p, "ken")).toEqual({ - ok: true, handle: "ken", host: "agentcall.benree.tech", address: "ken@agentcall.benree.tech", + ok: true, org: "acme", handle: "ken", address: "@acme/ken", }); }); @@ -116,66 +123,28 @@ describe("resolveAddress", () => { } }); - // The host half of an address was parsed and then dropped: a call is dialled - // on the calling line's relay regardless of what the address says, so a - // custom AGENTCALL_RELAY silently sends the call somewhere else. It stays a - // warning rather than a rejection because the relay hands out a hardcoded - // RELAY_HOST, so a self-hosted or local-dev relay can never match. The merge - // of origin/main briefly reinstated the rejection; see relayHostWarning. - it("warns when the address host is not the relay the call will actually go to", () => { + // #66, and still a hard REJECTION rather than a diagnostic: this is the + // tenant boundary. It reads the org straight off the parsed address now + // instead of matching a DNS suffix, so it no longer depends on the relay + // host being spelled a particular way. + it("rejects a literal address belonging to a different organization", () => { const p = getMachinePaths(tempHome()); - const r = resolveAddress(p, "ken@agentcall.benree.tech", "https://relay.example.com"); - expect(r.ok).toBe(true); - if (r.ok) expect(r.warning).toMatch(/agentcall\.benree\.tech.*relay\.example\.com/); - }); - - // From origin/main (#66): on the real relay a tenant's addresses are - // @.agentcall.benree.tech, so the host we compare against has - // to carry the calling line's org or the warning names the wrong host. - it("expects the org-prefixed host when the relay is the real one", () => { - const p = getMachinePaths(tempHome()); - const same = resolveAddress(p, "ken@acme.agentcall.benree.tech", "https://agentcall.benree.tech", "acme"); - expect(same.ok).toBe(true); - if (same.ok) expect(same.warning).toBeUndefined(); - }); - - it("does not warn when the address host matches the relay", () => { - const p = getMachinePaths(tempHome()); - const r = resolveAddress(p, "ken@agentcall.benree.tech", "https://agentcall.benree.tech"); - expect(r.ok).toBe(true); - if (r.ok) expect(r.warning).toBeUndefined(); - }); - - it("does not warn when no relay is supplied", () => { - const p = getMachinePaths(tempHome()); - const r = resolveAddress(p, "ken@agentcall.benree.tech"); - expect(r.ok).toBe(true); - if (r.ok) expect(r.warning).toBeUndefined(); + const r = resolveAddress(p, "@other/ken", undefined, "acme"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/organization "other".*"acme"/); }); - // From origin/main (#66). Unlike the host mismatch above this is a hard - // REJECTION and must stay one: it is the tenant boundary, not a diagnostic. - // The literal-address half is covered by "rejects a hosted address belonging - // to another tenant" below; this is the contact-book half. it("rejects a contact-book hit belonging to a different organization", () => { const p = getMachinePaths(tempHome()); - addContact(p, "ken", "ken@other.agentcall.benree.tech"); - const r = resolveAddress(p, "ken", "https://agentcall.benree.tech", "acme"); + addContact(p, "ken", "@other/ken"); + const r = resolveAddress(p, "ken", undefined, "acme"); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toMatch(/organization "other".*"acme"/); }); - it("warns for a contact-book hit too, naming the contact's address", () => { - const p = getMachinePaths(tempHome()); - addContact(p, "ken", "ken@agentcall.benree.tech"); - const r = resolveAddress(p, "ken", "http://127.0.0.1:8787"); - expect(r.ok).toBe(true); - if (r.ok) expect(r.warning).toMatch(/ken.*127\.0\.0\.1:8787/); - }); - - it("an unparseable relay URL is ignored rather than blocking the call", () => { + it("accepts an address in the caller's own organization", () => { const p = getMachinePaths(tempHome()); - const r = resolveAddress(p, "ken@agentcall.benree.tech", "not a url"); + const r = resolveAddress(p, "@acme/ken", undefined, "acme"); expect(r.ok).toBe(true); }); @@ -191,18 +160,18 @@ describe("resolveAddress", () => { it("rejects a hosted address belonging to another tenant", () => { const r = resolveAddress( getMachinePaths(tempHome()), - "ken@beta.agentcall.benree.tech", + "@other/ken", "https://agentcall.benree.tech", "acme", ); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toMatch(/beta.*acme/); + if (!r.ok) expect(r.error).toMatch(/other.*acme/); }); it("accepts a hosted address in the install's tenant", () => { const r = resolveAddress( getMachinePaths(tempHome()), - "ken@acme.agentcall.benree.tech", + "@acme/ken", "https://agentcall.benree.tech", "acme", ); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index a3fd0b0e..33363aed 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -258,11 +258,11 @@ describe("doctor key health", () => { const now = Date.now(); const record: EncryptionKeyRecordType = { v: 2, relay_origin: "relay.example", - address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks).toEqual([ @@ -279,11 +279,11 @@ describe("doctor key health", () => { const now = Date.now(); const record: EncryptionKeyRecordType = { v: 2, relay_origin: "relay.example", - address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch + 1, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks.at(-1)).toMatchObject({ name: "published identity keys", ok: false }); @@ -311,13 +311,13 @@ describe("doctor key health", () => { const now = Date.now(); const record: EncryptionKeyRecordType = { v: 2, relay_origin: "relay.example", - address: "ken@relay.example", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, + address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: "invalid" }, }), ); @@ -337,13 +337,13 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const values = await fields(local); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "ken@relay.example".slice("ken@relay.example".indexOf("@") + 1), address: "ken@relay.example", suite: HPKE_SUITE, pub: local.encryption_pub, + v: 2, relay_origin: "@acme/ken".slice("@acme/ken".indexOf("@") + 1), address: "@acme/ken", suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, prev: null, ...values, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, }), ); diff --git a/packages/cli/test/e2ee.test.ts b/packages/cli/test/e2ee.test.ts index 1854a5ae..6db42966 100644 --- a/packages/cli/test/e2ee.test.ts +++ b/packages/cli/test/e2ee.test.ts @@ -24,7 +24,7 @@ const NOW = 1_000_000; async function request(sender: StoredKeys, recipient: StoredKeys): Promise { return { v: 1, direction: "request", relay_origin: "acme.agentcall.test", - from: "alice@acme.agentcall.test", to: "bob@acme.agentcall.test", + from: "@acme/alice", to: "@acme/bob", request_id: "1".repeat(32), sender_identity_key_id: await keyIdFor(sender.identity_pub), recipient_encryption_key_id: await keyIdFor(recipient.encryption_pub), recipient_epoch: recipient.epoch, issued_at: NOW - 1, expires_at: NOW + 1_000, diff --git a/packages/cli/test/known-peers.test.ts b/packages/cli/test/known-peers.test.ts index c36c6966..49e19bfa 100644 --- a/packages/cli/test/known-peers.test.ts +++ b/packages/cli/test/known-peers.test.ts @@ -13,7 +13,7 @@ import { getMachinePaths, type MachinePaths } from "../src/paths.js"; let root: string; let machine: MachinePaths; -const PEER = "peer@relay.example"; +const PEER = "@acme/peer"; const NOW = 500; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "agentcall-peers-")); @@ -30,7 +30,7 @@ async function bundle(identity?: CryptoKeyPair, epoch = 1, address = PEER) { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: address.slice(address.indexOf("@") + 1), address, + v: 2, relay_origin: "relay.test", address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch, not_before: 1, not_after: 1_000, prev: null, }; @@ -38,7 +38,7 @@ async function bundle(identity?: CryptoKeyPair, epoch = 1, address = PEER) { identityKey: identity, value: { identity: { - v: 2 as const, relay_origin: address.slice(address.indexOf("@") + 1), + v: 2 as const, relay_origin: "relay.test", address, identity_pub: identityPub, }, encryption: { record, signature: await signTranscript(identity.privateKey, encryptionKeyTranscript(record)) }, @@ -50,7 +50,7 @@ describe("known-peer identity pins", () => { it("pins a first contact only after verifying its encryption signature", async () => { const first = await bundle(); const peer = await verifyAndPinPeer(machine, PEER, first.value, NOW); - expect(peer).toMatchObject({ address: "peer@relay.example", first_seen_at: NOW, highest_encryption_epoch: 1, call_count: 1 }); + expect(peer).toMatchObject({ address: "@acme/peer", first_seen_at: NOW, highest_encryption_epoch: 1, call_count: 1 }); expect(loadKnownPeers(machine)).toEqual([peer]); expect(statSync(machine.dir).mode & 0o777).toBe(0o700); expect(statSync(machine.knownPeersFile).mode & 0o777).toBe(0o600); @@ -74,9 +74,12 @@ describe("known-peer identity pins", () => { expect(readFileSync(machine.knownPeersFile, "utf8")).toBe(before); }); - it("refuses a valid bundle bound to a different requested host", async () => { + // Was "a different requested host". A bundle is bound to an address, and an + // address names an organization now, so the mismatch that matters is a + // same-named handle in another org. + it("refuses a valid bundle bound to a different requested address", async () => { const first = await bundle(); - await expect(verifyAndPinPeer(machine, "peer@other.example", first.value, NOW)).rejects.toThrow(/when peer@other\.example was requested/); + await expect(verifyAndPinPeer(machine, "@other/peer", first.value, NOW)).rejects.toThrow(/when @other\/peer was requested/); expect(loadKnownPeers(machine)).toEqual([]); }); @@ -134,7 +137,7 @@ describe("known-peer identity pins", () => { it("refuses to grow beyond the fixed peer cap", async () => { writeJsonAtomic(machine.knownPeersFile, { peers: Array.from({ length: MAX_KNOWN_PEERS }, (_, index) => ({ - relay_origin: "r.test", address: `p${index}@r.test`, identity_pub: "abc", + relay_origin: "r.test", address: `@acme/p${index}`, identity_pub: "abc", fingerprint: "SHA256:0123456789abcdef0123456789abcdef", first_seen_at: 1, highest_encryption_epoch: 1, call_count: 1, })) }); @@ -149,14 +152,14 @@ describe("known-peer identity pins", () => { }); it("serializes concurrent peer additions without losing either pin", async () => { - const alice = await bundle(undefined, 1, "alice@relay.example"); - const bob = await bundle(undefined, 1, "bob@relay.example"); + const alice = await bundle(undefined, 1, "@acme/alice"); + const bob = await bundle(undefined, 1, "@acme/bob"); await Promise.all([ - verifyAndPinPeer(machine, "alice@relay.example", alice.value, NOW), - verifyAndPinPeer(machine, "bob@relay.example", bob.value, NOW), + verifyAndPinPeer(machine, "@acme/alice", alice.value, NOW), + verifyAndPinPeer(machine, "@acme/bob", bob.value, NOW), ]); expect(loadKnownPeers(machine).map((peer) => peer.address).sort()).toEqual([ - "alice@relay.example", "bob@relay.example", + "@acme/alice", "@acme/bob", ]); }); diff --git a/packages/cli/test/line-cmd.test.ts b/packages/cli/test/line-cmd.test.ts index b26ce884..f1b761fa 100644 --- a/packages/cli/test/line-cmd.test.ts +++ b/packages/cli/test/line-cmd.test.ts @@ -39,7 +39,7 @@ beforeEach(() => { mkdirSync(m.linesDir, { recursive: true }); }); -const ok = async () => ({ org: "acme", token: "tok", address: "ken-cdx@r.example" }); +const ok = async () => ({ org: "acme", token: "tok" }); const base = { org: "acme", handle: "ken", token: "t", relay: "https://r.example", agent_kind: "claude" as const }; // listenerPathDirs (addLine's/removeLine's extraPathDirs default — see @@ -64,20 +64,23 @@ function removeLine(m: MachinePaths, name: string, opts: RemoveLineOpts = {}): v } describe("addLine", () => { - it("publishes through the canonical organization-qualified public address", async () => { + // Was: asserts both helpers receive the org-prefixed address host. The host + // is no longer part of an address, so the invariant that remains is that both + // publish against the same relay for the same line. + it("publishes identity and encryption against the same relay", async () => { const paths = getLinePaths(m, "caller"); const keys = await generateIdentityKeys(paths); - const hosts: string[] = []; + const relays: string[] = []; await publishStoredKeys( { org: "acme", handle: "ken", token: "t", relay: "https://agentcall.benree.tech" }, keys, paths, { - identity: async (_relay, _auth, _keys, relayHost) => { hosts.push(relayHost); }, - encryption: async (_relay, _auth, _paths, relayHost) => { hosts.push(relayHost); }, + identity: async (relay) => { relays.push(relay); }, + encryption: async (relay) => { relays.push(relay); }, }, ); - expect(hosts).toEqual(["acme.agentcall.benree.tech", "acme.agentcall.benree.tech"]); + expect(relays).toEqual(["https://agentcall.benree.tech", "https://agentcall.benree.tech"]); }); it("persists identity keys before registration and config immediately after", async () => { let keysExistedAtRegistration = false; @@ -142,7 +145,7 @@ describe("addLine", () => { it("rejects an invalid line name before registering", async () => { let called = false; await expect(addLine(m, { name: "../evil", handle: "x", agent: "codex", relay: "https://r.example", - register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, + register: async () => { called = true; return { org: "acme", token: "t" }; }, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/line name/i); expect(called).toBe(false); @@ -152,7 +155,7 @@ describe("addLine", () => { saveLineConfig(getLinePaths(m, "codex"), base); let called = false; await expect(addLine(m, { name: "codex", handle: "other", agent: "codex", relay: "https://r.example", - register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, + register: async () => { called = true; return { org: "acme", token: "t" }; }, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/already/); expect(called).toBe(false); @@ -162,7 +165,7 @@ describe("addLine", () => { saveLineConfig(getLinePaths(m, "claude"), { ...base, handle: "ken-cdx" }); let called = false; await expect(addLine(m, { name: "codex", handle: "ken-cdx", agent: "codex", relay: "https://r.example", - register: async () => { called = true; return { org: "acme", token: "t", address: "a" }; }, + register: async () => { called = true; return { org: "acme", token: "t" }; }, installListenerServiceFn: () => {}, publishCardFn: async () => undefined, verify: false })) .rejects.toThrow(/ken-cdx/); expect(called).toBe(false); @@ -386,11 +389,11 @@ describe("listLinesReport", () => { savePerson(m, { primary_line: "claude" }); const rows = listLinesReport(m); expect(rows.map((r) => r.name)).toEqual(["broken", "claude"]); - expect(rows.find((r) => r.name === "broken")!.address).toBe("ken-b@not-a-url"); + expect(rows.find((r) => r.name === "broken")!.address).toBe("@acme/ken-b"); // The happy-path formatting ("@") had no assertion of // its own — only the broken row did, above. `base.relay` is // "https://r.example", so this pins the host-only, scheme-stripped form. - expect(rows.find((r) => r.name === "claude")!.address).toBe("ken@r.example"); + expect(rows.find((r) => r.name === "claude")!.address).toBe("@acme/ken"); }); }); diff --git a/packages/cli/test/listener-stages.test.ts b/packages/cli/test/listener-stages.test.ts index 41a71bbc..30a52d7d 100644 --- a/packages/cli/test/listener-stages.test.ts +++ b/packages/cli/test/listener-stages.test.ts @@ -68,7 +68,7 @@ async function callerBundleFor(handle: string) { function fakePeer(address: string) { return { - relay_origin: address.slice(address.indexOf("@") + 1), + relay_origin: "relay.test", address, identity_pub: callerKeys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: callerKeys.epoch, call_count: 1, }; @@ -78,7 +78,7 @@ async function buildEnvelope(opts: { from: string; to: string; message: string } const issuedAt = Date.now(); const request = { v: 1 as const, direction: "request" as const, relay_origin: "127.0.0.1", - from: `${opts.from}@127.0.0.1`, to: `${opts.to}@127.0.0.1`, + from: `@acme/${opts.from}`, to: `@acme/${opts.to}`, request_id: crypto.randomUUID().replaceAll("-", ""), sender_identity_key_id: await keyIdFor(callerKeys.identity_pub), recipient_encryption_key_id: await keyIdFor(listenerKeys.encryption_pub), @@ -134,8 +134,8 @@ describe("openInboundEnvelope", () => { if (!result.ok) throw new Error("expected ok"); expect(result.envelope.request.message).toBe("hi"); expect(result.envelope.relayOrigin).toBe("127.0.0.1"); - expect(result.envelope.fromAddress).toBe("shusaku@127.0.0.1"); - expect(result.envelope.toAddress).toBe("ken@127.0.0.1"); + expect(result.envelope.fromAddress).toBe("@acme/shusaku"); + expect(result.envelope.toAddress).toBe("@acme/ken"); expect(reserved).toMatchObject({ sender_fingerprint: `SHA256:${"a".repeat(32)}` }); }); @@ -205,7 +205,7 @@ describe("makeOutcomeSender", () => { let sealedPayload: E2EEResponsePayloadType | undefined; const trySendOutcome = makeOutcomeSender( { - callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "shusaku@127.0.0.1", toAddress: "ken@127.0.0.1", + callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "@acme/shusaku", toAddress: "@acme/ken", request, requestHash: await transcriptHash(requestTranscript(request)), localKeys: listenerKeys, callerBundle: bundle, send: (obj) => sent.push(obj), }, @@ -227,7 +227,7 @@ describe("makeOutcomeSender", () => { const sent: unknown[] = []; const trySendOutcome = makeOutcomeSender( { - callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "shusaku@127.0.0.1", toAddress: "ken@127.0.0.1", + callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "@acme/shusaku", toAddress: "@acme/ken", request, requestHash: await transcriptHash(requestTranscript(request)), localKeys: listenerKeys, callerBundle: bundle, send: (obj) => sent.push(obj), }, @@ -245,7 +245,7 @@ describe("makeOutcomeSender", () => { const sent: unknown[] = []; const trySendOutcome = makeOutcomeSender( { - callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "shusaku@127.0.0.1", toAddress: "ken@127.0.0.1", + callId: "c1", relayOrigin: "127.0.0.1", fromAddress: "@acme/shusaku", toAddress: "@acme/ken", request, requestHash: await transcriptHash(requestTranscript(request)), localKeys: listenerKeys, callerBundle: bundle, send: (obj) => sent.push(obj), }, diff --git a/packages/cli/test/listener.test.ts b/packages/cli/test/listener.test.ts index 7284f9d6..621c4730 100644 --- a/packages/cli/test/listener.test.ts +++ b/packages/cli/test/listener.test.ts @@ -109,7 +109,7 @@ async function sendIncoming( const issuedAt = Date.now(); const request: E2EERequestPayloadType = { v: 1, direction: "request", relay_origin: "127.0.0.1", - from: `${frame.from}@127.0.0.1`, to: "ken@127.0.0.1", + from: `@acme/${frame.from}`, to: "@acme/ken", request_id: crypto.randomUUID().replaceAll("-", ""), sender_identity_key_id: await keyIdFor(callerKeys.identity_pub), recipient_encryption_key_id: await keyIdFor(listenerKeys.encryption_pub), @@ -170,7 +170,7 @@ function baseDeps(relay: string) { }; }, verifyAndPinPeer: async (_machine: MachinePaths, address: string) => ({ - relay_origin: address.slice(address.indexOf("@") + 1), + relay_origin: "relay.test", address, identity_pub: callerKeys.identity_pub, fingerprint: `SHA256:${"a".repeat(32)}`, first_seen_at: 1, highest_encryption_epoch: callerKeys.epoch, call_count: 1, }), diff --git a/packages/cli/test/outbound.test.ts b/packages/cli/test/outbound.test.ts index 5257de1c..a9a493d1 100644 --- a/packages/cli/test/outbound.test.ts +++ b/packages/cli/test/outbound.test.ts @@ -13,81 +13,80 @@ beforeEach(() => { mkdirSync(m.linesDir, { recursive: true }); }); -const A = "https://a.example"; -const B = "https://b.example"; +const RELAY = "https://relay.example"; +const ACME = "acme"; +const BETA = "beta"; +// Selection is by ORGANIZATION now, not by relay host. A line may only call +// inside its own organization, which is the rule the old host match was +// approximating — and with the host gone from addresses there is nothing else +// it could match on. describe("pickOutboundLine", () => { - it("uses the only line on the destination's relay", () => { - saveLineConfig(getLinePaths(m, "work"), { org: "acme", handle: "ken-w", token: "t", relay: B }); - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - expect(pickOutboundLine(m, B).name).toBe("work"); + it("uses the only line in the destination's organization", () => { + saveLineConfig(getLinePaths(m, "work"), { org: BETA, handle: "ken-w", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "home"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); + expect(pickOutboundLine(m, BETA).name).toBe("work"); }); - it("uses the primary when several lines share the destination relay", () => { - saveLineConfig(getLinePaths(m, "claude"), { org: "acme", handle: "ken", token: "t", relay: A }); - saveLineConfig(getLinePaths(m, "codex"), { org: "acme", handle: "ken-cdx", token: "t", relay: A }); + it("uses the primary when several lines share the destination organization", () => { + saveLineConfig(getLinePaths(m, "claude"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "codex"), { org: ACME, handle: "ken-cdx", token: "t", relay: RELAY }); savePerson(m, { primary_line: "codex" }); - expect(pickOutboundLine(m, A).name).toBe("codex"); + expect(pickOutboundLine(m, ACME).name).toBe("codex"); }); - it("refuses when the primary is on another relay and several candidates tie", () => { - saveLineConfig(getLinePaths(m, "w1"), { org: "acme", handle: "k1", token: "t", relay: B }); - saveLineConfig(getLinePaths(m, "w2"), { org: "acme", handle: "k2", token: "t", relay: B }); - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); + it("refuses when the primary is in another organization and several candidates tie", () => { + saveLineConfig(getLinePaths(m, "w1"), { org: BETA, handle: "k1", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "w2"), { org: BETA, handle: "k2", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "home"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); savePerson(m, { primary_line: "home" }); - expect(() => pickOutboundLine(m, B)).toThrow(/--as/); + expect(() => pickOutboundLine(m, BETA)).toThrow(/--as/); }); - it("names the relays this machine holds lines on when none match", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - expect(() => pickOutboundLine(m, B)).toThrow(/a\.example/); + it("names the organizations this machine holds lines in when none match", () => { + saveLineConfig(getLinePaths(m, "home"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); + expect(() => pickOutboundLine(m, BETA)).toThrow(/acme/); }); - it("honours --as, even across relays, but rejects a mismatch", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - saveLineConfig(getLinePaths(m, "work"), { org: "acme", handle: "ken-w", token: "t", relay: B }); - expect(pickOutboundLine(m, B, { as: "work" }).name).toBe("work"); - expect(() => pickOutboundLine(m, B, { as: "home" })).toThrow(/a\.example/); - }); - - it("matches on relay host, ignoring a trailing slash", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: "https://a.example/" }); - expect(pickOutboundLine(m, A).name).toBe("home"); + it("honours --as, but rejects a line from another organization", () => { + saveLineConfig(getLinePaths(m, "home"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "work"), { org: BETA, handle: "ken-w", token: "t", relay: RELAY }); + expect(pickOutboundLine(m, BETA, { as: "work" }).name).toBe("work"); + expect(() => pickOutboundLine(m, BETA, { as: "home" })).toThrow(/acme/); }); it("gracefully degrades when resolvePrimary throws: several candidates, no primary recorded", () => { - saveLineConfig(getLinePaths(m, "w1"), { org: "acme", handle: "k1", token: "t", relay: B }); - saveLineConfig(getLinePaths(m, "w2"), { org: "acme", handle: "k2", token: "t", relay: B }); + saveLineConfig(getLinePaths(m, "w1"), { org: BETA, handle: "k1", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "w2"), { org: BETA, handle: "k2", token: "t", relay: RELAY }); // deliberately NOT calling savePerson(), so resolvePrimary will throw - expect(() => pickOutboundLine(m, B)).toThrow(/--as/); + expect(() => pickOutboundLine(m, BETA)).toThrow(/--as/); }); - it("rejects --as with mismatched relay, naming both hosts", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - saveLineConfig(getLinePaths(m, "work"), { org: "acme", handle: "ken-w", token: "t", relay: B }); + it("rejects --as with a mismatched organization, naming both", () => { + saveLineConfig(getLinePaths(m, "home"), { org: ACME, handle: "ken", token: "t", relay: RELAY }); + saveLineConfig(getLinePaths(m, "work"), { org: BETA, handle: "ken-w", token: "t", relay: RELAY }); let error: Error | undefined; try { - pickOutboundLine(m, B, { as: "home" }); + pickOutboundLine(m, BETA, { as: "home" }); } catch (e) { error = e as Error; } expect(error).toBeDefined(); - // Check that error message names BOTH the line's relay (a.example) AND the destination relay (b.example) - expect(error!.message).toMatch(/a\.example/); - expect(error!.message).toMatch(/b\.example/); + expect(error!.message).toMatch(/acme/); + expect(error!.message).toMatch(/beta/); }); it("rejects --as when the named line doesn't exist", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - expect(() => pickOutboundLine(m, A, { as: "nonexistent" })).toThrow(/No line named "nonexistent"/); + saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: RELAY }); + expect(() => pickOutboundLine(m, ACME, { as: "nonexistent" })).toThrow(/No line named "nonexistent"/); }); // Wiring guard for `index.ts`'s `call`/`status`: proves the same function // this file already exercises above is what selects a line from the // destination's relay, not some fixed per-process config. it("call resolves its line from the destination address, not from a fixed config", () => { - saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: A }); - const ctx = pickOutboundLine(m, A); + saveLineConfig(getLinePaths(m, "home"), { org: "acme", handle: "ken", token: "t", relay: RELAY }); + const ctx = pickOutboundLine(m, ACME); expect(ctx.config.handle).toBe("ken"); expect(ctx.config.token).toBe("t"); }); diff --git a/packages/cli/test/search.test.ts b/packages/cli/test/search.test.ts index 8000ef65..2e931f24 100644 --- a/packages/cli/test/search.test.ts +++ b/packages/cli/test/search.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { allRostersFailed, rank, renderResults, sanitize, tokenize, toEntries, type SearchEntry } from "../src/search.js"; const entry = (over: Partial): SearchEntry => ({ - roster: "acme", handle: "tanaka", address: "tanaka@relay.test", task: "adr", + roster: "acme", handle: "tanaka", address: "@acme/tanaka", task: "adr", name: "ADR history", description: "Why past decisions were made.", keywords: [], ...over, }); @@ -34,7 +34,7 @@ describe("rank", () => { // A wholly invented colleague with a nonsense term must route. it("routes a fictitious colleague with an invented term", () => { const results = rank("zzzcustomtoolkit please", [ - entry({ handle: "nobody", address: "nobody@relay.test", task: "invented", + entry({ handle: "nobody", address: "@acme/nobody", task: "invented", name: "Invented", description: "d", keywords: ["zzzcustomtoolkit"] }), ]); expect(results[0]!.handle).toBe("nobody"); @@ -181,27 +181,27 @@ describe("sanitize", () => { describe("toEntries", () => { it("builds handle@host addresses and flattens tasks", () => { - const entries = toEntries("acme", "relay.test", [ + const entries = toEntries("acme", "acme", [ { handle: "tanaka", agent_kind: "claude", updated_at: 1, truncated: false, tasks: [{ id: "adr", name: "ADR", description: "Why.", keywords: ["auth"] }, { id: "ask", name: "Ask", description: "Q.", keywords: [] }] }, ]); expect(entries).toHaveLength(2); - expect(entries[0]!.address).toBe("tanaka@relay.test"); + expect(entries[0]!.address).toBe("@acme/tanaka"); expect(entries[0]!.roster).toBe("acme"); }); }); describe("renderResults", () => { const results = rank("auth migration", [ - { roster: "acme", handle: "tanaka", address: "tanaka@relay.test", task: "adr", + { roster: "acme", handle: "tanaka", address: "@acme/tanaka", task: "adr", name: "ADR history", description: "Why decisions were made.", keywords: ["auth", "migration"] }, ]); it("prints a runnable command with --task before the message", () => { // Matches the canonical ordering `agentcall card` already prints. expect(renderResults(results, [{ name: "acme", ageSeconds: 5, stale: false }])) - .toContain('agentcall call tanaka@relay.test --task adr ""'); + .toContain('agentcall call @acme/tanaka --task adr ""'); }); it("shows which terms matched and where, so the agent can judge", () => { @@ -231,26 +231,26 @@ describe("renderResults", () => { it("prefixes each result with its own roster when more than one roster is in scope", () => { const multi = rank("auth migration", [ - { roster: "acme", handle: "tanaka", address: "tanaka@relay.test", task: "adr", + { roster: "acme", handle: "tanaka", address: "@acme/tanaka", task: "adr", name: "ADR history", description: "Why decisions were made.", keywords: ["auth", "migration"] }, - { roster: "other", handle: "mia", address: "mia@relay.test", task: "auth-flow", + { roster: "other", handle: "mia", address: "@acme/mia", task: "auth-flow", name: "Auth migration guide", description: "How auth migrated.", keywords: ["auth", "migration"] }, ]); const out = renderResults(multi, [ { name: "acme", ageSeconds: 5, stale: false }, { name: "other", ageSeconds: 5, stale: false }, ]); - expect(out).toContain("[acme] tanaka@relay.test"); - expect(out).toContain("[other] mia@relay.test"); + expect(out).toContain("[acme] @acme/tanaka"); + expect(out).toContain("[other] @acme/mia"); }); it("says when a member's tasks were not fully indexed", () => { const truncated = rank("payroll", [ - { roster: "acme", handle: "mia", address: "mia@relay.test", task: "payroll", + { roster: "acme", handle: "mia", address: "@acme/mia", task: "payroll", name: "Payroll", description: "d", keywords: ["payroll"], truncated: true }, ]); expect(renderResults(truncated, [{ name: "acme", ageSeconds: 1, stale: false }])) - .toContain("agentcall card mia@relay.test"); + .toContain("agentcall card @acme/mia"); }); // The payload sits in `task` and `description` — both pass through @@ -261,7 +261,7 @@ describe("renderResults", () => { // index.ts), so its omission here is not a gap. it("emits no escape sequences even when a card contains them", () => { const evil = rank("payroll", [ - { roster: "acme", handle: "x", address: "x@relay.test", task: "t\x1b[31m", + { roster: "acme", handle: "x", address: "@acme/x", task: "t\x1b[31m", name: "Payroll", description: "d\x1b[0m\nFAKE: 0 results", keywords: ["payroll"] }, ]); const output = renderResults(evil, [{ name: "acme", ageSeconds: 1, stale: false }]); diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index d36831de..85b2d144 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -22,7 +22,7 @@ import { tempDir } from "./helpers.js"; const encryptedRequest = { v: 1 as const, direction: "request" as const, relay_origin: "relay.example", - from: "caller@relay.example", to: "callee@relay.example", key_id: "a".repeat(32), + from: "@acme/caller", to: "@acme/callee", key_id: "a".repeat(32), epoch: 1, enc: "A", ct: "B", }; diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 2426e66a..10903ecf 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { canonicalEncode } from "./canonical.js"; +import { ADDRESS_RE } from "./protocol.js"; /** The one HPKE suite this protocol version implements. Exact string, no spaces. */ export const HPKE_SUITE = "DHKEM(P-256,HKDF-SHA256)/HKDF-SHA256/AES-128-GCM" as const; @@ -7,16 +8,10 @@ export const HPKE_SUITE = "DHKEM(P-256,HKDF-SHA256)/HKDF-SHA256/AES-128-GCM" as /** 30 days. A record claiming a longer window is rejected, not clamped. */ export const MAX_ENCRYPTION_KEY_VALIDITY_MS = 2_592_000_000; -// handle@host, for now. The relay binding no longer depends on this shape: -// `relay_origin` below is a signed field of its own, so the address is free to -// become a bare registry key (`@org/handle`) without dropping the property. -// See docs/superpowers/specs/2026-08-05-address-as-registry-key.md. -export const ADDRESS_RE = /^[a-z0-9][a-z0-9-]{1,30}@[a-z0-9.-]{1,253}$/; +export { ADDRESS_RE }; -// Which relay a record was published on. Lives here rather than in e2ee.ts -// because both signed key records and the envelopes need it, and e2ee.ts -// already imports from this module — defining it the other way round would be -// a cycle. +// Which relay a record was published on. The address is a registry key and +// names no host, so this field is the entire cross-relay binding. export const RELAY_ORIGIN_RE = /^[a-z0-9.-]{1,253}$/; const KEY_ID_RE = /^[0-9a-f]{32}$/; // Same width as a key id but a different quantity: the digest of the previous diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 098ad108..23b3f119 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -21,12 +21,10 @@ export const ORG_RE = new RegExp(`^${ORG_BODY}$`); // oversight — nothing resolves an AgentCall address, and a key dressed as a // locator invites tooling to try. // -// Not exported. `keys.ts` still owns a separate, host-shaped `ADDRESS_RE` for -// signed identity and encryption-key records, where the host currently carries -// the cross-relay binding. Exporting a second name-alike from here would -// collide, and worse, would invite a caller to validate a signed record against -// the wrong grammar. Use `parseAddress`/`formatAddress`. -const ADDRESS_RE = new RegExp(`^@(${ORG_BODY})/(${HANDLE_BODY})$`); +// The single address grammar. `keys.ts` imports it rather than keeping its own +// so a signed record and a dialled address can never disagree about what an +// address is. +export const ADDRESS_RE = new RegExp(`^@(${ORG_BODY})/(${HANDLE_BODY})$`); // The hosted deployment's DNS host, and the single place it is written. It is // the relay *endpoint* only: the CLI derives its default relay URL from it. @@ -170,7 +168,10 @@ export const RegisterRequest = z.object({ // Absent = caller-only: the handle can call others but is not callable. agent_kind: AgentKindSchema.optional(), }); -export const RegisterResponse = z.object({ org: z.string().regex(ORG_RE), token: z.string(), address: z.string() }); +// No `address`: it is `formatAddress(org, handle)` and the caller already knows +// both. Shipping the composed string is what forced the relay to build one, and +// the client to parse a host back out of it. +export const RegisterResponse = z.object({ org: z.string().regex(ORG_RE), token: z.string() }); export const SHA256_HEX_RE = /^[0-9a-f]{64}$/; export const CREDENTIAL_PUBLIC_ID_RE = /^(?:act|agr)_[0-9a-f]{16}$/; @@ -239,26 +240,11 @@ export function formatAddress(org: string, handle: string): string { // Returns the pair, not a host. Callers that need to know which relay to dial // read `cfg.relay`; an address never carried that information usefully, because // a caller only ever reaches its own organization's relay. -export function parseKeyAddress(addr: string): { org: string; handle: string } | null { +export function parseAddress(addr: string): { org: string; handle: string } | null { const m = ADDRESS_RE.exec(addr); return m ? { org: m[1]!, handle: m[2]! } : null; } -// The outgoing `handle@host` grammar. Still live because signed identity and -// encryption-key records currently carry the relay binding inside the address -// (see keys.ts), so the cutover cannot happen in the CLI alone — it needs -// `relay_origin` to become an explicit signed field first. Deleted in that -// slice; until then both grammars exist and only this one is wired up. -export function parseAddress(addr: string): { handle: string; host: string } | null { - const at = addr.indexOf("@"); - if (at <= 0) return null; - const handle = addr.slice(0, at); - const host = addr.slice(at + 1); - if (!HANDLE_RE.test(handle)) return null; - if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(host)) return null; - return { handle, host }; -} - // Peer-controlled free-form text has two display paths. Human-readable output // must neutralize terminal controls and Unicode bidi formatting; structured // JSON output preserves the payload because JSON.stringify escapes controls. diff --git a/packages/shared/test/e2ee.test.ts b/packages/shared/test/e2ee.test.ts index da3b3ba8..1fad99ab 100644 --- a/packages/shared/test/e2ee.test.ts +++ b/packages/shared/test/e2ee.test.ts @@ -10,7 +10,7 @@ const toHex = (value: Uint8Array) => Buffer.from(value).toString("hex"); const request: E2EERequestPayloadType = { v: 1, direction: "request", relay_origin: "acme.agentcall.test", - from: "alice@acme.agentcall.test", to: "bob@acme.agentcall.test", + from: "@acme/alice", to: "@acme/bob", request_id: "1".repeat(32), sender_identity_key_id: "2".repeat(32), recipient_encryption_key_id: "3".repeat(32), recipient_epoch: 2, issued_at: 100, expires_at: 200, task: "ask", message: "hello", @@ -76,8 +76,8 @@ describe("E2EE envelope schemas and transcripts", () => { const aad = hpkeEnvelopeAad(base); for (const changed of [ { ...base, relay_origin: "other.agentcall.test" }, - { ...base, from: "mallory@acme.agentcall.test" }, - { ...base, to: "carol@acme.agentcall.test" }, + { ...base, from: "@acme/mallory" }, + { ...base, to: "@acme/carol" }, { ...base, key_id: "4".repeat(32) }, { ...base, epoch: 3 }, ]) expect(hpkeEnvelopeAad(changed)).not.toEqual(aad); @@ -86,11 +86,7 @@ describe("E2EE envelope schemas and transcripts", () => { it("binds optional request fields without ambiguous concatenation", () => { const transcript = requestTranscript(request); expect(toHex(transcript)).toBe( - "01000000146167656e7463616c6c2f726571756573742f7631020000000000000001010000000772657175657374" + - "010000001361636d652e6167656e7463616c6c2e746573740100000019616c6963654061636d652e6167656e7463616c6c2e74657374" + - "0100000017626f624061636d652e6167656e7463616c6c2e7465737401000000203131313131313131313131313131313131313131313131313131313131313131" + - "0100000020323232323232323232323232323232323232323232323232323232323232323201000000203333333333333333333333333333333333333333333333333333333333333333" + - "0200000000000000020200000000000000640200000000000000c8010000000361736b03010000000568656c6c6f", + "01000000146167656e7463616c6c2f726571756573742f7631020000000000000001010000000772657175657374010000001361636d652e6167656e7463616c6c2e74657374010000000b4061636d652f616c69636501000000094061636d652f626f620100000020313131313131313131313131313131313131313131313131313131313131313101000000203232323232323232323232323232323232323232323232323232323232323232010000002033333333333333333333333333333333333333333333333333333333333333330200000000000000020200000000000000640200000000000000c8010000000361736b03010000000568656c6c6f", ); expect(requestTranscript({ ...request, task: undefined, context_id: undefined })).not.toEqual(transcript); expect(requestTranscript({ ...request, message: "hello!" })).not.toEqual(transcript); @@ -107,12 +103,7 @@ describe("E2EE envelope schemas and transcripts", () => { outcome: { kind: "failure", code: "task_not_offered", offered: ["ask", "review"] }, }; expect(toHex(responseTranscript(response))).toBe( - "01000000156167656e7463616c6c2f726573706f6e73652f76310200000000000000010100000008726573706f6e7365" + - "010000001361636d652e6167656e7463616c6c2e746573740100000017626f624061636d652e6167656e7463616c6c2e74657374" + - "0100000019616c6963654061636d652e6167656e7463616c6c2e7465737401000000203131313131313131313131313131313131313131313131313131313131313131" + - "0100000020353535353535353535353535353535353535353535353535353535353535353501000000203636363636363636363636363636363636363636363636363636363636363636" + - "0200000000000000020200000000000000640200000000000000c8010000004063643064643336646238313238313438666232656330643263353233313032356238653961636338333138623432383830353865616533336332616538363239" + - "01000000076661696c75726501000000107461736b5f6e6f745f6f66666572656403020000000000000002010000000361736b0100000006726576696577", + "01000000156167656e7463616c6c2f726573706f6e73652f76310200000000000000010100000008726573706f6e7365010000001361636d652e6167656e7463616c6c2e7465737401000000094061636d652f626f62010000000b4061636d652f616c6963650100000020313131313131313131313131313131313131313131313131313131313131313101000000203535353535353535353535353535353535353535353535353535353535353535010000002036363636363636363636363636363636363636363636363636363636363636360200000000000000020200000000000000640200000000000000c801000000406335656337396632346230336431316438343065343536383832366564363965306363633734656633623332373831316430313831326164323061396461623401000000076661696c75726501000000107461736b5f6e6f745f6f66666572656403020000000000000002010000000361736b0100000006726576696577", ); expect(responseTranscript({ ...response, request_transcript_hash: "7".repeat(64) })).not.toEqual(responseTranscript(response)); expect(responseTranscript({ diff --git a/packages/shared/test/keys.test.ts b/packages/shared/test/keys.test.ts index e3e4d050..78334c84 100644 --- a/packages/shared/test/keys.test.ts +++ b/packages/shared/test/keys.test.ts @@ -7,14 +7,14 @@ import { const identity = { v: 2 as const, relay_origin: "agentcall.benree.tech", - address: "ken@agentcall.benree.tech", + address: "@acme/ken", identity_pub: "BASE64URLPUBLICKEY", }; const encKey = { v: 2 as const, relay_origin: "agentcall.benree.tech", - address: "ken@agentcall.benree.tech", + address: "@acme/ken", key_id: "0123456789abcdef0123456789abcdef", suite: HPKE_SUITE, pub: "BASE64URLENCRYPTIONKEY", @@ -92,7 +92,7 @@ describe("EncryptionKeyRecord", () => { describe("transcripts", () => { it("changes when any identity field changes", () => { const a = identityTranscript(identity); - const b = identityTranscript({ ...identity, address: "sarah@agentcall.benree.tech" }); + const b = identityTranscript({ ...identity, address: "@acme/sarah" }); expect(Array.from(a)).not.toEqual(Array.from(b)); }); diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index 317115a6..f60aa885 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { E2EECallerFrame, E2EEListenerToRelayFrame, E2EERelayToCallerFrame, E2EERelayToListenerFrame, E2EERequestPayload, E2EEOutcome, - formatAddress, HANDLE_RE, MAX_MESSAGE_BYTES, parseAddress, parseKeyAddress, safeParseFrame, + formatAddress, HANDLE_RE, MAX_MESSAGE_BYTES, parseAddress, safeParseFrame, RegisterRequest, MAX_DETAIL_LENGTH, sanitizeDetail, sanitizeTerminalOutput, sanitizeTerminalCell, stringifyTerminalSafeJson, CallAccepted, CallStarted, CancelCall, CallCancelled, CallNotCancelled, @@ -16,13 +16,13 @@ import { const requestEnvelope = { v: 1 as const, direction: "request" as const, relay_origin: "relay.test", - from: "alice@relay.test", to: "ken@relay.test", key_id: "a".repeat(32), + from: "@acme/alice", to: "@acme/ken", key_id: "a".repeat(32), epoch: 1, enc: "A", ct: "B", }; const innerRequest = { v: 1 as const, direction: "request" as const, relay_origin: "relay.test", - from: "alice@relay.test", to: "ken@relay.test", request_id: "1".repeat(32), + from: "@acme/alice", to: "@acme/ken", request_id: "1".repeat(32), sender_identity_key_id: "2".repeat(32), recipient_encryption_key_id: "3".repeat(32), recipient_epoch: 1, issued_at: 1, expires_at: 2, message: "hi", }; @@ -80,11 +80,11 @@ describe("task id bounds", () => { describe("address grammar", () => { it("splits @org/handle", () => { - expect(parseKeyAddress("@acme/ken")).toEqual({ org: "acme", handle: "ken" }); + expect(parseAddress("@acme/ken")).toEqual({ org: "acme", handle: "ken" }); }); it("round-trips through formatAddress", () => { - expect(parseKeyAddress(formatAddress("acme", "ken"))).toEqual({ org: "acme", handle: "ken" }); + expect(parseAddress(formatAddress("acme", "ken"))).toEqual({ org: "acme", handle: "ken" }); expect(formatAddress("acme", "ken")).toBe("@acme/ken"); }); @@ -92,35 +92,35 @@ describe("address grammar", () => { // that looks like a host may parse. A DNS-shaped address promises resolution // this system does not implement. it("rejects every host-shaped form", () => { - expect(parseKeyAddress("ken@agentcall.benree.tech")).toBeNull(); - expect(parseKeyAddress("ken@acme.agentcall.agent-call.app")).toBeNull(); - expect(parseKeyAddress("ken@acme")).toBeNull(); - expect(parseKeyAddress("@acme.corp/ken")).toBeNull(); - expect(parseKeyAddress("@acme/ken.tech")).toBeNull(); + expect(parseAddress("ken@agentcall.benree.tech")).toBeNull(); + expect(parseAddress("ken@acme.agentcall.agent-call.app")).toBeNull(); + expect(parseAddress("ken@acme")).toBeNull(); + expect(parseAddress("@acme.corp/ken")).toBeNull(); + expect(parseAddress("@acme/ken.tech")).toBeNull(); }); it("rejects garbage", () => { - expect(parseKeyAddress("ken")).toBeNull(); - expect(parseKeyAddress("@acme/")).toBeNull(); - expect(parseKeyAddress("@/ken")).toBeNull(); - expect(parseKeyAddress("acme/ken")).toBeNull(); - expect(parseKeyAddress("@ACME/ken")).toBeNull(); - expect(parseKeyAddress("@acme/KEN")).toBeNull(); - expect(parseKeyAddress("@acme/ken/extra")).toBeNull(); - expect(parseKeyAddress("")).toBeNull(); + expect(parseAddress("ken")).toBeNull(); + expect(parseAddress("@acme/")).toBeNull(); + expect(parseAddress("@/ken")).toBeNull(); + expect(parseAddress("acme/ken")).toBeNull(); + expect(parseAddress("@ACME/ken")).toBeNull(); + expect(parseAddress("@acme/KEN")).toBeNull(); + expect(parseAddress("@acme/ken/extra")).toBeNull(); + expect(parseAddress("")).toBeNull(); }); // Leading and trailing whitespace is the paste hazard: addresses are copied // out of chat and docs. Reject rather than trim, so a mis-scoped address can // never be silently normalised into a valid one. it("rejects surrounding whitespace rather than trimming", () => { - expect(parseKeyAddress(" @acme/ken")).toBeNull(); - expect(parseKeyAddress("@acme/ken ")).toBeNull(); + expect(parseAddress(" @acme/ken")).toBeNull(); + expect(parseAddress("@acme/ken ")).toBeNull(); }); it("enforces the org length cap so the address stays short", () => { - expect(parseKeyAddress(`@${"a".repeat(20)}/ken`)).not.toBeNull(); - expect(parseKeyAddress(`@${"a".repeat(21)}/ken`)).toBeNull(); + expect(parseAddress(`@${"a".repeat(20)}/ken`)).not.toBeNull(); + expect(parseAddress(`@${"a".repeat(21)}/ken`)).toBeNull(); }); }); diff --git a/packages/shared/test/task-protocol.test.ts b/packages/shared/test/task-protocol.test.ts index 185ee340..75c1f975 100644 --- a/packages/shared/test/task-protocol.test.ts +++ b/packages/shared/test/task-protocol.test.ts @@ -6,7 +6,7 @@ import { const request = { v: 1 as const, direction: "request" as const, relay_origin: "relay.test", - from: "alice@relay.test", to: "ken@relay.test", request_id: "1".repeat(32), + from: "@acme/alice", to: "@acme/ken", request_id: "1".repeat(32), sender_identity_key_id: "2".repeat(32), recipient_encryption_key_id: "3".repeat(32), recipient_epoch: 1, issued_at: 1, expires_at: 2, message: "hi", }; From 256ad97270bec353109f1347099ac970fb2b5964 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 21:44:33 -0700 Subject: [PATCH 4/6] docs: address the reader in `@org/handle` (#307 slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the published surface: README, the Mintlify guides, and the generated CLI reference. The prose that described an address as `handle@host` now describes what it is, and `contacts add` rejects a bad address with the grammar it actually enforces. `docs/site/reference/cli.mdx` is regenerated rather than hand-edited — the argument descriptions in `packages/cli/src/commands/*` are the source, and editing the output directly is how that file drifted before (#317). Left alone deliberately: `AGENTCALL_POLICY_EXT` still names benree.tech. It is a protocol namespace identifier rather than an address, so changing it is a wire break with its own decision to make, and #307 scoped it out explicitly. --- README.md | 14 +++++++------- docs/site/get-started/first-call.mdx | 12 ++++++------ docs/site/get-started/setup.mdx | 4 ++-- docs/site/guides/calls-and-conversations.mdx | 10 +++++----- docs/site/guides/discovery-and-contacts.mdx | 2 +- docs/site/guides/identity-and-keys.mdx | 6 +++--- docs/site/index.mdx | 2 +- docs/site/overview/concepts.mdx | 2 +- docs/site/reference/cli.mdx | 10 +++++----- packages/cli/src/commands/call.ts | 2 +- packages/cli/src/commands/card.ts | 2 +- packages/cli/src/commands/contacts.ts | 4 ++-- packages/cli/src/commands/peer.ts | 2 +- packages/cli/src/commands/status.ts | 2 +- packages/cli/src/contacts.ts | 4 ++-- packages/cli/src/snippet.ts | 6 +++--- packages/cli/src/verbs.ts | 2 +- packages/cli/test/contacts.test.ts | 2 +- 18 files changed, 44 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index db9c01ca..0ca6ce94 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Call another person's coding agent—Claude Code or Codex—on their machine, across the public internet. Install the CLI, claim an address such as -`ken@acme.agentcall.benree.tech`, and share it with your team. When someone +`@acme/ken`, and share it with your team. When someone calls, AgentCall starts a fresh agent process on your machine and returns its answer to the caller. @@ -76,27 +76,27 @@ and [setup guide](https://agentcall.mintlify.app/get-started/setup). Check an address, make a call, or ask for machine-readable output: ```bash -agentcall status ken@acme.agentcall.benree.tech -agentcall call ken@acme.agentcall.benree.tech "Why did CI fail?" -agentcall call ken@acme.agentcall.benree.tech "Summarize the failure" --json +agentcall status @acme/ken +agentcall call @acme/ken "Why did CI fail?" +agentcall call @acme/ken "Summarize the failure" --json ``` Pin a peer's identity and compare the fingerprint through another channel: ```bash -agentcall verify ken@acme.agentcall.benree.tech +agentcall verify @acme/ken ``` Continue the last open conversation with that address: ```bash -agentcall call ken@acme.agentcall.benree.tech "Which commit introduced it?" --continue +agentcall call @acme/ken "Which commit introduced it?" --continue ``` Save frequently used addresses locally: ```bash -agentcall contacts add ken ken@acme.agentcall.benree.tech +agentcall contacts add ken @acme/ken agentcall call ken "Can you review this migration plan?" ``` diff --git a/docs/site/get-started/first-call.mdx b/docs/site/get-started/first-call.mdx index 79d6d8e9..f8cacde1 100644 --- a/docs/site/get-started/first-call.mdx +++ b/docs/site/get-started/first-call.mdx @@ -4,12 +4,12 @@ description: Check a colleague's AgentCall address, inspect its task card, and m --- You need a completed setup and an address in your organization, such as -`ken@acme.agentcall.benree.tech`. +`@acme/ken`. ## 1. Check presence ```bash -agentcall status ken@acme.agentcall.benree.tech +agentcall status @acme/ken ``` `online` exits with status `0`; `offline` exits with status `2`. You can always @@ -19,7 +19,7 @@ calling does not. ## 2. Inspect the task menu ```bash -agentcall card ken@acme.agentcall.benree.tech +agentcall card @acme/ken ``` Without `--task`, calls use the built-in read-only `ask` task. Use an advertised @@ -28,7 +28,7 @@ task only when you need its specific instructions or capabilities. ## 3. Call ```bash -agentcall call ken@acme.agentcall.benree.tech "Summarize the current API authentication flow" +agentcall call @acme/ken "Summarize the current API authentication flow" ``` Lifecycle updates such as `ringing...`, `answered...`, and `agent working...` @@ -37,13 +37,13 @@ go to standard error. The reply goes to standard output, so it remains pipeable. For machine-readable output: ```bash -agentcall call ken@acme.agentcall.benree.tech "List the failing checks" --json +agentcall call @acme/ken "List the failing checks" --json ``` To request a published task: ```bash -agentcall call ken@acme.agentcall.benree.tech --task architecture-history \ +agentcall call @acme/ken --task architecture-history \ "Why did the team choose this migration?" ``` diff --git a/docs/site/get-started/setup.mdx b/docs/site/get-started/setup.mdx index 04451e6f..b6c3c041 100644 --- a/docs/site/get-started/setup.mdx +++ b/docs/site/get-started/setup.mdx @@ -27,14 +27,14 @@ Setup: 5. Publishes the line's card and keys. 6. Runs a verification call unless `--no-verify` is set. -Your address has the form `handle@organization.agentcall.benree.tech`. Share it +Your address has the form `@organization/handle`. Share it only after reviewing the [security posture](/security/overview). ## Verify the installation ```bash agentcall doctor -agentcall status your-handle@organization.agentcall.benree.tech +agentcall status @organization/your-handle ``` `doctor` uses `✓` for a pass, `✗` for a failure with a fix, and `!` when a check diff --git a/docs/site/guides/calls-and-conversations.mdx b/docs/site/guides/calls-and-conversations.mdx index 8bcdc73c..ca43591f 100644 --- a/docs/site/guides/calls-and-conversations.mdx +++ b/docs/site/guides/calls-and-conversations.mdx @@ -7,14 +7,14 @@ description: Choose tasks, consume AgentCall output, continue a conversation, an ```bash # Plain read-only question -agentcall call ken@acme.agentcall.benree.tech "What owns the billing webhook?" +agentcall call @acme/ken "What owns the billing webhook?" # A task advertised on the callee's card -agentcall call ken@acme.agentcall.benree.tech --task architecture-history \ +agentcall call @acme/ken --task architecture-history \ "Why did we choose this queue?" # Exact reply envelope for another program -agentcall call ken@acme.agentcall.benree.tech "List the failures" --json +agentcall call @acme/ken "List the failures" --json ``` Human-readable replies preserve tabs and line breaks while neutralizing terminal @@ -26,8 +26,8 @@ and escapes terminal-active characters in the serialized form. When a reply opens a context, the CLI prints a note on standard error: ```bash -agentcall call ken@acme.agentcall.benree.tech "Why did CI fail?" -agentcall call ken@acme.agentcall.benree.tech "Which commit introduced it?" --continue +agentcall call @acme/ken "Why did CI fail?" +agentcall call @acme/ken "Which commit introduced it?" --continue ``` Use `--context ` to select a specific context instead of the most recent one. diff --git a/docs/site/guides/discovery-and-contacts.mdx b/docs/site/guides/discovery-and-contacts.mdx index 0fb29e0a..04b4679c 100644 --- a/docs/site/guides/discovery-and-contacts.mdx +++ b/docs/site/guides/discovery-and-contacts.mdx @@ -9,7 +9,7 @@ capability you need but not the person who offers it. ## Save contacts ```bash -agentcall contacts add ken ken@acme.agentcall.benree.tech --note "payments" +agentcall contacts add ken @acme/ken --note "payments" agentcall contacts list agentcall call ken "Which service owns refunds?" ``` diff --git a/docs/site/guides/identity-and-keys.mdx b/docs/site/guides/identity-and-keys.mdx index 712b1710..364e99ae 100644 --- a/docs/site/guides/identity-and-keys.mdx +++ b/docs/site/guides/identity-and-keys.mdx @@ -10,7 +10,7 @@ channel before relying on it. ## Verify a peer ```bash -agentcall verify ken@acme.agentcall.benree.tech +agentcall verify @acme/ken ``` The command validates the signed encryption-key record and pins the identity in @@ -22,8 +22,8 @@ epochs fail closed. Confirm the new fingerprint out of band, then run: ```bash -agentcall trust --reset ken@acme.agentcall.benree.tech -agentcall verify ken@acme.agentcall.benree.tech +agentcall trust --reset @acme/ken +agentcall verify @acme/ken ``` diff --git a/docs/site/index.mdx b/docs/site/index.mdx index 252c3e69..4d1cbb25 100644 --- a/docs/site/index.mdx +++ b/docs/site/index.mdx @@ -5,7 +5,7 @@ description: Give Claude Code or Codex a callable address and ask another person AgentCall lets one person call another person's Claude Code or Codex agent from the terminal. The owner chooses what their agent can do; the caller sends a -message to an address such as `ken@acme.agentcall.benree.tech` and receives the +message to an address such as `@acme/ken` and receives the answer on standard output. diff --git a/docs/site/overview/concepts.mdx b/docs/site/overview/concepts.mdx index 908ab448..b3430fcc 100644 --- a/docs/site/overview/concepts.mdx +++ b/docs/site/overview/concepts.mdx @@ -10,7 +10,7 @@ policy or operating more than one identity. | --- | --- | | Organization | The outer routing boundary. Calls do not cross organizations. | | Handle | A name unique inside one organization, such as `ken`. | -| Address | A handle plus relay host, such as `ken@acme.agentcall.benree.tech`. | +| Address | A handle plus relay host, such as `@acme/ken`. | | Line | One local identity: handle, relay token, agent kind, policy, tasks, and working directory. | | Relay | The service that authenticates and routes calls. It sees metadata, not encrypted call content. | | Caller | The line placing a call. | diff --git a/docs/site/reference/cli.mdx b/docs/site/reference/cli.mdx index f1011115..d7d2cc7e 100644 --- a/docs/site/reference/cli.mdx +++ b/docs/site/reference/cli.mdx @@ -181,7 +181,7 @@ Usage: agentcall call [options]
call another handle's agent with a message and print its reply Arguments: - address contact name or handle@host to call + address contact name or @org/handle to call message message to send Options: @@ -203,7 +203,7 @@ Usage: agentcall status [options]
check whether a handle's agent is currently online Arguments: - address contact name or handle@host to check + address contact name or @org/handle to check Options: --as line to check from (defaults to the primary line on the @@ -219,7 +219,7 @@ Usage: agentcall verify [options]
fetch and verify a peer's pinned identity fingerprint Arguments: - address contact name or handle@host to verify + address contact name or @org/handle to verify Options: --as line whose relay credentials to use @@ -325,7 +325,7 @@ Usage: agentcall card [options] [target] show your own card with problems, another agent's menu, or publish yours (push) Arguments: - target contact name or handle@host to fetch, 'push' to publish, or + target contact name or @org/handle to fetch, 'push' to publish, or omit to review your own card Options: @@ -361,7 +361,7 @@ save (or update) a contact so you can call them by name Arguments: name short name to call them by (no @) - address their handle@host + address their @org/handle Options: --note who they are and what to ask them about diff --git a/packages/cli/src/commands/call.ts b/packages/cli/src/commands/call.ts index 65f79c02..c6d99725 100644 --- a/packages/cli/src/commands/call.ts +++ b/packages/cli/src/commands/call.ts @@ -13,7 +13,7 @@ export function register(program: Command): void { program .command("call") .description("call another handle's agent with a message and print its reply") - .argument("
", "contact name or handle@host to call") + .argument("
", "contact name or @org/handle to call") .argument("", "message to send") .option("--json", "print the full reply envelope instead of just the text") .option("--task ", "task from the callee's card to perform (see: agentcall card
)") diff --git a/packages/cli/src/commands/card.ts b/packages/cli/src/commands/card.ts index 7f4806a0..5a9e0f60 100644 --- a/packages/cli/src/commands/card.ts +++ b/packages/cli/src/commands/card.ts @@ -42,7 +42,7 @@ export function registerCard(program: Command): void { program .command("card") .description("show your own card with problems, another agent's menu, or publish yours (push)") - .argument("[target]", "contact name or handle@host to fetch, 'push' to publish, or omit to review your own card") + .argument("[target]", "contact name or @org/handle to fetch, 'push' to publish, or omit to review your own card") .option("--line ", "line to use (defaults to the primary line)") .action(async (target: string | undefined, o: { line?: string }) => { const machine = getMachinePaths(); diff --git a/packages/cli/src/commands/contacts.ts b/packages/cli/src/commands/contacts.ts index 0ef32241..7e5f3871 100644 --- a/packages/cli/src/commands/contacts.ts +++ b/packages/cli/src/commands/contacts.ts @@ -4,7 +4,7 @@ import { addContact, loadContacts, removeContact } from "../contacts.js"; export function register(program: { command(name: string): any }): void { const contacts = program.command("contacts").description("manage your local address book of callable agents"); contacts.command("add").description("save (or update) a contact so you can call them by name") - .argument("", "short name to call them by (no @)").argument("
", "their handle@host") + .argument("", "short name to call them by (no @)").argument("
", "their @org/handle") .option("--note ", "who they are and what to ask them about") .action((name: string, address: string, o: { note?: string }) => { try { @@ -18,7 +18,7 @@ export function register(program: { command(name: string): any }): void { const sorted = [...loadContacts(getMachinePaths()).contacts].sort((a, b) => a.name.localeCompare(b.name)); if (o.json) { console.log(JSON.stringify(sorted)); return; } if (sorted.length === 0) { - console.log('No contacts yet. Save one with:\n agentcall contacts add --note "who they are"\nThen call by name: agentcall call ""'); + console.log('No contacts yet. Save one with:\n agentcall contacts add <@org/handle> --note "who they are"\nThen call by name: agentcall call ""'); return; } for (const c of sorted) console.log(`${c.name} ${c.address}${c.note ? ` — ${c.note}` : ""}`); diff --git a/packages/cli/src/commands/peer.ts b/packages/cli/src/commands/peer.ts index 5f56eca1..8f38cbdf 100644 --- a/packages/cli/src/commands/peer.ts +++ b/packages/cli/src/commands/peer.ts @@ -9,7 +9,7 @@ export function register(program: { command(name: string): any }): void { program .command("verify") .description("fetch and verify a peer's pinned identity fingerprint") - .argument("
", "contact name or handle@host to verify") + .argument("
", "contact name or @org/handle to verify") .option("--as ", "line whose relay credentials to use") .action(async (address: string, o: { as?: string }) => { const machine = getMachinePaths(); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 258070b7..7bc92846 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -9,7 +9,7 @@ export function register(program: { command(name: string): any }): void { program .command("status") .description("check whether a handle's agent is currently online") - .argument("
", "contact name or handle@host to check") + .argument("
", "contact name or @org/handle to check") .option("--as ", "line to check from (defaults to the primary line on the destination's relay)") .action(async (address: string, o: { as?: string }) => { const machine = getMachinePaths(); diff --git a/packages/cli/src/contacts.ts b/packages/cli/src/contacts.ts index 64cf0794..cfe37d07 100644 --- a/packages/cli/src/contacts.ts +++ b/packages/cli/src/contacts.ts @@ -42,10 +42,10 @@ const byName = (contacts: Contact[], name: string) => export function addContact(p: MachinePaths, name: string, address: string, note?: string): "added" | "updated" { if (!NAME_RE.test(name)) { - throw new Error(`Invalid contact name "${name}" — start with a letter or digit, then letters, digits, ".", "_", "-" (no @).`); + throw new Error(`Invalid contact name "${name}" — start with a letter or digit, then letters, digits, ".", "_", "-" (no @ or /).`); } if (!parseAddress(address)) { - throw new Error(`Invalid address: ${address} (expected handle@host)`); + throw new Error(`Invalid address: ${address} (expected @org/handle)`); } const file = loadContacts(p); const idx = byName(file.contacts, name); diff --git a/packages/cli/src/snippet.ts b/packages/cli/src/snippet.ts index 9d5493ae..1f3bf0cf 100644 --- a/packages/cli/src/snippet.ts +++ b/packages/cli/src/snippet.ts @@ -14,11 +14,11 @@ address, like a phone call: who each person is and what to ask them about. Check here first when the user names a person without giving an address, and use the note to compose an appropriate message. -- \`agentcall call ""\` — sends the message to +- \`agentcall call ""\` — sends the message to that person's agent (runs on their machine) and prints its reply. Takes 30s-5min. -- \`agentcall status \` — check if their agent is online first. -- \`agentcall contacts add --note ""\` — when +- \`agentcall status \` — check if their agent is online first. +- \`agentcall contacts add <@org/handle> --note ""\` — when the user gives an address for someone new, offer to save it for next time. - \`agentcall line list\` — the addresses this machine answers on. Calls go out as the primary line unless you pass \`--as \`. diff --git a/packages/cli/src/verbs.ts b/packages/cli/src/verbs.ts index 456ca169..2873739f 100644 --- a/packages/cli/src/verbs.ts +++ b/packages/cli/src/verbs.ts @@ -14,7 +14,7 @@ export function execVerb( ): { policy: Policy; lines: string[] } { const requireHandle = (h: string) => { if (!HANDLE_RE.test(h)) { - throw new Error(`"${h}" is not a valid handle. Use the bare handle (e.g. ken), not handle@host.`); + throw new Error(`"${h}" is not a valid handle. Use the bare handle (e.g. ken), not @org/handle.`); } return h; }; diff --git a/packages/cli/test/contacts.test.ts b/packages/cli/test/contacts.test.ts index 11fe8235..bea01068 100644 --- a/packages/cli/test/contacts.test.ts +++ b/packages/cli/test/contacts.test.ts @@ -51,7 +51,7 @@ describe("contacts store", () => { const p = getMachinePaths(tempHome()); expect(() => addContact(p, "ken@home", "@acme/ken")).toThrow(/Invalid contact name/); expect(() => addContact(p, "-ken", "@acme/ken")).toThrow(/Invalid contact name/); - expect(() => addContact(p, "ken", "not-an-address")).toThrow(/handle@host/); + expect(() => addContact(p, "ken", "not-an-address")).toThrow(/@org\/handle/); expect(loadContacts(p)).toEqual({ contacts: [] }); }); From 3294bdde48115ab59dbe641b6fbb3e8e9176c8fb Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 21:52:48 -0700 Subject: [PATCH 5/6] refactor(shared)!: keep the key records at v1 rather than minting a v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the version bump from slice 2. The rule that argued for it — "adding a field means a new record version" — exists to protect deployed verifiers, and there are none: nothing has ever published an identity or encryption-key record. A v2 whose v1 never existed in the world is worse than no version change. It permanently implies an earlier shape someone might still hold, invites "do we still need to accept v1?" from every future reader, and left `keys.ts` as the only v2 in a protocol surface that is v1 everywhere else — e2ee payloads, the HPKE AAD, request and response transcripts, room join proofs. So v1 is this shape, including `relay_origin`, and the transcript labels go back to `agentcall/{identity,encryption-key}/v1`. The rule starts applying from here; the comment in keys.ts now says so, since the labels are stable from this point but were not across this change. Also corrects the record: #307's decision text says the long `handle@host` form would be accepted as input indefinitely. That is compatibility for users who do not exist, and it was never built — `parseAddress` rejects every host-shaped form and a test asserts it. The code was right; the decision text was stale. --- apps/relay/src/keys.ts | 4 ++-- apps/relay/test/keys.test.ts | 12 ++++++------ packages/cli/src/api.ts | 4 ++-- packages/cli/src/known-peers.ts | 2 +- packages/cli/test/api.test.ts | 4 ++-- packages/cli/test/call-client.test.ts | 4 ++-- packages/cli/test/cli-actions.test.ts | 8 ++++---- packages/cli/test/doctor.test.ts | 16 ++++++++-------- packages/cli/test/known-peers.test.ts | 4 ++-- packages/cli/test/listener-stages.test.ts | 4 ++-- packages/cli/test/listener.test.ts | 4 ++-- packages/shared/src/keys.ts | 17 +++++++++-------- packages/shared/test/keys.test.ts | 4 ++-- 13 files changed, 44 insertions(+), 43 deletions(-) diff --git a/apps/relay/src/keys.ts b/apps/relay/src/keys.ts index cf6c71e2..b0f8dff6 100644 --- a/apps/relay/src/keys.ts +++ b/apps/relay/src/keys.ts @@ -184,10 +184,10 @@ export function mountKeys(app: Hono): void { // reconstructing at all.) const address = addressFor(c, identity.org, target); return c.json({ - identity: { v: 2, relay_origin: relayOriginFor(c), address, identity_pub: identityPub }, + identity: { v: 1, relay_origin: relayOriginFor(c), address, identity_pub: identityPub }, encryption: { record: { - v: 2, relay_origin: relayOriginFor(c), address, + v: 1, relay_origin: relayOriginFor(c), address, key_id: row.key_id, suite: row.suite, pub: row.pub, epoch: row.epoch, not_before: row.not_before, not_after: row.not_after, prev: row.prev, }, diff --git a/apps/relay/test/keys.test.ts b/apps/relay/test/keys.test.ts index 77c513bf..c1b1a80f 100644 --- a/apps/relay/test/keys.test.ts +++ b/apps/relay/test/keys.test.ts @@ -18,7 +18,7 @@ async function newIdentity(handle: string) { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const record = { - v: 2 as const, + v: 1 as const, relay_origin: HOST, address: `@acme/${handle}`, identity_pub: await exportPublicKey(idKp.publicKey), @@ -48,7 +48,7 @@ async function encRecord(who: Awaited>, epoch: nu const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const record = { - v: 2 as const, + v: 1 as const, relay_origin: HOST, address: address ?? `@acme/${who.handle}`, key_id: await keyIdFor(pub), @@ -217,7 +217,7 @@ describe("key publication endpoints", () => { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 2 as const, relay_origin: HOST, + v: 1 as const, relay_origin: HOST, address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { @@ -240,7 +240,7 @@ describe("key publication endpoints", () => { const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const encryption = { - v: 2 as const, + v: 1 as const, relay_origin: HOST, address, key_id: await keyIdFor(pub), @@ -282,7 +282,7 @@ describe("key publication endpoints", () => { const token = await registerHandle(handle); const idKp = await generateIdentityKeyPair(); const identity = { - v: 2 as const, relay_origin: HOST, + v: 1 as const, relay_origin: HOST, address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { @@ -305,7 +305,7 @@ describe("key publication endpoints", () => { const encKp = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encKp.publicKey); const encryption = { - v: 2 as const, + v: 1 as const, relay_origin: HOST, address, key_id: await keyIdFor(pub), diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 3bce988c..540b86e0 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -388,7 +388,7 @@ export async function publishIdentityKey( const record: IdentityRecordType = IdentityRecord.parse({ // Both bindings are derived, never passed in pre-composed: the address is // a registry key over (org, handle), and the relay origin is the endpoint. - v: 2, relay_origin: new URL(relay).hostname, + v: 1, relay_origin: new URL(relay).hostname, address: formatAddress(auth.org, auth.handle), identity_pub: keys.identity_pub, }); @@ -422,7 +422,7 @@ export async function publishEncryptionKey( if (!publication) { const pub = keys.encryption_pub; const record: EncryptionKeyRecordType = EncryptionKeyRecord.parse({ - v: 2, + v: 1, relay_origin: new URL(relay).hostname, address: formatAddress(auth.org, auth.handle), key_id: await keyIdFor(pub), diff --git a/packages/cli/src/known-peers.ts b/packages/cli/src/known-peers.ts index 4d81670d..fffb4097 100644 --- a/packages/cli/src/known-peers.ts +++ b/packages/cli/src/known-peers.ts @@ -81,7 +81,7 @@ export async function verifyAndPinPeer( const existing = peers.find((peer) => peer.address === address); const servedFingerprint = await fingerprint(identityTranscript(bundle.identity)); const storedIdentity = existing && { - v: 2 as const, relay_origin: existing.relay_origin, + v: 1 as const, relay_origin: existing.relay_origin, address: existing.address, identity_pub: existing.identity_pub, }; const storedFingerprint = storedIdentity && await fingerprint(identityTranscript(storedIdentity)); diff --git a/packages/cli/test/api.test.ts b/packages/cli/test/api.test.ts index 94e87af9..470293ae 100644 --- a/packages/cli/test/api.test.ts +++ b/packages/cli/test/api.test.ts @@ -470,12 +470,12 @@ async function buildValidKeysResponse( keys: StoredKeys, address: string, ): Promise<{ identity: IdentityRecordType; encryption: { record: EncryptionKeyRecordType; signature: string } }> { const identity: IdentityRecordType = { - v: 2, relay_origin: "relay.test", address, + v: 1, relay_origin: "relay.test", address, identity_pub: keys.identity_pub, }; const now = 1_754_000_000_000; const record: EncryptionKeyRecordType = { - v: 2, + v: 1, relay_origin: "relay.test", address, key_id: await keyIdFor(keys.encryption_pub), diff --git a/packages/cli/test/call-client.test.ts b/packages/cli/test/call-client.test.ts index 04e2051d..ee058cd4 100644 --- a/packages/cli/test/call-client.test.ts +++ b/packages/cli/test/call-client.test.ts @@ -77,7 +77,7 @@ async function identity(name: string): Promise<{ keys: StoredKeys; paths: Return async function encryptionRecord(address: string, keys: StoredKeys): Promise { return { - v: 2, relay_origin: "relay.test", + v: 1, relay_origin: "relay.test", address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, pub: keys.encryption_pub, epoch: keys.epoch, not_before: 1, not_after: Date.now() + 1_000_000, prev: null, @@ -99,7 +99,7 @@ async function fixture(relay: string, overrides: Partial = {}) { keyDeps: { fetchKeys: async () => ({ identity: { - v: 2, relay_origin: origin, + v: 1, relay_origin: origin, address: toAddress, identity_pub: recipient.keys.identity_pub, }, encryption: { record: recipientRecord, signature: "unused" }, diff --git a/packages/cli/test/cli-actions.test.ts b/packages/cli/test/cli-actions.test.ts index 887661c9..3c8a4e00 100644 --- a/packages/cli/test/cli-actions.test.ts +++ b/packages/cli/test/cli-actions.test.ts @@ -130,12 +130,12 @@ describe("trust CLI", () => { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record = { - v: 2 as const, relay_origin: "relay.test", + v: 1 as const, relay_origin: "relay.test", address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, }; const identityRecord = { - v: 2 as const, relay_origin: "relay.test", + v: 1 as const, relay_origin: "relay.test", address, identity_pub: identityPub, }; return { @@ -217,11 +217,11 @@ async function startCallRelay( const relayOrigin = "127.0.0.1"; const remoteAddress = "@acme/sota"; const identity = { - v: 2 as const, relay_origin: relayOrigin, + v: 1 as const, relay_origin: relayOrigin, address: remoteAddress, identity_pub: remote.identity_pub, }; const record = { - v: 2 as const, relay_origin: relayOrigin, + v: 1 as const, relay_origin: relayOrigin, address: remoteAddress, key_id: await keyIdFor(remote.encryption_pub), suite: HPKE_SUITE, pub: remote.encryption_pub, epoch: 1, not_before: Date.now() - 1_000, not_after: Date.now() + 60_000, prev: null, diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index 33363aed..e8fd7ae4 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -257,12 +257,12 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "relay.example", + v: 1, relay_origin: "relay.example", address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, + identity: { v: 1, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks).toEqual([ @@ -278,12 +278,12 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "relay.example", + v: 1, relay_origin: "relay.example", address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch + 1, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth(cfg, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, + identity: { v: 1, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, })); expect(checks.at(-1)).toMatchObject({ name: "published identity keys", ok: false }); @@ -310,14 +310,14 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const now = Date.now(); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "relay.example", + v: 1, relay_origin: "relay.example", address: "@acme/ken", key_id: await keyIdFor(local.encryption_pub), suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, not_before: now - 1_000, not_after: now + 60_000, prev: null, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, + identity: { v: 1, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: "invalid" }, }), ); @@ -337,13 +337,13 @@ describe("doctor key health", () => { const local = await generateIdentityKeys(paths); const values = await fields(local); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "@acme/ken".slice("@acme/ken".indexOf("@") + 1), address: "@acme/ken", suite: HPKE_SUITE, pub: local.encryption_pub, + v: 1, relay_origin: "@acme/ken".slice("@acme/ken".indexOf("@") + 1), address: "@acme/ken", suite: HPKE_SUITE, pub: local.encryption_pub, epoch: local.epoch, prev: null, ...values, }; const checks = await checkLineKeyHealth( { org: "acme", handle: "ken", token: "t", relay: "https://relay.example" }, paths, async () => ({ - identity: { v: 2, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, + identity: { v: 1, relay_origin: "relay.example", address: "@acme/ken", identity_pub: local.identity_pub }, encryption: { record, signature: await signed(local, record) }, }), ); diff --git a/packages/cli/test/known-peers.test.ts b/packages/cli/test/known-peers.test.ts index 49e19bfa..ddfae0fc 100644 --- a/packages/cli/test/known-peers.test.ts +++ b/packages/cli/test/known-peers.test.ts @@ -30,7 +30,7 @@ async function bundle(identity?: CryptoKeyPair, epoch = 1, address = PEER) { const encryption = await generateEncryptionKeyPair(); const pub = await exportPublicKey(encryption.publicKey); const record: EncryptionKeyRecordType = { - v: 2, relay_origin: "relay.test", address, + v: 1, relay_origin: "relay.test", address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, epoch, not_before: 1, not_after: 1_000, prev: null, }; @@ -38,7 +38,7 @@ async function bundle(identity?: CryptoKeyPair, epoch = 1, address = PEER) { identityKey: identity, value: { identity: { - v: 2 as const, relay_origin: "relay.test", + v: 1 as const, relay_origin: "relay.test", address, identity_pub: identityPub, }, encryption: { record, signature: await signTranscript(identity.privateKey, encryptionKeyTranscript(record)) }, diff --git a/packages/cli/test/listener-stages.test.ts b/packages/cli/test/listener-stages.test.ts index 30a52d7d..e6b8a32f 100644 --- a/packages/cli/test/listener-stages.test.ts +++ b/packages/cli/test/listener-stages.test.ts @@ -56,12 +56,12 @@ function seedTask(paths: LinePaths, id: string, frontmatter: string[], body = "d async function callerBundleFor(handle: string) { const record: EncryptionKeyRecordType = { - v: 2, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), + v: 1, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), suite: HPKE_SUITE, pub: callerKeys.encryption_pub, epoch: callerKeys.epoch, not_before: 1, not_after: Date.now() + RELAY_CALL_TIMEOUT_MS, prev: null, }; return { - identity: { v: 2 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, + identity: { v: 1 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, encryption: { record, signature: "unused" }, }; } diff --git a/packages/cli/test/listener.test.ts b/packages/cli/test/listener.test.ts index 621c4730..0f0e069f 100644 --- a/packages/cli/test/listener.test.ts +++ b/packages/cli/test/listener.test.ts @@ -160,12 +160,12 @@ function baseDeps(relay: string) { codexToolTelemetryEnabled: () => true, fetchKeys: async (_relay: string, _auth: unknown, handle: string) => { const record: EncryptionKeyRecordType = { - v: 2, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), + v: 1, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, key_id: await keyIdFor(callerKeys.encryption_pub), suite: HPKE_SUITE, pub: callerKeys.encryption_pub, epoch: callerKeys.epoch, not_before: 1, not_after: Date.now() + RELAY_CALL_TIMEOUT_MS, prev: null, }; return { - identity: { v: 2 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, + identity: { v: 1 as const, relay_origin: `${handle}@127.0.0.1`.slice(`${handle}@127.0.0.1`.indexOf("@") + 1), address: `${handle}@127.0.0.1`, identity_pub: callerKeys.identity_pub }, encryption: { record, signature: "unused" }, }; }, diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 10903ecf..d0bbfa02 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -21,10 +21,7 @@ const PREV_RE = /^[0-9a-f]{32}$/; const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; export const IdentityRecord = z.object({ - // v2: `relay_origin` became an explicit signed field. It previously rode - // inside `address` as the host part, which tied the cross-relay binding to - // addresses being DNS-shaped. - v: z.literal(2), + v: z.literal(1), relay_origin: z.string().regex(RELAY_ORIGIN_RE), address: z.string().regex(ADDRESS_RE), identity_pub: z.string().regex(BASE64URL_RE).max(256), @@ -32,8 +29,7 @@ export const IdentityRecord = z.object({ export type IdentityRecordType = z.infer; export const EncryptionKeyRecord = z.object({ - // v2: see IdentityRecord. - v: z.literal(2), + v: z.literal(1), relay_origin: z.string().regex(RELAY_ORIGIN_RE), address: z.string().regex(ADDRESS_RE), key_id: z.string().regex(KEY_ID_RE), @@ -64,15 +60,20 @@ export type EncryptionKeyRecordType = z.infer; // Field order is part of the signature. Never reorder these lists; adding a // field means a new record version. +// +// That rule starts applying now. `relay_origin` was added to both records while +// nothing had ever published one, so v1 is this shape rather than the shape +// before it — there is no earlier v1 in the world to be compatible with, and +// minting a v2 for it would have implied one forever. export function identityTranscript(r: IdentityRecordType): Uint8Array { return canonicalEncode([ - "agentcall/identity/v2", r.v, r.relay_origin, r.address, r.identity_pub, + "agentcall/identity/v1", r.v, r.relay_origin, r.address, r.identity_pub, ]); } export function encryptionKeyTranscript(r: EncryptionKeyRecordType): Uint8Array { return canonicalEncode([ - "agentcall/encryption-key/v2", r.v, r.relay_origin, r.address, r.key_id, r.suite, r.pub, + "agentcall/encryption-key/v1", r.v, r.relay_origin, r.address, r.key_id, r.suite, r.pub, r.epoch, r.not_before, r.not_after, r.prev, ]); } diff --git a/packages/shared/test/keys.test.ts b/packages/shared/test/keys.test.ts index 78334c84..bc058a10 100644 --- a/packages/shared/test/keys.test.ts +++ b/packages/shared/test/keys.test.ts @@ -5,14 +5,14 @@ import { } from "../src/keys.js"; const identity = { - v: 2 as const, + v: 1 as const, relay_origin: "agentcall.benree.tech", address: "@acme/ken", identity_pub: "BASE64URLPUBLICKEY", }; const encKey = { - v: 2 as const, + v: 1 as const, relay_origin: "agentcall.benree.tech", address: "@acme/ken", key_id: "0123456789abcdef0123456789abcdef", From 2ac7d084522edc14d211fded09408475124f68fd Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 4 Aug 2026 21:59:45 -0700 Subject: [PATCH 6/6] ci: probe the unconfigured install with a well-formed address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed-CLI consumer job asserts that `doctor` and `status` both refuse an install with no lines, by grepping for "No agentcall config found". It probed with `status nobody@example.invalid`. That address is host-shaped, so under the new grammar `resolveAddress` rejects it as malformed before `pickOutboundLine` is ever reached, and the pinned string never prints. The job failed on a correct behaviour change: a malformed address should be reported as malformed. What was stale is the probe. `@acme/nobody` parses and resolves to nothing, so it exercises the path the check is actually about. Changed in both `.github/workflows/ci.yml` and `scripts/ci-local.sh`, which have to move together — a local gate that has drifted reports green for a rule CI would fail. Found by `scripts/ci-local.sh packaged`; `fast` does not pack or run the CLI as a consumer would, so it could not have caught this. --- .github/workflows/ci.yml | 2 +- scripts/ci-local.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e8b84d1..18ba4bae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: exit 1 fi grep -F "No agentcall config found" "$RUNNER_TEMP/doctor-output" - if "$agentcall_bin" status nobody@example.invalid >"$RUNNER_TEMP/status-output" 2>&1; then + if "$agentcall_bin" status @acme/nobody >"$RUNNER_TEMP/status-output" 2>&1; then echo "status unexpectedly succeeded without a configured identity" exit 1 fi diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index f48e8f68..cba03849 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -322,7 +322,7 @@ consumer_leg() { fi grep -F "No agentcall config found" "$out-doctor" || return 1 - if "$cli" status nobody@example.invalid >"$out-status" 2>&1; then + if "$cli" status @acme/nobody >"$out-status" 2>&1; then echo "status unexpectedly succeeded without a configured identity"; return 1 fi grep -F "No agentcall config found" "$out-status" || return 1