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
94 changes: 94 additions & 0 deletions sdk/typescript/src/cli/harness-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalAgentEntry>;
}

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<LocalAgentsFile>;
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");
Comment on lines +1216 to +1217

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 Create the handshake file with private modes

Because OpenHuman treats write access to ~/.openhuman/local-agents.json as the trust proof, relying on Node's default directory/file modes (0777/0666 masked only by the user's umask) weakens that boundary on multi-user machines with permissive or group-writable umasks. In that environment another local account in the group can write a matching entry and have OpenHuman auto-accept its contact request; this should explicitly create/verify the directory as private and write the file with 0600-style permissions.

Useful? React with 👍 / 👎.

} catch {
// Ignore — co-location is an optimization; contact request still fires.
}
}
Comment on lines +1211 to +1221

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and inspect the relevant region with line numbers.
wc -l sdk/typescript/src/cli/harness-wrapper.ts
sed -n '560,660p' sdk/typescript/src/cli/harness-wrapper.ts

# Find the trust-boundary docs and related filesystem helpers.
rg -n "Same-user filesystem access|local-agents.json|announceLocalAgent|localAgentsDir|mkdirSync\\(|writeFileSync\\(" sdk/typescript/src/cli/harness-wrapper.ts sdk/typescript/src -S

Repository: tinyhumansai/tiny.place

Length of output: 6130


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the documented trust boundary and the structure of the handshake entry.
sed -n '532,590p' sdk/typescript/src/cli/harness-wrapper.ts

# See whether this path ever sets restrictive permissions elsewhere.
rg -n "chmod|mode: 0o7|0o600|0o700|local-agents.json|\\.openhuman" sdk/typescript/src -S

# Inspect the LocalAgentEntry fields to judge sensitivity.
sed -n '1,120p' sdk/typescript/src/cli/harness-wrapper.ts

Repository: tinyhumansai/tiny.place

Length of output: 6204


Create the handshake dir/file with owner-only permissions. ~/.openhuman/local-agents.json is the trust proof for same-user access, but mkdirSync/writeFileSync currently rely on the process umask. With a typical 022 umask, other local users can read this file and learn cwd, provider, and owner metadata. Set mode: 0o700/0o600, and chmod existing paths if they already exist.

🧰 Tools
🪛 ast-grep (0.44.1)

[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 611 - 621, Update
announceLocalAgent to create the handshake directory with mode 0o700 and the
local-agents.json file with mode 0o600, then explicitly chmod both existing
paths to enforce owner-only permissions regardless of umask. Preserve the
existing merge/write behavior and keep permission failures handled by the
current catch block.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for asynchronous auto-accept before failing

When OpenHuman is running in a separate process, it can only accept after it observes both this file and the newly created pending request. This code writes the file and then the existing flow immediately does a single status read; if OpenHuman accepts even a moment later, after.status is still pending, and the rejected contactPromise is then reused for all queued envelopes, so the whole fresh session fails despite the local handshake succeeding shortly afterward. Please poll for a bounded interval after contacts.request or clear/retry the cached contact promise when the only failure is pending approval.

Useful? React with 👍 / 👎.

{
agentId: ctx.signer.publicKeyBase64,

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 Use the cryptoId in the local-agent handshake

For the OpenHuman auto-accept path, this writes the base64 public key into the agentId field, but the pending contact request OpenHuman sees is keyed by the sender's base58 cryptoId: contacts.request() is agent-authenticated with X-Agent-ID = signer.agentId, and resolveRecipientKey() also normalizes messaging keys to cryptoIds before contact/status checks. When OpenHuman matches the pending requester to local-agents.json, the file entry will not match for normal local CLI sessions, so the new co-location auto-accept falls back to manual approval; write ctx.signer.agentId here (or use a separate public-key field) for the requester id.

Useful? React with 👍 / 👎.

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
Expand Down
61 changes: 61 additions & 0 deletions sdk/typescript/tests/local-agents-handshake.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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"]);
});
});
4 changes: 2 additions & 2 deletions website/app/a2a/[id]/skill.md/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
agentDocResponse,
agentDocumentResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";
Expand All @@ -18,5 +18,5 @@ export async function GET(
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "skillMd") : notFound();
return card ? agentDocumentResponse(card, "skillMd") : notFound();
}
4 changes: 2 additions & 2 deletions website/app/a2a/[id]/swagger.json/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
agentDocResponse,
agentDocumentResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";
Expand All @@ -18,5 +18,5 @@ export async function GET(
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "swaggerJson") : notFound();
return card ? agentDocumentResponse(card, "swaggerJson") : notFound();
}
4 changes: 2 additions & 2 deletions website/app/a2a/[id]/swagger.md/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
agentDocResponse,
agentDocumentResponse,
notFound,
resolveA2aAgentCard,
} from "@src/common/a2a-route";
Expand All @@ -18,5 +18,5 @@ export async function GET(
): Promise<Response> {
const { id } = await params;
const card = await resolveA2aAgentCard(id);
return card ? agentDocResponse(card, "swaggerMd") : notFound();
return card ? agentDocumentResponse(card, "swaggerMd") : notFound();
}
50 changes: 25 additions & 25 deletions website/src/common/a2a-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest";

import {
type A2aAgentCard,
agentDocResponse,
agentDocUrl,
agentDocumentResponse,
agentDocumentUrl,
resolveA2aAgentCard,
} from "./a2a-route";

Expand All @@ -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<Response> => {
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(
Expand All @@ -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 () => {
Expand All @@ -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();
});
});
Loading
Loading