graphmind 0.6.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 +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +464 -0
- data/lib/graphmind/api.rb +267 -0
- data/lib/graphmind/clock.rb +49 -0
- data/lib/graphmind/edit_guard.rb +87 -0
- data/lib/graphmind/env.rb +83 -0
- data/lib/graphmind/errors.rb +40 -0
- data/lib/graphmind/gate_engine.rb +282 -0
- data/lib/graphmind/held_ledger.rb +209 -0
- data/lib/graphmind/ids.rb +52 -0
- data/lib/graphmind/integrations/ruby_llm.rb +449 -0
- data/lib/graphmind/integrations/ruby_openai.rb +388 -0
- data/lib/graphmind/integrations/support.rb +459 -0
- data/lib/graphmind/jack.rb +201 -0
- data/lib/graphmind/loop_guard.rb +516 -0
- data/lib/graphmind/protocol.rb +123 -0
- data/lib/graphmind/railtie.rb +66 -0
- data/lib/graphmind/redaction.rb +781 -0
- data/lib/graphmind/ring_buffer.rb +75 -0
- data/lib/graphmind/runtime.rb +50 -0
- data/lib/graphmind/safe.rb +48 -0
- data/lib/graphmind/session.rb +1180 -0
- data/lib/graphmind/shrink.rb +787 -0
- data/lib/graphmind/token_batcher.rb +84 -0
- data/lib/graphmind/transport.rb +331 -0
- data/lib/graphmind/version.rb +5 -0
- data/lib/graphmind/websocket.rb +448 -0
- data/lib/graphmind/wrap.rb +295 -0
- data/lib/graphmind.rb +54 -0
- metadata +83 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
require_relative "../loop_guard"
|
|
7
|
+
|
|
8
|
+
module Graphmind
|
|
9
|
+
module Integrations
|
|
10
|
+
# Shared helpers for provider integrations, including the LLM-step capture
|
|
11
|
+
# rules of contract C1 (0.6.0) — a port of packages/client/src/llm-capture.ts
|
|
12
|
+
# held to the same conformance fixture (packages/client/test/fixtures/llm.json):
|
|
13
|
+
#
|
|
14
|
+
# make_usage the wire TokenUsage: inputTokens is the TOTAL prompt
|
|
15
|
+
# (cache reads and writes included), inclusive: true,
|
|
16
|
+
# cache/reasoning counts only when reported (never 0-filled)
|
|
17
|
+
# normalize_finish_reason / finish_fields
|
|
18
|
+
# stop|length|tool-calls|content-filter|error|other, plus
|
|
19
|
+
# the provider's own string as rawFinishReason
|
|
20
|
+
# tool_call {id?, name, input, inputText?} — inputText only when the
|
|
21
|
+
# argument text does not parse
|
|
22
|
+
# capture_tools tools: [{name, schemaHash}], each definition sent once
|
|
23
|
+
# per run as toolSchemas (sha256 of canonical JSON, 16 hex),
|
|
24
|
+
# as sanitize_tool_definition leaves it (never a credential)
|
|
25
|
+
# pick_params the allow-listed sampling parameters actually sent
|
|
26
|
+
# record a prompt made JSON-safe in FULL: no message-count or
|
|
27
|
+
# length trim (the session's 512 KB shrink is the bound)
|
|
28
|
+
module Support
|
|
29
|
+
# Kept for the one place a non-Hash provider response is shown as text.
|
|
30
|
+
MAX_CONTENT_CHARS = 2000
|
|
31
|
+
|
|
32
|
+
FINISH_REASON_MAP = {
|
|
33
|
+
"stop" => "stop", "end_turn" => "stop", "stop_sequence" => "stop", "pause_turn" => "other",
|
|
34
|
+
"eos" => "stop", "eos_token" => "stop", "complete" => "stop", "completed" => "stop",
|
|
35
|
+
"finished" => "stop",
|
|
36
|
+
"length" => "length", "max_tokens" => "length", "max_output_tokens" => "length",
|
|
37
|
+
"max_completion_tokens" => "length", "model_context_window_exceeded" => "length",
|
|
38
|
+
"model_length" => "length",
|
|
39
|
+
"tool_calls" => "tool-calls", "tool_call" => "tool-calls", "tool_use" => "tool-calls",
|
|
40
|
+
"function_call" => "tool-calls",
|
|
41
|
+
"content_filter" => "content-filter", "content_filtered" => "content-filter",
|
|
42
|
+
"refusal" => "content-filter", "safety" => "content-filter", "recitation" => "content-filter",
|
|
43
|
+
"blocklist" => "content-filter", "prohibited_content" => "content-filter",
|
|
44
|
+
"spii" => "content-filter", "image_safety" => "content-filter",
|
|
45
|
+
"guardrail_intervened" => "content-filter",
|
|
46
|
+
"error" => "error", "failed" => "error", "malformed_function_call" => "error",
|
|
47
|
+
"other" => "other", "unknown" => "other"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Same list, same order as the TypeScript client (pinned by the fixture).
|
|
51
|
+
SAMPLING_PARAM_KEYS = %w[
|
|
52
|
+
maxOutputTokens maxTokens temperature topP topK stopSequences presencePenalty
|
|
53
|
+
frequencyPenalty seed toolChoice responseFormat reasoning
|
|
54
|
+
max_tokens max_completion_tokens max_output_tokens top_p top_k stop stop_sequences
|
|
55
|
+
presence_penalty frequency_penalty logit_bias logprobs top_logprobs n tool_choice
|
|
56
|
+
parallel_tool_calls response_format reasoning_effort thinking service_tier truncation
|
|
57
|
+
text verbosity prompt_cache_key max_tool_calls context_management output_config
|
|
58
|
+
cache_control
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
SCHEMA_HASH_HEX_CHARS = 16
|
|
62
|
+
MAX_SCHEMA_RUNS = 256
|
|
63
|
+
MAX_SCHEMA_HASHES_PER_RUN = 1024
|
|
64
|
+
MAX_RECORD_DEPTH = 64
|
|
65
|
+
|
|
66
|
+
# Keys of a tool definition that hold its SCHEMA: recorded verbatim (a
|
|
67
|
+
# schema's property names are the tool's parameter names).
|
|
68
|
+
TOOL_SCHEMA_KEYS = %w[parameters input_schema inputSchema output_schema outputSchema schema format].freeze
|
|
69
|
+
# A key of a tool definition (outside its schema) that may carry a
|
|
70
|
+
# credential or transport configuration: never recorded.
|
|
71
|
+
TOOL_SECRET_KEY_RE = /authori[sz]ation|header|token|secret|passw(?:or)?d|key|cookie|credential|bearer/i
|
|
72
|
+
TOOL_URL_KEY_RE = /url\z/i
|
|
73
|
+
# A URL's userinfo: after its "//" (a backslash may stand for a slash, a
|
|
74
|
+
# tab or newline may sit between them; whatever precedes them is kept) up
|
|
75
|
+
# to the LAST "@" before the path, where URL parsers end it, so a password
|
|
76
|
+
# holding "@" goes whole. Same pattern as TypeScript.
|
|
77
|
+
URL_USERINFO_RE = %r{\A([^/\\]*[/\\][\t\n\r]*[/\\])[^/]*@}
|
|
78
|
+
MAX_TOOL_DEFINITION_DEPTH = 16
|
|
79
|
+
|
|
80
|
+
# What an owner (the session) was sent lives on the owner itself, so it is
|
|
81
|
+
# exactly as alive as the owner. Not an ObjectSpace::WeakMap: its VALUES
|
|
82
|
+
# are weak too, so any GC wiped it and the schemas were re-sent mid-run.
|
|
83
|
+
SCHEMA_MEMORY_IVAR = :@__graphmind_tool_schemas
|
|
84
|
+
SCHEMA_LOCK = Mutex.new
|
|
85
|
+
private_constant :SCHEMA_MEMORY_IVAR, :SCHEMA_LOCK
|
|
86
|
+
|
|
87
|
+
module_function
|
|
88
|
+
|
|
89
|
+
# Read a parameter that may be keyed by Symbol or String.
|
|
90
|
+
def param(parameters, key)
|
|
91
|
+
return nil unless parameters.respond_to?(:[])
|
|
92
|
+
|
|
93
|
+
value = parameters[key.to_sym]
|
|
94
|
+
value.nil? ? parameters[key.to_s] : value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def param_key(parameters, key)
|
|
98
|
+
return key.to_sym unless parameters.is_a?(Hash)
|
|
99
|
+
return key.to_sym if parameters.key?(key.to_sym)
|
|
100
|
+
return key.to_s if parameters.key?(key.to_s)
|
|
101
|
+
|
|
102
|
+
key.to_sym
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def truncate(text, limit = MAX_CONTENT_CHARS)
|
|
106
|
+
string = text.to_s
|
|
107
|
+
string.length > limit ? "#{string[0, limit]}… (#{string.length} chars)" : string
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -- usage ---------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
# A finite non-negative number rounded like JS Math.round, else nil.
|
|
113
|
+
def token_count(value)
|
|
114
|
+
case value
|
|
115
|
+
when Integer then value >= 0 ? value : nil
|
|
116
|
+
when Float then value.finite? && value >= 0 ? (value + 0.5).floor : nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The sum of the parts that were reported; nil when none was.
|
|
121
|
+
def sum_reported(*parts)
|
|
122
|
+
present = parts.compact
|
|
123
|
+
present.empty? ? nil : present.sum
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The wire TokenUsage, or nil when neither count was reported. The schema
|
|
127
|
+
# requires both counts, so the one a provider left out is 0; the optional
|
|
128
|
+
# counts are never invented.
|
|
129
|
+
def make_usage(input: nil, output: nil, cache_read: nil, cache_write: nil, reasoning: nil)
|
|
130
|
+
input = token_count(input)
|
|
131
|
+
output = token_count(output)
|
|
132
|
+
return nil if input.nil? && output.nil?
|
|
133
|
+
|
|
134
|
+
usage = { "inputTokens" => input || 0, "outputTokens" => output || 0, "inclusive" => true }
|
|
135
|
+
{ "cacheReadTokens" => cache_read, "cacheWriteTokens" => cache_write,
|
|
136
|
+
"reasoningTokens" => reasoning }.each do |key, value|
|
|
137
|
+
count = token_count(value)
|
|
138
|
+
usage[key] = count unless count.nil?
|
|
139
|
+
end
|
|
140
|
+
usage
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Chat Completions usage: prompt_tokens is inclusive; cache reads in
|
|
144
|
+
# prompt_tokens_details.cached_tokens (prompt_cache_hit_tokens on
|
|
145
|
+
# OpenAI-compatible servers), writes in .cache_write_tokens.
|
|
146
|
+
def openai_chat_usage(usage)
|
|
147
|
+
return nil unless usage.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
details = param(usage, :prompt_tokens_details)
|
|
150
|
+
cache_read = token_count(param(details, :cached_tokens))
|
|
151
|
+
cache_read = token_count(param(usage, :prompt_cache_hit_tokens)) if cache_read.nil?
|
|
152
|
+
make_usage(
|
|
153
|
+
input: param(usage, :prompt_tokens),
|
|
154
|
+
output: param(usage, :completion_tokens),
|
|
155
|
+
cache_read: cache_read,
|
|
156
|
+
cache_write: param(details, :cache_write_tokens),
|
|
157
|
+
reasoning: param(param(usage, :completion_tokens_details), :reasoning_tokens)
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Responses usage: input_tokens is inclusive; details in
|
|
162
|
+
# input_tokens_details / output_tokens_details.
|
|
163
|
+
def openai_responses_usage(usage)
|
|
164
|
+
return nil unless usage.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
details = param(usage, :input_tokens_details)
|
|
167
|
+
make_usage(
|
|
168
|
+
input: param(usage, :input_tokens),
|
|
169
|
+
output: param(usage, :output_tokens),
|
|
170
|
+
cache_read: param(details, :cached_tokens),
|
|
171
|
+
cache_write: param(details, :cache_write_tokens),
|
|
172
|
+
reasoning: param(param(usage, :output_tokens_details), :reasoning_tokens)
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Either OpenAI shape, by its keys.
|
|
177
|
+
def openai_usage(usage)
|
|
178
|
+
return nil unless usage.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
chat = !param(usage, :prompt_tokens).nil? || !param(usage, :completion_tokens).nil?
|
|
181
|
+
chat ? openai_chat_usage(usage) : openai_responses_usage(usage)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ruby_llm token counts: `input` is the NON-cached count in 2.0
|
|
185
|
+
# (Tokens#input, "standard (non-cached) input tokens") and in 1.x (every
|
|
186
|
+
# provider subtracts or reports the uncached tail), so the total adds the
|
|
187
|
+
# cache reads and writes. 1.x names them cached / cache_creation.
|
|
188
|
+
def ruby_llm_usage(input:, output:, cache_read: nil, cache_write: nil, thinking: nil)
|
|
189
|
+
read = token_count(cache_read)
|
|
190
|
+
write = token_count(cache_write)
|
|
191
|
+
make_usage(
|
|
192
|
+
input: sum_reported(token_count(input), read, write),
|
|
193
|
+
output: output,
|
|
194
|
+
cache_read: read,
|
|
195
|
+
cache_write: write,
|
|
196
|
+
reasoning: thinking
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# -- finish reasons ------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# The normalized finish reason, or nil when nothing usable was reported. A
|
|
203
|
+
# Symbol (ruby_llm's :stop, :max_tokens) counts as its name.
|
|
204
|
+
def normalize_finish_reason(raw, has_tool_calls = false)
|
|
205
|
+
raw = raw.name if raw.is_a?(Symbol)
|
|
206
|
+
return nil unless raw.is_a?(String)
|
|
207
|
+
|
|
208
|
+
key = raw.strip.downcase.gsub(/[-\s]+/, "_")
|
|
209
|
+
return nil if key.empty?
|
|
210
|
+
|
|
211
|
+
mapped = FINISH_REASON_MAP.fetch(key, "other")
|
|
212
|
+
mapped == "stop" && has_tool_calls ? "tool-calls" : mapped
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A `refused` step (OpenAI: a plain stop carrying a refusal) is
|
|
216
|
+
# content-filter, as Anthropic's `refusal` stop reason is.
|
|
217
|
+
def finish_fields(raw, has_tool_calls, refused = false)
|
|
218
|
+
out = {}
|
|
219
|
+
normalized = normalize_finish_reason(raw, has_tool_calls)
|
|
220
|
+
normalized = "content-filter" if refused && normalized == "stop"
|
|
221
|
+
out["finishReason"] = normalized unless normalized.nil?
|
|
222
|
+
raw = raw.name if raw.is_a?(Symbol)
|
|
223
|
+
out["rawFinishReason"] = String.new(raw) if raw.is_a?(String) && !raw.empty?
|
|
224
|
+
out
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# -- tool calls ----------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
# One requested call as {id?, name, input, inputText?}, or nil without a
|
|
230
|
+
# name. A String `args` is JSON-parsed (blank -> {}; text that does not
|
|
231
|
+
# parse -> input nil + inputText); anything else is the input as it is.
|
|
232
|
+
def tool_call(id, name, args)
|
|
233
|
+
name = name.name if name.is_a?(Symbol)
|
|
234
|
+
return nil unless name.is_a?(String) && !name.empty?
|
|
235
|
+
|
|
236
|
+
out = {}
|
|
237
|
+
out["id"] = String.new(id) if id.is_a?(String) && !id.empty?
|
|
238
|
+
out["name"] = String.new(name)
|
|
239
|
+
if args.nil?
|
|
240
|
+
out["input"] = {}
|
|
241
|
+
elsif args.is_a?(String)
|
|
242
|
+
if args.strip.empty?
|
|
243
|
+
out["input"] = {}
|
|
244
|
+
else
|
|
245
|
+
begin
|
|
246
|
+
out["input"] = JSON.parse(args)
|
|
247
|
+
rescue JSON::ParserError, EncodingError
|
|
248
|
+
out["input"] = nil
|
|
249
|
+
out["inputText"] = String.new(args)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
else
|
|
253
|
+
out["input"] = record(args)
|
|
254
|
+
end
|
|
255
|
+
out
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# -- tool definitions ----------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def schema_hash(definition)
|
|
261
|
+
Digest::SHA256.hexdigest(Graphmind::LoopGuard.canonicalize(definition))[0, SCHEMA_HASH_HEX_CHARS]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The name of one request tool definition in any provider shape.
|
|
265
|
+
def tool_def_name(entry)
|
|
266
|
+
return nil unless entry.is_a?(Hash)
|
|
267
|
+
|
|
268
|
+
[param(param(entry, :function), :name), param(param(entry, :custom), :name),
|
|
269
|
+
param(entry, :name), param(entry, :type)].each do |candidate|
|
|
270
|
+
candidate = candidate.name if candidate.is_a?(Symbol)
|
|
271
|
+
return String.new(candidate) if candidate.is_a?(String) && !candidate.empty?
|
|
272
|
+
end
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A (JSON-safe) tool definition as it is hashed and recorded: the schema
|
|
277
|
+
# keys verbatim, every key matching TOOL_SECRET_KEY_RE dropped at any
|
|
278
|
+
# depth outside them (an OpenAI `type: "mcp"` tool's authorization and
|
|
279
|
+
# headers), a `*url` value cut to scheme://host/path. A function tool is
|
|
280
|
+
# unchanged. Held to the fixture's toolDefinitions.
|
|
281
|
+
def sanitize_tool_definition(definition, depth = 0)
|
|
282
|
+
case definition
|
|
283
|
+
when Array
|
|
284
|
+
return [] if depth >= MAX_TOOL_DEFINITION_DEPTH
|
|
285
|
+
|
|
286
|
+
definition.map { |item| sanitize_tool_definition(item, depth + 1) }
|
|
287
|
+
when Hash
|
|
288
|
+
return {} if depth >= MAX_TOOL_DEFINITION_DEPTH
|
|
289
|
+
|
|
290
|
+
definition.each_with_object({}) do |(key, item), out|
|
|
291
|
+
next unless key.is_a?(String)
|
|
292
|
+
|
|
293
|
+
if TOOL_SCHEMA_KEYS.include?(key)
|
|
294
|
+
out[key] = item
|
|
295
|
+
elsif key.match?(TOOL_SECRET_KEY_RE)
|
|
296
|
+
next
|
|
297
|
+
elsif item.is_a?(String) && key.match?(TOOL_URL_KEY_RE)
|
|
298
|
+
out[key] = without_url_secrets(item)
|
|
299
|
+
else
|
|
300
|
+
out[key] = sanitize_tool_definition(item, depth + 1)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
definition
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# scheme://user:pass@host/path?query#fragment -> scheme://host/path
|
|
309
|
+
def without_url_secrets(url)
|
|
310
|
+
cut = [url.index("?"), url.index("#")].compact.min || url.length
|
|
311
|
+
url[0, cut].sub(URL_USERINFO_RE) { Regexp.last_match(1) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# {"tools" => [...], "toolSchemas" => {...}} for one LLM step, or nil. The
|
|
315
|
+
# block names a definition (default: tool_def_name). The definition is
|
|
316
|
+
# recorded JSON-safe and without credentials (sanitize_tool_definition),
|
|
317
|
+
# and hashed in that form. Never raises.
|
|
318
|
+
def capture_tools(owner, run_key, definitions, &describe)
|
|
319
|
+
return nil unless definitions.is_a?(Array) && !definitions.empty?
|
|
320
|
+
|
|
321
|
+
describe ||= method(:tool_def_name)
|
|
322
|
+
tools = []
|
|
323
|
+
schemas = {}
|
|
324
|
+
SCHEMA_LOCK.synchronize do
|
|
325
|
+
sent = run_memory(owner, run_key.to_s)
|
|
326
|
+
# So the session can hand the hashes back (release_tool_schemas) when
|
|
327
|
+
# the event carrying them does not reach the wire whole.
|
|
328
|
+
schemas.instance_variable_set(:@graphmind_sent, sent)
|
|
329
|
+
definitions.each do |definition|
|
|
330
|
+
name = begin
|
|
331
|
+
describe.call(definition)
|
|
332
|
+
rescue StandardError
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
next if name.nil?
|
|
336
|
+
|
|
337
|
+
plain = sanitize_tool_definition(record(definition))
|
|
338
|
+
digest = schema_hash(plain)
|
|
339
|
+
tools << { "name" => name, "schemaHash" => digest }
|
|
340
|
+
next if sent.include?(digest)
|
|
341
|
+
|
|
342
|
+
sent.clear if sent.size >= MAX_SCHEMA_HASHES_PER_RUN
|
|
343
|
+
sent << digest
|
|
344
|
+
schemas[digest] = plain
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
return nil if tools.empty?
|
|
348
|
+
|
|
349
|
+
out = { "tools" => tools }
|
|
350
|
+
out["toolSchemas"] = schemas unless schemas.empty?
|
|
351
|
+
out
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# The definitions in `tool_schemas` (a capture_tools result's, as it sits
|
|
357
|
+
# in a node.started input) did NOT reach the wire whole: the payload
|
|
358
|
+
# budget shrank that event (emptying every array inside them, `required:
|
|
359
|
+
# []`) or it was dropped. Forget the run was sent them, so its next step
|
|
360
|
+
# that uses them sends them again, intact. The session calls this. Never
|
|
361
|
+
# raises.
|
|
362
|
+
def release_tool_schemas(tool_schemas)
|
|
363
|
+
return unless tool_schemas.is_a?(Hash) && tool_schemas.instance_variable_defined?(:@graphmind_sent)
|
|
364
|
+
|
|
365
|
+
SCHEMA_LOCK.synchronize do
|
|
366
|
+
sent = tool_schemas.instance_variable_get(:@graphmind_sent)
|
|
367
|
+
tool_schemas.remove_instance_variable(:@graphmind_sent)
|
|
368
|
+
tool_schemas.each_key { |digest| sent.delete(digest) } if sent.is_a?(Set)
|
|
369
|
+
end
|
|
370
|
+
rescue StandardError
|
|
371
|
+
nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def run_memory(owner, run_key)
|
|
375
|
+
runs = owner_memory(owner)
|
|
376
|
+
hashes = runs.delete(run_key)
|
|
377
|
+
if hashes.nil?
|
|
378
|
+
hashes = Set.new
|
|
379
|
+
runs.delete(runs.first[0]) while runs.size >= MAX_SCHEMA_RUNS
|
|
380
|
+
end
|
|
381
|
+
runs[run_key] = hashes
|
|
382
|
+
hashes
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# A frozen owner cannot carry the memory: it gets a fresh one per step, so
|
|
386
|
+
# its schemas are re-sent (extra bytes, never a missing definition).
|
|
387
|
+
def owner_memory(owner)
|
|
388
|
+
return {} if owner.frozen?
|
|
389
|
+
|
|
390
|
+
runs = owner.instance_variable_get(SCHEMA_MEMORY_IVAR)
|
|
391
|
+
runs.is_a?(Hash) ? runs : owner.instance_variable_set(SCHEMA_MEMORY_IVAR, {})
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Forget what a session was sent (tests).
|
|
395
|
+
def reset_tool_schema_memory(owner)
|
|
396
|
+
SCHEMA_LOCK.synchronize { owner.instance_variable_set(SCHEMA_MEMORY_IVAR, {}) unless owner.frozen? }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# The allow-listed parameters actually given (String or Symbol keys), JSON-safe.
|
|
400
|
+
def pick_params(source)
|
|
401
|
+
return {} unless source.is_a?(Hash)
|
|
402
|
+
|
|
403
|
+
SAMPLING_PARAM_KEYS.each_with_object({}) do |key, out|
|
|
404
|
+
value = param(source, key)
|
|
405
|
+
out[key] = record(value) unless value.nil? || value.respond_to?(:call)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# -- full prompts --------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
# `value` made JSON-safe WITHOUT truncation: every item, every character.
|
|
412
|
+
# Binary Strings become {"type" => "binary", "bytes" => n}, a cycle
|
|
413
|
+
# "[circular]", depth past 64 "…[depth limit]". Never raises.
|
|
414
|
+
def record(value, depth = 0, path = nil)
|
|
415
|
+
case value
|
|
416
|
+
when nil, true, false, Integer then return value
|
|
417
|
+
when Float then return value.finite? ? value : nil
|
|
418
|
+
when Symbol then return value.name
|
|
419
|
+
when String
|
|
420
|
+
return value if value.encoding != Encoding::BINARY || value.ascii_only?
|
|
421
|
+
|
|
422
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
423
|
+
return utf8 if utf8.valid_encoding?
|
|
424
|
+
|
|
425
|
+
return { "type" => "binary", "bytes" => value.bytesize }
|
|
426
|
+
end
|
|
427
|
+
return "…[depth limit]" if depth >= MAX_RECORD_DEPTH
|
|
428
|
+
|
|
429
|
+
path ||= {}.compare_by_identity
|
|
430
|
+
return "[circular]" if path.key?(value)
|
|
431
|
+
|
|
432
|
+
path[value] = true
|
|
433
|
+
begin
|
|
434
|
+
record_container(value, depth, path)
|
|
435
|
+
ensure
|
|
436
|
+
path.delete(value)
|
|
437
|
+
end
|
|
438
|
+
rescue StandardError, SystemStackError
|
|
439
|
+
begin
|
|
440
|
+
value.to_s
|
|
441
|
+
rescue StandardError
|
|
442
|
+
value.class.name.to_s
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def record_container(value, depth, path)
|
|
447
|
+
case value
|
|
448
|
+
when Hash
|
|
449
|
+
value.each_with_object({}) do |(key, item), out|
|
|
450
|
+
out[key.is_a?(Symbol) ? key.name : key.to_s] = record(item, depth + 1, path)
|
|
451
|
+
end
|
|
452
|
+
when Array, Set then value.map { |item| record(item, depth + 1, path) }
|
|
453
|
+
else
|
|
454
|
+
value.respond_to?(:to_h) ? record(value.to_h, depth + 1, path) : value.to_s
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Graphmind
|
|
10
|
+
# GraphMind Jack — account linking for your API (Ruby port of @graphmind-ai/jack-verify).
|
|
11
|
+
#
|
|
12
|
+
# Jack's `link` sign-in mode is for apps with a session login and no OAuth
|
|
13
|
+
# server. You add two things, both with this module:
|
|
14
|
+
#
|
|
15
|
+
# 1. A link route. Jack sends a signed-in user to it with `?request=<token>`.
|
|
16
|
+
# Verify the request, take the user from your session, redirect to
|
|
17
|
+
# `link_response_url`:
|
|
18
|
+
#
|
|
19
|
+
# jack = Graphmind::Jack::Verifier.new(secret: ENV.fetch("JACK_LINK_SECRET"), issuer: ..., audience: ...)
|
|
20
|
+
#
|
|
21
|
+
# get "/jack/link" do
|
|
22
|
+
# jack.verify_link_request(params[:request])
|
|
23
|
+
# redirect to("/login?next=#{CGI.escape(request.fullpath)}") unless current_user
|
|
24
|
+
# redirect jack.link_response_url(params[:request], user_id: current_user.id, name: current_user.name)
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# 2. The assertion. Every request Jack makes carries a `Jack-Assertion`
|
|
28
|
+
# header: a 60-second, single-use signed token naming the user and the
|
|
29
|
+
# permissions they granted. `Graphmind::Jack::Middleware` (Rack) puts the
|
|
30
|
+
# verified assertion in `env["jack.user"]`; no header means "not a Jack call".
|
|
31
|
+
#
|
|
32
|
+
# Standard library only. One shared secret (`JACK_LINK_SECRET`, also stored
|
|
33
|
+
# encrypted in Jack) signs all three token kinds (HS256), each with its own `typ`.
|
|
34
|
+
module Jack
|
|
35
|
+
ASSERTION_HEADER = "jack-assertion"
|
|
36
|
+
TYP_REQUEST = "jack-link-request"
|
|
37
|
+
TYP_RESPONSE = "jack-link-response"
|
|
38
|
+
TYP_ASSERTION = "jack-link-assertion"
|
|
39
|
+
RESPONSE_TTL_SECONDS = 300
|
|
40
|
+
|
|
41
|
+
class LinkError < StandardError; end
|
|
42
|
+
|
|
43
|
+
LinkRequest = Struct.new(:request_id, :return_url, :scopes, keyword_init: true)
|
|
44
|
+
Assertion = Struct.new(:user_id, :scopes, :grant_id, :jti, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def b64url(bytes)
|
|
49
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def unb64url(text)
|
|
53
|
+
Base64.urlsafe_decode64(text + ("=" * ((4 - text.length % 4) % 4)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compact HS256 JWT. Standard, so Jack (Node) verifies it unchanged.
|
|
57
|
+
def sign_hs256(payload, secret)
|
|
58
|
+
head = b64url(JSON.generate({ alg: "HS256", typ: "JWT" }))
|
|
59
|
+
body = b64url(JSON.generate(payload))
|
|
60
|
+
sig = OpenSSL::HMAC.digest("SHA256", secret, "#{head}.#{body}")
|
|
61
|
+
"#{head}.#{body}.#{b64url(sig)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Verify signature (constant-time), alg, typ, iss, aud, exp/nbf/iat.
|
|
65
|
+
def verify_hs256(token, secret, typ:, issuer:, audience:, now: nil, skew: 60)
|
|
66
|
+
parts = token.to_s.split(".", -1)
|
|
67
|
+
raise LinkError, "malformed token" unless parts.length == 3
|
|
68
|
+
|
|
69
|
+
head, body, sig = parts
|
|
70
|
+
begin
|
|
71
|
+
header = JSON.parse(unb64url(head))
|
|
72
|
+
payload = JSON.parse(unb64url(body))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
raise LinkError, "malformed token"
|
|
75
|
+
end
|
|
76
|
+
raise LinkError, "unexpected algorithm" unless header.is_a?(Hash) && header["alg"] == "HS256"
|
|
77
|
+
|
|
78
|
+
expected = OpenSSL::HMAC.digest("SHA256", secret, "#{head}.#{body}")
|
|
79
|
+
given = begin
|
|
80
|
+
unb64url(sig)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
raise LinkError, "bad signature"
|
|
83
|
+
end
|
|
84
|
+
raise LinkError, "bad signature" unless given.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(given, expected)
|
|
85
|
+
raise LinkError, "malformed token" unless payload.is_a?(Hash)
|
|
86
|
+
raise LinkError, "wrong token type (expected #{typ})" unless payload["typ"] == typ
|
|
87
|
+
raise LinkError, "wrong issuer" unless payload["iss"] == issuer
|
|
88
|
+
|
|
89
|
+
auds = payload["aud"].is_a?(Array) ? payload["aud"] : [payload["aud"]]
|
|
90
|
+
raise LinkError, "wrong audience" unless auds.include?(audience)
|
|
91
|
+
|
|
92
|
+
t = (now || Time.now.to_f).to_i
|
|
93
|
+
exp = payload["exp"]
|
|
94
|
+
raise LinkError, "expired" unless exp.is_a?(Numeric) && exp + skew >= t
|
|
95
|
+
raise LinkError, "not yet valid" if payload["nbf"].is_a?(Numeric) && payload["nbf"] - skew > t
|
|
96
|
+
raise LinkError, "issued in the future" if payload["iat"].is_a?(Numeric) && payload["iat"] - skew > t
|
|
97
|
+
|
|
98
|
+
payload
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Verify Jack's link requests and assertions; sign link responses.
|
|
102
|
+
class Verifier
|
|
103
|
+
def initialize(secret:, issuer:, audience:, now: nil)
|
|
104
|
+
raise LinkError, "secret must be at least 32 characters" unless secret.is_a?(String) && secret.length >= 32
|
|
105
|
+
|
|
106
|
+
@secret = secret
|
|
107
|
+
@issuer = issuer.sub(%r{/+\z}, "")
|
|
108
|
+
a = URI(audience)
|
|
109
|
+
@audience = "#{a.scheme}://#{a.host}#{a.port && a.port != a.default_port ? ":#{a.port}" : ""}"
|
|
110
|
+
@now = now || -> { Time.now.to_f }
|
|
111
|
+
@seen = {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
attr_reader :issuer, :audience
|
|
115
|
+
|
|
116
|
+
def verify_link_request(token)
|
|
117
|
+
p = Jack.verify_hs256(token, @secret, typ: TYP_REQUEST, issuer: @issuer, audience: @audience, now: @now.call)
|
|
118
|
+
request_id = text(p["jti"])
|
|
119
|
+
return_url = text(p["return"])
|
|
120
|
+
raise LinkError, "malformed link request" unless request_id && return_url
|
|
121
|
+
raise LinkError, "link request return URL is not the issuer" unless return_url.start_with?("#{@issuer}/")
|
|
122
|
+
|
|
123
|
+
LinkRequest.new(request_id: request_id, return_url: return_url, scopes: p["scope"].is_a?(String) ? p["scope"].split : [])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The signed response token for a linked user. Usually you want `link_response_url`.
|
|
127
|
+
def link_response(request_token, user_id:, name: nil, email: nil)
|
|
128
|
+
req = verify_link_request(request_token)
|
|
129
|
+
raise LinkError, "user_id is required" unless user_id.is_a?(String) && !user_id.empty?
|
|
130
|
+
|
|
131
|
+
iat = @now.call.to_i
|
|
132
|
+
payload = { typ: TYP_RESPONSE, iss: @audience, aud: @issuer, jti: req.request_id, sub: user_id }
|
|
133
|
+
payload[:name] = name if name && !name.empty?
|
|
134
|
+
payload[:email] = email if email && !email.empty?
|
|
135
|
+
payload[:iat] = iat
|
|
136
|
+
payload[:exp] = iat + RESPONSE_TTL_SECONDS
|
|
137
|
+
Jack.sign_hs256(payload, @secret)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Where to redirect the user after linking.
|
|
141
|
+
def link_response_url(request_token, user_id:, name: nil, email: nil)
|
|
142
|
+
req = verify_link_request(request_token)
|
|
143
|
+
token = link_response(request_token, user_id: user_id, name: name, email: email)
|
|
144
|
+
u = URI(req.return_url)
|
|
145
|
+
query = URI.decode_www_form(u.query || "") << ["response", token]
|
|
146
|
+
u.query = URI.encode_www_form(query)
|
|
147
|
+
u.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Raises LinkError on anything wrong, including a replay.
|
|
151
|
+
def verify_assertion(token)
|
|
152
|
+
p = Jack.verify_hs256(token, @secret, typ: TYP_ASSERTION, issuer: @issuer, audience: @audience, now: @now.call, skew: 30)
|
|
153
|
+
jti = text(p["jti"])
|
|
154
|
+
user_id = text(p["sub"])
|
|
155
|
+
grant_id = text(p["grant_id"])
|
|
156
|
+
raise LinkError, "malformed assertion" unless jti && user_id && grant_id
|
|
157
|
+
|
|
158
|
+
t = @now.call.to_i
|
|
159
|
+
@seen.delete_if { |_, exp| exp < t } if @seen.size >= 10_000
|
|
160
|
+
raise LinkError, "assertion replayed" if (@seen[jti] || 0) >= t
|
|
161
|
+
|
|
162
|
+
@seen[jti] = p["exp"].to_f + 30
|
|
163
|
+
Assertion.new(user_id: user_id, scopes: p["scope"].is_a?(String) ? p["scope"].split : [], grant_id: grant_id, jti: jti)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# From a Rack env or a plain header hash. nil when absent.
|
|
167
|
+
def assertion_from(headers)
|
|
168
|
+
value = headers["HTTP_JACK_ASSERTION"] || headers[ASSERTION_HEADER] || headers["Jack-Assertion"]
|
|
169
|
+
return nil if value.nil? || value.to_s.empty?
|
|
170
|
+
|
|
171
|
+
verify_assertion(value.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def text(value)
|
|
177
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Rack middleware: `env["jack.user"]` is the Assertion when a valid header is present;
|
|
182
|
+
# an invalid one is a 401; no header passes through.
|
|
183
|
+
class Middleware
|
|
184
|
+
def initialize(app, verifier)
|
|
185
|
+
@app = app
|
|
186
|
+
@verifier = verifier
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def call(env)
|
|
190
|
+
begin
|
|
191
|
+
assertion = @verifier.assertion_from(env)
|
|
192
|
+
env["jack.user"] = assertion if assertion
|
|
193
|
+
rescue LinkError => e
|
|
194
|
+
body = JSON.generate({ title: "Unauthorized", status: 401, detail: "invalid Jack assertion: #{e.message}" })
|
|
195
|
+
return [401, { "content-type" => "application/problem+json" }, [body]]
|
|
196
|
+
end
|
|
197
|
+
@app.call(env)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|