samagotchi 0.128.0 → 0.130.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 +126 -1
- data/docs/architecture.md +13 -6
- data/docs/cli.md +20 -10
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +202 -33
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +29 -6
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
- data/lib/samagotchi/guardrails/outside.rb +68 -17
- data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
- data/lib/samagotchi/guardrails/pair_command.rb +327 -72
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +30 -14
- data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
- data/lib/samagotchi/guardrails/targets.rb +131 -29
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +127 -20
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +32 -25
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +91 -33
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +3 -1
|
@@ -716,6 +716,8 @@ module Samagotchi
|
|
|
716
716
|
max_iterations: max_iterations,
|
|
717
717
|
cancel_controller: cancellation_controller,
|
|
718
718
|
pending_input: method(:drain_steering),
|
|
719
|
+
# A steering line waits (#steer_line): a running task_wait ends for it.
|
|
720
|
+
input_waiting: -> { !@pending_input_queue.empty? },
|
|
719
721
|
continue: continue,
|
|
720
722
|
images: images,
|
|
721
723
|
resumed: resumed,
|
|
@@ -221,7 +221,7 @@ module Samagotchi
|
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
223
|
name: "task_wait",
|
|
224
|
-
description: "Wait for a background task to finish. Polls every 0.5s until completion, a log pattern matches, or timeout. Timed-out waits include a bounded output tail.",
|
|
224
|
+
description: "Wait for a background task to finish. Polls every 0.5s until completion, a log pattern matches, a message from the user arrives, or timeout. Timed-out waits include a bounded output tail.",
|
|
225
225
|
parameters: {
|
|
226
226
|
type: "object",
|
|
227
227
|
properties: {
|
|
@@ -86,7 +86,8 @@ module Samagotchi
|
|
|
86
86
|
end,
|
|
87
87
|
TaskWait::NAME => lambda do |call, kctx|
|
|
88
88
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
89
|
-
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx)
|
|
89
|
+
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx),
|
|
90
|
+
message_waiting: input_waiting_proc(kctx))
|
|
90
91
|
end,
|
|
91
92
|
Execute::NAME => lambda do |call, kctx|
|
|
92
93
|
Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
|
|
@@ -188,6 +189,14 @@ module Samagotchi
|
|
|
188
189
|
-> { !!peers&.cancelled? }
|
|
189
190
|
end
|
|
190
191
|
|
|
192
|
+
# A person's message waits for the turn's next boundary
|
|
193
|
+
# (Engine#input_waiting?, through its PeerView): a task_wait returns on
|
|
194
|
+
# it. A kernel whose peers can't tell: never.
|
|
195
|
+
def input_waiting_proc(kctx)
|
|
196
|
+
peers = kctx.peers
|
|
197
|
+
-> { peers.respond_to?(:input_waiting?) && !!peers.input_waiting? }
|
|
198
|
+
end
|
|
199
|
+
|
|
191
200
|
# The running turn's stop reason (:signal, :manual, …), nil without one.
|
|
192
201
|
def stop_reason_proc(kctx)
|
|
193
202
|
peers = kctx.peers
|
|
@@ -23,6 +23,10 @@ module Samagotchi
|
|
|
23
23
|
def cancelled?
|
|
24
24
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# A person's message waits for the turn's next boundary: never here
|
|
28
|
+
# (Engine::PeerView asks the running turn).
|
|
29
|
+
def input_waiting? = false
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -14,13 +14,23 @@ module Samagotchi
|
|
|
14
14
|
# A handed-over execute keeps stderr in a log of its own: at most this
|
|
15
15
|
# many of its last lines follow the output tail.
|
|
16
16
|
STDERR_TAIL_LINES_MAX = 10
|
|
17
|
+
# The note of a wait a waiting message ended (wait_result:
|
|
18
|
+
# message_waiting); %<id>s is the task's id. "The user" whoever sent
|
|
19
|
+
# it: the merged message itself names its sender.
|
|
20
|
+
MESSAGE_WAITING_NOTE = [
|
|
21
|
+
"note: a message from the user is waiting; the task is STILL RUNNING.",
|
|
22
|
+
" Read the message first. To keep waiting after it: task_wait %<id>s. If it is no longer needed: task_stop %<id>s."
|
|
23
|
+
].join("\n").freeze
|
|
17
24
|
|
|
18
25
|
def self.name = NAME
|
|
19
26
|
|
|
20
27
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
21
28
|
# wait returns then, and the task keeps running
|
|
29
|
+
# @param message_waiting [#call] true once a person's message waits for
|
|
30
|
+
# the turn's next boundary (Engine#input_waiting?): the wait returns
|
|
31
|
+
# then, so the model reads it, and the task keeps running
|
|
22
32
|
def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil,
|
|
23
|
-
cancelled: -> { false })
|
|
33
|
+
cancelled: -> { false }, message_waiting: -> { false })
|
|
24
34
|
timeout = TIMEOUT_DEFAULT if blank?(timeout)
|
|
25
35
|
tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
|
|
26
36
|
timeout = timeout.to_i
|
|
@@ -48,6 +58,7 @@ module Samagotchi
|
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
return cancelled_response(task_id, tail_lines, started) if cancelled.call
|
|
61
|
+
return message_waiting_response(task_id, tail_lines, started) if message_waiting.call
|
|
51
62
|
|
|
52
63
|
break if monotonic_time > deadline
|
|
53
64
|
|
|
@@ -80,6 +91,20 @@ module Samagotchi
|
|
|
80
91
|
end
|
|
81
92
|
private_class_method :cancelled_response
|
|
82
93
|
|
|
94
|
+
# A message waits: the wait ends so the model reads it at the next
|
|
95
|
+
# boundary. A task that finished meanwhile gives its finished result,
|
|
96
|
+
# as after a Stop.
|
|
97
|
+
def self.message_waiting_response(task_id, tail_lines, started)
|
|
98
|
+
record, error = TaskRuntime.get_record(task_id)
|
|
99
|
+
return error if error
|
|
100
|
+
return finished_response(record, tail_lines) unless record.running?
|
|
101
|
+
|
|
102
|
+
note = [format(MESSAGE_WAITING_NOTE, id: task_id), "waited: #{(monotonic_time - started).round}s"]
|
|
103
|
+
format_response(record, wait_result: "message_waiting", output_tail: output_tail(record, tail_lines),
|
|
104
|
+
tail_lines: tail_lines, extra: note)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :message_waiting_response
|
|
107
|
+
|
|
83
108
|
# A finished task's text, as task_wait gives it: its status lines and
|
|
84
109
|
# always the log's last lines (a test run's summary, an error), then a
|
|
85
110
|
# stderr_tail when the record has a non-empty one. A task the user
|
|
@@ -5,7 +5,8 @@ require "monitor"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
# A session's turn as other threads see it: whether one runs, its cancel
|
|
7
7
|
# controller and event sink, the plugin steers waiting for its next
|
|
8
|
-
# boundary,
|
|
8
|
+
# boundary, whether a person's message waits for it (its caller's
|
|
9
|
+
# input_waiting: probe), and the activity clock the idle jobs read.
|
|
9
10
|
#
|
|
10
11
|
# The turn thread writes it (Engine#begin_turn, #release_turn); the idle
|
|
11
12
|
# scheduler, the bridge's request threads, plugin threads and the UIs
|
|
@@ -24,6 +25,8 @@ module Samagotchi
|
|
|
24
25
|
@sink = nil
|
|
25
26
|
@thread = nil
|
|
26
27
|
@ended = false
|
|
28
|
+
@input_waiting = nil
|
|
29
|
+
@input_waiting_warned = false
|
|
27
30
|
@steers = []
|
|
28
31
|
@carried_steers = []
|
|
29
32
|
end
|
|
@@ -67,20 +70,24 @@ module Samagotchi
|
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
# A turn begins, on the calling thread (the turn's own): running, with
|
|
70
|
-
# its controller and
|
|
71
|
-
|
|
73
|
+
# its controller, sink and input_waiting probe, in one step.
|
|
74
|
+
# @param input_waiting [#call, nil] true while a person's message waits
|
|
75
|
+
# for the turn's next boundary (Engine#input_waiting?); nil: never
|
|
76
|
+
def begin!(controller:, sink:, input_waiting: nil)
|
|
72
77
|
@lock.synchronize do
|
|
73
78
|
@running = true
|
|
74
79
|
@controller = controller
|
|
75
80
|
@sink = sink
|
|
76
81
|
@thread = Thread.current
|
|
77
82
|
@ended = false
|
|
83
|
+
@input_waiting = input_waiting
|
|
84
|
+
@input_waiting_warned = false
|
|
78
85
|
@steers.concat(@carried_steers)
|
|
79
86
|
@carried_steers = []
|
|
80
87
|
end
|
|
81
88
|
end
|
|
82
89
|
|
|
83
|
-
# The turn is over: not running, no controller, sink or steers.
|
|
90
|
+
# The turn is over: not running, no controller, sink, probe or steers.
|
|
84
91
|
# @return [Array<Hash>] the steers it never took ({text:, source:})
|
|
85
92
|
def finish!
|
|
86
93
|
@lock.synchronize do
|
|
@@ -89,6 +96,7 @@ module Samagotchi
|
|
|
89
96
|
@sink = nil
|
|
90
97
|
@thread = nil
|
|
91
98
|
@ended = false
|
|
99
|
+
@input_waiting = nil
|
|
92
100
|
@steers.tap { @steers = [] }
|
|
93
101
|
end
|
|
94
102
|
end
|
|
@@ -118,6 +126,17 @@ module Samagotchi
|
|
|
118
126
|
end
|
|
119
127
|
end
|
|
120
128
|
|
|
129
|
+
# @return [#call, nil] the running turn's input_waiting probe (called
|
|
130
|
+
# outside the lock), nil outside a turn or for a turn given none
|
|
131
|
+
def input_waiting
|
|
132
|
+
@lock.synchronize { @running ? @input_waiting : nil }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A probe failed: true the first time in a turn (warn then), false after.
|
|
136
|
+
def input_waiting_failed!
|
|
137
|
+
@lock.synchronize { !@input_waiting_warned.tap { @input_waiting_warned = true } }
|
|
138
|
+
end
|
|
139
|
+
|
|
121
140
|
# Cancel the running turn, if any (outside the lock).
|
|
122
141
|
# @return [Boolean] whether a cancellation was triggered
|
|
123
142
|
def cancel!(reason)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -118,6 +118,9 @@ module Samagotchi
|
|
|
118
118
|
# Seconds GET /api/models waits for the hosts' lists before answering
|
|
119
119
|
# with what it has (the listing goes on and fills the registry's cache).
|
|
120
120
|
MODELS_WAIT_TIMEOUT = 4.0
|
|
121
|
+
# The one refusal for an update operation (an install, a workers
|
|
122
|
+
# restart, a chi web restart) asked while another one runs: 409 update_running.
|
|
123
|
+
UPDATE_RUNNING = "a chi update operation is already running; try again when it ends"
|
|
121
124
|
EVENTS_POLL = 1.0
|
|
122
125
|
|
|
123
126
|
# A callable the event loops ask whether the server still runs;
|
|
@@ -456,12 +459,14 @@ module Samagotchi
|
|
|
456
459
|
# The request's Principal, as #route resolved it.
|
|
457
460
|
def principal(req) = req.env.fetch(PRINCIPAL_ENV)
|
|
458
461
|
|
|
459
|
-
# LAN mode
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
462
|
+
# LAN mode: every route needs the access token, from a phone and from
|
|
463
|
+
# this machine's loopback too (a pair participant's shell runs here),
|
|
464
|
+
# as the chi_token cookie or an Authorization: Bearer header; only
|
|
465
|
+
# GATE_FREE_PATHS and chi's token-less /api/info probe
|
|
466
|
+
# (ProxyGate#probe_info?) are answered without it. The page with
|
|
467
|
+
# ?token= (or a link's #code=, POST /api/token) trades it for the
|
|
468
|
+
# cookie and a 303 to itself without it, so the token doesn't stay in
|
|
469
|
+
# the browser's history.
|
|
465
470
|
# @return [Array(Principal, nil), Array(nil, Array)] the request's
|
|
466
471
|
# principal (#principal_for), or the response that refuses it
|
|
467
472
|
def token_gate(req)
|
|
@@ -486,9 +491,9 @@ module Samagotchi
|
|
|
486
491
|
# the access token; a participant for a live grant's token (a chp_
|
|
487
492
|
# one, looked up only when no owner token was given), wherever the
|
|
488
493
|
# request comes from, this machine's loopback too; with neither, the
|
|
489
|
-
# owner
|
|
490
|
-
#
|
|
491
|
-
# nil anywhere: it never falls back to the owner.
|
|
494
|
+
# owner only on a chi web that asks no token (loopback only: no LAN
|
|
495
|
+
# mode, no web.public_url), else nil. An ended or unknown grant's
|
|
496
|
+
# token is nil anywhere: it never falls back to the owner.
|
|
492
497
|
# @return [Principal, nil]
|
|
493
498
|
# With web.public_url (ProxyGate) every request needs a token, and a
|
|
494
499
|
# proxied one is #proxied_principal's.
|
|
@@ -592,11 +597,10 @@ module Samagotchi
|
|
|
592
597
|
"#{token_cookie_name(req)}=; #{token_cookie_attributes(req).sub(/Max-Age=\d+/, "Max-Age=0")}"
|
|
593
598
|
end
|
|
594
599
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
end
|
|
600
|
+
# Whether the gate asks this request for a token: on a chi web on the
|
|
601
|
+
# LAN or behind a proxy, every request does, this machine's loopback
|
|
602
|
+
# too. Only a loopback-only chi web takes loopback as the owner.
|
|
603
|
+
def token_required?(_req) = !(@public_url || @lan).nil?
|
|
600
604
|
|
|
601
605
|
# The page opened with ?token= (a gated request, or a grant's token
|
|
602
606
|
# anywhere): its token is traded for the cookie, whatever else the
|
|
@@ -702,7 +706,7 @@ module Samagotchi
|
|
|
702
706
|
false
|
|
703
707
|
end
|
|
704
708
|
end
|
|
705
|
-
return error_response(409, "update_running",
|
|
709
|
+
return error_response(409, "update_running", UPDATE_RUNNING) if already_running
|
|
706
710
|
|
|
707
711
|
@hub.publish_update(state: "running", rows: [], operation: "install", version: version)
|
|
708
712
|
Thread.new { perform_update(version) }
|
|
@@ -774,7 +778,7 @@ module Samagotchi
|
|
|
774
778
|
false
|
|
775
779
|
end
|
|
776
780
|
end
|
|
777
|
-
return error_response(409, "update_running",
|
|
781
|
+
return error_response(409, "update_running", UPDATE_RUNNING) if already_running
|
|
778
782
|
|
|
779
783
|
@hub.publish_update(state: "running", rows: [], operation: "workers")
|
|
780
784
|
Thread.new { perform_worker_restarts }
|
|
@@ -834,7 +838,7 @@ module Samagotchi
|
|
|
834
838
|
@update_running = true # held for good once restarting: the exec ends this process
|
|
835
839
|
false
|
|
836
840
|
end
|
|
837
|
-
return error_response(409, "update_running",
|
|
841
|
+
return error_response(409, "update_running", UPDATE_RUNNING) if busy
|
|
838
842
|
|
|
839
843
|
case @restart_web.call
|
|
840
844
|
when true then return json_response(202, { state: "restarting" })
|
|
@@ -900,7 +904,11 @@ module Samagotchi
|
|
|
900
904
|
|
|
901
905
|
def unauthorized_detail(req, reason)
|
|
902
906
|
return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
|
|
903
|
-
|
|
907
|
+
|
|
908
|
+
unless @public_url
|
|
909
|
+
here = LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
|
|
910
|
+
return "chi web on the LAN needs its access token#{", on this machine too" if here}"
|
|
911
|
+
end
|
|
904
912
|
|
|
905
913
|
detail = "chi web with web.public_url needs its access token, on this machine too"
|
|
906
914
|
direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
|
|
@@ -938,7 +946,7 @@ module Samagotchi
|
|
|
938
946
|
# the proxy too.
|
|
939
947
|
def self.public_unauthorized_page(page)
|
|
940
948
|
page.sub('<form method="get" action="/">', '<form method="post" action="/api/token">')
|
|
941
|
-
.sub("Open the link chi web printed in its terminal (LAN, or
|
|
949
|
+
.sub("Open the link chi web printed in its terminal (Local on this machine, LAN, or\nProxied behind an auth proxy), or scan its QR code.",
|
|
942
950
|
"Open the Proxied link chi web printed in its terminal (it works once; chi web again prints a new " \
|
|
943
951
|
"one), or paste the token from its web-token file.")
|
|
944
952
|
end
|
|
@@ -958,8 +966,8 @@ module Samagotchi
|
|
|
958
966
|
</style></head>
|
|
959
967
|
<body>
|
|
960
968
|
<h1>chi web</h1>
|
|
961
|
-
<p>This page needs chi web's access token. Open the link chi web printed in its terminal (LAN, or
|
|
962
|
-
auth proxy), or scan its QR code.</p>
|
|
969
|
+
<p>This page needs chi web's access token. Open the link chi web printed in its terminal (Local on this machine, LAN, or
|
|
970
|
+
Proxied behind an auth proxy), or scan its QR code.</p>
|
|
963
971
|
<p id="trade" hidden></p>
|
|
964
972
|
<form method="get" action="/">
|
|
965
973
|
<label for="token">Or paste the token:</label>
|
|
@@ -1721,8 +1729,7 @@ module Samagotchi
|
|
|
1721
1729
|
outcome.is_a?(WorkerLink::Leaving) || outcome.is_a?(WorkerLink::Stale) ||
|
|
1722
1730
|
outcome.is_a?(WorkerLink::Skew) ||
|
|
1723
1731
|
(outcome.is_a?(WorkerLink::Starting) && outcome.stuck)
|
|
1724
|
-
end
|
|
1725
|
-
host: DEFAULT_HOST)
|
|
1732
|
+
end)
|
|
1726
1733
|
end
|
|
1727
1734
|
|
|
1728
1735
|
def stream_link_error(id, link)
|
|
@@ -1741,7 +1748,7 @@ module Samagotchi
|
|
|
1741
1748
|
def stream_open_error(id, outcome)
|
|
1742
1749
|
return error_response(503, "not_live", WorkerLink.unanswered(id)) if outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
|
|
1743
1750
|
return error_response(503, "not_live", WorkerLink.not_live(id)) if outcome.reason == :unknown_session
|
|
1744
|
-
return error_response(502,
|
|
1751
|
+
return error_response(502, BridgeSecret::ERROR, BridgeSecret.refused(id)) if outcome.reason == :bridge_secret
|
|
1745
1752
|
|
|
1746
1753
|
error_response(502, "bridge_error", outcome.detail || "the session's worker answered #{outcome.status}")
|
|
1747
1754
|
end
|
|
@@ -1752,7 +1759,7 @@ module Samagotchi
|
|
|
1752
1759
|
# @return [WorkerLink::Live, WorkerLink::None, WorkerLink::Stale]
|
|
1753
1760
|
def worker_link(session_id)
|
|
1754
1761
|
dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
|
|
1755
|
-
WorkerLink.resolve(dir, unlink: true
|
|
1762
|
+
WorkerLink.resolve(dir, unlink: true)
|
|
1756
1763
|
rescue StandardError
|
|
1757
1764
|
WorkerLink::None.new
|
|
1758
1765
|
end
|
|
@@ -124,13 +124,15 @@ module Samagotchi
|
|
|
124
124
|
participant_principal(req, grant_tokens, identity: identity, proxied: true)
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
# chi's own probe of a chi web
|
|
128
|
-
#
|
|
127
|
+
# chi's own probe of a chi web that asks this machine for its token
|
|
128
|
+
# (behind a proxy, or on the LAN: InfoProbe from a second chi web, chi
|
|
129
|
+
# share, chi update, chi self) sends no token: GET /api/info from this
|
|
129
130
|
# machine, not through the proxy, with no token at all, gets the
|
|
130
|
-
# fields it checks (nothing secret
|
|
131
|
-
# to whatever listens on
|
|
131
|
+
# fields it checks (nothing secret: its start folder is a path on this
|
|
132
|
+
# machine), so no probe ever hands the token to whatever listens on
|
|
133
|
+
# the port. Any token or cookie makes it an ordinary request.
|
|
132
134
|
def probe_info?(req)
|
|
133
|
-
return false unless @public_url && req.get? && req.path_info == "/api/info"
|
|
135
|
+
return false unless (@public_url || @lan) && req.get? && req.path_info == "/api/info"
|
|
134
136
|
return false unless App::LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
|
|
135
137
|
|
|
136
138
|
req.get_header("HTTP_AUTHORIZATION").nil? && req.get_header("HTTP_COOKIE").nil?
|
|
@@ -138,7 +140,7 @@ module Samagotchi
|
|
|
138
140
|
|
|
139
141
|
def probe_info
|
|
140
142
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, features: App::INFO_FEATURES,
|
|
141
|
-
lan: @lan && @lan[:ip] })
|
|
143
|
+
lan: @lan && @lan[:ip], start_dir: start_dir })
|
|
142
144
|
end
|
|
143
145
|
|
|
144
146
|
def proxy_refuse(req, reason)
|
|
@@ -60,7 +60,7 @@ import { createSystemOneWrongAction } from "./system_one_wrong.js";
|
|
|
60
60
|
import { appendQuote } from "./annotations.js";
|
|
61
61
|
import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
|
|
62
62
|
import { CHECK_NOT_ON_PAGE, nameVerdictCards, ownerCheckHash } from "./verdict_card.js";
|
|
63
|
-
import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
|
|
63
|
+
import { freshURL, placeVerdictWithFresh, redrawFresh, settleRunning } from "./verdict_fresh.js";
|
|
64
64
|
import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
|
|
65
65
|
import { parsePresets } from "./annotate_presets.js";
|
|
66
66
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
@@ -590,8 +590,13 @@ function copyUpdateCommand(command) {
|
|
|
590
590
|
function followSelectedSession(sess) {
|
|
591
591
|
current.owner = sess.owner || null;
|
|
592
592
|
current.workerVersion = sess.worker_version || null;
|
|
593
|
+
const verifyWas = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
|
|
593
594
|
current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
|
|
594
595
|
current.bridgeUp = sess.bridge_up === true;
|
|
596
|
+
// The Verify gate flipped (a restart brought the feature): the cards' Verify fresh part follows.
|
|
597
|
+
if (verifyWas !== verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp)) {
|
|
598
|
+
redrawFresh(historyEl, verifyFreshState());
|
|
599
|
+
}
|
|
595
600
|
const streaming = !!(streamControl && streamControl.live);
|
|
596
601
|
if (!streaming) current.status = sess.status;
|
|
597
602
|
updateInfoBar();
|
|
@@ -1356,6 +1361,7 @@ function placeVerdict(record) {
|
|
|
1356
1361
|
|
|
1357
1362
|
// The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
|
|
1358
1363
|
// always theirs to start; its running check links to the child's session.
|
|
1364
|
+
const verifyFreshErrors = {};
|
|
1359
1365
|
function verifyFreshState() {
|
|
1360
1366
|
const id = selected;
|
|
1361
1367
|
const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
|
|
@@ -1363,6 +1369,7 @@ function verifyFreshState() {
|
|
|
1363
1369
|
return {
|
|
1364
1370
|
verdicts: current.verdicts || [],
|
|
1365
1371
|
running: current.verifyFresh,
|
|
1372
|
+
errors: verifyFreshErrors,
|
|
1366
1373
|
canStart: !!id && !unavailable,
|
|
1367
1374
|
unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
|
|
1368
1375
|
start: async (of) => {
|
|
@@ -434,12 +434,14 @@ function renderThread() {
|
|
|
434
434
|
// participant starts one while their grant could run a turn now
|
|
435
435
|
// (can_comment); the owner looking through the participant view never
|
|
436
436
|
// does, and no one here gets a link to the child's session.
|
|
437
|
+
const freshErrors = {};
|
|
437
438
|
function verifyFreshState() {
|
|
438
439
|
if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
|
|
439
440
|
const unavailable = data.session?.verify_unavailable === true;
|
|
440
441
|
return {
|
|
441
442
|
verdicts: data.verdicts || [],
|
|
442
443
|
running: data.fresh_running,
|
|
444
|
+
errors: freshErrors,
|
|
443
445
|
canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
|
|
444
446
|
unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
|
|
445
447
|
start: async (of) => {
|
|
@@ -29,7 +29,8 @@ export function settleRunning(running, record) {
|
|
|
29
29
|
// Places a session verdict as the pages draw them: an original verdict's
|
|
30
30
|
// card (verdict_card.js) with its Verify fresh part, or a fresh verdict on
|
|
31
31
|
// its original's card. +fresh+: {verdicts (all of the session's), running
|
|
32
|
-
// (the page's own array; a start adds to it),
|
|
32
|
+
// (the page's own array; a start adds to it), errors (the page's own
|
|
33
|
+
// object, check id → the last start error, so a redraw shows it again), canStart, start(of) → Promise
|
|
33
34
|
// of {ok, child_id, error}, childHref(id) → a link to the child or null}.
|
|
34
35
|
export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
|
|
35
36
|
if (record?.fresh) {
|
|
@@ -43,6 +44,11 @@ export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
|
|
|
43
44
|
return card;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// Every verdict card under +root+ drawn again from +fresh+ (the gate changed).
|
|
48
|
+
export function redrawFresh(root, fresh = {}) {
|
|
49
|
+
for (const card of root?.querySelectorAll?.(".verify-verdict") || []) renderFresh(card, fresh);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
// The card's Verify fresh part, drawn again from +fresh+: the fresh
|
|
47
53
|
// verdicts, the running check, else the button (an unclear verdict, and a
|
|
48
54
|
// viewer who could run a turn now).
|
|
@@ -100,10 +106,11 @@ export function renderFresh(card, fresh = {}) {
|
|
|
100
106
|
part.append(button);
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
|
|
109
|
+
const error = (fresh.errors ||= {})[of];
|
|
110
|
+
if (error) {
|
|
104
111
|
const line = doc.createElement("p");
|
|
105
112
|
line.className = "verify-fresh-error";
|
|
106
|
-
line.textContent =
|
|
113
|
+
line.textContent = error;
|
|
107
114
|
part.append(line);
|
|
108
115
|
}
|
|
109
116
|
if (part.childNodes.length) card.append(part);
|
|
@@ -120,12 +127,12 @@ async function startFresh(card, button, fresh) {
|
|
|
120
127
|
result = { ok: false, error: error?.message };
|
|
121
128
|
}
|
|
122
129
|
if (result?.ok) {
|
|
123
|
-
delete
|
|
130
|
+
if (fresh.errors) delete fresh.errors[of];
|
|
124
131
|
if (fresh.running && !fresh.running.some((entry) => runningOf(entry) === of)) {
|
|
125
132
|
fresh.running.push(result.child_id ? { of, child_id: result.child_id } : of);
|
|
126
133
|
}
|
|
127
134
|
} else {
|
|
128
|
-
|
|
135
|
+
(fresh.errors ||= {})[of] = result?.error || "Couldn't start a fresh check.";
|
|
129
136
|
}
|
|
130
137
|
renderFresh(card, fresh);
|
|
131
138
|
}
|
|
@@ -118,11 +118,15 @@ module Samagotchi
|
|
|
118
118
|
puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
|
|
119
119
|
other_start = start_given && other_start_line(found["start_dir"], start_dir)
|
|
120
120
|
puts other_start if other_start
|
|
121
|
-
puts running_lan_lines(found["lan"], port) if found["lan"]
|
|
122
121
|
running_public = public_url || Running.live_public_url(probe: ->(at) { probe(host, at) })
|
|
122
|
+
# On the LAN this machine's page needs the token too: the link
|
|
123
|
+
# printed and opened here carries an owner's one-time code.
|
|
124
|
+
here = found["lan"] && !running_public && Token.read(Token.path) ? local_owner_link(url) : nil
|
|
125
|
+
puts local_lines(here) if here
|
|
126
|
+
puts running_lan_lines(found["lan"], port) if found["lan"]
|
|
123
127
|
puts running_public_lines(running_public) if running_public
|
|
124
128
|
puts lan_off_line(setting) if new_token && !found["lan"]
|
|
125
|
-
open_url(url) if open_browser
|
|
129
|
+
open_url(here ? local_owner_link(url) : url) if open_browser
|
|
126
130
|
0
|
|
127
131
|
when :other
|
|
128
132
|
warn in_use_message(port)
|
|
@@ -388,6 +392,10 @@ module Samagotchi
|
|
|
388
392
|
on_bound&.call
|
|
389
393
|
record_running(running_path, port, public_url)
|
|
390
394
|
puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
|
|
395
|
+
# On the LAN (no web.public_url) this machine's page needs the
|
|
396
|
+
# token too: a one-time owner's code in the link instead.
|
|
397
|
+
local = lan && !public_url
|
|
398
|
+
puts local_lines(local_owner_link(url, codes: owner_codes(state_dir))) if local
|
|
391
399
|
puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
|
|
392
400
|
if public_url
|
|
393
401
|
puts public_lines(public_url, token: Token.read(token_path), codes: owner_codes(state_dir), notes: public_notes)
|
|
@@ -395,8 +403,12 @@ module Samagotchi
|
|
|
395
403
|
puts "Press Ctrl-C to stop."
|
|
396
404
|
$stdout.flush # a log file isn't line-buffered
|
|
397
405
|
if open_browser
|
|
398
|
-
# With web.public_url the page opens through the proxy
|
|
399
|
-
|
|
406
|
+
# With web.public_url the page opens through the proxy; on the
|
|
407
|
+
# LAN with a one-time owner's code (#code=, its own).
|
|
408
|
+
open_target = if public_url then public_url.link(owner_codes(state_dir).issue(kind: :owner, ttl: LinkCodes::OWNER_TTL))
|
|
409
|
+
elsif local then local_owner_link(url, codes: owner_codes(state_dir))
|
|
410
|
+
else url
|
|
411
|
+
end
|
|
400
412
|
Thread.new do
|
|
401
413
|
sleep 0.8
|
|
402
414
|
open_url(open_target)
|
|
@@ -518,6 +530,19 @@ module Samagotchi
|
|
|
518
530
|
lines
|
|
519
531
|
end
|
|
520
532
|
|
|
533
|
+
# This machine's page on the LAN (web.host lan, no web.public_url),
|
|
534
|
+
# whose loopback needs the token too: +url+ with an owner's one-time
|
|
535
|
+
# code in its fragment (token_fragment.js trades it for the cookie on
|
|
536
|
+
# 127.0.0.1), never the token. One code per link.
|
|
537
|
+
def self.local_owner_link(url, codes: owner_codes)
|
|
538
|
+
"#{url}#code=#{codes.issue(kind: :owner, ttl: LinkCodes::OWNER_TTL)}"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def self.local_lines(link)
|
|
542
|
+
["Local: #{link} ← opens chi web as you once, within #{LinkCodes::OWNER_TTL / 60} minutes " \
|
|
543
|
+
"(chi web again prints a new one; the LAN link works here too)"]
|
|
544
|
+
end
|
|
545
|
+
|
|
521
546
|
# The owner's one-time link codes, next to the grants.
|
|
522
547
|
def self.owner_codes(state_dir = nil) = LinkCodes.for_grants(Grants.path(state_dir: state_dir))
|
|
523
548
|
|