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/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/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 36cf1e6f..b0f8dff6 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,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: 1, relay_origin: relayOriginFor(c), address, identity_pub: identityPub }, encryption: { record: { - v: 1, address, key_id: row.key_id, suite: row.suite, pub: row.pub, + 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, }, signature: row.signature, 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: 1 as const, - address: address ?? `${who.handle}@${HOST}`, + relay_origin: HOST, + address: address ?? `@acme/${who.handle}`, key_id: await keyIdFor(pub), suite: HPKE_SUITE, pub, @@ -83,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)), }); @@ -95,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)), }); @@ -205,20 +207,18 @@ 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: 1 as const, address, identity_pub: await exportPublicKey(idKp.publicKey), + v: 1 as const, relay_origin: HOST, + address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { "content-type": "application/json", @@ -241,6 +241,7 @@ describe("key publication endpoints", () => { const pub = await exportPublicKey(encKp.publicKey); const encryption = { v: 1 as const, + relay_origin: HOST, address, key_id: await keyIdFor(pub), suite: HPKE_SUITE, @@ -276,12 +277,13 @@ 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: 1 as const, address, identity_pub: await exportPublicKey(idKp.publicKey), + v: 1 as const, relay_origin: HOST, + address, identity_pub: await exportPublicKey(idKp.publicKey), }; const headers = { "content-type": "application/json", @@ -304,6 +306,7 @@ describe("key publication endpoints", () => { const pub = await exportPublicKey(encKp.publicKey); const encryption = { v: 1 as const, + 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/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/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/cli/src/api.ts b/packages/cli/src/api.ts index 824853b6..540b86e0 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,10 +383,14 @@ 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({ - v: 1, address: `${auth.handle}@${host}`, identity_pub: keys.identity_pub, + // 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: 1, 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 // has no way to check an identity key against anything else, so possession of @@ -401,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); @@ -419,7 +423,8 @@ export async function publishEncryptionKey( const pub = keys.encryption_pub; const record: EncryptionKeyRecordType = EncryptionKeyRecord.parse({ v: 1, - 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, @@ -479,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..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
)") @@ -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..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(); @@ -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/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/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..8f38cbdf 100644 --- a/packages/cli/src/commands/peer.ts +++ b/packages/cli/src/commands/peer.ts @@ -9,21 +9,21 @@ 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(); 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..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(); @@ -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..cfe37d07 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({ @@ -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); @@ -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 84fd959f..fffb4097 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"; @@ -11,7 +11,12 @@ import type { MachinePaths } from "./paths.js"; export const MAX_KNOWN_PEERS = 10_000; const KnownPeerSchema = z.object({ - address: z.string().regex(/^[a-z0-9][a-z0-9-]{1,30}@[a-z0-9.-]{1,253}$/), + // 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(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(), @@ -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: 1 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/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/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/api.test.ts b/packages/cli/test/api.test.ts index 394c6380..470293ae 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 () => { @@ -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: 1, relay_origin: "relay.test", address, + identity_pub: keys.identity_pub, + }; const now = 1_754_000_000_000; const record: EncryptionKeyRecordType = { v: 1, + relay_origin: "relay.test", address, key_id: await keyIdFor(keys.encryption_pub), suite: HPKE_SUITE, @@ -506,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 @@ -538,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); @@ -574,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]); @@ -608,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 { @@ -631,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(); @@ -672,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"]); @@ -710,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); @@ -749,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"); @@ -767,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 }; @@ -784,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" } }, @@ -806,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" }); @@ -823,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 }))); @@ -839,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 9fe246e6..ee058cd4 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: 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, }; @@ -89,18 +90,22 @@ 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, ...overrides, keyDeps: { fetchKeys: async () => ({ - identity: { v: 1, address: toAddress, identity_pub: recipient.keys.identity_pub }, + identity: { + v: 1, relay_origin: origin, + address: toAddress, identity_pub: recipient.keys.identity_pub, + }, encryption: { record: recipientRecord, signature: "unused" }, }), verifyAndPinPeer: async () => ({ + 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 11e9e8e2..3c8a4e00 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), }; }); @@ -113,13 +112,14 @@ describe("trust CLI", () => { const testHome = home(); const machine = getMachinePaths(testHome, testHome); writeJsonAtomic(machine.knownPeersFile, { peers: [{ - address: "peer@relay.example", identity_pub: "abc", + relay_origin: "relay.example", + 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([]); }); @@ -130,10 +130,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: 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: 1 as const, address, identity_pub: identityPub }; + const identityRecord = { + v: 1 as const, relay_origin: "relay.test", + address, identity_pub: identityPub, + }; return { expected: await fingerprint(identityTranscript(identityRecord)), response: { @@ -146,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(); @@ -211,10 +215,14 @@ async function startCallRelay( ): Promise<{ relay: string; connections: () => number }> { 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 remoteAddress = "@acme/sota"; + const identity = { + v: 1 as const, relay_origin: relayOrigin, + address: remoteAddress, identity_pub: remote.identity_pub, + }; const record = { - v: 1 as const, address: remoteAddress, key_id: await keyIdFor(remote.encryption_pub), + 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, }; @@ -253,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, }, ); @@ -261,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, @@ -472,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..bea01068 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,25 +33,25 @@ 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", "not-an-address")).toThrow(/handle@host/); + 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(/@org\/handle/); 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 61f83a43..e8fd7ae4 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: 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: 1, address: "ken@relay.example", 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([ @@ -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: 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: 1, address: "ken@relay.example", 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 }); @@ -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: 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: 1, address: "ken@relay.example", identity_pub: local.identity_pub }, + identity: { v: 1, relay_origin: "relay.example", address: "@acme/ken", 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: 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: 1, address: "ken@relay.example", 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/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 4896c219..ddfae0fc 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,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: 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, }; return { identityKey: identity, value: { - identity: { v: 1 as const, address, identity_pub: identityPub }, + identity: { + v: 1 as const, relay_origin: "relay.test", + address, identity_pub: identityPub, + }, encryption: { record, signature: await signTranscript(identity.privateKey, encryptionKeyTranscript(record)) }, }, }; @@ -46,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); @@ -70,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([]); }); @@ -130,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) => ({ - 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, })) }); @@ -145,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 433f883e..e6b8a32f 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: 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: 1 as const, 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" }, }; } function fakePeer(address: string) { return { + 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, }; @@ -77,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), @@ -133,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)}` }); }); @@ -204,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), }, @@ -226,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), }, @@ -244,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 1397af00..0f0e069f 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), @@ -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: 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: 1 as const, 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" }, }; }, verifyAndPinPeer: async (_machine: MachinePaths, address: string) => ({ + 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/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..d0bbfa02 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,9 +8,11 @@ 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. -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. 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 // epoch's transcript. It gets its own pattern so that widening one can never @@ -19,6 +22,7 @@ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; export const IdentityRecord = z.object({ 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), }); @@ -26,6 +30,7 @@ export type IdentityRecordType = z.infer; export const EncryptionKeyRecord = z.object({ 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), suite: z.literal(HPKE_SUITE), @@ -55,13 +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/v1", r.v, r.address, r.identity_pub]); + return canonicalEncode([ + "agentcall/identity/v1", 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/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/src/protocol.ts b/packages/shared/src/protocol.ts index 0eaa6952..23b3f119 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -1,15 +1,37 @@ 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. +// +// 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. +// 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}$/; @@ -146,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}$/; @@ -208,14 +233,16 @@ export type RecoveryStatusResponseType = z.infer; export type RecoveryRedeemRequestType = z.infer; export type RecoveryReceiptType = z.infer; -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 }; +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 parseAddress(addr: string): { org: string; handle: string } | null { + const m = ADDRESS_RE.exec(addr); + return m ? { org: m[1]!, handle: m[2]! } : null; } // Peer-controlled free-form text has two display paths. Human-readable output 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 0f2821b0..bc058a10 100644 --- a/packages/shared/test/keys.test.ts +++ b/packages/shared/test/keys.test.ts @@ -6,13 +6,15 @@ import { const identity = { v: 1 as const, - address: "ken@agentcall.benree.tech", + relay_origin: "agentcall.benree.tech", + address: "@acme/ken", identity_pub: "BASE64URLPUBLICKEY", }; const encKey = { v: 1 as const, - address: "ken@agentcall.benree.tech", + relay_origin: "agentcall.benree.tech", + address: "@acme/ken", key_id: "0123456789abcdef0123456789abcdef", suite: HPKE_SUITE, pub: "BASE64URLENCRYPTIONKEY", @@ -90,10 +92,33 @@ 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)); }); + // 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) }); diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index 0109355c..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, - HANDLE_RE, MAX_MESSAGE_BYTES, parseAddress, 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", }; @@ -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(parseAddress("@acme/ken")).toEqual({ org: "acme", handle: "ken" }); }); + + it("round-trips through formatAddress", () => { + expect(parseAddress(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(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(parseAddress("ken")).toBeNull(); - expect(parseAddress("KEN@x.y")).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(parseAddress(" @acme/ken")).toBeNull(); + expect(parseAddress("@acme/ken ")).toBeNull(); + }); + + it("enforces the org length cap so the address stays short", () => { + 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", }; 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