datagrout-conduit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27d8af58b9252394051bb3c764f30bc19b5062664a3bac0295e5bd7aa4e8df94
4
- data.tar.gz: 4f6c526fe03897d6666d2b0c76c61651dc9bc71e1eaa269f55f35e321a34671f
3
+ metadata.gz: d3a08c2cb30deefe712f0083b473838a1ec6b578c1b7decbcc53ac14aca4cffb
4
+ data.tar.gz: ea88b13e4a5998164c4dfaa1b71d448e5d498b694b8de312449a8c82d4daf555
5
5
  SHA512:
6
- metadata.gz: bba7f383d85b62c05fff950f527047c830b82842536a31151988d87414c5da597a1b67160f507753a8c43c5cd41f0e9cf463a1bdd93ab0bb898165da7a1afc18
7
- data.tar.gz: a8c3fec760966850b04ec6c5e9b0c4af46f967b87a9627130bb6bb18cf84157bd1fa0da90486cc64ff01e97c03d815e153e7e1bccd77579f6b93a5775b75df6f
6
+ metadata.gz: 70c40acb181dcc382395c454e769e26fede6798a32dc2dab7797ffaf66b1936dd0e89cf767f2f9f16a5e668ad1ceff9dfd3d2a22d03db34bcb146ab1eef78ba6
7
+ data.tar.gz: 3c2cf29896ae08b63876e60e8807ccb7ea85118fdb5d422aaf9b94e4ef3aa76345786b42bb0406f1c8df1b938d3755cf78a84fd3bf9728f5ecc4fd54a6c7b737
data/README.md CHANGED
@@ -9,13 +9,13 @@ Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities w
9
9
  Add to your Gemfile:
10
10
 
11
11
  ```ruby
12
- gem "datagrout-conduit", "~> 0.4.0"
12
+ gem "datagrout-conduit", "~> 0.5.0"
13
13
  ```
14
14
 
15
15
  Or install directly:
16
16
 
17
17
  ```sh
18
- gem install datagrout-conduit -v 0.4.0
18
+ gem install datagrout-conduit -v 0.5.0
19
19
  ```
20
20
 
21
21
  ## Quick Start
@@ -31,13 +31,15 @@ module DatagroutConduit
31
31
  end
32
32
 
33
33
  def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
34
- use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
34
+ use_intelligent_interface: nil, max_retries: 3, logger: nil,
35
+ identity_auto: false, disable_mtls: false)
35
36
  @url = url
36
37
  @auth = auth
37
38
  @transport_mode = transport
38
39
  @identity = identity
39
40
  @identity_dir = identity_dir
40
- @disable_mtls = disable_mtls
41
+ @identity_auto = identity_auto
42
+ @disable_mtls = disable_mtls # deprecated, kept for backward compat
41
43
  @max_retries = max_retries
42
44
  @initialized = false
43
45
  @server_info = nil
@@ -104,6 +106,38 @@ module DatagroutConduit
104
106
  bootstrap_identity(url: url, auth_token: token, name: name, identity_dir: identity_dir)
105
107
  end
106
108
 
109
+ # Bootstrap by performing the autonomous DG onramp flow.
110
+ #
111
+ # The all-in-one flow: onramp (no prior credentials required) →
112
+ # OAuth token exchange → mTLS identity registration and persistence.
113
+ #
114
+ # On subsequent runs the saved mTLS identity is auto-discovered and
115
+ # no credentials are needed.
116
+ #
117
+ # @param opts [DatagroutConduit::Onramp::OnrampOptions]
118
+ # @param url [String, nil] MCP server URL; required when +opts.mcp_url+ is absent
119
+ # @param name [String] human-readable identity label
120
+ # @param identity_dir [String, nil] custom identity storage directory
121
+ # @return [Client] unconnected client; call +#connect+ before use
122
+ def self.bootstrap_onramp(opts:, url: nil, name: "conduit-client", identity_dir: nil)
123
+ dir = identity_dir || Registration.default_identity_dir || File.join(Dir.home, ".conduit")
124
+
125
+ # Fast path: existing valid identity.
126
+ identity = Identity.try_discover(override_dir: dir)
127
+ if identity && !identity.needs_rotation?
128
+ raise ArgumentError, "'url' must be provided when an existing identity is reused" if url.nil?
129
+ return new(url: url, identity: identity)
130
+ end
131
+
132
+ # Slow path: full onramp flow.
133
+ creds, token = Onramp.register_and_exchange(opts)
134
+
135
+ mcp_url = creds.mcp_url || url
136
+ raise ArgumentError, "'url' must be provided when mcp_url is absent from onramp response" if mcp_url.nil?
137
+
138
+ bootstrap_identity(url: mcp_url, auth_token: token, name: name, identity_dir: identity_dir)
139
+ end
140
+
107
141
  def connect
108
142
  @transport.connect
109
143
 
@@ -418,8 +452,7 @@ module DatagroutConduit
418
452
 
419
453
  def resolve_identity!
420
454
  return if @identity
421
- return if @disable_mtls
422
- return unless @is_dg
455
+ return unless @identity_auto
423
456
 
424
457
  @identity = Identity.try_discover(override_dir: @identity_dir)
425
458
  end
@@ -67,31 +67,30 @@ module DatagroutConduit
67
67
  # Walk the auto-discovery chain and return the first identity found,
68
68
  # or nil if nothing is available.
69
69
  def self.try_discover(override_dir: nil)
70
- # 1. Override directory
70
+ # When an explicit directory is given, scope search to that dir only.
71
71
  if override_dir
72
- id = try_load_from_dir(override_dir)
73
- return id if id
72
+ return try_load_from_dir(override_dir)
74
73
  end
75
74
 
76
- # 2. Environment variables (individual cert/key PEMs)
75
+ # 1. Environment variables (individual cert/key PEMs)
77
76
  id = from_env
78
77
  return id if id
79
78
 
80
- # 3. CONDUIT_IDENTITY_DIR env var
79
+ # 2. CONDUIT_IDENTITY_DIR env var
81
80
  identity_dir = ENV["CONDUIT_IDENTITY_DIR"]
82
81
  if identity_dir && !identity_dir.empty?
83
82
  id = try_load_from_dir(identity_dir)
84
83
  return id if id
85
84
  end
86
85
 
87
- # 4. ~/.conduit/
86
+ # 3. ~/.conduit/
88
87
  home = ENV["HOME"] || ENV["USERPROFILE"]
89
88
  if home
90
89
  id = try_load_from_dir(File.join(home, ".conduit"))
91
90
  return id if id
92
91
  end
93
92
 
94
- # 5. .conduit/ relative to cwd
93
+ # 4. .conduit/ relative to cwd
95
94
  id = try_load_from_dir(File.join(Dir.pwd, ".conduit"))
96
95
  return id if id
97
96
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module DatagroutConduit
7
+ # Autonomous agent self-registration (onramp) for DataGrout.
8
+ #
9
+ # The onramp flow lets a machine intelligence register itself with DG
10
+ # without a human in the loop, using only plain HTTP JSON — no MCP client
11
+ # required.
12
+ #
13
+ # == Flow
14
+ #
15
+ # 1. POST to +/onramp+ with agent identity metadata (no auth).
16
+ # 2. DG returns a short-lived +session_token+ (5 minutes).
17
+ # 3. POST to +/onramp/complete+ with +Authorization: Bearer <session_token>+.
18
+ # 4. DG issues provisional +client_id+ + +client_secret+ (restricted scopes).
19
+ #
20
+ # == Example
21
+ #
22
+ # opts = DatagroutConduit::Onramp::OnrampOptions.new(
23
+ # gateway: "https://app.datagrout.ai",
24
+ # agent_name: "my-research-agent",
25
+ # agent_type: "claude-sonnet-4-6"
26
+ # )
27
+ # client = DatagroutConduit::Client.bootstrap_onramp(opts: opts, url: nil)
28
+ module Onramp
29
+ # Options for the autonomous agent onramp flow.
30
+ OnrampOptions = Struct.new(
31
+ :gateway,
32
+ :agent_name,
33
+ :agent_type,
34
+ :intended_use,
35
+ :access_code,
36
+ keyword_init: true
37
+ )
38
+
39
+ # Provisional credentials returned by the DG onramp complete endpoint.
40
+ #
41
+ # Store +client_id+ and +client_secret+ securely — the secret is shown
42
+ # exactly once and cannot be recovered after this point.
43
+ OnrampCredentials = Struct.new(
44
+ :client_id,
45
+ :client_secret,
46
+ :token_url,
47
+ :scopes,
48
+ :expires_in,
49
+ :rpc_url,
50
+ :mcp_url,
51
+ keyword_init: true
52
+ )
53
+
54
+ class OnrampError < StandardError; end
55
+
56
+ # Perform the onramp handshake and return provisional OAuth credentials.
57
+ #
58
+ # Low-level entry point. Most callers should use
59
+ # {DatagroutConduit::Client.bootstrap_onramp} instead.
60
+ #
61
+ # @param opts [OnrampOptions]
62
+ # @return [OnrampCredentials]
63
+ def self.register_only(opts)
64
+ register(opts)
65
+ end
66
+
67
+ # Perform the full onramp handshake and OAuth token exchange.
68
+ #
69
+ # @param opts [OnrampOptions]
70
+ # @return [Array(OnrampCredentials, String)] credentials and access token
71
+ def self.register_and_exchange(opts)
72
+ creds = register(opts)
73
+ token = exchange_token(creds)
74
+ [creds, token]
75
+ end
76
+
77
+ # @api private
78
+ def self.register(opts)
79
+ base = opts.gateway.chomp("/")
80
+
81
+ body = { agent_name: opts.agent_name }
82
+ body[:agent_type] = opts.agent_type if opts.agent_type
83
+ body[:intended_use] = opts.intended_use if opts.intended_use
84
+ body[:access_code] = opts.access_code if opts.access_code
85
+
86
+ conn = build_conn
87
+
88
+ init_resp = conn.post("#{base}/onramp") do |req|
89
+ req.headers["Content-Type"] = "application/json"
90
+ req.body = JSON.generate(body)
91
+ end
92
+
93
+ raise OnrampError, "onramp init rejected (HTTP #{init_resp.status}): #{init_resp.body}" \
94
+ unless init_resp.success?
95
+
96
+ init_data = parse_body(init_resp.body)
97
+ session_token = init_data["session_token"]
98
+
99
+ complete_resp = conn.post("#{base}/onramp/complete") do |req|
100
+ req.headers["Authorization"] = "Bearer #{session_token}"
101
+ end
102
+
103
+ raise OnrampError, "onramp complete rejected (HTTP #{complete_resp.status}): #{complete_resp.body}" \
104
+ unless complete_resp.success?
105
+
106
+ data = parse_body(complete_resp.body)
107
+
108
+ OnrampCredentials.new(
109
+ client_id: data["client_id"],
110
+ client_secret: data["client_secret"],
111
+ token_url: data["token_url"],
112
+ scopes: data["scopes"] || [],
113
+ expires_in: data["expires_in"] || 0,
114
+ rpc_url: data["rpc_url"],
115
+ mcp_url: data["mcp_url"]
116
+ )
117
+ end
118
+ private_class_method :register
119
+
120
+ # @api private
121
+ def self.exchange_token(creds)
122
+ conn = Faraday.new do |f|
123
+ f.request :url_encoded
124
+ f.response :json, content_type: /\bjson$/
125
+ f.adapter Faraday.default_adapter
126
+ end
127
+
128
+ resp = conn.post(creds.token_url, {
129
+ grant_type: "client_credentials",
130
+ client_id: creds.client_id,
131
+ client_secret: creds.client_secret
132
+ })
133
+
134
+ raise OnrampError, "token exchange failed (HTTP #{resp.status}): #{resp.body}" \
135
+ unless resp.success?
136
+
137
+ data = parse_body(resp.body)
138
+ data["access_token"]
139
+ end
140
+ private_class_method :exchange_token
141
+
142
+ def self.build_conn
143
+ Faraday.new do |f|
144
+ f.response :json, content_type: /\bjson$/
145
+ f.adapter Faraday.default_adapter
146
+ end
147
+ end
148
+ private_class_method :build_conn
149
+
150
+ def self.parse_body(body)
151
+ body.is_a?(String) ? JSON.parse(body) : body
152
+ end
153
+ private_class_method :parse_body
154
+ end
155
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatagroutConduit
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -8,6 +8,7 @@ require_relative "datagrout_conduit/types"
8
8
  require_relative "datagrout_conduit/identity"
9
9
  require_relative "datagrout_conduit/oauth"
10
10
  require_relative "datagrout_conduit/registration"
11
+ require_relative "datagrout_conduit/onramp"
11
12
  require_relative "datagrout_conduit/transport/base"
12
13
  require_relative "datagrout_conduit/transport/mcp"
13
14
  require_relative "datagrout_conduit/transport/jsonrpc"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: datagrout-conduit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DataGrout
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-02 00:00:00.000000000 Z
11
+ date: 2026-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -129,6 +129,7 @@ files:
129
129
  - lib/datagrout_conduit/namespaces/prism.rb
130
130
  - lib/datagrout_conduit/namespaces/warden.rb
131
131
  - lib/datagrout_conduit/oauth.rb
132
+ - lib/datagrout_conduit/onramp.rb
132
133
  - lib/datagrout_conduit/registration.rb
133
134
  - lib/datagrout_conduit/transport/base.rb
134
135
  - lib/datagrout_conduit/transport/jsonrpc.rb