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
28 changes: 14 additions & 14 deletions apps/relay/src/do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import { MAX_RETAINED_ORG_AUDIT_EVENTS } from "./events.js";
type CallerAttachment = {
kind: "caller";
from: string;
org?: string;
to?: string;
org: string;
to: string;
actorIp?: string;
actorCountry?: string;
groups: string[];
relayOrigin?: string;
relayOrigin: string;
call_id?: string;
correlation_id?: string;
timeoutMs?: number;
Expand Down Expand Up @@ -306,6 +306,9 @@ export class HandleDO extends DurableObject {
credentialGeneration,
} satisfies ListenerAttachment);
} else {
if (!org || !target || !relayOrigin) {
return new Response("missing verified caller metadata", { status: 400 });
}
this.ctx.acceptWebSocket(server, ["caller"]);
server.serializeAttachment({
kind: "caller", from, org, to: target, actorIp, actorCountry,
Expand Down Expand Up @@ -506,21 +509,21 @@ export class HandleDO extends DurableObject {
caller: WebSocket | undefined,
listener: ListenerAttachment,
): Promise<void> {
const current = record.state ?? "ringing";
const current = record.state;
if (STATUS_RANK[state] <= STATUS_RANK[current]) return;
// Persist before fan-out so a DO restart or duplicate/out-of-order frame
// cannot move the caller backward after it has observed a later state.
const next = {
...record,
state,
task_state: state === "working" ? "TASK_STATE_WORKING" : (record.task_state ?? "TASK_STATE_SUBMITTED"),
task_state: state === "working" ? "TASK_STATE_WORKING" : record.task_state,
updated_at: Date.now(),
} satisfies PersistedTask;
// Starting work from ringing is an implicit acceptance, so it must not
// leave a lifecycle gap. A later
// explicit acceptance is rank-rejected and cannot duplicate the event.
const accepted = state === "answered" || (state === "working" && current === "ringing")
? callAuditIntent("call.accept", next, record.to ?? listener.handle ?? "", "handle", listener, next.updated_at!)
? callAuditIntent("call.accept", next, record.to, "handle", listener, next.updated_at)
: undefined;
if (accepted) await this.persistTaskWithAudit(next, accepted);
else await this.ctx.storage.put<PersistedTask>(`call:${record.call_id}`, next);
Expand Down Expand Up @@ -563,10 +566,7 @@ export class HandleDO extends DurableObject {
frame.envelope.from !== `${att.from}@${att.relayOrigin}` ||
frame.envelope.to !== `${att.to}@${att.relayOrigin}`
) return this.fail(ws, "protocol_error");
// New callers always mint this. During mixed-version overlap, minting at
// the first new relay preserves delivery and gives the new listener a
// bounded application join key even when the old caller omitted it.
const correlation_id = frame.correlation_id ?? crypto.randomUUID().replaceAll("-", "");
const correlation_id = frame.correlation_id!;

const listener = this.ctx.getWebSockets("listener")[0];
if (!listener) return this.fail(ws, "offline", true, { correlation_id });
Expand Down Expand Up @@ -619,7 +619,7 @@ export class HandleDO extends DurableObject {
// terminal response, a concurrent GetTask must never move backward.
await this.persistTaskWithAudit(
canceled,
callAuditIntent("call.cancel", canceled, record.to ?? att.handle ?? "", "handle", att, canceled.updated_at!),
callAuditIntent("call.cancel", canceled, record.to, "handle", att, canceled.updated_at),
);
if (caller) {
this.fail(caller, "canceled", true, {
Expand All @@ -641,7 +641,7 @@ export class HandleDO extends DurableObject {
const failed = updateTask(record, { task_state: "TASK_STATE_FAILED" });
await this.persistTaskWithAudit(
failed,
callAuditIntent("call.fail", failed, record.to ?? att.handle ?? "", "handle", att, failed.updated_at!),
callAuditIntent("call.fail", failed, record.to, "handle", att, failed.updated_at),
);
if (caller) this.fail(caller, "protocol_error", true, {
call_id: frame.call_id, correlation_id: record.correlation_id,
Expand All @@ -664,8 +664,8 @@ export class HandleDO extends DurableObject {
outcome_envelope: frame.envelope,
});
const intent = frame.terminal === "completed"
? callAuditIntent("call.complete", finished, record.to ?? att.handle, "handle", att, finished.updated_at!)
: callAuditIntent("call.fail", finished, record.to ?? att.handle, "handle", att, finished.updated_at!);
? callAuditIntent("call.complete", finished, record.to, "handle", att, finished.updated_at)
: callAuditIntent("call.fail", finished, record.to, "handle", att, finished.updated_at);
await this.persistTaskWithAudit(
finished,
intent,
Expand Down
2 changes: 1 addition & 1 deletion apps/relay/src/roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ export function mountRoster(app: Hono<{ Bindings: Env }>): void {
for (const row of results ?? []) {
const upload = parseStoredCard(row.card_json, org, row.handle);
if (!upload) {
// One bad legacy card must not 500 the bundle for everyone else.
// One invalid stored card must not 500 the bundle for everyone else.
skipped++;
continue;
}
Expand Down
6 changes: 3 additions & 3 deletions apps/relay/src/stored-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { CardUpload } from "@benree/agentcall-shared";
type StoredCard = ReturnType<typeof CardUpload.parse>;

// Stored cards were valid when written, but a later schema tightening can
// make a legacy row unreadable. Treat that row as unavailable at every read
// boundary and log only identifying metadata — never the card or validation
// message, both of which can contain user-authored content.
// make a stale or corrupt row unreadable. Treat that row as unavailable at
// every read boundary and log only identifying metadata — never the card or
// validation message, both of which can contain user-authored content.
export function parseStoredCard(cardJson: string, org: string, handle: string): StoredCard | null {
try {
return CardUpload.parse(JSON.parse(cardJson));
Expand Down
28 changes: 10 additions & 18 deletions apps/relay/src/task-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
A2AListTasksResponse,
A2ATask,
A2ATaskState,
RELAY_CALL_TIMEOUT_MS,
type A2AListTasksResponseType,
type A2ATaskStateType,
type A2ATaskType,
Expand All @@ -12,21 +11,15 @@ import {

export type PersistedTask = {
call_id: string;
// Optional for in-flight records written by a pre-correlation deployment.
correlation_id?: string;
correlation_id: string;
from: string;
// Tenant and callee address are audit-routing metadata. Optional only for
// in-flight records created before central call evidence shipped.
org?: string;
to?: string;
org: string;
to: string;
deadline: number;
// Optional only for in-flight records written by a pre-#89 deployment.
state?: CallStatusType["state"];
// Everything below is optional for a mixed-version record created before
// the A2A task store shipped. Projection supplies safe compatibility values.
task_state?: A2ATaskStateType;
created_at?: number;
updated_at?: number;
state: CallStatusType["state"];
task_state: A2ATaskStateType;
created_at: number;
updated_at: number;
outcome_envelope?: HpkeEnvelopeType;
};

Expand All @@ -49,20 +42,19 @@ export function taskBelongsToCaller(task: PersistedTask, caller: string): boolea
}

export function taskState(task: PersistedTask): A2ATaskStateType {
if (task.task_state) return task.task_state;
return task.state === "working" ? "TASK_STATE_WORKING" : "TASK_STATE_SUBMITTED";
return task.task_state;
}

export function taskIsTerminal(task: PersistedTask): boolean {
return TERMINAL_TASK_STATES.has(taskState(task));
}

export function taskCreatedAt(task: PersistedTask): number {
return task.created_at ?? task.deadline - RELAY_CALL_TIMEOUT_MS;
return task.created_at;
}

export function taskUpdatedAt(task: PersistedTask): number {
return task.updated_at ?? taskCreatedAt(task);
return task.updated_at;
}

export function toA2ATask(task: PersistedTask): A2ATaskType {
Expand Down
8 changes: 5 additions & 3 deletions apps/relay/test/a2a-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ async function seedCard(handle: string) {
JSON.stringify({
description: "Ken's agent",
agent_kind: "claude",
tasks: [{ id: "ask", name: "Ask", description: "Answer a question.", examples: [] }],
tasks: [{ id: "ask", name: "Ask", description: "Answer a question.", examples: [], keywords: [] }],
default_offer: ["ask"],
grants: { someoneelse: ["secret-task"] },
group_grants: {},
blocked: [],
}),
1,
)
Expand Down Expand Up @@ -114,7 +116,7 @@ describe("GET /v1/a2a/:handle/agent-card.json", () => {
await env.DB.prepare("INSERT OR REPLACE INTO cards (org, handle, card_json, updated_at) VALUES (?, ?, ?, ?)")
.bind("acme", "a2a-group", JSON.stringify({
description: "grouped", agent_kind: "claude",
tasks: [{ id: "eng", name: "Eng", description: "Engineering", examples: [] }],
tasks: [{ id: "eng", name: "Eng", description: "Engineering", examples: [], keywords: [] }],
default_offer: [], grants: {}, group_grants: { [created.roster_id]: ["eng"] }, blocked: [],
}), 2).run();

Expand All @@ -127,7 +129,7 @@ describe("GET /v1/a2a/:handle/agent-card.json", () => {
await env.DB.prepare("INSERT INTO cards (org, handle, card_json, updated_at) VALUES ('acme', ?, ?, 3)")
.bind("a2a-blocked", JSON.stringify({
description: "blocked", agent_kind: "claude",
tasks: [{ id: "ask", name: "Ask", description: "Ask", examples: [] }],
tasks: [{ id: "ask", name: "Ask", description: "Ask", examples: [], keywords: [] }],
default_offer: ["ask"], grants: {}, group_grants: {}, blocked: ["viewer"],
})).run();
const blocked = await SELF.fetch(`${ORIGIN}/v1/a2a/a2a-blocked/agent-card.json`, { headers: viewerHeaders() });
Expand Down
6 changes: 4 additions & 2 deletions apps/relay/test/card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ const UPLOAD = {
description: "Ken's public agent",
agent_kind: "claude",
tasks: [
{ id: "ask", name: "Ask", description: "Answer questions.", examples: [] },
{ id: "schedule-meeting", name: "Schedule", description: "Book a time.", examples: [] },
{ id: "ask", name: "Ask", description: "Answer questions.", examples: [], keywords: [] },
{ id: "schedule-meeting", name: "Schedule", description: "Book a time.", examples: [], keywords: [] },
],
default_offer: ["ask"],
grants: { mia: ["schedule-meeting"] },
group_grants: {},
blocked: [],
};
const ORG_HEADERS = { "X-AgentCall-Org": "acme" };

Expand Down
7 changes: 6 additions & 1 deletion apps/relay/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ function envelope(direction: "request" | "response", from: string, to: string) {
export function encryptedCallRequest(
from: string, to: string, metadata: { correlation_id?: string; traceparent?: string } = {},
) {
return { type: "call_request" as const, envelope: envelope("request", from, to), ...metadata };
return {
type: "call_request" as const,
envelope: envelope("request", from, to),
correlation_id: metadata.correlation_id ?? "f".repeat(32),
...(metadata.traceparent ? { traceparent: metadata.traceparent } : {}),
};
}

export function encryptedCallOutcome(
Expand Down
1 change: 1 addition & 0 deletions apps/relay/test/roster-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { registerHandle, wsAuth } from "./helpers.js";

const card = (tasks: unknown[], defaultOffer: string[], grants: Record<string, string[]> = {}) => ({
description: "d", agent_kind: "claude", tasks, default_offer: defaultOffer, grants,
group_grants: {}, blocked: [],
});
const task = (id: string, keywords: string[] = []) =>
({ id, name: id.toUpperCase(), description: `About ${id}.`, examples: [], keywords });
Expand Down
45 changes: 21 additions & 24 deletions apps/relay/test/task-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,39 @@
import { describe, expect, it } from "vitest";
import { RELAY_CALL_TIMEOUT_MS } from "@benree/agentcall-shared";
import {
listCallerTasks, taskState, taskUpdatedAt, updateTask, type TaskListQuery,
toA2ATask, type PersistedTask,
} from "../src/task-store.js";

describe("task-store mixed-version projection", () => {
it("projects an old ringing record without requiring a migration", () => {
const deadline = Date.UTC(2026, 7, 3, 12);
const oldRecord: PersistedTask = {
call_id: "old-call", from: "caller", deadline, state: "ringing",
};
// @ts-expect-error Current task records require correlation, audit-routing,
// lifecycle, and timestamp fields at creation.
const incompleteTask: PersistedTask = {
call_id: "old-call", from: "caller", deadline: 1_000,
};
void incompleteTask;

expect(taskState(oldRecord)).toBe("TASK_STATE_SUBMITTED");
expect(taskUpdatedAt(oldRecord)).toBe(deadline - RELAY_CALL_TIMEOUT_MS);
expect(toA2ATask(oldRecord)).toEqual({
id: "old-call",
const task = (overrides: Partial<PersistedTask> = {}): PersistedTask => ({
call_id: "task", correlation_id: "a".repeat(32), from: "caller", org: "acme", to: "callee",
deadline: 1_000, state: "ringing", task_state: "TASK_STATE_SUBMITTED",
created_at: 100, updated_at: 100, ...overrides,
});

describe("task-store projection", () => {
it("projects a current submitted record", () => {
expect(taskState(task())).toBe("TASK_STATE_SUBMITTED");
expect(taskUpdatedAt(task())).toBe(100);
expect(toA2ATask(task())).toEqual({
id: "task",
status: {
state: "TASK_STATE_SUBMITTED",
timestamp: new Date(deadline - RELAY_CALL_TIMEOUT_MS).toISOString(),
timestamp: new Date(100).toISOString(),
},
});
});

it("projects an old working record as working", () => {
expect(taskState({
call_id: "old-working", from: "caller", deadline: Date.now(), state: "working",
})).toBe("TASK_STATE_WORKING");
});

it("does not skip an unseen task that transitions between pages", async () => {
const query: TaskListQuery = { pageSize: 1, includeArtifacts: false };
const first: PersistedTask = {
call_id: "first", from: "caller", deadline: 1_000, created_at: 300, updated_at: 300,
};
const unseen: PersistedTask = {
call_id: "unseen", from: "caller", deadline: 1_000, created_at: 200, updated_at: 200,
};
const first = task({ call_id: "first", created_at: 300, updated_at: 300 });
const unseen = task({ call_id: "unseen", created_at: 200, updated_at: 200 });

const page1 = await listCallerTasks([first, unseen], "caller", query, "cursor-key", "org:callee");
expect(page1?.tasks.map((task) => task.id)).toEqual(["first"]);
Expand Down
4 changes: 2 additions & 2 deletions docs/site/reference/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Send an encrypted call payload.
| --- | --- | --- |
| `type` | `call_request` | yes |
| `envelope` | object | yes |
| `correlation_id` | string | no |
| `correlation_id` | string | yes |
| `traceparent` | string | no |

## Relay to caller
Expand Down Expand Up @@ -66,7 +66,7 @@ Deliver an encrypted call with relay-attested routing metadata.
| `call_id` | string | yes |
| `from` | string | yes |
| `envelope` | object | yes |
| `correlation_id` | string | no |
| `correlation_id` | string | yes |
| `traceparent` | string | no |
| `groups` | string[] | no |

Expand Down
11 changes: 3 additions & 8 deletions packages/cli/src/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@ const ContactSchema = z.object({
address: z.string(),
note: z.string().optional(),
});
// .loose() (zod 4's passthrough mode) preserves unknown top-level keys across
// a load+save round-trip, so future fields survive being written back by a
// version of the CLI that doesn't know about them yet.
const ContactsFileSchema = z
.object({
contacts: z.array(ContactSchema).default([]),
})
.loose();
const ContactsFileSchema = z.object({
contacts: z.array(ContactSchema).default([]),
});
export type Contact = z.infer<typeof ContactSchema>;
export type ContactsFile = z.infer<typeof ContactsFileSchema>;

Expand Down
15 changes: 1 addition & 14 deletions packages/cli/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,9 @@ const DENIED_DIRS = [
".codex", // auth.json, plus a config.toml that routinely holds API keys
"Library/LaunchAgents", // how the listener itself gets launched
".config/systemd/user", // Linux user units can replace the listener command
// Legacy flat layout. As of Task 12, nothing in this codebase reads or
// writes this path anymore — card.ts, index.ts, and lint.ts all moved to
// the per-line AgentCall/<line>/tasks layout, and setup.ts no longer
// creates it. It stays denied because it may still exist on disk, holding
// real SKILL.md files from an install made before Task 12: a stale entry
// over-denies (fails safe), while removing it would leave genuine content
// from a previous install unprotected. The per-line entries below cover
// AgentCall/<line>/tasks; this covers the pre-multi-line AgentCall/tasks
// that may still be sitting there regardless. This is also the reason
// "tasks" and "public" are reserved line names — see RESERVED_LINE_NAMES in
// lineName.ts for the other half of this.
"AgentCall/tasks",
// AgentCall/<line>/tasks, one directory per line, has no single
// home-relative entry that can name them all — see runGuard, which
// enumerates every line's tasksDir and passes it in as an extra denied
// root, alongside this legacy path.
// enumerates every line's tasksDir and passes it in as an extra denied root.
];

// Home-relative single files.
Expand Down
14 changes: 1 addition & 13 deletions packages/cli/src/lineName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,6 @@
// nothing duplicates it.
export const LINE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;

// A line's authored content lives at ~/AgentCall/<line>/ (see getLinePaths in
// paths.ts). The guard denies the legacy path ~/AgentCall/tasks wholesale, so
// a line named "tasks" would put its own share directory
// (~/AgentCall/tasks/public) *inside* a denied root: `line add tasks` would
// succeed, and every answered call on that line would then fail at its first
// tool use with the generic denial (which deliberately reveals no path or
// rule name) — silent and very hard to diagnose. See guard.ts's DENIED_DIRS
// entry for the other half of this. "public" is reserved for the symmetric
// reason (~/AgentCall/<line>/public colliding with a line literally named
// "public"). Do not remove these without re-checking that the guard's path
// denial no longer applies.
//
// "doctor-probe" is reserved for an unrelated reason: it is
// verify.ts's GUARD_PROBE_LINE, the synthetic line name every verification
// spawn in that file runs under (checkAgentSpawn, and the two checkGuard
Expand All @@ -34,7 +22,7 @@ export const LINE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
// enumerate a stray directory by this name rather than filtering it out, so
// that if a redirect ever does regress, the orphan stays visible instead of
// disappearing.
const RESERVED_LINE_NAMES = new Set(["tasks", "public", "doctor-probe"]);
const RESERVED_LINE_NAMES = new Set(["doctor-probe"]);

export function assertValidLineName(name: string): void {
if (!LINE_NAME_RE.test(name)) {
Expand Down
Loading