samagotchi 0.108.0 → 0.110.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. metadata +12 -1
@@ -0,0 +1,57 @@
1
+ // A link's one-time code in its fragment (https://<public url>/#code=…&s=ID,
2
+ // what chi web makes behind an auth proxy: never a token, since a fragment
3
+ // outlives the sign-in redirects and stays in the browser's history). The
4
+ // page posts it to POST /api/token, which uses it up and sets the cookie,
5
+ // then loads itself again without it. The 401 page runs it (chi web serves
6
+ // this file without the token); the owner's and participant's pages do on
7
+ // load, asking first, since the trade replaces the browser's session. Plain
8
+ // module, no imports.
9
+
10
+ // {code, rest}: the fragment's code and what else it says ("s=ID"), or
11
+ // null without one.
12
+ export function fragmentCode(hash) {
13
+ const params = new URLSearchParams(String(hash || "").replace(/^#/, ""));
14
+ const code = params.get("code");
15
+ if (!code) return null;
16
+ params.delete("code");
17
+ return { code, rest: params.toString() };
18
+ }
19
+
20
+ export const REPLACE_SESSION_TEXT = "This link signs this browser in as its holder, in place of the session it has now. Open it?";
21
+
22
+ // Trades the fragment's code, if there is one: the URL loses it at once
23
+ // (history.replaceState), the page reloads once the cookie is set. ask():
24
+ // whether to go ahead (a page that already has a session); declined, the
25
+ // code is dropped unused. Resolves true when it reloads, false when there
26
+ // was nothing to trade, it was declined, or the code was refused
27
+ // (onRefused gets the server's words).
28
+ export async function tradeFragmentCode({
29
+ loc = globalThis.location, hist = globalThis.history, fetchImpl = globalThis.fetch, onRefused = () => {},
30
+ ask = () => true,
31
+ } = {}) {
32
+ const found = fragmentCode(loc?.hash);
33
+ if (!found) return false;
34
+ try {
35
+ hist.replaceState(null, "", `${loc.pathname}${loc.search}${found.rest ? `#${found.rest}` : ""}`);
36
+ } catch (_) {}
37
+ if (!ask()) return false;
38
+ let res;
39
+ try {
40
+ res = await fetchImpl("/api/token", {
41
+ method: "POST",
42
+ credentials: "same-origin",
43
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
44
+ body: JSON.stringify({ code: found.code }),
45
+ });
46
+ } catch (_) {
47
+ onRefused("chi web can't be reached: reload to try again.");
48
+ return false;
49
+ }
50
+ if (res.ok) {
51
+ loc.reload();
52
+ return true;
53
+ }
54
+ const body = await res.json().catch(() => ({}));
55
+ onRefused(body.detail || `That link was refused (HTTP ${res.status}): it works once, and an owner's link for 10 minutes.`);
56
+ return false;
57
+ }
@@ -3,10 +3,10 @@
3
3
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
4
4
  --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
5
5
  --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
6
- --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3)}
6
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
7
7
  /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
8
8
  @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;--pick-border:#0969da;
9
9
  --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
10
10
  --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
11
11
  --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
12
- --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3)}}
12
+ --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3);--time-gap:var(--text-faint)}}
@@ -340,7 +340,9 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
340
340
  const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
341
341
  // A participant's turn names them (TurnAccumulator keeps author, author_name).
342
342
  const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}) } : {};
343
- events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...cutAfter, ...author }, turn.images));
343
+ // Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
344
+ const at = typeof turn.at === "string" ? { at: turn.at } : {};
345
+ events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...author }, turn.images));
344
346
  let textIteration = null; // a text bubble is open for this iteration
345
347
  const closeText = () => {
346
348
  if (textIteration !== null) events.push({ type: "generation_completed" });
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
8
8
  import { commandBlockTemplate, rowHover } from "./command_view.js";
9
9
  import { copySource } from "./copy.js";
10
10
  import { activityDiffTemplate } from "./diff_view.js";
11
- import { clip, steerSender } from "./format.js";
11
+ import { clip, fullTime, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
14
  import { handedOverId } from "./task_stop.js";
@@ -219,9 +219,15 @@ export function shellCommandMessage(content) {
219
219
  // A `!cmd`'s history bubble: the same markup the live page draws for a
220
220
  // command (app.js's drawCommandBubble): the line, the output in a
221
221
  // pre.command-output.
222
- export function commandBubbleTemplate({ line, text }) {
222
+ export function commandBubbleTemplate({ line, text, at = null }) {
223
223
  const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
224
- return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
224
+ return html`<div class="bubble command" title=${timeTitle(at)}><div class="command-line">${line}</div>${body}</div>`;
225
+ }
226
+
227
+ // A bubble's hover: when it happened (its message's `at`, MessageTime), in
228
+ // full local time; no title when unknown.
229
+ export function timeTitle(at) {
230
+ return fullTime(at) || nothing;
225
231
  }
226
232
 
227
233
  // A user message's body: runs of `>` lines become <blockquote>s, the rest
@@ -257,9 +263,10 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
257
263
  const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
258
264
  const answerAt = at ?? nothing;
259
265
  const source = rendered || user ? copySource(message) : nothing;
260
- if (rendered) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
261
- if (user) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
262
- return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
266
+ const title = timeTitle(message?.at);
267
+ if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
268
+ if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
269
+ return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
263
270
  }
264
271
 
265
272
  // A context note's bubble (background the user or another session pushed
@@ -267,7 +274,7 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
267
274
  // text.
268
275
  export function noteBubbleTemplate(note) {
269
276
  const from = note?.label ? `note from ${note.label}` : "note";
270
- return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
277
+ return html`<div class="bubble note" title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
271
278
  }
272
279
 
273
280
  // A plugin's steer (a nudge put into the running turn): one line that
@@ -295,7 +302,7 @@ export function userBubbleTemplate(message, { label = null, state = null, step =
295
302
  const from = author
296
303
  ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
297
304
  : label ? html`<span class="origin-label">${label}</span>` : nothing;
298
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
305
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
299
306
  }
300
307
 
301
308
  // The notice of a turn that ended with no answer (the server's
@@ -42,6 +42,7 @@ import { createHold } from "./hold.js";
42
42
  import { linkedLine } from "./notice_links.js";
43
43
  import { taskCommand, taskStopButton } from "./task_stop.js";
44
44
  import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
45
+ import { groupGaps, timeGapTemplate } from "./time_gaps.js";
45
46
 
46
47
  // ---- The templates: one step, its thinking, its label ----
47
48
 
@@ -847,7 +848,7 @@ function failedLineTemplate(record) {
847
848
  // web.working_set, never the turn view's): each block stamped with its
848
849
  // turn's ordinal and its rows with theirs (working_set_view.js), and the
849
850
  // turn's footprint line after it.
850
- export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
851
+ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now() }) {
851
852
  const timingTemplate = (group) => {
852
853
  const record = group.record;
853
854
  // Numbered by its place in the turn records, as live (a failed turn
@@ -868,7 +869,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
868
869
  // as its command bubble, as live does — not as a user bubble.
869
870
  const userTemplate = (m, opts = {}) => {
870
871
  const shell = shellCommandMessage(m?.content);
871
- if (shell) return commandBubbleTemplate(shell);
872
+ if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
872
873
  return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
873
874
  };
874
875
  // The first text after a delegate report merged mid-turn, unless it is
@@ -886,7 +887,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
886
887
  const outlines = [];
887
888
  const blockTurns = [];
888
889
  let turnOrdinal = -1;
889
- for (const group of turnGroups(items, timing)) {
890
+ const groups = turnGroups(items, timing);
891
+ // A quiet stretch before a group: its divider first (time_gaps.js).
892
+ const gaps = groupGaps(groups, items, now);
893
+ for (const [k, group] of groups.entries()) {
894
+ if (gaps[k]) parts.push(timeGapTemplate(gaps[k]));
890
895
  if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
891
896
  if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
892
897
  if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../redacted_inspect"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ # chi web behind an auth proxy (web.public_url, docs/configuration.md
9
+ # "Behind an auth proxy"): the https origin people open, the request
10
+ # header the proxy puts the signed-in user's email in (nil: none is
11
+ # trusted, the chi token alone decides) and the owner's email. Set, it
12
+ # turns on proxied mode (Web::ProxyGate): every request needs a token.
13
+ PublicUrl = Data.define(:origin, :host, :identity_header, :owner) do
14
+ # The Rack env key of the identity header (HTTP_X_FORWARDED_EMAIL),
15
+ # nil without one.
16
+ def identity_env = identity_header && PublicUrl.env_key(identity_header)
17
+
18
+ # Whether a Host header names the public host: any case, a trailing
19
+ # dot, and an explicit :443 for a 443 origin are the same host.
20
+ def host?(header)
21
+ name, port = header.to_s.downcase.match(/\A(.*?)(?::(\d+))?\z/).captures
22
+ name = name.delete_suffix(".")
23
+ port = nil if port == "443"
24
+ host == (port ? "#{name}:#{port}" : name)
25
+ end
26
+
27
+ # The header's name as people write it, or "none".
28
+ def identity_label = identity_header || "none"
29
+
30
+ # https://host[:port]/#code=CODE[&FRAGMENT]: a link with a one-time
31
+ # code (LinkCodes) in its fragment, never a token: a fragment outlives
32
+ # the sign-in redirects and stays in the browser's history.
33
+ def link(code, fragment = nil) = "#{origin}/##{["code=#{code}", fragment].compact.join("&")}"
34
+ end
35
+
36
+ class PublicUrl
37
+ class Invalid < StandardError; end
38
+
39
+ # An HTTP header name (RFC 9110 token characters chi accepts).
40
+ HEADER_NAME = /\A[A-Za-z0-9][A-Za-z0-9-]*\z/
41
+
42
+ # @return [PublicUrl, nil] nil when +url+ is unset
43
+ # @raise [Invalid] a url that isn't an https origin, a bad header name,
44
+ # or an owner without an identity header to check it against
45
+ def self.from_config(url:, identity_header: nil, owner: nil)
46
+ text = url.to_s.strip
47
+ return nil if text.empty?
48
+
49
+ origin, host = parse_origin(text)
50
+ header = identity_header.to_s.strip
51
+ header = nil if header.empty? || header.casecmp?("none")
52
+ if header && !HEADER_NAME.match?(header)
53
+ raise Invalid, "web.proxy.identity_header must be a header name (letters, digits and -), not #{header}"
54
+ end
55
+
56
+ owner = owner.to_s.strip.downcase
57
+ owner = nil if owner.empty?
58
+ if owner && header.nil?
59
+ raise Invalid, "web.proxy.owner needs web.proxy.identity_header (the header the proxy puts #{owner} in); " \
60
+ "without one only the chi token decides, so leave owner unset"
61
+ end
62
+
63
+ new(origin: origin, host: host, identity_header: header, owner: owner)
64
+ end
65
+
66
+ def self.parse_origin(text)
67
+ uri = URI.parse(text)
68
+ bare = uri.path.to_s.empty? || uri.path == "/"
69
+ unless uri.scheme&.downcase == "https" && uri.host && !uri.host.empty? && bare && uri.query.nil? &&
70
+ uri.fragment.nil? && uri.userinfo.nil?
71
+ raise Invalid, invalid_url(text)
72
+ end
73
+
74
+ name = uri.host.downcase
75
+ host = uri.port == 443 ? name : "#{name}:#{uri.port}"
76
+ ["https://#{host}", host]
77
+ rescue URI::InvalidURIError
78
+ raise Invalid, invalid_url(text)
79
+ end
80
+
81
+ def self.invalid_url(text) = "web.public_url must be an https origin (https://host or https://host:port), not #{text}"
82
+
83
+ # The Rack env key WEBrick makes of a header name.
84
+ def self.env_key(name) = "HTTP_#{name.upcase.tr("-", "_")}"
85
+ end
86
+ end
87
+ end
@@ -4,18 +4,23 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../session"
7
+ require_relative "info_probe"
8
+ require_relative "public_url"
7
9
 
8
10
  module Samagotchi
9
11
  module Web
10
12
  # Where this machine's chi web listens: $XDG_STATE_HOME/samagotchi/web.json
11
- # ({pid, port}), written once its port is bound and removed when it
13
+ # ({pid, port}, and public_url when it runs with one: config's or
14
+ # --tunnel's), written once its port is bound and removed when it
12
15
  # stops (by that process only). A command that names chi web in what it
13
16
  # prints (chi share's link) reads the port here before config's
14
17
  # web.port, which a chi web started with --port or another config
15
18
  # doesn't follow. The file is a hint: a chi web killed hard leaves it,
16
- # so a reader probes the port (InfoProbe) before trusting it.
19
+ # so a reader probes the port (InfoProbe) before trusting it. One slot:
20
+ # a second chi web on another port takes it over.
17
21
  module Running
18
22
  FILE = "web.json"
23
+ PROBE_TIMEOUT = 1.0
19
24
 
20
25
  module_function
21
26
 
@@ -24,9 +29,10 @@ module Samagotchi
24
29
  end
25
30
 
26
31
  # @return [String] +path+
27
- def record(port:, path: self.path, pid: Process.pid)
32
+ # @param public_url [String, nil] the https origin it runs behind
33
+ def record(port:, path: self.path, pid: Process.pid, public_url: nil)
28
34
  FileUtils.mkdir_p(File.dirname(path))
29
- AtomicFile.write(path, JSON.generate({ pid: pid, port: port }))
35
+ AtomicFile.write(path, JSON.generate({ pid: pid, port: port, public_url: public_url }.compact))
30
36
  end
31
37
 
32
38
  # Removes the file when this process wrote it (a second chi web's
@@ -41,6 +47,35 @@ module Samagotchi
41
47
  value.is_a?(Integer) && value.positive? ? value : nil
42
48
  end
43
49
 
50
+ # The public_url the running chi web recorded (a --tunnel's, which no
51
+ # config has), for a link that names it: only while the chi web that
52
+ # answers /api/info on the recorded port is the process that wrote the
53
+ # file (its pid), and only an https origin. A record left by a chi web
54
+ # killed hard, with a reused pid or another chi web (another state dir)
55
+ # on that port, is nil. The origin is this file's, never the probe's.
56
+ # @param probe [#call] (port) → InfoProbe's answer
57
+ # @return [PublicUrl, nil]
58
+ def live_public_url(path: self.path, probe: ->(port) { InfoProbe.call("127.0.0.1", port, timeout: PROBE_TIMEOUT) })
59
+ data = read(path) or return nil
60
+ pid, port, url = data.values_at("pid", "port", "public_url")
61
+ return nil unless pid.is_a?(Integer) && pid.positive? && port.is_a?(Integer) && port.positive? && url.is_a?(String)
62
+ return nil unless alive?(pid)
63
+
64
+ info = probe.call(port)
65
+ return nil unless info.is_a?(Hash) && info["pid"] == pid
66
+
67
+ PublicUrl.from_config(url: url)
68
+ rescue PublicUrl::Invalid
69
+ nil
70
+ end
71
+
72
+ def alive?(pid)
73
+ Process.kill(0, pid)
74
+ true
75
+ rescue Errno::ESRCH, Errno::EPERM
76
+ false
77
+ end
78
+
44
79
  def read(path)
45
80
  data = JSON.parse(File.read(path))
46
81
  data.is_a?(Hash) ? data : nil
@@ -9,10 +9,14 @@ require "webrick"
9
9
  require_relative "app"
10
10
  require_relative "info_probe"
11
11
  require_relative "lan"
12
+ require_relative "link_codes"
13
+ require_relative "proxy_gate"
14
+ require_relative "public_url"
12
15
  require_relative "qr"
13
16
  require_relative "running"
14
17
  require_relative "session_hub"
15
18
  require_relative "token"
19
+ require_relative "tunnel"
16
20
  require_relative "../config"
17
21
  require_relative "../project_scope"
18
22
  require_relative "../log"
@@ -58,24 +62,34 @@ module Samagotchi
58
62
  # How long `chi web` waits for a server already on its port to answer.
59
63
  PROBE_TIMEOUT = 0.3
60
64
 
65
+ # The raw header names next to Rack's env (ProxyGate's identity check):
66
+ # chi web is the only WEBrick server chi runs.
67
+ WEBrick::HTTPRequest.prepend(ProxyGate::RawHeaderNames) unless WEBrick::HTTPRequest < ProxyGate::RawHeaderNames
68
+
61
69
  # `chi web`: one server serves every project, the scope is in the page
62
70
  # URL. If a chi web already runs on the port, print (and with --open,
63
71
  # open) its page for +dir+ and leave it be; else start one.
64
72
  # @param scope ["project", "all"] "all": the plain page, every session
65
73
  # @param new_token [Boolean] replace the LAN access token first
66
74
  # (--new-token): a running server takes the new one at once
75
+ # @param tunnel [String, nil] --tunnel's provider (Tunnel.for): this
76
+ # run's public_url is the tunnel's, never config's, and the tunnel is
77
+ # on while the server runs
67
78
  # @return [Integer] the exit status
68
79
  def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
69
80
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
70
81
  editor: Config::BY_KEY["web.editor"].default, new_token: false,
71
- working_set: Config::BY_KEY["web.working_set"].default)
82
+ working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil)
72
83
  rotate_token if new_token
84
+ public_url = public_url_setting unless tunnel
73
85
  setting = host_setting(host)
74
86
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
75
87
  host = resolve_host(setting)
76
88
  port = resolve_port(port)
77
89
  url = scope_url(host, port, dir: dir, scope: scope)
78
90
  found = probe(host, port)
91
+ raise Tunnel::AlreadyRunning, port if tunnel && found.is_a?(Hash)
92
+
79
93
  case found
80
94
  when Hash
81
95
  if lan && !found["lan"]
@@ -85,6 +99,8 @@ module Samagotchi
85
99
  end
86
100
  puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
87
101
  puts running_lan_lines(found["lan"], port) if found["lan"]
102
+ running_public = public_url || Running.live_public_url(probe: ->(at) { probe(host, at) })
103
+ puts running_public_lines(running_public) if running_public
88
104
  puts lan_off_line(setting) if new_token && !found["lan"]
89
105
  open_url(url) if open_browser
90
106
  0
@@ -92,20 +108,30 @@ module Samagotchi
92
108
  warn in_use_message(port)
93
109
  1
94
110
  else
95
- if new_token && !lan
111
+ if tunnel
112
+ provider = Tunnel.for(tunnel)
113
+ plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
114
+ config_public_url: Samagotchi::Config.get("web.public_url"),
115
+ config_identity_header: Samagotchi::Config.get("web.proxy.identity_header"))
116
+ public_url = plan.public_url
117
+ end
118
+ if new_token && !lan && !public_url
96
119
  puts lan_off_line(setting)
97
120
  return 0
98
121
  end
99
122
  # The participants' grants: never served from a store others can read.
100
123
  Grants.check_permissions!(Grants.path)
101
- if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
102
- annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
103
- 0
104
- else
105
- 1
106
- end
124
+ options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
125
+ annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
126
+ public_url: public_url }
127
+ started = if plan
128
+ with_tunnel(provider, plan) { start(**options, public_notes: provider.start_lines(plan)) }
129
+ else
130
+ start(**options)
131
+ end
132
+ started ? 0 : 1
107
133
  end
108
- rescue Lan::Error, Grants::Insecure => e
134
+ rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused => e
109
135
  warn "Error: #{e.message}"
110
136
  1
111
137
  rescue Interrupt
@@ -115,16 +141,52 @@ module Samagotchi
115
141
  CLI::Exit::INTERRUPTED
116
142
  end
117
143
 
144
+ # The tunnel on while the block runs (the server), off after it
145
+ # whatever ends it (Ctrl-C, SIGTERM, SIGHUP, a port taken), and only
146
+ # once #up succeeded: a refusal before it, or a failed #up, turns off
147
+ # nothing (a live tunnel of another chi web stays).
148
+ def self.with_tunnel(provider, plan)
149
+ provider.up(plan)
150
+ upped = true
151
+ yield
152
+ ensure
153
+ tunnel_down(provider, plan) if upped
154
+ end
155
+
156
+ # The tunnel's teardown, before any line (a hung-up terminal can't
157
+ # write): a failure is one warning line, never a crash or another exit
158
+ # status, and a second Ctrl-C during it only cuts it short.
159
+ def self.tunnel_down(provider, plan)
160
+ line = begin
161
+ provider.down_line(plan, provider.down(plan))
162
+ rescue Tunnel::Refused, SignalException => e
163
+ provider.down_warning(plan, e)
164
+ end
165
+ say(line) if line
166
+ end
167
+
168
+ # A line that may meet a terminal gone (SIGHUP): never an error.
169
+ def self.say(line)
170
+ puts line
171
+ $stdout.flush
172
+ rescue IOError, SystemCallError
173
+ nil
174
+ end
175
+
118
176
  # @param hub [SessionHub, nil] the session projection the page streams
119
177
  # from; built over the app's state dir unless given
120
178
  # @param lan [Lan::Choice, nil] also listen on this LAN address, where
121
179
  # every request but this machine's needs the access token
122
180
  # @param token_path [String] the LAN access token's file
181
+ # @param public_url [PublicUrl, nil] behind an auth proxy
182
+ # (web.public_url, or --tunnel's): every request needs the access token
183
+ # @param public_notes [Array<String>] lines after the proxied link's
184
+ # (the tunnel's)
123
185
  # @return [Boolean] false when the port was taken (said so on stderr)
124
186
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
125
187
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
126
188
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
127
- working_set: Config::BY_KEY["web.working_set"].default)
189
+ working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [])
128
190
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
129
191
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
130
192
  end
@@ -133,14 +195,18 @@ module Samagotchi
133
195
  url ||= "http://#{url_host(host)}:#{port}/"
134
196
 
135
197
  hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
136
- if lan
137
- Token.load_or_create(token_path)
138
- lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
139
- end
198
+ Token.load_or_create(token_path) if lan || public_url
199
+ lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
200
+ public_token = Token::Source.new(token_path) if public_url
140
201
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
141
- annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set)
202
+ annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
203
+ public_url: public_url, public_token: public_token)
142
204
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
143
205
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
206
+ if public_url
207
+ Samagotchi::Log.info(:web, "public_url", origin: public_url.origin, identity_header: public_url.identity_label,
208
+ owner: public_url.owner ? "set" : "none")
209
+ end
144
210
  # Subscribed before the first scan, which makes the first check.
145
211
  hub.subscribe(method(:on_hub_event))
146
212
  hub.start
@@ -148,15 +214,20 @@ module Samagotchi
148
214
  # the in-use line instead.
149
215
  running_path = Running.path(state_dir: state_dir)
150
216
  started = lambda do
151
- record_running(running_path, port)
217
+ record_running(running_path, port, public_url)
152
218
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
153
219
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
220
+ if public_url
221
+ puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir), notes: public_notes)
222
+ end
154
223
  puts "Press Ctrl-C to stop."
155
224
  $stdout.flush # a log file isn't line-buffered
156
225
  if open_browser
226
+ # With web.public_url the page opens through the proxy.
227
+ open_target = public_url ? public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)) : url
157
228
  Thread.new do
158
229
  sleep 0.8
159
- open_url(url)
230
+ open_url(open_target)
160
231
  end
161
232
  end
162
233
  end
@@ -184,11 +255,11 @@ module Samagotchi
184
255
 
185
256
  class LanListenError < StandardError; end
186
257
 
187
- # Where this chi web listens, for chi share's link (Web::Running); a
188
- # write that fails leaves the server running and the readers on
189
- # web.port.
190
- def self.record_running(path, port)
191
- Running.record(port: port, path: path)
258
+ # Where this chi web listens, and its public_url (config's or the
259
+ # tunnel's), for chi share's link (Web::Running); a write that fails
260
+ # leaves the server running and the readers on web.port and config.
261
+ def self.record_running(path, port, public_url = nil)
262
+ Running.record(port: port, path: path, public_url: public_url&.origin)
192
263
  rescue SystemCallError => e
193
264
  Samagotchi::Log.warn(:web, "running_record_failed", error: e.class.name)
194
265
  end
@@ -224,6 +295,51 @@ module Samagotchi
224
295
  raise LanListenError, e.message
225
296
  end
226
297
 
298
+ # web.public_url as Server runs it, nil when unset.
299
+ # @raise [PublicUrl::Invalid]
300
+ def self.public_url_setting
301
+ PublicUrl.from_config(url: Samagotchi::Config.get("web.public_url"),
302
+ identity_header: Samagotchi::Config.get("web.proxy.identity_header"),
303
+ owner: Samagotchi::Config.get("web.proxy.owner"))
304
+ end
305
+
306
+ # What web.public_url adds to the start lines: the proxied link (a
307
+ # one-time code in its fragment), who the owner is, +notes+ (the
308
+ # tunnel's) and, at a terminal, the link's QR code. On this machine the
309
+ # page opens through the proxy too; a direct request takes the token
310
+ # as Authorization: Bearer only (no cookie for 127.0.0.1, which every
311
+ # local port would get).
312
+ def self.public_lines(public_url, token:, codes: owner_codes, notes: [], qr: $stdout.tty?)
313
+ unless token
314
+ return ["Proxied: #{public_url.origin}/ (no access token: its file is gone; chi web --new-token makes one)",
315
+ *notes]
316
+ end
317
+
318
+ who = if public_url.identity_header.nil? then "token only, no identity header"
319
+ elsif public_url.owner then "owner #{public_url.owner} by #{public_url.identity_header}, and the token"
320
+ else "nobody is the owner by identity: web.proxy.owner is unset"
321
+ end
322
+ link = public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))
323
+ lines = ["Proxied: #{link} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
324
+ "(chi web again prints a new one)",
325
+ " (#{who}; on this machine the page opens through the proxy too)", *notes]
326
+ lines.concat(QR.lines(link)) if qr
327
+ lines
328
+ end
329
+
330
+ # The owner's one-time link codes, next to the grants.
331
+ def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
332
+
333
+ # A second chi web, when this machine's config has web.public_url (or
334
+ # the running chi web recorded one: Running.live_public_url): the link
335
+ # from there and the token file, never from what answered on the port.
336
+ def self.running_public_lines(public_url, token_path: Token.path, codes: owner_codes)
337
+ return ["Proxied: #{public_url.origin}/"] unless Token.read(token_path)
338
+
339
+ ["Proxied: #{public_url.link(codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))} (once, within " \
340
+ "#{LinkCodes::OWNER_TTL / 60} minutes)"]
341
+ end
342
+
227
343
  def self.host_setting(host)
228
344
  (host || Samagotchi::Config.get("web.host")).to_s.strip
229
345
  end