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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "time"
9
+ require_relative "../atomic_file"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # One-time link codes (web.public_url): what a link's fragment carries
14
+ # instead of a token (https://<public url>/#code=chc_…). A fragment
15
+ # outlives the proxy's sign-in redirects, so the sign-in page sits at
16
+ # that URL (its scripts can read it) and it lands in the browser's
17
+ # history: a code works once, then nothing. The page trades it at
18
+ # POST /api/token for the token's cookie.
19
+ #
20
+ # A code is the owner's (10 minutes) or a grant's (until the grant
21
+ # expires). Nothing secret is kept: the store has each code's digest,
22
+ # and a grant's token sealed (AES-256-GCM) under a key derived from the
23
+ # code itself, so only the code's holder can open it. The store is
24
+ # next to the grants (web-grants/link-codes.json, 0600 in that 0700
25
+ # folder), written under a lock: chi web and a local chi share both
26
+ # issue codes.
27
+ class LinkCodes
28
+ FILE = "link-codes.json"
29
+ LOCK = "link-codes.lock"
30
+ PREFIX = "chc_"
31
+ OWNER_TTL = 600
32
+ CODE_RE = /\Achc_[A-Za-z0-9_-]{43}\z/
33
+
34
+ # @param grants_path [String] the grants store's path (Grants#path)
35
+ def self.for_grants(grants_path, clock: -> { Time.now }) = new(File.join(File.dirname(grants_path), FILE), clock: clock)
36
+
37
+ def self.code?(value) = value.is_a?(String) && CODE_RE.match?(value)
38
+
39
+ attr_reader :path
40
+
41
+ def initialize(path, clock: -> { Time.now })
42
+ @path = path
43
+ @clock = clock
44
+ end
45
+
46
+ # @param kind [:owner, :grant]
47
+ # @param token [String, nil] the grant's token (sealed), nil for the owner
48
+ # @param ttl [Numeric] seconds
49
+ # @return [String] the code
50
+ def issue(kind:, ttl:, token: nil)
51
+ code = "#{PREFIX}#{SecureRandom.urlsafe_base64(32)}"
52
+ entry = { "digest" => digest(code), "kind" => kind.to_s, "expires_at" => (@clock.call + ttl).utc.iso8601 }
53
+ entry["sealed"] = seal(code, token) if token
54
+ update { |entries| entries << entry }
55
+ code
56
+ end
57
+
58
+ # The code's [kind, token] (token nil for the owner's), once: the code
59
+ # is gone after. nil for an unknown, used or expired code.
60
+ def redeem(code)
61
+ return nil unless LinkCodes.code?(code)
62
+
63
+ wanted = digest(code)
64
+ found = nil
65
+ update do |entries|
66
+ entries.reject do |entry|
67
+ next false unless same_digest?(entry["digest"].to_s, wanted)
68
+
69
+ found = entry
70
+ true
71
+ end
72
+ end
73
+ return nil unless found
74
+ return nil if Time.iso8601(found["expires_at"]) <= @clock.call
75
+
76
+ kind = found["kind"] == "grant" ? :grant : :owner
77
+ token = found["sealed"] && unseal(code, found["sealed"])
78
+ return nil if kind == :grant && token.nil?
79
+
80
+ [kind, token]
81
+ rescue ArgumentError
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def digest(code) = Digest::SHA256.hexdigest(code)
88
+
89
+ def same_digest?(left, right) = left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
90
+
91
+ def key(code) = OpenSSL::KDF.hkdf(code, salt: "chi-link-code", info: "seal", length: 32, hash: "SHA256")
92
+
93
+ def seal(code, token)
94
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
95
+ cipher.key = key(code)
96
+ iv = cipher.random_iv
97
+ cipher.auth_data = ""
98
+ sealed = cipher.update(token) + cipher.final
99
+ [iv + cipher.auth_tag + sealed].pack("m0")
100
+ end
101
+
102
+ def unseal(code, value)
103
+ raw = value.unpack1("m0")
104
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
105
+ cipher.key = key(code)
106
+ cipher.iv = raw[0, 12]
107
+ cipher.auth_tag = raw[12, 16]
108
+ cipher.auth_data = ""
109
+ cipher.update(raw[28..]) + cipher.final
110
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
111
+ nil
112
+ end
113
+
114
+ # Read, change and write under the lock; expired codes go. The block
115
+ # gets the live codes and returns the codes to keep.
116
+ def update
117
+ dir = File.dirname(@path)
118
+ FileUtils.mkdir_p(dir, mode: 0o700)
119
+ File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
120
+ lock.flock(File::LOCK_EX)
121
+ now = @clock.call
122
+ stored = read
123
+ entries = yield(stored.reject { |entry| expired?(entry, now) })
124
+ # Written only when it changes: a guessed code costs no write.
125
+ next if entries == stored
126
+
127
+ AtomicFile.write(@path, "#{JSON.generate({ "version" => 1, "codes" => entries })}\n", perm: 0o600)
128
+ end
129
+ end
130
+
131
+ def expired?(entry, now)
132
+ Time.iso8601(entry["expires_at"].to_s) <= now
133
+ rescue ArgumentError
134
+ true
135
+ end
136
+
137
+ def read
138
+ data = JSON.parse(File.read(@path))
139
+ codes = data.is_a?(Hash) ? data["codes"] : nil
140
+ codes.is_a?(Array) ? codes.select { |entry| entry.is_a?(Hash) } : []
141
+ rescue Errno::ENOENT, JSON::ParserError
142
+ []
143
+ end
144
+ end
145
+ end
146
+ end
@@ -57,11 +57,18 @@ module Samagotchi
57
57
  # request's env for #token_gate's answer (#grant_ended_response). A
58
58
  # grant whose session was archived or is gone is revoked here (archive
59
59
  # and delete revoke too; this catches retention's prune and a delete
60
- # from another process).
60
+ # from another process). A grant bound to an identity (Grant#identity)
61
+ # is honoured only on a proxied request with that identity
62
+ # (ProxyGate): never on the LAN or this machine.
63
+ # @param identity [String, nil] the proxied request's identity
61
64
  # @return [Principal, nil]
62
- def participant_principal(req, tokens)
65
+ def participant_principal(req, tokens, identity: nil, proxied: false)
63
66
  tokens.each do |token|
64
67
  grant = @grants.lookup(token) or next
68
+ if grant.identity && !(proxied && identity == grant.identity)
69
+ req.env[ProxyGate::REFUSED_ENV] = "grant #{grant.id} is bound to another identity than #{identity || "(none)"}"
70
+ next
71
+ end
65
72
  grant = settle_grant(grant)
66
73
  state = grant.state(@grants.now)
67
74
  if state == :active
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  # paths; the owner's token is never shown.
40
40
  def participant_viewer(req, session)
41
41
  author = principal(req).owner? ? nil : principal(req).grant.author
42
- ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [@lan && current_token].compact)
42
+ ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [(@lan || @public_url) && current_token].compact)
43
43
  end
44
44
 
45
45
  # GET /api/sessions/:id/review: the participant page's data, always
@@ -108,7 +108,7 @@ module Samagotchi
108
108
  def turn_started(event, viewer)
109
109
  origin = TurnOrigin.of(field(event, :origin))
110
110
  author = field(event, :author) || origin.author
111
- out = { "type" => "turn_started", **id_fields(event) }
111
+ out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
112
112
  out.merge!(author_fields(author, field(event, :author_name), viewer))
113
113
  prompt = field(event, :prompt)
114
114
  if field(event, :continue)
@@ -164,8 +164,8 @@ module Samagotchi
164
164
  # analytics records' +automatic+, and the running one's): their
165
165
  # prompt is shown as an automatic turn, without its text
166
166
  # @return [Array<Hash>] {role: "user", content, turn_id, author,
167
- # author_name, mine, automatic, images} and {role: "assistant",
168
- # content, tools: [{tool, path}]}; no notes, steers, automatic
167
+ # author_name, mine, automatic, images, at} and {role: "assistant",
168
+ # content, tools: [{tool, path}], at}; no notes, steers, automatic
169
169
  # input or empty-answer markers. html is added by the caller, from
170
170
  # this (redacted) content.
171
171
  def messages(shown, viewer, automatic: [])
@@ -194,7 +194,7 @@ module Samagotchi
194
194
  merged = field(message, :merged)
195
195
  return nil unless turn_id || merged
196
196
 
197
- out = { "role" => "user" }
197
+ out = { "role" => "user", **time_field(message) }
198
198
  if turn_id && automatic.include?(turn_id.to_s)
199
199
  out.merge!("content" => "", "automatic" => true, "turn_id" => turn_id.to_s)
200
200
  return out
@@ -215,7 +215,7 @@ module Samagotchi
215
215
  content = field(message, :content).to_s
216
216
  return nil if content.strip.empty? && rows.empty?
217
217
 
218
- out = { "role" => "assistant", "content" => content }
218
+ out = { "role" => "assistant", "content" => content, **time_field(message) }
219
219
  out["tools"] = rows unless rows.empty?
220
220
  out
221
221
  end
@@ -287,6 +287,13 @@ module Samagotchi
287
287
  out
288
288
  end
289
289
 
290
+ # When it happened (MessageTime's stamp, a turn's start): the page's
291
+ # hover and gap dividers. A wall time only, nothing of what was done.
292
+ def time_field(item)
293
+ at = field(item, :at)
294
+ at.is_a?(String) && !at.empty? ? { "at" => at } : {}
295
+ end
296
+
290
297
  def id_fields(event)
291
298
  out = seq(event)
292
299
  turn_id = field(event, :turn_id)
@@ -57,10 +57,10 @@ module Samagotchi
57
57
 
58
58
  issued = Participants.invite(@grants, session_id: id, name: body["name"], kind: (body["kind"] || "human").to_s,
59
59
  ttl: ttl, cap: cap, state_dir: default_state_dir)
60
- link = participant_link(req, id, issued.token)
60
+ link = participant_link(req, id, issued.token, expires_at: issued.grant.expires_at)
61
61
  warning = ReviewStart.share_warning(Session.load(id, state_dir: default_state_dir))
62
62
  json_response(201, { participant: issued.grant.public_h(@grants.now), token: issued.token,
63
- link: link, lan: !@lan.nil?, qr_svg: QR.svg(link), warning: warning })
63
+ link: link, lan: !@lan.nil? || !@public_url.nil?, qr_svg: QR.svg(link), warning: warning })
64
64
  rescue Grants::Invalid => e
65
65
  error_response(400, "invalid_participant", e.message)
66
66
  end
@@ -115,10 +115,17 @@ module Samagotchi
115
115
  error_response(400, "invalid_participant", e.message)
116
116
  end
117
117
 
118
- # The link a participant opens: this chi web's LAN address when it has
119
- # one (the owner may be asking on loopback), else the Host asked; the
120
- # token traded for the cookie, the session in the fragment.
121
- def participant_link(req, id, token)
118
+ # The link a participant opens: web.public_url's with a one-time code
119
+ # for the token in the fragment (LinkCodes, until the grant expires;
120
+ # the page trades it, POST /api/token); else this chi web's LAN address
121
+ # when it has one (the owner may be asking on loopback), else the Host
122
+ # asked; the token traded for the cookie, the session in the fragment.
123
+ def participant_link(req, id, token, expires_at: nil)
124
+ if @public_url
125
+ ttl = expires_at ? [Time.iso8601(expires_at) - @grants.now, 1].max : LinkCodes::OWNER_TTL
126
+ return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
127
+ end
128
+
122
129
  host = req.get_header("HTTP_HOST").to_s
123
130
  port = host[/:(\d+)\z/, 1] || req.port.to_s
124
131
  name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "link_codes"
4
+ require_relative "principal"
5
+ require_relative "public_url"
6
+ require_relative "../log"
7
+ require_relative "../proxy_trace"
8
+
9
+ module Samagotchi
10
+ module Web
11
+ # chi web behind an auth proxy (web.public_url), mixed into App. With a
12
+ # PublicUrl every request needs a token (this machine's too: a proxy
13
+ # with no trace and a rewritten Host looks local). A request is
14
+ # *proxied* when it carries a proxy trace (ProxyTrace: X-Forwarded-*,
15
+ # Forwarded, X-Real-IP, a known identity header…), the configured
16
+ # identity header, or the public Host. A proxied request:
17
+ # - is the owner with the owner token, and, when web.proxy.identity_header
18
+ # is set, only if that header is one clean value equal to
19
+ # web.proxy.owner (D-a: identity AND token);
20
+ # - is a participant with a live grant's token (a grant bound to an
21
+ # identity only for that identity, and only proxied);
22
+ # - else nil: the gate's 401, its reason in the detail (only the
23
+ # requester's own identity) and the log.
24
+ # The identity header is refused when repeated, with a comma, empty, or
25
+ # sent under two names or with an underscore: WEBrick folds
26
+ # X_Forwarded_Email into the same Rack key as X-Forwarded-Email, the
27
+ # last one winning, so the gate reads the raw names (RawHeaderNames,
28
+ # installed by Server). See docs/configuration.md "Behind an auth proxy".
29
+ module ProxyGate
30
+ # The header names a request came with, as WEBrick read them
31
+ # (lower-cased, "_" kept), in the Rack env. Prepended to
32
+ # WEBrick::HTTPRequest by Server.
33
+ module RawHeaderNames
34
+ def meta_vars
35
+ super.merge(RAW_NAMES_ENV => header.keys)
36
+ end
37
+ end
38
+
39
+ RAW_NAMES_ENV = "samagotchi.raw_header_names"
40
+ # Why the gate refused a proxied request, for the 401's detail.
41
+ REFUSED_ENV = "samagotchi.proxy_refused"
42
+ # The token cookie on a proxied (https) request: Secure, Path=/, no
43
+ # Domain (other host names under the domain, a pod's other ports, don't
44
+ # get it). Cookies aren't port-scoped: a server on the same host name
45
+ # and another port does.
46
+ SECURE_COOKIE = "__Host-chi_token"
47
+
48
+ private
49
+
50
+ def init_proxy_gate(public_url, token)
51
+ @public_url = public_url
52
+ @public_token = token
53
+ @link_codes = LinkCodes.for_grants(@grants.path)
54
+ @proxy_hint_mutex = Mutex.new
55
+ @proxy_hint_logged = false
56
+ end
57
+
58
+ def proxied?(req)
59
+ return false unless @public_url
60
+
61
+ env = req.env
62
+ return true if @public_url.host?(env["HTTP_HOST"])
63
+ return true if @public_url.identity_env && env.key?(@public_url.identity_env)
64
+
65
+ names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
66
+ names.any? { |name| ProxyTrace.header?(name) }
67
+ end
68
+
69
+ # [:ok, email] | [:none, nil] | [:bad, reason]: the configured identity
70
+ # header of a proxied request; :none without one configured.
71
+ def proxy_identity(req)
72
+ env_key = @public_url.identity_env
73
+ return [:none, nil] unless env_key
74
+
75
+ raw = req.env[RAW_NAMES_ENV]
76
+ return [:bad, "the server gave no raw header names to check the identity header against"] unless raw
77
+
78
+ names = raw.select { |name| PublicUrl.env_key(name) == env_key }.uniq
79
+ return [:bad, "identity header sent under #{names.size} names (#{names.join(", ")})"] if names.size > 1
80
+ return [:bad, "identity header sent with an underscore (#{names.first})"] if names.first&.include?("_")
81
+
82
+ value = req.env[env_key]
83
+ return [:none, nil] if value.nil?
84
+ return [:bad, "identity header not among the raw header names"] if names.empty?
85
+
86
+ value = value.strip
87
+ return [:bad, "identity header empty"] if value.empty?
88
+ return [:bad, "identity header repeated or with a comma"] if value.include?(",")
89
+
90
+ [:ok, value.downcase]
91
+ end
92
+
93
+ # The principal of a proxied request, or nil (the reason in the env).
94
+ def proxied_principal(req, given)
95
+ state, identity = proxy_identity(req)
96
+ return proxy_refuse(req, identity) if state == :bad
97
+
98
+ notice_identity_headers(req) if state == :none
99
+ shown = identity || "(none)"
100
+ if owner_token?(given)
101
+ return Principal::OWNER if @public_url.identity_env.nil?
102
+ return Principal::OWNER if @public_url.owner && identity == @public_url.owner
103
+
104
+ return proxy_refuse(req, "owner token with identity #{shown}#{identity_hint(req)}")
105
+ end
106
+ grant_tokens = given.select { |value| Grants.token?(value) }
107
+ return proxy_refuse(req, "no token (identity #{shown})") if grant_tokens.empty?
108
+
109
+ participant_principal(req, grant_tokens, identity: identity, proxied: true)
110
+ end
111
+
112
+ # chi's own probe of a chi web behind a proxy (InfoProbe: a second chi
113
+ # web, chi share, chi update) sends no token: GET /api/info from this
114
+ # machine, not through the proxy, with no token at all, gets the
115
+ # fields it checks (nothing secret), so no probe ever hands the token
116
+ # to whatever listens on the port.
117
+ def probe_info?(req)
118
+ return false unless @public_url && req.get? && req.path_info == "/api/info"
119
+ return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
120
+
121
+ req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
122
+ end
123
+
124
+ def probe_info
125
+ json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
126
+ lan: @lan && @lan[:ip] })
127
+ end
128
+
129
+ def proxy_refuse(req, reason)
130
+ req.env[REFUSED_ENV] = reason
131
+ nil
132
+ end
133
+
134
+ # The known identity headers (ProxyTrace::IDENTITY_HEADERS) a proxied
135
+ # request carries other than the configured one.
136
+ def other_identity_headers(req)
137
+ ProxyTrace::IDENTITY_HEADERS.select do |name|
138
+ key = PublicUrl.env_key(name)
139
+ key != @public_url.identity_env && req.env.key?(key)
140
+ end
141
+ end
142
+
143
+ def identity_hint(req)
144
+ return "" if @public_url.identity_env.nil? || req.env.key?(@public_url.identity_env)
145
+
146
+ others = other_identity_headers(req)
147
+ others.empty? ? "" : "; the proxy sends #{others.join(", ")}, not #{@public_url.identity_header}"
148
+ end
149
+
150
+ # Once per start: a proxied request without the configured identity
151
+ # header (or with none configured) but with a header a known setup
152
+ # puts the identity in. A wrong header locks the owner out, and one the
153
+ # proxy doesn't replace is a client-chosen string: the line says which.
154
+ def notice_identity_headers(req)
155
+ return if @proxy_hint_logged
156
+
157
+ others = other_identity_headers(req)
158
+ return if others.empty?
159
+ return unless @proxy_hint_mutex.synchronize { !@proxy_hint_logged && (@proxy_hint_logged = true) }
160
+
161
+ if @public_url.identity_env
162
+ Log.warn(:web, "proxy_identity_header", configured: @public_url.identity_header, sent: others.join(","),
163
+ echo: "Warning: chi web: the proxy sends #{others.join(", ")}, not " \
164
+ "web.proxy.identity_header #{@public_url.identity_header}")
165
+ else
166
+ Log.info(:web, "proxy_identity_header", configured: "none", sent: others.join(","),
167
+ echo: "chi web: the proxy sends #{others.join(", ")}; with " \
168
+ "web.proxy.identity_header and web.proxy.owner it is checked " \
169
+ "next to the token (only a header the proxy replaces)")
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -24,7 +24,10 @@ import {
24
24
  addContext,
25
25
  renderText,
26
26
  getWorkingSet,
27
+ onAuthLost,
27
28
  } from "./data.js";
29
+ import { authProbe, createAuthLost } from "./auth_lost.js";
30
+ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
28
31
  import { createContextChips } from "./context_chips.js";
29
32
  import { createLlmContextControl } from "./llm_context_chip.js";
30
33
  import { createParticipantsControl } from "./participants_panel.js";
@@ -43,6 +46,7 @@ import { createCardsPlace } from "./cards_place.js";
43
46
  import { createStageView } from "./stage_view.js";
44
47
  import { createWorkingSet, groupItems } from "./working_set_view.js";
45
48
  import { turnGroups } from "./timing.js";
49
+ import { createGapClock, lastActivity } from "./time_gaps.js";
46
50
  import { createMarkdownPlace } from "./markdown_place.js";
47
51
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
48
52
  import { createTaskStopper } from "./task_stop.js";
@@ -96,6 +100,16 @@ import { createSessionActions } from "./session_actions.js";
96
100
  import { createRename } from "./session_rename.js";
97
101
  import { createSendTurn } from "./send_turn.js";
98
102
 
103
+ // A link's #code= (behind an auth proxy): traded for the cookie before
104
+ // anything else runs; the page reloads without it.
105
+ // This page has a session already: the trade would replace it (a link
106
+ // someone sent to swap this browser's session for theirs), so ask first.
107
+ if (fragmentCode(globalThis.location?.hash)
108
+ && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
109
+ // Signed out (the proxy's session or chi's token): a banner, or one reload.
110
+ const authLost = createAuthLost();
111
+ onAuthLost(authLost);
112
+
99
113
  const $ = (s) => document.querySelector(s);
100
114
  const topStripEl = $("#topStrip");
101
115
  const allListEl = $("#allList");
@@ -358,8 +372,11 @@ const bubbles = createBubbles({
358
372
  });
359
373
  const {
360
374
  removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
361
- showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
375
+ showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble, addTimeGap,
362
376
  } = bubbles;
377
+ // The open session's last activity, for the live gap dividers
378
+ // (time_gaps.js): renderHistory seeds it, the stream moves it.
379
+ const gapClock = createGapClock();
363
380
  // The plugin cards and a snapshot's notices in the history (cards_place.js).
364
381
  const cards = createCardsPlace({
365
382
  historyEl,
@@ -449,9 +466,20 @@ function openSessionEvents() {
449
466
  eventsControl = null;
450
467
  refresh();
451
468
  },
469
+ // The browser gave up after frames: signed out (the proxy's sign-in
470
+ // page answered the reconnect), or chi web went away for a while.
471
+ onClosed: () => {
472
+ eventsControl = null;
473
+ authProbe().then((state) => {
474
+ if (state === "lost") authLost("events");
475
+ else setTimeout(openSessionEvents, EVENTS_REOPEN_MS);
476
+ });
477
+ },
452
478
  });
453
479
  }
454
480
 
481
+ const EVENTS_REOPEN_MS = 3000;
482
+
455
483
  // Which chi serves this page now and which is the newest installed (the
456
484
  // events snapshot, and `chi` frames when that changes). A toast says when
457
485
  // chi web was upgraded while this tab stayed open (offering the reload,
@@ -923,7 +951,7 @@ function renderSessionView(data, { kept = [] } = {}) {
923
951
  if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
924
952
  // The recap saved with the session (also a stopped one's), else a live
925
953
  // worker's from before saved_recap existed.
926
- if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
954
+ if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
927
955
  else showRecap(data.recap);
928
956
  // A live worker's last cards and between-turns notices, where they
929
957
  // arrived; the running turn's go into its step once it is drawn.
@@ -980,12 +1008,15 @@ function seedWorkingSet(data) {
980
1008
  }
981
1009
 
982
1010
  function renderHistory(items) {
1011
+ gapClock.seed(null);
983
1012
  if (!items.length) {
984
1013
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
985
1014
  return;
986
1015
  }
987
1016
  if (typeof items[0] === "object" && items[0].role) {
988
1017
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
1018
+ // The live dividers count from the history's last activity.
1019
+ gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
989
1020
  linkTextRefs();
990
1021
  } else {
991
1022
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -1392,7 +1423,8 @@ const streamHandlers = createStreamHandlers({
1392
1423
  setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
1393
1424
  // bubbles.js
1394
1425
  applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
1395
- addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
1426
+ addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice, addTimeGap,
1427
+ gapClock,
1396
1428
  // cards_place.js
1397
1429
  upsertCard, refreshCardBodies,
1398
1430
  syncModelPick: startPage.commandRan,
@@ -0,0 +1,76 @@
1
+ // Signed out: the auth proxy's session (Okta…) or chi's token is gone. An
2
+ // /api answer says so in one of these ways: a 401 (chi's unauthorized, or
3
+ // the proxy's own 401 {} for Accept: application/json), a fetch that was
4
+ // redirected or came back as HTML (a sign-in page), or, from the probe,
5
+ // a redirect fetch can't follow (real IdPs send no CORS: a TypeError, so
6
+ // the probe asks with redirect: "manual"). The page then says so in a
7
+ // banner with Reload, and reloads by itself at most once a minute (the
8
+ // proxy's sign-in brings the user back), never when it can't keep the time.
9
+
10
+ export const AUTH_RELOAD_KEY = "chi.authReloadAt";
11
+ export const AUTH_RELOAD_MS = 60_000;
12
+
13
+ // Whether a fetch Response says the sign-in is gone.
14
+ export function lostAnswer(res) {
15
+ if (!res) return false;
16
+ if (res.status === 401 || res.redirected || res.type === "opaqueredirect") return true;
17
+ const type = res.headers?.get?.("content-type") || "";
18
+ return type.includes("text/html");
19
+ }
20
+
21
+ // "lost" | "ok" | "down" (chi web unreachable: no banner, the page's own
22
+ // retries go on).
23
+ export async function authProbe(fetchImpl = globalThis.fetch) {
24
+ try {
25
+ const res = await fetchImpl("/api/info", {
26
+ credentials: "same-origin", redirect: "manual", headers: { Accept: "application/json" },
27
+ });
28
+ return lostAnswer(res) ? "lost" : "ok";
29
+ } catch (_) {
30
+ return "down";
31
+ }
32
+ }
33
+
34
+ export const AUTH_LOST_TEXT = "Signed out (the proxy's sign-in or chi's token ended).";
35
+
36
+ // The page's authLost(reason): once, a banner (doc) and, unless one ran in
37
+ // the last minute, a reload. storage: sessionStorage (may be missing or throw).
38
+ export function createAuthLost({
39
+ doc = globalThis.document, storage = () => globalThis.sessionStorage, reload = () => globalThis.location.reload(),
40
+ now = () => Date.now(), log = (reason) => console.warn("chi: signed out", reason),
41
+ } = {}) {
42
+ let shown = false;
43
+ return function authLost(reason) {
44
+ if (shown) return;
45
+ shown = true;
46
+ log(reason);
47
+ // The reload only when its stamp can be kept: without storage (a
48
+ // private window) a page that keeps losing its sign-in would reload
49
+ // forever, so it gets the banner.
50
+ let due = false;
51
+ try {
52
+ due = now() - (Number(storage().getItem(AUTH_RELOAD_KEY)) || 0) >= AUTH_RELOAD_MS;
53
+ if (due) storage().setItem(AUTH_RELOAD_KEY, String(now()));
54
+ } catch (_) {
55
+ due = false;
56
+ }
57
+ if (due) {
58
+ reload();
59
+ return;
60
+ }
61
+ const bar = doc.createElement("div");
62
+ bar.id = "authLost";
63
+ bar.setAttribute("role", "alert");
64
+ bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:10000;padding:8px 16px;font-size:14px;"
65
+ + "color:var(--warn-text);background:var(--warn-bg);border-bottom:1px solid var(--warn-border);"
66
+ + "display:flex;gap:12px;align-items:center;justify-content:center";
67
+ const text = doc.createElement("span");
68
+ text.textContent = AUTH_LOST_TEXT;
69
+ const button = doc.createElement("button");
70
+ button.type = "button";
71
+ button.textContent = "Reload";
72
+ button.addEventListener("click", () => reload());
73
+ bar.append(text, button);
74
+ doc.body.append(bar);
75
+ };
76
+ }