Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions sdk/typescript/src/cli/harness-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -540,6 +540,7 @@ class HarnessSessionTailer {
}

class SessionEnvelopePublisher {
private contactPromise: Promise<string> | undefined;
private contextPromise:
| ReturnType<typeof makeContext>
| undefined;
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset Signal state before retrying rejected first sends

When the relay returns not_a_contact for the first encrypted DM to a peer after ensureContact succeeds, the failed sendMessage has already run MessagesApi.send/SignalSession.encrypt, which stores and advances an outbound Signal session before the PUT response is known. Retrying here then sends a CIPHERTEXT instead of the required PREKEY_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 👍 / 👎.

}
}

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not cache a rejected pending-contact check

In a long-running harness where the first envelope creates an outgoing contact request and the operator accepts it before later semantic messages, this ??= keeps the rejected “contact request pending” promise in contactPromise. Every later publish reuses that same rejection and never re-reads /contacts/.../status, so DM forwarding stays disabled until the wrapper restarts even though the relationship is now accepted. Clear the cached promise on rejection or cache only successful resolutions.

Useful? React with 👍 / 👎.

return this.contactPromise;
Comment on lines +606 to +608

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid caching rejected contact checks.

contactPromise is assigned before ensureContactNow settles, so a transient status/request failure—or a pending-contact throw—gets reused by every later publish without rechecking after recovery or approval.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private ensureContact(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> {
this.contactPromise ??= this.ensureContactNow(ctx);
return this.contactPromise;
private ensureContact(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> {
this.contactPromise ??= this.ensureContactNow(ctx).catch((error: unknown) => {
this.contactPromise = undefined;
throw error;
});
return this.contactPromise;
🧰 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.
Context: import { spawn as spawnChild } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/typescript/src/cli/harness-wrapper.ts` around lines 606 - 608, Avoid
caching failed contact checks in ensureContact; contactPromise is currently set
from ensureContactNow(ctx) before it settles, so a rejection gets reused on
later publishes. Update ensureContact in harness-wrapper so only successful
contact results are cached, and clear/reset contactPromise when ensureContactNow
rejects or throws. Use ensureContact and ensureContactNow as the key locations
to keep the retry behavior working after recovery or approval.

}

private async ensureContactNow(ctx: Awaited<ReturnType<typeof makeContext>>): Promise<string> {
Comment on lines +606 to +611

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rename ctx in the new helpers.

ctx is not in the abbreviation exception list; use context for the new helper parameter. As per coding guidelines, “Avoid abbreviations … exceptions: db, arg, args, env, fn, prop, props, ref, refs.”

🧰 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.
Context: import { spawn as spawnChild } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/typescript/src/cli/harness-wrapper.ts` around lines 606 - 611, The new
helper parameter name uses an unsupported abbreviation. Update the
`ensureContact` and `ensureContactNow` method signatures in `harness-wrapper.ts`
to use `context` instead of `ctx`, and rename any matching references inside
those helpers so the naming follows the coding guidelines.

Source: 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 {
Expand Down
145 changes: 141 additions & 4 deletions sdk/typescript/tests/codex-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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>);
Expand All @@ -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");
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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 };
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use descriptive contact-key parameter names.

a and b violate the abbreviation rule.

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: db, arg, args, env, fn, prop, props, ref, refs.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/typescript/tests/codex-cli.test.ts` around lines 666 - 667, The helper
contactKey currently uses abbreviated parameter names a and b, which violates
the naming guideline. Rename the parameters in contactKey to descriptive names
that reflect the two contacts being compared, and update the function body to
use those new names consistently.

Source: Coding guidelines

}
Loading