samagotchi 0.130.0 → 0.131.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +15 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/desktop.md +28 -9
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/participants.md +36 -9
  9. data/docs/internals/prompt-caching.md +5 -2
  10. data/docs/sessions.md +4 -2
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/cache_confirm.rb +29 -9
  13. data/lib/samagotchi/desktop/macos.rb +99 -19
  14. data/lib/samagotchi/desktop_command.rb +7 -2
  15. data/lib/samagotchi/engine.rb +31 -2
  16. data/lib/samagotchi/guardrails/rules.rb +2 -2
  17. data/lib/samagotchi/guardrails/targets.rb +13 -0
  18. data/lib/samagotchi/level_setting_command.rb +3 -2
  19. data/lib/samagotchi/session.rb +49 -4
  20. data/lib/samagotchi/session_commands.rb +4 -4
  21. data/lib/samagotchi/toolkit_command.rb +2 -2
  22. data/lib/samagotchi/tools/execute.rb +5 -2
  23. data/lib/samagotchi/tools/task_runtime.rb +4 -2
  24. data/lib/samagotchi/update_command.rb +9 -1
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +47 -8
  27. data/lib/samagotchi/web/participant_review.rb +9 -2
  28. data/lib/samagotchi/web/participant_view.rb +22 -6
  29. data/lib/samagotchi/web/proxy_gate.rb +6 -0
  30. data/lib/samagotchi/web/public/annotate_bar.js +6 -1
  31. data/lib/samagotchi/web/public/app.js +14 -5
  32. data/lib/samagotchi/web/public/participant.js +24 -6
  33. data/lib/samagotchi/web/public/verdict_card.js +48 -4
  34. data/lib/samagotchi/web/public/verdict_fresh.js +8 -4
  35. data/lib/samagotchi/web/public/verify_target.js +73 -0
  36. data/lib/samagotchi/web/token.rb +20 -1
  37. data/lib/samagotchi/worker.rb +7 -0
  38. metadata +2 -1
@@ -55,6 +55,7 @@ require_relative "../review_start"
55
55
  require_relative "../parent_answers_command"
56
56
  require_relative "../guardrails/parent_policy"
57
57
  require_relative "markdown_renderer"
58
+ require_relative "token"
58
59
  require_relative "capabilities"
59
60
  require_relative "editor"
60
61
  require_relative "../message_parts"
@@ -431,6 +432,10 @@ module Samagotchi
431
432
  end
432
433
 
433
434
  TOKEN_COOKIE = "chi_token"
435
+ # The owner's cookie holds the token, but on the LAN a loopback
436
+ # peer's holds Token.loopback_cookie (#owner_cookie_value): a cookie
437
+ # for 127.0.0.1 goes to every server on any of its ports (a dev
438
+ # server that logs headers), and that value works nowhere else.
434
439
  # 400 days, the longest a browser keeps a cookie (Chrome's cap). No
435
440
  # Secure: the LAN page is plain http. Lax, not Strict: a token link
436
441
  # opened from another site (a web messenger) makes its 303 chain
@@ -473,10 +478,10 @@ module Samagotchi
473
478
  principal = principal_for(req)
474
479
  return [nil, refused_token(req)] if principal.nil?
475
480
 
476
- # The cookie gets the token that was presented: the owner's, or the
477
- # participant's grant's.
481
+ # The cookie gets the participant's grant's token, or the owner's
482
+ # cookie value (the loopback one on the LAN's 127.0.0.1).
478
483
  if token_trade?(req)
479
- token = req.env[GRANT_TOKEN_ENV] || current_token
484
+ token = req.env[GRANT_TOKEN_ENV] || owner_cookie_value(req)
480
485
  return [nil, token_redirect(req, token)] unless fragment_trade?(req)
481
486
  return [nil, form_trade_redirect(req, token)] if form_post?(req)
482
487
 
@@ -492,7 +497,9 @@ module Samagotchi
492
497
  # one, looked up only when no owner token was given), wherever the
493
498
  # request comes from, this machine's loopback too; with neither, the
494
499
  # owner only on a chi web that asks no token (loopback only: no LAN
495
- # mode, no web.public_url), else nil. An ended or unknown grant's
500
+ # mode, no web.public_url), else nil. The owner's cookie on the LAN's
501
+ # loopback is the loopback value (#loopback_cookie: it stands for the
502
+ # token in +given+). An ended or unknown grant's
496
503
  # token is nil anywhere: it never falls back to the owner.
497
504
  # @return [Principal, nil]
498
505
  # With web.public_url (ProxyGate) every request needs a token, and a
@@ -512,12 +519,39 @@ module Samagotchi
512
519
  # through the proxy (curl, chi's own callers) presents the Bearer
513
520
  # header only: no cookie, and no trade that would set one. A cookie for
514
521
  # 127.0.0.1 goes to every server on any of its ports. A LAN peer
515
- # (web.host lan) keeps the LAN's token link and cookie.
522
+ # (web.host lan) keeps the LAN's token link and cookie; a loopback
523
+ # peer of a LAN chi web reads its cookie through #loopback_cookie.
516
524
  def presented_tokens(req)
517
525
  return [traded_token(req)] if token_trade?(req)
518
526
  return [bearer_token(req)] if direct_public?(req)
519
527
 
520
- [bearer_token(req), req.cookies[token_cookie_name(req)]]
528
+ cookie = req.cookies[token_cookie_name(req)]
529
+ [bearer_token(req), loopback_cookie_peer?(req) ? loopback_cookie(cookie) : cookie]
530
+ end
531
+
532
+ # A loopback peer of a LAN chi web, whose owner cookie holds the
533
+ # loopback value. With web.public_url, #direct_public? takes the
534
+ # Bearer only. A request with a proxy's trace (one the user forwards
535
+ # to loopback by hand) is the LAN's: a leaked loopback value must not
536
+ # work from off this machine through it.
537
+ def loopback_cookie_peer?(req)
538
+ !@lan.nil? && @public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) &&
539
+ !proxy_trace?(req.env)
540
+ end
541
+
542
+ # What a trade sets as the owner's cookie.
543
+ def owner_cookie_value(req)
544
+ loopback_cookie_peer?(req) ? Token.loopback_cookie(current_token) : current_token
545
+ end
546
+
547
+ # A loopback peer's cookie: the loopback value stands for the owner's
548
+ # token, a grant's chp_ token is itself, and anything else, the token
549
+ # itself included (a 0.130.0 cookie, #refused_token clears it), is
550
+ # nothing.
551
+ def loopback_cookie(value)
552
+ return current_token if token_match?(value, Token.loopback_cookie(current_token))
553
+
554
+ Grants.token?(value) ? value : nil
521
555
  end
522
556
 
523
557
  def direct_public?(req)
@@ -622,10 +656,15 @@ module Samagotchi
622
656
  # The 401 for a request #principal_for refused: an ended grant's, else
623
657
  # unauthorized. On a peer the gate doesn't ask, a grant's cookie that
624
658
  # no longer works is cleared too, so the browser it was left in (the
625
- # owner's, who opened a participant link) is the owner's again.
659
+ # owner's, who opened a participant link) is the owner's again. A
660
+ # LAN chi web's loopback peer whose cookie is the token itself (set
661
+ # before the loopback value) has it cleared, so it stops riding to
662
+ # the other local ports.
626
663
  def refused_token(req)
627
664
  response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
628
- return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
665
+ cookie = req.cookies[token_cookie_name(req)]
666
+ stale = loopback_cookie_peer?(req) && token_match?(cookie, current_token)
667
+ return response unless stale || (!token_required?(req) && Grants.token?(cookie))
629
668
 
630
669
  status, headers, body = response
631
670
  [status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "participant_view"
4
+ require_relative "token"
4
5
  require_relative "participant_stream_filter"
5
6
  require_relative "participant_exposure"
6
7
  require_relative "../session_metrics"
@@ -39,10 +40,16 @@ module Samagotchi
39
40
 
40
41
  # Who reads: the participant (their author), or the owner through the
41
42
  # participant view (nobody's own). The session's folder places tool
42
- # paths; the owner's token is never shown.
43
+ # paths; the owner's token is never shown, nor (LAN) its loopback
44
+ # cookie value.
43
45
  def participant_viewer(req, session)
44
46
  author = principal(req).owner? ? nil : principal(req).grant.author
45
- ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [(@lan || @public_url) && current_token].compact)
47
+ ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
48
+ end
49
+
50
+ def owner_secrets
51
+ token = (@lan || @public_url) && current_token
52
+ [token, @lan && Token.loopback_cookie(token)].compact
46
53
  end
47
54
 
48
55
  # GET /api/sessions/:id/review: the participant page's data, always
@@ -211,7 +211,8 @@ module Samagotchi
211
211
  # @return [Array<Hash>] {role: "user", content, turn_id, author,
212
212
  # author_name, mine, automatic, images, at} and {role: "assistant",
213
213
  # content, tools: [{tool, path}], at}; no notes, steers, automatic
214
- # input or empty-answer markers. html is added by the caller, from
214
+ # input or empty-answer markers, but a wake turn's start as an
215
+ # automatic turn (#wake_start). html is added by the caller, from
215
216
  # this (redacted) content.
216
217
  def messages(shown, viewer, automatic: [])
217
218
  Array(shown).filter_map do |message|
@@ -220,6 +221,7 @@ module Samagotchi
220
221
  out = case field(message, :role).to_s
221
222
  when "user" then user_message(message, viewer, automatic)
222
223
  when "assistant" then assistant_message(message, viewer)
224
+ when "note" then wake_start(message)
223
225
  end
224
226
  out && Redact.deep(out, viewer)
225
227
  end
@@ -228,15 +230,15 @@ module Samagotchi
228
230
  # A user message a participant sees: a turn's prompt (it has the
229
231
  # turn's id) or a person's line merged into a turn. Every other user
230
232
  # message is dropped: the owner's `!` line and its output, a
231
- # delegate's report (a wake turn's input), the notes chi adds (a
232
- # Continue the owner declined). Any message from a source that isn't a
233
- # person's is dropped whatever it is.
233
+ # delegate's report (a wake turn's input: only its turn's start is
234
+ # kept, #wake_start), the notes chi adds (a Continue the owner
235
+ # declined). Any other message from a source that isn't a person's is
236
+ # dropped whatever it is.
234
237
  def user_message(message, viewer, automatic = [])
235
238
  source = field(message, :source).to_s
236
- return nil unless PERSON_SOURCES.include?(source)
237
-
238
239
  turn_id = field(message, :turn_id)
239
240
  merged = field(message, :merged)
241
+ return (turn_id && !merged ? wake_start(message) : nil) unless PERSON_SOURCES.include?(source)
240
242
  return nil unless turn_id || merged
241
243
 
242
244
  out = { "role" => "user", **time_field(message) }
@@ -254,6 +256,20 @@ module Samagotchi
254
256
  out
255
257
  end
256
258
 
259
+ # A wake turn's start (its prompt a delegate's report or a context
260
+ # note, MessageDisplay's turn_start note): the turn's boundary only,
261
+ # as an automatic turn with no text, so its answer is never read as
262
+ # one more reply of the turn before (Verify, verdict cards, claim
263
+ # links group by these). Nothing of the report or the note. nil for
264
+ # a message that starts no turn.
265
+ def wake_start(message)
266
+ turn_id = field(message, :turn_id)
267
+ return nil if turn_id.to_s.empty?
268
+ return nil if field(message, :role).to_s == "note" && field(message, :turn_start) != true
269
+
270
+ { "role" => "user", **time_field(message), "content" => "", "automatic" => true, "turn_id" => turn_id.to_s }
271
+ end
272
+
257
273
  def assistant_message(message, viewer)
258
274
  parts = field(message, :parts)
259
275
  tools = parts.is_a?(Hash) ? Array(field(parts, :tools)) : []
@@ -74,6 +74,12 @@ module Samagotchi
74
74
  return true if @public_url.host?(env["HTTP_HOST"])
75
75
  return true if @public_url.identity_env && env.key?(@public_url.identity_env)
76
76
 
77
+ proxy_trace?(env)
78
+ end
79
+
80
+ # Whether a request carries a proxy's trace (ProxyTrace: X-Forwarded-*,
81
+ # Forwarded, X-Real-IP, a known identity header).
82
+ def proxy_trace?(env)
77
83
  names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
78
84
  names.any? { |name| ProxyTrace.header?(name) }
79
85
  end
@@ -33,6 +33,9 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
33
33
  // @param onQuote (text) runs with the quote (and a preset's note) to append
34
34
  // to the composer; the selection is cleared and the bar hidden first
35
35
  // @param disabledPreset (preset) → a reason to disable it, else null
36
+ // @param offeredPreset (preset, source, range) → whether the selection
37
+ // offers it (default: every preset); one not offered is hidden, not
38
+ // disabled (Verify outside a turn's final answer: verify_target.js)
36
39
  // @param actions () => the open session's annotation actions
37
40
  // (annotate_actions.js annotationActions), read on every selection
38
41
  // @param running () => whether a turn runs (a send pill may wait for it)
@@ -58,6 +61,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
58
61
  // next frame (call it on scroll)
59
62
  export function createAnnotateBar({
60
63
  presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
64
+ offeredPreset = () => true,
61
65
  actions = () => [], running = () => false, onAction = () => {},
62
66
  dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
63
67
  doc = globalThis.document, win = globalThis.window,
@@ -85,6 +89,7 @@ export function createAnnotateBar({
85
89
  const presetButtons = [...bar.children].slice(1, presets.length + 1);
86
90
  function markPresets() {
87
91
  presetButtons.forEach((btn, i) => {
92
+ btn.hidden = !!pendingSource && !offeredPreset(presets[i], pendingSource, pendingRange);
88
93
  const reason = disabledPreset(presets[i]);
89
94
  btn.disabled = !!reason;
90
95
  btn.title = reason || presetParts(presets[i]).text;
@@ -259,7 +264,7 @@ export function createAnnotateBar({
259
264
  return;
260
265
  }
261
266
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
262
- if (btn.disabled || (preset && disabledPreset(preset))) return;
267
+ if (btn.disabled || btn.hidden || (preset && disabledPreset(preset))) return;
263
268
  const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
264
269
  const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
265
270
  win.getSelection()?.removeAllRanges();
@@ -65,6 +65,7 @@ import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
65
65
  import { parsePresets } from "./annotate_presets.js";
66
66
  import { createAnnotateBar } from "./annotate_bar.js";
67
67
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
68
+ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
68
69
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
69
70
  import { copyText, installCopy } from "./copy.js";
70
71
  import { createQuestionCards } from "./question_cards.js";
@@ -1351,6 +1352,8 @@ function placeVerdict(record) {
1351
1352
  placeVerdictWithFresh(historyEl, record, {
1352
1353
  who: badge ? badge.name : "You",
1353
1354
  author: record.by || null,
1355
+ roots: CLAIM_ROOTS,
1356
+ opts: claimOpts(),
1354
1357
  onMissingCheck: (missing) => {
1355
1358
  const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
1356
1359
  if (hash) location.hash = hash;
@@ -1959,8 +1962,9 @@ const updateSheet = createUpdateSheet({
1959
1962
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
1960
1963
 
1961
1964
  // Annotate (annotate_bar.js): a selection in the conversation offers
1962
- // "Annotate" and the presets (then the built-in Verify: verify_preset.js),
1963
- // which append a quote to the composer, the session's plugins' pills
1965
+ // "Annotate" and the presets (then the built-in Verify: verify_preset.js,
1966
+ // only in an ended turn's final answer: verify_target.js), which append a
1967
+ // quote to the composer, the session's plugins' pills
1964
1968
  // (chi.annotation, annotate_actions.js), which fill the composer with their
1965
1969
  // command line and the quote, or send it at once, and Copy link in a turn
1966
1970
  // (claimLinkFor).
@@ -1978,6 +1982,7 @@ annotateBar = createAnnotateBar({
1978
1982
  presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1979
1983
  disabledPreset: (preset) => preset === VERIFY_PRESET
1980
1984
  && verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
1985
+ offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
1981
1986
  canAnnotate: () => !!selected && current.owner !== "tui",
1982
1987
  roots: turnRoots,
1983
1988
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
@@ -1997,10 +2002,14 @@ annotateBar = createAnnotateBar({
1997
2002
  dock: () => $("#dock"),
1998
2003
  });
1999
2004
 
2005
+ // Verify's claim: a selection in an ended turn's final answer (the only
2006
+ // text Engine#verified_anchor takes; verify_target.js), else null and no
2007
+ // Verify on the bar.
2000
2008
  function verifyAnchorFor({ source, range }) {
2001
- if (!source || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
2002
- const turnId = turnIdOf(historyEl, source.root);
2003
- const quote = selectionClaim(source.root, range, claimOpts()).q;
2009
+ if (source?.kind !== "answer" || !range) return null;
2010
+ const running = liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null;
2011
+ const turnId = verifyTurnOf(historyEl, source.root, OWNER_FINAL, { running });
2012
+ const quote = turnId ? selectionClaim(source.root, range, claimOpts()).q : "";
2004
2013
  return turnId && quote ? { turn_id: turnId, quote } : null;
2005
2014
  }
2006
2015
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -23,6 +23,7 @@ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_f
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
25
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
26
+ import { PARTICIPANT_FINAL, verifyTurnOf } from "./verify_target.js";
26
27
  import { appendQuote } from "./annotations.js";
27
28
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
29
  import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
@@ -76,6 +77,8 @@ let drawnMessages = [];
76
77
  // A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
77
78
  // the thread is drawn; a later hash change shows another.
78
79
  let pendingClaim = claimFromHash(globalThis.location?.hash);
80
+ // Where a claim's text can be on this page (a claim link, a verdict's card).
81
+ const CLAIM_ROOTS = ".p-msg > .p-bubble";
79
82
  let claimNoteTimer = null;
80
83
 
81
84
  function el(tag, cls, text) {
@@ -259,7 +262,7 @@ function showPendingClaim() {
259
262
  return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
260
263
  }
261
264
  setView("thread");
262
- if (showClaim($("thread"), claim, { roots: ".p-msg > .p-bubble" })) return;
265
+ if (showClaim($("thread"), claim, { roots: CLAIM_ROOTS })) return;
263
266
  $("main").scrollTop = 0;
264
267
  showClaimNote("The linked turn isn't in this session.");
265
268
  }
@@ -330,6 +333,8 @@ function setView(view) {
330
333
  $("segThread").setAttribute("aria-selected", String(shown === "thread"));
331
334
  }
332
335
 
336
+ const AUTOMATIC_TURN = "An automatic turn";
337
+
333
338
  function messageNode(message) {
334
339
  const user = message.role === "user";
335
340
  const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
@@ -346,7 +351,10 @@ function messageNode(message) {
346
351
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
347
352
  if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
348
353
  node.append(who);
349
- const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
354
+ // An automatic turn (a task's, a wake turn's: a delegate report or a
355
+ // context note started it) opens its turn without its prompt.
356
+ const content = message.automatic && !message.content ? AUTOMATIC_TURN : message.content;
357
+ const text = content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
350
358
  node.append(el("div", "p-bubble", text));
351
359
  return node;
352
360
  }
@@ -423,6 +431,7 @@ function renderThread() {
423
431
  placeVerdictWithFresh(thread, record, {
424
432
  who,
425
433
  author,
434
+ roots: CLAIM_ROOTS,
426
435
  onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
427
436
  }, verifyFreshState());
428
437
  }
@@ -477,7 +486,7 @@ function currentTurnNodes() {
477
486
  const turn = data.current_turn;
478
487
  const nodes = [];
479
488
  if (turn.prompt !== undefined || turn.automatic) {
480
- nodes.push(messageNode({ role: "user", content: turn.automatic ? "An automatic turn" : turn.prompt, author: turn.author,
489
+ nodes.push(messageNode({ role: "user", content: turn.automatic ? AUTOMATIC_TURN : turn.prompt, author: turn.author,
481
490
  author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at, turn_id: turn.turn_id }));
482
491
  }
483
492
  const reply = el("div", "p-msg reply live");
@@ -653,7 +662,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
653
662
 
654
663
  // Annotate (annotate_bar.js, participant_annotate.js): a selection in the
655
664
  // Plan or the thread offers "Annotate" and the presets
656
- // (web.annotate_presets, then the built-in Verify: verify_preset.js), which
665
+ // (web.annotate_presets, then the built-in Verify: verify_preset.js, only
666
+ // in an ended turn's final reply: verify_target.js), which
657
667
  // append a quote to the comment box; nothing is sent. No plugin pills: a
658
668
  // review turn refuses `/` lines. Not in the owner's participant view, nor
659
669
  // while this participant can't comment.
@@ -661,6 +671,7 @@ const annotateBar = createAnnotateBar({
661
671
  presets: withVerify(parsePresets(body.annotatePresets)),
662
672
  disabledPreset: (preset) => preset === VERIFY_PRESET
663
673
  && data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
674
+ offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
664
675
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
665
676
  roots: () => [$("planDoc"), $("thread")],
666
677
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -671,9 +682,16 @@ const annotateBar = createAnnotateBar({
671
682
  dock: () => $("dock"),
672
683
  });
673
684
 
685
+ // Verify's claim: a selection in an ended turn's final reply (the last
686
+ // reply bubble after the turn's opening, as the owner's page and
687
+ // placeVerdict count turns: verify_target.js), the only text
688
+ // Engine#verified_anchor takes; else null and no Verify on the bar. Not an
689
+ // earlier reply, a line merged mid-turn, a prompt, the running turn or the
690
+ // Plan.
674
691
  function verifyAnchorFor({ source, range }) {
675
- const message = source?.root?.closest?.(".p-msg");
676
- const turnId = message?.dataset?.turnId;
692
+ if (source?.kind !== "reply" || !range) return null;
693
+ const running = data?.current_turn ? { turnId: data.current_turn.turn_id || null } : null;
694
+ const turnId = verifyTurnOf($("thread"), source.root, PARTICIPANT_FINAL, { running });
677
695
  if (!turnId) return null;
678
696
  const quote = selectionClaim(source.root, range).q;
679
697
  return quote ? { turn_id: turnId, quote } : null;
@@ -1,4 +1,4 @@
1
- import { ownerClaimHash } from "./claim_link.js";
1
+ import { findClaim, ownerClaimHash } from "./claim_link.js";
2
2
 
3
3
  const TYPES = Object.freeze({
4
4
  confirmed: { icon: "✅", label: "confirmed" },
@@ -17,7 +17,14 @@ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
17
17
  return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
18
18
  }
19
19
 
20
- export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
20
+ // A verdict's card on its source turn: after the bubble that holds the
21
+ // quote (+roots+, the page's claim roots selector; +opts+, how it reads the
22
+ // page's text, claim_link.js), the answer's first; a quote not found (a
23
+ // bubble not drawn yet): at the turn's end. Cards on one bubble keep their
24
+ // order; a card already on the page is returned as it is.
25
+ export function placeVerdict(root, record, {
26
+ who = "Owner", author = null, onMissingCheck = null, roots = null, opts = {},
27
+ } = {}) {
21
28
  const anchor = record?.anchor;
22
29
  const type = TYPES[record?.verdict];
23
30
  if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
@@ -67,11 +74,48 @@ export function placeVerdict(root, record, { who = "Owner", author = null, onMis
67
74
  });
68
75
  card.append(heading, quote, reason, link);
69
76
 
70
- const previous = cards.filter((candidate) => candidate.dataset.sourceTurnId === anchor.turn_id).at(-1);
71
- (previous || source).after(card);
77
+ afterCards(quotedElement(source, anchor.quote, roots, opts) || turnEnd(source)).after(card);
72
78
  return card;
73
79
  }
74
80
 
81
+ // The element at +source+'s level that holds the quote: of the turn's
82
+ // roots after its prompt, the last match first (the answer the Engine
83
+ // checked the quote against comes after the turn's steps).
84
+ function quotedElement(source, quote, selector, opts) {
85
+ if (!selector || !source.parentElement) return null;
86
+ const level = source.parentElement;
87
+ const candidates = turnElements(source).flatMap((element) =>
88
+ [...(element.matches(selector) ? [element] : []), ...element.querySelectorAll(selector)]).reverse();
89
+ const hit = findClaim(candidates.map((candidate) => candidate.textContent), { q: quote }, opts);
90
+ if (!hit) return null;
91
+ let element = candidates[hit.at];
92
+ while (element && element.parentElement !== level) element = element.parentElement;
93
+ return element || null;
94
+ }
95
+
96
+ // +source+'s turn: its siblings after it up to the next turn's opening
97
+ // (or a gap divider before it).
98
+ function turnElements(source) {
99
+ const elements = [];
100
+ for (let next = source.nextElementSibling; next; next = next.nextElementSibling) {
101
+ if (next.matches("[data-turn-id]") || next.querySelector("[data-turn-id]")) break;
102
+ elements.push(next);
103
+ }
104
+ while (elements.at(-1)?.classList.contains("time-gap")) elements.pop();
105
+ return elements;
106
+ }
107
+
108
+ function turnEnd(source) {
109
+ return turnElements(source).at(-1) || source;
110
+ }
111
+
112
+ // +element+, or the last of the verdict cards right after it.
113
+ function afterCards(element) {
114
+ let last = element;
115
+ while (last.nextElementSibling?.classList.contains("verify-verdict")) last = last.nextElementSibling;
116
+ return last;
117
+ }
118
+
75
119
  export function nameVerdictCards(root, nameFor) {
76
120
  for (const card of root?.querySelectorAll?.(".verify-verdict[data-author]") || []) {
77
121
  const name = nameFor(card.dataset.author);
@@ -51,7 +51,8 @@ export function redrawFresh(root, fresh = {}) {
51
51
 
52
52
  // The card's Verify fresh part, drawn again from +fresh+: the fresh
53
53
  // verdicts, the running check, else the button (an unclear verdict, and a
54
- // viewer who could run a turn now).
54
+ // viewer who could run a turn now; "Verify fresh again" after a fresh
55
+ // verdict, each press another paid check).
55
56
  export function renderFresh(card, fresh = {}) {
56
57
  const doc = card.ownerDocument;
57
58
  const of = card.dataset.checkTurnId;
@@ -68,7 +69,10 @@ export function renderFresh(card, fresh = {}) {
68
69
  return link;
69
70
  };
70
71
 
71
- for (const result of (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of)) {
72
+ const results = (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of);
73
+ // Once a fresh verdict is in, the button says a press runs another one.
74
+ const label = results.length ? "Verify fresh again" : "Verify fresh";
75
+ for (const result of results) {
72
76
  const line = doc.createElement("p");
73
77
  line.className = `verify-fresh-result ${ICONS[result.verdict] ? result.verdict : ""}`.trim();
74
78
  line.textContent = `fresh: ${ICONS[result.verdict] || ""} ${result.verdict} — ${result.reason || ""}`;
@@ -89,7 +93,7 @@ export function renderFresh(card, fresh = {}) {
89
93
  const button = doc.createElement("button");
90
94
  button.type = "button";
91
95
  button.className = "verify-fresh-btn";
92
- button.textContent = "Verify fresh";
96
+ button.textContent = label;
93
97
  button.title = fresh.unavailableReason;
94
98
  button.disabled = true;
95
99
  const note = doc.createElement("p");
@@ -100,7 +104,7 @@ export function renderFresh(card, fresh = {}) {
100
104
  const button = doc.createElement("button");
101
105
  button.type = "button";
102
106
  button.className = "verify-fresh-btn";
103
- button.textContent = "Verify fresh";
107
+ button.textContent = label;
104
108
  button.title = "A new agent checks this claim against the files, with none of this session's context";
105
109
  button.addEventListener("click", () => startFresh(card, button, fresh));
106
110
  part.append(button);
@@ -0,0 +1,73 @@
1
+ // Where the Verify preset is offered (verify_preset.js), on both pages: only
2
+ // on text in an ended turn's final answer, the one place the server takes a
3
+ // claim from. Engine#verified_anchor records a Verify turn's card only when
4
+ // its quote is in the last answer of the source turn (AnswerTail.answer?:
5
+ // the turn's last assistant message with text, from its prompt up to the
6
+ // next prompt, merged lines and steers inside); anything else (an earlier
7
+ // reply, a step, a person's line, a reply still running) is refused with
8
+ // `quote_missing` and the turn runs as a plain comment, no card. So the
9
+ // page offers Verify where the server will accept it, and nowhere else.
10
+ //
11
+ // The turn is the page's own grouping (claim_link.js turnIdOf,
12
+ // verdict_card.js turnElements): the last turn opening ([data-turn-id]) at
13
+ // or before the selection, and its elements up to the next opening. Its
14
+ // final answer is the last of them matching the page's answer selector.
15
+ //
16
+ // Pure DOM reads (Element, compareDocumentPosition), so it runs under
17
+ // `node --test` with happy-dom. A module of its own: static JS is cached at
18
+ // unversioned URLs, and a new export on an old module would blank the page.
19
+
20
+ const PRECEDING = 2;
21
+
22
+ // The owner's page: an answer bubble (not a delegate report's reply, which
23
+ // a turn shows before its answer); a context note ends a turn's elements
24
+ // (a wake turn starts with one, and its bubble carries no turn id).
25
+ export const OWNER_FINAL = Object.freeze({ answers: ".bubble.output:not(.report-reply)", boundary: ".bubble.note" });
26
+ // The participant page: a reply's bubble.
27
+ export const PARTICIPANT_FINAL = Object.freeze({ answers: ".p-msg.reply > .p-bubble", boundary: null });
28
+
29
+ // The turn opening +node+ is in (the last [data-turn-id] at or before it in
30
+ // +container+) and whether it is the newest one: {anchor, last}, or null.
31
+ function openingOf(container, node) {
32
+ const anchors = [...container.querySelectorAll("[data-turn-id]")];
33
+ let at = -1;
34
+ anchors.forEach((anchor, i) => {
35
+ if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) at = i;
36
+ });
37
+ return at < 0 ? null : { anchor: anchors[at], last: at === anchors.length - 1 };
38
+ }
39
+
40
+ // The final answer of the turn whose opening is +anchor+: the last element
41
+ // matching +answers+ among the opening's siblings after it, up to the next
42
+ // opening or a +boundary+ element. null when it has none.
43
+ function finalAnswer(container, anchor, { answers, boundary }) {
44
+ let top = anchor;
45
+ while (top && top.parentElement !== container) top = top.parentElement;
46
+ if (!top) return null;
47
+ let final = null;
48
+ for (let el = top.nextElementSibling; el; el = el.nextElementSibling) {
49
+ if (el.matches("[data-turn-id]") || el.querySelector("[data-turn-id]")) break;
50
+ if (boundary && el.matches(boundary)) break;
51
+ const inside = [...el.querySelectorAll(answers)];
52
+ if (inside.length) final = inside[inside.length - 1];
53
+ else if (el.matches(answers)) final = el;
54
+ }
55
+ return final;
56
+ }
57
+
58
+ // The turn id Verify anchors to for a selection in +root+ (the annotate
59
+ // source's root) in +container+, or null where Verify isn't offered: not in
60
+ // a turn, not in its final answer, or in the running turn. +rule+:
61
+ // OWNER_FINAL or PARTICIPANT_FINAL. +running+: null when no turn runs, else
62
+ // {turnId} (the running turn's id; null when unknown, then the newest turn
63
+ // counts as the running one) — a running turn's answer isn't final yet.
64
+ export function verifyTurnOf(container, root, rule, { running = null } = {}) {
65
+ if (!container || !root || !container.contains(root)) return null;
66
+ const opening = openingOf(container, root);
67
+ if (!opening) return null;
68
+ const turnId = opening.anchor.dataset.turnId || null;
69
+ if (!turnId) return null;
70
+ if (running && (opening.last || (running.turnId && running.turnId === turnId))) return null;
71
+ const final = finalAnswer(container, opening.anchor, rule);
72
+ return final && (final === root || final.contains(root)) ? turnId : null;
73
+ }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "openssl"
4
5
  require "securerandom"
5
6
  require_relative "../atomic_file"
6
7
 
@@ -14,11 +15,15 @@ module Samagotchi
14
15
  # can read, $XDG_STATE_HOME/samagotchi/web-token (0600). It is made on
15
16
  # the first LAN start and kept, so a phone's bookmark survives
16
17
  # restarts; `chi web --new-token` replaces it. A loopback-only chi web
17
- # never reads it.
18
+ # never reads it. The owner's cookie for this machine's 127.0.0.1
19
+ # holds #loopback_cookie, never the token: a server on any other local
20
+ # port is sent that cookie too.
18
21
  module Token
19
22
  FILE = "web-token"
20
23
  # 32 random bytes: 43 URL-safe characters.
21
24
  BYTES = 32
25
+ LOOPBACK_PREFIX = "chl_"
26
+ LOOPBACK_LABEL = "chi web loopback cookie v1"
22
27
 
23
28
  module_function
24
29
 
@@ -49,6 +54,20 @@ module Samagotchi
49
54
  SecureRandom.urlsafe_base64(BYTES)
50
55
  end
51
56
 
57
+ # The owner's 127.0.0.1 cookie on the LAN: an HMAC of a label keyed
58
+ # by +token+, so a leaked one gives nothing about the token, and
59
+ # rotating the token ends it. The gate takes it only as that cookie,
60
+ # from an unproxied loopback peer (App#loopback_cookie_peer?).
61
+ # @return [String, nil] "chl_" and 43 URL-safe characters; nil
62
+ # without a token
63
+ def loopback_cookie(token)
64
+ return nil if token.nil? || token.empty?
65
+
66
+ # URL-safe base64 without padding (pack: base64 isn't a default gem).
67
+ digest = OpenSSL::HMAC.digest("SHA256", token, LOOPBACK_LABEL)
68
+ LOOPBACK_PREFIX + [digest].pack("m0").tr("+/", "-_").delete("=")
69
+ end
70
+
52
71
  # Written whole under a temporary name, then renamed over +path+: a
53
72
  # reader never sees half a token. The file is 0600 from the start,
54
73
  # its folder 0700 when this makes it.
@@ -251,6 +251,13 @@ module Samagotchi
251
251
  ChildMove.clear_starting(@session_id, state_dir: @state_dir)
252
252
  ChildMove.adopt(@session_id, state_dir: @state_dir)
253
253
  @session = Session.load(@session_id, state_dir: @state_dir)
254
+ # A newer chi saved it: this worker keeps the fields it doesn't know
255
+ # but runs the session without them (docs/sessions.md).
256
+ if @session.newer_writer?
257
+ unknown = @session.extra_fields.keys.join(",")
258
+ Log.warn(:worker, "newer_writer", sid: @session_id, version: VERSION, written_by: @session.written_by,
259
+ unknown_fields: (unknown unless unknown.empty?))
260
+ end
254
261
  drop_dead_question
255
262
  # The running turn, on disk at its iteration boundaries.
256
263
  @checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)