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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6d2cb467e17acca48ee7001c042d041bbc9e50a923fedeb2828050603f6319e
4
- data.tar.gz: 60bdcb7b49ce9ecefea229cb5cf8e9ac913651ec05a9910846840d5803e1907d
3
+ metadata.gz: 5dbd83ac7632140b6d208f2e242ae24bfd0e11c0c05a2fd5b61d144c2b156190
4
+ data.tar.gz: c5f750acb8b699b3e6740677767ee11737f589dd16085395116d0fbd84b550fa
5
5
  SHA512:
6
- metadata.gz: e09b72fd5e9b76d06f11946bf88f4285e570334b7b6e05021e3736338135ed0a2ac4f6aa01fe0621b3a3e59de351e1f8372a7738d736e8a88671bc5dabcf80f2
7
- data.tar.gz: 5ffdcb91c1d5fb8026c1debcb888ef0270cb0e2f7fe579544c18246cbdb91e02b3dcf6d43e96fa9e3eea78fb8bc4a7675f8ea662bfafff5eb741e84c44ec0ab2
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)
@@ -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
- # Not implemented yet. The CLI is M4 work in docs/terret-implementation-plan.md
7
- # and it needs a real model adapter to drive, which is M2 work still outstanding.
8
- # This gem exists so that the `terret` name belongs to the project that will
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
- VERSION = "0.0.2"
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
- def self.start(_argv = ARGV)
14
- abort <<~MSG
15
- terret: the trt CLI does not exist yet.
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
- What works today is the library. See https://terret.org
18
- MSG
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