activeagent 1.9.4 → 1.9.6

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: 715323643179a060b32d624ba906586778645e818c0436d4e4efe9ea6585847e
4
- data.tar.gz: 3efe81e8a8d5357baf0f46adcfa5ce2fedd96234bb8ed603ffae63bac17bec58
3
+ metadata.gz: 9f73d1c3c9a6dffc5bf68d07e59a21a88e40eef1ab92da228688e4f1e8148658
4
+ data.tar.gz: caf1a01b8e53a7a6e9685e39162c38f88fed355fe9bef46f8850a1c4b313a09b
5
5
  SHA512:
6
- metadata.gz: 4a0ca1235fb0e8290ad261b61d77e38b4ee87372158384644612d54e81742f9f743968c8b8fa2c4e5cbebd57b62cdba7b0b0efbede6b3ad78176a7d7cca8dc37
7
- data.tar.gz: 8fc1c553e0a008287280492dc401efccf1fdd675417985ef8acd8df3cb86ebe263a7cf6066b1740c8c387c1eb6de92ffffe428c5024b33a3ddf0464e729202a3
6
+ metadata.gz: '02886d6a6c6d30366ae19b1a2877c3969a39b814896a942f67e4c6beca14fa53c5169dff447adcb956039e8c6358d2903969d9c254e8475c8c0af467b66770e6'
7
+ data.tar.gz: 5cf3b2847e796674db3033a0745ab1e549a54ede83ddee48fb6f3edb8bd33d7256db17267a3ff42935aba39f1c07e0f2e8832bf9e7578216825176919271898d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,191 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.6] - 2026-10-29
9
+
10
+ The dashboard on a phone, and the small finished pieces: the sidebar as a
11
+ drawer and the interactions, agents, evaluations and traces views laid out
12
+ for a phone's width; agents and evaluations first in the sidebar; the
13
+ appearance following the OS, with the agent interactions page dark when the
14
+ dashboard is; the results that moved since the run before marked in the
15
+ step box; and a project page that says why its sandbox backend did not
16
+ answer instead of failing to load. All of it is `actionagent`; the
17
+ framework gem moves to 1.9.6 with it and is otherwise unchanged.
18
+
19
+ Upgrading: no migration and nothing to run; `bundle update activeagent
20
+ actionagent`.
21
+
22
+ ### Added
23
+
24
+ - **The dashboard follows the system appearance** (`actionagent`). Settings
25
+ → Appearance offers System, Light and Dark; System (the default when
26
+ nothing was chosen) follows the OS light/dark setting and changes with it
27
+ while the page is open. Light and Dark are fixed, as the old toggle's
28
+ choices were, and a choice that toggle saved still holds. The header's
29
+ sun/moon button flips to the opposite of what shows and saves it as a
30
+ fixed choice; pick System on the settings page to follow the OS again.
31
+ - **What changed since the last run** (`actionagent`). A scenario suite's
32
+ step box marks each result that moved against the latest earlier complete
33
+ run sharing one of its models: ▲ where a failure now passes, ▼ where a pass
34
+ now fails, ○ where the replay errored this time. The cell's tooltip names
35
+ the earlier result and run, and a `changed vs #N` chip counts each kind and
36
+ filters the box to the scenarios that moved.
37
+
38
+ ### Changed
39
+
40
+ - **The dashboard works on a phone** (`actionagent`; closes #653). Below
41
+ 768px the sidebar is a drawer behind a menu button in the header, and the
42
+ interactions, agents, evaluations and traces views lay out for the width:
43
+ headers, controls, chart legends and stat tiles wrap or stack; the
44
+ fixed-width meta strips fold into their cards; the agent interactions page
45
+ shows one pane at a time, with a switch between the runs and the open run;
46
+ the new-interaction picker is a bottom sheet; the agent editor's tab strip
47
+ and the runs × scenarios matrix scroll inside their boxes, the matrix with
48
+ a sticky first column; and nothing scrolls the page sideways. The folds
49
+ that sit beside the fixed sidebar switch below 1024px instead, since at
50
+ 768px the content column is phone-sized. From 1024px up the layout is as
51
+ it was. Layout only: no route, path, view or Ruby change.
52
+ - **Agents and evaluations lead the sidebar** (`actionagent`). The first
53
+ group is Ask ActiveAgents, Agents, New Agent, Run Agents, Evaluations and
54
+ Scenarios; Observability keeps Traces, Interactions, Sessions and Metrics;
55
+ a new Build group holds Projects, Tools and MCP Services; Workspace is
56
+ unchanged. Every path and view is the same, only the order and grouping.
57
+
58
+ ### Fixed
59
+
60
+ - **The agent interactions page follows the dark theme** (`actionagent`). It
61
+ was written in Tailwind's grey scale (white panes, grey text) and stayed
62
+ light under the dark theme at every width, with its run stream rendered
63
+ light too. Its surfaces, borders and text now use the dashboard's tokens,
64
+ as `aa-*` utilities in `dashboard.css` that take Tailwind's variants; its
65
+ source and run/sessions toggles are the shared segmented control; and the
66
+ stream renders in the theme that shows.
67
+ - **A sandbox backend that cannot report a boot no longer breaks the project
68
+ page** (`actionagent`; #650). `GET /api/projects/:id/boot` answered 500
69
+ whenever the backend raised while reading the boot (no sandbox host
70
+ configured, the host unreachable, a container that had not written its
71
+ state yet), so the project page showed "Could not load the project" under
72
+ "sandbox booting" and stopped following the boot. It now answers 200 with
73
+ `boot` null and `backend_error` naming the backend and the scrubbed
74
+ reason; the page says no sandbox backend answered, with that reason,
75
+ beside the boot's own failure when it failed. `GET
76
+ /api/sandboxes/:session_id/boot` answers the same way.
77
+
78
+ ## [1.9.5] - 2026-10-28
79
+
80
+ The `activeagent` command line: the gem's third surface beside the dashboard
81
+ and its API, working with a dashboard over HTTP from a terminal or a CI job,
82
+ and the facade tools it needed. A run, a scenario catalog and a trace each
83
+ move between installs as one file. The framework and the dashboard engine
84
+ move to 1.9.5 together.
85
+
86
+ Upgrading: `rails generate action_agent:install` and `rails db:migrate` add
87
+ the GitHub App webhook deliveries table (below). The CLI needs `actionagent`
88
+ 1.9.5 on the dashboard for `catalogs export`, `catalogs import --preview`,
89
+ `agents versions` and `traces export` (the facade's new tools), and 1.9.3
90
+ for `evals report` (the report export); an older dashboard answers those
91
+ commands with exit code 5.
92
+
93
+ ### Added
94
+
95
+ - **Evaluation runs published while they run** (`activeagent`, `actionagent`).
96
+ `ActiveAgent::Evals::Publisher#progress` sends the results so far as a
97
+ `"status": "running"` envelope, and the collector stores them as a running
98
+ run that the Evaluations page follows live; each later delivery under the
99
+ run_id replaces the results until `#call` completes the run. Only the first
100
+ delivery is admitted and counted. A progress delivery after completion
101
+ changes nothing. The collector lists `"progress"` in the `capabilities` of
102
+ its readiness answer, and `#progress` sends nothing to a collector that does
103
+ not (`#supports_progress?`), since an older one would store the partial
104
+ report as complete.
105
+ - **Imported evaluation runs keep their dates** (`actionagent`). The
106
+ collector dates a run by its report's `metadata.finished`, which an export
107
+ carries, so an exported run imported into another install keeps its place
108
+ in the run history instead of reading as just run. A report naming no
109
+ finish time is dated on arrival, and a malformed one is refused.
110
+ - **`activeagent` command line** (`activeagent`, `actionagent`; refs #619).
111
+ The gem ships an `activeagent` executable (stdlib `OptionParser` and
112
+ `Net::HTTP`, no new dependencies) that works with a dashboard over HTTP:
113
+ `login`/`logout` (named profiles in `~/.config/activeagent/credentials.yml`,
114
+ `0600`; `ACTIVEAGENT_URL`, `ACTIVEAGENT_API_KEY` and
115
+ `ACTIVEAGENT_INGEST_KEY` override them for CI), `status`,
116
+ `evals list|runs|run|report|import`, `catalogs list|import|export`
117
+ (`import --preview` shows what a document would change, `--mode merge`
118
+ keeps what it leaves out), `agents list|versions` and
119
+ `traces list|get|export|import`, with `--json` output and distinct exit
120
+ codes. `evals run --min-pass-rate 0.8` waits for the run and exits 4
121
+ below the threshold, for CI gating. Reads and runs go through the MCP
122
+ facade's dashboard tools with a dashboard API key; the imports and
123
+ `evals report` use the endpoints the framework's own reporters post to,
124
+ with the ingest key. A run, a catalog and a trace each move between
125
+ installs as one file: `evals report RUN --format json` writes the
126
+ version-1 envelope `evals import` posts (`--format html|md` the report
127
+ page or its Markdown, for a pull request comment), `catalogs export KEY`
128
+ the canonical document (YAML, or `--format json`) that `catalogs import`
129
+ reads, and `traces export TRACE` the payload `traces import` sends to
130
+ trace ingest. See `docs/framework/cli.md`.
131
+
132
+ - **Four more dashboard tools on the MCP facade** (`actionagent`), the
133
+ API-key routes the CLI needed: `catalogs_export` (a catalog's canonical
134
+ document as YAML or JSON, whole or one product or set), `catalogs_preview`
135
+ (what `catalogs_import` would change, writing nothing), `agents_versions`
136
+ (an agent's versions with their diffs, as `GET /api/agents/:id/versions`)
137
+ and `traces_export` (one trace, whole and untruncated, as the payload
138
+ `POST <mount>/api/traces` takes, so a trace moves between installs).
139
+ `catalogs_import` takes `mode: replace|merge` as the API does. Every run
140
+ summary (`GET /api/evaluations`, the facade's `evaluations_get`,
141
+ `evaluations_run` and `evaluation_runs_get`) now carries `run_id`, the
142
+ handle `GET <mount>/api/evaluation_reports/:run_id` takes.
143
+
144
+ - **GitHub App webhooks** (`actionagent`). `POST <mount>/api/github_app/webhooks`
145
+ receives the App's deliveries, verified with `X-Hub-Signature-256` under
146
+ `ActionAgent.github_app_webhook_secret` (falling back to
147
+ `GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies
148
+ them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked
149
+ installation removed, suspend and unsuspend set and clear `suspended_at`,
150
+ accepted permissions are recorded, a repository removed from the
151
+ installation leaves the owner's selection, and a revoked App authorization
152
+ deletes the user's stored connection when connections authorize through the
153
+ App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery
154
+ is a no-op. Ships a new migration,
155
+ `create_active_agent_github_webhook_deliveries`; run
156
+ `rails generate action_agent:install` and `rails db:migrate` to add it.
157
+
158
+ ### Fixed
159
+
160
+ - **Sandbox sign-in recognises Claude Code 2.1's authorization page**
161
+ (`actionagent`). "Sign in with your Claude subscription" on a local sandbox
162
+ stayed at "Preparing Claude's authorization page": the CLI now sends
163
+ sign-ins to `claude.com/cai/oauth/authorize` and prints the link as a
164
+ terminal hyperlink whose text repeats the URL, which the flow neither
165
+ accepted nor cut at the hyperlink's end. Both are read now, and
166
+ `claude.ai` and `platform.claude.com` still are.
167
+ - **Projects accept GitHub App installations** (`actionagent`). A dashboard
168
+ that runs only the GitHub App could link an installation but not create a
169
+ project ("GitHub is not connected"). The New Project checklist now counts a
170
+ usable installation as connected, `GET /api/projects/repositories` lists
171
+ the installations' repositories together with the OAuth connection's, and
172
+ the preflight, secret discovery and creation reach a repository through the
173
+ installation first, as its checkout does, with a token minted for reading
174
+ that one repository. Picking a repository the installation has not
175
+ selected selects it there. A removed or suspended installation answers
176
+ `installation_unavailable`.
177
+ - **Report a missing Members permission when linking an organization's
178
+ GitHub App installation** (`actionagent`). GitHub answers the membership
179
+ check with 403 while the App lacks the organization permission Members
180
+ (read); the callback now reports `members_permission` instead of
181
+ `not_admin`. The dashboard guide documents the permission, and that the
182
+ dashboard's callback URL must be the App's first.
183
+
184
+ - **Boot on Ruby 3.4 and 4.0 with bootsnap 1.24.6 or newer** (`actionagent`).
185
+ `ProviderKey#owner_record` unwrapped delegators with
186
+ `... while defined?(::Delegator) && ...`, which Prism miscompiles when the
187
+ source is compiled from a string ("argument stack underflow"); bootsnap
188
+ compiles files that way on those Rubies as its Ruby bug #22023 workaround,
189
+ so a host on bootsnap 1.26 (and local runs on Ruby 3.4.9 or 4.0.1) failed
190
+ to boot the engine. The `defined?` check now sits outside the loop, and a
191
+ test compiles every shipped Ruby file from a string.
192
+
8
193
  ## [1.9.4] - 2026-10-25
9
194
 
10
195
  ### Added
data/README.md CHANGED
@@ -181,7 +181,12 @@ telemetry:
181
181
  The generator mounts the engine at `/activeagents` — open it and every
182
182
  generation appears as a trace. See
183
183
  [docs/framework/dashboard.md](docs/framework/dashboard.md) for
184
- authentication, remote ingestion, and multi-tenant mode. The hosted
184
+ authentication, remote ingestion, and multi-tenant mode. The gem's
185
+ `activeagent` command line works with that dashboard from a terminal or a
186
+ CI job — `activeagent evals run "Support orders" --min-pass-rate 0.8` fails
187
+ a build below the threshold, and runs, scenario catalogs and traces move
188
+ between installs as JSON files; see
189
+ [docs/framework/cli.md](docs/framework/cli.md). The hosted
185
190
  platform at [activeagents.ai](https://activeagents.ai) runs this same
186
191
  engine multi-tenant, adding what a hosted product has to have — accounts,
187
192
  plans, billing, quotas and managed sandboxes; every workspace starts with
@@ -198,6 +203,7 @@ a free low-volume trial.
198
203
  - **Agent-as-Tool Delegation**: Hand work to sub-agents with declared schemas, cost/latency budgets, and swappable backends
199
204
  - **Context Management**: Maintain conversation history across interactions
200
205
  - **Structured Output**: Define JSON schemas for predictable responses
206
+ - **Command Line**: `activeagent` runs evaluations and gates CI on a pass rate from a terminal, and moves runs, scenario catalogs and traces between dashboards as JSON
201
207
 
202
208
  ## Examples
203
209
 
data/exe/activeagent ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The activeagent CLI. Loads only lib/active_agent/cli, not Rails or the
5
+ # framework. See docs/framework/cli.md.
6
+ require_relative "../lib/active_agent/cli"
7
+
8
+ exit ActiveAgent::CLI.start(ARGV)
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "zlib"
8
+ require_relative "../evals/publisher"
9
+
10
+ module ActiveAgent
11
+ class CLI
12
+ # HTTP to one dashboard mount, with the conventions of
13
+ # ActiveAgent::Evals::Publisher: blocking, bounded timeouts, no redirects
14
+ # followed with a bearer key, and errors that never quote a response body
15
+ # beyond its sanitized +error+ string, with the keys filtered out.
16
+ #
17
+ # The dashboard authenticates two kinds of key, and this client sends
18
+ # each where it belongs:
19
+ #
20
+ # - +api_key+, a dashboard API key (Settings -> API Keys, "aa_..."), for
21
+ # the MCP facade at <mount>/mcp, whose dashboard tools back most
22
+ # commands.
23
+ # - +ingest_key+, the install's ingest key (ActionAgent.ingest_api_key,
24
+ # or the tenant's telemetry key), for the endpoints the framework's
25
+ # reporters post to: the report collector at
26
+ # <mount>/api/evaluation_reports and trace ingest at <mount>/api/traces.
27
+ # Defaults to +api_key+.
28
+ class Client
29
+ DETAIL_LIMIT = 200
30
+ JSONRPC_INVALID_PARAMS = -32602
31
+
32
+ attr_reader :url
33
+
34
+ def initialize(url:, api_key: nil, ingest_key: nil, timeout: 30, open_timeout: 10)
35
+ @uri = self.class.parse_url(url)
36
+ @url = @uri.to_s
37
+ @api_key = api_key.to_s.strip
38
+ @ingest_key = ingest_key.to_s.strip.empty? ? @api_key : ingest_key.to_s.strip
39
+ @timeout = timeout
40
+ @open_timeout = open_timeout
41
+ @request_id = 0
42
+ end
43
+
44
+ # The dashboard mount as a URI, without a trailing slash. HTTPS is
45
+ # required except on loopback hosts, as for the publisher.
46
+ def self.parse_url(url)
47
+ uri = URI.parse(url.to_s.strip.sub(%r{/+\z}, ""))
48
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
49
+ raise Error.new("The dashboard URL must be an HTTP(S) URL without credentials, query or fragment, " \
50
+ "such as https://ops.example.com/activeagents", exit_code: EXIT_USAGE)
51
+ end
52
+ unless uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
53
+ raise Error.new("The dashboard URL requires HTTPS except on loopback hosts", exit_code: EXIT_USAGE)
54
+ end
55
+ uri
56
+ rescue URI::InvalidURIError
57
+ raise Error.new("The dashboard URL is not a valid URL", exit_code: EXIT_USAGE)
58
+ end
59
+
60
+ def mcp_endpoint
61
+ "#{url}/mcp"
62
+ end
63
+
64
+ def reports_endpoint
65
+ "#{url}/api/evaluation_reports"
66
+ end
67
+
68
+ def traces_endpoint
69
+ "#{url}/api/traces"
70
+ end
71
+
72
+ # A Publisher for the mount's collector, authenticated with the ingest key.
73
+ def publisher
74
+ require_key!(@ingest_key, "an ingest key")
75
+ ActiveAgent::Evals::Publisher.new(api_key: @ingest_key, endpoint: reports_endpoint, timeout: @timeout, open_timeout: @open_timeout)
76
+ rescue ArgumentError => e
77
+ raise Error.new(e.message, exit_code: EXIT_CONFIG)
78
+ end
79
+
80
+ # Sends one JSON-RPC request to the MCP facade and returns its result.
81
+ def mcp(method, params = {})
82
+ require_key!(@api_key, "a dashboard API key")
83
+ @request_id += 1
84
+ body = JSON.generate(jsonrpc: "2.0", id: @request_id, method: method, params: params)
85
+ response = exchange(:post, URI(mcp_endpoint), @api_key, body: body)
86
+
87
+ case response.code
88
+ when "200" then nil
89
+ when "401" then raise refused("the dashboard refused the API key; create one under Settings -> API Keys", response, @api_key, 401)
90
+ when "404" then raise refused("nothing at #{mcp_endpoint} answers MCP; check the dashboard URL (the engine's mount)", response, @api_key, 404)
91
+ else raise refused("the dashboard's MCP endpoint answered HTTP #{response.code}", response, @api_key, response.code.to_i)
92
+ end
93
+
94
+ payload = parse_json(response.body)
95
+ if (error = payload["error"])
96
+ raise Error.new("#{method} failed: #{sanitize(error["message"], @api_key) || "error #{error["code"]}"}", status: error["code"])
97
+ end
98
+ payload["result"]
99
+ end
100
+
101
+ # Calls one of the dashboard's MCP tools and returns its structured
102
+ # result. A tool's own refusal (isError) raises with its message; a
103
+ # dashboard tool the facade does not list means the install turned
104
+ # them off (ActionAgent.mcp_dashboard_tools = false) or predates them.
105
+ def tool(name, arguments = {})
106
+ result = mcp("tools/call", name: name, arguments: arguments.compact)
107
+ structured = result.is_a?(Hash) ? result["structuredContent"] : nil
108
+ if result.is_a?(Hash) && result["isError"]
109
+ message = structured.is_a?(Hash) && structured["error"] || Array(result["content"]).filter_map { |part| part["text"] if part.is_a?(Hash) }.join(" ")
110
+ raise Error.new("#{name}: #{sanitize(message, @api_key) || "the tool refused the call"}")
111
+ end
112
+ structured.is_a?(Hash) ? structured : {}
113
+ rescue Error => e
114
+ raise unless e.status == JSONRPC_INVALID_PARAMS && e.message.include?("Unknown tool")
115
+
116
+ raise Error.new("This dashboard does not offer the #{name} tool over MCP: it predates the dashboard tools, or " \
117
+ "the install sets ActionAgent.mcp_dashboard_tools = false", exit_code: EXIT_UNAVAILABLE)
118
+ end
119
+
120
+ # Rails' own 404 body, from a dashboard whose routes have no such path.
121
+ GENERIC_NOT_FOUND = /\Anot found\z/i
122
+
123
+ # GETs +path+ under the mount with the ingest key and returns the
124
+ # response, raising on anything but 200. A 404 is one of two things:
125
+ # the endpoint's own refusal, a JSON error naming what is missing,
126
+ # raised as +missing+ with that detail; or nothing at the path at all
127
+ # (an older engine), raised as +unavailable+ with EXIT_UNAVAILABLE.
128
+ def get(path, accept:, query: nil, missing: nil, unavailable: nil)
129
+ require_key!(@ingest_key, "an ingest key")
130
+ uri = URI("#{url}#{path}")
131
+ uri.query = query if query
132
+ response = exchange(:get, uri, @ingest_key, accept: accept)
133
+ return response if response.code == "200"
134
+
135
+ case response.code
136
+ when "404"
137
+ detail = error_detail(response, @ingest_key)
138
+ raise Error.new("#{missing} (#{detail})", status: 404) if missing && detail && !detail.match?(GENERIC_NOT_FOUND)
139
+
140
+ raise Error.new(unavailable || "Nothing at #{path} answers; check the dashboard URL", status: 404, exit_code: EXIT_UNAVAILABLE)
141
+ when "401"
142
+ raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
143
+ else
144
+ raise refused("GET #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
145
+ end
146
+ end
147
+
148
+ # POSTs +body+, a JSON string, to +path+ under the mount with the
149
+ # ingest key and returns the response, raising on anything but a 2xx.
150
+ def post(path, body)
151
+ require_key!(@ingest_key, "an ingest key")
152
+ response = exchange(:post, URI("#{url}#{path}"), @ingest_key, body: body)
153
+ return response if response.code.start_with?("2")
154
+
155
+ if response.code == "401"
156
+ raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
157
+ end
158
+ raise refused("POST #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
159
+ end
160
+
161
+ private
162
+
163
+ def require_key!(key, label)
164
+ return unless key.empty?
165
+
166
+ raise Error.new("No #{label}: run `activeagent login`, or set ACTIVEAGENT_API_KEY", exit_code: EXIT_CONFIG)
167
+ end
168
+
169
+ def exchange(verb, uri, key, body: nil, accept: "application/json")
170
+ http = Net::HTTP.new(uri.hostname, uri.port)
171
+ http.use_ssl = uri.scheme == "https"
172
+ http.open_timeout = @open_timeout
173
+ http.read_timeout = @timeout
174
+ http.write_timeout = @timeout
175
+ request = (verb == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri.request_uri)
176
+ request["Authorization"] = "Bearer #{key}"
177
+ request["Accept"] = accept
178
+ request["User-Agent"] = "activeagent-cli/#{ActiveAgent::CLI.version}"
179
+ if body
180
+ request["Content-Type"] = "application/json"
181
+ request.body = body
182
+ end
183
+ http.request(request)
184
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error => e
185
+ # These messages can quote the response's status line, headers or body.
186
+ raise Error.new("The dashboard returned a malformed response (#{e.class})"), cause: nil
187
+ rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
188
+ raise Error.new("Could not reach #{uri.scheme}://#{uri.host} (#{e.class}); check the URL and that the dashboard is up")
189
+ end
190
+
191
+ def parse_json(body)
192
+ parsed = JSON.parse(body.to_s)
193
+ raise Error, "The dashboard returned JSON that is not an object" unless parsed.is_a?(Hash)
194
+
195
+ parsed
196
+ rescue JSON::ParserError
197
+ # The parser's message quotes the body.
198
+ raise Error.new("The dashboard returned invalid JSON"), cause: nil
199
+ end
200
+
201
+ def refused(message, response, key, status, exit_code: EXIT_FAILURE)
202
+ detail = error_detail(response, key)
203
+ Error.new(detail ? "#{message} (#{detail})" : message, status: status, exit_code: exit_code)
204
+ end
205
+
206
+ # The sanitized `error` string of a JSON error body, or nil.
207
+ def error_detail(response, key)
208
+ parsed = JSON.parse(response.body.to_s)
209
+ sanitize(parsed["error"], key) if parsed.is_a?(Hash)
210
+ rescue JSON::ParserError, EncodingError
211
+ nil
212
+ end
213
+
214
+ # Control characters and runs of whitespace become one space, the keys
215
+ # become [FILTERED], and the result is cut to DETAIL_LIMIT characters.
216
+ def sanitize(message, key)
217
+ return unless message.is_a?(String)
218
+
219
+ detail = message.scrub.gsub(/[[:space:]\p{C}]+/, " ").strip
220
+ [ key, @api_key, @ingest_key ].uniq.reject(&:empty?).each { |secret| detail = detail.gsub(secret, "[FILTERED]") }
221
+ return if detail.empty?
222
+
223
+ detail.length > DETAIL_LIMIT ? "#{detail[0, DETAIL_LIMIT - 1].rstrip}…" : detail
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module ActiveAgent
7
+ class CLI
8
+ # Named dashboard profiles in ~/.config/activeagent/credentials.yml
9
+ # ($XDG_CONFIG_HOME when set), written 0600 inside a 0700 directory:
10
+ #
11
+ # default:
12
+ # url: https://ops.example.com/activeagents
13
+ # api_key: aa_...
14
+ # ingest_key: ... # only when it differs from api_key
15
+ #
16
+ # A profile's values are what +login+ stored; the environment
17
+ # (ACTIVEAGENT_URL, ACTIVEAGENT_API_KEY, ACTIVEAGENT_INGEST_KEY) overrides
18
+ # them in CLI#profile, so CI never needs the file.
19
+ class Credentials
20
+ FIELDS = %w[url api_key ingest_key].freeze
21
+
22
+ attr_reader :path
23
+
24
+ def self.default_path(env = ENV)
25
+ base = env["XDG_CONFIG_HOME"].to_s.empty? ? File.join(Dir.home, ".config") : env["XDG_CONFIG_HOME"]
26
+ File.join(base, "activeagent", "credentials.yml")
27
+ end
28
+
29
+ def initialize(path)
30
+ @path = path
31
+ end
32
+
33
+ # Returns the profile's stored values, or an empty hash when the file or
34
+ # the profile does not exist.
35
+ def [](name)
36
+ profiles.fetch(name.to_s, {})
37
+ end
38
+
39
+ def names
40
+ profiles.keys
41
+ end
42
+
43
+ def save(name, values)
44
+ data = profiles.merge(name.to_s => values.transform_keys(&:to_s).slice(*FIELDS).compact)
45
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
46
+ # Written beside the target and renamed over it, so a reader never
47
+ # sees half a file, and created 0600 so the key is never readable by
48
+ # others, even for a moment.
49
+ temp = "#{path}.#{Process.pid}.tmp"
50
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(YAML.dump(data)) }
51
+ File.chmod(0o600, temp)
52
+ File.rename(temp, path)
53
+ ensure
54
+ FileUtils.rm_f(temp) if temp && File.exist?(temp)
55
+ end
56
+
57
+ def delete(name)
58
+ data = profiles
59
+ return false unless data.delete(name.to_s)
60
+
61
+ data.empty? ? File.delete(path) : File.open(path, "w", 0o600) { |file| file.write(YAML.dump(data)) }
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def profiles
68
+ return {} unless File.exist?(path)
69
+
70
+ data = YAML.safe_load_file(path) || {}
71
+ raise Error.new("#{path} is not a profile map; fix or remove it", exit_code: EXIT_CONFIG) unless data.is_a?(Hash)
72
+
73
+ data.select { |_, values| values.is_a?(Hash) }
74
+ rescue Psych::Exception => e
75
+ raise Error.new("#{path} is not valid YAML (#{e.class}); fix or remove it", exit_code: EXIT_CONFIG)
76
+ end
77
+ end
78
+ end
79
+ end