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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- 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
|