-
Notifications
You must be signed in to change notification settings - Fork 23
feat(cli): announce co-located agent to OpenHuman before contact request #242
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -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"); | ||
| } catch { | ||
| // Ignore — co-location is an optimization; contact request still fires. | ||
| } | ||
| } | ||
|
Comment on lines
+611
to
+621
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. 🔒 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 -SRepository: 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.tsRepository: tinyhumansai/tiny.place Length of output: 6204 Create the handshake dir/file with owner-only permissions. 🧰 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. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| class SessionEnvelopePublisher { | ||
| private contactPromise: Promise<string> | undefined; | ||
| private contextPromise: | ||
|
|
@@ -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( | ||
|
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.
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, 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") { | ||
|
|
||
| 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"]); | ||
| }); | ||
| }); |
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.
Because OpenHuman treats write access to
~/.openhuman/local-agents.jsonas the trust proof, relying on Node's default directory/file modes (0777/0666masked 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 with0600-style permissions.Useful? React with 👍 / 👎.