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 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