Skip to content
Open
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 @@ -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<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 +616 to +617

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 +611 to +621

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.


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

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,
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") {
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"]);
});
});