agent-yes 1.175.0 → 1.176.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/agent-yes.config.schema.json +2 -2
- package/default.config.yaml +45 -0
- package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
- package/dist/{SUPPORTED_CLIS-DOs4OKwx.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
- package/dist/agentShare-DRfghQz8.js +231 -0
- package/dist/bash-yes.js +10 -0
- package/dist/cli.js +8 -8
- package/dist/cmd-yes.js +10 -0
- package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
- package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-EQPDGq-K.js → notifyDaemon-C08fJ2Ai.js} +5 -5
- package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
- package/dist/powershell-yes.js +10 -0
- package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
- package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
- package/dist/{rustBinary-B5pGAxJj.js → rustBinary-DZTU07SA.js} +2 -2
- package/dist/{schedule-2gVJxKiN.js → schedule-BQtFOk0z.js} +5 -5
- package/dist/{serve-3WOXuTLt.js → serve-B5PQdiOb.js} +143 -76
- package/dist/{setup-CJQPRwIb.js → setup-BCqFNJoB.js} +3 -3
- package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
- package/dist/share-nvqy64A2.js +4 -0
- package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
- package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
- package/dist/{subcommands-CpA7qj-z.js → subcommands-BbSJ_nTU.js} +736 -714
- package/dist/subcommands-BokdtqqC.js +9 -0
- package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
- package/dist/{ts-DRUpMU-r.js → ts-Ce7a44Rj.js} +3 -3
- package/dist/{versionChecker-0OpVlsRu.js → versionChecker-C7C7QAPu.js} +2 -2
- package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
- package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
- package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
- package/lab/ui/index.html +495 -291
- package/lab/ui/qrcode.js +2297 -0
- package/lab/ui/sw.js +2 -1
- package/package.json +8 -2
- package/scripts/build-rgui.ts +78 -0
- package/ts/agentShare.lifecycle.spec.ts +139 -0
- package/ts/agentShare.spec.ts +238 -0
- package/ts/agentShare.ts +330 -0
- package/ts/forkNested.spec.ts +39 -2
- package/ts/index.ts +5 -1
- package/ts/serve.ts +119 -5
- package/ts/share.ts +10 -3
- package/ts/subcommands.ts +29 -0
- package/dist/SUPPORTED_CLIS-7XJNuNWe.js +0 -8
- package/dist/subcommands-CkrKWSp8.js +0 -9
package/lab/ui/index.html
CHANGED
|
@@ -303,6 +303,178 @@
|
|
|
303
303
|
background: var(--line);
|
|
304
304
|
margin: 4px 2px;
|
|
305
305
|
}
|
|
306
|
+
/* View-only share: read-only badge in the header + hiding write controls. */
|
|
307
|
+
.robadge {
|
|
308
|
+
display: none;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 4px;
|
|
311
|
+
font-size: 11px;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
padding: 2px 8px;
|
|
314
|
+
border-radius: 999px;
|
|
315
|
+
color: #b45309;
|
|
316
|
+
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
|
317
|
+
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
|
318
|
+
white-space: nowrap;
|
|
319
|
+
}
|
|
320
|
+
.app.readonly-agent .robadge {
|
|
321
|
+
display: inline-flex;
|
|
322
|
+
}
|
|
323
|
+
/* A viewer can't steer/share — hide the composer, key bar and every agent
|
|
324
|
+
action in the ⋯ menu (Share/Stop/Restart/Kill). View prefs stay. */
|
|
325
|
+
.app.readonly-agent .composer,
|
|
326
|
+
.app.readonly-agent .keybar,
|
|
327
|
+
.app.readonly-agent .rmenuact {
|
|
328
|
+
display: none !important;
|
|
329
|
+
}
|
|
330
|
+
/* Read-write (steer) share: the viewer CAN type, so composer + key bar stay.
|
|
331
|
+
But they aren't granted control/admin, so hide stop/restart/kill/share. */
|
|
332
|
+
.rwbadge {
|
|
333
|
+
display: none;
|
|
334
|
+
align-items: center;
|
|
335
|
+
gap: 4px;
|
|
336
|
+
font-size: 11px;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
padding: 2px 8px;
|
|
339
|
+
border-radius: 999px;
|
|
340
|
+
color: #7c3aed;
|
|
341
|
+
background: color-mix(in srgb, #8b5cf6 18%, transparent);
|
|
342
|
+
border: 1px solid color-mix(in srgb, #8b5cf6 45%, transparent);
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
}
|
|
345
|
+
.app.steer-agent .rwbadge {
|
|
346
|
+
display: inline-flex;
|
|
347
|
+
}
|
|
348
|
+
.app.steer-agent #stopAgent,
|
|
349
|
+
.app.steer-agent #restartAgent,
|
|
350
|
+
.app.steer-agent #killAgent,
|
|
351
|
+
.app.steer-agent #shareAgent,
|
|
352
|
+
.app.steer-agent #shareAgentRW {
|
|
353
|
+
display: none !important;
|
|
354
|
+
}
|
|
355
|
+
/* Share modal — QR + link for a single-agent view-only share. */
|
|
356
|
+
.modal-backdrop {
|
|
357
|
+
position: fixed;
|
|
358
|
+
inset: 0;
|
|
359
|
+
background: rgba(0, 0, 0, 0.55);
|
|
360
|
+
display: flex;
|
|
361
|
+
align-items: center;
|
|
362
|
+
justify-content: center;
|
|
363
|
+
z-index: 60;
|
|
364
|
+
padding: 16px;
|
|
365
|
+
}
|
|
366
|
+
.modal-backdrop[hidden] {
|
|
367
|
+
display: none;
|
|
368
|
+
}
|
|
369
|
+
.sharebox {
|
|
370
|
+
background: var(--bg);
|
|
371
|
+
color: var(--fg);
|
|
372
|
+
border: 1px solid var(--line);
|
|
373
|
+
border-radius: 12px;
|
|
374
|
+
max-width: 380px;
|
|
375
|
+
width: 100%;
|
|
376
|
+
padding: 20px;
|
|
377
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
|
378
|
+
}
|
|
379
|
+
.sharebox h3 {
|
|
380
|
+
margin: 0 0 4px;
|
|
381
|
+
font-size: 15px;
|
|
382
|
+
}
|
|
383
|
+
.sharebox .sub {
|
|
384
|
+
margin: 0 0 14px;
|
|
385
|
+
font-size: 12px;
|
|
386
|
+
opacity: 0.7;
|
|
387
|
+
}
|
|
388
|
+
.sharebox .qr {
|
|
389
|
+
display: flex;
|
|
390
|
+
justify-content: center;
|
|
391
|
+
padding: 12px;
|
|
392
|
+
background: #fff;
|
|
393
|
+
border-radius: 8px;
|
|
394
|
+
margin: 0 auto 14px;
|
|
395
|
+
width: fit-content;
|
|
396
|
+
}
|
|
397
|
+
.sharebox .qr canvas {
|
|
398
|
+
display: block;
|
|
399
|
+
image-rendering: pixelated;
|
|
400
|
+
}
|
|
401
|
+
.sharebox .shareurl {
|
|
402
|
+
width: 100%;
|
|
403
|
+
box-sizing: border-box;
|
|
404
|
+
font: 11px var(--mono);
|
|
405
|
+
padding: 8px;
|
|
406
|
+
border: 1px solid var(--line);
|
|
407
|
+
border-radius: 6px;
|
|
408
|
+
background: var(--bg2, var(--bg));
|
|
409
|
+
color: var(--fg);
|
|
410
|
+
word-break: break-all;
|
|
411
|
+
}
|
|
412
|
+
.sharebox .srow {
|
|
413
|
+
display: flex;
|
|
414
|
+
gap: 8px;
|
|
415
|
+
margin-top: 12px;
|
|
416
|
+
}
|
|
417
|
+
.sharebox button {
|
|
418
|
+
flex: 1;
|
|
419
|
+
padding: 9px;
|
|
420
|
+
border-radius: 8px;
|
|
421
|
+
border: 1px solid var(--line);
|
|
422
|
+
background: var(--bg2, var(--bg));
|
|
423
|
+
color: var(--fg);
|
|
424
|
+
cursor: pointer;
|
|
425
|
+
font-size: 13px;
|
|
426
|
+
}
|
|
427
|
+
.sharebox button.primary {
|
|
428
|
+
background: #2563eb;
|
|
429
|
+
border-color: #2563eb;
|
|
430
|
+
color: #fff;
|
|
431
|
+
}
|
|
432
|
+
.sharebox button.danger {
|
|
433
|
+
color: #dc2626;
|
|
434
|
+
border-color: color-mix(in srgb, #dc2626 45%, transparent);
|
|
435
|
+
}
|
|
436
|
+
.sharebox .note {
|
|
437
|
+
font-size: 11px;
|
|
438
|
+
opacity: 0.65;
|
|
439
|
+
margin-top: 12px;
|
|
440
|
+
line-height: 1.4;
|
|
441
|
+
}
|
|
442
|
+
.sharewarn[hidden] {
|
|
443
|
+
display: none;
|
|
444
|
+
}
|
|
445
|
+
.sharewarn {
|
|
446
|
+
border: 1px solid color-mix(in srgb, #dc2626 55%, transparent);
|
|
447
|
+
background: color-mix(in srgb, #dc2626 12%, transparent);
|
|
448
|
+
border-radius: 10px;
|
|
449
|
+
padding: 14px;
|
|
450
|
+
margin-bottom: 4px;
|
|
451
|
+
}
|
|
452
|
+
.sharewarn .warnhead {
|
|
453
|
+
font-weight: 700;
|
|
454
|
+
color: #dc2626;
|
|
455
|
+
margin-bottom: 8px;
|
|
456
|
+
font-size: 14px;
|
|
457
|
+
}
|
|
458
|
+
.sharewarn p {
|
|
459
|
+
margin: 0 0 8px;
|
|
460
|
+
font-size: 12.5px;
|
|
461
|
+
line-height: 1.45;
|
|
462
|
+
}
|
|
463
|
+
.sharewarn button {
|
|
464
|
+
width: 100%;
|
|
465
|
+
padding: 10px;
|
|
466
|
+
margin-top: 4px;
|
|
467
|
+
border-radius: 8px;
|
|
468
|
+
border: 1px solid #dc2626;
|
|
469
|
+
background: #dc2626;
|
|
470
|
+
color: #fff;
|
|
471
|
+
font-size: 13px;
|
|
472
|
+
font-weight: 600;
|
|
473
|
+
cursor: pointer;
|
|
474
|
+
}
|
|
475
|
+
#shareRevealable[hidden] {
|
|
476
|
+
display: none;
|
|
477
|
+
}
|
|
306
478
|
/* Terminal font-size stepper (a static row, not a hover/click target). */
|
|
307
479
|
.rmenufont {
|
|
308
480
|
justify-content: space-between;
|
|
@@ -1460,6 +1632,10 @@
|
|
|
1460
1632
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
1461
1633
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
1462
1634
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
1635
|
+
<!-- QR generator (MIT, Kazuhiko Arase) — vendored locally so single-agent
|
|
1636
|
+
share QR codes render offline under `ay serve --http`. Classic script:
|
|
1637
|
+
defines the global `qrcode`. -->
|
|
1638
|
+
<script src="./qrcode.js"></script>
|
|
1463
1639
|
<script type="module">
|
|
1464
1640
|
// codehost room transport — vendored from codehost's `bun run build:lib`.
|
|
1465
1641
|
// Loaded as a module (deferred), so the classic script below awaits the
|
|
@@ -1524,6 +1700,12 @@
|
|
|
1524
1700
|
<span class="dot" id="rdot"></span>
|
|
1525
1701
|
<span class="name" id="rname"></span>
|
|
1526
1702
|
<span class="badge" id="rpid"></span>
|
|
1703
|
+
<span class="robadge" title="view-only share — you can watch but not steer this agent"
|
|
1704
|
+
>👁 read-only</span
|
|
1705
|
+
>
|
|
1706
|
+
<span class="rwbadge" title="read-write share — you can type but not stop/restart this agent"
|
|
1707
|
+
>✏️ read-write</span
|
|
1708
|
+
>
|
|
1527
1709
|
<span class="live"
|
|
1528
1710
|
><span class="dot" id="livedot"></span><span id="livetxt">connecting…</span></span
|
|
1529
1711
|
>
|
|
@@ -1549,6 +1731,13 @@
|
|
|
1549
1731
|
><input type="checkbox" id="perfToggle" /> <span>Perf HUD</span></label
|
|
1550
1732
|
>
|
|
1551
1733
|
<div class="rmenusep"></div>
|
|
1734
|
+
<button class="rmenuitem rmenuact" id="shareAgent" type="button">
|
|
1735
|
+
👁 Share (view-only)
|
|
1736
|
+
</button>
|
|
1737
|
+
<button class="rmenuitem rmenuact" id="shareAgentRW" type="button">
|
|
1738
|
+
✏️ Share (read-write)…
|
|
1739
|
+
</button>
|
|
1740
|
+
<div class="rmenusep"></div>
|
|
1552
1741
|
<button class="rmenuitem rmenuact" id="stopAgent" type="button">⏹ Stop agent</button>
|
|
1553
1742
|
<button class="rmenuitem rmenuact" id="restartAgent" type="button">
|
|
1554
1743
|
↻ Restart (resume)
|
|
@@ -1646,6 +1835,52 @@
|
|
|
1646
1835
|
<div class="launchoverlay" id="launch" style="display: none"></div>
|
|
1647
1836
|
<div class="launchoverlay" id="newform" style="display: none"></div>
|
|
1648
1837
|
|
|
1838
|
+
<!-- Single-agent view-only share: QR + link a viewer can open to watch (not
|
|
1839
|
+
steer) just this agent. The link opens a read-only console; the host
|
|
1840
|
+
enforces read-only regardless of the client. -->
|
|
1841
|
+
<div class="modal-backdrop" id="shareModal" hidden>
|
|
1842
|
+
<div class="sharebox" role="dialog" aria-modal="true" aria-label="Share agent">
|
|
1843
|
+
<h3 id="shareTitle">Share this agent</h3>
|
|
1844
|
+
<p class="sub" id="shareSub">view-only · anyone with the link can watch</p>
|
|
1845
|
+
|
|
1846
|
+
<!-- Read-write warning gate: the link is NOT shown until the operator
|
|
1847
|
+
acknowledges that it hands over control. Shown only for rw shares. -->
|
|
1848
|
+
<div class="sharewarn" id="shareWarn" hidden>
|
|
1849
|
+
<div class="warnhead">⚠️ This link lets anyone control this agent</div>
|
|
1850
|
+
<p>
|
|
1851
|
+
Whoever opens this link can <strong>type into this agent</strong> — and because an
|
|
1852
|
+
agent can run commands, that can mean <strong>control of your whole system</strong>.
|
|
1853
|
+
The link isn't a password prompt: no further confirmation is asked.
|
|
1854
|
+
</p>
|
|
1855
|
+
<p><strong>Only share it with someone you completely trust.</strong></p>
|
|
1856
|
+
<button class="danger" id="shareReveal" type="button">
|
|
1857
|
+
I understand — reveal the link
|
|
1858
|
+
</button>
|
|
1859
|
+
</div>
|
|
1860
|
+
|
|
1861
|
+
<!-- Link + QR, hidden behind the warning for rw shares. -->
|
|
1862
|
+
<div id="shareRevealable">
|
|
1863
|
+
<div class="qr" id="shareQr"></div>
|
|
1864
|
+
<div class="shareurl" id="shareUrl"></div>
|
|
1865
|
+
<div class="srow">
|
|
1866
|
+
<button class="primary" id="shareCopy" type="button">Copy link</button>
|
|
1867
|
+
<button id="shareClose" type="button">Close</button>
|
|
1868
|
+
</div>
|
|
1869
|
+
<div class="srow">
|
|
1870
|
+
<button class="danger" id="shareRevoke" type="button">Revoke</button>
|
|
1871
|
+
</div>
|
|
1872
|
+
<p class="note" id="shareNote">
|
|
1873
|
+
View-only: viewers see this agent's terminal (which may include secrets) but cannot
|
|
1874
|
+
type, stop, or restart it. The link expires automatically and stops working when
|
|
1875
|
+
revoked.
|
|
1876
|
+
</p>
|
|
1877
|
+
</div>
|
|
1878
|
+
<div class="srow" id="shareWarnClose" hidden>
|
|
1879
|
+
<button id="shareCloseAlt" type="button">Cancel</button>
|
|
1880
|
+
</div>
|
|
1881
|
+
</div>
|
|
1882
|
+
</div>
|
|
1883
|
+
|
|
1649
1884
|
<!-- Cmd/Ctrl+K omnibox: search agents by title (instant) then output (tail),
|
|
1650
1885
|
or spawn a new agent in the highlighted agent's cwd with the typed prompt. -->
|
|
1651
1886
|
<div class="omni" id="omni" style="display: none">
|
|
@@ -1702,21 +1937,10 @@
|
|
|
1702
1937
|
docTitle,
|
|
1703
1938
|
omniScore,
|
|
1704
1939
|
} from "./console-logic.js";
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
CONFIRM_TIMEOUT_MS,
|
|
1710
|
-
deriveAuthToken,
|
|
1711
|
-
deriveDirKeys,
|
|
1712
|
-
computeTranscriptHash,
|
|
1713
|
-
seal as e2eSeal,
|
|
1714
|
-
open as e2eOpen,
|
|
1715
|
-
packEnvelope,
|
|
1716
|
-
unpackEnvelope,
|
|
1717
|
-
parseSecret,
|
|
1718
|
-
randomHex,
|
|
1719
|
-
} from "./e2e.js";
|
|
1940
|
+
// The ay-share (rtc) remote-room transport lives in a shared module so the
|
|
1941
|
+
// /r/ rgui viewer reuses the exact same WebRTC + e2e wire (rtc.js imports
|
|
1942
|
+
// e2e.js internally). The console only needs the client class.
|
|
1943
|
+
import { RTCClient } from "./rtc.js";
|
|
1720
1944
|
|
|
1721
1945
|
let entries = [];
|
|
1722
1946
|
let sel = null; // selected agent's composite key (room#pid)
|
|
@@ -1806,8 +2030,17 @@
|
|
|
1806
2030
|
});
|
|
1807
2031
|
|
|
1808
2032
|
const $ = (id) => document.getElementById(id);
|
|
2033
|
+
// Escape for HTML — including BOTH quote styles, because esc() is
|
|
2034
|
+
// interpolated into double-quoted attribute values (title="${esc(...)}",
|
|
2035
|
+
// data-key="${esc(...)}", …), not just element text. A remote host controls
|
|
2036
|
+
// agent metadata (OSC terminal title, prompt, cwd) that lands in those
|
|
2037
|
+
// attributes; without escaping " an agent titled `" onfocus=…` breaks out of
|
|
2038
|
+
// the attribute and runs script at this origin (theft of saved room tokens).
|
|
1809
2039
|
const esc = (s) =>
|
|
1810
|
-
String(s ?? "").replace(
|
|
2040
|
+
String(s ?? "").replace(
|
|
2041
|
+
/[&<>"']/g,
|
|
2042
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
|
2043
|
+
);
|
|
1811
2044
|
|
|
1812
2045
|
// ---- transport ---------------------------------------------------------
|
|
1813
2046
|
// Local: same-origin fetch + EventSource (via the ay-serve proxy).
|
|
@@ -1815,278 +2048,11 @@
|
|
|
1815
2048
|
// `ay serve --share` — established through the signaling server. Same call
|
|
1816
2049
|
// sites, two wires. Remote is selected by a URL hash: #room:token[@sighost].
|
|
1817
2050
|
const SIG_DEFAULT = "s.agent-yes.com"; // signaling host (override in the hash with @host)
|
|
1818
|
-
const SUB = "ay-signal-1";
|
|
1819
2051
|
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
class
|
|
1823
|
-
|
|
1824
|
-
let resolveKeys;
|
|
1825
|
-
const keysReady = new Promise((r) => (resolveKeys = r));
|
|
1826
|
-
Object.assign(this, {
|
|
1827
|
-
host,
|
|
1828
|
-
room,
|
|
1829
|
-
token,
|
|
1830
|
-
dc: null,
|
|
1831
|
-
calls: new Map(),
|
|
1832
|
-
streams: new Map(),
|
|
1833
|
-
onstate: () => {},
|
|
1834
|
-
// e2e (v2) per-connection state
|
|
1835
|
-
_s: null,
|
|
1836
|
-
_v2: false,
|
|
1837
|
-
_send: { sendCtr: 0n },
|
|
1838
|
-
_recvState: { lastSeen: -1n },
|
|
1839
|
-
_tHash: null,
|
|
1840
|
-
_keyH2C: null, // host->client: client decrypts with this
|
|
1841
|
-
_keyC2H: null, // client->host: client encrypts with this
|
|
1842
|
-
_keysReady: keysReady,
|
|
1843
|
-
_resolveKeys: resolveKeys,
|
|
1844
|
-
_myNonce: randomHex(16),
|
|
1845
|
-
_confirmedIn: false,
|
|
1846
|
-
_confirmedOut: false,
|
|
1847
|
-
_confirmed: false,
|
|
1848
|
-
_confirmTimer: null,
|
|
1849
|
-
_recvChain: Promise.resolve(), // serialize decrypts (ordered replay check)
|
|
1850
|
-
_sendChain: Promise.resolve(), // serialize seals (wire order == counter order)
|
|
1851
|
-
});
|
|
1852
|
-
}
|
|
1853
|
-
async connect() {
|
|
1854
|
-
// Parse the secret marker (fail-closed on malformed) and split into the
|
|
1855
|
-
// server-visible authToken vs the end-to-end keys the server never sees.
|
|
1856
|
-
const { s, v2 } = parseSecret(this.token);
|
|
1857
|
-
this._s = s;
|
|
1858
|
-
this._v2 = v2;
|
|
1859
|
-
if (!v2 && !ALLOW_LEGACY_PLAINTEXT)
|
|
1860
|
-
throw new Error(
|
|
1861
|
-
"this link uses the old unencrypted protocol — ask the host to upgrade",
|
|
1862
|
-
);
|
|
1863
|
-
const authToken = v2 ? await deriveAuthToken(s, this.room, this.host) : this.token;
|
|
1864
|
-
return new Promise((resolve, reject) => {
|
|
1865
|
-
const ws = new WebSocket(`wss://${this.host}/${this.room}`, [SUB]);
|
|
1866
|
-
this.ws = ws; // kept so close() can drop the signaling registration too
|
|
1867
|
-
let pc,
|
|
1868
|
-
settled = false;
|
|
1869
|
-
const fail = (e) => {
|
|
1870
|
-
if (!settled) {
|
|
1871
|
-
settled = true;
|
|
1872
|
-
reject(e);
|
|
1873
|
-
}
|
|
1874
|
-
};
|
|
1875
|
-
// Resolve ONLY after the mutual key-confirmation completes (see _dcRecv),
|
|
1876
|
-
// never on bare dc.onopen — there must be no "connected but unverified" window.
|
|
1877
|
-
const done = () => {
|
|
1878
|
-
if (!settled) {
|
|
1879
|
-
settled = true;
|
|
1880
|
-
this.onstate("open");
|
|
1881
|
-
resolve();
|
|
1882
|
-
}
|
|
1883
|
-
};
|
|
1884
|
-
ws.onopen = () =>
|
|
1885
|
-
ws.send(JSON.stringify({ type: "hello", role: "client", v: 2, token: authToken }));
|
|
1886
|
-
ws.onmessage = async (ev) => {
|
|
1887
|
-
const m = JSON.parse(ev.data);
|
|
1888
|
-
if (m.type === "welcome") {
|
|
1889
|
-
if (this._v2 && m.v !== 2)
|
|
1890
|
-
return fail(new Error("host is running an old agent-yes — ask it to upgrade"));
|
|
1891
|
-
// pc is created on the offer below so it can use the host-supplied
|
|
1892
|
-
// iceServers (incl. short-lived TURN creds for relaying behind NAT).
|
|
1893
|
-
} else if (m.type === "offer") {
|
|
1894
|
-
if (!pc) {
|
|
1895
|
-
pc = new RTCPeerConnection({
|
|
1896
|
-
iceServers:
|
|
1897
|
-
m.iceServers && m.iceServers.length
|
|
1898
|
-
? m.iceServers
|
|
1899
|
-
: [{ urls: "stun:stun.l.google.com:19302" }],
|
|
1900
|
-
});
|
|
1901
|
-
this.pc = pc;
|
|
1902
|
-
pc.onicecandidate = (e) => {
|
|
1903
|
-
if (e.candidate)
|
|
1904
|
-
ws.send(JSON.stringify({ type: "candidate", candidate: e.candidate }));
|
|
1905
|
-
};
|
|
1906
|
-
pc.onconnectionstatechange = () => this.onstate(pc.connectionState);
|
|
1907
|
-
pc.ondatachannel = (e) => {
|
|
1908
|
-
this.dc = e.channel;
|
|
1909
|
-
this.dc.binaryType = "arraybuffer";
|
|
1910
|
-
this.dc.onopen = async () => {
|
|
1911
|
-
try {
|
|
1912
|
-
await this._keysReady;
|
|
1913
|
-
// Open the bidirectional confirmation handshake.
|
|
1914
|
-
this._dcSend(FLAG_CONFIRM, { t: "confirm", nonce: this._myNonce });
|
|
1915
|
-
this._confirmTimer = setTimeout(() => {
|
|
1916
|
-
if (!this._confirmed) fail(new Error("key confirmation timed out"));
|
|
1917
|
-
}, CONFIRM_TIMEOUT_MS);
|
|
1918
|
-
} catch (err) {
|
|
1919
|
-
fail(err);
|
|
1920
|
-
}
|
|
1921
|
-
};
|
|
1922
|
-
this.dc.onmessage = (ev2) => {
|
|
1923
|
-
this._recvChain = this._recvChain
|
|
1924
|
-
.then(() => this._dcRecv(ev2.data, done))
|
|
1925
|
-
.catch(() => {});
|
|
1926
|
-
};
|
|
1927
|
-
this.dc.onclose = () => this.onstate("closed");
|
|
1928
|
-
};
|
|
1929
|
-
}
|
|
1930
|
-
await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
|
|
1931
|
-
await pc.setLocalDescription(await pc.createAnswer());
|
|
1932
|
-
ws.send(JSON.stringify({ type: "answer", sdp: pc.localDescription.sdp }));
|
|
1933
|
-
// Derive per-connection keys now both descriptions are stable, before
|
|
1934
|
-
// the DataChannel opens. Client: remote=host offer, local=our answer.
|
|
1935
|
-
try {
|
|
1936
|
-
this._tHash = await computeTranscriptHash(
|
|
1937
|
-
pc.remoteDescription.sdp,
|
|
1938
|
-
pc.localDescription.sdp,
|
|
1939
|
-
);
|
|
1940
|
-
const { keyH2C, keyC2H } = await deriveDirKeys(this._s, this._tHash);
|
|
1941
|
-
this._keyH2C = keyH2C;
|
|
1942
|
-
this._keyC2H = keyC2H;
|
|
1943
|
-
this._resolveKeys();
|
|
1944
|
-
} catch (err) {
|
|
1945
|
-
fail(err);
|
|
1946
|
-
}
|
|
1947
|
-
} else if (m.type === "candidate") {
|
|
1948
|
-
if (pc) await pc.addIceCandidate(m.candidate).catch(() => {});
|
|
1949
|
-
}
|
|
1950
|
-
};
|
|
1951
|
-
ws.onerror = () => fail(new Error("signaling error"));
|
|
1952
|
-
ws.onclose = () => fail(new Error("signaling closed"));
|
|
1953
|
-
setTimeout(() => fail(new Error("connect timeout")), 8000);
|
|
1954
|
-
});
|
|
1955
|
-
}
|
|
1956
|
-
// Seal an envelope and send it, serialized so wire order == counter order.
|
|
1957
|
-
_dcSend(flags, obj) {
|
|
1958
|
-
this._sendChain = this._sendChain.then(async () => {
|
|
1959
|
-
if (!this.dc || this.dc.readyState !== "open" || !this._keyC2H || !this._tHash) return;
|
|
1960
|
-
let frame;
|
|
1961
|
-
try {
|
|
1962
|
-
frame = await e2eSeal(
|
|
1963
|
-
this._keyC2H,
|
|
1964
|
-
this._send,
|
|
1965
|
-
flags,
|
|
1966
|
-
this._tHash,
|
|
1967
|
-
packEnvelope(obj),
|
|
1968
|
-
);
|
|
1969
|
-
} catch {
|
|
1970
|
-
this.close();
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
try {
|
|
1974
|
-
this.dc.send(frame);
|
|
1975
|
-
} catch {}
|
|
1976
|
-
});
|
|
1977
|
-
return this._sendChain;
|
|
1978
|
-
}
|
|
1979
|
-
// Decrypt + route one frame. Fail-closed: any failure, replay, string
|
|
1980
|
-
// frame, or pre-confirmation app frame closes the connection.
|
|
1981
|
-
async _dcRecv(data, done) {
|
|
1982
|
-
if (!this.dc) return;
|
|
1983
|
-
if (typeof data === "string" || !this._keyH2C || !this._tHash) return this.close();
|
|
1984
|
-
let env;
|
|
1985
|
-
try {
|
|
1986
|
-
const { plaintext } = await e2eOpen(this._keyH2C, data, this._tHash, this._recvState);
|
|
1987
|
-
env = unpackEnvelope(plaintext);
|
|
1988
|
-
} catch {
|
|
1989
|
-
return this.close();
|
|
1990
|
-
}
|
|
1991
|
-
if (!this._confirmed) {
|
|
1992
|
-
if (!env || env.t !== "confirm") return this.close();
|
|
1993
|
-
if (typeof env.nonce === "string" && !this._confirmedOut) {
|
|
1994
|
-
// Send (and flush) our echo BEFORE marking confirmed-out, so connect()
|
|
1995
|
-
// can't resolve and let a req() race ahead of the echo on the wire.
|
|
1996
|
-
await this._dcSend(FLAG_CONFIRM, {
|
|
1997
|
-
t: "confirm",
|
|
1998
|
-
nonce: this._myNonce,
|
|
1999
|
-
echo: env.nonce,
|
|
2000
|
-
});
|
|
2001
|
-
this._confirmedOut = true;
|
|
2002
|
-
}
|
|
2003
|
-
if (env.echo && env.echo === this._myNonce) this._confirmedIn = true;
|
|
2004
|
-
if (this._confirmedIn && this._confirmedOut) {
|
|
2005
|
-
this._confirmed = true;
|
|
2006
|
-
if (this._confirmTimer) clearTimeout(this._confirmTimer);
|
|
2007
|
-
done(); // connect() resolves only now — the channel is mutually verified
|
|
2008
|
-
}
|
|
2009
|
-
return;
|
|
2010
|
-
}
|
|
2011
|
-
if (!env || env.t === "confirm") return; // stray confirm after handshake
|
|
2012
|
-
this._recv(env);
|
|
2013
|
-
}
|
|
2014
|
-
_recv(r) {
|
|
2015
|
-
// Frames are authenticated + replay-checked before they reach here, so an
|
|
2016
|
-
// id we don't know is a late/cancelled response from the legitimate host,
|
|
2017
|
-
// not an attack — drop it rather than tear down the channel.
|
|
2018
|
-
const call = this.calls.get(r.id),
|
|
2019
|
-
stream = this.streams.get(r.id);
|
|
2020
|
-
if (r.t === "res") {
|
|
2021
|
-
if (call) call.status = r.status;
|
|
2022
|
-
} else if (r.t === "data") {
|
|
2023
|
-
if (call) {
|
|
2024
|
-
call.body += r.chunk;
|
|
2025
|
-
call.dataCount = (call.dataCount || 0) + 1;
|
|
2026
|
-
}
|
|
2027
|
-
if (stream) stream(r.chunk);
|
|
2028
|
-
} else if (r.t === "end") {
|
|
2029
|
-
if (call) {
|
|
2030
|
-
clearTimeout(call.timer);
|
|
2031
|
-
this.calls.delete(r.id);
|
|
2032
|
-
// end.seq is the count of data frames the host sent; a mismatch means
|
|
2033
|
-
// the stream was truncated, so don't resolve it as complete.
|
|
2034
|
-
if (typeof r.seq === "number" && r.seq !== (call.dataCount || 0))
|
|
2035
|
-
call.reject(new Error("truncated response"));
|
|
2036
|
-
else if (r.error) call.reject(new Error(r.error));
|
|
2037
|
-
else call.resolve({ status: call.status, text: call.body });
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
req(method, path, body) {
|
|
2042
|
-
const id = randomHex(16);
|
|
2043
|
-
return new Promise((resolve, reject) => {
|
|
2044
|
-
// Without a deadline a request over a silently-dead DataChannel (host
|
|
2045
|
-
// gone, ICE not yet timed out) never settles, so the caller — and the
|
|
2046
|
-
// poll loop — hangs forever and the room never reconnects. Reject on a
|
|
2047
|
-
// timeout so listSource sees the failure and triggers backoff.
|
|
2048
|
-
const timer = setTimeout(() => {
|
|
2049
|
-
if (this.calls.delete(id)) reject(new Error("request timed out"));
|
|
2050
|
-
}, 12000);
|
|
2051
|
-
this.calls.set(id, { status: 0, body: "", dataCount: 0, resolve, reject, timer });
|
|
2052
|
-
this._dcSend(0, { t: "req", id, method, path, body }).catch((e) => {
|
|
2053
|
-
clearTimeout(timer);
|
|
2054
|
-
this.calls.delete(id);
|
|
2055
|
-
reject(e); // channel already torn down
|
|
2056
|
-
});
|
|
2057
|
-
});
|
|
2058
|
-
}
|
|
2059
|
-
subscribe(path, onRaw) {
|
|
2060
|
-
const id = randomHex(16);
|
|
2061
|
-
this.streams.set(id, onRaw);
|
|
2062
|
-
this._dcSend(0, { t: "req", id, method: "GET", path });
|
|
2063
|
-
return () => {
|
|
2064
|
-
this.streams.delete(id);
|
|
2065
|
-
this._dcSend(0, { t: "abort", id });
|
|
2066
|
-
};
|
|
2067
|
-
}
|
|
2068
|
-
// Tear down BOTH wires. Closing only the pc leaves the signaling socket
|
|
2069
|
-
// open and this client registered in the room, so each reconnect would
|
|
2070
|
-
// leak another peer on the host. onstate is detached by the caller first.
|
|
2071
|
-
close() {
|
|
2072
|
-
if (this._confirmTimer) clearTimeout(this._confirmTimer);
|
|
2073
|
-
try {
|
|
2074
|
-
this.ws?.close();
|
|
2075
|
-
} catch {}
|
|
2076
|
-
try {
|
|
2077
|
-
this.pc?.close();
|
|
2078
|
-
} catch {}
|
|
2079
|
-
this.dc = null;
|
|
2080
|
-
// Settle anything still in flight now, rather than letting each req's
|
|
2081
|
-
// 12s timeout fire long after the client is gone.
|
|
2082
|
-
for (const c of this.calls.values()) {
|
|
2083
|
-
clearTimeout(c.timer);
|
|
2084
|
-
c.reject(new Error("connection closed"));
|
|
2085
|
-
}
|
|
2086
|
-
this.calls.clear();
|
|
2087
|
-
this.streams.clear();
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2052
|
+
// The RTCClient (ay-share rtc wire) now lives in the shared ./rtc.js module
|
|
2053
|
+
// — imported above and reused by the /r/ rgui viewer. SUB (subprotocol) and
|
|
2054
|
+
// the client class moved there; SIG_DEFAULT stays here (used by saveRoom /
|
|
2055
|
+
// addRoomSource / the header pill below).
|
|
2090
2056
|
|
|
2091
2057
|
// ---- codehost rooms: a THIRD wire -----------------------------------
|
|
2092
2058
|
// A codehost room can hold several machines: every `codehost serve`
|
|
@@ -2269,6 +2235,10 @@
|
|
|
2269
2235
|
});
|
|
2270
2236
|
return { ok: r.ok, text: await r.text() };
|
|
2271
2237
|
},
|
|
2238
|
+
async del(path) {
|
|
2239
|
+
const r = await fetch(withTok(path), { method: "DELETE" });
|
|
2240
|
+
return { ok: r.ok, text: await r.text() };
|
|
2241
|
+
},
|
|
2272
2242
|
subscribe(path, onText, onOpen, onError) {
|
|
2273
2243
|
const ev = new EventSource(withTok(path));
|
|
2274
2244
|
ev.onopen = () => onOpen && onOpen();
|
|
@@ -2289,6 +2259,10 @@
|
|
|
2289
2259
|
const r = await rtc.req("POST", path, JSON.stringify(bodyObj));
|
|
2290
2260
|
return { ok: r.status >= 200 && r.status < 300, text: r.text };
|
|
2291
2261
|
},
|
|
2262
|
+
async del(path) {
|
|
2263
|
+
const r = await rtc.req("DELETE", path);
|
|
2264
|
+
return { ok: r.status >= 200 && r.status < 300, text: r.text };
|
|
2265
|
+
},
|
|
2292
2266
|
subscribe(path, onText, onOpen, onError) {
|
|
2293
2267
|
onOpen && onOpen();
|
|
2294
2268
|
let buf = "";
|
|
@@ -2318,6 +2292,26 @@
|
|
|
2318
2292
|
const sources = new Map(); // id -> { id, host, kind, tx, client, live, devices, serverCount }
|
|
2319
2293
|
const srcFor = (e) => (e && sources.get(e._room)) || sources.get(LOCAL) || null;
|
|
2320
2294
|
const txFor = (e) => srcFor(e)?.tx || localTx;
|
|
2295
|
+
// True when the agent lives in a view-only share room (the host advertises
|
|
2296
|
+
// this via /api/whoami → share.readonly and 403s every write regardless).
|
|
2297
|
+
const isReadonlyEnt = (e) => !!srcFor(e)?.readonly;
|
|
2298
|
+
// Wrap a source tx so every write (POST) is refused client-side on a
|
|
2299
|
+
// read-only share — reads (fetchJSON/fetchText/subscribe) pass through. The
|
|
2300
|
+
// host is the real boundary; this just stops the UI from firing doomed
|
|
2301
|
+
// writes (send / resize / presence / kill / restart / share).
|
|
2302
|
+
function readonlyTx(tx) {
|
|
2303
|
+
if (!tx || tx._ro) return tx;
|
|
2304
|
+
return {
|
|
2305
|
+
...tx,
|
|
2306
|
+
_ro: true,
|
|
2307
|
+
async post() {
|
|
2308
|
+
return { ok: false, text: "read-only share" };
|
|
2309
|
+
},
|
|
2310
|
+
async del() {
|
|
2311
|
+
return { ok: false, text: "read-only share" };
|
|
2312
|
+
},
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2321
2315
|
|
|
2322
2316
|
// ---- connection-path classification (header [local/lan/wan/relay] pill) ----
|
|
2323
2317
|
// Tells the user, at a glance, whether the terminal they're watching rides the
|
|
@@ -3083,6 +3077,159 @@
|
|
|
3083
3077
|
alert(`Restart failed for ${who}:\n${(res && res.text) || "request failed"}`);
|
|
3084
3078
|
}
|
|
3085
3079
|
|
|
3080
|
+
// ---- Single-agent view-only share (QR + link) -----------------------
|
|
3081
|
+
// Ask THIS agent's host to mint a fresh, scoped, read-only share room and
|
|
3082
|
+
// show its link + QR. The host stands up an isolated e2ee room exposing
|
|
3083
|
+
// only this agent (read-only) — see ts/agentShare.ts. Never reuses the
|
|
3084
|
+
// fleet/master token, so the link can't reach other agents or steer.
|
|
3085
|
+
let currentShare = null; // { shareId, link, perm } of the share shown in the modal
|
|
3086
|
+
let pendingRw = null; // the agent awaiting a read-write warning acknowledgement
|
|
3087
|
+
// perm: "r" (view-only) or "rw" (read-write / steer). A read-write link hands
|
|
3088
|
+
// over keystroke control of the agent — effectively control of the host — so
|
|
3089
|
+
// it is NOT even minted until the operator clicks through the warning.
|
|
3090
|
+
async function shareAgent(perm, ent) {
|
|
3091
|
+
const e = ent || (sel ? entries.find((x) => x._key === sel) : null);
|
|
3092
|
+
if (!e) return;
|
|
3093
|
+
// A shared viewer can't re-share; its tx 403s /api/share anyway.
|
|
3094
|
+
if (isReadonlyEnt(e) || sharePermOf(e)) {
|
|
3095
|
+
alert("This is a shared view — you can't re-share it from here.");
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
if (perm === "rw") {
|
|
3099
|
+
// Show the warning FIRST; mint only after "reveal" (revealRwShare).
|
|
3100
|
+
pendingRw = e;
|
|
3101
|
+
openShareModal(null, e, "rw");
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
const info = await mintShare(e, "r");
|
|
3105
|
+
if (info) openShareModal(info, e, "r");
|
|
3106
|
+
}
|
|
3107
|
+
async function mintShare(e, perm) {
|
|
3108
|
+
const agentRef = e.agent_id || String(e.pid); // stable id; fall back to pid
|
|
3109
|
+
const res = await txFor(e)
|
|
3110
|
+
.post("/api/share", { agent: agentRef, perm })
|
|
3111
|
+
.catch((err) => ({ ok: false, text: String(err?.message || err) }));
|
|
3112
|
+
if (!res || !res.ok) {
|
|
3113
|
+
alert(`Couldn't create share link:\n${(res && res.text) || "request failed"}`);
|
|
3114
|
+
return null;
|
|
3115
|
+
}
|
|
3116
|
+
let info;
|
|
3117
|
+
try {
|
|
3118
|
+
info = JSON.parse(res.text);
|
|
3119
|
+
} catch {
|
|
3120
|
+
alert("Share created but the response was unreadable.");
|
|
3121
|
+
return null;
|
|
3122
|
+
}
|
|
3123
|
+
currentShare = { shareId: info.shareId, link: info.link, srcRoom: e._room, perm };
|
|
3124
|
+
return info;
|
|
3125
|
+
}
|
|
3126
|
+
// "I understand — reveal": now that the operator has read the warning, mint
|
|
3127
|
+
// the read-write share and reveal its URL/QR.
|
|
3128
|
+
async function revealRwShare() {
|
|
3129
|
+
const e = pendingRw;
|
|
3130
|
+
if (!e) return;
|
|
3131
|
+
pendingRw = null;
|
|
3132
|
+
const info = await mintShare(e, "rw");
|
|
3133
|
+
if (!info) return closeShareModal();
|
|
3134
|
+
$("shareUrl").textContent = info.link;
|
|
3135
|
+
renderQr($("shareQr"), info.link);
|
|
3136
|
+
$("shareWarn").hidden = true;
|
|
3137
|
+
$("shareWarnClose").hidden = true;
|
|
3138
|
+
$("shareRevealable").hidden = false;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function openShareModal(info, e, perm) {
|
|
3142
|
+
const modal = $("shareModal");
|
|
3143
|
+
if (!modal) return;
|
|
3144
|
+
const who = e.title || cliLabel(e) || ident(e) || `pid ${e.pid}`;
|
|
3145
|
+
const rw = perm === "rw";
|
|
3146
|
+
$("shareTitle").textContent = rw ? "Share control of this agent" : "Share this agent";
|
|
3147
|
+
$("shareSub").textContent = `${rw ? "read-write · can type" : "view-only · can watch"} · ${who}`;
|
|
3148
|
+
$("shareNote").textContent = rw
|
|
3149
|
+
? "Read-write: viewers can type into this agent (interrupt, run commands). They cannot stop or restart it. The link expires and stops working when revoked."
|
|
3150
|
+
: "View-only: viewers see this agent's terminal (which may include secrets) but cannot type, stop, or restart it. The link expires and stops working when revoked.";
|
|
3151
|
+
// Read-write opens in the warning state with NO link/QR shown or rendered
|
|
3152
|
+
// yet; view-only shows the link immediately (the milder note suffices).
|
|
3153
|
+
$("shareWarn").hidden = !rw;
|
|
3154
|
+
$("shareWarnClose").hidden = !rw;
|
|
3155
|
+
$("shareRevealable").hidden = rw;
|
|
3156
|
+
if (!rw && info) {
|
|
3157
|
+
$("shareUrl").textContent = info.link;
|
|
3158
|
+
renderQr($("shareQr"), info.link);
|
|
3159
|
+
}
|
|
3160
|
+
modal.hidden = false;
|
|
3161
|
+
}
|
|
3162
|
+
// The perm a shared SOURCE was granted (from whoami), or null when we're the
|
|
3163
|
+
// owner. Used to hide re-share/admin controls a viewer isn't allowed.
|
|
3164
|
+
function sharePermOf(e) {
|
|
3165
|
+
return srcFor(e)?.sharePerm || null;
|
|
3166
|
+
}
|
|
3167
|
+
function closeShareModal() {
|
|
3168
|
+
const modal = $("shareModal");
|
|
3169
|
+
if (modal) modal.hidden = true;
|
|
3170
|
+
pendingRw = null; // a cancelled read-write flow never minted anything
|
|
3171
|
+
}
|
|
3172
|
+
// Draw the QR onto a fresh canvas (white quiet-zone always, so it scans in
|
|
3173
|
+
// dark mode too). `qrcode` is the vendored global from qrcode.js.
|
|
3174
|
+
function renderQr(container, text) {
|
|
3175
|
+
container.textContent = "";
|
|
3176
|
+
try {
|
|
3177
|
+
const qr = qrcode(0, "M"); // type 0 = smallest version that fits; EC level M
|
|
3178
|
+
qr.addData(text);
|
|
3179
|
+
qr.make();
|
|
3180
|
+
const n = qr.getModuleCount();
|
|
3181
|
+
const cell = 5,
|
|
3182
|
+
margin = 4;
|
|
3183
|
+
const px = (n + margin * 2) * cell;
|
|
3184
|
+
const cv = document.createElement("canvas");
|
|
3185
|
+
cv.width = cv.height = px;
|
|
3186
|
+
const ctx = cv.getContext("2d");
|
|
3187
|
+
ctx.fillStyle = "#fff";
|
|
3188
|
+
ctx.fillRect(0, 0, px, px);
|
|
3189
|
+
ctx.fillStyle = "#000";
|
|
3190
|
+
for (let r = 0; r < n; r++)
|
|
3191
|
+
for (let c = 0; c < n; c++)
|
|
3192
|
+
if (qr.isDark(r, c))
|
|
3193
|
+
ctx.fillRect((c + margin) * cell, (r + margin) * cell, cell, cell);
|
|
3194
|
+
container.appendChild(cv);
|
|
3195
|
+
} catch (err) {
|
|
3196
|
+
container.textContent = "QR unavailable";
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
async function revokeCurrentShare() {
|
|
3200
|
+
if (!currentShare) return closeShareModal();
|
|
3201
|
+
// DELETE over whichever transport owns the agent's host (local or the
|
|
3202
|
+
// room the agent lives in) — the same host that minted the share.
|
|
3203
|
+
const e = entries.find((x) => x._room === currentShare.srcRoom) || null;
|
|
3204
|
+
const tx = (e ? srcFor(e)?.tx : null) || localTx;
|
|
3205
|
+
try {
|
|
3206
|
+
await tx.del("/api/share/" + encodeURIComponent(currentShare.shareId));
|
|
3207
|
+
} catch {}
|
|
3208
|
+
currentShare = null;
|
|
3209
|
+
closeShareModal();
|
|
3210
|
+
}
|
|
3211
|
+
(function shareModalBoot() {
|
|
3212
|
+
$("shareClose")?.addEventListener("click", closeShareModal);
|
|
3213
|
+
$("shareCloseAlt")?.addEventListener("click", closeShareModal);
|
|
3214
|
+
$("shareReveal")?.addEventListener("click", revealRwShare);
|
|
3215
|
+
$("shareRevoke")?.addEventListener("click", revokeCurrentShare);
|
|
3216
|
+
$("shareCopy")?.addEventListener("click", async () => {
|
|
3217
|
+
if (!currentShare) return;
|
|
3218
|
+
try {
|
|
3219
|
+
await navigator.clipboard.writeText(currentShare.link);
|
|
3220
|
+
const b = $("shareCopy");
|
|
3221
|
+
const t = b.textContent;
|
|
3222
|
+
b.textContent = "Copied ✓";
|
|
3223
|
+
setTimeout(() => (b.textContent = t), 1200);
|
|
3224
|
+
} catch {
|
|
3225
|
+
alert("Copy failed — select the link manually.");
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3228
|
+
$("shareModal")?.addEventListener("click", (ev) => {
|
|
3229
|
+
if (ev.target === $("shareModal")) closeShareModal(); // backdrop click
|
|
3230
|
+
});
|
|
3231
|
+
})();
|
|
3232
|
+
|
|
3086
3233
|
// ---- Terminal right-click menu: copy + customizable quick commands ----
|
|
3087
3234
|
// A right-click on the terminal used to copy the selection outright. Now it
|
|
3088
3235
|
// opens a small menu: Copy, Paste, and user-defined quick commands. A quick
|
|
@@ -3477,6 +3624,8 @@
|
|
|
3477
3624
|
});
|
|
3478
3625
|
}
|
|
3479
3626
|
if (cb) cb.addEventListener("change", () => setPerfHud(cb.checked));
|
|
3627
|
+
$("shareAgent")?.addEventListener("click", () => (closePanel(), shareAgent("r")));
|
|
3628
|
+
$("shareAgentRW")?.addEventListener("click", () => (closePanel(), shareAgent("rw")));
|
|
3480
3629
|
$("stopAgent")?.addEventListener("click", () => (closePanel(), stopAgent(false)));
|
|
3481
3630
|
$("restartAgent")?.addEventListener("click", () => (closePanel(), restartAgent()));
|
|
3482
3631
|
$("killAgent")?.addEventListener("click", () => (closePanel(), stopAgent(true)));
|
|
@@ -3969,6 +4118,13 @@
|
|
|
3969
4118
|
// Mobile: flip the single-column layout to the terminal ("detail") pane.
|
|
3970
4119
|
// Done BEFORE term.open below so xterm measures a visible container.
|
|
3971
4120
|
document.querySelector(".app").classList.add("show-detail");
|
|
4121
|
+
// Shared-view gating (host-enforced; this is UX):
|
|
4122
|
+
// • view-only (r) → readonly-agent: hide composer/keybar + ALL agent actions.
|
|
4123
|
+
// • read-write (rw) → steer-agent: keep composer/keybar (can type), but hide
|
|
4124
|
+
// the admin actions (stop/restart/kill/share) a viewer isn't granted.
|
|
4125
|
+
const _perm = sharePermOf(e);
|
|
4126
|
+
document.querySelector(".app").classList.toggle("readonly-agent", isReadonlyEnt(e));
|
|
4127
|
+
document.querySelector(".app").classList.toggle("steer-agent", _perm === "rw");
|
|
3972
4128
|
$("rhead").style.display = "flex";
|
|
3973
4129
|
$("rdot").className = "dot " + e.status;
|
|
3974
4130
|
const rname = e.title || cliLabel(e) || ident(e) || "agent";
|
|
@@ -4053,6 +4209,12 @@
|
|
|
4053
4209
|
let suppressPush = true;
|
|
4054
4210
|
const pushSize = () => {
|
|
4055
4211
|
if (term && sel === e._key && !suppressPush) {
|
|
4212
|
+
// Read-only viewer: we can't drive the agent's PTY, so a pane resize
|
|
4213
|
+
// just re-letterboxes the agent's fixed grid to fit — never a SIGWINCH.
|
|
4214
|
+
if (isReadonlyEnt(e)) {
|
|
4215
|
+
applyCanvasScale();
|
|
4216
|
+
return;
|
|
4217
|
+
}
|
|
4056
4218
|
lastDroveAt = Date.now(); // we're driving the size now (lease the grid)
|
|
4057
4219
|
tx.post("/api/resize/" + encodeURIComponent(pid), {
|
|
4058
4220
|
cols: term.cols,
|
|
@@ -4094,6 +4256,16 @@
|
|
|
4094
4256
|
const fitAndSync = (sz) => {
|
|
4095
4257
|
if (sel !== selKey || !term) return;
|
|
4096
4258
|
if (sz && sz.cols && sz.rows) agentSize = sz; // remember for the badge tooltip
|
|
4259
|
+
// Read-only viewer: don't reflow the agent to our pane (we can't resize
|
|
4260
|
+
// it, and shouldn't). Adopt the AGENT's own grid and CSS-scale it to fit,
|
|
4261
|
+
// exactly like a shared-canvas watcher — otherwise the raw stream (emitted
|
|
4262
|
+
// at the agent's width) wraps at the wrong column in our terminal.
|
|
4263
|
+
if (isReadonlyEnt(e)) {
|
|
4264
|
+
if (sz && sz.cols && sz.rows) followAgentSize(sz);
|
|
4265
|
+
else applyCanvasScale();
|
|
4266
|
+
suppressPush = false;
|
|
4267
|
+
return;
|
|
4268
|
+
}
|
|
4097
4269
|
try {
|
|
4098
4270
|
fit.fit();
|
|
4099
4271
|
} catch {}
|
|
@@ -4113,7 +4285,18 @@
|
|
|
4113
4285
|
// inside fitAndSync keeps an already-matching agent from a redundant SIGWINCH.
|
|
4114
4286
|
// Returns the in-flight sync promise so the initial open can await it
|
|
4115
4287
|
// before starting the tail (below); tab-reactivation callers ignore it.
|
|
4116
|
-
resyncTerm = () => {
|
|
4288
|
+
resyncTerm = async () => {
|
|
4289
|
+
if (sel !== selKey || !term) return;
|
|
4290
|
+
// Settle the source's read-only state first (bounded) so fitAndSync picks
|
|
4291
|
+
// the right sizing path on the FIRST render: a read-only viewer must
|
|
4292
|
+
// adopt the agent's grid, not fit-to-pane — the raw snapshot can't be
|
|
4293
|
+
// re-wrapped later. Bounded so an older host without whoami never wedges.
|
|
4294
|
+
const src = srcFor(e);
|
|
4295
|
+
if (src?.whoamiReady) {
|
|
4296
|
+
try {
|
|
4297
|
+
await Promise.race([src.whoamiReady, new Promise((r) => setTimeout(r, 1500))]);
|
|
4298
|
+
} catch {}
|
|
4299
|
+
}
|
|
4117
4300
|
if (sel !== selKey || !term) return;
|
|
4118
4301
|
return tx
|
|
4119
4302
|
.fetchJSON("/api/size/" + encodeURIComponent(pid))
|
|
@@ -4867,15 +5050,36 @@
|
|
|
4867
5050
|
// every machine's rows would be unlabelled. Ask the host who it is once
|
|
4868
5051
|
// and tag this room's agents with it in listSource. Best-effort: an older
|
|
4869
5052
|
// host with no /api/whoami just leaves rows device-less.
|
|
5053
|
+
// Expose whoami as an awaitable so the terminal can settle the read-only
|
|
5054
|
+
// state BEFORE it sizes/renders — otherwise a fast selection races the
|
|
5055
|
+
// fetch and the initial raw snapshot lands on a fit-to-pane grid that a
|
|
5056
|
+
// later resize can't un-wrap (a read-only viewer must render at the
|
|
5057
|
+
// AGENT's grid). Re-created on every (re)connect.
|
|
5058
|
+
let resolveWhoami;
|
|
5059
|
+
s.whoamiReady = new Promise((r) => (resolveWhoami = r));
|
|
4870
5060
|
try {
|
|
4871
|
-
|
|
5061
|
+
const who = await s.tx.fetchJSON("/api/whoami");
|
|
5062
|
+
s.deviceLabel = who?.host || "";
|
|
5063
|
+
// A single-agent view-only share advertises its capability here so the
|
|
5064
|
+
// console enters read-only mode (hide + refuse writes). Wrap this
|
|
5065
|
+
// source's tx so no write leaks through any call path; the host still
|
|
5066
|
+
// enforces (403s writes) as the real boundary.
|
|
5067
|
+
s.readonly = !!who?.share?.readonly;
|
|
5068
|
+
s.sharePerm = who?.share?.perm || null; // "r" | "rw" | null(owner)
|
|
5069
|
+
s.shareAgentId = who?.share?.agent_id || null;
|
|
5070
|
+
if (s.readonly) s.tx = readonlyTx(s.tx);
|
|
4872
5071
|
// whoami resolves after the first listSource may have already rendered
|
|
4873
5072
|
// this room's rows device-less (and activity-gated polling might not
|
|
4874
5073
|
// re-fire soon on a backgrounded tab). Re-render now so the host label
|
|
4875
|
-
// lands immediately instead of lagging a poll.
|
|
4876
|
-
if (s.deviceLabel) loadList();
|
|
5074
|
+
// (and any read-only state) lands immediately instead of lagging a poll.
|
|
5075
|
+
if (s.deviceLabel || s.readonly) loadList();
|
|
4877
5076
|
} catch {
|
|
4878
5077
|
s.deviceLabel = "";
|
|
5078
|
+
// No whoami (older host / error): treat as writable, don't wedge the
|
|
5079
|
+
// terminal — resolve so any awaiter proceeds immediately.
|
|
5080
|
+
s.readonly = s.readonly || false;
|
|
5081
|
+
} finally {
|
|
5082
|
+
resolveWhoami();
|
|
4879
5083
|
}
|
|
4880
5084
|
} finally {
|
|
4881
5085
|
s.reconnecting = false;
|