rogerthat 1.24.5 → 1.25.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.
- package/README.md +6 -5
- package/dist/admin.js +2 -2
- package/dist/agentcard.js +2 -2
- package/dist/app.js +9 -527
- package/dist/channel.js +1 -2
- package/dist/cli.js +2 -8
- package/dist/connect.js +1 -1
- package/dist/discovery.js +15 -226
- package/dist/landing.js +8 -290
- package/dist/listen-here.js +25 -0
- package/dist/mcp.js +36 -363
- package/dist/policy.js +5 -7
- package/dist/presets.js +6 -41
- package/dist/store.js +2 -93
- package/package.json +1 -1
- package/dist/account-ui.js +0 -895
- package/dist/accounts.js +0 -253
- package/dist/email.js +0 -67
- package/dist/remote-control.js +0 -174
- package/dist/remote-ui.js +0 -906
- package/dist/webhooks.js +0 -154
package/dist/accounts.js
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname } from "node:path";
|
|
4
|
-
const ACCOUNTS_PATH = process.env.ROGERRAT_ACCOUNTS ?? "./data/accounts.json";
|
|
5
|
-
const IDENTITIES_PATH = process.env.ROGERRAT_IDENTITIES ?? "./data/identities.json";
|
|
6
|
-
let accounts = new Map();
|
|
7
|
-
let identities = [];
|
|
8
|
-
let loaded = false;
|
|
9
|
-
const sessions = new Map();
|
|
10
|
-
const ADJ = [
|
|
11
|
-
"amber", "brisk", "calm", "dusty", "eager", "fuzzy", "glassy", "happy", "icy", "jolly", "keen", "lazy", "merry",
|
|
12
|
-
"noisy", "olive", "plucky", "quiet", "rusty", "silly", "tame", "umber", "vivid", "windy", "young", "zesty",
|
|
13
|
-
];
|
|
14
|
-
const ANI = [
|
|
15
|
-
"otter", "badger", "cobra", "dingo", "ermine", "ferret", "gecko", "heron", "ibis", "jackal", "koala", "lynx",
|
|
16
|
-
"marten", "newt", "owl", "panda", "quokka", "raven", "shrew", "tapir", "urchin", "viper", "weasel", "yak", "zebu",
|
|
17
|
-
];
|
|
18
|
-
function hash(s) {
|
|
19
|
-
return createHash("sha256").update(s).digest("hex");
|
|
20
|
-
}
|
|
21
|
-
function genId() {
|
|
22
|
-
const a = ADJ[randomInt(0, ADJ.length)];
|
|
23
|
-
const n = ANI[randomInt(0, ANI.length)];
|
|
24
|
-
const sfx = randomBytes(2).toString("hex");
|
|
25
|
-
return `${a}-${n}-${sfx}`;
|
|
26
|
-
}
|
|
27
|
-
function ensureDir(d) {
|
|
28
|
-
if (!existsSync(d))
|
|
29
|
-
mkdirSync(d, { recursive: true });
|
|
30
|
-
}
|
|
31
|
-
function ensureLoaded() {
|
|
32
|
-
if (loaded)
|
|
33
|
-
return;
|
|
34
|
-
loaded = true;
|
|
35
|
-
try {
|
|
36
|
-
if (existsSync(ACCOUNTS_PATH)) {
|
|
37
|
-
const arr = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8"));
|
|
38
|
-
accounts = new Map(arr.map((r) => [
|
|
39
|
-
r.id,
|
|
40
|
-
{
|
|
41
|
-
id: r.id,
|
|
42
|
-
recoveryHash: r.recoveryHash,
|
|
43
|
-
createdAt: r.createdAt,
|
|
44
|
-
email: typeof r.email === "string" ? r.email : undefined,
|
|
45
|
-
emailVerifiedAt: typeof r.emailVerifiedAt === "number" ? r.emailVerifiedAt : undefined,
|
|
46
|
-
},
|
|
47
|
-
]));
|
|
48
|
-
}
|
|
49
|
-
if (existsSync(IDENTITIES_PATH)) {
|
|
50
|
-
identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch (err) {
|
|
54
|
-
console.error("[accounts] load failed:", err);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
58
|
-
const CODE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
59
|
-
const pendingVerifications = new Map();
|
|
60
|
-
const pendingRecoveries = new Map();
|
|
61
|
-
function gcPending() {
|
|
62
|
-
const now = Date.now();
|
|
63
|
-
for (const [k, v] of pendingVerifications)
|
|
64
|
-
if (v.expiresAt < now)
|
|
65
|
-
pendingVerifications.delete(k);
|
|
66
|
-
for (const [k, v] of pendingRecoveries)
|
|
67
|
-
if (v.expiresAt < now)
|
|
68
|
-
pendingRecoveries.delete(k);
|
|
69
|
-
}
|
|
70
|
-
function persistAccounts() {
|
|
71
|
-
ensureDir(dirname(ACCOUNTS_PATH));
|
|
72
|
-
const tmp = `${ACCOUNTS_PATH}.tmp`;
|
|
73
|
-
writeFileSync(tmp, JSON.stringify([...accounts.values()], null, 2));
|
|
74
|
-
renameSync(tmp, ACCOUNTS_PATH);
|
|
75
|
-
}
|
|
76
|
-
function persistIdentities() {
|
|
77
|
-
ensureDir(dirname(IDENTITIES_PATH));
|
|
78
|
-
const tmp = `${IDENTITIES_PATH}.tmp`;
|
|
79
|
-
writeFileSync(tmp, JSON.stringify(identities, null, 2));
|
|
80
|
-
renameSync(tmp, IDENTITIES_PATH);
|
|
81
|
-
}
|
|
82
|
-
function issueSession(accountId) {
|
|
83
|
-
const token = randomBytes(24).toString("base64url");
|
|
84
|
-
const now = Date.now();
|
|
85
|
-
sessions.set(hash(token), { accountId, createdAt: now, lastUsedAt: now });
|
|
86
|
-
return token;
|
|
87
|
-
}
|
|
88
|
-
export function createAccount() {
|
|
89
|
-
ensureLoaded();
|
|
90
|
-
let id;
|
|
91
|
-
do {
|
|
92
|
-
id = genId();
|
|
93
|
-
} while (accounts.has(id));
|
|
94
|
-
const recoveryToken = randomBytes(32).toString("base64url");
|
|
95
|
-
accounts.set(id, { id, recoveryHash: hash(recoveryToken), createdAt: Date.now() });
|
|
96
|
-
persistAccounts();
|
|
97
|
-
const sessionToken = issueSession(id);
|
|
98
|
-
return { account_id: id, recovery_token: recoveryToken, session_token: sessionToken };
|
|
99
|
-
}
|
|
100
|
-
export function recoverAccount(recoveryToken) {
|
|
101
|
-
ensureLoaded();
|
|
102
|
-
const h = hash(recoveryToken);
|
|
103
|
-
for (const rec of accounts.values()) {
|
|
104
|
-
if (rec.recoveryHash === h) {
|
|
105
|
-
return { account_id: rec.id, session_token: issueSession(rec.id) };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
export function verifySession(sessionToken) {
|
|
111
|
-
ensureLoaded();
|
|
112
|
-
const rec = sessions.get(hash(sessionToken));
|
|
113
|
-
if (!rec)
|
|
114
|
-
return null;
|
|
115
|
-
rec.lastUsedAt = Date.now();
|
|
116
|
-
return rec.accountId;
|
|
117
|
-
}
|
|
118
|
-
export function getAccount(accountId) {
|
|
119
|
-
ensureLoaded();
|
|
120
|
-
const a = accounts.get(accountId);
|
|
121
|
-
if (!a)
|
|
122
|
-
return null;
|
|
123
|
-
return {
|
|
124
|
-
id: a.id,
|
|
125
|
-
created_at: a.createdAt,
|
|
126
|
-
email: a.email ?? null,
|
|
127
|
-
email_verified: !!a.emailVerifiedAt,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
export function attachEmail(accountId, rawEmail) {
|
|
131
|
-
ensureLoaded();
|
|
132
|
-
const email = rawEmail.trim().toLowerCase();
|
|
133
|
-
if (!EMAIL_RE.test(email) || email.length > 254) {
|
|
134
|
-
return { error: "invalid email format" };
|
|
135
|
-
}
|
|
136
|
-
const account = accounts.get(accountId);
|
|
137
|
-
if (!account)
|
|
138
|
-
return { error: "account not found" };
|
|
139
|
-
account.email = email;
|
|
140
|
-
account.emailVerifiedAt = undefined;
|
|
141
|
-
persistAccounts();
|
|
142
|
-
const code = randomBytes(32).toString("base64url");
|
|
143
|
-
pendingVerifications.set(hash(code), { accountId, email, expiresAt: Date.now() + CODE_TTL_MS });
|
|
144
|
-
return { code, email };
|
|
145
|
-
}
|
|
146
|
-
export function verifyEmailCode(code) {
|
|
147
|
-
ensureLoaded();
|
|
148
|
-
gcPending();
|
|
149
|
-
const rec = pendingVerifications.get(hash(code));
|
|
150
|
-
if (!rec)
|
|
151
|
-
return { error: "invalid or expired verification link" };
|
|
152
|
-
const account = accounts.get(rec.accountId);
|
|
153
|
-
if (!account)
|
|
154
|
-
return { error: "account not found" };
|
|
155
|
-
for (const other of accounts.values()) {
|
|
156
|
-
if (other.id !== rec.accountId && other.email === rec.email && other.emailVerifiedAt) {
|
|
157
|
-
pendingVerifications.delete(hash(code));
|
|
158
|
-
account.email = undefined;
|
|
159
|
-
persistAccounts();
|
|
160
|
-
return { error: "this email is already verified on another account" };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
account.email = rec.email;
|
|
164
|
-
account.emailVerifiedAt = Date.now();
|
|
165
|
-
persistAccounts();
|
|
166
|
-
pendingVerifications.delete(hash(code));
|
|
167
|
-
return { accountId: rec.accountId, email: rec.email };
|
|
168
|
-
}
|
|
169
|
-
export function removeEmail(accountId) {
|
|
170
|
-
ensureLoaded();
|
|
171
|
-
const account = accounts.get(accountId);
|
|
172
|
-
if (!account)
|
|
173
|
-
return false;
|
|
174
|
-
account.email = undefined;
|
|
175
|
-
account.emailVerifiedAt = undefined;
|
|
176
|
-
persistAccounts();
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
export function requestEmailRecovery(rawEmail) {
|
|
180
|
-
ensureLoaded();
|
|
181
|
-
const email = rawEmail.trim().toLowerCase();
|
|
182
|
-
if (!EMAIL_RE.test(email))
|
|
183
|
-
return null;
|
|
184
|
-
for (const a of accounts.values()) {
|
|
185
|
-
if (a.email === email && a.emailVerifiedAt) {
|
|
186
|
-
const code = randomBytes(32).toString("base64url");
|
|
187
|
-
pendingRecoveries.set(hash(code), { accountId: a.id, expiresAt: Date.now() + CODE_TTL_MS });
|
|
188
|
-
return { code, accountId: a.id };
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
export function confirmEmailRecovery(code) {
|
|
194
|
-
ensureLoaded();
|
|
195
|
-
gcPending();
|
|
196
|
-
const rec = pendingRecoveries.get(hash(code));
|
|
197
|
-
if (!rec)
|
|
198
|
-
return null;
|
|
199
|
-
const account = accounts.get(rec.accountId);
|
|
200
|
-
if (!account)
|
|
201
|
-
return null;
|
|
202
|
-
pendingRecoveries.delete(hash(code));
|
|
203
|
-
return { accountId: rec.accountId, session_token: issueSession(rec.accountId) };
|
|
204
|
-
}
|
|
205
|
-
export function listIdentities(accountId) {
|
|
206
|
-
ensureLoaded();
|
|
207
|
-
return identities
|
|
208
|
-
.filter((i) => i.accountId === accountId)
|
|
209
|
-
.map((i) => ({ callsign: i.callsign, created_at: i.createdAt }))
|
|
210
|
-
.sort((a, b) => a.created_at - b.created_at);
|
|
211
|
-
}
|
|
212
|
-
export function createIdentity(accountId, callsign) {
|
|
213
|
-
ensureLoaded();
|
|
214
|
-
const normalized = callsign.trim().toLowerCase();
|
|
215
|
-
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
216
|
-
return { error: "callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit" };
|
|
217
|
-
}
|
|
218
|
-
if (normalized === "all")
|
|
219
|
-
return { error: 'callsign "all" is reserved' };
|
|
220
|
-
const existing = identities.find((i) => i.accountId === accountId && i.callsign === normalized);
|
|
221
|
-
if (existing)
|
|
222
|
-
return { error: `identity '${normalized}' already exists on this account` };
|
|
223
|
-
const key = randomBytes(32).toString("base64url");
|
|
224
|
-
identities.push({ accountId, callsign: normalized, keyHash: hash(key), createdAt: Date.now() });
|
|
225
|
-
persistIdentities();
|
|
226
|
-
return { callsign: normalized, identity_key: key };
|
|
227
|
-
}
|
|
228
|
-
export function deleteIdentity(accountId, callsign) {
|
|
229
|
-
ensureLoaded();
|
|
230
|
-
const normalized = callsign.trim().toLowerCase();
|
|
231
|
-
const idx = identities.findIndex((i) => i.accountId === accountId && i.callsign === normalized);
|
|
232
|
-
if (idx === -1)
|
|
233
|
-
return false;
|
|
234
|
-
identities.splice(idx, 1);
|
|
235
|
-
persistIdentities();
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
export function verifyIdentity(identityKey) {
|
|
239
|
-
ensureLoaded();
|
|
240
|
-
const h = hash(identityKey);
|
|
241
|
-
const i = identities.find((x) => x.keyHash === h);
|
|
242
|
-
return i ? { account_id: i.accountId, callsign: i.callsign } : null;
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Find any account that owns this callsign as an identity. Used by webhook
|
|
246
|
-
* delivery: when a message is addressed to "alpha", we look up which account
|
|
247
|
-
* (if any) has an identity called "alpha" and fire that account's webhooks.
|
|
248
|
-
*/
|
|
249
|
-
export function getAccountIdsByIdentityCallsign(callsign) {
|
|
250
|
-
ensureLoaded();
|
|
251
|
-
const cs = callsign.trim().toLowerCase();
|
|
252
|
-
return identities.filter((i) => i.callsign === cs).map((i) => i.accountId);
|
|
253
|
-
}
|
package/dist/email.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
2
|
-
const RESEND_FROM = process.env.RESEND_FROM ?? "RogerThat <no-reply@rogerthat.chat>";
|
|
3
|
-
export function emailEnabled() {
|
|
4
|
-
return !!RESEND_API_KEY;
|
|
5
|
-
}
|
|
6
|
-
export async function sendEmail(to, subject, text, html) {
|
|
7
|
-
if (!RESEND_API_KEY)
|
|
8
|
-
throw new Error("email is disabled on this instance (no RESEND_API_KEY)");
|
|
9
|
-
const r = await fetch("https://api.resend.com/emails", {
|
|
10
|
-
method: "POST",
|
|
11
|
-
headers: {
|
|
12
|
-
Authorization: `Bearer ${RESEND_API_KEY}`,
|
|
13
|
-
"Content-Type": "application/json",
|
|
14
|
-
},
|
|
15
|
-
body: JSON.stringify({
|
|
16
|
-
from: RESEND_FROM,
|
|
17
|
-
to: [to],
|
|
18
|
-
subject,
|
|
19
|
-
text,
|
|
20
|
-
...(html ? { html } : {}),
|
|
21
|
-
}),
|
|
22
|
-
});
|
|
23
|
-
if (!r.ok) {
|
|
24
|
-
const body = await r.text();
|
|
25
|
-
throw new Error(`Resend ${r.status}: ${body}`);
|
|
26
|
-
}
|
|
27
|
-
return (await r.json());
|
|
28
|
-
}
|
|
29
|
-
export function buildVerifyEmail(origin, code, accountId) {
|
|
30
|
-
const url = `${origin}/api/account/email-verify?code=${encodeURIComponent(code)}`;
|
|
31
|
-
return {
|
|
32
|
-
subject: "Verify your email for RogerThat",
|
|
33
|
-
text: `Hello,
|
|
34
|
-
|
|
35
|
-
You (or someone with your session_token) attached this email to RogerThat account ${accountId}.
|
|
36
|
-
|
|
37
|
-
Click the link below to verify your email:
|
|
38
|
-
${url}
|
|
39
|
-
|
|
40
|
-
The link expires in 1 hour. If you didn't request this, you can ignore the email — nothing happens unless you click.
|
|
41
|
-
|
|
42
|
-
— RogerThat`,
|
|
43
|
-
html: `<p>Hello,</p>
|
|
44
|
-
<p>You (or someone with your session_token) attached this email to RogerThat account <strong>${accountId}</strong>.</p>
|
|
45
|
-
<p><a href="${url}">Click here to verify your email</a></p>
|
|
46
|
-
<p style="color:#7a6f5f;font-size:13px">The link expires in 1 hour. If you didn't request this, ignore this email — nothing happens unless you click.</p>
|
|
47
|
-
<p style="color:#7a6f5f;font-size:13px">— RogerThat</p>`,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
export function buildRecoveryEmail(origin, code) {
|
|
51
|
-
const url = `${origin}/api/account/email-recover-confirm?code=${encodeURIComponent(code)}`;
|
|
52
|
-
return {
|
|
53
|
-
subject: "RogerThat account recovery",
|
|
54
|
-
text: `Someone requested account recovery for this email on RogerThat.
|
|
55
|
-
|
|
56
|
-
If it was you, click here to get a fresh session_token:
|
|
57
|
-
${url}
|
|
58
|
-
|
|
59
|
-
The link expires in 1 hour and can only be used once. If it wasn't you, ignore this email — nothing happens unless you click.
|
|
60
|
-
|
|
61
|
-
— RogerThat`,
|
|
62
|
-
html: `<p>Someone requested account recovery for this email on RogerThat.</p>
|
|
63
|
-
<p>If it was you, <a href="${url}">click here to get a fresh session_token</a>.</p>
|
|
64
|
-
<p style="color:#7a6f5f;font-size:13px">The link expires in 1 hour and can only be used once. If it wasn't you, ignore this email — nothing happens unless you click.</p>
|
|
65
|
-
<p style="color:#7a6f5f;font-size:13px">— RogerThat</p>`,
|
|
66
|
-
};
|
|
67
|
-
}
|
package/dist/remote-control.js
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
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, getChannelRecord, hasOwnerPassword, setOwnerPassword, verifyChannel, } 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 rogerthat 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
|
-
// 7. Self-test: append a synthetic line directly to the inbox so the Monitor
|
|
100
|
-
// fires once at bootstrap. This proves (a) the file path is writable and
|
|
101
|
-
// (b) the Monitor is tailing the right file — both without waiting for the
|
|
102
|
-
// listener's npx download to finish (which can take 30-60s on first use).
|
|
103
|
-
// The line uses the same `[<from>] <text>` shape as listen-here `--format text`
|
|
104
|
-
// so it looks like a real message in the Monitor stream. mkdir -p protects
|
|
105
|
-
// against weird tmp dirs; touch ensures the file exists before tail -F
|
|
106
|
-
// attaches (tail -F handles late-creation but some tail variants don't).
|
|
107
|
-
const selftestCommandTemplate = `mkdir -p $(dirname ${inboxPath}) && touch ${inboxPath} && echo "[selftest] monitor wired at $(date -u +%H:%M:%SZ) — listener bootstrapping (npx -y rogerthat takes 30-60s the first run on this machine); phone messages arrive once the human opens the URL and both sides are joined" >> ${inboxPath}`;
|
|
108
|
-
return {
|
|
109
|
-
channel_id: ch.id,
|
|
110
|
-
channel_token: ch.token,
|
|
111
|
-
owner_password: ownerPassword,
|
|
112
|
-
agent: { callsign: agent.callsign, identity_key: agent.identity_key },
|
|
113
|
-
phone: { callsign: phone.callsign, identity_key: phone.identity_key },
|
|
114
|
-
mobile_url: mobileUrl,
|
|
115
|
-
qr_ascii: qrAscii,
|
|
116
|
-
account_id: accountId,
|
|
117
|
-
recovery_token: recoveryToken,
|
|
118
|
-
session_ttl_seconds: ch.session_ttl_seconds,
|
|
119
|
-
receiver_command_template: receiverCommandTemplate,
|
|
120
|
-
monitor_command_template: monitorCommand,
|
|
121
|
-
selftest_command_template: selftestCommandTemplate,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
export async function retrofitRemoteLink(opts) {
|
|
125
|
-
// 1. Session must be valid. We mint the phone identity on this account.
|
|
126
|
-
const accountId = verifySession(opts.sessionToken);
|
|
127
|
-
if (!accountId)
|
|
128
|
-
return { error: "invalid or expired session_token", code: "unauthorized" };
|
|
129
|
-
// 2. Channel must exist and the channel_token must match.
|
|
130
|
-
const rec = getChannelRecord(opts.channelId);
|
|
131
|
-
if (!rec)
|
|
132
|
-
return { error: "channel not found", code: "not_found" };
|
|
133
|
-
if (!verifyChannel(opts.channelId, opts.channelToken)) {
|
|
134
|
-
return { error: "bad channel_token", code: "bad_token" };
|
|
135
|
-
}
|
|
136
|
-
// 3. Mint a fresh phone identity on the caller's account. (Even if the
|
|
137
|
-
// channel has require_identity=false, the identity_key is still useful —
|
|
138
|
-
// /remote uses it to attribute the phone session, and it costs nothing.)
|
|
139
|
-
const phoneCallsign = `phone-${randomBytes(3).toString("hex")}`;
|
|
140
|
-
const phone = createIdentity(accountId, phoneCallsign);
|
|
141
|
-
if ("error" in phone)
|
|
142
|
-
return { error: phone.error, code: "internal" };
|
|
143
|
-
// 4. Owner password handling. If the channel already has one, we don't
|
|
144
|
-
// rotate it (would break existing peers); we just flag this and let the
|
|
145
|
-
// caller relay the password they already have. Otherwise, mint one.
|
|
146
|
-
let ownerPassword = null;
|
|
147
|
-
if (!hasOwnerPassword(opts.channelId)) {
|
|
148
|
-
const minted = randomBytes(16).toString("base64url");
|
|
149
|
-
const setRes = setOwnerPassword(opts.channelId, minted);
|
|
150
|
-
if ("error" in setRes)
|
|
151
|
-
return { error: setRes.error, code: "internal" };
|
|
152
|
-
ownerPassword = minted;
|
|
153
|
-
}
|
|
154
|
-
// 5. Build the same mobile_url + QR as createRemoteControl.
|
|
155
|
-
const frag = new URLSearchParams();
|
|
156
|
-
frag.set("t", opts.channelToken);
|
|
157
|
-
frag.set("k", phone.identity_key);
|
|
158
|
-
frag.set("cs", phone.callsign);
|
|
159
|
-
const mobileUrl = `${opts.publicOrigin}/remote/${opts.channelId}#${frag.toString()}`;
|
|
160
|
-
const qrAscii = await QRCode.toString(mobileUrl, {
|
|
161
|
-
type: "terminal",
|
|
162
|
-
small: true,
|
|
163
|
-
errorCorrectionLevel: "L",
|
|
164
|
-
});
|
|
165
|
-
return {
|
|
166
|
-
channel_id: opts.channelId,
|
|
167
|
-
channel_token: opts.channelToken,
|
|
168
|
-
owner_password: ownerPassword,
|
|
169
|
-
owner_password_existing: ownerPassword === null,
|
|
170
|
-
phone: { callsign: phone.callsign, identity_key: phone.identity_key },
|
|
171
|
-
mobile_url: mobileUrl,
|
|
172
|
-
qr_ascii: qrAscii,
|
|
173
|
-
};
|
|
174
|
-
}
|