pwn 0.5.642 → 0.5.647
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/Gemfile +1 -1
- data/README.md +1 -1
- data/documentation/Cron.md +3 -2
- data/documentation/Home.md +1 -1
- data/documentation/Mistakes.md +13 -0
- data/documentation/Reinforcement-Learning.md +37 -3
- data/documentation/Skills-Memory-Learning.md +5 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +3 -3
- data/documentation/diagrams/dot/reinforcement-learning.dot +8 -8
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +318 -319
- data/documentation/diagrams/reinforcement-learning.svg +188 -187
- data/documentation/pwn-ai-Agent.md +5 -4
- data/lib/pwn/ai/agent/curriculum.rb +368 -32
- data/lib/pwn/ai/agent/learning.rb +159 -13
- data/lib/pwn/ai/agent/loop.rb +75 -4
- data/lib/pwn/ai/agent/metrics.rb +140 -10
- data/lib/pwn/ai/agent/mistakes.rb +26 -8
- data/lib/pwn/ai/agent/registry.rb +5 -2
- data/lib/pwn/ai/agent/reward.rb +279 -5
- data/lib/pwn/ai/agent/tools/reward.rb +66 -0
- data/lib/pwn/ai/anthropic.rb +265 -4
- data/lib/pwn/ai/open_ai.rb +307 -4
- data/lib/pwn/config.rb +50 -9
- data/lib/pwn/cron.rb +14 -2
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +507 -12
- data/spec/lib/pwn/ai/agent/reward_spec.rb +64 -1
- data/third_party/pwn_rdoc.jsonl +35 -2
- metadata +3 -3
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -4,6 +4,9 @@ require 'json'
|
|
|
4
4
|
require 'rest-client'
|
|
5
5
|
require 'tty-spinner'
|
|
6
6
|
require 'securerandom'
|
|
7
|
+
require 'base64'
|
|
8
|
+
require 'digest'
|
|
9
|
+
require 'uri'
|
|
7
10
|
|
|
8
11
|
module PWN
|
|
9
12
|
module AI
|
|
@@ -12,6 +15,205 @@ module PWN
|
|
|
12
15
|
# API documentation: https://docs.anthropic.com/en/api
|
|
13
16
|
# Obtain an API key from https://console.anthropic.com/
|
|
14
17
|
module Anthropic
|
|
18
|
+
# Internal helper: true when +opts[:value]+ is a *real* configured value coming
|
|
19
|
+
# from PWN::Config / pwn-vault (i.e. not nil, not blank, and not one of
|
|
20
|
+
# the "optional - ..." / "required - ..." placeholder strings that
|
|
21
|
+
# PWN::Config.default_env writes into a fresh ~/.pwn/pwn.yaml).
|
|
22
|
+
private_class_method def self.real_config_value?(opts = {})
|
|
23
|
+
s = opts[:value].to_s.strip
|
|
24
|
+
return false if s.empty?
|
|
25
|
+
return false if s.match?(/\A(optional|required)\b/i)
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Anthropic / Claude OAuth (Claude Pro/Max) -- public-client PKCE.
|
|
32
|
+
#
|
|
33
|
+
# Same identity path Claude Code / community tools use:
|
|
34
|
+
# * public client_id 9d1c250a-e61b-44d9-88ed-5944d1962f5e (no secret)
|
|
35
|
+
# * authorization_code + S256 PKCE against claude.ai
|
|
36
|
+
# * token endpoint at platform.claude.com
|
|
37
|
+
# * redirect_uri is the official hosted callback that returns a
|
|
38
|
+
# pasteable code (code=true) -- SSH / headless friendly, no
|
|
39
|
+
# localhost listener required.
|
|
40
|
+
#
|
|
41
|
+
# Access tokens are short-lived; refresh_token is durable. Persist via
|
|
42
|
+
# pwn-vault under ai.anthropic.oauth.refresh_token.
|
|
43
|
+
#
|
|
44
|
+
# Wire format for OAuth bearers is different from API keys:
|
|
45
|
+
# Authorization: Bearer <access_token>
|
|
46
|
+
# anthropic-beta: <claude-code betas>
|
|
47
|
+
# (NO x-api-key)
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
ANTHROPIC_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
50
|
+
ANTHROPIC_OAUTH_AUTHORIZE_URI = 'https://claude.ai/oauth/authorize'
|
|
51
|
+
ANTHROPIC_OAUTH_TOKEN_URI = 'https://platform.claude.com/v1/oauth/token'
|
|
52
|
+
ANTHROPIC_OAUTH_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback'
|
|
53
|
+
ANTHROPIC_OAUTH_SCOPE = 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload'
|
|
54
|
+
ANTHROPIC_OAUTH_USER_AGENT = 'claude-cli/2.1.81 (external, cli)'
|
|
55
|
+
ANTHROPIC_OAUTH_BETA_FLAGS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05'
|
|
56
|
+
|
|
57
|
+
private_class_method def self.pkce_pair
|
|
58
|
+
verifier = Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
|
|
59
|
+
challenge = Base64.urlsafe_encode64(
|
|
60
|
+
Digest::SHA256.digest(verifier),
|
|
61
|
+
padding: false
|
|
62
|
+
)
|
|
63
|
+
{ verifier: verifier, challenge: challenge }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method def self.jwt_exp(opts = {})
|
|
67
|
+
seg = opts[:token].to_s.split('.')[1]
|
|
68
|
+
return nil unless seg
|
|
69
|
+
|
|
70
|
+
seg += '=' * ((4 - (seg.length % 4)) % 4)
|
|
71
|
+
JSON.parse(Base64.urlsafe_decode64(seg))['exp']
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method def self.oauth_token_expiring?(opts = {})
|
|
77
|
+
token = opts[:token]
|
|
78
|
+
skew = opts[:skew] || 120
|
|
79
|
+
return true unless real_config_value?(value: token)
|
|
80
|
+
|
|
81
|
+
# Prefer explicit expires_at when present (Anthropic access tokens are often opaque).
|
|
82
|
+
expires_at = opts[:expires_at]
|
|
83
|
+
return Time.now.to_i >= (expires_at.to_i - skew) if expires_at
|
|
84
|
+
|
|
85
|
+
exp = jwt_exp(token: token)
|
|
86
|
+
return false if exp.nil? # opaque token -- trust it until 401
|
|
87
|
+
|
|
88
|
+
Time.now.to_i >= (exp.to_i - skew)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Supported Method Parameters::
|
|
92
|
+
# access_token = PWN::AI::Anthropic.refresh_oauth_bearer_token(
|
|
93
|
+
# refresh_token: 'required - Anthropic OAuth refresh_token',
|
|
94
|
+
# client_id: 'optional - defaults to Claude Code public client',
|
|
95
|
+
# token_uri: 'optional - defaults to platform.claude.com token endpoint'
|
|
96
|
+
# )
|
|
97
|
+
public_class_method def self.refresh_oauth_bearer_token(opts = {})
|
|
98
|
+
refresh_token = opts[:refresh_token]
|
|
99
|
+
raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
|
|
100
|
+
|
|
101
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : ANTHROPIC_OAUTH_CLIENT_ID
|
|
102
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : ANTHROPIC_OAUTH_TOKEN_URI
|
|
103
|
+
|
|
104
|
+
resp = RestClient.post(
|
|
105
|
+
token_uri,
|
|
106
|
+
{
|
|
107
|
+
grant_type: 'refresh_token',
|
|
108
|
+
refresh_token: refresh_token,
|
|
109
|
+
client_id: client_id
|
|
110
|
+
},
|
|
111
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
112
|
+
accept: 'application/json',
|
|
113
|
+
user_agent: ANTHROPIC_OAUTH_USER_AGENT
|
|
114
|
+
)
|
|
115
|
+
data = JSON.parse(resp.body)
|
|
116
|
+
raise "Anthropic OAuth refresh error: #{data['error']} - #{data['error_description'] || data['message']}" if data['error']
|
|
117
|
+
|
|
118
|
+
opts[:bearer_token] = data['access_token']
|
|
119
|
+
opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
|
|
120
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
121
|
+
data['access_token']
|
|
122
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
123
|
+
raise "Anthropic OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Supported Method Parameters::
|
|
127
|
+
# bearer = PWN::AI::Anthropic.obtain_oauth_bearer_token(
|
|
128
|
+
# client_id: 'optional - Claude Code public client id',
|
|
129
|
+
# scope: 'optional - space-delimited scopes',
|
|
130
|
+
# redirect_uri: 'optional - must match registered callback',
|
|
131
|
+
# authorize_uri:'optional - claude.ai authorize endpoint',
|
|
132
|
+
# token_uri: 'optional - platform.claude.com token endpoint'
|
|
133
|
+
# )
|
|
134
|
+
#
|
|
135
|
+
# Runs the OAuth 2.0 Authorization Code + PKCE (S256) flow against
|
|
136
|
+
# Anthropic's public Claude Code client. The hosted redirect returns a
|
|
137
|
+
# pasteable code (code=true) so this works over SSH with no localhost
|
|
138
|
+
# listener -- same UX class as the Grok device flow.
|
|
139
|
+
public_class_method def self.obtain_oauth_bearer_token(opts = {})
|
|
140
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : ANTHROPIC_OAUTH_CLIENT_ID
|
|
141
|
+
scope = real_config_value?(value: opts[:scope]) ? opts[:scope] : ANTHROPIC_OAUTH_SCOPE
|
|
142
|
+
redirect_uri = real_config_value?(value: opts[:redirect_uri]) ? opts[:redirect_uri] : ANTHROPIC_OAUTH_REDIRECT_URI
|
|
143
|
+
authorize_uri = real_config_value?(value: opts[:authorize_uri]) ? opts[:authorize_uri] : ANTHROPIC_OAUTH_AUTHORIZE_URI
|
|
144
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : ANTHROPIC_OAUTH_TOKEN_URI
|
|
145
|
+
|
|
146
|
+
pkce = pkce_pair
|
|
147
|
+
params = {
|
|
148
|
+
'code' => 'true',
|
|
149
|
+
'response_type' => 'code',
|
|
150
|
+
'client_id' => client_id,
|
|
151
|
+
'redirect_uri' => redirect_uri,
|
|
152
|
+
'scope' => scope,
|
|
153
|
+
'code_challenge' => pkce[:challenge],
|
|
154
|
+
'code_challenge_method' => 'S256',
|
|
155
|
+
'state' => pkce[:verifier]
|
|
156
|
+
}
|
|
157
|
+
auth_url = "#{authorize_uri}?#{URI.encode_www_form(params)}"
|
|
158
|
+
|
|
159
|
+
puts "\n[*] Anthropic / Claude OAuth -- Authorization Code + PKCE (S256, public client, no secret)"
|
|
160
|
+
puts ' A Claude Pro / Max subscription on the approving account is required for inference scope.'
|
|
161
|
+
puts ''
|
|
162
|
+
puts ' Step 1: In a browser (any device), open:'
|
|
163
|
+
puts " #{auth_url}"
|
|
164
|
+
puts ' Step 2: Approve access for Claude Code / CLI.'
|
|
165
|
+
puts ' Step 3: Copy the authorization code shown after redirect and paste it below.'
|
|
166
|
+
puts ''
|
|
167
|
+
print ' Authorization code> '
|
|
168
|
+
raw = $stdin.gets
|
|
169
|
+
raise 'Anthropic OAuth enrollment aborted: no code provided.' if raw.nil?
|
|
170
|
+
|
|
171
|
+
# Hosted callback sometimes returns "code#state" — take the code segment only.
|
|
172
|
+
code = raw.to_s.strip.split('#', 2).first
|
|
173
|
+
raise 'Anthropic OAuth enrollment aborted: empty code.' if code.empty?
|
|
174
|
+
|
|
175
|
+
resp = RestClient.post(
|
|
176
|
+
token_uri,
|
|
177
|
+
{
|
|
178
|
+
grant_type: 'authorization_code',
|
|
179
|
+
code: code,
|
|
180
|
+
code_verifier: pkce[:verifier],
|
|
181
|
+
client_id: client_id,
|
|
182
|
+
redirect_uri: redirect_uri,
|
|
183
|
+
state: pkce[:verifier]
|
|
184
|
+
},
|
|
185
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
186
|
+
accept: 'application/json',
|
|
187
|
+
user_agent: ANTHROPIC_OAUTH_USER_AGENT
|
|
188
|
+
)
|
|
189
|
+
data = JSON.parse(resp.body)
|
|
190
|
+
raise "Anthropic OAuth token error: #{data['error']} - #{data['error_description'] || data['message']}" if data['error']
|
|
191
|
+
|
|
192
|
+
access_token = data['access_token']
|
|
193
|
+
refresh_token = data['refresh_token']
|
|
194
|
+
raise 'Anthropic OAuth token endpoint returned no access_token.' unless access_token
|
|
195
|
+
|
|
196
|
+
opts[:bearer_token] = access_token
|
|
197
|
+
opts[:refresh_token] = refresh_token if refresh_token
|
|
198
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
199
|
+
|
|
200
|
+
puts "\n[*] SUCCESS: Anthropic OAuth bearer obtained via authorization_code + PKCE."
|
|
201
|
+
puts ' Cached in-memory for this pwn / pwn-ai process.'
|
|
202
|
+
puts ''
|
|
203
|
+
puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
|
|
204
|
+
puts " ai.anthropic.oauth.refresh_token = #{refresh_token}" if refresh_token
|
|
205
|
+
puts " ai.anthropic.oauth.bearer_token = #{access_token}"
|
|
206
|
+
puts ' On future runs the refresh_token alone is enough -- PWN::AI::Anthropic will'
|
|
207
|
+
puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
|
|
208
|
+
puts ''
|
|
209
|
+
|
|
210
|
+
access_token
|
|
211
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
212
|
+
raise "Failed to obtain Anthropic OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
raise "Failed to obtain Anthropic OAuth bearer token: #{e.message}"
|
|
215
|
+
end
|
|
216
|
+
|
|
15
217
|
# Supported Method Parameters::
|
|
16
218
|
# anthropic_rest_call(
|
|
17
219
|
# token: 'required - anthropic api key',
|
|
@@ -25,10 +227,55 @@ module PWN
|
|
|
25
227
|
# )
|
|
26
228
|
|
|
27
229
|
private_class_method def self.anthropic_rest_call(opts = {})
|
|
28
|
-
engine = PWN::Env[:ai][:anthropic]
|
|
230
|
+
engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
|
|
29
231
|
raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
30
232
|
|
|
31
|
-
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Credential resolution (PWN::Config / pwn-vault via PWN::Env).
|
|
235
|
+
# Priority:
|
|
236
|
+
# 1. oauth[:bearer_token] (not expiring)
|
|
237
|
+
# 2. oauth[:refresh_token] (silent refresh)
|
|
238
|
+
# 3. oauth device/PKCE enroll when oauth opted-in OR no API key
|
|
239
|
+
# 4. engine[:key] (classic console API key via x-api-key)
|
|
240
|
+
# 5. interactive prompt
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
|
|
243
|
+
token = nil
|
|
244
|
+
using_oauth = false
|
|
245
|
+
|
|
246
|
+
if real_config_value?(value: oauth[:bearer_token]) &&
|
|
247
|
+
!oauth_token_expiring?(token: oauth[:bearer_token], expires_at: oauth[:expires_at])
|
|
248
|
+
token = oauth[:bearer_token]
|
|
249
|
+
using_oauth = true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if token.nil? && real_config_value?(value: oauth[:refresh_token])
|
|
253
|
+
begin
|
|
254
|
+
token = refresh_oauth_bearer_token(oauth)
|
|
255
|
+
using_oauth = true
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
warn "[!] Anthropic OAuth refresh failed, falling back: #{e.message}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
|
|
262
|
+
oauth[:enroll] == true ||
|
|
263
|
+
real_config_value?(value: oauth[:bearer_token]) ||
|
|
264
|
+
real_config_value?(value: oauth[:refresh_token])
|
|
265
|
+
|
|
266
|
+
if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
|
|
267
|
+
token = obtain_oauth_bearer_token(oauth)
|
|
268
|
+
using_oauth = true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
if token.nil? && real_config_value?(value: engine[:key])
|
|
272
|
+
token = engine[:key]
|
|
273
|
+
using_oauth = false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
token ||= PWN::Plugins::AuthenticationHelper.mask_password(
|
|
277
|
+
prompt: 'Anthropic API Key (or run PWN::AI::Anthropic.obtain_oauth_bearer_token for Claude Pro/Max OAuth)'
|
|
278
|
+
)
|
|
32
279
|
|
|
33
280
|
http_method = if opts[:http_method].nil?
|
|
34
281
|
:get
|
|
@@ -36,14 +283,24 @@ module PWN
|
|
|
36
283
|
opts[:http_method].to_s.scrub.to_sym
|
|
37
284
|
end
|
|
38
285
|
|
|
39
|
-
base_uri = engine[:base_uri]
|
|
286
|
+
base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.anthropic.com/v1'
|
|
40
287
|
rest_call = opts[:rest_call].to_s.scrub
|
|
41
288
|
params = opts[:params]
|
|
42
289
|
headers = {
|
|
43
290
|
content_type: 'application/json; charset=UTF-8',
|
|
44
|
-
'x-api-key': token,
|
|
45
291
|
'anthropic-version': '2023-06-01'
|
|
46
292
|
}
|
|
293
|
+
if using_oauth
|
|
294
|
+
beta = real_config_value?(value: oauth[:beta_flags]) ? oauth[:beta_flags] : ANTHROPIC_OAUTH_BETA_FLAGS
|
|
295
|
+
ua = real_config_value?(value: oauth[:user_agent]) ? oauth[:user_agent] : ANTHROPIC_OAUTH_USER_AGENT
|
|
296
|
+
headers[:authorization] = "Bearer #{token}"
|
|
297
|
+
headers['anthropic-beta'] = beta
|
|
298
|
+
headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
|
299
|
+
headers['user-agent'] = ua
|
|
300
|
+
headers['x-app'] = 'cli'
|
|
301
|
+
else
|
|
302
|
+
headers['x-api-key'] = token
|
|
303
|
+
end
|
|
47
304
|
|
|
48
305
|
http_body = opts[:http_body]
|
|
49
306
|
http_body ||= {}
|
|
@@ -476,6 +733,10 @@ module PWN
|
|
|
476
733
|
puts "USAGE:
|
|
477
734
|
models = #{self}.get_models
|
|
478
735
|
|
|
736
|
+
# One-time Claude Pro/Max OAuth enrollment (PKCE paste flow):
|
|
737
|
+
bearer = #{self}.obtain_oauth_bearer_token
|
|
738
|
+
# Subsequent runs silently refresh via ai.anthropic.oauth.refresh_token
|
|
739
|
+
|
|
479
740
|
response = #{self}.chat(
|
|
480
741
|
request: 'required - message to Anthropic',
|
|
481
742
|
model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:anthropic][:model])',
|
data/lib/pwn/ai/open_ai.rb
CHANGED
|
@@ -4,6 +4,8 @@ require 'json'
|
|
|
4
4
|
require 'base64'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'tty-spinner'
|
|
7
|
+
require 'digest'
|
|
8
|
+
require 'uri'
|
|
7
9
|
|
|
8
10
|
module PWN
|
|
9
11
|
module AI
|
|
@@ -12,6 +14,264 @@ module PWN
|
|
|
12
14
|
# This is based on the following OpenAI API Specification:
|
|
13
15
|
# https://api.openai.com/v1
|
|
14
16
|
module OpenAI
|
|
17
|
+
# Internal helper: true when +opts[:value]+ is a *real* configured value
|
|
18
|
+
# coming from PWN::Config / pwn-vault (not a placeholder string).
|
|
19
|
+
private_class_method def self.real_config_value?(opts = {})
|
|
20
|
+
s = opts[:value].to_s.strip
|
|
21
|
+
return false if s.empty?
|
|
22
|
+
return false if s.match?(/\A(optional|required)\b/i)
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# OpenAI / ChatGPT OAuth (Codex / ChatGPT subscription) -- public client.
|
|
29
|
+
#
|
|
30
|
+
# Same identity path openai/codex uses:
|
|
31
|
+
# * public client_id app_EMoamEEZ73f0CkXaXp7hrann (no secret)
|
|
32
|
+
# * issuer https://auth.openai.com
|
|
33
|
+
# * device-code UX via /api/accounts/deviceauth/* then authorization_code
|
|
34
|
+
# exchange at /oauth/token (PKCE verifier supplied by the deviceauth
|
|
35
|
+
# token response -- no localhost listener)
|
|
36
|
+
# * refresh_token grant at /oauth/token (JSON body, same as codex)
|
|
37
|
+
#
|
|
38
|
+
# Access tokens are short-lived JWTs. Persist refresh_token via
|
|
39
|
+
# pwn-vault under ai.openai.oauth.refresh_token.
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
OPENAI_OAUTH_ISSUER = 'https://auth.openai.com'
|
|
42
|
+
OPENAI_OAUTH_TOKEN_URI = "#{OPENAI_OAUTH_ISSUER}/oauth/token".freeze
|
|
43
|
+
OPENAI_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
|
44
|
+
OPENAI_OAUTH_SCOPE = 'openid profile email offline_access'
|
|
45
|
+
OPENAI_OAUTH_DEVICE_USERCODE_URI = "#{OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/usercode".freeze
|
|
46
|
+
OPENAI_OAUTH_DEVICE_TOKEN_URI = "#{OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/token".freeze
|
|
47
|
+
OPENAI_OAUTH_DEVICE_VERIFY_URI = "#{OPENAI_OAUTH_ISSUER}/codex/device".freeze
|
|
48
|
+
OPENAI_OAUTH_DEVICE_REDIRECT_URI = "#{OPENAI_OAUTH_ISSUER}/deviceauth/callback".freeze
|
|
49
|
+
|
|
50
|
+
private_class_method def self.jwt_exp(opts = {})
|
|
51
|
+
seg = opts[:token].to_s.split('.')[1]
|
|
52
|
+
return nil unless seg
|
|
53
|
+
|
|
54
|
+
seg += '=' * ((4 - (seg.length % 4)) % 4)
|
|
55
|
+
JSON.parse(Base64.urlsafe_decode64(seg))['exp']
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method def self.oauth_token_expiring?(opts = {})
|
|
61
|
+
token = opts[:token]
|
|
62
|
+
skew = opts[:skew] || 120
|
|
63
|
+
return true unless real_config_value?(value: token)
|
|
64
|
+
|
|
65
|
+
expires_at = opts[:expires_at]
|
|
66
|
+
return Time.now.to_i >= (expires_at.to_i - skew) if expires_at
|
|
67
|
+
|
|
68
|
+
exp = jwt_exp(token: token)
|
|
69
|
+
return false if exp.nil?
|
|
70
|
+
|
|
71
|
+
Time.now.to_i >= (exp.to_i - skew)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Supported Method Parameters::
|
|
75
|
+
# access_token = PWN::AI::OpenAI.refresh_oauth_bearer_token(
|
|
76
|
+
# refresh_token: 'required - OpenAI/ChatGPT OAuth refresh_token',
|
|
77
|
+
# client_id: 'optional - defaults to Codex public client',
|
|
78
|
+
# token_uri: 'optional - defaults to https://auth.openai.com/oauth/token'
|
|
79
|
+
# )
|
|
80
|
+
#
|
|
81
|
+
# Codex posts JSON (not form-urlencoded) to the refresh endpoint.
|
|
82
|
+
public_class_method def self.refresh_oauth_bearer_token(opts = {})
|
|
83
|
+
refresh_token = opts[:refresh_token]
|
|
84
|
+
raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
|
|
85
|
+
|
|
86
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : OPENAI_OAUTH_CLIENT_ID
|
|
87
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : OPENAI_OAUTH_TOKEN_URI
|
|
88
|
+
|
|
89
|
+
resp = RestClient.post(
|
|
90
|
+
token_uri,
|
|
91
|
+
{
|
|
92
|
+
client_id: client_id,
|
|
93
|
+
grant_type: 'refresh_token',
|
|
94
|
+
refresh_token: refresh_token
|
|
95
|
+
}.to_json,
|
|
96
|
+
content_type: 'application/json',
|
|
97
|
+
accept: 'application/json'
|
|
98
|
+
)
|
|
99
|
+
data = JSON.parse(resp.body)
|
|
100
|
+
raise "OpenAI OAuth refresh error: #{data['error']} - #{data['error_description'] || data.dig('error', 'message')}" if data['error'] && !data['access_token']
|
|
101
|
+
|
|
102
|
+
access = data['access_token']
|
|
103
|
+
raise 'OpenAI OAuth refresh returned no access_token.' unless access
|
|
104
|
+
|
|
105
|
+
opts[:bearer_token] = access
|
|
106
|
+
opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
|
|
107
|
+
opts[:id_token] = data['id_token'] if data['id_token']
|
|
108
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
109
|
+
# Prefer explicit account id; fall back to JWT claim when present.
|
|
110
|
+
if data['id_token']
|
|
111
|
+
begin
|
|
112
|
+
exp_seg = data['id_token'].to_s.split('.')[1]
|
|
113
|
+
if exp_seg
|
|
114
|
+
exp_seg += '=' * ((4 - (exp_seg.length % 4)) % 4)
|
|
115
|
+
claims = JSON.parse(Base64.urlsafe_decode64(exp_seg))
|
|
116
|
+
acct = claims.dig('https://api.openai.com/auth', 'chatgpt_account_id')
|
|
117
|
+
opts[:account_id] = acct if acct
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# ignore claim parse failures
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
access
|
|
124
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
125
|
+
raise "OpenAI OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Supported Method Parameters::
|
|
129
|
+
# bearer = PWN::AI::OpenAI.obtain_oauth_bearer_token(
|
|
130
|
+
# client_id: 'optional - Codex public client id',
|
|
131
|
+
# issuer: 'optional - defaults to https://auth.openai.com',
|
|
132
|
+
# timeout: 'optional - seconds to wait for user consent (default 900)'
|
|
133
|
+
# )
|
|
134
|
+
#
|
|
135
|
+
# Runs the Codex device-code login:
|
|
136
|
+
# 1. POST /api/accounts/deviceauth/usercode -> device_auth_id, user_code
|
|
137
|
+
# 2. User opens https://auth.openai.com/codex/device and enters code
|
|
138
|
+
# 3. Poll POST /api/accounts/deviceauth/token until authorization_code + pkce
|
|
139
|
+
# 4. POST /oauth/token authorization_code grant -> access/refresh/id tokens
|
|
140
|
+
public_class_method def self.obtain_oauth_bearer_token(opts = {})
|
|
141
|
+
client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : OPENAI_OAUTH_CLIENT_ID
|
|
142
|
+
issuer = real_config_value?(value: opts[:issuer]) ? opts[:issuer].to_s.sub(%r{/*\z}, '') : OPENAI_OAUTH_ISSUER
|
|
143
|
+
token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : "#{issuer}/oauth/token"
|
|
144
|
+
usercode_uri = "#{issuer}/api/accounts/deviceauth/usercode"
|
|
145
|
+
poll_uri = "#{issuer}/api/accounts/deviceauth/token"
|
|
146
|
+
verify_uri = "#{issuer}/codex/device"
|
|
147
|
+
redirect_uri = "#{issuer}/deviceauth/callback"
|
|
148
|
+
timeout = (opts[:timeout] || 900).to_i
|
|
149
|
+
|
|
150
|
+
# -- Step 1: request user code ---------------------------------------
|
|
151
|
+
uc = JSON.parse(
|
|
152
|
+
RestClient.post(
|
|
153
|
+
usercode_uri,
|
|
154
|
+
{ client_id: client_id }.to_json,
|
|
155
|
+
content_type: 'application/json',
|
|
156
|
+
accept: 'application/json'
|
|
157
|
+
).body
|
|
158
|
+
)
|
|
159
|
+
raise "OpenAI device usercode error: #{uc['error'] || uc}" if uc['error'] && !uc['device_auth_id']
|
|
160
|
+
|
|
161
|
+
device_auth_id = uc['device_auth_id']
|
|
162
|
+
user_code = uc['user_code'] || uc['usercode']
|
|
163
|
+
interval = (uc['interval'] || 5).to_i
|
|
164
|
+
interval = 5 if interval <= 0
|
|
165
|
+
deadline = Time.now.to_i + timeout
|
|
166
|
+
|
|
167
|
+
raise 'OpenAI device usercode response missing device_auth_id/user_code' if device_auth_id.to_s.empty? || user_code.to_s.empty?
|
|
168
|
+
|
|
169
|
+
puts "\n[*] OpenAI / ChatGPT OAuth -- Device Authorization (Codex public client, no secret)"
|
|
170
|
+
puts ' A ChatGPT Plus / Pro / Team / Enterprise plan is typically required for Codex API access.'
|
|
171
|
+
puts ''
|
|
172
|
+
puts ' Step 1: In a browser (any device), open:'
|
|
173
|
+
puts " #{verify_uri}"
|
|
174
|
+
puts " Step 2: Enter this one-time code: #{user_code}"
|
|
175
|
+
puts ' Step 3: Approve access for Codex / ChatGPT.'
|
|
176
|
+
puts ''
|
|
177
|
+
puts " Waiting for approval (polling every #{interval}s, timeout #{timeout}s)..."
|
|
178
|
+
|
|
179
|
+
# -- Step 2: poll until authorization_code + pkce material -----------
|
|
180
|
+
code_resp = nil
|
|
181
|
+
loop do
|
|
182
|
+
sleep interval
|
|
183
|
+
begin
|
|
184
|
+
poll = RestClient.post(
|
|
185
|
+
poll_uri,
|
|
186
|
+
{
|
|
187
|
+
device_auth_id: device_auth_id,
|
|
188
|
+
user_code: user_code
|
|
189
|
+
}.to_json,
|
|
190
|
+
content_type: 'application/json',
|
|
191
|
+
accept: 'application/json'
|
|
192
|
+
)
|
|
193
|
+
code_resp = JSON.parse(poll.body)
|
|
194
|
+
break if code_resp['authorization_code']
|
|
195
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
196
|
+
# Codex treats 403/404 as "still pending"
|
|
197
|
+
if [403, 404].include?(e.http_code)
|
|
198
|
+
raise 'OpenAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
|
|
199
|
+
|
|
200
|
+
next
|
|
201
|
+
end
|
|
202
|
+
body = begin
|
|
203
|
+
JSON.parse(e.response.body)
|
|
204
|
+
rescue StandardError
|
|
205
|
+
{ 'error' => "http_#{e.http_code}", 'error_description' => e.response&.body }
|
|
206
|
+
end
|
|
207
|
+
raise "OpenAI OAuth device poll error: #{body['error']} - #{body['error_description'] || body}"
|
|
208
|
+
end
|
|
209
|
+
raise 'OpenAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
authorization_code = code_resp['authorization_code']
|
|
213
|
+
code_verifier = code_resp['code_verifier']
|
|
214
|
+
raise 'OpenAI deviceauth/token returned no authorization_code.' unless authorization_code
|
|
215
|
+
raise 'OpenAI deviceauth/token returned no code_verifier.' unless code_verifier
|
|
216
|
+
|
|
217
|
+
# -- Step 3: exchange authorization_code for tokens ------------------
|
|
218
|
+
resp = RestClient.post(
|
|
219
|
+
token_uri,
|
|
220
|
+
URI.encode_www_form(
|
|
221
|
+
grant_type: 'authorization_code',
|
|
222
|
+
code: authorization_code,
|
|
223
|
+
redirect_uri: redirect_uri,
|
|
224
|
+
client_id: client_id,
|
|
225
|
+
code_verifier: code_verifier
|
|
226
|
+
),
|
|
227
|
+
content_type: 'application/x-www-form-urlencoded',
|
|
228
|
+
accept: 'application/json'
|
|
229
|
+
)
|
|
230
|
+
data = JSON.parse(resp.body)
|
|
231
|
+
raise "OpenAI OAuth token error: #{data['error']} - #{data['error_description']}" if data['error'] && !data['access_token']
|
|
232
|
+
|
|
233
|
+
access_token = data['access_token']
|
|
234
|
+
refresh_token = data['refresh_token']
|
|
235
|
+
id_token = data['id_token']
|
|
236
|
+
raise 'OpenAI OAuth token endpoint returned no access_token.' unless access_token
|
|
237
|
+
|
|
238
|
+
opts[:bearer_token] = access_token
|
|
239
|
+
opts[:refresh_token] = refresh_token if refresh_token
|
|
240
|
+
opts[:id_token] = id_token if id_token
|
|
241
|
+
opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
|
|
242
|
+
|
|
243
|
+
if id_token
|
|
244
|
+
begin
|
|
245
|
+
seg = id_token.to_s.split('.')[1]
|
|
246
|
+
if seg
|
|
247
|
+
seg += '=' * ((4 - (seg.length % 4)) % 4)
|
|
248
|
+
claims = JSON.parse(Base64.urlsafe_decode64(seg))
|
|
249
|
+
acct = claims.dig('https://api.openai.com/auth', 'chatgpt_account_id')
|
|
250
|
+
opts[:account_id] = acct if acct
|
|
251
|
+
end
|
|
252
|
+
rescue StandardError
|
|
253
|
+
# ignore
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
puts "\n[*] SUCCESS: OpenAI / ChatGPT OAuth bearer obtained via device_code grant."
|
|
258
|
+
puts ' Cached in-memory for this pwn / pwn-ai process.'
|
|
259
|
+
puts ''
|
|
260
|
+
puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
|
|
261
|
+
puts " ai.openai.oauth.refresh_token = #{refresh_token}" if refresh_token
|
|
262
|
+
puts " ai.openai.oauth.bearer_token = #{access_token}"
|
|
263
|
+
puts " ai.openai.oauth.account_id = #{opts[:account_id]}" if opts[:account_id]
|
|
264
|
+
puts ' On future runs the refresh_token alone is enough -- PWN::AI::OpenAI will'
|
|
265
|
+
puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
|
|
266
|
+
puts ''
|
|
267
|
+
|
|
268
|
+
access_token
|
|
269
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
270
|
+
raise "Failed to obtain OpenAI OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
raise "Failed to obtain OpenAI OAuth bearer token: #{e.message}"
|
|
273
|
+
end
|
|
274
|
+
|
|
15
275
|
# Supported Method Parameters::
|
|
16
276
|
# open_ai_rest_call(
|
|
17
277
|
# http_method: 'optional HTTP method (defaults to GET)
|
|
@@ -23,23 +283,62 @@ module PWN
|
|
|
23
283
|
# )
|
|
24
284
|
|
|
25
285
|
private_class_method def self.open_ai_rest_call(opts = {})
|
|
26
|
-
engine = PWN::Env[:ai][:openai]
|
|
27
|
-
raise 'ERROR:
|
|
286
|
+
engine = PWN::Env[:ai][:openai] if defined?(PWN::Env)
|
|
287
|
+
raise 'ERROR: OpenAI Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
# Credential resolution (PWN::Config / pwn-vault via PWN::Env).
|
|
291
|
+
# Priority:
|
|
292
|
+
# 1. oauth[:bearer_token] (not expiring)
|
|
293
|
+
# 2. oauth[:refresh_token] (silent refresh)
|
|
294
|
+
# 3. oauth device flow when oauth opted-in OR no API key
|
|
295
|
+
# 4. engine[:key] (classic OpenAI API key)
|
|
296
|
+
# 5. interactive prompt
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
|
|
299
|
+
token = nil
|
|
300
|
+
|
|
301
|
+
if real_config_value?(value: oauth[:bearer_token]) &&
|
|
302
|
+
!oauth_token_expiring?(token: oauth[:bearer_token], expires_at: oauth[:expires_at])
|
|
303
|
+
token = oauth[:bearer_token]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if token.nil? && real_config_value?(value: oauth[:refresh_token])
|
|
307
|
+
begin
|
|
308
|
+
token = refresh_oauth_bearer_token(oauth)
|
|
309
|
+
rescue StandardError => e
|
|
310
|
+
warn "[!] OpenAI OAuth refresh failed, falling back: #{e.message}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
|
|
315
|
+
oauth[:enroll] == true ||
|
|
316
|
+
real_config_value?(value: oauth[:bearer_token]) ||
|
|
317
|
+
real_config_value?(value: oauth[:refresh_token])
|
|
318
|
+
|
|
319
|
+
token = obtain_oauth_bearer_token(oauth) if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
|
|
320
|
+
|
|
321
|
+
token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
|
|
322
|
+
|
|
323
|
+
token ||= PWN::Plugins::AuthenticationHelper.mask_password(
|
|
324
|
+
prompt: 'OpenAI API Key (or run PWN::AI::OpenAI.obtain_oauth_bearer_token for ChatGPT/Codex OAuth)'
|
|
325
|
+
)
|
|
28
326
|
|
|
29
|
-
token = engine[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'OpenAI API Key')
|
|
30
327
|
http_method = if opts[:http_method].nil?
|
|
31
328
|
:get
|
|
32
329
|
else
|
|
33
330
|
opts[:http_method].to_s.scrub.to_sym
|
|
34
331
|
end
|
|
35
332
|
|
|
36
|
-
base_uri = engine[:base_uri]
|
|
333
|
+
base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.openai.com/v1'
|
|
37
334
|
rest_call = opts[:rest_call].to_s.scrub
|
|
38
335
|
params = opts[:params]
|
|
39
336
|
headers = {
|
|
40
337
|
content_type: 'application/json; charset=UTF-8',
|
|
41
338
|
authorization: "Bearer #{token}"
|
|
42
339
|
}
|
|
340
|
+
# ChatGPT subscription tokens often need the account id header (codex).
|
|
341
|
+
headers['ChatGPT-Account-Id'] = oauth[:account_id] if real_config_value?(value: oauth[:account_id])
|
|
43
342
|
|
|
44
343
|
http_body = opts[:http_body]
|
|
45
344
|
http_body ||= {}
|
|
@@ -746,6 +1045,10 @@ module PWN
|
|
|
746
1045
|
puts "USAGE:
|
|
747
1046
|
models = #{self}.get_models
|
|
748
1047
|
|
|
1048
|
+
# One-time ChatGPT/Codex OAuth enrollment (device-code flow):
|
|
1049
|
+
bearer = #{self}.obtain_oauth_bearer_token
|
|
1050
|
+
# Subsequent runs silently refresh via ai.openai.oauth.refresh_token
|
|
1051
|
+
|
|
749
1052
|
response = #{self}.chat(
|
|
750
1053
|
request: 'required - message to ChatGPT',
|
|
751
1054
|
model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:openai][:model])',
|