agent-cli-runtime 0.2.0 → 0.2.4
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/CHANGELOG.md +82 -15
- data/README.md +54 -18
- data/agent-cli-runtime.gemspec +7 -6
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +35 -120
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +40 -16
- data/lib/agent_cli_runtime/opencode/result_parser.rb +24 -10
- data/lib/agent_cli_runtime/profile.rb +28 -13
- data/lib/agent_cli_runtime/profiles.rb +28 -2
- data/lib/agent_cli_runtime/runtime.rb +102 -11
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +54 -8
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +6 -0
- metadata +9 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0202bb311856ad96fcd486517d11cfc7175db0102a188920c4d851bf338b7d6
|
|
4
|
+
data.tar.gz: 2e72a23b5e7a021fc439a9ce8ef34bf166c0af0141de491a27686d6f3ba3f139
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3736f38aa29d809028b1e99c9df1dd17ddb4a210382e89f752def380e84cf22e3f144da2d10ee6c86c2dab8496865a35347196277367a0734b9c39991c33f0e
|
|
7
|
+
data.tar.gz: 5adacccc362958a8cc9a2eeab22b9100041101de4795d7f31ddaa563406ec031f6b3662e84eca058b144ab86ee41d1505c42baa267c1eae436cda4566680b100
|
data/CHANGELOG.md
CHANGED
|
@@ -2,23 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.2.4 - 2026-08-25
|
|
6
|
+
|
|
7
|
+
- Expose the OpenCode permission compiler independently from the prepared
|
|
8
|
+
overlay so callers can supply the same deny-first rules through native
|
|
9
|
+
per-process configuration without duplicating policy generation.
|
|
10
|
+
|
|
11
|
+
- Carry the already-resolved OpenCode executable on `ProbeRequest` (`executable:`)
|
|
12
|
+
instead of re-encoding it as the `AGENT_CLI_RUNTIME_OPENCODE_BIN` environment
|
|
13
|
+
override. The route-aware probe now resolves the executable once — honoring a
|
|
14
|
+
caller-supplied value first — and threads that single value through the
|
|
15
|
+
installation check, version check, every local inspection command, and the
|
|
16
|
+
reported result executable. `Profile#binary_installed?`,
|
|
17
|
+
`Profile#check_version!`, and `Profile#capture_local` accept an optional
|
|
18
|
+
`executable:` override; existing environment-based resolution is unchanged
|
|
19
|
+
when no explicit value is carried.
|
|
20
|
+
|
|
21
|
+
## 0.2.3 - 2026-08-21
|
|
22
|
+
|
|
23
|
+
- Classify extracted provider failures once at the runtime boundary as
|
|
24
|
+
`provider_limit`, `rate_limited`, `model_output_limit`, or `provider_error`.
|
|
25
|
+
HTTP 402/429 payloads are authoritative even when their provider-specific
|
|
26
|
+
wording does not match an orchestrator's legacy quota phrases.
|
|
27
|
+
|
|
28
|
+
## 0.2.2 - 2026-08-18
|
|
29
|
+
|
|
30
|
+
- Deliver built-in Pi prompts through its native non-TTY stdin reader instead
|
|
31
|
+
of one positional argv element. This prevents implementation-sized prompts
|
|
32
|
+
from failing at process spawn with the operating system's per-argument
|
|
33
|
+
`E2BIG` limit; Codex retains its separate stdin-plus-`-` transport.
|
|
34
|
+
- Treat Pi's `stopReason: "length"` as a typed `model_output_limit` failure.
|
|
35
|
+
Pi exits zero after this provider stop even when the model exhausted its
|
|
36
|
+
output allowance before writing the requested artifact; callers can now
|
|
37
|
+
distinguish that incomplete turn from an agent that silently produced
|
|
38
|
+
0 bytes and tell operators to raise `maxTokens` or lower reasoning effort.
|
|
39
|
+
- Declare Grok's filesystem sandbox flags, so a caller asking for confined
|
|
40
|
+
execution gets it instead of being told Grok cannot confine. `--sandbox
|
|
41
|
+
workspace` limits writes to the working directory and `--sandbox read-only`
|
|
42
|
+
forbids them, both built-in profiles that custom ones extend from
|
|
43
|
+
`~/.grok/sandbox.toml`; `--always-approve` suppresses the approval prompt a
|
|
44
|
+
headless run can never answer, leaving the sandbox — not the prompt — as the
|
|
45
|
+
boundary. Only the Grok profile changes.
|
|
46
|
+
|
|
47
|
+
## 0.2.1 - 2026-08-17
|
|
48
|
+
|
|
49
|
+
- Add per-profile provider-error extraction so a refusal a CLI reports on its
|
|
50
|
+
event stream is distinguishable from an agent that genuinely produced
|
|
51
|
+
nothing. `extract_provider_error` returns the provider, the HTTP status when
|
|
52
|
+
the provider supplied one, and the redacted provider text.
|
|
53
|
+
- Add the `pi` extractor for turns that keep the envelope type and carry the
|
|
54
|
+
refusal in `stopReason`/`errorMessage`, which no event-type match observes.
|
|
55
|
+
Such turns previously reached the caller as a clean run with empty content
|
|
56
|
+
while the process exited zero.
|
|
57
|
+
- Default every other profile to the previously assumed shapes — dedicated
|
|
58
|
+
`error`, `turn.failed`, and `rate_limit_event` events plus failed `result`
|
|
59
|
+
events — so no existing profile changes behaviour.
|
|
60
|
+
- Read usage from the assistant message and accept the bare
|
|
61
|
+
`input`/`output`/`cacheRead`/`cacheWrite` spellings, so a provider reporting
|
|
62
|
+
usage there is metered instead of silently recording nothing. The bare
|
|
63
|
+
spellings are matched last, leaving a provider that reports explicit
|
|
64
|
+
`*_tokens` keys with its existing reading.
|
|
65
|
+
|
|
5
66
|
## 0.2.0 - 2026-08-15
|
|
6
67
|
|
|
7
|
-
- Add OpenCode `1.18.16+`
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
68
|
+
- Add first-class OpenCode `1.18.16+` support alongside the built-in Claude
|
|
69
|
+
Code, Codex CLI, Pi, and Grok CLI profiles. OpenCode uses the shared request
|
|
70
|
+
and facade, then adds provider-specific preparation, route probing, and
|
|
71
|
+
strict result normalization.
|
|
72
|
+
- Route every OpenCode run to an exact `provider/model` and validate model
|
|
73
|
+
variants, preserving the route the application requested and the route the
|
|
74
|
+
captured result reports.
|
|
75
|
+
- Prepare owner-private OpenCode config, data, cache, and state overlays for
|
|
76
|
+
each invocation, with scoped `read-only` and `workspace-write` policies,
|
|
77
|
+
deliberate credential forwarding, and idempotent cleanup.
|
|
78
|
+
- Check run/export capabilities, selected authentication source, cached model
|
|
79
|
+
inventory, and the exact requested route locally before an application
|
|
80
|
+
starts work.
|
|
81
|
+
- Correlate run and sanitized-export evidence into typed outcomes for
|
|
82
|
+
completion, authentication, configuration, CLI failure, malformed output,
|
|
83
|
+
cancellation, and timeout.
|
|
84
|
+
- Report input, output, cache-read, cache-write, reasoning, and cost evidence
|
|
85
|
+
while preserving the difference between unavailable data and numeric zero.
|
|
86
|
+
- Return prepared commands and normalized evidence through lifecycle-safe
|
|
87
|
+
values that fit an application's existing streaming, timeout, cancellation,
|
|
88
|
+
retry, and process-supervision stack.
|
|
22
89
|
|
|
23
90
|
## 0.1.1 - 2026-08-11
|
|
24
91
|
|
data/README.md
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
# Agent CLI Runtime
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
Agent CLI Runtime gives Ruby applications one stable integration layer for
|
|
4
|
+
Claude Code, Codex CLI, Pi, Grok CLI, and OpenCode. It builds provider-specific
|
|
5
|
+
commands from a shared request model, checks local versions and named
|
|
6
|
+
capabilities, and normalizes usage and results afterward. Centralized profiles,
|
|
7
|
+
environment rules, and parsers make agent providers easier to add, switch, and
|
|
8
|
+
upgrade.
|
|
9
|
+
|
|
10
|
+
Version 0.2.0 adds first-class OpenCode support with exact `provider/model`
|
|
11
|
+
routing, isolated per-invocation configuration, scoped permission policies,
|
|
12
|
+
offline readiness checks, and typed outcome normalization. See the
|
|
13
|
+
[changelog](CHANGELOG.md) for the complete release notes.
|
|
14
|
+
|
|
15
|
+
## Why use it?
|
|
16
|
+
|
|
17
|
+
Agent CLIs disagree on flags, prompt transport, configuration locations,
|
|
18
|
+
permission controls, usage events, and result formats. Agent CLI Runtime keeps
|
|
19
|
+
those differences in versioned profiles so application code can use one
|
|
20
|
+
request and result vocabulary.
|
|
21
|
+
|
|
22
|
+
- Add or switch agent CLIs without spreading provider conditionals throughout
|
|
23
|
+
the application.
|
|
24
|
+
- Validate installed versions, capabilities, and exact routes before starting
|
|
25
|
+
work.
|
|
26
|
+
- Preserve typed usage and outcome evidence across provider-specific output
|
|
27
|
+
formats.
|
|
28
|
+
- Add custom profiles through the same immutable compatibility contract.
|
|
10
29
|
|
|
11
30
|
## Install
|
|
12
31
|
|
|
@@ -41,6 +60,11 @@ Compilation does not execute the returned command. Unsupported requested
|
|
|
41
60
|
controls raise `AgentCliRuntime::UnsupportedCapability` with typed evidence
|
|
42
61
|
instead of silently widening the request.
|
|
43
62
|
|
|
63
|
+
Prompt transport is profile-owned. `:stdin` writes the prompt to stdin and
|
|
64
|
+
adds the CLI's conventional `-` argv marker; `:piped_stdin` writes the prompt
|
|
65
|
+
to stdin without a marker for CLIs such as Pi that consume a non-TTY stream
|
|
66
|
+
directly. Built-in Pi therefore keeps arbitrarily large prompts out of argv.
|
|
67
|
+
|
|
44
68
|
`permission_mode: nil` selects the profile's default non-interactive permission
|
|
45
69
|
flags, which may include a provider's bypass flag. Pass `"read-only"` or
|
|
46
70
|
`"workspace-write"` explicitly when the integration requires that constraint.
|
|
@@ -58,12 +82,14 @@ represent.
|
|
|
58
82
|
- `extract_usage(profile, event)` normalizes provider usage when present and
|
|
59
83
|
returns `nil` when usage is absent or malformed.
|
|
60
84
|
- `observe(profile, result)` normalizes bounded, redacted result metadata.
|
|
61
|
-
- `prepare!(open_code_preparation)`
|
|
62
|
-
|
|
85
|
+
- `prepare!(open_code_preparation)` returns a `PreparedInvocation` containing
|
|
86
|
+
an isolated OpenCode overlay, argv, and child environment.
|
|
87
|
+
- `OpenCode::Permissions.compile(...)` returns the same deny-first permission
|
|
88
|
+
document independently for callers that retain native OpenCode state.
|
|
63
89
|
- `parse_run(profile, stdout:)` parses a successful OpenCode JSONL capture
|
|
64
90
|
into the session and terminal-message identity required for inspection.
|
|
65
|
-
- `prepare_inspection(prepared, parsed_run)`
|
|
66
|
-
session-export
|
|
91
|
+
- `prepare_inspection(prepared, parsed_run)` returns the non-model sanitized
|
|
92
|
+
session-export invocation.
|
|
67
93
|
- `normalize(profile, captured, requested_route:)` returns one typed OpenCode
|
|
68
94
|
outcome from caller-captured run, termination, and inspection evidence.
|
|
69
95
|
|
|
@@ -125,6 +151,11 @@ read-only configuration source. Configuration may define providers and an
|
|
|
125
151
|
exact default model, but it must not contain credential values. Name the
|
|
126
152
|
credential environment variables the caller is allowed to forward instead.
|
|
127
153
|
|
|
154
|
+
Callers that deliberately retain native OpenCode config and login can use
|
|
155
|
+
`OpenCode::Permissions.compile` directly and pass the JSON document through
|
|
156
|
+
OpenCode's per-process permission input. The prepared-overlay API below
|
|
157
|
+
remains the closed, isolated option.
|
|
158
|
+
|
|
128
159
|
```ruby
|
|
129
160
|
require "agent_cli_runtime"
|
|
130
161
|
require "tmpdir"
|
|
@@ -150,7 +181,8 @@ preparation = AgentCliRuntime::OpenCodePreparationRequest.new(
|
|
|
150
181
|
},
|
|
151
182
|
credential_environment_keys: ["ANTHROPIC_API_KEY"],
|
|
152
183
|
additional_read_roots: [Dir.pwd],
|
|
153
|
-
additional_write_roots: [Dir.pwd]
|
|
184
|
+
additional_write_roots: [Dir.pwd],
|
|
185
|
+
bash_patterns: ["git*", "bundle*", "bin/*"]
|
|
154
186
|
)
|
|
155
187
|
|
|
156
188
|
prepared = AgentCliRuntime.prepare!(preparation)
|
|
@@ -198,8 +230,10 @@ to call twice. Call it from the process owner's `ensure` path after every
|
|
|
198
230
|
pre-spawn and post-spawn outcome.
|
|
199
231
|
|
|
200
232
|
`read-only` denies edits, shell, unsafe tools, and external writes.
|
|
201
|
-
`workspace-write` permits edits only under the declared write roots
|
|
202
|
-
|
|
233
|
+
`workspace-write` permits edits only under the declared write roots. Shell is
|
|
234
|
+
denied unless the caller supplies explicit OpenCode `bash_patterns`; those
|
|
235
|
+
patterns are application permissions, not an OS sandbox. A `nil` permission
|
|
236
|
+
mode is rejected unless the
|
|
203
237
|
consumer supplies an explicit typed `OpenCodePermissionPolicy`; the ordinary
|
|
204
238
|
preparation API never silently falls back to a bypass. Plugin sources are
|
|
205
239
|
explicit, and `--pure` remains enabled when no plugin was selected.
|
|
@@ -218,10 +252,12 @@ model request:
|
|
|
218
252
|
bundle exec ruby -Itest test/opencode_offline_smoke_test.rb
|
|
219
253
|
```
|
|
220
254
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
behavior.
|
|
255
|
+
Auto-discovery considers every matching executable in `PATH` and prefers an
|
|
256
|
+
already-installed native executable over an earlier package-manager launcher,
|
|
257
|
+
including script and multicall-binary shims, so the
|
|
258
|
+
smoke cannot trigger package-manager installation or refresh behavior. If only
|
|
259
|
+
a shim is discoverable, set `AGENT_CLI_RUNTIME_OPENCODE_OFFLINE_BIN` to the
|
|
260
|
+
native OpenCode executable explicitly.
|
|
225
261
|
|
|
226
262
|
The authenticated atomic-edit smoke is separately gated and refuses to run
|
|
227
263
|
without an explicit route, config path, credential variable name, opt-in, and
|
data/agent-cli-runtime.gemspec
CHANGED
|
@@ -5,13 +5,14 @@ Gem::Specification.new do |spec|
|
|
|
5
5
|
spec.version = AgentCliRuntime::VERSION
|
|
6
6
|
spec.authors = [ "Ivan Kuznetsov" ]
|
|
7
7
|
spec.email = [ "ivan@ikuznetsov.com" ]
|
|
8
|
-
spec.summary =
|
|
8
|
+
spec.summary =
|
|
9
|
+
"One Ruby API for Claude Code, Codex CLI, Pi, Grok CLI, and OpenCode"
|
|
9
10
|
spec.description = <<~DESC
|
|
10
|
-
Agent CLI Runtime
|
|
11
|
-
local
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
Agent CLI Runtime gives Ruby applications one stable integration layer for
|
|
12
|
+
local coding-agent CLIs. It builds provider-specific commands from a shared
|
|
13
|
+
request model, checks local versions and named capabilities, and normalizes
|
|
14
|
+
usage and results afterward. Centralized profiles, environment rules, and
|
|
15
|
+
parsers make agent providers easier to add, switch, and upgrade.
|
|
15
16
|
DESC
|
|
16
17
|
spec.homepage = "https://github.com/ivankuznetsov/agent-cli-runtime"
|
|
17
18
|
spec.license = "MIT"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module AgentCliRuntime
|
|
2
|
+
# Provider-side failures that a CLI reports on its event stream while still
|
|
3
|
+
# exiting zero. Each profile owns the shape its own CLI emits. Extractors
|
|
4
|
+
# return either the raw provider text, an ExtractedFailure, or nil when the
|
|
5
|
+
# event carries no failure. Runtime.extract_provider_error normalizes and
|
|
6
|
+
# redacts the result.
|
|
7
|
+
module ErrorExtractors
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# CLIs that name the failure with a dedicated event type. This is the same
|
|
11
|
+
# set Hive scanned for inline before provider shape moved behind the
|
|
12
|
+
# profile, so wiring a profile to DEFAULT preserves its behaviour.
|
|
13
|
+
DEFAULT = lambda do |event|
|
|
14
|
+
next nil unless event.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
case event["type"]
|
|
17
|
+
when "error", "turn.failed", "rate_limit_event"
|
|
18
|
+
ErrorExtractors.message_from(event)
|
|
19
|
+
when "result"
|
|
20
|
+
if event["is_error"] == true || event["subtype"].to_s.start_with?("error")
|
|
21
|
+
ErrorExtractors.message_from(event)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Claude's subscription wall is a dedicated structured event with no
|
|
27
|
+
# human-readable message. The generic extractor therefore returned nil,
|
|
28
|
+
# leaving callers to flatten a rejected five-hour/seven-day window into a
|
|
29
|
+
# generic exit-code failure. Accept only the exact rejected account-window
|
|
30
|
+
# shapes observed from Claude Code and synthesize bounded provider text for
|
|
31
|
+
# the normal typed limit path. Other Claude errors retain DEFAULT behavior.
|
|
32
|
+
CLAUDE_ACCOUNT_LIMIT_TYPES = %w[five_hour seven_day].freeze
|
|
33
|
+
CLAUDE = lambda do |event|
|
|
34
|
+
if event.is_a?(Hash) && event["type"] == "rate_limit_event"
|
|
35
|
+
info = event["rate_limit_info"]
|
|
36
|
+
if info.is_a?(Hash) && info["status"] == "rejected"
|
|
37
|
+
limit_type = info["rateLimitType"].to_s
|
|
38
|
+
if CLAUDE_ACCOUNT_LIMIT_TYPES.include?(limit_type)
|
|
39
|
+
next ExtractedFailure.new(
|
|
40
|
+
kind: :provider_limit,
|
|
41
|
+
message: "Claude account quota reached (#{limit_type})"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
DEFAULT.call(event)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# pi keeps the envelope type ("message_start"/"message_end") and moves the
|
|
51
|
+
# terminal state into stopReason. Provider refusals use
|
|
52
|
+
# stopReason=error/errorMessage. A model that consumes its entire output
|
|
53
|
+
# allowance uses stopReason=length and can exit zero without executing the
|
|
54
|
+
# final write tool. Matching on type alone sees neither failure.
|
|
55
|
+
PI = lambda do |event|
|
|
56
|
+
next nil unless event.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
message = event["message"].is_a?(Hash) ? event["message"] : event
|
|
59
|
+
case message["stopReason"].to_s
|
|
60
|
+
when "error"
|
|
61
|
+
ErrorExtractors.message_from(message) || ErrorExtractors.message_from(event)
|
|
62
|
+
when "length"
|
|
63
|
+
ExtractedFailure.new(
|
|
64
|
+
kind: :model_output_limit,
|
|
65
|
+
message: "model response reached its maximum output tokens"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# OpenCode keeps provider failures under error.data.message rather than
|
|
71
|
+
# the top-level/message and error.message shapes used by the default
|
|
72
|
+
# extractor. Read only the dedicated error event so ordinary model text
|
|
73
|
+
# cannot forge a provider-limit signal.
|
|
74
|
+
OPENCODE = lambda do |event|
|
|
75
|
+
next nil unless event.is_a?(Hash) && event["type"] == "error"
|
|
76
|
+
|
|
77
|
+
data = event.dig("error", "data")
|
|
78
|
+
message = data["message"] if data.is_a?(Hash)
|
|
79
|
+
message if message.is_a?(String) && !message.strip.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def message_from(event)
|
|
83
|
+
return nil unless event.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
candidates = [
|
|
86
|
+
event["errorMessage"],
|
|
87
|
+
event["message"],
|
|
88
|
+
event.dig("error", "message"),
|
|
89
|
+
event["error"]
|
|
90
|
+
]
|
|
91
|
+
text = candidates.find { |value| value.is_a?(String) && !value.strip.empty? }
|
|
92
|
+
text&.strip
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -66,12 +66,26 @@ module AgentCliRuntime
|
|
|
66
66
|
preparation.credential_environment_keys
|
|
67
67
|
)
|
|
68
68
|
plugins = selected_plugins(source_config, preparation.plugins)
|
|
69
|
-
permission = permission_rules(preparation, roots, plugins: plugins)
|
|
70
69
|
|
|
71
70
|
root = create_root!(preparation.invocation_root)
|
|
72
71
|
cleanup = Cleanup.new(root)
|
|
73
72
|
begin
|
|
74
73
|
paths = create_directories(root)
|
|
74
|
+
permission = Permissions.compile(
|
|
75
|
+
permission_mode: preparation.request.permission_mode,
|
|
76
|
+
permission_policy: preparation.permission_policy,
|
|
77
|
+
working_directory: roots.fetch(:working),
|
|
78
|
+
additional_read_roots: roots.fetch(:read),
|
|
79
|
+
additional_write_roots: roots.fetch(:write),
|
|
80
|
+
edit_patterns: preparation.edit_patterns,
|
|
81
|
+
bash_patterns: preparation.bash_patterns,
|
|
82
|
+
plugins: plugins,
|
|
83
|
+
runtime_write_roots: [ paths.fetch(:temporary) ]
|
|
84
|
+
)
|
|
85
|
+
unless permission
|
|
86
|
+
raise ConfigurationError,
|
|
87
|
+
"OpenCode requires an explicit OpenCode permission policy when permission_mode is nil"
|
|
88
|
+
end
|
|
75
89
|
pure = preparation.pure && plugins.empty?
|
|
76
90
|
config = generated_configuration(
|
|
77
91
|
source_config, plugins, requested_route, permission
|
|
@@ -91,9 +105,6 @@ module AgentCliRuntime
|
|
|
91
105
|
)
|
|
92
106
|
executable =
|
|
93
107
|
preparation.request.executable || profile.bin(env: env)
|
|
94
|
-
probe_env = env.to_h.merge(
|
|
95
|
-
"AGENT_CLI_RUNTIME_OPENCODE_BIN" => executable
|
|
96
|
-
)
|
|
97
108
|
probe_request = ProbeRequest.new(
|
|
98
109
|
profile: profile,
|
|
99
110
|
route: requested_route,
|
|
@@ -103,9 +114,12 @@ module AgentCliRuntime
|
|
|
103
114
|
credential_file_staged:
|
|
104
115
|
staged_credential && credential_file_supports_provider?(
|
|
105
116
|
staged_credential.last, requested_route.provider
|
|
106
|
-
)
|
|
117
|
+
),
|
|
118
|
+
configured_variants:
|
|
119
|
+
configured_variants(source_config, requested_route),
|
|
120
|
+
executable: executable
|
|
107
121
|
)
|
|
108
|
-
probe_result = OpenCode::Probe.call!(probe_request, env:
|
|
122
|
+
probe_result = OpenCode::Probe.call!(probe_request, env: env)
|
|
109
123
|
invocation = compile_invocation(
|
|
110
124
|
preparation, profile, requested_route, roots,
|
|
111
125
|
executable:, pure: pure,
|
|
@@ -175,6 +189,21 @@ module AgentCliRuntime
|
|
|
175
189
|
end
|
|
176
190
|
private_class_method :validate_provider!
|
|
177
191
|
|
|
192
|
+
# A selected OpenCode configuration may declare a custom model that is
|
|
193
|
+
# newer than the CLI's bundled provider catalog. With model fetching
|
|
194
|
+
# disabled for hermetic launches, that declaration is durable route
|
|
195
|
+
# evidence; the large `models --verbose` inventory is complementary,
|
|
196
|
+
# not its replacement.
|
|
197
|
+
def configured_variants(config, route)
|
|
198
|
+
models = config.dig("provider", route.provider, "models")
|
|
199
|
+
return nil unless models.is_a?(Hash) && models.key?(route.model)
|
|
200
|
+
|
|
201
|
+
definition = models.fetch(route.model)
|
|
202
|
+
variants = definition.is_a?(Hash) ? definition["variants"] : nil
|
|
203
|
+
variants.is_a?(Hash) ? variants.keys.sort.freeze : [].freeze
|
|
204
|
+
end
|
|
205
|
+
private_class_method :configured_variants
|
|
206
|
+
|
|
178
207
|
def validate_nonsecret!(value, key = nil)
|
|
179
208
|
case value
|
|
180
209
|
when Hash
|
|
@@ -244,120 +273,6 @@ module AgentCliRuntime
|
|
|
244
273
|
end
|
|
245
274
|
private_class_method :resolve_roots
|
|
246
275
|
|
|
247
|
-
def permission_rules(preparation, roots, plugins: preparation.plugins)
|
|
248
|
-
mode = preparation.request.permission_mode
|
|
249
|
-
if mode.nil?
|
|
250
|
-
policy = preparation.permission_policy
|
|
251
|
-
unless policy
|
|
252
|
-
raise ConfigurationError,
|
|
253
|
-
"OpenCode requires an explicit OpenCode permission policy when permission_mode is nil"
|
|
254
|
-
end
|
|
255
|
-
return deep_copy(policy.rules)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
unless %w[read-only workspace-write].include?(mode)
|
|
259
|
-
raise ConfigurationError,
|
|
260
|
-
"unsupported OpenCode permission mode"
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
external = { "*" => "deny" }
|
|
264
|
-
(roots.fetch(:read) + roots.fetch(:write)).uniq.each do |root|
|
|
265
|
-
external[root] = "allow"
|
|
266
|
-
external["#{root}/**"] = "allow"
|
|
267
|
-
end
|
|
268
|
-
common = {
|
|
269
|
-
"*" => "deny",
|
|
270
|
-
"read" => {
|
|
271
|
-
"*" => "allow",
|
|
272
|
-
"*.env" => "deny",
|
|
273
|
-
"*.env.*" => "deny",
|
|
274
|
-
"*.env.example" => "allow"
|
|
275
|
-
},
|
|
276
|
-
"glob" => "allow",
|
|
277
|
-
"grep" => "allow",
|
|
278
|
-
"list" => "allow",
|
|
279
|
-
"lsp" => "allow",
|
|
280
|
-
"skill" => plugins.empty? ? "deny" : "allow",
|
|
281
|
-
"external_directory" => external
|
|
282
|
-
}
|
|
283
|
-
if mode == "read-only"
|
|
284
|
-
return common.merge(
|
|
285
|
-
"edit" => "deny", "bash" => "deny", "task" => "deny",
|
|
286
|
-
"webfetch" => "deny", "websearch" => "deny",
|
|
287
|
-
"question" => "deny"
|
|
288
|
-
)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# OpenCode applies the last matching permission rule. Start from a
|
|
292
|
-
# denial and append only the invocation's declared write roots. The
|
|
293
|
-
# working directory is an implicit write root for workspace-write;
|
|
294
|
-
# additional read roots remain explicitly denied unless the caller
|
|
295
|
-
# also declared them writable.
|
|
296
|
-
writable_roots = [ roots.fetch(:working), *roots.fetch(:write) ].uniq
|
|
297
|
-
edit = { "*" => "deny" }
|
|
298
|
-
allows = if preparation.edit_patterns.empty?
|
|
299
|
-
writable_roots.flat_map do |root|
|
|
300
|
-
edit_patterns(root, working: roots.fetch(:working))
|
|
301
|
-
end
|
|
302
|
-
else
|
|
303
|
-
normalize_declared_edit_patterns(
|
|
304
|
-
preparation.edit_patterns, writable_roots,
|
|
305
|
-
working: roots.fetch(:working)
|
|
306
|
-
)
|
|
307
|
-
end
|
|
308
|
-
allows.each do |pattern|
|
|
309
|
-
edit[pattern] = "allow"
|
|
310
|
-
end
|
|
311
|
-
(roots.fetch(:read) - roots.fetch(:write)).each do |root|
|
|
312
|
-
edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
|
|
313
|
-
edit[pattern] = "deny"
|
|
314
|
-
end
|
|
315
|
-
end
|
|
316
|
-
common.merge(
|
|
317
|
-
"edit" => edit, "bash" => "deny", "task" => "deny",
|
|
318
|
-
"webfetch" => "deny", "websearch" => "deny",
|
|
319
|
-
"question" => "deny"
|
|
320
|
-
)
|
|
321
|
-
end
|
|
322
|
-
private_class_method :permission_rules
|
|
323
|
-
|
|
324
|
-
def edit_patterns(root, working:)
|
|
325
|
-
if root == working
|
|
326
|
-
[ "**" ]
|
|
327
|
-
elsif root.start_with?(working + File::SEPARATOR)
|
|
328
|
-
relative = root.delete_prefix(working + File::SEPARATOR)
|
|
329
|
-
[ relative, "#{relative}/**" ]
|
|
330
|
-
else
|
|
331
|
-
[ root, "#{root}/**" ]
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
private_class_method :edit_patterns
|
|
335
|
-
|
|
336
|
-
def normalize_declared_edit_patterns(patterns, writable_roots, working:)
|
|
337
|
-
patterns.map do |value|
|
|
338
|
-
pattern = value.sub(%r{\A//}, "/")
|
|
339
|
-
unless File.absolute_path?(pattern) && !pattern.include?("\0")
|
|
340
|
-
raise ConfigurationError,
|
|
341
|
-
"OpenCode edit patterns must be absolute path patterns"
|
|
342
|
-
end
|
|
343
|
-
literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
|
|
344
|
-
unless writable_roots.any? do |root|
|
|
345
|
-
literal_prefix == root || literal_prefix.start_with?(root + File::SEPARATOR)
|
|
346
|
-
end
|
|
347
|
-
raise ConfigurationError,
|
|
348
|
-
"OpenCode edit pattern is outside the declared write roots"
|
|
349
|
-
end
|
|
350
|
-
if pattern == working
|
|
351
|
-
"**"
|
|
352
|
-
elsif pattern.start_with?(working + File::SEPARATOR)
|
|
353
|
-
pattern.delete_prefix(working + File::SEPARATOR)
|
|
354
|
-
else
|
|
355
|
-
pattern
|
|
356
|
-
end
|
|
357
|
-
end.uniq.freeze
|
|
358
|
-
end
|
|
359
|
-
private_class_method :normalize_declared_edit_patterns
|
|
360
|
-
|
|
361
276
|
def create_root!(value)
|
|
362
277
|
path = File.expand_path(value)
|
|
363
278
|
unless File.absolute_path?(value.to_s)
|