From cb3a7bc001ca6e43f14d185467bb38c8e88ccef3 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 10 Jul 2026 14:22:39 +0530 Subject: [PATCH] feat(cli): announce co-located agent to OpenHuman before contact request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrapped agent's first DM to its OpenHuman owner is gated behind a contact request the owner must accept; a fresh CLI identity therefore sits pending until a human clicks accept, and its session stream is dropped meanwhile. A tiny.place contact request carries no owner declaration on the wire, so the owner cannot recognise a co-located CLI from the request alone. Before sending the request, write { agentId, owner, cwd, provider, ts } to ~/.openhuman/local-agents.json. A same-machine OpenHuman reads this file and auto-accepts a pending request whose id + owner match a fresh entry, so a local agent connects with no manual step. The write is best-effort (any fs error is swallowed — the request still fires), and mergeLocalAgentEntry replaces our own prior row while pruning expired (>1h) and undated entries, mirroring the OpenHuman-side TTL. --- sdk/typescript/src/cli/harness-wrapper.ts | 94 +++++++++++++++++++ .../tests/local-agents-handshake.test.ts | 61 ++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 sdk/typescript/tests/local-agents-handshake.test.ts diff --git a/sdk/typescript/src/cli/harness-wrapper.ts b/sdk/typescript/src/cli/harness-wrapper.ts index 14f21825..f57d8ddd 100644 --- a/sdk/typescript/src/cli/harness-wrapper.ts +++ b/sdk/typescript/src/cli/harness-wrapper.ts @@ -539,6 +539,87 @@ 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. + } +} + class SessionEnvelopePublisher { private contactPromise: Promise | undefined; private contextPromise: @@ -625,6 +706,19 @@ class SessionEnvelopePublisher { throw new Error(`tiny.place contact blocked for ${recipient}; unblock before DM forwarding`); } + // 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(), + ); await ctx.client.contacts.request(recipient); const after = await ctx.client.contacts.status(recipient); if (after.status === "accepted") { 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"]); + }); +});