From 8df38fbdfcb31e3a8d615d74f29ba93dcd018c99 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 3 Aug 2026 19:33:50 -0700 Subject: [PATCH] refactor: remove pre-user compatibility paths --- apps/relay/src/do.ts | 28 +++++++------- apps/relay/src/roster.ts | 2 +- apps/relay/src/stored-card.ts | 6 +-- apps/relay/src/task-store.ts | 28 +++++--------- apps/relay/test/a2a-card.test.ts | 8 ++-- apps/relay/test/card.test.ts | 6 ++- apps/relay/test/helpers.ts | 7 +++- apps/relay/test/roster-bundle.test.ts | 1 + apps/relay/test/task-store.test.ts | 45 ++++++++++------------ docs/site/reference/protocol.mdx | 4 +- packages/cli/src/contacts.ts | 11 ++---- packages/cli/src/guard.ts | 15 +------- packages/cli/src/lineName.ts | 14 +------ packages/cli/src/lines.ts | 16 +------- packages/cli/src/listener.ts | 2 +- packages/cli/src/rosters.ts | 4 +- packages/cli/src/verify.ts | 3 +- packages/cli/test/api.test.ts | 2 + packages/cli/test/cli-actions.test.ts | 1 + packages/cli/test/contacts.test.ts | 4 +- packages/cli/test/guard.test.ts | 9 ++--- packages/cli/test/lines.test.ts | 31 ++++++--------- packages/cli/test/listener.test.ts | 2 +- packages/cli/test/rosters.test.ts | 9 +++++ packages/shared/src/card.ts | 20 ++++------ packages/shared/src/e2ee.ts | 4 +- packages/shared/src/invite.ts | 3 +- packages/shared/src/roster.ts | 15 ++++---- packages/shared/test/card.test.ts | 23 +++++------ packages/shared/test/e2ee.test.ts | 18 ++++++++- packages/shared/test/invite.test.ts | 4 ++ packages/shared/test/protocol.test.ts | 2 +- packages/shared/test/roster.test.ts | 5 +++ packages/shared/test/task-protocol.test.ts | 27 ++++++------- 34 files changed, 170 insertions(+), 209 deletions(-) diff --git a/apps/relay/src/do.ts b/apps/relay/src/do.ts index e6b53fa6..fb76c2e5 100644 --- a/apps/relay/src/do.ts +++ b/apps/relay/src/do.ts @@ -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; @@ -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, @@ -506,21 +509,21 @@ export class HandleDO extends DurableObject { caller: WebSocket | undefined, listener: ListenerAttachment, ): Promise { - 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(`call:${record.call_id}`, next); @@ -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 }); @@ -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, { @@ -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, @@ -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, diff --git a/apps/relay/src/roster.ts b/apps/relay/src/roster.ts index 2a642696..35861d05 100644 --- a/apps/relay/src/roster.ts +++ b/apps/relay/src/roster.ts @@ -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; } diff --git a/apps/relay/src/stored-card.ts b/apps/relay/src/stored-card.ts index 8f9f5d3f..52582aab 100644 --- a/apps/relay/src/stored-card.ts +++ b/apps/relay/src/stored-card.ts @@ -3,9 +3,9 @@ import { CardUpload } from "@benree/agentcall-shared"; type StoredCard = ReturnType; // 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)); diff --git a/apps/relay/src/task-store.ts b/apps/relay/src/task-store.ts index a4c433c3..9c6e318e 100644 --- a/apps/relay/src/task-store.ts +++ b/apps/relay/src/task-store.ts @@ -2,7 +2,6 @@ import { A2AListTasksResponse, A2ATask, A2ATaskState, - RELAY_CALL_TIMEOUT_MS, type A2AListTasksResponseType, type A2ATaskStateType, type A2ATaskType, @@ -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; }; @@ -49,8 +42,7 @@ 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 { @@ -58,11 +50,11 @@ export function taskIsTerminal(task: PersistedTask): boolean { } 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 { diff --git a/apps/relay/test/a2a-card.test.ts b/apps/relay/test/a2a-card.test.ts index d9ce86b4..c3105101 100644 --- a/apps/relay/test/a2a-card.test.ts +++ b/apps/relay/test/a2a-card.test.ts @@ -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, ) @@ -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(); @@ -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() }); diff --git a/apps/relay/test/card.test.ts b/apps/relay/test/card.test.ts index 59862e5c..99dc1328 100644 --- a/apps/relay/test/card.test.ts +++ b/apps/relay/test/card.test.ts @@ -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" }; diff --git a/apps/relay/test/helpers.ts b/apps/relay/test/helpers.ts index 0e405deb..00cd4500 100644 --- a/apps/relay/test/helpers.ts +++ b/apps/relay/test/helpers.ts @@ -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( diff --git a/apps/relay/test/roster-bundle.test.ts b/apps/relay/test/roster-bundle.test.ts index dfc87302..0004f1b0 100644 --- a/apps/relay/test/roster-bundle.test.ts +++ b/apps/relay/test/roster-bundle.test.ts @@ -5,6 +5,7 @@ import { registerHandle, wsAuth } from "./helpers.js"; const card = (tasks: unknown[], defaultOffer: string[], grants: Record = {}) => ({ 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 }); diff --git a/apps/relay/test/task-store.test.ts b/apps/relay/test/task-store.test.ts index 11680519..92c92d3d 100644 --- a/apps/relay/test/task-store.test.ts +++ b/apps/relay/test/task-store.test.ts @@ -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 => ({ + 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"]); diff --git a/docs/site/reference/protocol.mdx b/docs/site/reference/protocol.mdx index 922b7fb3..5e3ede37 100644 --- a/docs/site/reference/protocol.mdx +++ b/docs/site/reference/protocol.mdx @@ -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 @@ -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 | diff --git a/packages/cli/src/contacts.ts b/packages/cli/src/contacts.ts index c9daf1a8..5ae50e5f 100644 --- a/packages/cli/src/contacts.ts +++ b/packages/cli/src/contacts.ts @@ -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; export type ContactsFile = z.infer; diff --git a/packages/cli/src/guard.ts b/packages/cli/src/guard.ts index e658125b..a936f87d 100644 --- a/packages/cli/src/guard.ts +++ b/packages/cli/src/guard.ts @@ -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//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//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//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. diff --git a/packages/cli/src/lineName.ts b/packages/cli/src/lineName.ts index 6ae17c3b..8f1f2763 100644 --- a/packages/cli/src/lineName.ts +++ b/packages/cli/src/lineName.ts @@ -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// (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//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 @@ -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)) { diff --git a/packages/cli/src/lines.ts b/packages/cli/src/lines.ts index aa4c3234..9b4f7398 100644 --- a/packages/cli/src/lines.ts +++ b/packages/cli/src/lines.ts @@ -17,10 +17,6 @@ import { writeJsonAtomic } from "./json-store.js"; import { assertValidLineName, LINE_NAME_RE } from "./lineName.js"; export { assertValidLineName, LINE_NAME_RE }; -// Keep unknown top-level keys (`.loose()`) so an older CLI does not discard -// fields added by a newer release when a command loads, updates, and saves -// this credential store (main's #131, re-homed onto the per-line store). -// // `relay` is a REQUIRED non-empty string but deliberately NOT parsed as a URL // here, unlike main's flat ConfigSchema. Requiring it is what stops a silent // fall-through to the public default; validating its syntax at load would make @@ -35,7 +31,7 @@ export const LineConfigSchema = z.object({ relay: z.string().min(1), agent_kind: AgentKindSchema.optional(), workdir: z.string().optional(), -}).loose(); +}); export function loadLineConfig(l: LinePaths): LineConfig { if (!existsSync(l.configFile)) { @@ -58,16 +54,6 @@ export function loadLineConfig(l: LinePaths): LineConfig { } catch (e) { throw new Error(`Corrupt config.json for line "${l.name}" at ${l.configFile}: invalid JSON (${e instanceof Error ? e.message : String(e)}). Fix or remove this file, then re-run \`agentcall line add ${l.name} --invite \`.`); } - // Checked ahead of the schema so a line written before tenancy existed gets - // the actionable re-enroll instruction rather than a zod "required" error - // reported as generic corruption. `org` is not recoverable locally — only - // the relay can issue one against an invite. - if (raw !== null && typeof raw === "object" && !(raw as { org?: unknown }).org) { - throw new Error( - `Line "${l.name}" at ${l.configFile} has no organization. ` + - `Re-enroll it with \`agentcall line add ${l.name} --invite \`.`, - ); - } try { return LineConfigSchema.parse(raw); } catch (e) { diff --git a/packages/cli/src/listener.ts b/packages/cli/src/listener.ts index aad34fe7..a700b22d 100644 --- a/packages/cli/src/listener.ts +++ b/packages/cli/src/listener.ts @@ -222,7 +222,7 @@ export function startListener(deps: ListenerDeps): { stop(): Promise } { const { call_id, correlation_id, from, groups, } = frame; - const correlation = correlation_id ? { correlation_id } : {}; + const correlation = { correlation_id }; const started = Date.now(); const relayOrigin = relayAddressHost(deps.relay, config.org); const fromAddress = `${from}@${relayOrigin}`; diff --git a/packages/cli/src/rosters.ts b/packages/cli/src/rosters.ts index ce414648..640db570 100644 --- a/packages/cli/src/rosters.ts +++ b/packages/cli/src/rosters.ts @@ -21,9 +21,7 @@ const Membership = z.object({ relay: z.string().min(1), roster_id: z.string().regex(ROSTER_ID_RE), }); -// .loose() so unknown top-level keys survive a load+save round-trip under an -// older CLI, matching contacts.json. -const MembershipsFile = z.object({ rosters: z.array(Membership).default([]) }).loose(); +const MembershipsFile = z.object({ rosters: z.array(Membership).default([]) }); export type Membership = z.infer; const CachedBundle = z.object({ diff --git a/packages/cli/src/verify.ts b/packages/cli/src/verify.ts index 6b60a7d0..6698d1cd 100644 --- a/packages/cli/src/verify.ts +++ b/packages/cli/src/verify.ts @@ -534,8 +534,7 @@ const defaultGuardBinaryProbe: GuardBinaryProbeFn = async () => { }; // Per-line layout: the guard writes calls.log under -// /.agentcall/lines//calls.log, not the flat legacy -// /.agentcall/calls.log — `home` here is always the temp +// /.agentcall/lines//calls.log — `home` here is always the temp // AGENTCALL_HOME defaultGuardProbe redirected to, and GUARD_PROBE_LINE is the // line name it ran the probe under, so this must resolve the same path // getLinePaths would. diff --git a/packages/cli/test/api.test.ts b/packages/cli/test/api.test.ts index 3a2e21b5..538cc624 100644 --- a/packages/cli/test/api.test.ts +++ b/packages/cli/test/api.test.ts @@ -195,6 +195,7 @@ describe("api client", () => { const metadata = { id: "a".repeat(64), description: "vendor", created_by: "ken", created_at: 1, expires_at: 2, used_at: null, used_by: null, revoked_at: null, + role: "member" as const, }; const relay = await startServer((req, res, body) => { seen = { path: req.url, headers: req.headers, body }; @@ -215,6 +216,7 @@ describe("api client", () => { const metadata = { id: "b".repeat(64), description: "", created_by: "ken", created_at: 1, expires_at: 2, used_at: null, used_by: null, revoked_at: null, + role: "member" as const, }; let requests: string[] = []; const relay = await startServer((req, res) => { diff --git a/packages/cli/test/cli-actions.test.ts b/packages/cli/test/cli-actions.test.ts index 8fbde95f..1b4855f8 100644 --- a/packages/cli/test/cli-actions.test.ts +++ b/packages/cli/test/cli-actions.test.ts @@ -482,6 +482,7 @@ describe.sequential("CLI command actions", () => { const metadata = { id, description: "contractor", created_by: "ken", created_at: 1, expires_at: 2_000_000_000_000, used_at: null, used_by: null, revoked_at: null, + role: "admin" as const, }; const requests: Array<{ url: string; body: string }> = []; const relay = await startRelay((url, _method, body) => { diff --git a/packages/cli/test/contacts.test.ts b/packages/cli/test/contacts.test.ts index efc42338..a8a08d5e 100644 --- a/packages/cli/test/contacts.test.ts +++ b/packages/cli/test/contacts.test.ts @@ -69,7 +69,7 @@ describe("contacts store", () => { expect(() => removeContact(p, "ken")).toThrow(/No contact named "ken"/); }); - it("preserves unknown top-level keys across a load + save round-trip", () => { + it("writes only fields owned by the current contacts schema", () => { const p = getMachinePaths(tempHome()); mkdirSync(p.dir, { recursive: true }); writeFileSync( @@ -82,7 +82,7 @@ describe("contacts store", () => { loadContacts(p); addContact(p, "amy", "amy@agentcall.benree.tech"); const raw = JSON.parse(readFileSync(p.contactsFile, "utf8")); - expect(raw.future_field).toBe("x"); + expect(raw).not.toHaveProperty("future_field"); }); }); diff --git a/packages/cli/test/guard.test.ts b/packages/cli/test/guard.test.ts index 4cf20040..d7502f24 100644 --- a/packages/cli/test/guard.test.ts +++ b/packages/cli/test/guard.test.ts @@ -666,15 +666,12 @@ describe("runGuard — enumerates every line's tasksDir from the real home, not expect(decision.hookSpecificOutput.permissionDecision).toBe("deny"); }); - // Legacy flat layout, pre-dating per-line: still real and writable on - // every already-set-up machine until Task 12, so it must stay denied - // independently of the per-line enumeration above. - it("denies the legacy flat AgentCall/tasks directory under the real home", () => { + it("does not treat the obsolete flat task path as a policy directory", () => { const { stateRoot, userHome } = splitHomes(); const legacyTask = join(userHome, "AgentCall", "tasks", "ask", "SKILL.md"); const out = runGuard(payload("Write", { file_path: legacyTask }), actingDeps(stateRoot, userHome)); - const decision = JSON.parse(out.stdout); - expect(decision.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(out.stdout).toBe(""); + expect(out.exitCode).toBe(0); }); it("still allows writing to the acting line's own share directory", () => { diff --git a/packages/cli/test/lines.test.ts b/packages/cli/test/lines.test.ts index c4b6afed..6ce8f2d3 100644 --- a/packages/cli/test/lines.test.ts +++ b/packages/cli/test/lines.test.ts @@ -26,15 +26,13 @@ describe("assertValidLineName", () => { }, ); - // "tasks" and "public" are otherwise well-formed names, but a line's - // authored content lives at ~/AgentCall//{tasks,public} and the - // guard denies the legacy ~/AgentCall/tasks path wholesale — a line named - // "tasks" would nest its own tasks dir inside a denied root and fail - // every call silently. "doctor-probe" is reserved for an unrelated - // reason — it's verify.ts's GUARD_PROBE_LINE, the synthetic line name - // every doctor/setup verification spawn runs under. See the comment on - // RESERVED_LINE_NAMES in lineName.ts. - it.each(["tasks", "public", "doctor-probe"])("rejects the reserved name %j", (name) => { + it.each(["tasks", "public"])("accepts the current-layout line name %j", (name) => { + expect(() => assertValidLineName(name)).not.toThrow(); + }); + + // verify.ts uses this synthetic line name for doctor/setup probes. + it("rejects the reserved doctor probe name", () => { + const name = "doctor-probe"; expect(() => assertValidLineName(name)).toThrow(/reserved/i); }); }); @@ -76,16 +74,11 @@ describe("saveLineConfig / loadLineConfig", () => { expect(loadLineConfig(l)).toEqual(cfg); }); - // Re-homed from main's config.test.ts, where it covered loadConfig. `org` - // moved onto the LINE with the rest of the tenant identity, so this is now - // loadLineConfig's job. The message must stay distinct from the generic - // "corrupt config.json" one: `org` cannot be recovered locally, so the only - // useful instruction is to re-enroll against an invite. - it("rejects a line config without an organization, pointing at re-enrollment", () => { + it("treats a line config missing a current required field as corrupt", () => { const l = getLinePaths(m, "preorg"); mkdirSync(l.dir, { recursive: true }); writeFileSync(l.configFile, JSON.stringify({ handle: "ken", token: "old", relay: "https://relay.example" })); - expect(() => loadLineConfig(l)).toThrow(/no organization.*line add.*--invite/i); + expect(() => loadLineConfig(l)).toThrow(/corrupt config\.json.*org.*line add.*--invite/i); }); it("rejects a malformed organization slug", () => { @@ -133,14 +126,12 @@ describe("saveLineConfig / loadLineConfig", () => { expect(loadLineConfig(l).relay).toBe("not a url"); }); - // An older CLI reading, updating, and saving a config written by a newer - // release must not silently drop the fields it doesn't know about. - it("preserves unknown fields across a load and save", () => { + it("writes only fields owned by the current line schema", () => { const l = getLinePaths(m, "future"); mkdirSync(l.dir, { recursive: true }); writeFileSync(l.configFile, JSON.stringify({ ...cfg, future_option: true })); saveLineConfig(l, loadLineConfig(l)); - expect(JSON.parse(readFileSync(l.configFile, "utf8"))).toMatchObject({ future_option: true }); + expect(JSON.parse(readFileSync(l.configFile, "utf8"))).not.toHaveProperty("future_option"); }); }); diff --git a/packages/cli/test/listener.test.ts b/packages/cli/test/listener.test.ts index b5abb9a9..d3259856 100644 --- a/packages/cli/test/listener.test.ts +++ b/packages/cli/test/listener.test.ts @@ -126,7 +126,7 @@ async function sendIncoming( const wire = { type: "incoming_call", call_id: frame.call_id, from: frame.from, groups: frame.groups ?? [], envelope, - ...(frame.correlation_id ? { correlation_id: frame.correlation_id } : {}), + correlation_id: frame.correlation_id ?? "f".repeat(32), ...(frame.traceparent ? { traceparent: frame.traceparent } : {}), }; ws.send(JSON.stringify(wire)); diff --git a/packages/cli/test/rosters.test.ts b/packages/cli/test/rosters.test.ts index 6b076f96..1197f837 100644 --- a/packages/cli/test/rosters.test.ts +++ b/packages/cli/test/rosters.test.ts @@ -52,6 +52,15 @@ describe("memberships (user data)", () => { expect(statSync(p.rostersFile).mode & 0o777).toBe(0o600); }); + it("writes only fields owned by the current membership schema", () => { + const p = paths(); + saveMembership(p, { name: "first", relay: "https://r.test", roster_id: "b".repeat(22) }); + const existing = JSON.parse(readFileSync(p.rostersFile, "utf8")); + writeFileSync(p.rostersFile, JSON.stringify({ ...existing, future_field: "x" })); + saveMembership(p, { name: "acme", relay: "https://r.test", roster_id: "a".repeat(22) }); + expect(JSON.parse(readFileSync(p.rostersFile, "utf8"))).not.toHaveProperty("future_field"); + }); + // Mirrors addContact's own NAME_RE check in contacts.ts: UX and // consistency (typo-catching, unambiguous CLI arguments), not a security // boundary — readCached's relay/caller check is what actually gates access. diff --git a/packages/shared/src/card.ts b/packages/shared/src/card.ts index 4df07ec0..12ee105c 100644 --- a/packages/shared/src/card.ts +++ b/packages/shared/src/card.ts @@ -7,38 +7,34 @@ export const MAX_CARD_BLOCKED_CALLERS = 200; export const MAX_TASK_KEYWORDS = 20; export const MAX_KEYWORD_LENGTH = 40; -// A `tier` field ("T1" | "T2") used to ride along here, reserving T2 for -// approval-gated tasks. No code ever branched on it and the approval gate is -// not being built, so it's gone. Zod strips unknown keys, so cards already -// stored on the relay with a tier still parse. export const CardTask = z.object({ id: z.string().regex(TASK_ID_RE), name: z.string().min(1).max(100), description: z.string().min(1).max(1000), - examples: z.array(z.string().max(500)).max(10).default([]), + examples: z.array(z.string().max(500)).max(10), // Bounded per-string like every neighbouring field. Unbounded keyword // strings amplify: 20 per task x 50 tasks x 200 roster members, re-sent on // every bundle refresh. These are the highest-weighted field in // `agentcall search`, so they are the callee's precision lever. - keywords: z.array(z.string().min(1).max(MAX_KEYWORD_LENGTH)).max(MAX_TASK_KEYWORDS).default([]), -}); + keywords: z.array(z.string().min(1).max(MAX_KEYWORD_LENGTH)).max(MAX_TASK_KEYWORDS), +}).strict(); // What a callee pushes to the relay: full task list + visibility policy. export const CardUpload = z.object({ - description: z.string().max(500).default(""), + description: z.string().max(500), agent_kind: AgentKindSchema, tasks: z.array(CardTask).max(MAX_CARD_TASKS), default_offer: z.array(z.string().regex(TASK_ID_RE)).max(MAX_CARD_TASKS), - grants: z.record(z.string().regex(HANDLE_RE), z.array(z.string().regex(TASK_ID_RE)).max(MAX_CARD_TASKS)).default({}), + grants: z.record(z.string().regex(HANDLE_RE), z.array(z.string().regex(TASK_ID_RE)).max(MAX_CARD_TASKS)), group_grants: z.record( // Same opaque relay-issued id shape as ROSTER_ID_RE. Kept inline to avoid // a card -> roster -> card runtime import cycle. z.string().regex(/^[A-Za-z0-9_-]{16,64}$/), z.array(z.string().regex(TASK_ID_RE)).max(MAX_CARD_TASKS), ).refine((groups) => Object.keys(groups).length <= MAX_CARD_GROUPS, { message: `at most ${MAX_CARD_GROUPS} group grants`, - }).default({}), - blocked: z.array(z.string().regex(HANDLE_RE)).max(MAX_CARD_BLOCKED_CALLERS).default([]), -}); + }), + blocked: z.array(z.string().regex(HANDLE_RE)).max(MAX_CARD_BLOCKED_CALLERS), +}).strict(); // What a caller gets back from GET /v1/card/:handle — already filtered to // the tasks visible to that caller (public view or authenticated extended view). diff --git a/packages/shared/src/e2ee.ts b/packages/shared/src/e2ee.ts index cd11c1b2..5cdf2528 100644 --- a/packages/shared/src/e2ee.ts +++ b/packages/shared/src/e2ee.ts @@ -121,7 +121,7 @@ const ResponseEnvelope = HpkeEnvelope.refine((value) => value.direction === "res export const EncryptedCallRequest = z.preprocess(normalizeTraceContext, z.object({ type: z.literal("call_request"), envelope: RequestEnvelope, - correlation_id: CorrelationId.optional(), + correlation_id: CorrelationId, traceparent: z.string().optional(), }).strict()); @@ -130,7 +130,7 @@ export const EncryptedIncomingCall = z.preprocess(normalizeTraceContext, z.objec call_id: z.string(), from: z.string().regex(HANDLE_RE), envelope: RequestEnvelope, - correlation_id: CorrelationId.optional(), + correlation_id: CorrelationId, traceparent: z.string().optional(), groups: z.array(z.string().regex(/^[A-Za-z0-9_-]{16,64}$/)).max(MAX_CALLER_GROUPS).default([]), }).strict()); diff --git a/packages/shared/src/invite.ts b/packages/shared/src/invite.ts index 46b27325..2e217cc9 100644 --- a/packages/shared/src/invite.ts +++ b/packages/shared/src/invite.ts @@ -29,8 +29,7 @@ export const OrgInviteMetadata = z.object({ used_at: z.number().int().nonnegative().nullable(), used_by: z.string().regex(HANDLE_RE).nullable(), revoked_at: z.number().int().nonnegative().nullable(), - // Optional only for compatibility with relay responses from before org roles. - role: OrgRole.optional(), + role: OrgRole, }); export const CreateOrgInviteResponse = z.object({ diff --git a/packages/shared/src/roster.ts b/packages/shared/src/roster.ts index 63fbe2f3..b0568f82 100644 --- a/packages/shared/src/roster.ts +++ b/packages/shared/src/roster.ts @@ -91,8 +91,8 @@ export const BundleTask = z.object({ id: z.string().regex(TASK_ID_RE), name: z.string().max(100), description: z.string().max(1000), - keywords: z.array(z.string().max(MAX_KEYWORD_LENGTH)).max(MAX_TASK_KEYWORDS).default([]), -}); + keywords: z.array(z.string().max(MAX_KEYWORD_LENGTH)).max(MAX_TASK_KEYWORDS), +}).strict(); export const BundleEntry = z.object({ handle: z.string().regex(HANDLE_RE), @@ -101,16 +101,15 @@ export const BundleEntry = z.object({ updated_at: z.number(), // True when the member had more tasks than MAX_BUNDLE_TASKS_PER_CARD. The // bundle never truncates silently: search surfaces this to the user. - truncated: z.boolean().default(false), -}); + truncated: z.boolean(), +}).strict(); export const RosterBundle = z.object({ roster_id: z.string().regex(ROSTER_ID_RE), entries: z.array(BundleEntry).max(MAX_ROSTER_MEMBERS), - // Count of member cards that failed to parse and were skipped. One bad - // legacy card must never 500 the bundle for the other 199 members. - skipped: z.number().int().nonnegative().default(0), -}); + // Count of member cards that failed to parse and were skipped. + skipped: z.number().int().nonnegative(), +}).strict(); export type BundleTaskType = z.infer; export type BundleEntryType = z.infer; diff --git a/packages/shared/test/card.test.ts b/packages/shared/test/card.test.ts index d5c4d7e3..6fb6c5e2 100644 --- a/packages/shared/test/card.test.ts +++ b/packages/shared/test/card.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from "vitest"; import { CardTask, CardUpload, visibleTasks } from "../src/card.js"; -const TASK = { id: "ask", name: "Ask", description: "Answer questions.", examples: [] }; +const TASK = { id: "ask", name: "Ask", description: "Answer questions.", examples: [], keywords: [] }; describe("CardTask.keywords", () => { - it("defaults to [] for a card stored before the field existed", () => { - // This is the back-compat mechanism: .default([]) supplies the missing - // field. (Zod's unknown-key stripping is a different property — it is what - // let `tier` be removed — and is NOT what makes additions safe.) - expect(CardTask.parse(TASK).keywords).toEqual([]); + it("requires the current keyword field", () => { + const { keywords: _, ...withoutKeywords } = TASK; + expect(CardTask.safeParse(withoutKeywords).success).toBe(false); }); it("round-trips supplied keywords", () => { @@ -29,20 +27,19 @@ describe("CardTask.keywords", () => { expect(CardTask.safeParse({ ...TASK, keywords: many }).success).toBe(false); }); - it("keeps parsing a whole CardUpload stored before the field existed", () => { - const upload = CardUpload.parse({ + it("requires the complete current upload shape", () => { + expect(CardUpload.safeParse({ description: "d", agent_kind: "claude", tasks: [TASK], default_offer: ["ask"], - }); - expect(upload.tasks[0]!.keywords).toEqual([]); + }).success).toBe(false); }); }); const UPLOAD = CardUpload.parse({ description: "d", agent_kind: "claude", tasks: [ - { id: "ask", name: "Ask", description: "Answer questions.", examples: [] }, - { id: "adr", name: "ADR", description: "Why.", examples: [] }, - { id: "payroll", name: "Payroll", description: "Secret.", examples: [] }, + { id: "ask", name: "Ask", description: "Answer questions.", examples: [], keywords: [] }, + { id: "adr", name: "ADR", description: "Why.", examples: [], keywords: [] }, + { id: "payroll", name: "Payroll", description: "Secret.", examples: [], keywords: [] }, ], default_offer: ["ask"], // mia's grants are deliberately out of card order (payroll before adr): diff --git a/packages/shared/test/e2ee.test.ts b/packages/shared/test/e2ee.test.ts index c1653952..da3b3ba8 100644 --- a/packages/shared/test/e2ee.test.ts +++ b/packages/shared/test/e2ee.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - E2EECallerFrame, E2EEListenerToRelayFrame, E2EERelayToCallerFrame, + E2EECallerFrame, E2EEListenerToRelayFrame, E2EERelayToCallerFrame, E2EERelayToListenerFrame, E2EERequestPayload, E2EEResponsePayload, HpkeEnvelope, MAX_E2EE_CIPHERTEXT_BYTES, hpkeEnvelopeAad, requestTranscript, responseTranscript, transcriptHash, type E2EERequestPayloadType, type E2EEResponsePayloadType, @@ -23,7 +23,9 @@ describe("E2EE envelope schemas and transcripts", () => { from: request.from, to: request.to, key_id: request.recipient_encryption_key_id, epoch: request.recipient_epoch, enc: "A", ct: "B", }; - expect(E2EECallerFrame.safeParse({ type: "call_request", envelope: requestEnvelope }).success).toBe(true); + expect(E2EECallerFrame.safeParse({ + type: "call_request", envelope: requestEnvelope, correlation_id: "4".repeat(32), + }).success).toBe(true); for (const field of ["message", "task", "context_id"] as const) { expect(E2EECallerFrame.safeParse({ type: "call_request", envelope: requestEnvelope, [field]: "plaintext", @@ -41,6 +43,18 @@ describe("E2EE envelope schemas and transcripts", () => { } }); + it("requires correlation metadata on both sides of call admission", () => { + const requestEnvelope = { + v: 1 as const, direction: "request" as const, relay_origin: request.relay_origin, + from: request.from, to: request.to, key_id: request.recipient_encryption_key_id, + epoch: request.recipient_epoch, enc: "A", ct: "B", + }; + expect(E2EECallerFrame.safeParse({ type: "call_request", envelope: requestEnvelope }).success).toBe(false); + expect(E2EERelayToListenerFrame.safeParse({ + type: "incoming_call", call_id: "c1", from: "alice", envelope: requestEnvelope, groups: [], + }).success).toBe(false); + }); + it("separates unauthenticated relay errors from encrypted peer outcomes", () => { expect(E2EERelayToCallerFrame.safeParse({ type: "call_error", origin: "relay", code: "offline", diff --git a/packages/shared/test/invite.test.ts b/packages/shared/test/invite.test.ts index 5dde2f3d..95bc0008 100644 --- a/packages/shared/test/invite.test.ts +++ b/packages/shared/test/invite.test.ts @@ -7,6 +7,7 @@ import { const metadata = { id: "a".repeat(64), description: "contractor onboarding", created_by: "ken", created_at: 1, expires_at: 2, used_at: null, used_by: null, revoked_at: null, + role: "member" as const, }; describe("organization invite protocol", () => { @@ -27,5 +28,8 @@ describe("organization invite protocol", () => { expect(ListOrgInvitesResponse.safeParse({ invites: Array.from({ length: MAX_LISTED_ORG_INVITES + 1 }, () => metadata), }).success).toBe(false); + const { role: _role, ...missingRole } = metadata; + expect(CreateOrgInviteResponse.safeParse({ invite: "i".repeat(43), metadata: missingRole }).success) + .toBe(false); }); }); diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index 5fc0eaae..e638540e 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -91,7 +91,7 @@ describe("parseAddress", () => { describe("frames", () => { it("round-trips an encrypted call_request", () => { - const f = { type: "call_request", envelope: requestEnvelope }; + const f = { type: "call_request", envelope: requestEnvelope, correlation_id: "a".repeat(32) }; expect(safeParseFrame(E2EECallerFrame, JSON.stringify(f))).toEqual(f); }); it("rejects unknown type via safeParseFrame", () => { diff --git a/packages/shared/test/roster.test.ts b/packages/shared/test/roster.test.ts index 2f16a2f9..2f44223c 100644 --- a/packages/shared/test/roster.test.ts +++ b/packages/shared/test/roster.test.ts @@ -58,6 +58,11 @@ describe("RosterBundle", () => { const b = RosterBundle.parse({ roster_id: "a".repeat(22), entries: [ENTRY], skipped: 0 }); expect(b.entries[0]!.tasks[0]!.keywords).toEqual(["auth"]); }); + it("requires current projection metadata", () => { + expect(RosterBundle.safeParse({ roster_id: "a".repeat(22), entries: [] }).success).toBe(false); + const { truncated: _, ...withoutTruncated } = ENTRY; + expect(BundleEntry.safeParse(withoutTruncated).success).toBe(false); + }); it("rejects an entry with more than MAX_BUNDLE_TASKS_PER_CARD tasks", () => { const tasks = Array.from({ length: MAX_BUNDLE_TASKS_PER_CARD + 1 }, (_, i) => ({ id: `t${i}`, name: "N", description: "D", keywords: [], diff --git a/packages/shared/test/task-protocol.test.ts b/packages/shared/test/task-protocol.test.ts index e8f654ce..185ee340 100644 --- a/packages/shared/test/task-protocol.test.ts +++ b/packages/shared/test/task-protocol.test.ts @@ -69,32 +69,27 @@ describe("TASK_ID_RE", () => { }); describe("card schemas", () => { - const task = { id: "ask", name: "Ask", description: "Answer questions." }; - it("round-trips a CardUpload and applies defaults", () => { - const parsed = CardUpload.parse({ agent_kind: "claude", tasks: [task], default_offer: ["ask"] }); - expect(parsed.description).toBe(""); - expect(parsed.grants).toEqual({}); - expect(parsed.tasks[0]).toMatchObject({ id: "ask", examples: [] }); + const task = { id: "ask", name: "Ask", description: "Answer questions.", examples: [], keywords: [] }; + const upload = { + description: "", agent_kind: "claude" as const, tasks: [task], default_offer: ["ask"], + grants: {}, group_grants: {}, blocked: [], + }; + it("round-trips a current CardUpload", () => { + expect(CardUpload.parse(upload)).toEqual(upload); }); it("rejects a grant keyed by an invalid handle", () => { const bad = CardUpload.safeParse({ - agent_kind: "claude", tasks: [task], default_offer: ["ask"], grants: { "Bad Handle": ["ask"] }, + ...upload, grants: { "Bad Handle": ["ask"] }, }); expect(bad.success).toBe(false); }); - // `tier` was removed; cards already stored on the relay still carry it, so - // parsing must strip it rather than reject the whole card. - it("strips a legacy tier field instead of rejecting the card", () => { - const parsed = CardUpload.parse({ - agent_kind: "claude", tasks: [{ ...task, tier: "T2" }], default_offer: ["ask"], - }); - expect(parsed.tasks[0]).not.toHaveProperty("tier"); - expect(parsed.tasks[0].id).toBe("ask"); + it("rejects the removed tier field", () => { + expect(CardUpload.safeParse({ ...upload, tasks: [{ ...task, tier: "T2" }] }).success).toBe(false); }); it("round-trips an AgentCard (the relay's GET response shape)", () => { const card = AgentCard.parse({ handle: "ken", description: "", agent_kind: "claude", - tasks: [{ ...task, examples: [] }], updated_at: 1752600000000, + tasks: [task], updated_at: 1752600000000, }); expect(card.tasks).toHaveLength(1); });