From 99e7ac69289adb2376c15d33bcb903a742ff9c33 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 1 Jul 2026 21:19:12 -0700 Subject: [PATCH] Add harness contact handshake --- sdk/typescript/src/cli/harness-wrapper.ts | 63 ++++++++-- sdk/typescript/tests/codex-cli.test.ts | 145 +++++++++++++++++++++- 2 files changed, 197 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/src/cli/harness-wrapper.ts b/sdk/typescript/src/cli/harness-wrapper.ts index c63d1c46..14f21825 100644 --- a/sdk/typescript/src/cli/harness-wrapper.ts +++ b/sdk/typescript/src/cli/harness-wrapper.ts @@ -11,7 +11,7 @@ import { import { homedir, platform } from "node:os"; import { basename, join, resolve } from "node:path"; -import { sendMessage } from "../agent/messaging.js"; +import { resolveRecipientKey, sendMessage } from "../agent/messaging.js"; import { SESSION_ENVELOPE_VERSION_V1, type HarnessBucketUnit, @@ -540,6 +540,7 @@ class HarnessSessionTailer { } class SessionEnvelopePublisher { + private contactPromise: Promise | undefined; private contextPromise: | ReturnType | undefined; @@ -584,18 +585,66 @@ class SessionEnvelopePublisher { if (!ctx.signer) { throw new Error("DM forwarding requires a tiny.place signer"); } - await sendMessage( - ctx.client, - ctx.signer, - this.config.dmRecipient ?? "", - JSON.stringify(envelope), - ); + const recipient = await this.ensureContact(ctx); + try { + await sendMessage(ctx.client, ctx.signer, recipient, JSON.stringify(envelope)); + } catch (error) { + if (!isNotAContactError(error)) { + throw error; + } + this.contactPromise = undefined; + const refreshedRecipient = await this.ensureContact(ctx); + await sendMessage(ctx.client, ctx.signer, refreshedRecipient, JSON.stringify(envelope)); + } } private context(): ReturnType { this.contextPromise ??= makeContext(this.options); return this.contextPromise; } + + private ensureContact(ctx: Awaited>): Promise { + this.contactPromise ??= this.ensureContactNow(ctx); + return this.contactPromise; + } + + private async ensureContactNow(ctx: Awaited>): Promise { + if (!ctx.signer) { + throw new Error("DM forwarding requires a tiny.place signer"); + } + const recipient = await resolveRecipientKey(ctx.client, this.config.dmRecipient ?? ""); + if (recipient === ctx.signer.publicKeyBase64) { + return recipient; + } + + const before = await ctx.client.contacts.status(recipient); + if (before.status === "accepted") { + return recipient; + } + if (before.status === "blocked") { + throw new Error(`tiny.place contact blocked for ${recipient}; unblock before DM forwarding`); + } + + await ctx.client.contacts.request(recipient); + const after = await ctx.client.contacts.status(recipient); + if (after.status === "accepted") { + return recipient; + } + if (after.status === "blocked") { + throw new Error(`tiny.place contact blocked for ${recipient}; unblock before DM forwarding`); + } + throw new Error( + `tiny.place contact request pending for ${recipient}; approve it in OpenHuman before DM forwarding`, + ); + } +} + +function isNotAContactError(error: unknown): boolean { + const body = typeof error === "object" && error !== null ? (error as { body?: unknown }).body : undefined; + const bodyText = + typeof body === "string" ? body : body !== undefined ? JSON.stringify(body) : ""; + const message = error instanceof Error ? error.message : String(error); + return /not[_ ]a[_ ]contact/i.test(`${message} ${bodyText}`); } class TerminalEnvelopeWriter { diff --git a/sdk/typescript/tests/codex-cli.test.ts b/sdk/typescript/tests/codex-cli.test.ts index e4048923..41c38096 100644 --- a/sdk/typescript/tests/codex-cli.test.ts +++ b/sdk/typescript/tests/codex-cli.test.ts @@ -238,7 +238,7 @@ describe("tinyplace codex", () => { "30", "rollout-2026-06-30T10-00-00-019f1111-2222-7333-8444-666666666666.jsonl", ); - const relay = makeRelay(); + const relay = makeRelay({ autoAcceptContacts: true }); const recipient = await makeClient(44, relay); await publishKeys(recipient.client, recipient.signer); @@ -316,6 +316,7 @@ describe("tinyplace codex", () => { ); expect(result.code).toBe(0); + expect(relay.contactRequests).toEqual([recipient.signer.publicKeyBase64]); const messages = await readMessages(recipient.client, recipient.signer); expect(messages).toHaveLength(2); const envelopes = messages.map((message) => JSON.parse(message.text) as Record); @@ -329,6 +330,93 @@ describe("tinyplace codex", () => { }); }); + it("requests contact approval and withholds DMs while the relationship is pending", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "tinyplace-codex-")); + const sessionsDir = join(tempDir, "sessions"); + const sessionFile = join( + sessionsDir, + "2026", + "06", + "30", + "rollout-2026-06-30T10-00-00-019f1111-2222-7333-8444-777777777777.jsonl", + ); + const relay = makeRelay(); + const recipient = await makeClient(44, relay); + await publishKeys(recipient.client, recipient.signer); + const stderr = new PassThrough(); + const stderrChunks: Array = []; + stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString("utf8"))); + + const result = await runTinyPlaceCli( + [ + "codex", + "--tinyplace-no-pty", + "--tinyplace-out", + tempDir, + "--tinyplace-session-id", + "session-test", + "--tinyplace-session-poll-ms", + "5", + "--tinyplace-session-tail-grace-ms", + "20", + "prompt", + ], + { + cwd: "/tmp/project", + env: { + TINYPLACE_CODEX_BIN: "fake-codex", + TINYPLACE_CODEX_SESSIONS_DIR: sessionsDir, + TINYPLACE_CONFIG: join(tempDir, "config.json"), + TINYPLACE_ENDPOINT: "https://relay.test", + TINYPLACE_HARNESS_DM_TO: recipient.signer.publicKeyBase64, + TINYPLACE_SECRET_KEY: hexSeed(33), + }, + fetch: relay, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr, + spawn: () => { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 1234; + queueMicrotask(() => { + mkdirSync(join(sessionsDir, "2026", "06", "30"), { recursive: true }); + writeFileSync( + sessionFile, + [ + JSON.stringify({ + timestamp: "2026-06-30T10:00:00.000Z", + type: "session_meta", + payload: { + cwd: "/tmp/project", + id: "019f1111-2222-7333-8444-777777777777", + }, + }), + JSON.stringify({ + timestamp: "2026-06-30T10:01:00.000Z", + type: "event_msg", + payload: { message: "real user prompt", type: "user_message" }, + }), + ].join("\n") + "\n", + "utf8", + ); + setTimeout(() => { + child.emit("exit", 0, null); + }, 20); + }); + return child; + }, + }, + ); + + expect(result.code).toBe(1); + expect(relay.contactRequests).toEqual([recipient.signer.publicKeyBase64]); + expect(stderrChunks.join("")).toContain("contact request pending"); + await expect(readMessages(recipient.client, recipient.signer)).resolves.toHaveLength(0); + }); + it("mirrors the wrapper for Claude Code session JSONL", async () => { const tempDir = await mkdtemp(join(tmpdir(), "tinyplace-claude-")); const sessionsDir = join(tempDir, "claude-projects"); @@ -468,12 +556,21 @@ async function makeClient( return { client, signer }; } -function makeRelay(): typeof globalThis.fetch { +interface HarnessRelay { + ( + input: Parameters[0], + init?: Parameters[1], + ): Promise; + contactRequests: Array; +} + +function makeRelay(options: { autoAcceptContacts?: boolean } = {}): HarnessRelay { const inbox = new Map>(); const signedPreKeys = new Map(); const preKeys = new Map>(); + const contacts = new Map(); - return async (input, init): Promise => { + const relay = (async (input, init): Promise => { const request = new Request(input, init); const url = new URL(request.url); const path = url.pathname; @@ -481,6 +578,12 @@ function makeRelay(): typeof globalThis.fetch { if (path === "/messages" && method === "PUT") { const envelope = (await request.json()) as MessageEnvelope; + if ( + envelope.from !== envelope.to && + contacts.get(contactKey(envelope.from, envelope.to)) !== "accepted" + ) { + return Response.json({ error: "not_a_contact" }, { status: 403 }); + } inbox.set(envelope.to, [...(inbox.get(envelope.to) ?? []), envelope]); return Response.json(envelope, { status: 202 }); } @@ -497,6 +600,30 @@ function makeRelay(): typeof globalThis.fetch { ); return new Response(null, { status: 204 }); } + const contactStatusMatch = path.match(/^\/contacts\/([^/]+)\/status$/); + if (contactStatusMatch && method === "GET") { + const agentId = decodeURIComponent(contactStatusMatch[1]!); + return Response.json({ + agentId, + status: contacts.get(contactKey(actorId(request), agentId)) ?? "none", + }); + } + const contactRequestMatch = path.match(/^\/contacts\/([^/]+)$/); + if (contactRequestMatch && method === "POST") { + const agentId = decodeURIComponent(contactRequestMatch[1]!); + const requester = actorId(request); + const status = options.autoAcceptContacts ? "accepted" : "pending"; + const now = "2026-06-16T00:00:00.000Z"; + relay.contactRequests.push(agentId); + contacts.set(contactKey(requester, agentId), status); + return Response.json({ + requester, + addressee: agentId, + status, + createdAt: now, + updatedAt: now, + }); + } const signedMatch = path.match(/^\/keys\/([^/]+)\/signed-prekey$/); if (signedMatch && method === "PUT") { const body = (await request.json()) as { signedPreKey: SignedKey }; @@ -527,5 +654,15 @@ function makeRelay(): typeof globalThis.fetch { }); } return Response.json({ error: `unhandled ${method} ${path}` }, { status: 500 }); - }; + }) as HarnessRelay; + relay.contactRequests = []; + return relay; +} + +function actorId(request: Request): string { + return request.headers.get("X-TinyPlace-Public-Key") ?? request.headers.get("X-Agent-ID") ?? ""; +} + +function contactKey(a: string, b: string): string { + return [a, b].sort().join("\0"); }