Files

190 lines
7.1 KiB
JavaScript

// Boot a real `openhuman-core` process and drive its tiny.place messaging RPCs.
//
// openhuman is a Rust core exposing JSON-RPC over `POST /rpc`. All tiny.place
// messaging lives in the core's `tinyplace` domain (methods
// `openhuman.tinyplace_*`), backed by the vendored Rust tiny.place SDK. The
// core derives its tiny.place identity (a base58 Solana cryptoId) from the
// wallet mnemonic at m/44'/501'/0'/0'. So: one core process = one identity, and
// two cores with two mnemonics = two agents that can message each other over a
// real tiny.place backend — exactly what these tests exercise.
import { spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateMnemonic } from "./mnemonic.mjs";
const HERE = resolve(fileURLToPath(import.meta.url), "..");
const REPO_ROOT = resolve(HERE, "..", "..", "..");
export const DEFAULT_CORE_BIN =
process.env.OPENHUMAN_CORE_BIN || join(REPO_ROOT, "target", "debug", "openhuman-core");
export const DEFAULT_BACKEND =
process.env.TINYPLACE_API_BASE_URL || "http://localhost:18080";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// The tiny.place identity is re-derived from the mnemonic, so these per-chain
// account addresses are cosmetic metadata; wallet_setup only validates that
// each supported chain appears once with a non-empty address + derivation path.
// The Solana derivation path must be the canonical one the signer uses.
const PLACEHOLDER_ACCOUNTS = [
{ chain: "evm", address: "0x0000000000000000000000000000000000000001", derivationPath: "m/44'/60'/0'/0/0" },
{ chain: "btc", address: "bc1qplaceholderplaceholderplaceholderplac0000", derivationPath: "m/84'/0'/0'/0/0" },
{ chain: "solana", address: "11111111111111111111111111111111", derivationPath: "m/44'/501'/0'/0'" },
{ chain: "tron", address: "T0000000000000000000000000000000001", derivationPath: "m/44'/195'/0'/0/0" },
];
function makeRpc(port, token, label) {
return async function rpc(method, params = {}) {
let res;
try {
res = await fetch(`http://127.0.0.1:${port}/rpc`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }),
});
} catch (e) {
throw new Error(`[${label}] ${method} transport error: ${e.message}`);
}
const body = await res.json();
if (body.error) {
throw new Error(`[${label}] ${method} -> ${JSON.stringify(body.error)}`);
}
// The core wraps every handler payload as { logs, result }; unwrap it.
let result = body.result;
if (result && typeof result === "object" && "result" in result && "logs" in result) {
result = result.result;
}
return result;
};
}
/**
* Launch an openhuman core, import a fresh wallet identity, publish Signal
* pre-keys, and advertise the encryption key on the directory card — i.e. a
* fully message-ready tiny.place agent.
*
* Returns a handle: { name, cryptoId, rpc, port, workspace, stop() }.
*/
export async function launchAgent(name, opts = {}) {
const {
port,
backend = DEFAULT_BACKEND,
coreBin = DEFAULT_CORE_BIN,
mnemonic = generateMnemonic(),
preKeyCount = 10,
verbose = !!process.env.VERBOSE,
readyTimeoutMs = 45_000,
} = opts;
if (!port) throw new Error("launchAgent requires an explicit port");
const token = `oh-${name}-${process.pid}-token`;
const workspace = mkdtempSync(join(tmpdir(), `oh-${name}-`));
const child = spawn(
coreBin,
["run", "--host", "127.0.0.1", "--port", String(port), "--jsonrpc-only"],
{
env: {
...process.env,
OPENHUMAN_WORKSPACE: workspace,
OPENHUMAN_KEYRING_BACKEND: "file",
OPENHUMAN_CORE_TOKEN: token,
TINYPLACE_API_BASE_URL: backend,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
const logs = [];
const capture = (stream, tag) =>
stream.on("data", (d) => {
const line = d.toString();
logs.push(line);
if (verbose) process.stderr.write(`[${name}${tag}] ${line}`);
});
capture(child.stdout, "");
capture(child.stderr, "!");
let stopped = false;
const stop = () => {
if (!stopped) {
stopped = true;
child.kill("SIGKILL");
}
};
const fail = (msg) => {
stop();
return new Error(`${msg}\n--- last core logs (${name}) ---\n${logs.slice(-15).join("")}`);
};
try {
// 1) Wait for the HTTP server to accept connections.
let up = false;
const deadline = Date.now() + readyTimeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw fail(`core '${name}' exited early (code ${child.exitCode})`);
try {
const r = await fetch(`http://127.0.0.1:${port}/health`);
if (r.ok) { up = true; break; }
} catch { /* not up yet */ }
await sleep(400);
}
if (!up) throw fail(`core '${name}' /health never came up on :${port}`);
const rpc = makeRpc(port, token, name);
// 2) Import a fresh wallet identity (encrypt the mnemonic, then persist it).
const encryptedMnemonic = await rpc("openhuman.encrypt_secret", { plaintext: mnemonic });
await rpc("openhuman.wallet_setup", {
consentGranted: true,
source: "imported",
mnemonicWordCount: mnemonic.split(" ").length,
encryptedMnemonic,
accounts: PLACEHOLDER_ACCOUNTS,
force: true,
});
// 3) Publish Signal pre-keys so peers can start an encrypted session, and
// advertise the encryption key on the directory card so it's routable.
await rpc("openhuman.tinyplace_signal_provision", { preKeyCount });
await rpc("openhuman.tinyplace_signal_register_encryption_key", {});
const status = await rpc("openhuman.tinyplace_signal_key_status", {});
const cryptoId = status.agentId;
if (!cryptoId) throw fail(`core '${name}' produced an empty cryptoId`);
return { name, cryptoId, rpc, port, workspace, stop, mnemonic };
} catch (e) {
stop();
throw e;
}
}
/**
* Poll a core's mailbox, decrypting + acknowledging each envelope, until a
* message from `fromCryptoId` (or any, if omitted) is decrypted — or timeout.
* Returns the decrypted plaintext string, or null on timeout.
*/
export async function receiveMessage(core, { fromCryptoId, timeoutMs = 8_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const list = await core.rpc("openhuman.tinyplace_messages_list", { limit: 50 });
const envelopes = Array.isArray(list) ? list : list?.messages ?? [];
for (const envelope of envelopes) {
let decoded;
try {
decoded = await core.rpc("openhuman.tinyplace_signal_decrypt_message", { envelope });
} finally {
// Acknowledge (delete) regardless: the ratchet already advanced.
await core.rpc("openhuman.tinyplace_messages_acknowledge", { messageId: envelope.id });
}
if (decoded && (!fromCryptoId || decoded.from === fromCryptoId)) {
return decoded.plaintext;
}
}
await sleep(400);
}
return null;
}