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.
Files changed (47) hide show
  1. package/agent-yes.config.schema.json +2 -2
  2. package/default.config.yaml +45 -0
  3. package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
  4. package/dist/{SUPPORTED_CLIS-DOs4OKwx.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
  5. package/dist/agentShare-DRfghQz8.js +231 -0
  6. package/dist/bash-yes.js +10 -0
  7. package/dist/cli.js +8 -8
  8. package/dist/cmd-yes.js +10 -0
  9. package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
  10. package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
  11. package/dist/index.js +2 -2
  12. package/dist/{notifyDaemon-EQPDGq-K.js → notifyDaemon-C08fJ2Ai.js} +5 -5
  13. package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
  14. package/dist/powershell-yes.js +10 -0
  15. package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
  16. package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
  17. package/dist/{rustBinary-B5pGAxJj.js → rustBinary-DZTU07SA.js} +2 -2
  18. package/dist/{schedule-2gVJxKiN.js → schedule-BQtFOk0z.js} +5 -5
  19. package/dist/{serve-3WOXuTLt.js → serve-B5PQdiOb.js} +143 -76
  20. package/dist/{setup-CJQPRwIb.js → setup-BCqFNJoB.js} +3 -3
  21. package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
  22. package/dist/share-nvqy64A2.js +4 -0
  23. package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
  24. package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
  25. package/dist/{subcommands-CpA7qj-z.js → subcommands-BbSJ_nTU.js} +736 -714
  26. package/dist/subcommands-BokdtqqC.js +9 -0
  27. package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
  28. package/dist/{ts-DRUpMU-r.js → ts-Ce7a44Rj.js} +3 -3
  29. package/dist/{versionChecker-0OpVlsRu.js → versionChecker-C7C7QAPu.js} +2 -2
  30. package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
  31. package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
  32. package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
  33. package/lab/ui/index.html +495 -291
  34. package/lab/ui/qrcode.js +2297 -0
  35. package/lab/ui/sw.js +2 -1
  36. package/package.json +8 -2
  37. package/scripts/build-rgui.ts +78 -0
  38. package/ts/agentShare.lifecycle.spec.ts +139 -0
  39. package/ts/agentShare.spec.ts +238 -0
  40. package/ts/agentShare.ts +330 -0
  41. package/ts/forkNested.spec.ts +39 -2
  42. package/ts/index.ts +5 -1
  43. package/ts/serve.ts +119 -5
  44. package/ts/share.ts +10 -3
  45. package/ts/subcommands.ts +29 -0
  46. package/dist/SUPPORTED_CLIS-7XJNuNWe.js +0 -8
  47. 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
- import {
1706
- MARKER as E2E_MARKER,
1707
- ALLOW_LEGACY_PLAINTEXT,
1708
- FLAG_CONFIRM,
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(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
2040
+ String(s ?? "").replace(
2041
+ /[&<>"']/g,
2042
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- // Tunnels request/response + streaming over one DataChannel. Mirrors the
1821
- // envelope in lab/ui/share-host.ts: {t:"req"|"abort"} out, {t:"res"|"data"|"end"} in.
1822
- class RTCClient {
1823
- constructor(host, room, token) {
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
- s.deviceLabel = (await s.tx.fetchJSON("/api/whoami"))?.host || "";
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;