terret 0.0.2 → 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 +4 -4
- data/config/bundle.yml +189 -0
- data/exe/trt +12 -0
- data/lib/terret/boot.rb +149 -0
- data/lib/terret/cli.rb +269 -11
- data/lib/terret/composition.rb +755 -0
- data/lib/terret/doctor.rb +183 -0
- data/lib/terret/home.rb +85 -0
- data/lib/terret/schema_gems.rb +22 -0
- data/lib/terret/version.rb +14 -0
- data/profiles/headless/patch.yml +27 -0
- data/profiles/headless/profile.yml +57 -0
- metadata +93 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5dbd83ac7632140b6d208f2e242ae24bfd0e11c0c05a2fd5b61d144c2b156190
|
|
4
|
+
data.tar.gz: c5f750acb8b699b3e6740677767ee11737f589dd16085395116d0fbd84b550fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bee7e82ba1466f623a5b3852f698568ae14ed884564cc041c4bdc9b78cb69aca394a75cdb4a5f0612135e4dc544207ef5d3a4c7f80069260d2ff5fa9b71e9d93
|
|
7
|
+
data.tar.gz: 2ca60bc9a5143f9105b419eacb94e90715aeff0679e4c3f3456eac30ca4a87bc0b8bc44bf92fdd9c0f32026bb65d365516c8a2ec5638fa25464e2e67bdd44ba1
|
data/config/bundle.yml
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# terret-base — layer one of every profile (docs/composition.md §6).
|
|
2
|
+
#
|
|
3
|
+
# These rows are the answer to "what is a Terret": the session log, the
|
|
4
|
+
# harness, the model seam, the execution world, the tool roster, and the
|
|
5
|
+
# policy floor. A profile stacks this bundle first and then says what is
|
|
6
|
+
# different about ITS machine, in profile.yml settings and patch.yml rows.
|
|
7
|
+
#
|
|
8
|
+
# Two things here are secure-by-default rather than convenient-by-default, and
|
|
9
|
+
# both are called out where they sit: the sandbox is docker with the network
|
|
10
|
+
# denied, and every allow list starts closed.
|
|
11
|
+
#
|
|
12
|
+
# Remember that a patch replaces a row's config WHOLESALE. Read a row here
|
|
13
|
+
# before you patch it (or run `trt dump-config`) — a patch that mentions one
|
|
14
|
+
# key drops the rest.
|
|
15
|
+
name: terret-base
|
|
16
|
+
|
|
17
|
+
# A bundle ships rows; the code those rows name comes from the gems the bundle
|
|
18
|
+
# depends on (see terret.gemspec). Bundler puts them on the load path, and
|
|
19
|
+
# these are the files that pull them in before a row's constant has to resolve.
|
|
20
|
+
requires:
|
|
21
|
+
- terret/store/sqlite
|
|
22
|
+
- terret/openrouter
|
|
23
|
+
- terret/exec
|
|
24
|
+
- terret/tools_std
|
|
25
|
+
- terret/sandbox/docker
|
|
26
|
+
|
|
27
|
+
rows:
|
|
28
|
+
# -- the durable log ------------------------------------------------------
|
|
29
|
+
# Everything the model can see is a projection of this file. Losing it loses
|
|
30
|
+
# the agent's memory; it is the one row worth backing up.
|
|
31
|
+
- id: session_store
|
|
32
|
+
plugin: Terret::Store::SQLite
|
|
33
|
+
config:
|
|
34
|
+
path: !setting store.path
|
|
35
|
+
|
|
36
|
+
- id: sessions
|
|
37
|
+
plugin: Terret::Sessions
|
|
38
|
+
|
|
39
|
+
- id: prompt
|
|
40
|
+
plugin: Terret::Prompt
|
|
41
|
+
|
|
42
|
+
- id: tools
|
|
43
|
+
plugin: Terret::Tools::Registry
|
|
44
|
+
|
|
45
|
+
# -- the model seam -------------------------------------------------------
|
|
46
|
+
# Roles are "provider/model"; "openrouter" is the provider name the row
|
|
47
|
+
# below registers. Point a role somewhere else by patching this row — and
|
|
48
|
+
# restate every role you still want, because the replacement is wholesale.
|
|
49
|
+
- id: llm
|
|
50
|
+
plugin: Terret::LLM::Service
|
|
51
|
+
config:
|
|
52
|
+
roles:
|
|
53
|
+
main: !setting model.main
|
|
54
|
+
|
|
55
|
+
# nil api_key is deliberate when the variable is unset: the adapter falls
|
|
56
|
+
# back to OPENROUTER_API_KEY itself and raises a better error than YAML can,
|
|
57
|
+
# and dump-config stays runnable on a machine that holds no secrets.
|
|
58
|
+
- id: openrouter
|
|
59
|
+
plugin: Terret::OpenRouter::Plugin
|
|
60
|
+
config:
|
|
61
|
+
api_key: !env OPENROUTER_API_KEY
|
|
62
|
+
|
|
63
|
+
# -- the execution world --------------------------------------------------
|
|
64
|
+
# THE SANDBOX ROW IS THE ONE TO READ TWICE. Every argv the harness spawns
|
|
65
|
+
# goes through ctx[:sandbox], and the default is a container with no
|
|
66
|
+
# network. Running tools directly on the host is a per-profile opt-in
|
|
67
|
+
# (docs/security.md, plan §13) — the shipped headless patch.yml shows what
|
|
68
|
+
# that opt-in looks like and what it costs.
|
|
69
|
+
- id: sandbox
|
|
70
|
+
plugin: Terret::Sandbox::Docker
|
|
71
|
+
config:
|
|
72
|
+
image: !setting sandbox.image
|
|
73
|
+
network: none
|
|
74
|
+
workspace: !setting workspace
|
|
75
|
+
|
|
76
|
+
- id: subprocess
|
|
77
|
+
plugin: Terret::Exec::Subprocess
|
|
78
|
+
|
|
79
|
+
# AND THE FS ROW FOR THE OPPOSITE REASON: `workspace:` is what every
|
|
80
|
+
# filesystem tool is contained to, and an empty or unconfigured list DENIES
|
|
81
|
+
# EVERY FILE OPERATION rather than permitting them. There is no
|
|
82
|
+
# ungranted-but-permitted state, so a profile that never sets
|
|
83
|
+
# settings.workspace gets an agent that cannot read a file. That is the safe
|
|
84
|
+
# failure, and a confusing one if nobody says so in advance.
|
|
85
|
+
- id: fs
|
|
86
|
+
plugin: Terret::Exec::FS
|
|
87
|
+
config:
|
|
88
|
+
workspace: !setting workspace
|
|
89
|
+
|
|
90
|
+
- id: shell
|
|
91
|
+
plugin: Terret::Exec::Shell
|
|
92
|
+
|
|
93
|
+
- id: terminals
|
|
94
|
+
plugin: Terret::Exec::Terminals
|
|
95
|
+
|
|
96
|
+
- id: jobs
|
|
97
|
+
plugin: Terret::Exec::Jobs
|
|
98
|
+
|
|
99
|
+
# -- the tool roster ------------------------------------------------------
|
|
100
|
+
# Claude Code's names wherever it has one. Every handler reaches the world
|
|
101
|
+
# only through a seam, which is why swapping the sandbox row above moves the
|
|
102
|
+
# whole roster into a container with no tool change.
|
|
103
|
+
- id: std_files
|
|
104
|
+
plugin: Terret::ToolsStd::Files
|
|
105
|
+
|
|
106
|
+
- id: std_bash
|
|
107
|
+
plugin: Terret::ToolsStd::Bash
|
|
108
|
+
|
|
109
|
+
- id: std_terminals
|
|
110
|
+
plugin: Terret::ToolsStd::Terminals
|
|
111
|
+
|
|
112
|
+
- id: std_jobs
|
|
113
|
+
plugin: Terret::ToolsStd::Jobs
|
|
114
|
+
|
|
115
|
+
- id: std_todo
|
|
116
|
+
plugin: Terret::ToolsStd::Todo
|
|
117
|
+
|
|
118
|
+
# Deny-by-default, like every other list here: WebFetch reaches nothing
|
|
119
|
+
# until a profile names hosts in `allow`. Patch this row with the globs your
|
|
120
|
+
# agent actually needs.
|
|
121
|
+
- id: std_web_fetch
|
|
122
|
+
plugin: Terret::ToolsStd::WebFetch
|
|
123
|
+
config:
|
|
124
|
+
allow: []
|
|
125
|
+
deny: []
|
|
126
|
+
|
|
127
|
+
- id: subagents
|
|
128
|
+
plugin: Terret::Subagents
|
|
129
|
+
|
|
130
|
+
- id: std_task
|
|
131
|
+
plugin: Terret::ToolsStd::Task
|
|
132
|
+
|
|
133
|
+
- id: loop
|
|
134
|
+
plugin: Terret::Loop
|
|
135
|
+
|
|
136
|
+
# -- policy ---------------------------------------------------------------
|
|
137
|
+
# Provider secrets resolved in one place (plan §6.9): ENV <PROVIDER>_API_KEY
|
|
138
|
+
# always wins, then an optional AES-256-GCM file store (its master key in ENV
|
|
139
|
+
# TERRET_CREDENTIALS_KEY, never config). Every value it resolves is fed to the
|
|
140
|
+
# scrubber below, so a resolved credential can never reach the log even if a
|
|
141
|
+
# tool echoes it. With no `file:` set this is ENV-only — the current behavior
|
|
142
|
+
# — and inert until a deployment points it at an encrypted store.
|
|
143
|
+
- id: credentials
|
|
144
|
+
plugin: Terret::Credentials
|
|
145
|
+
|
|
146
|
+
# The scrubber at the append boundary and at tools/post_execute: a
|
|
147
|
+
# credential that reaches a tool result never reaches the session log or the
|
|
148
|
+
# model. These patterns are a floor, not a guarantee — add the shapes your
|
|
149
|
+
# environment actually issues.
|
|
150
|
+
- id: redactor
|
|
151
|
+
plugin: Terret::Redactor
|
|
152
|
+
config:
|
|
153
|
+
patterns:
|
|
154
|
+
- "sk-[A-Za-z0-9_-]{16,}"
|
|
155
|
+
- "gh[pousr]_[A-Za-z0-9]{20,}"
|
|
156
|
+
- "AKIA[0-9A-Z]{16}"
|
|
157
|
+
- "xox[baprs]-[A-Za-z0-9-]{10,}"
|
|
158
|
+
|
|
159
|
+
# The floor: the policy governing a session that never issued a
|
|
160
|
+
# policy/updated of its own. It names exactly the roster mounted above, so a
|
|
161
|
+
# tool arriving from a third-party bundle is denied until a profile says
|
|
162
|
+
# otherwise — that is what deny-by-default buys, and the cost is this list.
|
|
163
|
+
- id: allow_list
|
|
164
|
+
plugin: Terret::Tools::AllowListFloor
|
|
165
|
+
config:
|
|
166
|
+
patterns:
|
|
167
|
+
- Read
|
|
168
|
+
- Write
|
|
169
|
+
- Edit
|
|
170
|
+
- Glob
|
|
171
|
+
- Grep
|
|
172
|
+
- Bash
|
|
173
|
+
- WebFetch
|
|
174
|
+
- Task
|
|
175
|
+
- TodoWrite
|
|
176
|
+
- terminal_open
|
|
177
|
+
- terminal_read
|
|
178
|
+
- terminal_input
|
|
179
|
+
- terminal_close
|
|
180
|
+
- job_start
|
|
181
|
+
- job_collect
|
|
182
|
+
- job_stop
|
|
183
|
+
|
|
184
|
+
# Present but unmounted: approvals are opt-in per tool (M6), and an
|
|
185
|
+
# unattended profile has nobody to ask. Flip `disabled` in a patch to turn
|
|
186
|
+
# them on; the row is here so you can see what you are turning on.
|
|
187
|
+
- id: approvals
|
|
188
|
+
plugin: Terret::Tools::Approvals
|
|
189
|
+
disabled: true
|
data/exe/trt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The one caller that turns CLI.start's status into an exit. Everything else
|
|
5
|
+
# drives Terret::CLI.start(argv, out:, err:) in-process.
|
|
6
|
+
begin
|
|
7
|
+
require "terret/boot"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
require_relative "../lib/terret/boot" # monorepo path source
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
exit Terret::CLI.start(ARGV)
|
data/lib/terret/boot.rb
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Running from a source checkout, the sibling gems are not on the load path;
|
|
4
|
+
# a `gem install` puts them there. Seeding them here is what lets a bundle's
|
|
5
|
+
# `requires:` name `terret/exec` and resolve identically in both worlds, and it
|
|
6
|
+
# is the same monorepo path-source affordance every other gem's entry file
|
|
7
|
+
# makes for terret-core.
|
|
8
|
+
gems_root = File.expand_path("../../..", __dir__)
|
|
9
|
+
if File.directory?(File.join(gems_root, "terret-core", "lib"))
|
|
10
|
+
# One unshift, so the sibling libs keep the order they are listed in rather
|
|
11
|
+
# than the reverse of it.
|
|
12
|
+
$LOAD_PATH.unshift(*Dir.children(gems_root).sort
|
|
13
|
+
.map { |gem_dir| File.join(gems_root, gem_dir, "lib") }
|
|
14
|
+
.select { |lib| File.directory?(lib) && !$LOAD_PATH.include?(lib) })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require "terret"
|
|
18
|
+
|
|
19
|
+
require_relative "version"
|
|
20
|
+
require_relative "home"
|
|
21
|
+
require_relative "composition"
|
|
22
|
+
require_relative "doctor"
|
|
23
|
+
require_relative "cli"
|
|
24
|
+
|
|
25
|
+
module Terret
|
|
26
|
+
# Resolve the layers, hand the row list to the Hames loader, return the
|
|
27
|
+
# booted context (docs/composition.md §7). That is the whole surface, and its
|
|
28
|
+
# shape is the embeddability goal made concrete: a Rails app calls this in an
|
|
29
|
+
# initializer and holds the ctx, with no process to supervise and no socket
|
|
30
|
+
# to speak.
|
|
31
|
+
def self.boot(profile:, patches: [], allow_config_ruby: false, home: nil)
|
|
32
|
+
Boot.new(Composition.resolve(profile: profile, home: home, patches: patches),
|
|
33
|
+
allow_config_ruby: allow_config_ruby).boot!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The impure half: requiring the code a composition names, turning plugin
|
|
37
|
+
# names into constants, and mounting. Split out from Composition so the pure
|
|
38
|
+
# half stays runnable on a machine with none of this installed.
|
|
39
|
+
class Boot
|
|
40
|
+
Error = Class.new(StandardError)
|
|
41
|
+
|
|
42
|
+
attr_reader :resolved, :allow_config_ruby
|
|
43
|
+
|
|
44
|
+
def initialize(resolved, allow_config_ruby: false)
|
|
45
|
+
@resolved = resolved
|
|
46
|
+
@allow_config_ruby = allow_config_ruby
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def boot! = loader.boot!
|
|
50
|
+
|
|
51
|
+
# The loader rather than the context, for a caller that wants
|
|
52
|
+
# reconfigure!/unload! on the composition it just booted. Memoized, because
|
|
53
|
+
# a second loader would be a second context: `b.loader` then `b.boot!`
|
|
54
|
+
# would hand back two unrelated worlds built from the same rows.
|
|
55
|
+
def loader
|
|
56
|
+
@loader ||= begin
|
|
57
|
+
require_code!
|
|
58
|
+
built = Hames::Loader.new.layer(rows)
|
|
59
|
+
# Reachable from the context it boots. Terret.boot returns the ctx and
|
|
60
|
+
# nothing else, so without this a caller holding one has no way to
|
|
61
|
+
# reconfigure a row, unload one, or shut the composition down through
|
|
62
|
+
# the services' own stop hooks.
|
|
63
|
+
built.ctx.register_service(:loader, built)
|
|
64
|
+
built
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def rows
|
|
69
|
+
@rows ||= resolved.materialize(allow_config_ruby: allow_config_ruby)
|
|
70
|
+
.map { |row| row.merge(plugin: constantize(row[:plugin], row[:id])) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A bundle's requires first, then the profile's own `plugins:` — code that
|
|
74
|
+
# is not a bundle loads last so it can reopen what a bundle defined.
|
|
75
|
+
def require_code!
|
|
76
|
+
# A bundle's requires: ship inside a gem the operator added to the Gemfile
|
|
77
|
+
# and installed, so they are trusted and may name a path to their own lib.
|
|
78
|
+
# A profile's plugins: is portable config a profile "downloaded from
|
|
79
|
+
# anywhere" (docs/composition.md §5) carries, so a path-shaped one there is
|
|
80
|
+
# arbitrary code execution — gated behind the same --allow-config-ruby
|
|
81
|
+
# consent as !ruby. Validate up front so a bad path fails before any
|
|
82
|
+
# require has run a line of code.
|
|
83
|
+
resolved.plugins.each do |file|
|
|
84
|
+
next if allow_config_ruby || Composition.load_path_feature?(file)
|
|
85
|
+
|
|
86
|
+
raise Error, "profile #{resolved.profile.inspect} refuses to require #{file.inspect}: " \
|
|
87
|
+
"it is a filesystem path, not a load-path feature name. Loading Ruby by " \
|
|
88
|
+
"path is arbitrary code execution; pass --allow-config-ruby to permit it " \
|
|
89
|
+
"(docs/composition.md §5, docs/security.md)."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
(resolved.requires + resolved.plugins).each do |file|
|
|
93
|
+
require file
|
|
94
|
+
rescue LoadError => e
|
|
95
|
+
raise Error, "profile #{resolved.profile.inspect} needs #{file.inspect}, " \
|
|
96
|
+
"which is not on the load path (#{e.message}). Is the gem " \
|
|
97
|
+
"that ships it in your Gemfile?"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def constantize(name, id)
|
|
102
|
+
klass = Object.const_get(name)
|
|
103
|
+
unless klass.is_a?(Class) && klass.method_defined?(:apply)
|
|
104
|
+
raise Error, "row #{id.inspect}: #{name} is not a plugin — a plugin is a " \
|
|
105
|
+
"class whose instances respond to #apply(ctx), which is what " \
|
|
106
|
+
"subclassing Hames::Service gives you"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
klass
|
|
110
|
+
rescue NameError
|
|
111
|
+
raise Error, "row #{id.inspect}: no such plugin #{name}. The constant did not " \
|
|
112
|
+
"resolve, so either the name is wrong or the gem that defines it " \
|
|
113
|
+
"is not required by any bundle in this profile."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Take a booted context down through the loader, so every row's own stop
|
|
117
|
+
# hook runs: the shell closes its bash, the sandbox discards its container,
|
|
118
|
+
# the SQLite store closes its handle. A hand-written list of seams runs
|
|
119
|
+
# none of the hooks it does not happen to name, and grows a hole every time
|
|
120
|
+
# a bundle mounts something new.
|
|
121
|
+
#
|
|
122
|
+
# Reverse MOUNT order, which is exact reverse-dependency order: a consumer
|
|
123
|
+
# comes down before the service it injects, and the session store — which
|
|
124
|
+
# everything else may still be writing to — closes last.
|
|
125
|
+
#
|
|
126
|
+
# Best-effort means each step is separately best-effort. One wedged seam
|
|
127
|
+
# aborting the rest is how a container survives the process that owned it.
|
|
128
|
+
#
|
|
129
|
+
# A context built by hand rather than by Terret.boot has no loader to find,
|
|
130
|
+
# and this says so rather than quietly disposing and running no hooks at
|
|
131
|
+
# all — pass `loader:` to get the real teardown.
|
|
132
|
+
def self.shutdown(ctx, loader: nil)
|
|
133
|
+
loader ||= ctx[:loader] if ctx.service?(:loader)
|
|
134
|
+
if loader
|
|
135
|
+
loader.mounted.keys.reverse_each { |id| step("row #{id}") { loader.unload!(id) } }
|
|
136
|
+
else
|
|
137
|
+
warn "terret: shutdown: no loader for this context, so no service's stop hook ran. " \
|
|
138
|
+
"Boot through Terret.boot, or pass shutdown(ctx, loader:) — disposing registrations only."
|
|
139
|
+
end
|
|
140
|
+
step("dispose") { ctx.dispose! }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.step(what)
|
|
144
|
+
yield
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
warn "terret: shutdown: #{what}: #{e.class}: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/terret/cli.rb
CHANGED
|
@@ -1,21 +1,279 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "composition"
|
|
7
|
+
require_relative "doctor"
|
|
8
|
+
|
|
3
9
|
module Terret
|
|
4
|
-
# The `trt` command line interface.
|
|
10
|
+
# The `trt` command line interface (docs/composition.md §8).
|
|
11
|
+
#
|
|
12
|
+
# Non-interactive: optparse, no thor, no REPL, no TUI. Nothing here is a chat
|
|
13
|
+
# window and nothing here competes with the socket. `trt boot` starts the
|
|
14
|
+
# reactor and parks, the other two print and exit, and every one of them is a
|
|
15
|
+
# thin wrapper over Terret.boot or over pure resolution.
|
|
5
16
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# fill it. Everything that currently works lives in terret-core and hames.
|
|
17
|
+
# start returns a process exit status rather than calling exit, so the tests
|
|
18
|
+
# can drive it in-process with captured IO. exe/trt is the one caller that
|
|
19
|
+
# turns the status into an exit.
|
|
10
20
|
module CLI
|
|
11
|
-
|
|
21
|
+
COMMANDS = %w[boot dump-config doctor acp].freeze
|
|
22
|
+
|
|
23
|
+
USAGE = <<~TXT
|
|
24
|
+
Usage: trt <command> [options]
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
boot compose a profile, mount it, and park until interrupted
|
|
28
|
+
dump-config print the resolved rows, annotated with the layer that
|
|
29
|
+
contributed each one; secrets stay unresolved
|
|
30
|
+
doctor resolve a profile and report on its rows without booting
|
|
31
|
+
acp compose a profile and serve the Agent Client Protocol
|
|
32
|
+
on stdio, so an editor can drive an agent; stdout carries
|
|
33
|
+
only ACP frames and diagnostics go to stderr
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
-p, --profile NAME profile to compose (required)
|
|
37
|
+
--patch FILE overlay a patch file; repeatable, applied in order
|
|
38
|
+
--home DIR Terret home (default: $TERRET_HOME, else ~/.terret)
|
|
39
|
+
--allow-config-ruby permit !ruby scalars in this composition
|
|
40
|
+
-v, --version print the terret version
|
|
41
|
+
-h, --help print this message
|
|
42
|
+
TXT
|
|
43
|
+
|
|
44
|
+
Options = Struct.new(:command, :profile, :patches, :home, :allow_config_ruby)
|
|
45
|
+
|
|
46
|
+
def self.start(argv = ARGV, out: $stdout, err: $stderr, input: $stdin)
|
|
47
|
+
opts = parse(argv, out: out, err: err)
|
|
48
|
+
return opts if opts.is_a?(Integer)
|
|
49
|
+
|
|
50
|
+
dispatch(opts, out: out, err: err, input: input)
|
|
51
|
+
rescue Composition::Error => e
|
|
52
|
+
# Boot failures are caught in .boot, which is also the only command that
|
|
53
|
+
# needs boot.rb — this file must not name a constant from the file that
|
|
54
|
+
# requires it.
|
|
55
|
+
err.puts "trt: #{e.message}"
|
|
56
|
+
1
|
|
57
|
+
rescue SystemCallError, IOError, ScriptError => e
|
|
58
|
+
# Belt and braces. Resolution turns these into Composition::Error where
|
|
59
|
+
# it meets them, but a config file is a file on somebody's disk and a
|
|
60
|
+
# !ruby scalar is a compiler — neither is done surprising us, and a
|
|
61
|
+
# backtrace is not an error message.
|
|
62
|
+
err.puts "trt: #{e.class}: #{e.message.lines.first.to_s.strip}"
|
|
63
|
+
1
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
err.puts "trt: interrupted"
|
|
66
|
+
130
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.dispatch(opts, out:, err:, input: $stdin)
|
|
70
|
+
case opts.command
|
|
71
|
+
when "boot" then boot(opts, out: out, err: err)
|
|
72
|
+
when "dump-config" then dump_config(opts, out: out)
|
|
73
|
+
when "doctor" then Doctor.run(resolve(opts), allow_config_ruby: opts.allow_config_ruby, out: out)
|
|
74
|
+
when "acp" then acp(opts, out: out, err: err, input: input)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -- argument parsing ------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
# Returns Options, or an exit status when the run is over (help, version,
|
|
81
|
+
# usage error).
|
|
82
|
+
def self.parse(argv, out:, err:)
|
|
83
|
+
opts = Options.new(nil, nil, [], nil, false)
|
|
84
|
+
parser = OptionParser.new do |o|
|
|
85
|
+
o.banner = USAGE
|
|
86
|
+
o.on("-p", "--profile NAME") { |v| opts.profile = v }
|
|
87
|
+
o.on("--patch FILE") { |v| opts.patches << v }
|
|
88
|
+
o.on("--home DIR") { |v| opts.home = v }
|
|
89
|
+
o.on("--allow-config-ruby") { opts.allow_config_ruby = true }
|
|
90
|
+
o.on("-v", "--version") { out.puts "trt #{Terret::Meta::VERSION}"; return 0 }
|
|
91
|
+
o.on("-h", "--help") { out.puts USAGE; return 0 }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
rest = begin
|
|
95
|
+
parser.parse(argv.dup)
|
|
96
|
+
rescue OptionParser::ParseError => e
|
|
97
|
+
return usage_error(e.message, err: err)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
opts.command = rest.shift
|
|
101
|
+
return usage_error("no command given", err: err) if opts.command.nil?
|
|
102
|
+
return usage_error("unknown command #{opts.command.inspect}", err: err) unless COMMANDS.include?(opts.command)
|
|
103
|
+
return usage_error("unexpected arguments: #{rest.join(' ')}", err: err) unless rest.empty?
|
|
104
|
+
return usage_error("#{opts.command} needs --profile NAME", err: err) if opts.profile.nil?
|
|
105
|
+
|
|
106
|
+
opts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.usage_error(message, err:)
|
|
110
|
+
err.puts "trt: #{message}"
|
|
111
|
+
err.puts
|
|
112
|
+
err.puts USAGE
|
|
113
|
+
2
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.resolve(opts)
|
|
117
|
+
Composition.resolve(profile: opts.profile, home: opts.home, patches: opts.patches)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- boot ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def self.boot(opts, out:, err:)
|
|
123
|
+
require_relative "boot" # the one command that needs it; already loaded via exe/trt
|
|
124
|
+
ctx = nil
|
|
125
|
+
ctx = Terret.boot(profile: opts.profile, home: opts.home, patches: opts.patches,
|
|
126
|
+
allow_config_ruby: opts.allow_config_ruby)
|
|
127
|
+
out.puts "trt: profile #{opts.profile.inspect} is up. Interrupt to stop."
|
|
128
|
+
out.flush
|
|
129
|
+
begin
|
|
130
|
+
park
|
|
131
|
+
rescue Interrupt
|
|
132
|
+
out.puts
|
|
133
|
+
end
|
|
134
|
+
out.puts "trt: stopping"
|
|
135
|
+
0
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
err.puts "trt: boot failed: #{e.class}: #{e.message}"
|
|
138
|
+
1
|
|
139
|
+
ensure
|
|
140
|
+
# Teardown belongs here, not on the success path: a park that raises
|
|
141
|
+
# rather than catching its Interrupt used to return 1 and leak the whole
|
|
142
|
+
# booted world — its container, its bash, its open database. A boot that
|
|
143
|
+
# got as far as a live context is always torn down; a Terret.boot that
|
|
144
|
+
# never returned leaves ctx nil and nothing to shut down.
|
|
145
|
+
Boot.shutdown(ctx) if ctx
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# -- acp -------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
# Boot a profile and serve the Agent Client Protocol on stdio, so an editor
|
|
151
|
+
# can drive an agent (docs/acp.md). The one hard rule of this wire is that
|
|
152
|
+
# stdout carries ONLY ACP frames: diagnostics go to `err` (stderr), never
|
|
153
|
+
# `out`, and `serve` blocks on the reactor until the editor closes the pipe.
|
|
154
|
+
# The IO trio is injectable so a test drives the whole subcommand over an
|
|
155
|
+
# in-memory pipe; `exe/trt` passes $stdout/$stderr/$stdin.
|
|
156
|
+
def self.acp(opts, out:, err:, input:)
|
|
157
|
+
require_relative "boot"
|
|
158
|
+
ctx = nil
|
|
159
|
+
ctx = Terret.boot(profile: opts.profile, home: opts.home, patches: opts.patches,
|
|
160
|
+
allow_config_ruby: opts.allow_config_ruby)
|
|
161
|
+
unless ctx.service?(:acp)
|
|
162
|
+
err.puts "trt: profile #{opts.profile.inspect} mounts no acp row to serve"
|
|
163
|
+
return 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
err.puts "trt: profile #{opts.profile.inspect} is up; serving ACP on stdio. Interrupt to stop."
|
|
167
|
+
err.flush
|
|
168
|
+
ctx[:acp].serve(input: input, output: out)
|
|
169
|
+
0
|
|
170
|
+
rescue Interrupt
|
|
171
|
+
err.puts "trt: interrupted"
|
|
172
|
+
130
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
err.puts "trt: acp failed: #{e.class}: #{e.message}"
|
|
175
|
+
1
|
|
176
|
+
ensure
|
|
177
|
+
# The same teardown boot does, and for the same reason: a serve that
|
|
178
|
+
# returned on EOF, or raised, must still take its container, its bash, and
|
|
179
|
+
# its open database down. A Terret.boot that never returned leaves ctx nil.
|
|
180
|
+
Boot.shutdown(ctx) if ctx
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# An agent is a task tree on the fiber scheduler, so a booted process
|
|
184
|
+
# belongs on the reactor even when this command has nothing of its own to
|
|
185
|
+
# run. Without async there is nothing to park on but the process itself.
|
|
186
|
+
def self.park
|
|
187
|
+
require "async"
|
|
188
|
+
Async { |task| task.sleep }
|
|
189
|
+
rescue LoadError
|
|
190
|
+
sleep
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# -- dump-config -----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
# The resolved tree with each row annotated by the layer that contributed
|
|
196
|
+
# it. SECRETS RENDER AS THEIR UNRESOLVED TAG — `api_key: !env
|
|
197
|
+
# OPENROUTER_API_KEY` prints as written and the resolved value never
|
|
198
|
+
# appears here at all. This output exists to be pasted into an issue, and a
|
|
199
|
+
# resolved credential printed once is a credential rotated.
|
|
200
|
+
def self.dump_config(opts, out:)
|
|
201
|
+
resolved = resolve(opts)
|
|
202
|
+
lines = [["# resolved: profile #{resolved.profile.inspect}", nil], ["rows:", nil]]
|
|
203
|
+
|
|
204
|
+
resolved.rows.each do |row|
|
|
205
|
+
# Row ids are validated at resolution, but plugin names (constant paths)
|
|
206
|
+
# and layer labels (bundle names, --patch paths) are not — so every
|
|
207
|
+
# identifier printed here goes through one_line, and a newline in one
|
|
208
|
+
# cannot forge a row or a provenance line in this output.
|
|
209
|
+
lines << [" - id: #{safe(row.id)}", "row: #{safe(row.row_layer)}"]
|
|
210
|
+
# Annotated only when a later layer swapped it, so the annotation means
|
|
211
|
+
# "somebody changed this" rather than being visual noise on every row.
|
|
212
|
+
swapped = row.plugin_layer unless row.plugin_layer == row.row_layer
|
|
213
|
+
lines << [" plugin: #{safe(row.plugin)}", swapped && "plugin: #{safe(swapped)}"]
|
|
214
|
+
lines << [" disabled: true", nil] if row.disabled
|
|
215
|
+
if row.config.empty?
|
|
216
|
+
lines << [" config: {}", "config: #{safe(row.config_layer)}"]
|
|
217
|
+
else
|
|
218
|
+
lines << [" config:", "config: #{safe(row.config_layer)}"]
|
|
219
|
+
yaml_lines(row.config, 3).each { |l| lines << [l, nil] }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
out.puts align(lines)
|
|
224
|
+
0
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Comments live in a column so the provenance reads as a column.
|
|
228
|
+
def self.align(lines)
|
|
229
|
+
width = lines.filter_map { |text, comment| text.length if comment }.max.to_i
|
|
230
|
+
column = [width + 2, 32].max
|
|
231
|
+
lines.map { |text, comment| comment ? "#{text.ljust(column)}# #{comment}" : text }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.yaml_lines(value, depth)
|
|
235
|
+
pad = " " * depth
|
|
236
|
+
case value
|
|
237
|
+
when Hash
|
|
238
|
+
value.flat_map do |key, sub|
|
|
239
|
+
k = key_cell(key)
|
|
240
|
+
nested?(sub) ? ["#{pad}#{k}:", *yaml_lines(sub, depth + 1)] : ["#{pad}#{k}: #{scalar(sub)}"]
|
|
241
|
+
end
|
|
242
|
+
when Array
|
|
243
|
+
value.flat_map do |sub|
|
|
244
|
+
next ["#{pad}- #{scalar(sub)}"] unless nested?(sub)
|
|
245
|
+
|
|
246
|
+
nested = yaml_lines(sub, depth + 1)
|
|
247
|
+
["#{pad}- #{nested.first.lstrip}", *nested.drop(1)]
|
|
248
|
+
end
|
|
249
|
+
else [pad + scalar(value)]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def self.nested?(value) = (value.is_a?(Hash) || value.is_a?(Array)) && !value.empty?
|
|
254
|
+
|
|
255
|
+
def self.safe(value) = Composition.one_line(value.to_s)
|
|
256
|
+
|
|
257
|
+
# A config KEY is attacker-influenceable the same way a value or a plugin
|
|
258
|
+
# name is (an explicit YAML key can carry a newline that forges a provenance
|
|
259
|
+
# line, or spell a secret), so it goes through the same one_line/redact path
|
|
260
|
+
# the values already use — not the Psych.dump `scalar` path, because a key is
|
|
261
|
+
# printed bare rather than quoted.
|
|
262
|
+
def self.key_cell(key) = Composition.one_line(Composition.redact_secrets(key.to_s))
|
|
12
263
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
264
|
+
# Psych does the quoting, so a value that would reparse as a boolean, a
|
|
265
|
+
# number, or a null comes back quoted. A tag renders as itself (unresolved).
|
|
266
|
+
# A LITERAL string value is the one way a real secret reaches this output —
|
|
267
|
+
# !env/!setting stay tags — so a secret-shaped literal is redacted, and any
|
|
268
|
+
# control characters are neutralized so a value cannot forge a line either.
|
|
269
|
+
def self.scalar(value)
|
|
270
|
+
return safe(value) if value.is_a?(Composition::Tagged)
|
|
271
|
+
return "{}" if value == {}
|
|
272
|
+
return "[]" if value == []
|
|
16
273
|
|
|
17
|
-
|
|
18
|
-
|
|
274
|
+
value = Composition.one_line(Composition.redact_secrets(value)) if value.is_a?(String)
|
|
275
|
+
body = Psych.dump(value).delete_prefix("---").sub(/\n\z/, "").strip
|
|
276
|
+
body.empty? ? "~" : body
|
|
19
277
|
end
|
|
20
278
|
end
|
|
21
279
|
end
|