agent-yes 1.190.0 → 1.192.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/lab/ui/index.html CHANGED
@@ -352,6 +352,46 @@
352
352
  .app.steer-agent #shareAgentRW {
353
353
  display: none !important;
354
354
  }
355
+ /* Expose modal — ports manager list rows. */
356
+ .exposeList {
357
+ margin: 10px 0;
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 6px;
361
+ max-height: 40vh;
362
+ overflow-y: auto;
363
+ }
364
+ .exposerow {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 8px;
368
+ border: 1px solid var(--line);
369
+ border-radius: 8px;
370
+ padding: 7px 9px;
371
+ }
372
+ .exposerow .exmeta {
373
+ flex: 1;
374
+ min-width: 0;
375
+ }
376
+ .exposerow .explabel {
377
+ font-weight: 600;
378
+ }
379
+ .exposerow .exurl {
380
+ font-size: 11px;
381
+ opacity: 0.7;
382
+ overflow: hidden;
383
+ text-overflow: ellipsis;
384
+ white-space: nowrap;
385
+ }
386
+ .exposerow .exhost {
387
+ font-size: 10px;
388
+ opacity: 0.55;
389
+ }
390
+ .exposePrompt {
391
+ border-bottom: 1px solid var(--line);
392
+ padding-bottom: 10px;
393
+ margin-bottom: 4px;
394
+ }
355
395
  /* Share modal — QR + link for a single-agent view-only share. */
356
396
  .modal-backdrop {
357
397
  position: fixed;
@@ -1685,6 +1725,9 @@
1685
1725
  <button id="foldbtn" class="viewbtn" title="fold subagent trees">⊞ subs</button>
1686
1726
  <button id="sortbtn" class="viewbtn" title="cycle sort order">⇅ state</button>
1687
1727
  <button id="viewbtn" class="viewbtn" title="toggle compact list">☰</button>
1728
+ <button id="portsbtn" class="viewbtn" title="manage exposed localhost ports">
1729
+ ⇄ ports
1730
+ </button>
1688
1731
  <button id="rguibtn" class="viewbtn" title="open the agent graph view (/r/) for this fleet">
1689
1732
  ⌗ graph
1690
1733
  </button>
@@ -1890,6 +1933,32 @@
1890
1933
  </div>
1891
1934
  </div>
1892
1935
 
1936
+ <!-- Expose modal: prompt to publish a clicked localhost:PORT through
1937
+ agent-yes.com, plus the ports manager (list + revoke active exposures). -->
1938
+ <div class="modal-backdrop" id="exposeModal" hidden>
1939
+ <div class="sharebox" role="dialog" aria-modal="true" aria-label="Expose localhost port">
1940
+ <h3 id="exposeTitle">Exposed ports</h3>
1941
+ <div class="exposePrompt" id="exposePrompt" hidden>
1942
+ <p class="sub" id="exposePromptSub">Publish this local server on the internet?</p>
1943
+ <p class="note">
1944
+ A private link on <strong>agent-yes.com</strong> will tunnel to this port on the agent's
1945
+ machine. Only someone who opens the one-time claim link (which opens automatically for
1946
+ you) can reach it. Revoke anytime below.
1947
+ </p>
1948
+ <div class="srow">
1949
+ <button class="primary" id="exposeGo" type="button">Expose &amp; open</button>
1950
+ <button id="exposeCancel" type="button">Cancel</button>
1951
+ </div>
1952
+ </div>
1953
+ <div class="exposeList" id="exposeList">
1954
+ <p class="sub" id="exposeEmpty">No ports are exposed right now.</p>
1955
+ </div>
1956
+ <div class="srow">
1957
+ <button id="exposeClose" type="button">Close</button>
1958
+ </div>
1959
+ </div>
1960
+ </div>
1961
+
1893
1962
  <!-- Cmd/Ctrl+K omnibox: search agents by title (instant) then output (tail),
1894
1963
  or spawn a new agent in the highlighted agent's cwd with the typed prompt. -->
1895
1964
  <div class="omni" id="omni" style="display: none">
@@ -3242,6 +3311,157 @@
3242
3311
  if (modal) modal.hidden = true;
3243
3312
  pendingRw = null; // a cancelled read-write flow never minted anything
3244
3313
  }
3314
+
3315
+ // ---- Port exposure: publish a clicked localhost:PORT through agent-yes.com ----
3316
+
3317
+ // Recognise a local-loopback URL and pull its port (default 80). Only these
3318
+ // are offered for exposure — a real public URL just opens normally.
3319
+ function localhostPort(uri) {
3320
+ let u;
3321
+ try {
3322
+ u = new URL(uri);
3323
+ } catch {
3324
+ return null;
3325
+ }
3326
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
3327
+ const h = u.hostname;
3328
+ if (h !== "localhost" && h !== "127.0.0.1" && h !== "0.0.0.0" && h !== "::1") return null;
3329
+ const port = Number(u.port) || (u.protocol === "https:" ? 443 : 80);
3330
+ return port >= 1 && port <= 65535 ? port : null;
3331
+ }
3332
+
3333
+ // The tx the expose prompt should call (the daemon whose terminal was clicked).
3334
+ let exposePendingTx = null;
3335
+ let exposePendingPort = 0;
3336
+
3337
+ // Called by the terminal link handler when a localhost URL is clicked.
3338
+ function promptExpose(port, tx) {
3339
+ exposePendingTx = tx || localTx;
3340
+ exposePendingPort = port;
3341
+ $("exposeTitle").textContent = "Expose localhost:" + port + "?";
3342
+ $("exposePromptSub").textContent =
3343
+ "Publish this local server (port " + port + ") on the internet via agent-yes.com?";
3344
+ $("exposePrompt").hidden = false;
3345
+ openExposeManager(false);
3346
+ }
3347
+
3348
+ // Open the ports manager (list of active exposures across every host).
3349
+ function openExposeManager(resetPrompt) {
3350
+ if (resetPrompt !== false) {
3351
+ $("exposePrompt").hidden = true;
3352
+ $("exposeTitle").textContent = "Exposed ports";
3353
+ }
3354
+ const modal = $("exposeModal");
3355
+ if (modal) modal.hidden = false;
3356
+ refreshExposeList();
3357
+ }
3358
+ function closeExposeModal() {
3359
+ const modal = $("exposeModal");
3360
+ if (modal) modal.hidden = true;
3361
+ exposePendingTx = null;
3362
+ exposePendingPort = 0;
3363
+ }
3364
+
3365
+ // Agree → mint the exposure on its host and open the claim link (which sets
3366
+ // the 8h cookie and redirects to the app), then refresh the manager list.
3367
+ async function doExpose() {
3368
+ const tx = exposePendingTx || localTx;
3369
+ const port = exposePendingPort;
3370
+ $("exposePrompt").hidden = true;
3371
+ if (!port) return;
3372
+ let info = null;
3373
+ try {
3374
+ const r = await tx.post("/api/expose", { port });
3375
+ if (r.ok) info = JSON.parse(r.text);
3376
+ } catch {}
3377
+ if (!info || !info.claim) {
3378
+ $("exposeEmpty").hidden = false;
3379
+ $("exposeEmpty").textContent = "Couldn't expose port " + port + " (is `ay serve` reachable?).";
3380
+ return;
3381
+ }
3382
+ window.open(info.claim, "_blank", "noopener,noreferrer");
3383
+ refreshExposeList();
3384
+ }
3385
+
3386
+ // Gather active exposures from every live host and render the manager rows.
3387
+ async function refreshExposeList() {
3388
+ const list = $("exposeList");
3389
+ if (!list) return;
3390
+ const rows = [];
3391
+ await Promise.all(
3392
+ [...sources.values()].map(async (s) => {
3393
+ const tx = s.tx || (s.id === "local" ? localTx : null);
3394
+ if (!tx) return;
3395
+ let arr;
3396
+ try {
3397
+ arr = await tx.fetchJSON("/api/exposes");
3398
+ } catch {
3399
+ return;
3400
+ }
3401
+ if (Array.isArray(arr))
3402
+ for (const ex of arr) rows.push({ ...ex, _srcName: s.name || s.id, _tx: tx });
3403
+ }),
3404
+ );
3405
+ list.textContent = "";
3406
+ if (!rows.length) {
3407
+ const p = document.createElement("p");
3408
+ p.className = "sub";
3409
+ p.id = "exposeEmpty";
3410
+ p.textContent = "No ports are exposed right now.";
3411
+ list.appendChild(p);
3412
+ return;
3413
+ }
3414
+ rows.sort((a, b) => b.createdAt - a.createdAt);
3415
+ for (const ex of rows) list.appendChild(renderExposeRow(ex));
3416
+ }
3417
+
3418
+ function renderExposeRow(ex) {
3419
+ const row = document.createElement("div");
3420
+ row.className = "exposerow";
3421
+ const meta = document.createElement("div");
3422
+ meta.className = "exmeta";
3423
+ const label = document.createElement("div");
3424
+ label.className = "explabel";
3425
+ label.textContent = "localhost:" + ex.port;
3426
+ const url = document.createElement("div");
3427
+ url.className = "exurl";
3428
+ url.textContent = ex.url;
3429
+ const host = document.createElement("div");
3430
+ host.className = "exhost";
3431
+ host.textContent = ex._srcName;
3432
+ meta.appendChild(label);
3433
+ meta.appendChild(url);
3434
+ meta.appendChild(host);
3435
+ row.appendChild(meta);
3436
+
3437
+ const openBtn = document.createElement("button");
3438
+ openBtn.className = "primary";
3439
+ openBtn.textContent = "Open";
3440
+ // Re-mint a fresh claim so the owner (or a guest) gets a usable link.
3441
+ openBtn.onclick = async () => {
3442
+ try {
3443
+ const r = await ex._tx.post("/api/expose", { port: ex.port });
3444
+ const info = r.ok ? JSON.parse(r.text) : null;
3445
+ window.open(info && info.claim ? info.claim : ex.url, "_blank", "noopener,noreferrer");
3446
+ } catch {
3447
+ window.open(ex.url, "_blank", "noopener,noreferrer");
3448
+ }
3449
+ };
3450
+ row.appendChild(openBtn);
3451
+
3452
+ const revoke = document.createElement("button");
3453
+ revoke.className = "danger";
3454
+ revoke.textContent = "Revoke";
3455
+ revoke.onclick = async () => {
3456
+ revoke.disabled = true;
3457
+ try {
3458
+ await ex._tx.del("/api/expose/" + ex.port);
3459
+ } catch {}
3460
+ refreshExposeList();
3461
+ };
3462
+ row.appendChild(revoke);
3463
+ return row;
3464
+ }
3245
3465
  // Draw the QR onto a fresh canvas (white quiet-zone always, so it scans in
3246
3466
  // dark mode too). `qrcode` is the vendored global from qrcode.js.
3247
3467
  function renderQr(container, text) {
@@ -3291,6 +3511,18 @@
3291
3511
  currentShare = null;
3292
3512
  closeShareModal();
3293
3513
  }
3514
+ (function exposeModalBoot() {
3515
+ $("portsbtn")?.addEventListener("click", () => openExposeManager(true));
3516
+ $("exposeGo")?.addEventListener("click", doExpose);
3517
+ $("exposeCancel")?.addEventListener("click", () => {
3518
+ $("exposePrompt").hidden = true;
3519
+ $("exposeTitle").textContent = "Exposed ports";
3520
+ });
3521
+ $("exposeClose")?.addEventListener("click", closeExposeModal);
3522
+ $("exposeModal")?.addEventListener("click", (e) => {
3523
+ if (e.target === $("exposeModal")) closeExposeModal();
3524
+ });
3525
+ })();
3294
3526
  (function shareModalBoot() {
3295
3527
  $("shareClose")?.addEventListener("click", closeShareModal);
3296
3528
  $("shareCloseAlt")?.addEventListener("click", closeShareModal);
@@ -4256,9 +4488,13 @@
4256
4488
  // new tab (noopener so the page can't be tampered with via window.opener).
4257
4489
  try {
4258
4490
  term.loadAddon(
4259
- new WebLinksAddon.WebLinksAddon((e, uri) =>
4260
- window.open(uri, "_blank", "noopener,noreferrer"),
4261
- ),
4491
+ new WebLinksAddon.WebLinksAddon((ev, uri) => {
4492
+ // A localhost URL isn't reachable from the viewer's browser — offer
4493
+ // to publish it through agent-yes.com (on THIS agent's host: txFor(e)).
4494
+ const port = localhostPort(uri);
4495
+ if (port) return promptExpose(port, txFor(e));
4496
+ window.open(uri, "_blank", "noopener,noreferrer");
4497
+ }),
4262
4498
  );
4263
4499
  } catch {
4264
4500
  /* addon CDN blocked — terminal still works, just without auto-links */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.190.0",
3
+ "version": "1.192.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
package/ts/expose.ts CHANGED
@@ -3,9 +3,15 @@
3
3
  //
4
4
  // The daemon dials OUT (wss://<relay>/_ay/tunnel/<id>), so it works behind any
5
5
  // NAT, and runs the codehost tunnel protocol's host half against the local
6
- // port. Private by default: visitors need the single-use claim link printed
7
- // below (it swaps for an 8h HttpOnly cookie at the edge; unauthenticated
8
- // requests never reach this machine). See lab/ui/cf/exposure.ts for the edge.
6
+ // port. Private by default: visitors need a single-use claim link (it swaps
7
+ // for an 8h HttpOnly cookie at the edge; unauthenticated requests never reach
8
+ // this machine). See lab/ui/cf/exposure.ts for the edge.
9
+ //
10
+ // Two front doors share one implementation:
11
+ // - the CLI (`cmdExpose`), which starts one exposure and blocks; and
12
+ // - the in-process manager (`ensureExposure` / `listExposures` /
13
+ // `stopExposure`), which `ay serve` drives from POST /api/expose so the web
14
+ // console can expose a clicked localhost port and revoke it later.
9
15
 
10
16
  import { randomBytes, createHash } from "node:crypto";
11
17
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -24,13 +30,41 @@ interface ExposureRecord {
24
30
  key: string;
25
31
  }
26
32
 
33
+ /** Live handle for one exposed port. */
34
+ export interface ExposureHandle {
35
+ /** Opaque exposure id (also the subdomain label). */
36
+ id: string;
37
+ /** Local loopback port being shared. */
38
+ port: number;
39
+ /** Public host, e.g. x….agent-yes.com (or the relay host for a dev relay). */
40
+ publicHost: string;
41
+ /** Public root URL. */
42
+ url: string;
43
+ /** Relay host this exposure is registered on. */
44
+ relayHost: string;
45
+ createdAt: number;
46
+ /** Mint a fresh single-use claim link and register it with the relay. The
47
+ * visitor opening it gets an 8h session cookie for this exposure. */
48
+ mintClaim(): string;
49
+ /** Stop sharing (closes the relay socket; the URL then answers 502). */
50
+ stop(): void;
51
+ }
52
+
53
+ /** Serializable view of an active exposure (for the console's ports manager). */
54
+ export interface ExposureInfo {
55
+ id: string;
56
+ port: number;
57
+ url: string;
58
+ createdAt: number;
59
+ }
60
+
27
61
  function exposuresPath(): string {
28
62
  const home = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
29
63
  mkdirSync(home, { recursive: true });
30
64
  return path.join(home, "exposures.json");
31
65
  }
32
66
 
33
- /** Stable id+key per (relay, port): re-running `ay expose 5173` keeps its URL. */
67
+ /** Stable id+key per (relay, port): re-exposing a port keeps its URL. */
34
68
  function loadOrCreateExposure(relayHost: string, port: number): ExposureRecord {
35
69
  const file = exposuresPath();
36
70
  let all: Record<string, ExposureRecord> = {};
@@ -79,6 +113,160 @@ function wsTransport(ws: WebSocket): TunnelTransport {
79
113
  };
80
114
  }
81
115
 
116
+ /**
117
+ * Start (or fail) one exposure. Resolves once the relay has accepted the daemon
118
+ * and the tunnel is live; rejects if the relay refuses this exposure (bad key).
119
+ * Reconnects with backoff for the life of the handle.
120
+ */
121
+ export function startExposure(opts: {
122
+ port: number;
123
+ relay?: string;
124
+ /** Log lifecycle transitions (CLI wants this; the manager stays quiet). */
125
+ log?: (msg: string) => void;
126
+ }): Promise<ExposureHandle> {
127
+ const relay = opts.relay ?? DEFAULT_RELAY;
128
+ const port = opts.port;
129
+ const log = opts.log ?? (() => {});
130
+ const relayUrl = new URL(relay);
131
+ const rec = loadOrCreateExposure(relayUrl.host, port);
132
+ const wsProto = relayUrl.protocol === "http:" ? "ws:" : "wss:";
133
+ const tunnelUrl = `${wsProto}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
134
+ // Public hostname: <id>.<zone> on the real relay; the relay host itself (with
135
+ // a Host-header spoof) when pointing at a dev relay (wrangler dev).
136
+ const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
137
+ const publicUrl = `https://${publicHost}/`;
138
+
139
+ let stopped = false;
140
+ let sock: WebSocket | null = null;
141
+ let ready = false;
142
+ let backoff = RECONNECT_MIN_MS;
143
+
144
+ const handle: ExposureHandle = {
145
+ id: rec.id,
146
+ port,
147
+ publicHost,
148
+ url: publicUrl,
149
+ relayHost: relayUrl.host,
150
+ createdAt: Date.now(),
151
+ mintClaim() {
152
+ const token = randomBytes(18).toString("base64url");
153
+ const hash = createHash("sha256").update(token).digest("hex");
154
+ if (sock && sock.readyState === WebSocket.OPEN) {
155
+ sock.send(JSON.stringify({ t: "claim", claims: [hash] }));
156
+ }
157
+ return `https://${publicHost}/_ay/claim?t=${token}`;
158
+ },
159
+ stop() {
160
+ stopped = true;
161
+ try {
162
+ sock?.close();
163
+ } catch {
164
+ /* ignore */
165
+ }
166
+ },
167
+ };
168
+
169
+ return new Promise<ExposureHandle>((resolve, reject) => {
170
+ const connect = () => {
171
+ if (stopped) return;
172
+ sock = new WebSocket(tunnelUrl);
173
+ sock.binaryType = "arraybuffer";
174
+ const ws = sock;
175
+ let ping: ReturnType<typeof setInterval> | null = null;
176
+
177
+ ws.addEventListener("open", () => {
178
+ ws.send(JSON.stringify({ t: "hello", key: rec.key, port, v: 1 }));
179
+ });
180
+ ws.addEventListener("message", (ev) => {
181
+ if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
182
+ let msg: { t?: string };
183
+ try {
184
+ msg = JSON.parse(ev.data);
185
+ } catch {
186
+ return;
187
+ }
188
+ if (msg.t === "ready") {
189
+ backoff = RECONNECT_MIN_MS;
190
+ new TunnelHost(wsTransport(ws), { port });
191
+ ping = setInterval(() => {
192
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
193
+ }, PING_MS);
194
+ if (!ready) {
195
+ ready = true;
196
+ log(`sharing 127.0.0.1:${port} at ${publicUrl}`);
197
+ resolve(handle);
198
+ } else {
199
+ log(`reconnected`);
200
+ }
201
+ }
202
+ });
203
+ ws.addEventListener("close", (ev) => {
204
+ if (ping) clearInterval(ping);
205
+ if (stopped) return;
206
+ if (ev.code === 1008) {
207
+ const err = new Error(`relay refused exposure (${ev.reason || "forbidden"})`);
208
+ if (!ready) return reject(err);
209
+ log(err.message);
210
+ return;
211
+ }
212
+ log(`connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
213
+ setTimeout(connect, backoff);
214
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
215
+ });
216
+ ws.addEventListener("error", () => {
217
+ /* close fires right after; retry there */
218
+ });
219
+ };
220
+ connect();
221
+ });
222
+ }
223
+
224
+ // ---- in-process manager (driven by `ay serve` POST /api/expose) ----
225
+
226
+ const active = new Map<number, ExposureHandle>();
227
+ /** In-flight starts, so concurrent POSTs for the same port share one dial. */
228
+ const starting = new Map<number, Promise<ExposureHandle>>();
229
+
230
+ /** Start an exposure for `port` (or reuse a running one). Idempotent per port. */
231
+ export async function ensureExposure(port: number, relay?: string): Promise<ExposureHandle> {
232
+ const existing = active.get(port);
233
+ if (existing) return existing;
234
+ const inflight = starting.get(port);
235
+ if (inflight) return inflight;
236
+ const p = startExposure({ port, relay })
237
+ .then((h) => {
238
+ active.set(port, h);
239
+ starting.delete(port);
240
+ return h;
241
+ })
242
+ .catch((e) => {
243
+ starting.delete(port);
244
+ throw e;
245
+ });
246
+ starting.set(port, p);
247
+ return p;
248
+ }
249
+
250
+ export function listExposures(): ExposureInfo[] {
251
+ return [...active.values()]
252
+ .sort((a, b) => b.createdAt - a.createdAt)
253
+ .map((h) => ({ id: h.id, port: h.port, url: h.url, createdAt: h.createdAt }));
254
+ }
255
+
256
+ export function stopExposure(port: number): boolean {
257
+ const h = active.get(port);
258
+ if (!h) return false;
259
+ h.stop();
260
+ active.delete(port);
261
+ return true;
262
+ }
263
+
264
+ export function stopAllExposures(): void {
265
+ for (const port of [...active.keys()]) stopExposure(port);
266
+ }
267
+
268
+ // ---- CLI ----
269
+
82
270
  export async function cmdExpose(args: string[]): Promise<number> {
83
271
  let relay = DEFAULT_RELAY;
84
272
  let port = 0;
@@ -102,83 +290,21 @@ export async function cmdExpose(args: string[]): Promise<number> {
102
290
  return 1;
103
291
  }
104
292
 
105
- const relayUrl = new URL(relay);
106
- const rec = loadOrCreateExposure(relayUrl.host, port);
107
- const wsProto = relayUrl.protocol === "http:" ? "ws:" : "wss:";
108
- const tunnelUrl = `${wsProto}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
109
- // Public hostname: <id>.<zone> on the real relay; the relay host itself (with
110
- // a Host-header spoof) when pointing at wrangler dev.
111
- const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
112
-
113
- // Fresh single-use claim token every run; only its hash goes to the edge.
114
- const claimToken = randomBytes(18).toString("base64url");
115
- const claimHash = createHash("sha256").update(claimToken).digest("hex");
116
-
117
- let stopped = false;
118
- let ws: WebSocket | null = null;
119
- let backoff = RECONNECT_MIN_MS;
120
- let announced = false;
121
-
122
- const connect = () => {
123
- if (stopped) return;
124
- ws = new WebSocket(tunnelUrl);
125
- ws.binaryType = "arraybuffer";
126
- const sock = ws;
127
- let ping: ReturnType<typeof setInterval> | null = null;
128
-
129
- sock.addEventListener("open", () => {
130
- sock.send(JSON.stringify({ t: "hello", key: rec.key, port, claims: [claimHash], v: 1 }));
131
- });
132
- sock.addEventListener("message", (ev) => {
133
- if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
134
- let msg: { t?: string };
135
- try {
136
- msg = JSON.parse(ev.data);
137
- } catch {
138
- return;
139
- }
140
- if (msg.t === "ready") {
141
- backoff = RECONNECT_MIN_MS;
142
- new TunnelHost(wsTransport(sock), { port });
143
- ping = setInterval(() => {
144
- if (sock.readyState === WebSocket.OPEN) sock.send("ping");
145
- }, PING_MS);
146
- if (!announced) {
147
- announced = true;
148
- console.log(`[ay expose] sharing 127.0.0.1:${port}`);
149
- console.log(` url: https://${publicHost}/`);
150
- console.log(` claim: https://${publicHost}/_ay/claim?t=${claimToken}`);
151
- console.log(` (one-time link — opens access for 8h in that browser)`);
152
- } else {
153
- console.log(`[ay expose] reconnected`);
154
- }
155
- }
156
- });
157
- sock.addEventListener("close", (ev) => {
158
- if (ping) clearInterval(ping);
159
- if (stopped) return;
160
- if (ev.code === 1008) {
161
- console.error(`[ay expose] relay refused this exposure (${ev.reason || "forbidden"}) — giving up`);
162
- process.exit(1);
163
- }
164
- console.log(`[ay expose] connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
165
- setTimeout(connect, backoff);
166
- backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
167
- });
168
- sock.addEventListener("error", () => {
169
- /* close fires right after; retry there */
170
- });
171
- };
172
- connect();
293
+ let handle: ExposureHandle;
294
+ try {
295
+ handle = await startExposure({ port, relay, log: (m) => console.log(`[ay expose] ${m}`) });
296
+ } catch (e) {
297
+ console.error(`[ay expose] ${(e as Error).message} giving up`);
298
+ return 1;
299
+ }
300
+ const claimUrl = handle.mintClaim();
301
+ console.log(` url: ${handle.url}`);
302
+ console.log(` claim: ${claimUrl}`);
303
+ console.log(` (one-time link opens access for 8h in that browser)`);
173
304
 
174
305
  const shutdown = () => {
175
- stopped = true;
176
306
  console.log("\n[ay expose] stopped — the URL now answers 502 until you expose again");
177
- try {
178
- ws?.close();
179
- } catch {
180
- /* ignore */
181
- }
307
+ handle.stop();
182
308
  process.exit(0);
183
309
  };
184
310
  process.on("SIGINT", shutdown);
package/ts/serve.ts CHANGED
@@ -2729,6 +2729,42 @@ export async function cmdServe(rest: string[]): Promise<number> {
2729
2729
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2730
2730
  }
2731
2731
 
2732
+ // POST /api/expose body {port} → share 127.0.0.1:<port> through the edge
2733
+ // relay and return {url, claim} (a fresh single-use claim link each call).
2734
+ if (req.method === "POST" && p === "/api/expose") {
2735
+ let body: { port?: number; relay?: string };
2736
+ try {
2737
+ body = (await req.json()) as typeof body;
2738
+ } catch {
2739
+ return new Response("invalid JSON body", { status: 400 });
2740
+ }
2741
+ const port = Number(body.port);
2742
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
2743
+ return new Response("valid port required", { status: 400 });
2744
+ }
2745
+ try {
2746
+ const { ensureExposure } = await import("./expose.ts");
2747
+ const h = await ensureExposure(port, body.relay);
2748
+ return Response.json({ id: h.id, port: h.port, url: h.url, claim: h.mintClaim(), createdAt: h.createdAt });
2749
+ } catch (e) {
2750
+ return new Response(`expose failed: ${(e as Error).message}`, { status: 502 });
2751
+ }
2752
+ }
2753
+
2754
+ // GET /api/exposes → active port exposures (for the console's ports manager).
2755
+ if (req.method === "GET" && p === "/api/exposes") {
2756
+ const { listExposures } = await import("./expose.ts");
2757
+ return Response.json(listExposures());
2758
+ }
2759
+
2760
+ // DELETE /api/expose/:port → revoke (the URL then answers 502).
2761
+ const unexposeM = /^\/api\/expose\/(\d+)$/.exec(p);
2762
+ if (req.method === "DELETE" && unexposeM) {
2763
+ const { stopExposure } = await import("./expose.ts");
2764
+ const ok = stopExposure(Number(unexposeM[1]));
2765
+ return new Response(ok ? "revoked" : "no such exposure", { status: ok ? 200 : 404 });
2766
+ }
2767
+
2732
2768
  return new Response("Not Found", { status: 404 });
2733
2769
  };
2734
2770
 
@@ -2793,10 +2829,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
2793
2829
  return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
2794
2830
  if (req.method === "GET" && p === "/console-logic.js")
2795
2831
  return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
2832
+ // rtc.js is a STATIC import of the console module (import { RTCClient }) — a
2833
+ // 401 here fails the whole module link and the console never boots.
2834
+ if (req.method === "GET" && p === "/rtc.js")
2835
+ return serveUiFile("rtc.js", "text/javascript; charset=utf-8");
2796
2836
  if (req.method === "GET" && p === "/e2e.js")
2797
2837
  return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
2798
2838
  if (req.method === "GET" && p === "/qrcode.js")
2799
2839
  return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
2840
+ if (req.method === "GET" && p === "/manifest.webmanifest")
2841
+ return serveUiFile("manifest.webmanifest", "application/manifest+json");
2842
+ if (req.method === "GET" && p === "/icon.svg")
2843
+ return serveUiFile("icon.svg", "image/svg+xml");
2800
2844
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
2801
2845
  return apiFetch(req);
2802
2846
  };