knoxcall 0.0.1 → 1.0.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,140 @@
1
+ require "uri"
2
+ require "set"
3
+ require "knoxcall/wrap_transport"
4
+
5
+ module KnoxCall
6
+ # The route-aware interception decision table — pure, no I/O
7
+ # (docs/internal/sdk-wrapping/route-aware-interception-plan.md §2.2, PARITY
8
+ # §21.1). One request in, one decision out: send it DIRECT (untouched),
9
+ # through a ROUTE (the manifest says an intercept-enabled Route covers this
10
+ # host + path; the Route injects the stored secret), or through the EPHEMERAL
11
+ # proxy (the caller listed the host, no Route covers it; the SDK's own
12
+ # credential is lifted out-of-band). The order of the rules is the feature.
13
+ #
14
+ # Every SDK's resolver passes the SAME fixtures — sdk/fixtures/intercept-
15
+ # resolver.json (spec/intercept_resolver_spec.rb runs them here) — so this is
16
+ # the Ruby copy of a contract whose reference is the Node SDK's
17
+ # src/intercept-resolver.ts, not a private heuristic.
18
+ module InterceptResolver
19
+ MODES = %i[direct route ephemeral].freeze
20
+ REASONS = %i[kill_switch unparseable own_host route_around outside_context
21
+ manifest no_base_path_match no_route unlisted].freeze
22
+
23
+ # The resolver's answer for one request. +slug+ / +path+ / +entry+ are set
24
+ # in route mode only; +route_around_reason+ for a route-around match.
25
+ Decision = Struct.new(:mode, :reason, :host, :slug, :path, :entry, :route_around_reason,
26
+ keyword_init: true) do
27
+ def direct? = mode == :direct
28
+ def route? = mode == :route
29
+ def ephemeral? = mode == :ephemeral
30
+ end
31
+
32
+ module_function
33
+
34
+ # KNOXCALL_INTERCEPT=off (or 0 / false): every interceptor and route-aware
35
+ # transport becomes pass-through, per request, with no deploy.
36
+ def kill_switch?
37
+ %w[off 0 false].include?(ENV.fetch("KNOXCALL_INTERCEPT", "").strip.downcase)
38
+ end
39
+
40
+ # KnoxCall's own domains are never intercepted, whatever a manifest or a
41
+ # host list says (anti-recursion).
42
+ def platform_host?(host)
43
+ host == "knoxcall.com" || host.end_with?(".knoxcall.com")
44
+ end
45
+
46
+ # Read a manifest-entry field tolerating string and symbol keys (the parsed
47
+ # manifest is string-keyed; a hand-built one may not be).
48
+ def entry_value(entry, key)
49
+ return nil unless entry.is_a?(Hash)
50
+
51
+ entry.key?(key.to_s) ? entry[key.to_s] : entry[key.to_sym]
52
+ end
53
+
54
+ # The request path with the route's base prefix removed (leading slash
55
+ # kept), or +nil+ when the request is not under the base. "/crm/v3" covers
56
+ # "/crm/v3" and "/crm/v3/x", never "/crm/v30" — the boundary is a path
57
+ # segment. Mirrors the server's rebasePath (src/lib/route-target-host.ts).
58
+ def rebase_path(request_path, base_path)
59
+ req_path = request_path.to_s.empty? ? "/" : request_path.to_s
60
+ if base_path.nil? || base_path == "/" || base_path == ""
61
+ return req_path.start_with?("/") ? req_path : "/#{req_path}"
62
+ end
63
+ return "/" if req_path == base_path
64
+ return nil unless req_path.start_with?("#{base_path}/")
65
+
66
+ rest = req_path[base_path.length..]
67
+ rest.empty? ? "/" : rest
68
+ end
69
+
70
+ # Manifest entries for a host in the order the server sorts them — longest
71
+ # base_path first, then base_path, then slug — so the first entry whose
72
+ # base covers the path is the longest-prefix, lowest-slug match.
73
+ def entries_for_host(manifest, host)
74
+ return [] if manifest.nil?
75
+
76
+ routes = entry_value(manifest, :routes) || []
77
+ routes.select { |e| WrapTransport.normalize_host(entry_value(e, :host)) == host }
78
+ .sort_by { |e| bp = entry_value(e, :base_path).to_s; [-bp.length, bp, entry_value(e, :slug).to_s] }
79
+ end
80
+
81
+ # Apply the decision table. First match wins.
82
+ #
83
+ # @param url [String] the request URL
84
+ # @param method [String] the HTTP method (carried for hooks; not a rule input today)
85
+ # @param hosts [Set<String>, :all] the caller's explicit host list (normalised),
86
+ # or +:all+ for the explicit-transport form where every request is listed
87
+ # @param manifest [Hash, nil] the intercept manifest ({"routes" => [...]})
88
+ # @param own_hosts [Set<String>] the client's own hosts (management + data plane)
89
+ # @param route_around [Array<Hash>] route-around rules
90
+ # @param kill_switch [Boolean]
91
+ # @param require_context [Boolean] only intercept inside +routed { }+
92
+ # @param in_context [Boolean] whether this request is inside +routed { }+
93
+ # @return [Decision]
94
+ def resolve(url:, method:, hosts:, manifest:, own_hosts:, route_around:,
95
+ kill_switch: false, require_context: false, in_context: false)
96
+ _ = method
97
+ return Decision.new(mode: :direct, reason: :kill_switch, host: "") if kill_switch
98
+
99
+ u = begin
100
+ URI.parse(url.to_s)
101
+ rescue URI::InvalidURIError
102
+ nil
103
+ end
104
+ unless u && %w[http https].include?(u.scheme.to_s.downcase)
105
+ return Decision.new(mode: :direct, reason: :unparseable, host: "")
106
+ end
107
+ host = WrapTransport.normalize_host(u.host)
108
+ return Decision.new(mode: :direct, reason: :unparseable, host: "") if host.empty?
109
+
110
+ if platform_host?(host) || own_hosts.include?(host)
111
+ return Decision.new(mode: :direct, reason: :own_host, host: host)
112
+ end
113
+
114
+ around = WrapTransport.match_route_around(url.to_s, route_around)
115
+ if around
116
+ return Decision.new(mode: :direct, reason: :route_around, host: host,
117
+ route_around_reason: WrapTransport.rule_value(around, :reason))
118
+ end
119
+
120
+ return Decision.new(mode: :direct, reason: :outside_context, host: host) if require_context && !in_context
121
+
122
+ entries = entries_for_host(manifest, host)
123
+ entries.each do |entry|
124
+ rebased = rebase_path(u.path, entry_value(entry, :base_path))
125
+ next if rebased.nil?
126
+
127
+ path = u.query ? "#{rebased}?#{u.query}" : rebased
128
+ return Decision.new(mode: :route, reason: :manifest, host: host,
129
+ slug: entry_value(entry, :slug), path: path, entry: entry)
130
+ end
131
+
132
+ listed = hosts == :all || hosts.include?(host)
133
+ if listed
134
+ return Decision.new(mode: :ephemeral, reason: entries.empty? ? :no_route : :no_base_path_match, host: host)
135
+ end
136
+
137
+ Decision.new(mode: :direct, reason: :unlisted, host: host)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,203 @@
1
+ require "knoxcall/errors"
2
+ require "knoxcall/warnings"
3
+
4
+ module KnoxCall
5
+ # The SDK-side copy of the intercept manifest — fetched, held, refreshed
6
+ # (route-aware-interception-plan.md §2.5; PARITY §21.1). One per transport /
7
+ # interceptor; process memory only; dropped on +stop+.
8
+ #
9
+ # Ruby idiom (as Python): the manifest is refreshed LAZILY at the TTL — the
10
+ # first request after +ttl_seconds+ pays one management call — rather than
11
+ # by a background thread. Behaviourally the same contract ("hold the manifest
12
+ # for ttl_seconds, then refresh"), and it works identically under Puma
13
+ # threads, a forked Unicorn/Sidekiq worker and a plain script, with no
14
+ # thread to own.
15
+ #
16
+ # - single-flight: concurrent refreshes share one fetch (a caller that waited
17
+ # on the mutex while another refreshed takes that answer);
18
+ # - stale-but-valid: a failed refresh keeps the last GOOD manifest and backs
19
+ # off exponentially from the second consecutive failure (cap 8×TTL);
20
+ # - a 401/403/404 from the manifest endpoint — the credential lacks
21
+ # routes:read, or an older server — is NOT a routing failure: the store
22
+ # warns once, behaves as "no manifest" (every listed host stays on the
23
+ # ephemeral path exactly as before this feature), and re-checks at 10×TTL;
24
+ # - out-of-cycle refreshes (a route-mode refusal, a promoted-route hint, an
25
+ # explicit +refresh+) are rate-limited so a burst costs one call.
26
+ #
27
+ # Discovery failing open is deliberate and bounded: it can only leave a host
28
+ # on the path it was on before the manifest existed. The DATA-PLANE hop is
29
+ # where fail-closed lives (D4), and that is in the intercept pipeline.
30
+ class InterceptManifestStore
31
+ DEFAULT_TTL_SECONDS = 60
32
+ MAX_BACKOFF_FACTOR = 8
33
+ PERMISSION_RECHECK_FACTOR = 10
34
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
35
+
36
+ attr_reader :manifest, :version, :last_error
37
+ # Seconds between out-of-cycle refreshes (a refusal, a hint, an explicit
38
+ # refresh); a burst inside the gap costs one call.
39
+ attr_accessor :min_refresh_gap
40
+
41
+ # @param fetch [#call] performs GET /v1/wrap/intercept-manifest and returns the Hash.
42
+ # When it accepts an +if_none_match:+ keyword the store passes the version it
43
+ # holds on every poll after the first (+If-None-Match: W/"<version>"+ on the
44
+ # wire) and reads +nil+ as the server's 304: keep the manifest, restart the
45
+ # TTL clock, fire no +on_refresh+ (PARITY §21.1 "Conditional poll"). A
46
+ # zero-argument callable is accepted and simply polls unconditionally.
47
+ # @param on_refresh [#call, nil] receives {reason:, version:, added:, removed:} after a change
48
+ # @param on_error [#call, nil] receives the exception of a failed refresh
49
+ # @param min_refresh_gap [Numeric] seconds between out-of-cycle refreshes
50
+ # @param now [#call] the clock (monotonic seconds); a test seam
51
+ def initialize(fetch, on_refresh: nil, on_error: nil, min_refresh_gap: 5.0, now: MONOTONIC)
52
+ @fetch = fetch
53
+ @fetch_conditional = self.class.accepts_if_none_match?(fetch)
54
+ @on_refresh = on_refresh
55
+ @on_error = on_error
56
+ @min_refresh_gap = min_refresh_gap
57
+ @now = now
58
+ @manifest = nil
59
+ @version = nil
60
+ @expires_at = 0.0 # stale until the first refresh
61
+ @last_refresh_at = -1e9
62
+ @failures = 0
63
+ @permission_denied = false
64
+ @last_error = nil
65
+ @stopped = false
66
+ @seq = 0
67
+ @mutex = Mutex.new
68
+ end
69
+
70
+ def permission_denied? = @permission_denied
71
+ def stopped? = @stopped
72
+
73
+ # Whether +fetch+ can take +if_none_match:+ (a keyword, or **rest). Decided
74
+ # once at construction so a zero-argument test/user seam keeps working.
75
+ def self.accepts_if_none_match?(fetch)
76
+ return false unless fetch.respond_to?(:parameters)
77
+
78
+ fetch.parameters.any? do |kind, name|
79
+ (%i[key keyreq].include?(kind) && name == :if_none_match) || kind == :keyrest
80
+ end
81
+ end
82
+
83
+ # Whether the next request should refresh before deciding.
84
+ def stale?
85
+ !@stopped && @now.call >= @expires_at
86
+ end
87
+
88
+ # A promoted-route hint arrived: make the NEXT request refresh (rate-limited).
89
+ def hint
90
+ @mutex.synchronize { @expires_at = @now.call if @now.call - @last_refresh_at >= @min_refresh_gap }
91
+ end
92
+
93
+ # Drop the manifest and refuse further refreshes.
94
+ def stop
95
+ @mutex.synchronize do
96
+ @stopped = true
97
+ @manifest = nil
98
+ @version = nil
99
+ end
100
+ end
101
+
102
+ # Refresh if stale (first load, TTL expiry, a hint), then return the manifest.
103
+ def ensure
104
+ refresh("ttl", force: true) if stale?
105
+ @manifest
106
+ end
107
+
108
+ # Refresh now. Single-flight; rate-limited unless +force+. Returns the
109
+ # manifest the store holds afterwards (nil after a permission refusal).
110
+ # Never raises for a fetch failure — the store has already applied
111
+ # stale-keep / backoff; read +last_error+.
112
+ def refresh(reason, force: false)
113
+ return nil if @stopped
114
+
115
+ seq_before = @seq # bumped when an attempt COMPLETES
116
+ @mutex.synchronize do
117
+ return nil if @stopped
118
+ # An attempt completed while we waited on the mutex: take its answer
119
+ # (single-flight — the refresh we queued behind is the one we wanted).
120
+ return @manifest if @seq != seq_before
121
+ return @manifest if !force && @now.call - @last_refresh_at < @min_refresh_gap
122
+
123
+ do_refresh(reason)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # Runs under @mutex. Hooks are invoked inside the lock deliberately kept
130
+ # cheap; a hook must not call back into the store.
131
+ def do_refresh(reason)
132
+ @last_refresh_at = @now.call
133
+ begin
134
+ # Every poll after the first is conditional on the held version; the
135
+ # server answers 304 (→ nil) when nothing changed, and that is a
136
+ # success: keep the manifest, restart the TTL clock, fire no hook.
137
+ nxt = @fetch_conditional && @version ? @fetch.call(if_none_match: @version) : @fetch.call
138
+ rescue StandardError => e
139
+ @seq += 1 # this attempt is over: a waiter that queued behind it takes its answer
140
+ @last_error = e
141
+ @on_error&.call(e)
142
+ status = e.is_a?(APIError) ? e.status_code : nil
143
+ if [401, 403, 404].include?(status)
144
+ # Not a routing failure: the credential cannot read routes, or the
145
+ # server predates the manifest. Every listed host stays ephemeral,
146
+ # as it was before this feature existed. Warn once, re-check slowly.
147
+ @permission_denied = true
148
+ @manifest = nil
149
+ @version = nil
150
+ Warnings.warn_once(
151
+ "KNOXCALL_INTERCEPT_MANIFEST_UNAVAILABLE",
152
+ "KnoxCall intercept manifest unavailable (HTTP #{status}): route-aware interception is off " \
153
+ "for this client — listed hosts use the ephemeral proxy. Grant the credential `routes:read` " \
154
+ "(or upgrade the server) to enable it."
155
+ )
156
+ @expires_at = @now.call + DEFAULT_TTL_SECONDS * PERMISSION_RECHECK_FACTOR
157
+ else
158
+ # Transport or server fault: keep the last good manifest, back off
159
+ # from the second consecutive failure.
160
+ @failures = [@failures + 1, 30].min
161
+ factor = [2**(@failures - 1), MAX_BACKOFF_FACTOR].min
162
+ base = ttl_of(@manifest)
163
+ @expires_at = @now.call + [base * factor, base * MAX_BACKOFF_FACTOR].min
164
+ end
165
+ return @manifest
166
+ end
167
+
168
+ @seq += 1
169
+ prev = @manifest
170
+ @failures = 0
171
+ @permission_denied = false
172
+ @last_error = nil
173
+ if nxt.nil?
174
+ # Not modified: the held manifest stands for another TTL.
175
+ @expires_at = @now.call + ttl_of(prev)
176
+ return @manifest
177
+ end
178
+ version = nxt["version"].to_s
179
+ if prev.nil? || @version != version
180
+ before = (prev ? prev["routes"] : []).to_h { |e| [entry_key(e), e] }
181
+ after = (nxt["routes"] || []).to_h { |e| [entry_key(e), e] }
182
+ added = after.reject { |k, _| before.key?(k) }.values
183
+ removed = before.reject { |k, _| after.key?(k) }.values
184
+ @manifest = nxt
185
+ @version = version
186
+ if @on_refresh && (!added.empty? || !removed.empty? || prev.nil?)
187
+ @on_refresh.call(reason: reason, version: version, added: added, removed: removed)
188
+ end
189
+ end
190
+ @expires_at = @now.call + ttl_of(nxt)
191
+ @manifest
192
+ end
193
+
194
+ def ttl_of(manifest)
195
+ ttl = manifest && manifest["ttl_seconds"]
196
+ ttl.is_a?(Numeric) && ttl.positive? ? ttl.to_f : DEFAULT_TTL_SECONDS.to_f
197
+ end
198
+
199
+ def entry_key(e)
200
+ [e["host"], e["base_path"], e["slug"]]
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,144 @@
1
+ require "rbconfig"
2
+ require "knoxcall/cli/common"
3
+ require "knoxcall/cli/login"
4
+
5
+ module KnoxCall
6
+ # Interactive first-run authentication — OPT-IN, and NEVER on the request
7
+ # path (PARITY §14).
8
+ #
9
+ # The persistent credential (`knoxcall login` -> ~/.knoxcall/credentials.json,
10
+ # rotating refresh token) already survives restarts; these helpers just let
11
+ # the SDK *initiate* that login programmatically. Because an SDK is embedded
12
+ # in someone else's process (production servers, CI, background jobs), a
13
+ # browser/device flow must be an explicit, TTY-gated call — never a silent
14
+ # side effect of a normal API call. Client construction and #call never
15
+ # trigger this; they raise NotAuthenticatedError when no credential is found.
16
+ #
17
+ # client = KnoxCall.ensure_login # reuse a stored profile, else prompt once
18
+ # client = KnoxCall.login(mode: "device") # force a fresh interactive login
19
+ #
20
+ # They reuse the already-tested CLI auth-code+PKCE loopback / RFC 8628 device
21
+ # flow (KnoxCall::CLI::Login) and its persist helper (KnoxCall::CLI::Common),
22
+ # so there is exactly one implementation of each flow.
23
+ class << self
24
+ # Run the interactive browser (loopback) or device-code login, persist the
25
+ # credential to ~/.knoxcall/credentials.json, and return a ready client
26
+ # bound to the written profile.
27
+ #
28
+ # @param tenant [String, nil] tenant hint for the authorize URL (optional;
29
+ # discovered otherwise)
30
+ # @param sandbox [Boolean] target the sandbox host / Test data plane
31
+ # @param base_url [String, nil] management base URL override
32
+ # (default production/sandbox, or KNOXCALL_BASE_URL)
33
+ # @param profile [String, nil] credentials-file profile to write/read
34
+ # (default: KNOXCALL_PROFILE or "default")
35
+ # @param mode ["auto", "browser", "device"] "auto" opens a browser on a
36
+ # desktop TTY, else falls back to the device flow
37
+ # @param timeout [Numeric] loopback wait timeout for the browser flow (s)
38
+ # @param allow_non_interactive [Boolean] bypass the TTY / CI /
39
+ # KNOXCALL_NO_INTERACTIVE guard (default false)
40
+ # @param open_browser [#call, nil] custom browser launcher (defaults to the
41
+ # OS opener) — receives the authorize URL
42
+ # @param client_options [Hash] extra options forwarded to the returned
43
+ # KnoxCall::Client (symbol keys)
44
+ # @return [KnoxCall::Client] a client bound to the freshly-written profile
45
+ # @raise [NotAuthenticatedError] when prompting is unsafe (no TTY, CI, or
46
+ # KNOXCALL_NO_INTERACTIVE) and allow_non_interactive is not set
47
+ def login(tenant: nil, sandbox: false, base_url: nil, profile: nil,
48
+ mode: "auto", timeout: 300.0, allow_non_interactive: false,
49
+ open_browser: nil, client_options: {})
50
+ mode = mode.to_s
51
+ unless %w[auto browser device].include?(mode)
52
+ raise ArgumentError, %(mode must be "auto", "browser", or "device" (got #{mode.inspect}))
53
+ end
54
+ interactive_guard(allow_non_interactive)
55
+
56
+ resolved_base = (base_url || CLI::Common.default_base_url(sandbox)).chomp("/")
57
+ resolved_profile = CredentialsFile.resolve_profile(profile)
58
+ use_device = mode == "device" || (mode == "auto" && !desktop_browser?)
59
+
60
+ token_body =
61
+ if use_device
62
+ CLI::Login.device_flow(resolved_base)
63
+ else
64
+ CLI::Login.auth_code_flow(resolved_base, tenant: tenant,
65
+ open_browser: open_browser, timeout: timeout)
66
+ end
67
+
68
+ CLI::Common.persist_login(
69
+ path: CredentialsFile.resolve_path,
70
+ profile: resolved_profile,
71
+ base_url: resolved_base,
72
+ token_body: token_body,
73
+ fallback_tenant: tenant
74
+ )
75
+ client_from_profile(resolved_profile, sandbox, client_options)
76
+ end
77
+
78
+ # Return a client from an already-stored credential for the profile when one
79
+ # is present (no prompt, no network), otherwise run the interactive {#login}
80
+ # once. The ergonomic "make sure I'm authenticated, then give me a client"
81
+ # entry point. Accepts the same options as {#login}.
82
+ #
83
+ # @return [KnoxCall::Client]
84
+ def ensure_login(tenant: nil, sandbox: false, base_url: nil, profile: nil,
85
+ mode: "auto", timeout: 300.0, allow_non_interactive: false,
86
+ open_browser: nil, client_options: {})
87
+ resolved_profile = CredentialsFile.resolve_profile(profile)
88
+ if CredentialsFile.profile_available?(CredentialsFile.resolve_path, resolved_profile)
89
+ return client_from_profile(resolved_profile, sandbox, client_options)
90
+ end
91
+ login(tenant: tenant, sandbox: sandbox, base_url: base_url, profile: profile,
92
+ mode: mode, timeout: timeout, allow_non_interactive: allow_non_interactive,
93
+ open_browser: open_browser, client_options: client_options)
94
+ end
95
+
96
+ private
97
+
98
+ # Refuse to pop a browser or block on a device code where doing so is
99
+ # unsafe: a non-interactive process (no TTY), CI, or an explicit opt-out.
100
+ # The caller can override with allow_non_interactive when they know it is
101
+ # safe (PARITY §14).
102
+ def interactive_guard(allow_non_interactive)
103
+ return if allow_non_interactive
104
+ if env_set?("KNOXCALL_NO_INTERACTIVE") || env_set?("CI")
105
+ raise NotAuthenticatedError,
106
+ "interactive login is disabled here (KNOXCALL_NO_INTERACTIVE or CI is set). " \
107
+ "Provision a non-interactive credential (client_id/secret or workload OIDC) instead."
108
+ end
109
+ unless $stdin.tty? && $stdout.tty?
110
+ raise NotAuthenticatedError,
111
+ "no interactive terminal detected — run `knoxcall login` in a terminal, " \
112
+ "or provision a non-interactive credential (client_id/secret or workload OIDC)."
113
+ end
114
+ end
115
+
116
+ # A present, non-empty env var counts as "set" — mirrors node's
117
+ # truthy-string test (so CI=false still counts as CI being present).
118
+ def env_set?(name)
119
+ value = ENV[name]
120
+ !value.nil? && !value.empty?
121
+ end
122
+
123
+ # Headless CI is already blocked by interactive_guard, so this only chooses
124
+ # browser-vs-device on a real TTY. On Linux, require a display server.
125
+ def desktop_browser?
126
+ if RbConfig::CONFIG["host_os"] =~ /linux/
127
+ env_set?("DISPLAY") || env_set?("WAYLAND_DISPLAY")
128
+ else
129
+ true
130
+ end
131
+ end
132
+
133
+ # Build a client bound to a stored profile. Construction performs no network
134
+ # I/O; the StoredCredentials bootstrap seeds tenant/base_url from the file
135
+ # (unless overridden) and refreshes tokens under the file lock at first use.
136
+ def client_from_profile(profile, sandbox, client_options)
137
+ opts = (client_options || {}).merge(
138
+ bootstrap: StoredCredentials.new(profile: profile),
139
+ sandbox: sandbox
140
+ )
141
+ Client.new(opts)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,12 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Account
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ def get = unwrap(@client.request("GET", "/v1/account"))
9
+ def get_usage = unwrap(@client.request("GET", "/v1/account/usage"))
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Agents
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Bare array — no pagination.
9
+ def list = unwrap(@client.request("GET", "/v1/agents"))
10
+ # The response's "agent_secret" is shown exactly once — store it now.
11
+ # (The server hand-rolls this 201: data carries the secret, meta only
12
+ # {secret_shown_once: true} — unwrapping still applies.)
13
+ # Requires an explicit `agent:create` policy grant: a wildcard (`*:*`) rule
14
+ # does not satisfy it, including the `legacy_admin` policy every key created
15
+ # before 2026-06-30 still carries. The seeded Key - Infrastructure and Key -
16
+ # Editor roles name the action literally and are unaffected. Without it the
17
+ # call returns 403. Every successful mint also emails the account's owners.
18
+ def create(name) = unwrap(@client.request("POST", "/v1/agents", body: { name: name }))
19
+ # Returns {"revoked" => true}.
20
+ def revoke(agent_id) = unwrap(@client.request("DELETE", "/v1/agents/#{encode(agent_id)}"))
21
+ # Bare array (server-capped at 50 rows) — no pagination.
22
+ def get_tamper_events(agent_id) = unwrap(@client.request("GET", "/v1/agents/#{encode(agent_id)}/tamper-events"))
23
+ end
24
+ end
25
+ end