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,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../tunnel"
|
|
5
|
+
require_relative "../public_url"
|
|
6
|
+
require_relative "../../session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Web
|
|
10
|
+
module Tunnel
|
|
11
|
+
# `chi web --tunnel tailscale`: Tailscale Serve on this machine's
|
|
12
|
+
# tailnet name, port 443 → chi web's 127.0.0.1 port, never Funnel.
|
|
13
|
+
# The identity is the Tailscale login Serve sends (Tailscale-User-Login);
|
|
14
|
+
# the owner is web.proxy.owner, else this machine's own login.
|
|
15
|
+
#
|
|
16
|
+
# Serve's other ports are never touched, and on 443 only exactly what
|
|
17
|
+
# this provider sets (one handler / → http://127.0.0.1:<port>, no
|
|
18
|
+
# Funnel) is adopted or turned off: anything else there is refused at
|
|
19
|
+
# start and left alone at exit.
|
|
20
|
+
class Tailscale
|
|
21
|
+
# The CLI to run, before PATH: every spec that runs it sets this to a
|
|
22
|
+
# fake.
|
|
23
|
+
ENV_CLI = "SAMAGOTCHI_TAILSCALE_CLI"
|
|
24
|
+
# The macOS app's own CLI, when there is no tailscale on PATH (never
|
|
25
|
+
# in a test run).
|
|
26
|
+
APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
27
|
+
IDENTITY_HEADER = "Tailscale-User-Login"
|
|
28
|
+
STATUS_TIMEOUT = 5
|
|
29
|
+
SERVE_TIMEOUT = 15
|
|
30
|
+
LOCK_DOCS = "https://tailscale.com/kb/1226/tailnet-lock"
|
|
31
|
+
|
|
32
|
+
# No tailscale to run.
|
|
33
|
+
class CliMissing < Refused
|
|
34
|
+
def initialize
|
|
35
|
+
super("tailscale isn't installed: no tailscale on PATH or at #{APP_CLI} (install it from tailscale.com, " \
|
|
36
|
+
"or set #{ENV_CLI} to its path)")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# SAMAGOTCHI_TAILSCALE_CLI names something that can't run.
|
|
41
|
+
class CliOverrideMissing < Refused
|
|
42
|
+
def initialize(path)
|
|
43
|
+
super("#{ENV_CLI} is #{path}, which isn't an executable file")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Tailscale is installed but not connected (BackendState).
|
|
48
|
+
class NotRunning < Refused
|
|
49
|
+
def initialize(state)
|
|
50
|
+
super("Tailscale isn't connected (#{state}): open the Tailscale app and sign in, then run this again")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# No MagicDNS name for this machine.
|
|
55
|
+
class NoDnsName < Refused
|
|
56
|
+
def initialize
|
|
57
|
+
super("this machine has no tailnet name: turn on MagicDNS in the Tailscale admin console (DNS)")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# HTTPS certificates are off (no CertDomains for the name).
|
|
62
|
+
class NoHttps < Refused
|
|
63
|
+
def initialize(name)
|
|
64
|
+
super("HTTPS certificates are off for this tailnet (none for #{name}): turn them on in the Tailscale admin " \
|
|
65
|
+
"console (DNS); rename this machine first, certificate names go into a public log")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A tagged machine's UserID isn't a person: no owner by default.
|
|
70
|
+
class TaggedOwner < Refused
|
|
71
|
+
def initialize(tags)
|
|
72
|
+
super("this machine is tagged (#{tags.join(", ")}), so its Tailscale login isn't a person's: set " \
|
|
73
|
+
"web.proxy.owner to your Tailscale login")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Something other than this provider's one handler serves 443.
|
|
78
|
+
class ServeInUse < Refused
|
|
79
|
+
def initialize(what)
|
|
80
|
+
super("Tailscale Serve already serves port 443 here (#{what}), and chi web won't change it: turn it off " \
|
|
81
|
+
"with tailscale serve --https=443 off, or use it as it is without --tunnel")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Funnel (the public internet) is on for 443.
|
|
86
|
+
class Funnel < Refused
|
|
87
|
+
def initialize(name)
|
|
88
|
+
super("Tailscale Funnel is on for #{name} (the public internet), and chi web never runs behind Funnel: " \
|
|
89
|
+
"turn it off first (tailscale funnel status shows it)")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param env [Hash] ENV: SAMAGOTCHI_TAILSCALE_CLI, PATH, the test-run flags
|
|
94
|
+
# @param app_cli [String] the app bundle's CLI (a spec's own path)
|
|
95
|
+
# @param runner [#call] (argv, timeout:) → Runner::Result
|
|
96
|
+
def initialize(env: ENV, app_cli: APP_CLI, runner: Runner.new)
|
|
97
|
+
@env = env
|
|
98
|
+
@app_cli = app_cli
|
|
99
|
+
@runner = runner
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The tailscale command: SAMAGOTCHI_TAILSCALE_CLI, else tailscale on
|
|
103
|
+
# PATH, else the macOS app's CLI (not in a test run).
|
|
104
|
+
# @raise [CliMissing, CliOverrideMissing]
|
|
105
|
+
def cli
|
|
106
|
+
@cli ||= lookup
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Everything checked before anything changes: connected, the name
|
|
110
|
+
# and its certificate, the owner, config conflicts, what Serve has
|
|
111
|
+
# on 443, Tailnet Lock.
|
|
112
|
+
# @param config_owner [String, nil] web.proxy.owner
|
|
113
|
+
# @param config_public_url [String, nil] web.public_url
|
|
114
|
+
# @param config_identity_header [String, nil] web.proxy.identity_header
|
|
115
|
+
# @return [Plan]
|
|
116
|
+
# @raise [Refused]
|
|
117
|
+
def prepare(port:, config_owner: nil, config_public_url: nil, config_identity_header: nil)
|
|
118
|
+
status = json(%w[status --json])
|
|
119
|
+
dns_name = connected_name(status)
|
|
120
|
+
origin = "https://#{dns_name}"
|
|
121
|
+
check_config(origin, config_public_url, config_identity_header)
|
|
122
|
+
owner, owner_source = owner_for(status, config_owner)
|
|
123
|
+
adopted = classify(json(%w[serve status --json]), dns_name, port) == :ours
|
|
124
|
+
public_url = PublicUrl.from_config(url: origin, identity_header: IDENTITY_HEADER, owner: owner)
|
|
125
|
+
Plan.new(public_url: public_url, owner_source: owner_source, adopted: adopted, lock_off: lock_off?,
|
|
126
|
+
port: port, dns_name: dns_name)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Serve 443 → the plan's port, in the background (tailscaled keeps
|
|
130
|
+
# it until #down, also past a chi web killed hard: the next
|
|
131
|
+
# --tunnel adopts it). The explicit target: chi web binds 127.0.0.1
|
|
132
|
+
# only.
|
|
133
|
+
# @raise [CliFailed, CliTimeout] its output in the message (a consent URL)
|
|
134
|
+
def up(plan)
|
|
135
|
+
args = ["serve", "--bg", "--https=443", plan.target]
|
|
136
|
+
result = run(args, timeout: SERVE_TIMEOUT)
|
|
137
|
+
raise CliFailed.new(command(args), result.output) unless result.success
|
|
138
|
+
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 443 off again, only while Serve has exactly what #up set (the same
|
|
143
|
+
# test as the adopt): a handler someone added meanwhile, another
|
|
144
|
+
# target, Funnel, is left as it is (`serve --https=443 off` would drop
|
|
145
|
+
# all of 443).
|
|
146
|
+
# @return [Symbol] :off, :changed (left alone), :gone (443 was off already)
|
|
147
|
+
# @raise [Refused] a command that failed (the caller warns)
|
|
148
|
+
def down(plan)
|
|
149
|
+
state = begin
|
|
150
|
+
classify(json(%w[serve status --json]), plan.dns_name, plan.port)
|
|
151
|
+
rescue ServeInUse, Funnel
|
|
152
|
+
:changed
|
|
153
|
+
end
|
|
154
|
+
return state == :none ? :gone : state unless state == :ours
|
|
155
|
+
|
|
156
|
+
args = %w[serve --https=443 off]
|
|
157
|
+
result = run(args, timeout: SERVE_TIMEOUT)
|
|
158
|
+
raise CliFailed.new(command(args), result.output) unless result.success
|
|
159
|
+
|
|
160
|
+
:off
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# What the start lines add: how it is reached and whose login is the
|
|
164
|
+
# owner, and the Tailnet Lock warning.
|
|
165
|
+
def start_lines(plan)
|
|
166
|
+
source = plan.owner_source == :config ? "web.proxy.owner" : "this machine's Tailscale login"
|
|
167
|
+
via = " via Tailscale Serve (tailnet only), owner #{plan.public_url.owner} (#{source})"
|
|
168
|
+
via += "; Serve already forwarded port 443 here, chi web turns it off on exit" if plan.adopted
|
|
169
|
+
lines = [via]
|
|
170
|
+
if plan.lock_off
|
|
171
|
+
lines << "Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; " \
|
|
172
|
+
"tailscale lock init turns it on (#{LOCK_DOCS})"
|
|
173
|
+
end
|
|
174
|
+
lines
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The line #down's outcome is said in, nil for none.
|
|
178
|
+
def down_line(plan, outcome)
|
|
179
|
+
case outcome
|
|
180
|
+
when :off then "Tailscale Serve: port 443 is off again."
|
|
181
|
+
when :changed
|
|
182
|
+
"Tailscale Serve's port 443 changed while chi web ran (#{plan.dns_name}): left as it is " \
|
|
183
|
+
"(tailscale serve status shows it)"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A #down that failed: Serve may still forward the name.
|
|
188
|
+
def down_warning(plan, error)
|
|
189
|
+
"Warning: Tailscale Serve may still forward #{plan.dns_name} to 127.0.0.1:#{plan.port} (#{error.message}): " \
|
|
190
|
+
"turn it off with tailscale serve --https=443 off"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# What Serve has on 443: :none, :ours (exactly one handler / →
|
|
194
|
+
# http://127.0.0.1:<port>, no Funnel), or a ServeInUse / Funnel.
|
|
195
|
+
# Other ports are not looked at.
|
|
196
|
+
# @raise [ServeInUse, Funnel, UnexpectedOutput]
|
|
197
|
+
def classify(serve, dns_name, port)
|
|
198
|
+
raise UnexpectedOutput, command(%w[serve status --json]) unless serve.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
funnel = hash(serve["AllowFunnel"]).find { |key, on| on && https_key?(key) }
|
|
201
|
+
raise Funnel, funnel.first if funnel
|
|
202
|
+
|
|
203
|
+
tcp = hash(serve["TCP"])["443"]
|
|
204
|
+
web = hash(serve["Web"]).select { |key, _| https_key?(key) }
|
|
205
|
+
return :none if tcp.nil? && web.empty?
|
|
206
|
+
return :ours if ours?(tcp, web, dns_name, port)
|
|
207
|
+
|
|
208
|
+
raise ServeInUse, describe(tcp, web)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def lookup
|
|
214
|
+
override = @env[ENV_CLI].to_s.strip
|
|
215
|
+
unless override.empty?
|
|
216
|
+
return override if executable?(override)
|
|
217
|
+
|
|
218
|
+
raise CliOverrideMissing, override
|
|
219
|
+
end
|
|
220
|
+
on_path = @env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?)
|
|
221
|
+
.map { |dir| File.join(dir, "tailscale") }.find { |path| executable?(path) }
|
|
222
|
+
return on_path if on_path
|
|
223
|
+
return @app_cli if !Session.test_session_env?(env: @env) && executable?(@app_cli)
|
|
224
|
+
|
|
225
|
+
raise CliMissing
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def executable?(path) = File.file?(path) && File.executable?(path)
|
|
229
|
+
|
|
230
|
+
def connected_name(status)
|
|
231
|
+
raise UnexpectedOutput, command(%w[status --json]) unless status.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
state = status["BackendState"].to_s
|
|
234
|
+
raise NotRunning, (state.empty? ? "no state" : state) unless state == "Running"
|
|
235
|
+
|
|
236
|
+
name = hash(status["Self"])["DNSName"].to_s.strip.downcase.delete_suffix(".")
|
|
237
|
+
raise NoDnsName if name.empty?
|
|
238
|
+
raise NoHttps, name unless Array(status["CertDomains"]).any? { |domain| domain.to_s.casecmp?(name) }
|
|
239
|
+
|
|
240
|
+
name
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def check_config(origin, config_public_url, config_identity_header)
|
|
244
|
+
unless blank?(config_public_url) || configured_origin(config_public_url) == origin
|
|
245
|
+
raise ConfigConflict.new("web.public_url", config_public_url.to_s.strip, origin)
|
|
246
|
+
end
|
|
247
|
+
return if blank?(config_identity_header) || config_identity_header.to_s.strip.casecmp?(IDENTITY_HEADER)
|
|
248
|
+
|
|
249
|
+
raise ConfigConflict.new("web.proxy.identity_header", config_identity_header.to_s.strip, IDENTITY_HEADER)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def configured_origin(url)
|
|
253
|
+
PublicUrl.from_config(url: url)&.origin
|
|
254
|
+
rescue PublicUrl::Invalid
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# [login, :config | :login]
|
|
259
|
+
def owner_for(status, config_owner)
|
|
260
|
+
return [config_owner.to_s.strip, :config] unless blank?(config_owner)
|
|
261
|
+
|
|
262
|
+
me = hash(status["Self"])
|
|
263
|
+
tags = Array(me["Tags"])
|
|
264
|
+
raise TaggedOwner, tags.map(&:to_s) unless tags.empty?
|
|
265
|
+
|
|
266
|
+
login = hash(hash(status["User"])[me["UserID"].to_s])["LoginName"].to_s.strip
|
|
267
|
+
raise UnexpectedOutput, command(%w[status --json]) if login.empty?
|
|
268
|
+
|
|
269
|
+
[login, :login]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def ours?(tcp, web, dns_name, port)
|
|
273
|
+
handlers = { "Handlers" => { "/" => { "Proxy" => "http://127.0.0.1:#{port}" } } }
|
|
274
|
+
tcp == { "HTTPS" => true } && web.size == 1 && web.keys.first.casecmp?("#{dns_name}:443") &&
|
|
275
|
+
web.values.first == handlers
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def describe(tcp, web)
|
|
279
|
+
parts = web.values.flat_map do |entry|
|
|
280
|
+
hash(hash(entry)["Handlers"]).map { |path, handler| "#{path} → #{handler_target(handler)}" }
|
|
281
|
+
end
|
|
282
|
+
forward = hash(tcp)["TCPForward"]
|
|
283
|
+
parts << "TCP → #{forward}" if forward
|
|
284
|
+
parts.empty? ? "a configuration chi web didn't make" : parts.join(", ")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def handler_target(handler)
|
|
288
|
+
handler = hash(handler)
|
|
289
|
+
handler["Proxy"] || (handler["Path"] && "files #{handler["Path"]}") || (handler.key?("Text") && "text") ||
|
|
290
|
+
handler.keys.join("/")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def https_key?(key) = key.to_s.end_with?(":443")
|
|
294
|
+
|
|
295
|
+
# Tailnet Lock is off (Enabled false); a failure says nothing.
|
|
296
|
+
def lock_off?
|
|
297
|
+
json(%w[lock status --json]).then { |lock| lock.is_a?(Hash) && lock["Enabled"] == false }
|
|
298
|
+
rescue Refused
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def json(args)
|
|
303
|
+
result = run(args, timeout: STATUS_TIMEOUT)
|
|
304
|
+
raise CliFailed.new(command(args), result.output) unless result.success
|
|
305
|
+
|
|
306
|
+
JSON.parse(result.stdout)
|
|
307
|
+
rescue JSON::ParserError
|
|
308
|
+
raise UnexpectedOutput, command(args)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def run(args, timeout:)
|
|
312
|
+
@runner.call([cli, *args], timeout: timeout)
|
|
313
|
+
rescue Runner::Timeout => e
|
|
314
|
+
raise CliTimeout.new(command(args), timeout, e.output)
|
|
315
|
+
rescue SystemCallError => e
|
|
316
|
+
raise CliFailed.new(command(args), e.message)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def command(args) = ["tailscale", *args].join(" ")
|
|
320
|
+
def hash(value) = value.is_a?(Hash) ? value : {}
|
|
321
|
+
def blank?(value) = value.to_s.strip.empty?
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# `chi web --tunnel PROVIDER`: chi web reached from outside this machine
|
|
9
|
+
# through a provider that gives it an https name and tells it who is
|
|
10
|
+
# asking (Tailscale Serve: docs/configuration.md "Tailscale Serve"). The
|
|
11
|
+
# provider checks what it can before anything changes (#prepare), turns
|
|
12
|
+
# the tunnel on (#up) and off again on exit (#down), never touching what
|
|
13
|
+
# it didn't set. The public_url it makes is this run's, never written to
|
|
14
|
+
# config.yml.
|
|
15
|
+
module Tunnel
|
|
16
|
+
PROVIDERS = %w[tailscale].freeze
|
|
17
|
+
|
|
18
|
+
# What a tunnel's #prepare found: the PublicUrl chi web runs with this
|
|
19
|
+
# time, whose login is the owner (:config for web.proxy.owner, :login
|
|
20
|
+
# for this machine's own), whether the serve was already there (a
|
|
21
|
+
# leftover of an earlier run: #down resets it all the same), whether
|
|
22
|
+
# Tailnet Lock is off (a warning line), and the port it forwards to.
|
|
23
|
+
Plan = Data.define(:public_url, :owner_source, :adopted, :lock_off, :port, :dns_name) do
|
|
24
|
+
def target = "http://127.0.0.1:#{port}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Why `chi web --tunnel` won't start: each condition its own class and
|
|
28
|
+
# its own text (callers add only context).
|
|
29
|
+
class Refused < StandardError; end
|
|
30
|
+
|
|
31
|
+
# @return [Tailscale] the provider +name+ names
|
|
32
|
+
# @raise [ArgumentError] an unknown name (the flag's list refuses it first)
|
|
33
|
+
def self.for(name, **)
|
|
34
|
+
case name.to_s
|
|
35
|
+
when "tailscale"
|
|
36
|
+
require_relative "tunnel/tailscale"
|
|
37
|
+
Tailscale.new(**)
|
|
38
|
+
else raise ArgumentError, "unknown tunnel #{name} (#{PROVIDERS.join(", ")})"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A web.public_url or web.proxy.identity_header setting the tunnel would
|
|
43
|
+
# override for its run.
|
|
44
|
+
class ConfigConflict < Refused
|
|
45
|
+
def initialize(key, value, tunnel_value)
|
|
46
|
+
env = Config::BY_KEY.fetch(key).env_key
|
|
47
|
+
super("#{key} is #{value}, but --tunnel sets it to #{tunnel_value} for this run: remove it from config.yml " \
|
|
48
|
+
"or the environment (#{env}), or drop --tunnel")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A chi web already answers on the port: --tunnel starts its own.
|
|
53
|
+
class AlreadyRunning < Refused
|
|
54
|
+
def initialize(port)
|
|
55
|
+
super("chi web already runs on port #{port}, and --tunnel starts its own: stop that one first (Ctrl-C in its " \
|
|
56
|
+
"terminal), or use --port")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The provider's command exited with an error.
|
|
61
|
+
class CliFailed < Refused
|
|
62
|
+
def initialize(command, output)
|
|
63
|
+
text = output.to_s.strip
|
|
64
|
+
super("#{command} failed#{text.empty? ? " (no output)" : ": #{text}"}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The provider's command didn't finish in time (stopped): a tailnet
|
|
69
|
+
# where Serve isn't allowed yet prints a consent URL and waits.
|
|
70
|
+
class CliTimeout < Refused
|
|
71
|
+
def initialize(command, seconds, output)
|
|
72
|
+
text = output.to_s.strip
|
|
73
|
+
super("#{command} didn't finish in #{seconds} s and was stopped#{": #{text}" unless text.empty?}" \
|
|
74
|
+
"#{" (if it printed a URL, open it to allow this, then run chi web again)" unless text.empty?}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The provider's command printed something chi can't read: never a
|
|
79
|
+
# guess that could change the wrong thing.
|
|
80
|
+
class UnexpectedOutput < Refused
|
|
81
|
+
def initialize(command)
|
|
82
|
+
super("#{command} printed something chi can't read (a newer version?); nothing was changed")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A command run with a deadline: its own process group, both pipes read
|
|
87
|
+
# on threads, the group KILLed past +timeout+ (what it printed so far
|
|
88
|
+
# goes into the Timeout).
|
|
89
|
+
class Runner
|
|
90
|
+
Result = Data.define(:stdout, :stderr, :success) do
|
|
91
|
+
def output = [stdout, stderr].map(&:to_s).reject(&:empty?).join("\n")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Timeout < StandardError
|
|
95
|
+
attr_reader :output
|
|
96
|
+
|
|
97
|
+
def initialize(output)
|
|
98
|
+
@output = output
|
|
99
|
+
super("timed out")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Result]
|
|
104
|
+
# @raise [Timeout]
|
|
105
|
+
# @raise [SystemCallError] the command can't run (ENOENT, EACCES)
|
|
106
|
+
def call(argv, timeout:)
|
|
107
|
+
Open3.popen3(*argv, pgroup: true) do |stdin, out, err, wait|
|
|
108
|
+
stdin.close
|
|
109
|
+
readers = [out, err].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
110
|
+
unless wait.join(timeout)
|
|
111
|
+
stop(wait.pid)
|
|
112
|
+
readers.each { |reader| reader.join(1) }
|
|
113
|
+
raise Timeout, readers.map { |reader| text(reader) }.reject(&:empty?).join("\n")
|
|
114
|
+
end
|
|
115
|
+
# A process it left behind may hold the pipes: what was read by then.
|
|
116
|
+
readers.each { |reader| reader.join(1) }
|
|
117
|
+
Result.new(stdout: text(readers[0]), stderr: text(readers[1]), success: wait.value.success?)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def text(reader) = reader.alive? ? "" : reader.value.to_s
|
|
124
|
+
|
|
125
|
+
def stop(pid)
|
|
126
|
+
Process.kill("KILL", -pid)
|
|
127
|
+
rescue SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -70,14 +70,15 @@ module Samagotchi
|
|
|
70
70
|
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
|
|
71
71
|
epoch: nil) = super
|
|
72
72
|
|
|
73
|
-
# Written whole or not at all
|
|
74
|
-
#
|
|
73
|
+
# Written whole or not at all, readable by this user only (0600: no
|
|
74
|
+
# other account learns the worker's port from it); input_format and
|
|
75
|
+
# epoch only when they name one.
|
|
75
76
|
def write(session_dir)
|
|
76
77
|
FileUtils.mkdir_p(session_dir)
|
|
77
78
|
record = to_h.transform_keys(&:to_s)
|
|
78
79
|
record.delete("input_format") if input_format.nil?
|
|
79
80
|
record.delete("epoch") if epoch.nil?
|
|
80
|
-
AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
|
|
81
|
+
AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n", perm: 0o600)
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.110.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -449,6 +449,7 @@ files:
|
|
|
449
449
|
- lib/samagotchi/memory_orphans.rb
|
|
450
450
|
- lib/samagotchi/memory_paths.rb
|
|
451
451
|
- lib/samagotchi/message_parts.rb
|
|
452
|
+
- lib/samagotchi/message_time.rb
|
|
452
453
|
- lib/samagotchi/model_catalog.rb
|
|
453
454
|
- lib/samagotchi/model_list_store.rb
|
|
454
455
|
- lib/samagotchi/model_match.rb
|
|
@@ -497,6 +498,7 @@ files:
|
|
|
497
498
|
- lib/samagotchi/prompt_literal_guard.rb
|
|
498
499
|
- lib/samagotchi/prompt_note.rb
|
|
499
500
|
- lib/samagotchi/prompt_warmup.rb
|
|
501
|
+
- lib/samagotchi/proxy_trace.rb
|
|
500
502
|
- lib/samagotchi/question_answer.rb
|
|
501
503
|
- lib/samagotchi/question_asked.rb
|
|
502
504
|
- lib/samagotchi/question_desk.rb
|
|
@@ -579,6 +581,7 @@ files:
|
|
|
579
581
|
- lib/samagotchi/terminal_ui/between_turns.rb
|
|
580
582
|
- lib/samagotchi/terminal_ui/event_renderer.rb
|
|
581
583
|
- lib/samagotchi/terminal_ui/formatting.rb
|
|
584
|
+
- lib/samagotchi/terminal_ui/gap_clock.rb
|
|
582
585
|
- lib/samagotchi/terminal_ui/image_input.rb
|
|
583
586
|
- lib/samagotchi/terminal_ui/input_support.rb
|
|
584
587
|
- lib/samagotchi/terminal_ui/line_reader.rb
|
|
@@ -697,6 +700,7 @@ files:
|
|
|
697
700
|
- lib/samagotchi/web/grants.rb
|
|
698
701
|
- lib/samagotchi/web/info_probe.rb
|
|
699
702
|
- lib/samagotchi/web/lan.rb
|
|
703
|
+
- lib/samagotchi/web/link_codes.rb
|
|
700
704
|
- lib/samagotchi/web/markdown_renderer.rb
|
|
701
705
|
- lib/samagotchi/web/participant_access.rb
|
|
702
706
|
- lib/samagotchi/web/participant_review.rb
|
|
@@ -704,6 +708,7 @@ files:
|
|
|
704
708
|
- lib/samagotchi/web/participant_view.rb
|
|
705
709
|
- lib/samagotchi/web/participants_admin.rb
|
|
706
710
|
- lib/samagotchi/web/principal.rb
|
|
711
|
+
- lib/samagotchi/web/proxy_gate.rb
|
|
707
712
|
- lib/samagotchi/web/public/activity.js
|
|
708
713
|
- lib/samagotchi/web/public/annotate_actions.js
|
|
709
714
|
- lib/samagotchi/web/public/annotate_bar.js
|
|
@@ -711,6 +716,7 @@ files:
|
|
|
711
716
|
- lib/samagotchi/web/public/annotations.js
|
|
712
717
|
- lib/samagotchi/web/public/answer_refs.js
|
|
713
718
|
- lib/samagotchi/web/public/app.js
|
|
719
|
+
- lib/samagotchi/web/public/auth_lost.js
|
|
714
720
|
- lib/samagotchi/web/public/bubbles.js
|
|
715
721
|
- lib/samagotchi/web/public/card.js
|
|
716
722
|
- lib/samagotchi/web/public/cards_place.js
|
|
@@ -796,9 +802,11 @@ files:
|
|
|
796
802
|
- lib/samagotchi/web/public/task_stop.js
|
|
797
803
|
- lib/samagotchi/web/public/thinking_chip.js
|
|
798
804
|
- lib/samagotchi/web/public/thinking_ticker.js
|
|
805
|
+
- lib/samagotchi/web/public/time_gaps.js
|
|
799
806
|
- lib/samagotchi/web/public/timing.js
|
|
800
807
|
- lib/samagotchi/web/public/toast_place.js
|
|
801
808
|
- lib/samagotchi/web/public/toasts.js
|
|
809
|
+
- lib/samagotchi/web/public/token_fragment.js
|
|
802
810
|
- lib/samagotchi/web/public/tokens.css
|
|
803
811
|
- lib/samagotchi/web/public/toolkit_chip.js
|
|
804
812
|
- lib/samagotchi/web/public/turn_events.js
|
|
@@ -815,6 +823,7 @@ files:
|
|
|
815
823
|
- lib/samagotchi/web/public/working_set.js
|
|
816
824
|
- lib/samagotchi/web/public/working_set_rail.js
|
|
817
825
|
- lib/samagotchi/web/public/working_set_view.js
|
|
826
|
+
- lib/samagotchi/web/public_url.rb
|
|
818
827
|
- lib/samagotchi/web/qr.rb
|
|
819
828
|
- lib/samagotchi/web/running.rb
|
|
820
829
|
- lib/samagotchi/web/server.rb
|
|
@@ -822,6 +831,8 @@ files:
|
|
|
822
831
|
- lib/samagotchi/web/session_hub.rb
|
|
823
832
|
- lib/samagotchi/web/session_summary.rb
|
|
824
833
|
- lib/samagotchi/web/token.rb
|
|
834
|
+
- lib/samagotchi/web/tunnel.rb
|
|
835
|
+
- lib/samagotchi/web/tunnel/tailscale.rb
|
|
825
836
|
- lib/samagotchi/worker.rb
|
|
826
837
|
- lib/samagotchi/worker_idle_exit.rb
|
|
827
838
|
- lib/samagotchi/worker_inbound.rb
|