-
Notifications
You must be signed in to change notification settings - Fork 21
[codex] Add harness contact handshake #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string> | undefined; | ||||||||||||||||||||
| private contextPromise: | ||||||||||||||||||||
| | ReturnType<typeof makeContext> | ||||||||||||||||||||
| | 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<typeof makeContext> { | ||||||||||||||||||||
| this.contextPromise ??= makeContext(this.options); | ||||||||||||||||||||
| return this.contextPromise; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private ensureContact(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> { | ||||||||||||||||||||
| this.contactPromise ??= this.ensureContactNow(ctx); | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a long-running harness where the first envelope creates an outgoing contact request and the operator accepts it before later semantic messages, this Useful? React with 👍 / 👎. |
||||||||||||||||||||
| return this.contactPromise; | ||||||||||||||||||||
|
Comment on lines
+606
to
+608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Avoid caching rejected contact checks.
Proposed fix private ensureContact(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> {
- this.contactPromise ??= this.ensureContactNow(ctx);
+ this.contactPromise ??= this.ensureContactNow(ctx).catch((error: unknown) => {
+ this.contactPromise = undefined;
+ throw error;
+ });
return this.contactPromise;
}📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.44.0)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private async ensureContactNow(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> { | ||||||||||||||||||||
|
Comment on lines
+606
to
+611
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Rename
🧰 Tools🪛 ast-grep (0.44.0)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||
| 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 { | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string, unknown>); | ||||||||||||
|
|
@@ -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<string> = []; | ||||||||||||
| 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,19 +556,34 @@ async function makeClient( | |||||||||||
| return { client, signer }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function makeRelay(): typeof globalThis.fetch { | ||||||||||||
| interface HarnessRelay { | ||||||||||||
| ( | ||||||||||||
| input: Parameters<typeof globalThis.fetch>[0], | ||||||||||||
| init?: Parameters<typeof globalThis.fetch>[1], | ||||||||||||
| ): Promise<Response>; | ||||||||||||
| contactRequests: Array<string>; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function makeRelay(options: { autoAcceptContacts?: boolean } = {}): HarnessRelay { | ||||||||||||
| const inbox = new Map<string, Array<MessageEnvelope>>(); | ||||||||||||
| const signedPreKeys = new Map<string, SignedKey>(); | ||||||||||||
| const preKeys = new Map<string, Array<SignedKey>>(); | ||||||||||||
| const contacts = new Map<string, "pending" | "accepted" | "blocked">(); | ||||||||||||
|
|
||||||||||||
| return async (input, init): Promise<Response> => { | ||||||||||||
| const relay = (async (input, init): Promise<Response> => { | ||||||||||||
| const request = new Request(input, init); | ||||||||||||
| const url = new URL(request.url); | ||||||||||||
| const path = url.pathname; | ||||||||||||
| const method = request.method; | ||||||||||||
|
|
||||||||||||
| 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"); | ||||||||||||
|
Comment on lines
+666
to
+667
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Use descriptive contact-key parameter names.
Proposed fix-function contactKey(a: string, b: string): string {
- return [a, b].sort().join("\0");
+function contactKey(firstParticipant: string, secondParticipant: string): string {
+ return [firstParticipant, secondParticipant].sort().join("\0");
}As per coding guidelines, “Avoid abbreviations … exceptions: 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the relay returns
not_a_contactfor the first encrypted DM to a peer afterensureContactsucceeds, the failedsendMessagehas already runMessagesApi.send/SignalSession.encrypt, which stores and advances an outbound Signal session before the PUT response is known. Retrying here then sends aCIPHERTEXTinstead of the requiredPREKEY_BUNDLE; a recipient that never received the rejected pre-key message has no session and will drop the retried envelope. Reset the peer Signal session or avoid encrypting until authorization is confirmed before retrying.Useful? React with 👍 / 👎.