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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module Exec
|
|
5
|
+
# This owner already holds `max_terminals` open. Refused rather than
|
|
6
|
+
# quietly reaping the oldest, or the dead ones: a terminal is a live
|
|
7
|
+
# process an agent asked to keep, and deciding on its behalf which one it
|
|
8
|
+
# has finished with is not ours to make. Closing one is the caller's move.
|
|
9
|
+
TerminalLimit = Class.new(Terret::Tools::Failure)
|
|
10
|
+
|
|
11
|
+
# No terminal by that name belongs to this owner — either it was never
|
|
12
|
+
# opened, it was closed, or it belongs to somebody else. All three are the
|
|
13
|
+
# same answer on purpose: which of them is true is not information one
|
|
14
|
+
# owner should be able to learn about another's terminals.
|
|
15
|
+
NoSuchTerminal = Class.new(Terret::Tools::Failure)
|
|
16
|
+
|
|
17
|
+
# The terminal is still registered, but the process behind it has exited.
|
|
18
|
+
# A typed refusal rather than a silent no-op, and rather than the raw
|
|
19
|
+
# Errno::EIO the pty master raises: the caller asked to type into
|
|
20
|
+
# something, nothing received it, and a model reading the result deserves
|
|
21
|
+
# a sentence naming the terminal instead of a device path. #read answers
|
|
22
|
+
# the same situation with nil, because "nothing to say" and "nothing left
|
|
23
|
+
# to say it" are the same shape to a reader.
|
|
24
|
+
TerminalGone = Class.new(Terret::Tools::Failure)
|
|
25
|
+
|
|
26
|
+
# A name this owner is already using. Opening over it would drop the
|
|
27
|
+
# handle to a running process — unreapable, since nothing would hold its
|
|
28
|
+
# pid any more — so the refusal is what keeps disposal honest.
|
|
29
|
+
TerminalExists = Class.new(Terret::Tools::Failure)
|
|
30
|
+
|
|
31
|
+
# ctx[:terminals] — named, long-lived PTYs (plan §6.6; docs/exec.md §2).
|
|
32
|
+
# They outlive a single tool call by design: a REPL or a dev server stays
|
|
33
|
+
# addressable across a turn, which is the whole difference between this
|
|
34
|
+
# seam and ctx[:subprocess]'s one-shot spawn.
|
|
35
|
+
#
|
|
36
|
+
# Names are scoped per owner, not global. `open(name, argv, session: key)`
|
|
37
|
+
# keys the registry on [owner, name], so two agents may each keep a
|
|
38
|
+
# terminal called "repl" without collision, and neither can address (or
|
|
39
|
+
# accidentally close) the other's by guessing its name — a terminal is a
|
|
40
|
+
# live process with the agent's authority, so a shared namespace would be a
|
|
41
|
+
# capability leak between agents, not merely a naming inconvenience.
|
|
42
|
+
# `close_all_for(key)` is then exactly "drop this owner's rows", which is
|
|
43
|
+
# what agent disposal needs. The cap counts per owner for the same reason:
|
|
44
|
+
# it is the limit an agent can see and act on, and its error message can
|
|
45
|
+
# say something true to the caller that hit it.
|
|
46
|
+
class Terminals < Hames::Service
|
|
47
|
+
service_key :terminals
|
|
48
|
+
inject :subprocess
|
|
49
|
+
config_schema max_terminals: { type: Integer, default: 8, doc: "cap on concurrently open terminals" },
|
|
50
|
+
read_timeout: { type: Numeric, default: 0.1,
|
|
51
|
+
doc: "seconds a non-blocking terminal read waits for output" },
|
|
52
|
+
cwd: { type: String,
|
|
53
|
+
doc: "default working directory for opened terminals (default: Dir.pwd)" }
|
|
54
|
+
|
|
55
|
+
# What `open` hands back. The handle itself stays in the registry: every
|
|
56
|
+
# other method is name-addressed, so nothing outside this service can
|
|
57
|
+
# drive a terminal around the cap or the ownership check.
|
|
58
|
+
Terminal = Data.define(:name, :owner, :pid)
|
|
59
|
+
|
|
60
|
+
DEFAULT_SESSION = :default
|
|
61
|
+
DEFAULT_MAX = 8
|
|
62
|
+
CHUNK = 4096
|
|
63
|
+
|
|
64
|
+
# Bounded, so reading a terminal that has nothing to say returns
|
|
65
|
+
# empty-handed instead of holding the turn open until it does.
|
|
66
|
+
DEFAULT_READ_TIMEOUT = 0.1
|
|
67
|
+
|
|
68
|
+
# Bounds the pre-close drain against a child writing without pause.
|
|
69
|
+
DRAIN_BUDGET = 0.1
|
|
70
|
+
|
|
71
|
+
def start(ctx)
|
|
72
|
+
@ctx = ctx
|
|
73
|
+
@open = {} # [owner, name] => PTYHandle
|
|
74
|
+
# Disposing the owning agent reaps every PTY it opened; fork disposal
|
|
75
|
+
# never touches this root-mounted state. Registered via ctx.on so it
|
|
76
|
+
# reverses when this service unloads.
|
|
77
|
+
ctx.on("agent/disposed") { |session_id| close_all_for(session_id) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The loader calls this on unload. Terminals are processes the harness
|
|
81
|
+
# owns; dropping the registry without reaping them would leak one per
|
|
82
|
+
# name for the life of the host process.
|
|
83
|
+
def stop(_ctx) = close_all
|
|
84
|
+
|
|
85
|
+
# Every knob is read where it is used, so a hot config swap needs nothing
|
|
86
|
+
# re-derived here — including a lowered `max_terminals`, which then bites
|
|
87
|
+
# on the next open rather than closing something already running.
|
|
88
|
+
def reconfigure(_config); end
|
|
89
|
+
|
|
90
|
+
def open(name, argv, session: DEFAULT_SESSION, cwd: nil, env: {})
|
|
91
|
+
name = name.to_s
|
|
92
|
+
owner = session.to_s
|
|
93
|
+
raise TerminalExists, "a terminal named #{name} is already open" if @open.key?([owner, name])
|
|
94
|
+
|
|
95
|
+
if (count = count_for(owner)) >= max
|
|
96
|
+
raise TerminalLimit, "#{count} terminals are already open; close one first (max_terminals: #{max})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
handle = @ctx[:subprocess].pty_spawn(argv, cwd: cwd || default_cwd, env: env)
|
|
100
|
+
@open[[owner, name]] = handle
|
|
101
|
+
Terminal.new(name: name, owner: owner, pid: handle.pid)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The liveness check comes first because the write alone would not tell:
|
|
105
|
+
# Linux accepts a write to a pty master whose child is gone and quietly
|
|
106
|
+
# queues it, so only macOS would ever reach the rescue below. The rescue
|
|
107
|
+
# stays for the race the check cannot close — a child dying between the
|
|
108
|
+
# probe and the write, where macOS still raises.
|
|
109
|
+
def input(name, text, session: DEFAULT_SESSION)
|
|
110
|
+
handle = fetch(name, session)
|
|
111
|
+
unless handle.alive?
|
|
112
|
+
raise TerminalGone, "the terminal named #{name} has no live process; close it and open another"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
handle.write(text)
|
|
116
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
117
|
+
raise TerminalGone, "the terminal named #{name} has no live process; close it and open another"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# "" means the terminal is alive with nothing to say; nil means its child
|
|
121
|
+
# is gone. The entry survives that EOF — reading is not disposal, and the
|
|
122
|
+
# owner still has to close it, which is also what frees its slot.
|
|
123
|
+
def read(name, session: DEFAULT_SESSION, max: CHUNK, timeout: nil)
|
|
124
|
+
fetch(name, session).read(max, timeout: timeout || read_timeout)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Reaps the child and forgets the name. Closing a name that is not open
|
|
128
|
+
# is a no-op rather than an error: disposal runs over sets that may
|
|
129
|
+
# already have been partly closed, and PTYHandle#close is itself
|
|
130
|
+
# idempotent.
|
|
131
|
+
def close(name, session: DEFAULT_SESSION)
|
|
132
|
+
handle = @open.delete([session.to_s, name.to_s]) or return nil
|
|
133
|
+
drain(handle)
|
|
134
|
+
handle.close
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The agent-disposal hook: everything this owner opened, closed and
|
|
138
|
+
# forgotten, by the names it used. Another owner's terminals are
|
|
139
|
+
# untouched.
|
|
140
|
+
def close_all_for(session)
|
|
141
|
+
owner = session.to_s
|
|
142
|
+
@open.keys.select { |(o, _n)| o == owner }.map do |(_o, name)|
|
|
143
|
+
close(name, session: owner)
|
|
144
|
+
name
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close_all = @open.keys.each { |(owner, name)| close(name, session: owner) }
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def max = config[:max_terminals] || DEFAULT_MAX
|
|
153
|
+
def read_timeout = config[:read_timeout] || DEFAULT_READ_TIMEOUT
|
|
154
|
+
def default_cwd = config[:cwd] || Dir.pwd
|
|
155
|
+
|
|
156
|
+
def count_for(owner) = @open.keys.count { |(o, _n)| o == owner }
|
|
157
|
+
|
|
158
|
+
# Empty the terminal before the handle reaps its child. A process killed
|
|
159
|
+
# while its terminal still holds bytes nobody read can get stuck in exit
|
|
160
|
+
# — measured on macOS with a bash in the terminal, reliably, with as
|
|
161
|
+
# little as a startup banner pending — and the reaper's blocking wait
|
|
162
|
+
# would then never return. In the ordinary case this costs one
|
|
163
|
+
# non-blocking read; the budget bounds a child writing without pause.
|
|
164
|
+
def drain(handle)
|
|
165
|
+
deadline = monotonic + DRAIN_BUDGET
|
|
166
|
+
loop do
|
|
167
|
+
chunk = handle.read(CHUNK, timeout: 0)
|
|
168
|
+
return if chunk.nil? || chunk.empty? || monotonic >= deadline
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
173
|
+
|
|
174
|
+
def fetch(name, session)
|
|
175
|
+
@open[[session.to_s, name.to_s]] or
|
|
176
|
+
raise NoSuchTerminal, "no terminal named #{name} is open"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/terret/exec.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "terret"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../../../terret-core/lib/terret" # monorepo path source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "exec/fs"
|
|
10
|
+
require_relative "exec/sandbox_none"
|
|
11
|
+
require_relative "exec/subprocess"
|
|
12
|
+
require_relative "exec/shell"
|
|
13
|
+
require_relative "exec/terminals"
|
|
14
|
+
require_relative "exec/jobs"
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: terret-exec
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Obie Fernandez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: terret-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
description: 'ctx[:fs]: workspace-contained file ops (read/write/edit/stat/glob) behind
|
|
27
|
+
an fs/authorize waterfall, with realpath-based containment that fails closed on
|
|
28
|
+
traversal and symlink escapes. ctx[:subprocess] spawns and captures under the fiber
|
|
29
|
+
scheduler with cooperative cancellation; ctx[:shell] keeps a bash per agent alive
|
|
30
|
+
across calls; ctx[:terminals] holds named long-lived PTYs; ctx[:jobs] runs a command
|
|
31
|
+
past the turn that started it; and every argv reaches a process through the ctx[:sandbox]
|
|
32
|
+
seam. Zero runtime dependencies beyond stdlib.'
|
|
33
|
+
email:
|
|
34
|
+
- obiefernandez@gmail.com
|
|
35
|
+
executables: []
|
|
36
|
+
extensions: []
|
|
37
|
+
extra_rdoc_files: []
|
|
38
|
+
files:
|
|
39
|
+
- lib/terret/exec.rb
|
|
40
|
+
- lib/terret/exec/fs.rb
|
|
41
|
+
- lib/terret/exec/jobs.rb
|
|
42
|
+
- lib/terret/exec/sandbox_none.rb
|
|
43
|
+
- lib/terret/exec/shell.rb
|
|
44
|
+
- lib/terret/exec/subprocess.rb
|
|
45
|
+
- lib/terret/exec/terminals.rb
|
|
46
|
+
homepage: https://terret.org
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
homepage_uri: https://terret.org
|
|
51
|
+
source_code_uri: https://github.com/terret-org/terret
|
|
52
|
+
bug_tracker_uri: https://github.com/terret-org/terret/issues
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '4.0'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.16
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: 'The execution-world gem for Terret: workspace-scoped filesystem and process
|
|
71
|
+
seams'
|
|
72
|
+
test_files: []
|