rogerrat 1.4.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ // One-shot bootstrap for the "let me drive the agent from my phone" flow.
2
+ // Composes the existing primitives — create_account + create_identity + create_channel
3
+ // + a /join for the agent itself — so an agent can do it in a single tool call
4
+ // instead of stitching four together.
5
+ //
6
+ // Returns:
7
+ // - agent: callsign + identity_key the agent uses to enter the channel.
8
+ // - phone: callsign + identity_key the phone uses to enter the channel.
9
+ // - channel_id, channel_token, mobile_url
10
+ //
11
+ // Same function backs:
12
+ // - POST /api/remote-control (REST: human-friendly bootstrap)
13
+ // - MCP tool open_remote_control (in mcp.ts) (one-call bootstrap for agents)
14
+ import { randomBytes } from "node:crypto";
15
+ import QRCode from "qrcode";
16
+ import { createAccount, createIdentity, verifySession } from "./accounts.js";
17
+ import { createChannel } from "./store.js";
18
+ export async function createRemoteControl(opts) {
19
+ // 1. Resolve the account: either reuse the caller's, or mint a fresh anonymous one.
20
+ let accountId;
21
+ let recoveryToken = null;
22
+ if (opts.sessionToken) {
23
+ const id = verifySession(opts.sessionToken);
24
+ if (!id)
25
+ return { error: "invalid or expired session_token", code: "unauthorized" };
26
+ accountId = id;
27
+ }
28
+ else {
29
+ const acc = createAccount();
30
+ accountId = acc.account_id;
31
+ recoveryToken = acc.recovery_token;
32
+ }
33
+ // 2. Mint two identities on that account. Both need require_identity=true on
34
+ // the channel, so each side authenticates with its own identity_key.
35
+ // Callsigns are random hex slugs — opaque, collision-resistant, and not
36
+ // advertised back to the user (they look at agent/phone roles, not names).
37
+ const randomCallsign = (prefix) => `${prefix}-${randomBytes(3).toString("hex")}`;
38
+ const agent = createIdentity(accountId, randomCallsign("agent"));
39
+ if ("error" in agent)
40
+ return { error: agent.error, code: "internal" };
41
+ const phone = createIdentity(accountId, randomCallsign("phone"));
42
+ if ("error" in phone)
43
+ return { error: phone.error, code: "internal" };
44
+ // 3. Mint a random owner_password and create the channel.
45
+ // - require_identity=true: both sides authenticate by identity_key.
46
+ // - trust_mode=trusted + owner_password set: when the agent joins with
47
+ // identity_key + owner_password, the join handler stamps that session as
48
+ // `human_authorized` → trust_posture becomes "trusted-authorized" →
49
+ // operating instructions tell the agent to act on peer requests without
50
+ // per-action confirmation (still refuses destructive ops).
51
+ // - retention=metadata: join/leave log without storing message bodies.
52
+ // - 24h TTL: sessions survive screen-off / app-switch without re-joining.
53
+ // base64url for URL-safety; 16 bytes = 22 chars, well above the 6-char floor.
54
+ const ownerPassword = randomBytes(16).toString("base64url");
55
+ const ch = createChannel({
56
+ retention: "metadata",
57
+ require_identity: true,
58
+ trust_mode: "trusted",
59
+ session_ttl_seconds: 24 * 60 * 60,
60
+ creator_account_id: accountId,
61
+ owner_password: ownerPassword,
62
+ });
63
+ if ("error" in ch)
64
+ return { error: ch.error, code: "internal" };
65
+ // 4. Build the mobile URL with creds in the fragment (never on the wire).
66
+ // The owner_password is DELIBERATELY NOT embedded — the user has to type it
67
+ // manually on /remote. Rationale: if the URL leaks (screenshot, share-sheet,
68
+ // browser sync), the leaker can still join the channel (identity_key is in
69
+ // the URL), but their session is `human_authorized=false` — auditable as a
70
+ // non-human join. Only someone who got the password through a separate
71
+ // channel (the agent's MCP response, told to them by the human) can flip
72
+ // their session to `trusted-authorized`.
73
+ const frag = new URLSearchParams();
74
+ frag.set("t", ch.token);
75
+ frag.set("k", phone.identity_key);
76
+ frag.set("cs", phone.callsign);
77
+ const mobileUrl = `${opts.publicOrigin}/remote/${ch.id}#${frag.toString()}`;
78
+ // 5. Render an ASCII (unicode-block) QR of mobile_url for terminal display.
79
+ // `type:'terminal' small:true` outputs 2-row-per-cell using ▀ ▄ █ — most
80
+ // terminals + camera apps handle it fine. errorCorrectionLevel 'L' keeps
81
+ // the QR small (URL is ~110 chars, M/Q/H would balloon the size and choke
82
+ // narrow terminal columns).
83
+ const qrAscii = await QRCode.toString(mobileUrl, {
84
+ type: "terminal",
85
+ small: true,
86
+ errorCorrectionLevel: "L",
87
+ });
88
+ // 6. Build the listen-here receiver command template. `<SID>` is a placeholder
89
+ // the agent substitutes with the session_id it gets back from /join.
90
+ // --format text + --inbox is the safe default — text format means each line
91
+ // is a self-contained human-readable notification, so the agent's Monitor
92
+ // tool can tail the file as bare `tail -F` with no parser in between
93
+ // (adding jq/python in the Monitor cmd is a frequent source of silent
94
+ // breakage from shell-escaping bugs). For programmatic consumers, swap to
95
+ // --format jsonl downstream of this recipe.
96
+ const inboxPath = `/tmp/rr-${ch.id}.log`;
97
+ const receiverCommandTemplate = `nohup npx -y rogerrat listen-here --channel ${ch.id} --token ${ch.token} --session <SID> --origin ${opts.publicOrigin} --inbox ${inboxPath} --format text --quiet >/dev/null 2>&1 &`;
98
+ const monitorCommand = `stdbuf -oL tail -n 0 -F ${inboxPath}`;
99
+ return {
100
+ channel_id: ch.id,
101
+ channel_token: ch.token,
102
+ owner_password: ownerPassword,
103
+ agent: { callsign: agent.callsign, identity_key: agent.identity_key },
104
+ phone: { callsign: phone.callsign, identity_key: phone.identity_key },
105
+ mobile_url: mobileUrl,
106
+ qr_ascii: qrAscii,
107
+ account_id: accountId,
108
+ recovery_token: recoveryToken,
109
+ session_ttl_seconds: ch.session_ttl_seconds,
110
+ receiver_command_template: receiverCommandTemplate,
111
+ monitor_command_template: monitorCommand,
112
+ };
113
+ }