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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +52 -10
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +47 -10
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- 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:
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
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
|
+
}
|