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,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; input_format and epoch only when they
74
- # name one.
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.108.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