Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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?"
```

Expand Down
12 changes: 6 additions & 6 deletions apps/relay/src/do.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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!;

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions apps/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 12 additions & 5 deletions apps/relay/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<RelayAppEnv>, 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<RelayAppEnv>): string {
return new URL(c.req.url).hostname;
}

function addressFor(_c: Context<RelayAppEnv>, org: string, handle: string): string {
return formatAddress(org, handle);
}

export function mountKeys(app: Hono<RelayAppEnv>): void {
Expand Down Expand Up @@ -178,10 +184,11 @@ export function mountKeys(app: Hono<RelayAppEnv>): 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,
Expand Down
21 changes: 7 additions & 14 deletions apps/relay/src/tenant.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
8 changes: 6 additions & 2 deletions apps/relay/test/a2a-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/relay/test/a2a-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@
const accepted = await SELF.fetch(taskUrl("accepted-callee", `tasks/${ringing.call_id}`), {
headers: wsAuth("accepted-caller", callerToken),
});
expect((await accepted.json<any>()).status.state).toBe("TASK_STATE_SUBMITTED");

Check failure on line 380 in apps/relay/test/a2a-task.test.ts

View workflow job for this annotation

GitHub Actions / windows-compat (24)

test/a2a-task.test.ts > A2A task store > keeps accepted tasks submitted and expires a completed short-lived record

TypeError: Cannot read properties of undefined (reading 'state') ❯ test/a2a-task.test.ts:380:48

listener.send(JSON.stringify(encryptedCallOutcome(
ringing.call_id, "accepted-callee", "accepted-caller",
Expand Down Expand Up @@ -442,7 +442,7 @@
`/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);

Expand Down
12 changes: 6 additions & 6 deletions apps/relay/test/callflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@
...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(
Expand Down Expand Up @@ -114,7 +114,7 @@
"/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" });
Expand All @@ -130,7 +130,7 @@
}
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 {
Expand All @@ -157,10 +157,10 @@
`/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");
Expand Down Expand Up @@ -318,7 +318,7 @@
expect(await nextFrame(overLimit)).toMatchObject({ type: "call_error", code: "rate_limited" });
});

it("rate limits one call past the hourly limit", async () => {

Check failure on line 321 in apps/relay/test/callflow.test.ts

View workflow job for this annotation

GitHub Actions / windows-compat (24)

test/callflow.test.ts > call flow > rate limits one call past the hourly limit

Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ test/callflow.test.ts:321:3
const { callerToken, listener } = await setupPair("rl-callee", "rl-caller");
for (let i = 0; i < RATE_LIMIT_PER_HOUR; i++) {
const c = await openWs("/v1/ws?role=call&to=rl-callee", wsAuth("rl-caller", callerToken));
Expand Down
12 changes: 6 additions & 6 deletions apps/relay/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,31 @@ export function wsAuth(handle: string, token: string, org = "acme"): Record<stri
return { Authorization: `Bearer ${token}`, "X-AgentCall-Org": org, "X-AgentCall-Handle": handle };
}

function envelope(direction: "request" | "response", from: string, to: string) {
function envelope(direction: "request" | "response", from: string, to: string, org = "acme") {
return {
v: 1 as const, direction, relay_origin: "relay.test",
from: `${from}@relay.test`, to: `${to}@relay.test`, key_id: "a".repeat(32),
from: `@${org}/${from}`, to: `@${org}/${to}`, key_id: "a".repeat(32),
epoch: 1, enc: "A", ct: "Q2lwaGVydGV4dA",
};
}

export function encryptedCallRequest(
from: string, to: string, metadata: { correlation_id?: string; traceparent?: string } = {},
from: string, to: string, metadata: { correlation_id?: string; traceparent?: string; org?: string } = {},
) {
return {
type: "call_request" as const,
envelope: envelope("request", from, to),
envelope: envelope("request", from, to, metadata.org),
correlation_id: metadata.correlation_id ?? "f".repeat(32),
...(metadata.traceparent ? { traceparent: metadata.traceparent } : {}),
};
}

export function encryptedCallOutcome(
callId: string, from: string, to: string, terminal: "completed" | "failed" = "completed",
callId: string, from: string, to: string, terminal: "completed" | "failed" = "completed", org = "acme",
) {
return {
type: "call_outcome" as const, call_id: callId, terminal,
envelope: envelope("response", from, to),
envelope: envelope("response", from, to, org),
};
}

Expand Down
33 changes: 18 additions & 15 deletions apps/relay/test/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ async function newIdentity(handle: string) {
const idKp = await generateIdentityKeyPair();
const record = {
v: 1 as const,
address: `${handle}@${HOST}`,
relay_origin: HOST,
address: `@acme/${handle}`,
identity_pub: await exportPublicKey(idKp.publicKey),
};
const signature = await signTranscript(idKp.privateKey, identityTranscript(record));
Expand Down Expand Up @@ -48,7 +49,8 @@ async function encRecord(who: Awaited<ReturnType<typeof newIdentity>>, 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,
Expand Down Expand Up @@ -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)),
});
Expand All @@ -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)),
});
Expand Down Expand Up @@ -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/<handle>`.
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",
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading