rogerrat 1.4.1 → 1.18.1
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/dist/account-ui.js +191 -2
- package/dist/app.js +222 -13
- package/dist/channel.js +68 -1
- package/dist/cli.js +34 -5
- package/dist/connect.js +74 -6
- package/dist/discovery.js +150 -6
- package/dist/landing.js +45 -0
- package/dist/listen-here.js +366 -0
- package/dist/mcp.js +141 -12
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +113 -0
- package/dist/remote-ui.js +604 -0
- package/package.json +10 -5
package/dist/account-ui.js
CHANGED
|
@@ -5,6 +5,9 @@ export function accountHtml() {
|
|
|
5
5
|
<meta charset="utf-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
<title>rogerrat — account</title>
|
|
8
|
+
<!-- Loaded for the "Pair phone" QR modal. Stays out of every other page; failure
|
|
9
|
+
is recoverable — the URL itself is still shown and can be opened directly. -->
|
|
10
|
+
<script src="https://unpkg.com/qrcode@1.5.4/build/qrcode.min.js" defer></script>
|
|
8
11
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231a1a1a'/><path d='M 9 7 Q 16 4 23 7' stroke='%23d6541f' stroke-width='1.5' fill='none' stroke-linecap='round'/><ellipse cx='11' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(-15 11 14)'/><ellipse cx='21' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(15 21 14)'/><ellipse cx='16' cy='22' rx='8' ry='6.5' fill='%23f4ede0'/><circle cx='13' cy='21' r='1.2' fill='%231a1a1a'/><circle cx='19' cy='21' r='1.2' fill='%231a1a1a'/><ellipse cx='16' cy='25' rx='1.5' ry='1' fill='%23d6541f'/></svg>" />
|
|
9
12
|
<style>
|
|
10
13
|
:root {
|
|
@@ -55,6 +58,25 @@ export function accountHtml() {
|
|
|
55
58
|
.empty { text-align: center; padding: 28px; color: var(--dim); font-size: 13px; }
|
|
56
59
|
footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--dim); font-size: 12px; }
|
|
57
60
|
footer a { color: var(--dim); }
|
|
61
|
+
|
|
62
|
+
/* Pair-phone modal — used to build a /remote/<id> URL with creds in the fragment */
|
|
63
|
+
.modal-backdrop {
|
|
64
|
+
position: fixed; inset: 0; background: rgba(26,26,26,0.55); z-index: 1000;
|
|
65
|
+
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
66
|
+
}
|
|
67
|
+
.modal-panel {
|
|
68
|
+
background: var(--paper); border: 2px solid var(--ink); padding: 20px 22px;
|
|
69
|
+
max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto;
|
|
70
|
+
}
|
|
71
|
+
.modal-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; gap: 12px; }
|
|
72
|
+
.modal-head h2 { margin: 0; font-size: 17px; }
|
|
73
|
+
.modal-head .x { background: transparent; color: var(--dim); border: none; padding: 4px 8px; font-size: 18px; cursor: pointer; }
|
|
74
|
+
#pair-url { background: var(--bg); border: 1px dashed var(--warn); padding: 10px 12px;
|
|
75
|
+
font-size: 11px; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap;
|
|
76
|
+
user-select: all; max-height: 90px; overflow-y: auto; }
|
|
77
|
+
#pair-qr { background: white; padding: 14px; text-align: center; min-height: 240px;
|
|
78
|
+
border: 1px solid var(--line); }
|
|
79
|
+
#pair-qr svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
|
58
80
|
</style>
|
|
59
81
|
</head>
|
|
60
82
|
<body>
|
|
@@ -194,6 +216,16 @@ export function accountHtml() {
|
|
|
194
216
|
<tbody id="ident-rows"><tr><td colspan="3" class="empty">No identities yet.</td></tr></tbody>
|
|
195
217
|
</table>
|
|
196
218
|
</div>
|
|
219
|
+
|
|
220
|
+
<div id="reveal" class="card reveal" hidden>
|
|
221
|
+
<strong>Save these now.</strong> They are shown ONLY on this screen. You will not see them again after navigation.
|
|
222
|
+
<div id="reveal-body"></div>
|
|
223
|
+
<div class="reveal-actions">
|
|
224
|
+
<button data-action="download">⬇ Download .txt</button>
|
|
225
|
+
<button data-action="copy">⎘ Copy</button>
|
|
226
|
+
<button id="reveal-pair-btn" hidden>📱 Pair phone now</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
197
229
|
</div>
|
|
198
230
|
|
|
199
231
|
<footer>
|
|
@@ -201,6 +233,55 @@ export function accountHtml() {
|
|
|
201
233
|
</footer>
|
|
202
234
|
</div>
|
|
203
235
|
|
|
236
|
+
<!-- Pair-phone modal: builds a /remote/<id> URL with the channel token + identity
|
|
237
|
+
in the URL fragment, renders the QR client-side. Token & key are never sent
|
|
238
|
+
to a third-party renderer. -->
|
|
239
|
+
<div id="pair-modal" class="modal-backdrop" hidden>
|
|
240
|
+
<div class="modal-panel">
|
|
241
|
+
<div class="modal-head">
|
|
242
|
+
<h2>Pair phone with a channel</h2>
|
|
243
|
+
<button id="pair-close" class="x" aria-label="close">✕</button>
|
|
244
|
+
</div>
|
|
245
|
+
<p class="sub">Builds a URL your phone can open. The token + identity_key live in the URL fragment (after <code>#</code>) — so they never reach the server logs or referrers. Treat the URL like a password.</p>
|
|
246
|
+
|
|
247
|
+
<label>Channel</label>
|
|
248
|
+
<input id="pair-channel" type="text" readonly style="background:var(--bg);color:var(--dim)" />
|
|
249
|
+
|
|
250
|
+
<label style="margin-top:12px">Channel token <span style="color:var(--dim);text-transform:none;letter-spacing:0">(shown once when you created the channel)</span></label>
|
|
251
|
+
<input id="pair-token" type="password" autocomplete="off" placeholder="paste the join_token" />
|
|
252
|
+
|
|
253
|
+
<p style="font-size:11px;color:var(--dim);margin:14px 0 4px;text-transform:uppercase;letter-spacing:0.06em">Authentication — one of:</p>
|
|
254
|
+
|
|
255
|
+
<label>identity_key <span style="color:var(--dim);text-transform:none;letter-spacing:0">(required if channel has require_identity=true)</span></label>
|
|
256
|
+
<input id="pair-key" type="password" autocomplete="off" placeholder="(optional)" />
|
|
257
|
+
|
|
258
|
+
<label style="margin-top:8px">or callsign</label>
|
|
259
|
+
<input id="pair-cs" type="text" autocomplete="off" placeholder="phone" />
|
|
260
|
+
|
|
261
|
+
<label style="margin-top:12px">Owner password <span style="color:var(--dim);text-transform:none;letter-spacing:0">(optional — for trusted channels, flips the phone's session to "human-authorized")</span></label>
|
|
262
|
+
<input id="pair-pw" type="password" autocomplete="off" placeholder="(optional)" />
|
|
263
|
+
|
|
264
|
+
<p id="pair-err" class="err" hidden></p>
|
|
265
|
+
|
|
266
|
+
<div class="row" style="margin-top:16px">
|
|
267
|
+
<button id="pair-gen">Generate pair link</button>
|
|
268
|
+
<span id="pair-status" style="font-size:12px;color:var(--dim)"></span>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div id="pair-output" hidden style="margin-top:18px">
|
|
272
|
+
<label>Pair URL</label>
|
|
273
|
+
<pre id="pair-url"></pre>
|
|
274
|
+
<div class="row" style="margin:8px 0 16px">
|
|
275
|
+
<button id="pair-copy" class="ghost">⎘ Copy</button>
|
|
276
|
+
<a id="pair-open" target="_blank" rel="noopener" class="ghost" style="padding:10px 18px;background:transparent;color:var(--dim);border:1px solid var(--line);font-family:inherit;font-size:14px;text-decoration:none;cursor:pointer">↗ Open here</a>
|
|
277
|
+
</div>
|
|
278
|
+
<label>QR (scan with phone camera)</label>
|
|
279
|
+
<div id="pair-qr">QR will appear here…</div>
|
|
280
|
+
<p style="font-size:11px;color:var(--dim);margin-top:8px"><strong>⚠</strong> Anyone with this URL can drive the agent on this channel. Don't paste it into anything other than your phone.</p>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
204
285
|
<script>
|
|
205
286
|
const KEY = 'rogerrat_account_session';
|
|
206
287
|
let session = sessionStorage.getItem(KEY) || '';
|
|
@@ -226,6 +307,10 @@ export function accountHtml() {
|
|
|
226
307
|
function showReveal(filename, text) {
|
|
227
308
|
revealPayload = { filename, text };
|
|
228
309
|
$('reveal-body').innerHTML = '<pre>' + esc(text) + '</pre>';
|
|
310
|
+
// Default: hide the channel-only "Pair phone" action. Channel-create
|
|
311
|
+
// unhides + wires it after this call.
|
|
312
|
+
const pb = $('reveal-pair-btn');
|
|
313
|
+
if (pb) { pb.hidden = true; pb.onclick = null; }
|
|
229
314
|
$('reveal').hidden = false;
|
|
230
315
|
}
|
|
231
316
|
|
|
@@ -355,12 +440,18 @@ export function accountHtml() {
|
|
|
355
440
|
'<td><span style="color:' + trustColor + '">' + trustLabel + '</span></td>' +
|
|
356
441
|
'<td>' + c.agent_count + '</td>' +
|
|
357
442
|
'<td>' + ago + '</td>' +
|
|
358
|
-
'<td style="text-align:right
|
|
443
|
+
'<td style="text-align:right;white-space:nowrap">' +
|
|
444
|
+
'<button class="ghost" data-pair="' + esc(c.id) + '" style="font-size:12px;padding:4px 10px;margin-right:6px" title="Build a phone pair URL + QR for this channel">📱 Pair</button>' +
|
|
445
|
+
'<button class="danger" data-ch="' + esc(c.id) + '">Delete</button>' +
|
|
446
|
+
'</td>' +
|
|
359
447
|
'</tr>';
|
|
360
448
|
}).join('');
|
|
361
|
-
tbody.querySelectorAll('button
|
|
449
|
+
tbody.querySelectorAll('button[data-ch]').forEach(btn => {
|
|
362
450
|
btn.addEventListener('click', () => deleteChannel(btn.dataset.ch));
|
|
363
451
|
});
|
|
452
|
+
tbody.querySelectorAll('button[data-pair]').forEach(btn => {
|
|
453
|
+
btn.addEventListener('click', () => openPairModal(btn.dataset.pair));
|
|
454
|
+
});
|
|
364
455
|
}
|
|
365
456
|
|
|
366
457
|
function fmtAgo(ts) {
|
|
@@ -605,8 +696,19 @@ export function accountHtml() {
|
|
|
605
696
|
lines.push('⚠ The owner_password lets joining peers prove human authorization (unlocks trusted-mode trust). Share out-of-band only with peers you actually invited.');
|
|
606
697
|
}
|
|
607
698
|
$('new-owner-password').value = '';
|
|
699
|
+
// Cache the join_token in this browser session so the "Pair phone" modal
|
|
700
|
+
// on this channel's row auto-fills the token field. sessionStorage clears
|
|
701
|
+
// on tab close, so we don't persist the secret beyond the current visit.
|
|
702
|
+
try { sessionStorage.setItem('rogerrat_chtok_' + data.channel_id, data.join_token); } catch {}
|
|
608
703
|
showReveal('rogerrat-channel-' + data.channel_id + '.txt', lines.join('\\n'));
|
|
609
704
|
loadChannels();
|
|
705
|
+
// Surface the pair-phone action right after creation — the token is in
|
|
706
|
+
// memory, so the modal can pre-fill it without the user re-pasting.
|
|
707
|
+
const pairBtn = $('reveal-pair-btn');
|
|
708
|
+
if (pairBtn) {
|
|
709
|
+
pairBtn.hidden = false;
|
|
710
|
+
pairBtn.onclick = () => openPairModal(data.channel_id, { token: data.join_token });
|
|
711
|
+
}
|
|
610
712
|
} catch (e) {
|
|
611
713
|
$('channel-err').textContent = 'Error: ' + e.message;
|
|
612
714
|
}
|
|
@@ -699,6 +801,93 @@ export function accountHtml() {
|
|
|
699
801
|
}
|
|
700
802
|
}
|
|
701
803
|
|
|
804
|
+
// ─── Pair-phone modal ──────────────────────────────────────────────────
|
|
805
|
+
function openPairModal(channelId, prefill) {
|
|
806
|
+
$('pair-channel').value = channelId;
|
|
807
|
+
// Auto-fill token from this-session cache (set on channel creation), or
|
|
808
|
+
// accept an explicit prefill that wins over the cache.
|
|
809
|
+
let tok = '';
|
|
810
|
+
try { tok = sessionStorage.getItem('rogerrat_chtok_' + channelId) || ''; } catch {}
|
|
811
|
+
if (prefill && prefill.token) tok = prefill.token;
|
|
812
|
+
$('pair-token').value = tok;
|
|
813
|
+
$('pair-key').value = (prefill && prefill.key) || '';
|
|
814
|
+
$('pair-cs').value = (prefill && prefill.cs) || '';
|
|
815
|
+
$('pair-pw').value = (prefill && prefill.pw) || '';
|
|
816
|
+
$('pair-output').hidden = true;
|
|
817
|
+
$('pair-err').hidden = true;
|
|
818
|
+
$('pair-status').textContent = '';
|
|
819
|
+
$('pair-modal').hidden = false;
|
|
820
|
+
// Focus the first empty field so the user can paste immediately
|
|
821
|
+
setTimeout(() => {
|
|
822
|
+
const first = !$('pair-token').value
|
|
823
|
+
? $('pair-token')
|
|
824
|
+
: (!$('pair-key').value && !$('pair-cs').value ? $('pair-key') : null);
|
|
825
|
+
if (first) first.focus();
|
|
826
|
+
}, 30);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function closePairModal() { $('pair-modal').hidden = true; }
|
|
830
|
+
|
|
831
|
+
$('pair-close').addEventListener('click', closePairModal);
|
|
832
|
+
$('pair-modal').addEventListener('click', (e) => {
|
|
833
|
+
// Click outside the panel (on the backdrop) closes the modal.
|
|
834
|
+
if (e.target === $('pair-modal')) closePairModal();
|
|
835
|
+
});
|
|
836
|
+
document.addEventListener('keydown', (e) => {
|
|
837
|
+
if (e.key === 'Escape' && !$('pair-modal').hidden) closePairModal();
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
$('pair-gen').addEventListener('click', () => {
|
|
841
|
+
const channelId = $('pair-channel').value;
|
|
842
|
+
const t = $('pair-token').value.trim();
|
|
843
|
+
const k = $('pair-key').value.trim();
|
|
844
|
+
const cs = $('pair-cs').value.trim();
|
|
845
|
+
const pw = $('pair-pw').value;
|
|
846
|
+
const err = $('pair-err');
|
|
847
|
+
err.hidden = true; err.textContent = '';
|
|
848
|
+
if (!t) { err.hidden = false; err.textContent = 'Channel token is required.'; return; }
|
|
849
|
+
if (!k && !cs) { err.hidden = false; err.textContent = 'Provide either an identity_key or a callsign.'; return; }
|
|
850
|
+
const params = new URLSearchParams();
|
|
851
|
+
params.set('t', t);
|
|
852
|
+
if (k) params.set('k', k);
|
|
853
|
+
if (cs) params.set('cs', cs);
|
|
854
|
+
if (pw) params.set('p', pw);
|
|
855
|
+
const url = location.origin + '/remote/' + encodeURIComponent(channelId) + '#' + params.toString();
|
|
856
|
+
$('pair-url').textContent = url;
|
|
857
|
+
$('pair-open').setAttribute('href', url);
|
|
858
|
+
$('pair-output').hidden = false;
|
|
859
|
+
// Render QR client-side. If the qrcode lib failed to load (offline / CDN
|
|
860
|
+
// blocked), fall back to showing just the URL — the page is still useful.
|
|
861
|
+
const qr = $('pair-qr');
|
|
862
|
+
qr.innerHTML = 'rendering…';
|
|
863
|
+
if (typeof window.QRCode !== 'undefined' && typeof window.QRCode.toString === 'function') {
|
|
864
|
+
window.QRCode.toString(url, { type: 'svg', errorCorrectionLevel: 'M', margin: 1, width: 256 }, (e, svg) => {
|
|
865
|
+
if (e) { qr.textContent = 'QR render failed: ' + (e.message || e); return; }
|
|
866
|
+
qr.innerHTML = svg;
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
869
|
+
qr.textContent = 'QR library failed to load. The URL above carries the same data — copy it to your phone manually.';
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
$('pair-copy').addEventListener('click', () => {
|
|
874
|
+
const url = $('pair-url').textContent;
|
|
875
|
+
if (!url) return;
|
|
876
|
+
const done = () => {
|
|
877
|
+
const b = $('pair-copy');
|
|
878
|
+
const prev = b.textContent;
|
|
879
|
+
b.textContent = '✓ Copied';
|
|
880
|
+
setTimeout(() => { b.textContent = prev; }, 1500);
|
|
881
|
+
};
|
|
882
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
883
|
+
navigator.clipboard.writeText(url).then(done).catch(() => {
|
|
884
|
+
$('pair-status').textContent = 'copy failed — select the URL and copy manually';
|
|
885
|
+
});
|
|
886
|
+
} else {
|
|
887
|
+
$('pair-status').textContent = 'clipboard API unavailable — select the URL and copy manually';
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
|
|
702
891
|
loadAccount();
|
|
703
892
|
</script>
|
|
704
893
|
</body>
|
package/dist/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join as joinPath } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
+
import { streamSSE } from "hono/streaming";
|
|
6
7
|
import { ChannelError, setSessionTtlLookup, startPeriodicGc } from "./channel.js";
|
|
7
8
|
import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
|
|
8
9
|
import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
|
|
@@ -16,7 +17,10 @@ import { agentCard } from "./agentcard.js";
|
|
|
16
17
|
import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
|
|
17
18
|
import { landingHtml } from "./landing.js";
|
|
18
19
|
import { handleMcpRequest } from "./mcp.js";
|
|
20
|
+
import { remoteHtml } from "./remote-ui.js";
|
|
21
|
+
import { createRemoteControl } from "./remote-control.js";
|
|
19
22
|
import { policyHtml, policyText } from "./policy.js";
|
|
23
|
+
import { applyPresetDefaults, getPreset, resolveMode, } from "./presets.js";
|
|
20
24
|
import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
|
|
21
25
|
import { channelExists, createChannel, deleteChannelByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, hasOwnerPassword, listBands, listChannelsByCreator, verifyChannel, verifyOwnerPassword, } from "./store.js";
|
|
22
26
|
import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
|
|
@@ -25,6 +29,16 @@ export function createApp(opts) {
|
|
|
25
29
|
setSessionTtlLookup(getChannelSessionTtlMs);
|
|
26
30
|
startPeriodicGc();
|
|
27
31
|
const app = new Hono();
|
|
32
|
+
// Mode resolution from the Host header. Subdomains like `team.rogerrat.chat`
|
|
33
|
+
// map to preset modes (team/park/live/go); anything else is "default" (the
|
|
34
|
+
// canonical rogerrat.chat, full unfiltered context). Stamped on the context
|
|
35
|
+
// so downstream handlers (channel creation, /llms.txt, MCP tool descriptions,
|
|
36
|
+
// agent_prompt) can adapt.
|
|
37
|
+
app.use("*", async (c, next) => {
|
|
38
|
+
const mode = resolveMode(c.req.header("host"));
|
|
39
|
+
c.set("mode", mode);
|
|
40
|
+
await next();
|
|
41
|
+
});
|
|
28
42
|
app.use("*", async (c, next) => {
|
|
29
43
|
await next();
|
|
30
44
|
c.header("X-Content-Type-Options", "nosniff");
|
|
@@ -83,7 +97,10 @@ export function createApp(opts) {
|
|
|
83
97
|
app.get("/robots.txt", (c) => c.text(`User-agent: *\nDisallow: /admin\nDisallow: /api/\nAllow: /\n\nSitemap: ${opts.publicOrigin}/llms.txt\n`));
|
|
84
98
|
app.get("/api/stats", (c) => c.json(getStats()));
|
|
85
99
|
app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
|
|
86
|
-
app.get("/llms.txt", (c) =>
|
|
100
|
+
app.get("/llms.txt", (c) => {
|
|
101
|
+
const mode = c.get("mode") ?? "default";
|
|
102
|
+
return c.text(llmsText(opts.publicOrigin, mode));
|
|
103
|
+
});
|
|
87
104
|
app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
|
|
88
105
|
app.get("/.well-known/agent.json", (c) => c.json(agentCard(opts.publicOrigin, "1.1.0")));
|
|
89
106
|
// Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
|
|
@@ -91,6 +108,21 @@ export function createApp(opts) {
|
|
|
91
108
|
app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
|
|
92
109
|
app.get("/account", (c) => c.html(accountHtml()));
|
|
93
110
|
app.get("/policy", (c) => c.html(policyHtml(opts.publicOrigin)));
|
|
111
|
+
// Mobile-first remote-control chat. Drives an agent that's already joined
|
|
112
|
+
// the same channel and looping on `wait`. Credentials are passed via the
|
|
113
|
+
// URL fragment (#t=…&k=…&cs=…) — fragment never reaches the server, so the
|
|
114
|
+
// bearer/identity_key don't end up in nginx logs or referrers.
|
|
115
|
+
app.get("/remote/:channelId", (c) => {
|
|
116
|
+
const id = c.req.param("channelId");
|
|
117
|
+
if (!channelExists(id)) {
|
|
118
|
+
return c.html(`<!doctype html><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>` +
|
|
119
|
+
`<title>not found</title><body style="font-family:ui-monospace,Menlo,monospace;background:#f4ede0;padding:24px;color:#1a1a1a">` +
|
|
120
|
+
`<h1 style="font-size:18px">Channel not found</h1>` +
|
|
121
|
+
`<p style="color:#7a6f5f;font-size:14px">No channel <code>${id.replace(/[<>&]/g, "")}</code> on this server. The pair link may be stale or wrong.</p>` +
|
|
122
|
+
`<p><a href="/account" style="color:#d6541f">→ go to /account</a></p></body>`, 404);
|
|
123
|
+
}
|
|
124
|
+
return c.html(remoteHtml(id));
|
|
125
|
+
});
|
|
94
126
|
app.get("/policy.txt", (c) => c.text(policyText(opts.publicOrigin)));
|
|
95
127
|
// ─── Accounts (passwordless, recovery-token based) ───
|
|
96
128
|
function requireSession(c) {
|
|
@@ -295,12 +327,11 @@ export function createApp(opts) {
|
|
|
295
327
|
if (retentionInput !== undefined && !isRetention(retentionInput)) {
|
|
296
328
|
return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
|
|
297
329
|
}
|
|
298
|
-
const
|
|
330
|
+
const requireIdentityInput = body.require_identity;
|
|
299
331
|
const trustModeInput = body.trust_mode;
|
|
300
332
|
if (trustModeInput !== undefined && trustModeInput !== "untrusted" && trustModeInput !== "trusted") {
|
|
301
333
|
return c.json({ error: "invalid trust_mode; must be 'untrusted' or 'trusted'" }, 400);
|
|
302
334
|
}
|
|
303
|
-
const trustMode = trustModeInput === "trusted" ? "trusted" : "untrusted";
|
|
304
335
|
const ownerPasswordInput = body.owner_password;
|
|
305
336
|
let ownerPassword;
|
|
306
337
|
if (ownerPasswordInput !== undefined) {
|
|
@@ -312,12 +343,30 @@ export function createApp(opts) {
|
|
|
312
343
|
ownerPassword = trimmed;
|
|
313
344
|
}
|
|
314
345
|
const sessionTtlSecondsInput = body.session_ttl_seconds;
|
|
315
|
-
let sessionTtlSeconds;
|
|
316
346
|
if (sessionTtlSecondsInput !== undefined) {
|
|
317
347
|
if (typeof sessionTtlSecondsInput !== "number" || !Number.isFinite(sessionTtlSecondsInput)) {
|
|
318
348
|
return c.json({ error: "session_ttl_seconds must be a positive number ≤ 86400 (24h)" }, 400);
|
|
319
349
|
}
|
|
320
|
-
|
|
350
|
+
}
|
|
351
|
+
// Apply preset defaults from the subdomain (mode resolved by the host
|
|
352
|
+
// middleware). Body fields always win — operators with `?preset=` flags
|
|
353
|
+
// disabled or curl users passing explicit values aren't surprised.
|
|
354
|
+
const mode = c.get("mode") ?? "default";
|
|
355
|
+
const presetMerged = applyPresetDefaults(mode, {
|
|
356
|
+
retention: retentionInput,
|
|
357
|
+
require_identity: requireIdentityInput === true ? true : requireIdentityInput === false ? false : undefined,
|
|
358
|
+
trust_mode: trustModeInput,
|
|
359
|
+
session_ttl_seconds: sessionTtlSecondsInput,
|
|
360
|
+
});
|
|
361
|
+
const retention = presetMerged.retention;
|
|
362
|
+
const requireIdentity = presetMerged.require_identity;
|
|
363
|
+
const trustMode = presetMerged.trust_mode;
|
|
364
|
+
const sessionTtlSeconds = presetMerged.session_ttl_seconds;
|
|
365
|
+
// Auto-mint owner_password for presets that opt in (e.g. `go.`): gives
|
|
366
|
+
// "trusted-authorized" trust posture without an identity dance.
|
|
367
|
+
const preset = getPreset(mode);
|
|
368
|
+
if (!ownerPassword && preset?.autoMintOwnerPassword) {
|
|
369
|
+
ownerPassword = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
321
370
|
}
|
|
322
371
|
let creatorAccountId;
|
|
323
372
|
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
@@ -331,7 +380,7 @@ export function createApp(opts) {
|
|
|
331
380
|
}
|
|
332
381
|
}
|
|
333
382
|
const result = createChannel({
|
|
334
|
-
retention
|
|
383
|
+
retention,
|
|
335
384
|
require_identity: requireIdentity,
|
|
336
385
|
trust_mode: trustMode,
|
|
337
386
|
session_ttl_seconds: sessionTtlSeconds,
|
|
@@ -340,13 +389,13 @@ export function createApp(opts) {
|
|
|
340
389
|
});
|
|
341
390
|
if ("error" in result)
|
|
342
391
|
return c.json(result, 400);
|
|
343
|
-
const { id, token, retention, require_identity, trust_mode, session_ttl_seconds, creator_account_id, has_owner_password } = result;
|
|
392
|
+
const { id, token, retention: createdRetention, require_identity: createdRequireIdentity, trust_mode: createdTrustMode, session_ttl_seconds: createdTtl, creator_account_id, has_owner_password, } = result;
|
|
344
393
|
return c.json({
|
|
345
|
-
...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode }),
|
|
346
|
-
retention,
|
|
347
|
-
require_identity,
|
|
348
|
-
trust_mode,
|
|
349
|
-
session_ttl_seconds,
|
|
394
|
+
...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode, mode }),
|
|
395
|
+
retention: createdRetention,
|
|
396
|
+
require_identity: createdRequireIdentity,
|
|
397
|
+
trust_mode: createdTrustMode,
|
|
398
|
+
session_ttl_seconds: createdTtl,
|
|
350
399
|
creator_account_id,
|
|
351
400
|
has_owner_password,
|
|
352
401
|
owner_password: ownerPassword ?? null,
|
|
@@ -362,6 +411,33 @@ export function createApp(opts) {
|
|
|
362
411
|
}));
|
|
363
412
|
return c.json({ channels: channelList });
|
|
364
413
|
});
|
|
414
|
+
// One-shot bootstrap for the "drive the agent from my phone" flow. Creates
|
|
415
|
+
// a private trusted channel + two identities (one for the agent on this
|
|
416
|
+
// machine, one for the phone) and returns a mobile_url with creds in the
|
|
417
|
+
// URL fragment. If the caller passes a session_token, the channel is bound
|
|
418
|
+
// to that account; otherwise a fresh anonymous account is minted (the
|
|
419
|
+
// recovery_token comes back so the caller can keep it if they want).
|
|
420
|
+
app.post("/api/remote-control", async (c) => {
|
|
421
|
+
let body = {};
|
|
422
|
+
try {
|
|
423
|
+
const raw = c.req.header("content-type")?.startsWith("application/json") ? await c.req.json() : {};
|
|
424
|
+
if (raw && typeof raw === "object")
|
|
425
|
+
body = raw;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* body optional */
|
|
429
|
+
}
|
|
430
|
+
const sessionToken = typeof body.session_token === "string" ? body.session_token : undefined;
|
|
431
|
+
const result = await createRemoteControl({ publicOrigin: opts.publicOrigin, sessionToken });
|
|
432
|
+
if ("error" in result) {
|
|
433
|
+
const status = result.code === "unauthorized" ? 401 : 500;
|
|
434
|
+
return c.json({ error: result.error }, status);
|
|
435
|
+
}
|
|
436
|
+
return c.json({
|
|
437
|
+
...result,
|
|
438
|
+
notice: "Two-step phone flow: (1) open mobile_url on the phone; (2) type owner_password on the /remote setup screen to mark the phone session as human-authorized. The password is NOT embedded in mobile_url on purpose — relay it through a separate channel so a leaked URL alone can't impersonate the human. On the agent side (this machine), join with agent.identity_key + owner_password and then loop on `wait`.",
|
|
439
|
+
});
|
|
440
|
+
});
|
|
365
441
|
app.delete("/api/account/channels/:id", (c) => {
|
|
366
442
|
const r = requireSession(c);
|
|
367
443
|
if (r instanceof Response)
|
|
@@ -713,6 +789,138 @@ export function createApp(opts) {
|
|
|
713
789
|
return handleChannelError(c, e);
|
|
714
790
|
}
|
|
715
791
|
});
|
|
792
|
+
// Wait: the canonical idle action. Same semantics as listen but with a longer
|
|
793
|
+
// ceiling (up to 5 min) and an explicit meta_hint nudge on empty returns —
|
|
794
|
+
// designed for agents that should sit waiting for a peer.
|
|
795
|
+
app.get("/api/channels/:id/wait", async (c) => {
|
|
796
|
+
const channelId = c.req.param("id");
|
|
797
|
+
const denied = requireChannelBearer(c, channelId);
|
|
798
|
+
if (denied)
|
|
799
|
+
return denied;
|
|
800
|
+
const sessionId = getSessionId(c);
|
|
801
|
+
if (!sessionId)
|
|
802
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
|
|
803
|
+
const timeoutSec = Math.max(1, Math.min(300, Number(c.req.query("timeout") ?? 120)));
|
|
804
|
+
const sinceRaw = c.req.query("since");
|
|
805
|
+
const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
|
|
806
|
+
if (sinceRaw !== undefined && !Number.isFinite(since)) {
|
|
807
|
+
return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
|
|
808
|
+
}
|
|
809
|
+
const channel = getOrCreateChannel(channelId);
|
|
810
|
+
try {
|
|
811
|
+
const msgs = await channel.listen(sessionId, timeoutSec * 1000, since);
|
|
812
|
+
const empty = msgs.length === 0;
|
|
813
|
+
return c.json({
|
|
814
|
+
messages: msgs,
|
|
815
|
+
timed_out: empty,
|
|
816
|
+
roster: channel.roster(),
|
|
817
|
+
meta_hint: empty
|
|
818
|
+
? "No new messages this round. Call /wait again — keep looping while you're waiting for a peer. If they seem fully offline, send anyway: messages queue per-callsign and deliver on their next listen/wait."
|
|
819
|
+
: `${msgs.length} new message${msgs.length === 1 ? "" : "s"}. After acting, call /wait again to hear replies.`,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
catch (e) {
|
|
823
|
+
return handleChannelError(c, e);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
// Stream: SSE push of incoming messages. Stays open until the client disconnects.
|
|
827
|
+
// Unlike /listen and /wait this is NOT turn-based — designed for `npx rogerrat
|
|
828
|
+
// listen-here` and any always-on consumer that wants zero polling cost. The session
|
|
829
|
+
// stays alive for as long as the connection is held (streamer counts as activity
|
|
830
|
+
// for the GC, so a parked agent with an open stream is never reaped).
|
|
831
|
+
//
|
|
832
|
+
// Query params:
|
|
833
|
+
// - since=<msg_id> resume from a known id (skips per-session cursor)
|
|
834
|
+
//
|
|
835
|
+
// Events emitted:
|
|
836
|
+
// - event: hello once, on connect, with channel metadata
|
|
837
|
+
// - event: message each delivered message (id, from, to, text, at)
|
|
838
|
+
// - event: error typed channel error before close (rare; pre-validated)
|
|
839
|
+
// - :ping comment line every 25s to defeat idle-proxy disconnects
|
|
840
|
+
app.get("/api/channels/:id/stream", (c) => {
|
|
841
|
+
const channelId = c.req.param("id");
|
|
842
|
+
const denied = requireChannelBearer(c, channelId);
|
|
843
|
+
if (denied)
|
|
844
|
+
return denied;
|
|
845
|
+
const sessionId = getSessionId(c);
|
|
846
|
+
if (!sessionId) {
|
|
847
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)", code: "invalid" }, 400);
|
|
848
|
+
}
|
|
849
|
+
const sinceRaw = c.req.query("since");
|
|
850
|
+
const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
|
|
851
|
+
if (sinceRaw !== undefined && !Number.isFinite(since)) {
|
|
852
|
+
return c.json({ error: "since must be a numeric message id", code: "invalid" }, 400);
|
|
853
|
+
}
|
|
854
|
+
const channel = getOrCreateChannel(channelId);
|
|
855
|
+
// Pre-validate session so we can return a real 4xx instead of streaming an error.
|
|
856
|
+
try {
|
|
857
|
+
channel.keepalive(sessionId);
|
|
858
|
+
}
|
|
859
|
+
catch (e) {
|
|
860
|
+
return handleChannelError(c, e);
|
|
861
|
+
}
|
|
862
|
+
const callsign = channel.callsignOf(sessionId);
|
|
863
|
+
return streamSSE(c, async (stream) => {
|
|
864
|
+
const queue = [];
|
|
865
|
+
let waker = null;
|
|
866
|
+
const wake = () => {
|
|
867
|
+
const w = waker;
|
|
868
|
+
waker = null;
|
|
869
|
+
if (w)
|
|
870
|
+
w();
|
|
871
|
+
};
|
|
872
|
+
const detach = channel.addStreamListener(sessionId, (msg) => {
|
|
873
|
+
queue.push(msg);
|
|
874
|
+
wake();
|
|
875
|
+
});
|
|
876
|
+
// Drain backlog AFTER subscribing — both ops are sync so no race window.
|
|
877
|
+
const backlog = channel.drainSince(sessionId, since);
|
|
878
|
+
queue.unshift(...backlog);
|
|
879
|
+
const pingTimer = setInterval(() => {
|
|
880
|
+
stream.write(": ping\n\n").catch(() => { });
|
|
881
|
+
}, 25_000);
|
|
882
|
+
pingTimer.unref?.();
|
|
883
|
+
const abortSignal = c.req.raw.signal;
|
|
884
|
+
const onAbort = () => wake();
|
|
885
|
+
abortSignal.addEventListener("abort", onAbort);
|
|
886
|
+
try {
|
|
887
|
+
await stream.writeSSE({
|
|
888
|
+
event: "hello",
|
|
889
|
+
data: JSON.stringify({
|
|
890
|
+
channel_id: channelId,
|
|
891
|
+
callsign,
|
|
892
|
+
roster: channel.roster(),
|
|
893
|
+
backlog_count: backlog.length,
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
while (!abortSignal.aborted) {
|
|
897
|
+
while (queue.length > 0) {
|
|
898
|
+
const msg = queue.shift();
|
|
899
|
+
await stream.writeSSE({
|
|
900
|
+
event: "message",
|
|
901
|
+
data: JSON.stringify(msg),
|
|
902
|
+
id: String(msg.id),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
if (abortSignal.aborted)
|
|
906
|
+
break;
|
|
907
|
+
await new Promise((resolve) => {
|
|
908
|
+
waker = resolve;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
// Client disconnect surfaces as a write error — silent. Anything else, log.
|
|
914
|
+
if (!abortSignal.aborted)
|
|
915
|
+
console.error(`[stream ${channelId}/${callsign}]`, err);
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
919
|
+
clearInterval(pingTimer);
|
|
920
|
+
detach();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
});
|
|
716
924
|
app.get("/api/channels/:id/stats", (c) => {
|
|
717
925
|
const channelId = c.req.param("id");
|
|
718
926
|
const denied = requireChannelBearer(c, channelId);
|
|
@@ -863,7 +1071,8 @@ export function createApp(opts) {
|
|
|
863
1071
|
return c.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } }, 400);
|
|
864
1072
|
}
|
|
865
1073
|
const sessionId = c.req.header("mcp-session-id") ?? c.req.header("Mcp-Session-Id");
|
|
866
|
-
const
|
|
1074
|
+
const mcpMode = c.get("mode") ?? "default";
|
|
1075
|
+
const result = await handleMcpRequest(channelId, body, sessionId, opts.publicOrigin, mcpMode);
|
|
867
1076
|
if (result.sessionId)
|
|
868
1077
|
c.header("Mcp-Session-Id", result.sessionId);
|
|
869
1078
|
if (result.body === null)
|