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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6efa30e8c04fd00a6b0345ccf1417c5c3db917ff7b02134a557b54efc8739882
|
|
4
|
+
data.tar.gz: ab3a1afbd5f1e6fde1b6539afe1e34032a2b571f58281a42d5376e2996153ddd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3c7b9f52ee566f2247598dc1d3151a545d6b82d67d6e5f0e2c5e85a9212db6bab77fa3e0bf4bad7606985880107f7837b3fe02e75e3b145de32c41ba5d9978d8
|
|
7
|
+
data.tar.gz: 5ef179a3ae62a9d4d402f32d95cb9e8baa0aadf7e1067eaf201bd5c39056c1036a9bf18d0fdffec1d5b7a8872ba37d5cccd1be4e04f061b12c104c0478845369
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module Exec
|
|
7
|
+
# Deny-by-default: a path outside every granted workspace directory —
|
|
8
|
+
# including one reached only by resolving a `../` traversal or following a
|
|
9
|
+
# symlink — is Denied rather than silently clamped or allowed through. An
|
|
10
|
+
# `fs/authorize` veto renders the same way, so from the caller's side a
|
|
11
|
+
# containment failure and a policy veto look identical: neither was
|
|
12
|
+
# admitted, and the reason is all that differs.
|
|
13
|
+
Denied = Class.new(Terret::Tools::Failure)
|
|
14
|
+
|
|
15
|
+
# #edit was asked to replace a string that doesn't appear exactly once.
|
|
16
|
+
# Guessing which occurrence was meant is worse than refusing outright: an
|
|
17
|
+
# ambiguous edit is a bug in the caller's plan, not something to resolve
|
|
18
|
+
# by picking the first match.
|
|
19
|
+
EditAmbiguous = Class.new(Terret::Tools::Failure)
|
|
20
|
+
|
|
21
|
+
# ctx[:fs] — workspace-contained file ops (plan §6.6; docs/exec.md §2-3).
|
|
22
|
+
# Every path is realpath-contained to the granted `workspace:` list before
|
|
23
|
+
# any syscall runs (see #contain), then the op dispatches through the
|
|
24
|
+
# `fs/authorize` waterfall so a plugin can veto an otherwise-contained
|
|
25
|
+
# call. A listener may veto or let the call proceed; it can never redirect
|
|
26
|
+
# the syscall to a different path (see #authorize!). Containment is
|
|
27
|
+
# realpath-based rather than string-based specifically so both a `../`
|
|
28
|
+
# traversal and a symlink planted inside the workspace that points
|
|
29
|
+
# outside it resolve to where they actually land before the check runs,
|
|
30
|
+
# rather than being caught (or missed) as strings.
|
|
31
|
+
class FS < Hames::Service
|
|
32
|
+
service_key :fs
|
|
33
|
+
config_schema workspace: { type: [String, Array],
|
|
34
|
+
doc: "directory root(s) every fs op is contained to; " \
|
|
35
|
+
"empty or unset denies every operation" }
|
|
36
|
+
|
|
37
|
+
def start(ctx)
|
|
38
|
+
@ctx = ctx
|
|
39
|
+
@workspace = resolve_workspace(config[:workspace])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reconfigure(config)
|
|
43
|
+
@workspace = resolve_workspace(config[:workspace])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read(path) = read_contained(authorize!(:read, path))
|
|
47
|
+
|
|
48
|
+
def write(path, content)
|
|
49
|
+
p = authorize!(:write, path)
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(p))
|
|
51
|
+
write_contained(p, content)
|
|
52
|
+
p
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def edit(path, old, new)
|
|
56
|
+
p = authorize!(:write, path)
|
|
57
|
+
body = read_contained(p)
|
|
58
|
+
# #scan with a String argument (not a Regexp) matches literally, so
|
|
59
|
+
# `old` is never interpreted as a regex source here.
|
|
60
|
+
count = body.scan(old).length
|
|
61
|
+
raise EditAmbiguous, "#{old.inspect} appears #{count} times in #{path}; must be exactly 1" unless count == 1
|
|
62
|
+
|
|
63
|
+
write_contained(p, body.sub(old, new))
|
|
64
|
+
p
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stat(path)
|
|
68
|
+
p = authorize!(:read, path)
|
|
69
|
+
s = File.stat(p)
|
|
70
|
+
{ size: s.size, mtime: s.mtime.utc.iso8601, directory: s.directory? }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pattern is joined against every granted root in turn, then each match is
|
|
74
|
+
# containment-checked (#within_workspace?) so a symlinked entry can never
|
|
75
|
+
# leak an outside path through the listing.
|
|
76
|
+
def glob(pattern)
|
|
77
|
+
@workspace.flat_map { |root| Dir.glob(File.join(root, pattern)) }
|
|
78
|
+
.select { |p| within_workspace?(p) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# A glob match is kept only if realpath-ing it (following whatever symlink
|
|
84
|
+
# the glob turned up) still lands inside the workspace. A dangling symlink
|
|
85
|
+
# (Errno::ENOENT) or a symlink loop (Errno::ELOOP) resolves to nothing this
|
|
86
|
+
# can contain, so it is dropped — quietly, rather than letting File.realpath's
|
|
87
|
+
# Errno turn the whole listing (and the Grep built on it) into a hard failure
|
|
88
|
+
# just because one bad symlink exists in the workspace.
|
|
89
|
+
def within_workspace?(path)
|
|
90
|
+
contained?(File.realpath(path))
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A granted root must itself already exist, and is realpath'd up front
|
|
96
|
+
# for the same reason every op's target is: on macOS in particular,
|
|
97
|
+
# `Dir.mktmpdir` hands back a path under `/var`, itself a symlink to
|
|
98
|
+
# `/private/var`, and #contain always realpaths its resolved result —
|
|
99
|
+
# comparing that against an un-realpath'd root would fail containment
|
|
100
|
+
# for every path inside a workspace whose own name involves a symlink.
|
|
101
|
+
def resolve_workspace(dirs)
|
|
102
|
+
Array(dirs).map { |d| File.realpath(File.expand_path(d)) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The waterfall's only power is to veto: a returned Veto raises Denied.
|
|
106
|
+
# A listener that chains with `next_.(call.merge(path: ...))` may hand
|
|
107
|
+
# back a Hash with a rewritten `:path`, but that rewrite is deliberately
|
|
108
|
+
# ignored — #contain already proved `resolved` sits inside the granted
|
|
109
|
+
# workspace, and honoring a listener's path would let it redirect the
|
|
110
|
+
# syscall anywhere on disk. The containment decision is never delegated
|
|
111
|
+
# to a listener.
|
|
112
|
+
def authorize!(op, path)
|
|
113
|
+
resolved = contain(path)
|
|
114
|
+
admitted = @ctx.waterfall("fs/authorize", { op:, path: resolved })
|
|
115
|
+
raise Denied, admitted.reason if admitted.is_a?(Terret::Tools::Veto)
|
|
116
|
+
|
|
117
|
+
resolved
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Resolve to where the syscall would ACTUALLY land, then containment-check
|
|
121
|
+
# that. The target itself may not exist yet (#write's whole point), so we
|
|
122
|
+
# can't blindly realpath it; instead #resolve_real walks up to the deepest
|
|
123
|
+
# prefix that exists OR is a symlink. A `../` segment or an already-real
|
|
124
|
+
# symlink in an existing prefix is followed by realpath; a DANGLING
|
|
125
|
+
# symlink — one File.exist? reports as absent because it follows the link
|
|
126
|
+
# to a missing target — is resolved to where it points instead of being
|
|
127
|
+
# waved through as a fresh path (the container-escape the earlier check
|
|
128
|
+
# missed, since the later File.write would follow it outside).
|
|
129
|
+
def contain(path)
|
|
130
|
+
resolved = resolve_real(File.expand_path(path.to_s))
|
|
131
|
+
raise Denied, "#{path} is outside the granted workspace" unless contained?(resolved)
|
|
132
|
+
|
|
133
|
+
resolved
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The kernel gives up on a symlink chain at MAXSYMLINKS and answers ELOOP;
|
|
137
|
+
# this does the same. A LOOP (a -> b -> a) is a dangling chain that never
|
|
138
|
+
# terminates, so unbounded recursion here would blow the stack — and
|
|
139
|
+
# SystemStackError is not a StandardError, so it escapes Registry#execute's
|
|
140
|
+
# and Loop#guarded_call's rescues and kills the whole turn rather than
|
|
141
|
+
# failing closed. Capping the hops turns it into an ordinary Denied.
|
|
142
|
+
MAX_SYMLINK_HOPS = 40
|
|
143
|
+
|
|
144
|
+
def resolve_real(expanded, hops = 0)
|
|
145
|
+
if hops > MAX_SYMLINK_HOPS
|
|
146
|
+
raise Denied, "#{expanded} resolves through more than #{MAX_SYMLINK_HOPS} symlinks " \
|
|
147
|
+
"(a loop, or a chain too long to follow)"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
deepest = expanded
|
|
151
|
+
deepest = File.dirname(deepest) until File.exist?(deepest) || File.symlink?(deepest)
|
|
152
|
+
tail = expanded[deepest.length..]
|
|
153
|
+
base =
|
|
154
|
+
if File.symlink?(deepest) && !File.exist?(deepest)
|
|
155
|
+
# Dangling symlink: resolve one hop to where it points (relative
|
|
156
|
+
# targets against the link's real directory), then keep resolving
|
|
157
|
+
# since the target may itself dangle, be relative, or be symlinked.
|
|
158
|
+
resolve_real(File.expand_path(File.readlink(deepest), File.realpath(File.dirname(deepest))), hops + 1)
|
|
159
|
+
else
|
|
160
|
+
File.realpath(deepest)
|
|
161
|
+
end
|
|
162
|
+
tail.nil? || tail.empty? ? base : File.join(base, tail)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Read/write the final syscall with File::NOFOLLOW so a symlink swapped in
|
|
166
|
+
# at the leaf between #contain and here refuses at open rather than being
|
|
167
|
+
# followed. #resolve_real never returns a symlink as the final component
|
|
168
|
+
# (an existing target is realpath'd; a dangling one is resolved to where
|
|
169
|
+
# it points), so a legitimate regular file always opens cleanly. This
|
|
170
|
+
# guards only the leaf; intermediate components remain a small accepted
|
|
171
|
+
# TOCTOU window.
|
|
172
|
+
def read_contained(path) = File.open(path, File::RDONLY | File::NOFOLLOW, &:read)
|
|
173
|
+
|
|
174
|
+
def write_contained(path, content)
|
|
175
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC | File::NOFOLLOW) { |f| f.write(content) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Prefix match with a trailing-separator guard: a workspace granted at
|
|
179
|
+
# `/ws` must admit `/ws` itself and anything under `/ws/`, but never
|
|
180
|
+
# `/ws-evil` — a bare `start_with?(root)` would let that sibling through.
|
|
181
|
+
def contained?(resolved)
|
|
182
|
+
@workspace.any? { |root| resolved == root || resolved.start_with?("#{root}/") }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module Exec
|
|
7
|
+
# No job by that id belongs to this session — either it was never started,
|
|
8
|
+
# it has already been collected for the last time, or it belongs to
|
|
9
|
+
# somebody else. All three are the same answer on purpose: which of them is
|
|
10
|
+
# true is not information one session should be able to learn about
|
|
11
|
+
# another's jobs.
|
|
12
|
+
NoSuchJob = Class.new(Terret::Tools::Failure)
|
|
13
|
+
|
|
14
|
+
# This session already holds `max_jobs`. Refused rather than quietly
|
|
15
|
+
# reaping the oldest: a job is a live process an agent asked to keep
|
|
16
|
+
# running, and deciding on its behalf which one it has finished with is not
|
|
17
|
+
# ours to make. Stopping one, or collecting one that has finished, is the
|
|
18
|
+
# caller's move.
|
|
19
|
+
JobLimit = Class.new(Terret::Tools::Failure)
|
|
20
|
+
|
|
21
|
+
# ctx[:jobs] — a subprocess that outlives the tool call that started it
|
|
22
|
+
# (docs/subagents.md §6). The seam lives here rather than in terret-core
|
|
23
|
+
# because it needs ctx[:subprocess] and therefore ctx[:sandbox]: a job's
|
|
24
|
+
# argv goes through the wrap like every other spawn in the harness, so a
|
|
25
|
+
# job in a sandboxed profile runs inside the container with everything
|
|
26
|
+
# else.
|
|
27
|
+
#
|
|
28
|
+
# The seam a job deliberately does NOT use is ctx[:shell]: a job parked in
|
|
29
|
+
# the agent's persistent bash would hold that one process for its whole
|
|
30
|
+
# lifetime, and every later Bash call in the session would block behind it.
|
|
31
|
+
# A command becomes a fresh `bash -lc` argv handed to ctx[:subprocess]
|
|
32
|
+
# instead.
|
|
33
|
+
#
|
|
34
|
+
# Nothing in the log says a job exists. It survives its turn and it does
|
|
35
|
+
# not survive a restart: the process that held the pid is gone, and the log
|
|
36
|
+
# — the only thing that crosses a restart — was never told. Restart-
|
|
37
|
+
# surviving jobs are a recorded non-goal for 0.1.
|
|
38
|
+
class Jobs < Hames::Service
|
|
39
|
+
service_key :jobs
|
|
40
|
+
inject :subprocess
|
|
41
|
+
config_schema max_jobs: { type: Integer, default: 8, doc: "cap on concurrent background jobs" },
|
|
42
|
+
max_output: { type: Integer, default: 1 << 20,
|
|
43
|
+
doc: "bytes of a job's output retained before truncation" },
|
|
44
|
+
cwd: { type: String, doc: "working directory for spawned jobs (default: Dir.pwd)" },
|
|
45
|
+
env: { type: Hash, default: {}, doc: "environment overlay for spawned jobs" }
|
|
46
|
+
|
|
47
|
+
# The ledger row. `handle` is the live process, `buffer` is what it has
|
|
48
|
+
# said that nobody has collected yet; both are mutable things this value
|
|
49
|
+
# points at rather than values themselves, which is the whole difference
|
|
50
|
+
# between a job and a result.
|
|
51
|
+
Job = Data.define(:id, :owner, :command, :handle, :buffer)
|
|
52
|
+
|
|
53
|
+
DEFAULT_MAX_JOBS = 8
|
|
54
|
+
|
|
55
|
+
# What one job may hold between two collects. The same number and the
|
|
56
|
+
# same reasoning as Shell::DEFAULT_MAX_OUTPUT: a command that writes
|
|
57
|
+
# without pause fills this process's memory at whatever rate the pipe
|
|
58
|
+
# will carry, and a job may go uncollected for minutes. It is a memory
|
|
59
|
+
# bound, not a display decision, which is why the tool layer applies its
|
|
60
|
+
# own smaller one on top.
|
|
61
|
+
DEFAULT_MAX_OUTPUT = 1 << 20
|
|
62
|
+
|
|
63
|
+
CHUNK = 64 * 1024
|
|
64
|
+
|
|
65
|
+
# How often the drain fiber looks at an idle job. Slower than
|
|
66
|
+
# Subprocess::POLL because nobody is waiting on this one: a capture's
|
|
67
|
+
# poll bounds a caller's latency, while this bounds only how long a
|
|
68
|
+
# job's own writes can sit in a pipe it is not filling.
|
|
69
|
+
POLL = 0.05
|
|
70
|
+
|
|
71
|
+
# Hames::Service#apply mounts a plugin by calling #start(ctx), and #start
|
|
72
|
+
# on this seam is how a JOB is started (docs/subagents.md §6). Two fixed
|
|
73
|
+
# names meet here — one the kernel's lifecycle, one a published seam
|
|
74
|
+
# every job tool is written against — so mounting does exactly what the
|
|
75
|
+
# base class would have done and skips the hook, rather than the seam
|
|
76
|
+
# bending the signature every caller reads in the docs.
|
|
77
|
+
def apply(ctx)
|
|
78
|
+
ctx.register_service(self.class.service_key, self)
|
|
79
|
+
@ctx = ctx
|
|
80
|
+
@jobs = {} # id => Job, every session's; the owner check is what separates them
|
|
81
|
+
# When the agent that owns a job is disposed, the job goes with it:
|
|
82
|
+
# this is root-mounted state keyed by session, so fork disposal never
|
|
83
|
+
# reaches it. Registered via ctx.on, so it reverses when the row
|
|
84
|
+
# unloads.
|
|
85
|
+
ctx.on("agent/disposed") { |session_id| stop_all_for(session_id) }
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Every knob is read where it is used, so a hot config swap needs nothing
|
|
90
|
+
# re-derived here. A running job keeps the cap its buffer was built with
|
|
91
|
+
# — it is a live buffer, not a value — and the new settings govern the
|
|
92
|
+
# next job.
|
|
93
|
+
def reconfigure(_config); end
|
|
94
|
+
|
|
95
|
+
# Spawns `command` and returns an opaque id for it. The command is a
|
|
96
|
+
# string rather than an argv because that is what `job_start` takes, and
|
|
97
|
+
# a shell line is what a model writes; the `bash -lc` below is the one
|
|
98
|
+
# place that decision turns into a process.
|
|
99
|
+
def start(command, session:, cwd: nil)
|
|
100
|
+
owner = session.to_s
|
|
101
|
+
if (count = count_for(owner)) >= max_jobs
|
|
102
|
+
raise JobLimit, "#{count} jobs are already running in this session; stop one, or " \
|
|
103
|
+
"collect one that has finished, first (max_jobs: #{max_jobs})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
handle = @ctx[:subprocess].pipe_spawn(["bash", "-lc", command.to_s],
|
|
107
|
+
cwd: cwd || default_cwd, env: env)
|
|
108
|
+
job = Job.new(id: mint_id, owner: owner, command: command.to_s,
|
|
109
|
+
handle: handle, buffer: Buffer.new(max_output))
|
|
110
|
+
@jobs[job.id] = job
|
|
111
|
+
pump(job)
|
|
112
|
+
job.id
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What the job has said since the last collect, and where it stands.
|
|
116
|
+
# `status` is the PROCESS's — a job whose bash has exited while a child
|
|
117
|
+
# of its own still holds the pipe reads `:exited` with output still
|
|
118
|
+
# arriving, which is the honest description of that situation.
|
|
119
|
+
#
|
|
120
|
+
# The row is forgotten once the process is gone AND its stream has ended:
|
|
121
|
+
# everything it will ever say has been handed over at that point, so
|
|
122
|
+
# keeping the row would only hold a slot against the cap. A later collect
|
|
123
|
+
# of the same id therefore fails closed, which is the same answer an id
|
|
124
|
+
# from another session gets.
|
|
125
|
+
def collect(id, session:)
|
|
126
|
+
job = fetch(id, session)
|
|
127
|
+
pull(job)
|
|
128
|
+
output, dropped = job.buffer.drain!
|
|
129
|
+
result = { status: job.handle.exited? ? :exited : :running,
|
|
130
|
+
exit_status: job.handle.exit_status,
|
|
131
|
+
output: output,
|
|
132
|
+
truncated: dropped.positive? }
|
|
133
|
+
forget(job) if job.handle.exited? && job.handle.eof?
|
|
134
|
+
result
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Ends the job: SIGTERM, escalating to SIGKILL after the grace, through
|
|
138
|
+
# subprocess's own escalation. Whatever the job managed to say on its way
|
|
139
|
+
# out is still collectible afterwards — the handle drains the last of the
|
|
140
|
+
# pipe before it drops it — and the next collect reports `:exited`.
|
|
141
|
+
#
|
|
142
|
+
# The lifecycle hook and the seam's kill share this name. Hames calls
|
|
143
|
+
# #stop(ctx) when the row unloads and docs/subagents.md §6 names this
|
|
144
|
+
# method #stop(id, session:); a Context is never a job id, so the two
|
|
145
|
+
# shapes cannot be confused, and the `session:` default exists for the
|
|
146
|
+
# lifecycle call rather than for callers — a stop without one names no
|
|
147
|
+
# session's job and fails closed like any other stranger's id.
|
|
148
|
+
def stop(id, session: nil)
|
|
149
|
+
return stop_all if id.is_a?(Hames::Context)
|
|
150
|
+
|
|
151
|
+
job = fetch(id, session)
|
|
152
|
+
job.handle.close
|
|
153
|
+
job.id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The agent-disposal hook: everything this session started, ended and
|
|
157
|
+
# forgotten. Another session's jobs are untouched.
|
|
158
|
+
def stop_all_for(session)
|
|
159
|
+
owner = session.to_s
|
|
160
|
+
end_each(@jobs.values.select { |job| job.owner == owner })
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Everything, every session's: the row is going away, so no job it holds
|
|
164
|
+
# has an owner left to collect it.
|
|
165
|
+
def stop_all = end_each(@jobs.values)
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def max_jobs = config[:max_jobs] || DEFAULT_MAX_JOBS
|
|
170
|
+
def max_output = config[:max_output] || DEFAULT_MAX_OUTPUT
|
|
171
|
+
def default_cwd = config[:cwd] || Dir.pwd
|
|
172
|
+
def env = config[:env] || {}
|
|
173
|
+
|
|
174
|
+
def count_for(owner) = @jobs.each_value.count { |job| job.owner == owner }
|
|
175
|
+
|
|
176
|
+
# Opaque, and unguessable with it. A job id is a handle rather than a
|
|
177
|
+
# fact: a pid would tell a model something it can act on outside this
|
|
178
|
+
# seam, and a counter would tell one session how many jobs another has
|
|
179
|
+
# run.
|
|
180
|
+
def mint_id = "job-#{SecureRandom.hex(8)}"
|
|
181
|
+
|
|
182
|
+
def fetch(id, session)
|
|
183
|
+
job = @jobs[id.to_s]
|
|
184
|
+
return job if job && job.owner == session.to_s
|
|
185
|
+
|
|
186
|
+
raise NoSuchJob, "no job #{id} is running in this session"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def forget(job)
|
|
190
|
+
@jobs.delete(job.id)
|
|
191
|
+
job.handle.close
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Ends a whole list of jobs, and every one of them gets its turn: a job
|
|
195
|
+
# whose close cannot be completed is one job's problem, and a raise that
|
|
196
|
+
# escaped here would leave every job behind it in the ledger running with
|
|
197
|
+
# the agent that owned it already gone. The failures are reported once,
|
|
198
|
+
# after the ledger is clear, because the caller is a disposal listener
|
|
199
|
+
# rather than somebody who can act on them.
|
|
200
|
+
def end_each(jobs)
|
|
201
|
+
failed = []
|
|
202
|
+
jobs.each do |job|
|
|
203
|
+
forget(job)
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
failed << "#{job.id} (#{e.class}: #{e.message})"
|
|
206
|
+
end
|
|
207
|
+
unless failed.empty?
|
|
208
|
+
warn "terret: #{failed.size} job(s) would not close: #{failed.join(', ')}"
|
|
209
|
+
end
|
|
210
|
+
jobs.map(&:id)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Moves whatever the pipe holds into the buffer. Never blocks: the handle
|
|
214
|
+
# reads non-blocking, so this costs one syscall against a job with
|
|
215
|
+
# nothing to say.
|
|
216
|
+
def pull(job)
|
|
217
|
+
loop do
|
|
218
|
+
chunk = job.handle.read(CHUNK)
|
|
219
|
+
break if chunk.nil? || chunk.empty?
|
|
220
|
+
|
|
221
|
+
job.buffer << chunk.b
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Under a reactor, one fiber per job drains its pipe as it fills. A pipe
|
|
226
|
+
# holds about 64KB before the writer blocks on it, so without this a job
|
|
227
|
+
# that outruns its collector simply stops running until somebody collects
|
|
228
|
+
# — which is exactly the case a job exists for.
|
|
229
|
+
#
|
|
230
|
+
# TRANSIENT is the whole of it, and it is about the REACTOR's lifetime
|
|
231
|
+
# rather than about who waits for whom: a transient task never keeps the
|
|
232
|
+
# reactor alive past the work that started it, and on shutdown it unwinds
|
|
233
|
+
# with an Async::Cancel at its `sleep`. Which task it hangs off is not a
|
|
234
|
+
# choice worth making — `async` re-parents to the calling task whichever
|
|
235
|
+
# task it is called on (measured) — and it would not matter if it were,
|
|
236
|
+
# because a parent does not wait for a transient child either. The turn
|
|
237
|
+
# that called `job_start` ends while the job runs on.
|
|
238
|
+
#
|
|
239
|
+
# Nothing depends on it having run. #collect drains the pipe itself
|
|
240
|
+
# before answering, so a deployment with no reactor sees the same output
|
|
241
|
+
# in the same order from the same calls. Two things it does not see are
|
|
242
|
+
# worth naming, because both look like the seam misbehaving: a job with
|
|
243
|
+
# more than a pipe buffer to write between two collects is parked in
|
|
244
|
+
# `write` until the next one — its side effects stop with it, because a
|
|
245
|
+
# parked job is not running — and a job that finishes while nobody is
|
|
246
|
+
# collecting stays an unreaped zombie until a collect, a stop, or its
|
|
247
|
+
# agent's disposal notices that it went.
|
|
248
|
+
def pump(job)
|
|
249
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
250
|
+
return unless task
|
|
251
|
+
|
|
252
|
+
task.async(transient: true) do
|
|
253
|
+
loop do
|
|
254
|
+
pull(job)
|
|
255
|
+
break if job.handle.exited? && job.handle.eof?
|
|
256
|
+
|
|
257
|
+
sleep POLL
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Byte-capped accumulation between two collects. Past the cap the bytes
|
|
263
|
+
# are counted and dropped rather than the reading stopping: a job whose
|
|
264
|
+
# pipe nobody drains blocks on its next write, and a job that stopped
|
|
265
|
+
# running is a worse answer than one whose output was truncated — which
|
|
266
|
+
# is why `truncated:` says so instead of the loss being silent.
|
|
267
|
+
#
|
|
268
|
+
# The bytes stay BINARY until they are handed over. What a job wrote is
|
|
269
|
+
# not guaranteed to be text, this seam preserves it either way, and
|
|
270
|
+
# making it storable is the tool layer's job — the same split Shell and
|
|
271
|
+
# Bash already keep.
|
|
272
|
+
class Buffer
|
|
273
|
+
def initialize(cap)
|
|
274
|
+
@cap = [cap, 0].max
|
|
275
|
+
@kept = String.new(encoding: Encoding::BINARY)
|
|
276
|
+
@dropped = 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def <<(bytes)
|
|
280
|
+
room = @cap - @kept.bytesize
|
|
281
|
+
if room <= 0
|
|
282
|
+
@dropped += bytes.bytesize
|
|
283
|
+
elsif bytes.bytesize <= room
|
|
284
|
+
@kept << bytes
|
|
285
|
+
else
|
|
286
|
+
@kept << bytes.byteslice(0, room)
|
|
287
|
+
@dropped += bytes.bytesize - room
|
|
288
|
+
end
|
|
289
|
+
self
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Hands over everything held and starts again, so the next collect owes
|
|
293
|
+
# only what arrived after this one.
|
|
294
|
+
def drain!
|
|
295
|
+
kept = @kept
|
|
296
|
+
dropped = @dropped
|
|
297
|
+
@kept = String.new(encoding: Encoding::BINARY)
|
|
298
|
+
@dropped = 0
|
|
299
|
+
[kept.force_encoding(Encoding::UTF_8), dropped]
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module Exec
|
|
5
|
+
# ctx[:sandbox] — the seam every argv passes through before it becomes a
|
|
6
|
+
# real process (docs/exec.md §4). `None` is the identity provider: the
|
|
7
|
+
# explicit, opt-in-only trusted mode (plan §13) — a profile that wants no
|
|
8
|
+
# process isolation says so by mounting this row rather than by an
|
|
9
|
+
# isolation feature silently failing open. The docker provider (plan §12)
|
|
10
|
+
# replaces this plugin wholesale via a single patch row — the same
|
|
11
|
+
# plugin-class swap the kernel work (Task 2) proved — so `wrap`'s
|
|
12
|
+
# contract has to be small and honest: it does nothing here, and that
|
|
13
|
+
# nothing is the point.
|
|
14
|
+
class SandboxNone < Hames::Service
|
|
15
|
+
service_key :sandbox
|
|
16
|
+
config_schema({}) # runs argv on the host unchanged; takes no config
|
|
17
|
+
|
|
18
|
+
def start(_ctx); end
|
|
19
|
+
|
|
20
|
+
# `tty:` is accepted and ignored. A provider that puts a terminal on the
|
|
21
|
+
# far side of the seam (docker, via `-t`) has to be told when one is
|
|
22
|
+
# wanted; here the host pty the caller already holds IS the terminal, so
|
|
23
|
+
# there is nothing left to arrange.
|
|
24
|
+
def wrap(argv, cwd:, tty: false) = argv
|
|
25
|
+
|
|
26
|
+
def isolated? = false
|
|
27
|
+
|
|
28
|
+
def workspace_ready!; end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|