diff --git a/sdk/typescript/src/cli/harness-wrapper.ts b/sdk/typescript/src/cli/harness-wrapper.ts index 599adc02..e2c53afe 100644 --- a/sdk/typescript/src/cli/harness-wrapper.ts +++ b/sdk/typescript/src/cli/harness-wrapper.ts @@ -1139,6 +1139,87 @@ export class HarnessSessionTailer { } } +/** + * The local co-location handshake file, `~/.openhuman/local-agents.json`. + * + * A wrapped agent writes its own messaging key + the OpenHuman owner it is + * connecting to here — on the same machine, moments before it sends its contact + * request. A tiny.place contact request carries NO owner declaration on the wire, + * so this same-user file is how a freshly-launched local CLI proves "I am a + * co-located agent that wants THIS OpenHuman as my owner": OpenHuman auto-accepts + * a pending request only when the requester id + declared owner match a fresh + * entry here. The path, entry shape, and TTL are mirrored on the OpenHuman side + * (`agent_orchestration::pairing`). Same-user filesystem access is the trust proof. + */ +const LOCAL_AGENTS_TTL_MS = 60 * 60 * 1000; + +export interface LocalAgentEntry { + agentId: string; + owner: string; + cwd?: string; + provider?: string; + ts: string; +} + +export interface LocalAgentsFile { + version: number; + agents: Array; +} + +function localAgentsDir(): string { + return join(homedir(), ".openhuman"); +} + +/** + * Insert `entry`, replacing this agent's own prior row and dropping any expired + * or undated foreign rows (so a since-departed agent can't linger). Pure — no IO, + * so the TTL/upsert contract is unit-testable and stays in lockstep with the + * OpenHuman-side reader. + */ +export function mergeLocalAgentEntry( + file: LocalAgentsFile, + entry: LocalAgentEntry, + now: number, +): LocalAgentsFile { + const kept = file.agents.filter( + (existing) => + existing.agentId !== entry.agentId && + typeof existing.ts === "string" && + now - Date.parse(existing.ts) < LOCAL_AGENTS_TTL_MS, + ); + return { version: 1, agents: [...kept, entry] }; +} + +function readLocalAgentsFile(path: string): LocalAgentsFile { + try { + const parsed = JSON.parse( + readFileSync(path, "utf8"), + ) as Partial; + return { version: 1, agents: Array.isArray(parsed.agents) ? parsed.agents : [] }; + } catch { + // Missing / unreadable / malformed → start fresh (best-effort registry). + return { version: 1, agents: [] }; + } +} + +/** + * Upsert this agent's co-location entry, pruning our own prior row and any + * expired/undated entries. Best-effort: every filesystem error is swallowed — a + * handshake-file failure must never break DM forwarding, since the contact + * request still fires and a human can accept it manually. + */ +function announceLocalAgent(entry: LocalAgentEntry, now: number): void { + try { + const dir = localAgentsDir(); + const path = join(dir, "local-agents.json"); + const next = mergeLocalAgentEntry(readLocalAgentsFile(path), entry, now); + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${JSON.stringify(next, undefined, 2)}\n`, "utf8"); + } catch { + // Ignore — co-location is an optimization; contact request still fires. + } +} + export class SessionEnvelopePublisher { private contactPromise: Promise | undefined; private contextPromise: ReturnType | undefined; @@ -1303,6 +1384,19 @@ export class SessionEnvelopePublisher { return recipient; } + // We're about to send a contact request that OpenHuman must accept before any + // DM lands. Drop a co-location entry naming this owner FIRST, so the same- + // machine OpenHuman can auto-accept the pending request without a manual click. + announceLocalAgent( + { + agentId: ctx.signer.publicKeyBase64, + owner: recipient, + cwd: process.cwd(), + provider: this.config.provider, + ts: new Date().toISOString(), + }, + Date.now(), + ); // Request first (idempotent; auto-accepts a reverse-pending request), then // read status. Never pre-check status: the backend 404s on a fresh // relationship, so a status-first probe would throw before we ever send the diff --git a/sdk/typescript/tests/local-agents-handshake.test.ts b/sdk/typescript/tests/local-agents-handshake.test.ts new file mode 100644 index 00000000..e5f3d874 --- /dev/null +++ b/sdk/typescript/tests/local-agents-handshake.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeLocalAgentEntry, + type LocalAgentEntry, + type LocalAgentsFile, +} from "../src/cli/harness-wrapper.js"; + +// The co-location handshake file a wrapped agent writes before it sends its +// contact request, so a same-machine OpenHuman can auto-accept without a manual +// click. `mergeLocalAgentEntry` is the pure TTL/upsert contract — it must stay in +// lockstep with the OpenHuman-side reader (1h TTL, own-row replace, prune stale). + +const NOW = Date.parse("2026-07-10T12:00:00.000Z"); + +function entry(over: Partial = {}): LocalAgentEntry { + return { + agentId: "self-key", + owner: "owner-key", + cwd: "/work/proj", + provider: "codex", + ts: "2026-07-10T12:00:00.000Z", + ...over, + }; +} + +describe("mergeLocalAgentEntry", () => { + it("adds an entry to an empty registry", () => { + const next = mergeLocalAgentEntry({ version: 1, agents: [] }, entry(), NOW); + expect(next.agents).toEqual([entry()]); + }); + + it("replaces this agent's own prior row instead of duplicating it", () => { + const file: LocalAgentsFile = { + version: 1, + agents: [entry({ owner: "stale-owner", ts: "2026-07-10T11:59:00.000Z" })], + }; + const next = mergeLocalAgentEntry(file, entry({ owner: "fresh-owner" }), NOW); + expect(next.agents).toHaveLength(1); + expect(next.agents[0].owner).toBe("fresh-owner"); + }); + + it("keeps other agents' fresh rows", () => { + const other = entry({ agentId: "peer-key", ts: "2026-07-10T11:30:00.000Z" }); + const next = mergeLocalAgentEntry({ version: 1, agents: [other] }, entry(), NOW); + expect(next.agents.map((a) => a.agentId).sort()).toEqual(["peer-key", "self-key"]); + }); + + it("prunes expired (past-TTL) and undated foreign rows", () => { + const file: LocalAgentsFile = { + version: 1, + agents: [ + entry({ agentId: "expired", ts: "2026-07-10T10:59:00.000Z" }), // 61 min → gone + entry({ agentId: "undated", ts: "not-a-timestamp" }), // unparseable → gone + entry({ agentId: "fresh", ts: "2026-07-10T11:15:00.000Z" }), // 45 min → kept + ], + }; + const next = mergeLocalAgentEntry(file, entry(), NOW); + expect(next.agents.map((a) => a.agentId).sort()).toEqual(["fresh", "self-key"]); + }); +}); diff --git a/website/app/a2a/[id]/skill.md/route.ts b/website/app/a2a/[id]/skill.md/route.ts index 2f49f00a..dd6e3c26 100644 --- a/website/app/a2a/[id]/skill.md/route.ts +++ b/website/app/a2a/[id]/skill.md/route.ts @@ -1,5 +1,5 @@ import { - agentDocResponse, + agentDocumentResponse, notFound, resolveA2aAgentCard, } from "@src/common/a2a-route"; @@ -18,5 +18,5 @@ export async function GET( ): Promise { const { id } = await params; const card = await resolveA2aAgentCard(id); - return card ? agentDocResponse(card, "skillMd") : notFound(); + return card ? agentDocumentResponse(card, "skillMd") : notFound(); } diff --git a/website/app/a2a/[id]/swagger.json/route.ts b/website/app/a2a/[id]/swagger.json/route.ts index 0fe14604..7105f4e4 100644 --- a/website/app/a2a/[id]/swagger.json/route.ts +++ b/website/app/a2a/[id]/swagger.json/route.ts @@ -1,5 +1,5 @@ import { - agentDocResponse, + agentDocumentResponse, notFound, resolveA2aAgentCard, } from "@src/common/a2a-route"; @@ -18,5 +18,5 @@ export async function GET( ): Promise { const { id } = await params; const card = await resolveA2aAgentCard(id); - return card ? agentDocResponse(card, "swaggerJson") : notFound(); + return card ? agentDocumentResponse(card, "swaggerJson") : notFound(); } diff --git a/website/app/a2a/[id]/swagger.md/route.ts b/website/app/a2a/[id]/swagger.md/route.ts index c14c982a..9a8ab1f0 100644 --- a/website/app/a2a/[id]/swagger.md/route.ts +++ b/website/app/a2a/[id]/swagger.md/route.ts @@ -1,5 +1,5 @@ import { - agentDocResponse, + agentDocumentResponse, notFound, resolveA2aAgentCard, } from "@src/common/a2a-route"; @@ -18,5 +18,5 @@ export async function GET( ): Promise { const { id } = await params; const card = await resolveA2aAgentCard(id); - return card ? agentDocResponse(card, "swaggerMd") : notFound(); + return card ? agentDocumentResponse(card, "swaggerMd") : notFound(); } diff --git a/website/src/common/a2a-route.test.ts b/website/src/common/a2a-route.test.ts index dacc4487..8648cb15 100644 --- a/website/src/common/a2a-route.test.ts +++ b/website/src/common/a2a-route.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { type A2aAgentCard, - agentDocResponse, - agentDocUrl, + agentDocumentResponse, + agentDocumentUrl, resolveA2aAgentCard, } from "./a2a-route"; @@ -18,17 +18,26 @@ const baseCard: A2aAgentCard = { describe("A2A route helpers", () => { it("resolves @handles through the directory before fetching the agent card", async () => { - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const fetcher = vi.fn((input: RequestInfo | URL): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; if (url.endsWith("/directory/resolve/%40naturedesk")) { - return Response.json({ - identity: { cryptoId: baseCard.agentId }, - }); + return Promise.resolve( + Response.json({ + identity: { cryptoId: baseCard.agentId }, + }) + ); } if (url.endsWith(`/directory/agents/${baseCard.agentId}`)) { - return Response.json(baseCard); + return Promise.resolve(Response.json(baseCard)); } - return Response.json({ error: "unexpected" }, { status: 500 }); + return Promise.resolve( + Response.json({ error: "unexpected" }, { status: 500 }) + ); }); await expect( @@ -42,42 +51,36 @@ describe("A2A route helpers", () => { }); it("derives docs from an external agent-card URL when no docs URL is advertised", () => { - expect(agentDocUrl(baseCard, "skillMd")).toBe( + expect(agentDocumentUrl(baseCard, "skillMd")).toBe( "https://naturedesk.github.io/site/a2a/@naturedesk/skill.md" ); }); - it("redirects to advertised skill markdown without proxying it", async () => { + it("redirects to advertised skill markdown without proxying it", () => { const card: A2aAgentCard = { ...baseCard, docs: { skillMdUrl: "https://docs.example.test/skill.md", }, }; - const fetcher = vi.fn(); - - const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch); + const response = agentDocumentResponse(card, "skillMd"); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe( "https://docs.example.test/skill.md" ); - expect(fetcher).not.toHaveBeenCalled(); }); - it("redirects to relative advertised docs URLs", async () => { + it("redirects to relative advertised docs URLs", () => { const card: A2aAgentCard = { ...baseCard, docs: { skillMdUrl: "/a2a/@naturedesk/skill.md", }, }; - const fetcher = vi.fn(); - - expect(agentDocUrl(card, "skillMd")).toBe("/a2a/@naturedesk/skill.md"); - const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch); + expect(agentDocumentUrl(card, "skillMd")).toBe("/a2a/@naturedesk/skill.md"); + const response = agentDocumentResponse(card, "skillMd"); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe("/a2a/@naturedesk/skill.md"); - expect(fetcher).not.toHaveBeenCalled(); }); it("serves inline skill markdown without fetching", async () => { @@ -87,10 +90,7 @@ describe("A2A route helpers", () => { skillMd: "# Inline skill\n", }, }; - const fetcher = vi.fn(); - - const response = await agentDocResponse(card, "skillMd", fetcher as typeof fetch); + const response = agentDocumentResponse(card, "skillMd"); await expect(response.text()).resolves.toBe("# Inline skill\n"); - expect(fetcher).not.toHaveBeenCalled(); }); }); diff --git a/website/src/common/a2a-route.ts b/website/src/common/a2a-route.ts index 11a60ca1..84b4019d 100644 --- a/website/src/common/a2a-route.ts +++ b/website/src/common/a2a-route.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-use-before-define -- small pure helpers are function declarations */ + type Fetcher = typeof fetch; export const A2A_API_BASE_URL = @@ -24,15 +26,15 @@ type ResolveResponse = { identity?: { cryptoId?: string | null } | null; }; -type DocKind = "skillMd" | "swaggerJson" | "swaggerMd"; +type DocumentKind = "skillMd" | "swaggerJson" | "swaggerMd"; -const DOC_FILENAMES: Record = { +const DOCUMENT_FILENAMES: Record = { skillMd: "skill.md", swaggerJson: "swagger.json", swaggerMd: "swagger.md", }; -const DOC_CONTENT_TYPES: Record = { +const DOCUMENT_CONTENT_TYPES: Record = { skillMd: "text/markdown; charset=utf-8", swaggerJson: "application/json; charset=utf-8", swaggerMd: "text/markdown; charset=utf-8", @@ -74,22 +76,21 @@ export function agentCardResponse(card: A2aAgentCard): Response { }); } -export async function agentDocResponse( +export function agentDocumentResponse( card: A2aAgentCard, - kind: DocKind, - _fetcher: Fetcher = fetch -): Promise { - const inline = inlineDoc(card, kind); + kind: DocumentKind +): Response { + const inline = inlineDocument(card, kind); if (inline !== undefined) { return new Response(inline, { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", - "Content-Type": DOC_CONTENT_TYPES[kind], + "Content-Type": DOCUMENT_CONTENT_TYPES[kind], }, }); } - const url = agentDocUrl(card, kind); + const url = agentDocumentUrl(card, kind); if (!url) { return notFound(); } @@ -103,21 +104,24 @@ export async function agentDocResponse( }); } -export function agentDocUrl(card: A2aAgentCard, kind: DocKind): string | null { - const docs = card.docs; +export function agentDocumentUrl( + card: A2aAgentCard, + kind: DocumentKind +): string | null { + const documentation = card.docs; const urlField = `${kind}Url` as keyof NonNullable; - const advertisedUrl = docs?.[urlField]; - if (typeof advertisedUrl === "string" && isDocUrl(advertisedUrl)) { + const advertisedUrl = documentation?.[urlField]; + if (typeof advertisedUrl === "string" && isDocumentUrl(advertisedUrl)) { return advertisedUrl; } - const advertisedValue = docs?.[kind]; - if (typeof advertisedValue === "string" && isDocUrl(advertisedValue)) { + const advertisedValue = documentation?.[kind]; + if (typeof advertisedValue === "string" && isDocumentUrl(advertisedValue)) { return advertisedValue; } if (card.url && isHttpUrl(card.url)) { - return new URL(DOC_FILENAMES[kind], card.url).toString(); + return new URL(DOCUMENT_FILENAMES[kind], card.url).toString(); } return null; @@ -130,9 +134,12 @@ export function notFound(): Response { }); } -function inlineDoc(card: A2aAgentCard, kind: DocKind): string | undefined { +function inlineDocument( + card: A2aAgentCard, + kind: DocumentKind +): string | undefined { const value = card.docs?.[kind]; - if (typeof value === "string" && !isDocUrl(value)) { + if (typeof value === "string" && !isDocumentUrl(value)) { return value; } return undefined; @@ -179,7 +186,7 @@ function isHttpUrl(value: string): boolean { } } -function isDocUrl(value: string): boolean { +function isDocumentUrl(value: string): boolean { return isHttpUrl(value) || isRelativePath(value); }