terret-exec 0.1.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 +7 -0
- data/lib/terret/exec/fs.rb +186 -0
- data/lib/terret/exec/jobs.rb +304 -0
- data/lib/terret/exec/sandbox_none.rb +31 -0
- data/lib/terret/exec/shell.rb +520 -0
- data/lib/terret/exec/subprocess.rb +617 -0
- data/lib/terret/exec/terminals.rb +180 -0
- data/lib/terret/exec.rb +14 -0
- metadata +72 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module Exec
|
|
7
|
+
# There is no shell to run anything in: bash could not be spawned, or it
|
|
8
|
+
# started and never answered its startup handshake. Raised rather than
|
|
9
|
+
# returned, because every other outcome on this seam describes a command
|
|
10
|
+
# that actually ran, and this one is the absence of the thing that runs
|
|
11
|
+
# them.
|
|
12
|
+
ShellUnavailable = Class.new(Terret::Tools::Failure)
|
|
13
|
+
|
|
14
|
+
# A second command arrived for a session that is still running one.
|
|
15
|
+
# Refused rather than queued: one bash per key can only run one thing at a
|
|
16
|
+
# time, and a queue would either make the waiting caller's `timeout:` a lie
|
|
17
|
+
# (its clock would start before its command did) or need a scheduler this
|
|
18
|
+
# seam has no business owning. The guarantee worth keeping is narrow and
|
|
19
|
+
# absolute — the result a caller gets back is that caller's command's
|
|
20
|
+
# result — and a refusal keeps it without inventing machinery.
|
|
21
|
+
ShellBusy = Class.new(Terret::Tools::Failure)
|
|
22
|
+
|
|
23
|
+
# ctx[:shell] — one persistent bash per session key (plan §6.6;
|
|
24
|
+
# docs/exec.md §2). The whole reason this seam exists next to
|
|
25
|
+
# ctx[:subprocess]'s one-shot #spawn is that the same process serves every
|
|
26
|
+
# call for a key, so `cd` and `export` from one run are visible to the
|
|
27
|
+
# next, exactly as a human's terminal session behaves.
|
|
28
|
+
#
|
|
29
|
+
# The protocol: write the command on its own line, then a `printf` line
|
|
30
|
+
# that emits a per-session random sentinel followed by `$?`, and read
|
|
31
|
+
# until that marker. The sentinel is generated here and never exported, so
|
|
32
|
+
# a command cannot forge one; the marker regexp additionally demands
|
|
33
|
+
# digits and a newline immediately after it, which is what makes the parse
|
|
34
|
+
# exact rather than lucky — the only other place the sentinel can appear
|
|
35
|
+
# in the stream is a terminal echo of the request line, where `%s` follows
|
|
36
|
+
# it instead of a status.
|
|
37
|
+
#
|
|
38
|
+
# There is deliberately no cap on the number of sessions, where
|
|
39
|
+
# ctx[:terminals] caps names hard. The difference is who supplies the key:
|
|
40
|
+
# a session key is an agent id the harness hands in, so the count is
|
|
41
|
+
# bounded by the agents the harness chose to run, while a terminal name
|
|
42
|
+
# comes from a tool call the model wrote and is therefore something a model
|
|
43
|
+
# can mint without limit. Capping the harness against itself would only
|
|
44
|
+
# move the failure somewhere less honest.
|
|
45
|
+
class Shell < Hames::Service
|
|
46
|
+
service_key :shell
|
|
47
|
+
inject :subprocess
|
|
48
|
+
config_schema timeout: { type: Numeric, default: 120, doc: "seconds a shell command may run" },
|
|
49
|
+
max_output: { type: Integer, default: 1 << 20,
|
|
50
|
+
doc: "bytes of command output retained before truncation" },
|
|
51
|
+
cwd: { type: String, doc: "working directory for the shell (default: Dir.pwd)" },
|
|
52
|
+
env: { type: Hash, default: {}, doc: "environment overlay for the shell" }
|
|
53
|
+
|
|
54
|
+
# `status` is nil when the command did not report one — it was
|
|
55
|
+
# interrupted, or the shell ended underneath it. An exit code we do not
|
|
56
|
+
# have is not invented. `stdout` is the terminal's stream: a pty has one,
|
|
57
|
+
# so a command's stderr arrives interleaved here rather than separately.
|
|
58
|
+
# `notice` is nil unless something happened that the caller did not ask
|
|
59
|
+
# for (a restart, a truncation); it is a field rather than text appended
|
|
60
|
+
# to stdout so that stdout stays exactly what the terminal carried.
|
|
61
|
+
#
|
|
62
|
+
# "Exactly" is worth pinning down: no echo of the request, no prompt, no
|
|
63
|
+
# separator this file injected. It does not mean only the command's own
|
|
64
|
+
# bytes — the shell's own lines (`[1] 1234` when a job is backgrounded, a
|
|
65
|
+
# syntax error it complains about) are written to the same terminal at
|
|
66
|
+
# the same time, and reporting them is more honest than guessing which
|
|
67
|
+
# lines a caller did not mean to ask for.
|
|
68
|
+
Result = Data.define(:status, :stdout, :notice)
|
|
69
|
+
|
|
70
|
+
Session = Data.define(:handle, :sentinel, :marker)
|
|
71
|
+
|
|
72
|
+
DEFAULT_SESSION = :default
|
|
73
|
+
DEFAULT_TIMEOUT = 120
|
|
74
|
+
HANDSHAKE_TIMEOUT = 10
|
|
75
|
+
CHUNK = 64 * 1024
|
|
76
|
+
|
|
77
|
+
# What one run may buffer. A command that writes without pause fills this
|
|
78
|
+
# process's memory at whatever rate the terminal will carry — measured
|
|
79
|
+
# here at 11.4MB in three seconds, so the default 120s timeout puts
|
|
80
|
+
# roughly 450MB within reach of a single `yes`. One mebibyte is far more
|
|
81
|
+
# than a model can read (the Bash tool caps what it shows at a fraction
|
|
82
|
+
# of it) and far less than a runaway command needs to hurt the host; the
|
|
83
|
+
# limit is a memory bound, not a display decision, which is why the tool
|
|
84
|
+
# layer still applies its own smaller one.
|
|
85
|
+
DEFAULT_MAX_OUTPUT = 1 << 20
|
|
86
|
+
|
|
87
|
+
# Once the cap is reached the tail of the stream is still scanned for the
|
|
88
|
+
# marker, in a window this size. It only has to be longer than a marker
|
|
89
|
+
# (a 38-byte sentinel, a status, a newline) for a marker split across two
|
|
90
|
+
# reads to survive the trim.
|
|
91
|
+
MARKER_WINDOW = 256
|
|
92
|
+
|
|
93
|
+
# UTF-8 lead bytes and the character length each one declares: [mask,
|
|
94
|
+
# value, bytes]. Read as "if (byte & mask) == value, the character is
|
|
95
|
+
# `bytes` long".
|
|
96
|
+
CHARACTER_LENGTHS = [
|
|
97
|
+
[0x80, 0x00, 1],
|
|
98
|
+
[0xE0, 0xC0, 2],
|
|
99
|
+
[0xF0, 0xE0, 3],
|
|
100
|
+
[0xF8, 0xF0, 4]
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
# A session that is not reused is never left half-drained, so the budget
|
|
104
|
+
# only bounds the pathological case: a background job spewing without
|
|
105
|
+
# pause between two runs. When it does lose that race the remainder is
|
|
106
|
+
# not lost, it simply arrives inside the next run's output — the same
|
|
107
|
+
# thing a human sees when a background job prints over their next
|
|
108
|
+
# command, and a better answer than reading a spewing terminal forever.
|
|
109
|
+
DRAIN_BUDGET = 0.1
|
|
110
|
+
|
|
111
|
+
# How long a session gets to leave on its own before the handle's reaper
|
|
112
|
+
# takes over. Bounded, because a shell still busy with a command will not
|
|
113
|
+
# read the request at all — and in exactly that case disposal costs this
|
|
114
|
+
# budget plus the reaper's own grace before the SIGKILL lands (measured:
|
|
115
|
+
# a close that would take 3s takes 6.01s when a child is still writing),
|
|
116
|
+
# because bash never gets to the `exit`.
|
|
117
|
+
FAREWELL_BUDGET = 1.0
|
|
118
|
+
|
|
119
|
+
# ETX — what a human's ^C is on the wire. The line discipline turns it
|
|
120
|
+
# into SIGINT for the terminal's foreground process group; with job
|
|
121
|
+
# control off (see HANDSHAKE) that group is the session's own, so the
|
|
122
|
+
# signal reaches the command's children as well as bash. It is the
|
|
123
|
+
# gentler half of ending a run: #sweep is what guarantees the rest.
|
|
124
|
+
INTERRUPT = "\u0003"
|
|
125
|
+
|
|
126
|
+
# `--noediting` is load-bearing, not tidiness: on a pty bash is
|
|
127
|
+
# interactive, and readline echoes the line it is reading no matter what
|
|
128
|
+
# the terminal's own echo flag says. Disabling line editing puts the
|
|
129
|
+
# echo back under the terminal's control, where the handshake's `stty
|
|
130
|
+
# -echo` can turn it off. `--norc --noprofile` keep a developer's dotfiles
|
|
131
|
+
# from deciding what an agent's shell prints.
|
|
132
|
+
BASH_ARGV = ["bash", "--norc", "--noprofile", "--noediting", "-s"].freeze
|
|
133
|
+
|
|
134
|
+
# Run once per session before any command. `-echo` so the request lines
|
|
135
|
+
# do not come back as output; `-onlcr` so the terminal stops rewriting
|
|
136
|
+
# the child's newlines as CR-LF; `-icanon min 1 time 0` because a
|
|
137
|
+
# canonical-mode terminal caps one input line at MAX_CANON (1024 bytes on
|
|
138
|
+
# macOS) and would silently lose the tail of a longer command; `-ixon` so
|
|
139
|
+
# a stray ^S in a command cannot wedge the stream. Failure is tolerated
|
|
140
|
+
# (`2>/dev/null`): under a sandbox whose exec has no tty there is nothing
|
|
141
|
+
# to configure, and bash is then non-interactive, which needs none of it.
|
|
142
|
+
#
|
|
143
|
+
# `set +m` turns job control off, and that is a disposal decision rather
|
|
144
|
+
# than a cosmetic one. With job control on, bash puts every job in a
|
|
145
|
+
# process group of its own, so a `&` job's group id is one nothing here
|
|
146
|
+
# ever learns — and a background job that outlives its session is a
|
|
147
|
+
# process holding the agent's authority that no part of the harness can
|
|
148
|
+
# still name. Without job control every child stays in the session's own
|
|
149
|
+
# process group, which #sweep can end as a unit. The trade-off, stated
|
|
150
|
+
# rather than discovered: the session has no `fg`, `bg`, or `%1`. What it
|
|
151
|
+
# does NOT buy is a quieter terminal — bash still prints its "[1] 1234"
|
|
152
|
+
# notice when a job is backgrounded (measured, not assumed), and that
|
|
153
|
+
# line lands in the run's output like anything else the shell says.
|
|
154
|
+
HANDSHAKE = "stty -echo -onlcr -icanon -ixon min 1 time 0 2>/dev/null; set +m; PS1=; PS2="
|
|
155
|
+
|
|
156
|
+
def start(ctx)
|
|
157
|
+
@ctx = ctx
|
|
158
|
+
@sessions = {} # key (String) => Session
|
|
159
|
+
@running = {} # key (String) => true while a command is in flight
|
|
160
|
+
# When the agent that owns a session key is disposed, its bash (and the
|
|
161
|
+
# background jobs in its process group) must go with it — fork disposal
|
|
162
|
+
# never reaches this root-mounted process. Registered via ctx.on, so it
|
|
163
|
+
# reverses when this service unloads.
|
|
164
|
+
ctx.on("agent/disposed") { |session_id| close(session: session_id) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The loader calls this on unload. A persistent shell is a process the
|
|
168
|
+
# harness owns, so dropping the reference without reaping it would leak a
|
|
169
|
+
# bash per agent for the life of the host process.
|
|
170
|
+
def stop(_ctx) = close_all
|
|
171
|
+
|
|
172
|
+
# Every knob is read where it is used, so a hot config swap needs nothing
|
|
173
|
+
# re-derived here. A live bash keeps the cwd and environment it was
|
|
174
|
+
# spawned with — it is a running process, not a value — and the new
|
|
175
|
+
# settings govern the next session.
|
|
176
|
+
def reconfigure(_config); end
|
|
177
|
+
|
|
178
|
+
# Runs one command in this key's session, spawning the session on first
|
|
179
|
+
# use. Never raises for a command's own failure: a non-zero status is a
|
|
180
|
+
# Result like any other, because a command that failed still ran.
|
|
181
|
+
def run(cmd, session: DEFAULT_SESSION, timeout: nil)
|
|
182
|
+
key = session.to_s
|
|
183
|
+
raise ShellBusy, "the #{key} shell session is already running a command" if @running[key]
|
|
184
|
+
|
|
185
|
+
@running[key] = true
|
|
186
|
+
begin
|
|
187
|
+
run!(key, cmd, timeout || default_timeout)
|
|
188
|
+
ensure
|
|
189
|
+
# released even when the run raised, so a session is never left
|
|
190
|
+
# permanently unusable by a failure it already reported
|
|
191
|
+
@running.delete(key)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The pid of this key's live bash, or nil if it has never run anything.
|
|
196
|
+
# An owner (and a test) can see whether a session is real without asking
|
|
197
|
+
# it to run something.
|
|
198
|
+
def pid(session: DEFAULT_SESSION) = @sessions[session.to_s]&.handle&.pid
|
|
199
|
+
|
|
200
|
+
# Reaps one session's bash. Closing a key that has none is not an error:
|
|
201
|
+
# disposal must be safe to call over a set of keys that may or may not
|
|
202
|
+
# have run anything.
|
|
203
|
+
def close(session: DEFAULT_SESSION) = discard(session.to_s)
|
|
204
|
+
|
|
205
|
+
def close_all = @sessions.keys.each { |key| discard(key) }
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def run!(key, cmd, timeout)
|
|
210
|
+
notices = []
|
|
211
|
+
if stale?(key)
|
|
212
|
+
discard(key)
|
|
213
|
+
notices << "the shell session had exited; a fresh one was started, " \
|
|
214
|
+
"so the cwd and variables from earlier runs are gone"
|
|
215
|
+
end
|
|
216
|
+
s = (@sessions[key] ||= open_session)
|
|
217
|
+
|
|
218
|
+
return ended(key, "", notices) unless write(s, request(s, cmd))
|
|
219
|
+
|
|
220
|
+
outcome, out, dropped = collect(s, monotonic + timeout)
|
|
221
|
+
if dropped.positive?
|
|
222
|
+
notices << "output truncated at max_output: kept the first #{out.bytesize} bytes " \
|
|
223
|
+
"and dropped #{dropped} more"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
case outcome
|
|
227
|
+
when :timeout then timed_out(key, s, out, notices, timeout)
|
|
228
|
+
when :eof then ended(key, out, notices)
|
|
229
|
+
else Result.new(status: outcome, stdout: out, notice: join(notices))
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def default_timeout = config[:timeout] || DEFAULT_TIMEOUT
|
|
234
|
+
def max_output = config[:max_output] || DEFAULT_MAX_OUTPUT
|
|
235
|
+
def cwd = config[:cwd] || Dir.pwd
|
|
236
|
+
def env = config[:env] || {}
|
|
237
|
+
|
|
238
|
+
# The command goes on its own line rather than joined to the marker line
|
|
239
|
+
# with `;`, so a multi-line command runs as written. printf emits no
|
|
240
|
+
# separator of its own before the sentinel, which is what makes stdout
|
|
241
|
+
# exact: there is no injected newline to strip back off, and a command
|
|
242
|
+
# whose output ends without one (or ends with a bare CR) is reported as
|
|
243
|
+
# it was written.
|
|
244
|
+
#
|
|
245
|
+
# The cost of this shape is honest and worth stating: a command that
|
|
246
|
+
# leaves bash waiting for more input (an unclosed quote, a trailing `\`)
|
|
247
|
+
# swallows the marker line, and the run ends at its timeout with the
|
|
248
|
+
# session restarted.
|
|
249
|
+
# `builtin printf`, not bare `printf`: a command that redefines printf as
|
|
250
|
+
# a shell function would otherwise intercept this line and forge both the
|
|
251
|
+
# sentinel and the status. `builtin` bypasses any function of that name,
|
|
252
|
+
# so the marker always carries the shell's real `$?`.
|
|
253
|
+
def request(s, cmd) = "#{cmd}\nbuiltin printf '%s%s\\n' '#{s.sentinel}' \"$?\"\n"
|
|
254
|
+
|
|
255
|
+
def open_session
|
|
256
|
+
sentinel = "TERRET#{SecureRandom.hex(16)}"
|
|
257
|
+
handle = @ctx[:subprocess].pty_spawn(BASH_ARGV, cwd: cwd, env: env)
|
|
258
|
+
s = Session.new(handle: handle, sentinel: sentinel,
|
|
259
|
+
marker: Regexp.new("#{sentinel}(\\d+)\\r?\\n"))
|
|
260
|
+
handshake!(s)
|
|
261
|
+
s
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Reading to the first marker also synchronises the session: everything
|
|
265
|
+
# bash said before it — the login banner some systems print, the default
|
|
266
|
+
# prompt, the echo of the handshake line itself before `stty -echo` took
|
|
267
|
+
# effect — is discarded, so the first command's output starts clean.
|
|
268
|
+
def handshake!(s)
|
|
269
|
+
outcome = if write(s, request(s, HANDSHAKE))
|
|
270
|
+
collect(s, monotonic + HANDSHAKE_TIMEOUT).first
|
|
271
|
+
else
|
|
272
|
+
:closed
|
|
273
|
+
end
|
|
274
|
+
return if outcome.is_a?(Integer)
|
|
275
|
+
|
|
276
|
+
# nothing else holds this handle yet, so a shell that never became
|
|
277
|
+
# usable is reaped here rather than left for the caller to dispose
|
|
278
|
+
s.handle.close
|
|
279
|
+
raise ShellUnavailable, "the shell did not answer its startup handshake (#{outcome})"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Reads until the marker, the deadline, or the end of the shell. Returns
|
|
283
|
+
# [status, stdout, dropped] for a command that reported one, or
|
|
284
|
+
# [:timeout, partial, dropped] / [:eof, partial, dropped] for the two
|
|
285
|
+
# ways it may not have.
|
|
286
|
+
#
|
|
287
|
+
# Past `max_output` the kept buffer stops growing, but reading does not:
|
|
288
|
+
# the marker arrives at the very end of a command's output, so a run that
|
|
289
|
+
# simply stopped reading would lose the status of every command that ever
|
|
290
|
+
# exceeded the cap, and would leave the unread bytes to be charged to the
|
|
291
|
+
# next run. Instead the overflow is counted and discarded, with a window
|
|
292
|
+
# of it kept so the marker is still found when it comes.
|
|
293
|
+
#
|
|
294
|
+
# The buffers stay BINARY until they are sliced: terminal bytes are not
|
|
295
|
+
# guaranteed to be valid UTF-8, and matching against a BINARY string is
|
|
296
|
+
# the one form that cannot raise on a child emitting whatever it likes.
|
|
297
|
+
def collect(s, deadline)
|
|
298
|
+
kept = String.new(encoding: Encoding::BINARY)
|
|
299
|
+
tail = nil # the rolling window, once the cap is reached
|
|
300
|
+
seen = 0 # every output byte the command produced, kept or not
|
|
301
|
+
cap = max_output
|
|
302
|
+
|
|
303
|
+
loop do
|
|
304
|
+
scan = tail || kept
|
|
305
|
+
if (m = s.marker.match(scan))
|
|
306
|
+
# Where the marker starts in the STREAM, not in whichever buffer
|
|
307
|
+
# found it: the window always ends at the stream's end, so its own
|
|
308
|
+
# offsets are relative. Everything before that point is the
|
|
309
|
+
# command's output and everything from it on is protocol — which
|
|
310
|
+
# is why the cut is taken here rather than at the cap. A command
|
|
311
|
+
# whose output stops within a marker's length of the cap leaves the
|
|
312
|
+
# marker BEGINNING inside the kept bytes, and returning those
|
|
313
|
+
# verbatim would hand the caller the session's sentinel, the one
|
|
314
|
+
# value the protocol's forgery resistance rests on.
|
|
315
|
+
marker_at = seen - scan.bytesize + m.begin(0)
|
|
316
|
+
out = kept.byteslice(0, marker_at) # byteslice clamps, so this is
|
|
317
|
+
# all of `kept` in the ordinary
|
|
318
|
+
# over-the-cap case
|
|
319
|
+
return [Integer(m[1]), text(out), dropped!(marker_at - out.bytesize)]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
remaining = deadline - monotonic
|
|
323
|
+
return [:timeout, *partial(kept, seen)] if remaining <= 0
|
|
324
|
+
|
|
325
|
+
chunk = s.handle.read(CHUNK, timeout: remaining)
|
|
326
|
+
return [:eof, *partial(kept, seen)] if chunk.nil?
|
|
327
|
+
|
|
328
|
+
seen += chunk.bytesize
|
|
329
|
+
if tail
|
|
330
|
+
window!(tail << chunk.b)
|
|
331
|
+
else
|
|
332
|
+
kept << chunk.b
|
|
333
|
+
next if kept.bytesize <= cap
|
|
334
|
+
|
|
335
|
+
# keep the first `cap` bytes; carry a window across the cut so a
|
|
336
|
+
# marker straddling it is still whole in the tail
|
|
337
|
+
tail = window!(kept.byteslice([cap - MARKER_WINDOW, 0].max..))
|
|
338
|
+
kept = whole_characters(kept.byteslice(0, cap))
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A run that ended without its marker: everything read is the command's
|
|
344
|
+
# output, and the cut is wherever the deadline or the shell's end landed
|
|
345
|
+
# — a place this file chose, so it gets the same character-boundary
|
|
346
|
+
# treatment the cap does.
|
|
347
|
+
def partial(kept, seen)
|
|
348
|
+
out = whole_characters(kept)
|
|
349
|
+
[text(out), dropped!(seen - out.bytesize)]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def window!(buf)
|
|
353
|
+
buf.slice!(0, buf.bytesize - MARKER_WINDOW) if buf.bytesize > MARKER_WINDOW
|
|
354
|
+
buf
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Back a byte-offset cut off to the last whole UTF-8 character. Cutting
|
|
358
|
+
# at a byte offset can split a character in half, and the halves are not
|
|
359
|
+
# the child's bytes — this seam made them. That matters beyond tidiness:
|
|
360
|
+
# a durable append JSON-encodes the payload, so a manufactured half
|
|
361
|
+
# character raises at the append boundary, one layer away from the code
|
|
362
|
+
# that broke it.
|
|
363
|
+
#
|
|
364
|
+
# Only an INCOMPLETE trailing character moves, and never more than three
|
|
365
|
+
# bytes, because that is the longest tail a split UTF-8 character can
|
|
366
|
+
# leave. Bytes a child emitted that were never valid UTF-8 are left
|
|
367
|
+
# exactly as they arrived — a stray continuation byte with no lead, an
|
|
368
|
+
# 0xFF — since preserving what the child actually wrote is the whole
|
|
369
|
+
# reason nothing here re-encodes. The rule is narrow on purpose: never
|
|
370
|
+
# manufacture invalid bytes out of valid ones.
|
|
371
|
+
def whole_characters(bytes)
|
|
372
|
+
seen = 0
|
|
373
|
+
index = bytes.bytesize - 1
|
|
374
|
+
while index >= 0 && seen < 4
|
|
375
|
+
byte = bytes.getbyte(index)
|
|
376
|
+
if (byte & 0xC0) == 0x80 # a continuation byte; keep walking back
|
|
377
|
+
index -= 1
|
|
378
|
+
seen += 1
|
|
379
|
+
next
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
need = CHARACTER_LENGTHS.find { |mask, value, _| (byte & mask) == value }&.last
|
|
383
|
+
return bytes if need.nil? || seen + 1 >= need # complete, or not ours to fix
|
|
384
|
+
|
|
385
|
+
return bytes.byteslice(0, index) # an incomplete tail: drop it
|
|
386
|
+
end
|
|
387
|
+
bytes
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Every byte dropped is a byte the command produced that the caller will
|
|
391
|
+
# not see, so the count is non-negative by construction — `out` is always
|
|
392
|
+
# a prefix of the output that preceded the marker. A negative count would
|
|
393
|
+
# mean protocol bytes were being counted as output, which is exactly the
|
|
394
|
+
# arithmetic that leaks a sentinel, so it fails loudly here rather than
|
|
395
|
+
# being rounded away by a `.positive?` check downstream.
|
|
396
|
+
def dropped!(count)
|
|
397
|
+
raise "shell: dropped byte count went negative (#{count}); the cap arithmetic is wrong" if count.negative?
|
|
398
|
+
|
|
399
|
+
count
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Bytes sitting on the terminal between two runs belong to whatever wrote
|
|
403
|
+
# them — a backgrounded job, a job-control notice — and never to the
|
|
404
|
+
# command about to run, so they are drained rather than charged to the
|
|
405
|
+
# next result. The same pass is the liveness check: a session whose bash
|
|
406
|
+
# is gone reads as EOF, and an idle live one answers "" on the first
|
|
407
|
+
# non-blocking read, so this costs a syscall in the ordinary case.
|
|
408
|
+
def stale?(key)
|
|
409
|
+
s = @sessions[key] or return false
|
|
410
|
+
|
|
411
|
+
deadline = monotonic + DRAIN_BUDGET
|
|
412
|
+
loop do
|
|
413
|
+
chunk = s.handle.read(CHUNK, timeout: 0)
|
|
414
|
+
return true if chunk.nil?
|
|
415
|
+
return false if chunk.empty? || monotonic >= deadline
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The decided semantics (docs/exec.md §2): kill and respawn rather than
|
|
420
|
+
# try to recover. The interrupt is what ends the command's own children;
|
|
421
|
+
# the session goes with it because after an interrupt its input queue and
|
|
422
|
+
# the output still in flight are in a state we cannot account for — the
|
|
423
|
+
# next run gets a shell we can, and the caller is told so rather than
|
|
424
|
+
# left to discover it through a lost `cd`.
|
|
425
|
+
def timed_out(key, s, out, notices, timeout)
|
|
426
|
+
interrupt(s)
|
|
427
|
+
discard(key)
|
|
428
|
+
notices << "timed out after #{timeout}s; the command was interrupted and the shell " \
|
|
429
|
+
"session killed, so the next run starts a fresh session"
|
|
430
|
+
Result.new(status: nil, stdout: out, notice: join(notices))
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def ended(key, out, notices)
|
|
434
|
+
discard(key)
|
|
435
|
+
notices << "the shell session ended while this command ran; the next run starts a " \
|
|
436
|
+
"fresh session, so the cwd and variables from earlier runs are gone"
|
|
437
|
+
Result.new(status: nil, stdout: out, notice: join(notices))
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def interrupt(s) = write(s, INTERRUPT)
|
|
441
|
+
|
|
442
|
+
# A write to a terminal whose child is gone is not an exception here: it
|
|
443
|
+
# is how we learn the session ended, and the caller asked to run a
|
|
444
|
+
# command, not to be told about a file descriptor.
|
|
445
|
+
def write(s, str)
|
|
446
|
+
s.handle.write(str)
|
|
447
|
+
true
|
|
448
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
449
|
+
false
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def discard(key)
|
|
453
|
+
s = @sessions.delete(key) or return nil
|
|
454
|
+
farewell(s)
|
|
455
|
+
sweep(s.handle.pid)
|
|
456
|
+
s.handle.close
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# End everything still running in the session's process group. Reaping
|
|
460
|
+
# bash is not enough on its own: a `&` job is not bash's business once
|
|
461
|
+
# started, and it survives the shell's exit to be reparented to init —
|
|
462
|
+
# a process with the agent's authority, outside the sandbox's lifecycle,
|
|
463
|
+
# that nothing in the harness can name any more. `set +m` (see HANDSHAKE)
|
|
464
|
+
# is what makes one signal reach all of them: without job control every
|
|
465
|
+
# child stays in the group bash leads, and PTY.spawn made bash a session
|
|
466
|
+
# leader, so its pid IS that group's id.
|
|
467
|
+
#
|
|
468
|
+
# Deliberately before the handle is closed: at this point bash is either
|
|
469
|
+
# alive or an unreaped zombie, so the pid is still ours and cannot have
|
|
470
|
+
# been recycled into somebody else's process group by the time the signal
|
|
471
|
+
# lands. KILL rather than TERM because this runs after the session has
|
|
472
|
+
# already been asked to leave politely.
|
|
473
|
+
#
|
|
474
|
+
# Both refusals mean the same thing here — there was nothing left to end.
|
|
475
|
+
# ESRCH is an empty group; EPERM is what Darwin answers when every member
|
|
476
|
+
# left is a zombie (measured: a group holding one live child signals
|
|
477
|
+
# fine, the same group once only the exited leader remains raises EPERM).
|
|
478
|
+
# The one case EPERM could hide is a live child running under another
|
|
479
|
+
# uid, which a setuid command could produce and which no signal of ours
|
|
480
|
+
# could have ended anyway.
|
|
481
|
+
def sweep(pgid)
|
|
482
|
+
Process.kill("KILL", -pgid)
|
|
483
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
484
|
+
nil
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Ask the shell to leave, and read it to EOF before the handle is closed.
|
|
488
|
+
# Both halves are load-bearing, and neither is politeness:
|
|
489
|
+
#
|
|
490
|
+
# An interactive bash ignores SIGTERM — that is what keeps a stray kill
|
|
491
|
+
# from taking down a human's terminal — so a close that went straight to
|
|
492
|
+
# the reaper would always spend the full grace period before the SIGKILL
|
|
493
|
+
# landed. `exit` is the only cheap way out.
|
|
494
|
+
#
|
|
495
|
+
# And a bash SIGKILLed while its terminal still holds bytes nobody read
|
|
496
|
+
# gets stuck in exit: measured on macOS, with as little as a startup
|
|
497
|
+
# banner pending, the process sits in `E` state indefinitely and the
|
|
498
|
+
# reaper's blocking wait never returns — a wedge that would take the
|
|
499
|
+
# whole reactor with it. Reading to EOF (which is also what draining
|
|
500
|
+
# does) is what keeps that from being reachable.
|
|
501
|
+
def farewell(s)
|
|
502
|
+
write(s, "exit\n")
|
|
503
|
+
deadline = monotonic + FAREWELL_BUDGET
|
|
504
|
+
loop do
|
|
505
|
+
return if s.handle.read(CHUNK, timeout: 0.01).nil? # EOF: the shell is gone
|
|
506
|
+
return if monotonic >= deadline
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def join(notices) = notices.empty? ? nil : notices.join(" ")
|
|
511
|
+
|
|
512
|
+
# Terminal bytes arrive as BINARY; everything downstream of this seam is
|
|
513
|
+
# text. Forced rather than encoded, so a command emitting invalid UTF-8
|
|
514
|
+
# still round-trips its bytes instead of raising here.
|
|
515
|
+
def text(buf) = buf.force_encoding(Encoding::UTF_8)
|
|
516
|
+
|
|
517
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|