insika 0.0.1 → 0.2.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/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Server
|
|
7
|
+
# Turns the components into a service.
|
|
8
|
+
# MANDATORY order, no parallelism: plugins → stores → recovery → (app
|
|
9
|
+
# for the listen). "Never accepts a request before recovery" is guaranteed BY
|
|
10
|
+
# CONSTRUCTION: the listen (Falcon) only runs after `#call` returns the app, and
|
|
11
|
+
# `#call` only returns after `Recovery.run` finishes.
|
|
12
|
+
class Boot
|
|
13
|
+
# wiring: object with the named steps (load_plugins/build_stores/
|
|
14
|
+
# recovery/app) — the config/wiring.rb. logger: simple IO (default $stdout;
|
|
15
|
+
# nil silences). app: overrides the wiring's `app` step — for a serving arm
|
|
16
|
+
# (config.ru) that assembles its own Rack app around the wiring; the
|
|
17
|
+
# "recovery before the listen" guarantee is unchanged (#call still only
|
|
18
|
+
# returns after recovery).
|
|
19
|
+
def initialize(wiring, logger: $stdout, app: nil)
|
|
20
|
+
@wiring = wiring
|
|
21
|
+
@logger = logger
|
|
22
|
+
@app = app
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# -> Rack app ready for the `run`. A store failure at boot (corrupted
|
|
26
|
+
# file → StoreError) PROPAGATES and aborts the process (coming up
|
|
27
|
+
# without durability is worse than not coming up); an unrecoverable task does NOT
|
|
28
|
+
# bring down the boot (Recovery already marks it :failed).
|
|
29
|
+
def call
|
|
30
|
+
@wiring.load_plugins
|
|
31
|
+
@wiring.build_stores
|
|
32
|
+
warn_if_ephemeral
|
|
33
|
+
summary = run_recovery
|
|
34
|
+
log("boot: recovery complete — #{summary[:resumed].size} resumed, " \
|
|
35
|
+
"#{summary[:failed].size} failed")
|
|
36
|
+
@app || @wiring.app
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Recovery dispatches resume_task, which creates task fibers — needs a
|
|
42
|
+
# current reactor. At config.ru load (Falcon) there is NO reactor: the Sync { }
|
|
43
|
+
# creates one and, by structured concurrency, only returns when the resume
|
|
44
|
+
# fibers FINISH (recovery + turns completed before the listen — slower
|
|
45
|
+
# boot, semantically safe). Under an already-current reactor (tests
|
|
46
|
+
# inside Async), it runs directly: returns after the resume DISPATCH, with
|
|
47
|
+
# the turns still in flight — also correct: "recovery before the listen" =
|
|
48
|
+
# dispatch before the listen, not turn completion.
|
|
49
|
+
def run_recovery
|
|
50
|
+
return do_recovery if Async::Task.current?
|
|
51
|
+
|
|
52
|
+
Sync { do_recovery }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Task recovery THEN delegation recovery: the delegation
|
|
56
|
+
# sweep re-delivers completed-but-undelivered async delegations, and depends
|
|
57
|
+
# on the task sweep having re-dispatched any in-flight children first. Both
|
|
58
|
+
# create task fibers, so both must run inside the reactor scope of run_recovery.
|
|
59
|
+
#
|
|
60
|
+
# The TASK sweep is additionally gated per boot generation:
|
|
61
|
+
# its "orphaned :running" test cannot see a sibling worker's live fiber, so
|
|
62
|
+
# only the worker that claims the generation sweeps — the others would steal
|
|
63
|
+
# in-flight turns. The delegation and channel sweeps stay ungated: each of
|
|
64
|
+
# their records carries its own transactional claim (at-most-once holds
|
|
65
|
+
# however many workers sweep). Duck-typed: a wiring without the claim (test
|
|
66
|
+
# doubles, single-process arms) sweeps unconditionally.
|
|
67
|
+
def do_recovery
|
|
68
|
+
summary =
|
|
69
|
+
if skip_task_sweep?
|
|
70
|
+
log("boot: task sweep skipped — another worker claimed this boot generation")
|
|
71
|
+
{ resumed: [], failed: [] }
|
|
72
|
+
else
|
|
73
|
+
@wiring.recovery.run
|
|
74
|
+
end
|
|
75
|
+
recover_delegations
|
|
76
|
+
recover_channel_deliveries
|
|
77
|
+
summary
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def skip_task_sweep?
|
|
81
|
+
@wiring.respond_to?(:claim_recovery_sweep) && !@wiring.claim_recovery_sweep
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Duck-typed (like durable?): a wiring without async delegation just omits it.
|
|
85
|
+
def recover_delegations
|
|
86
|
+
return unless @wiring.respond_to?(:recover_delegations)
|
|
87
|
+
|
|
88
|
+
result = @wiring.recover_delegations
|
|
89
|
+
log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# replies a previous process committed but never handed to the
|
|
93
|
+
# channel. Runs AFTER the task recovery for the same reason the delegation
|
|
94
|
+
# sweep does — a resumed turn writes its own outbox record at its terminal, and
|
|
95
|
+
# sweeping first would miss it.
|
|
96
|
+
def recover_channel_deliveries
|
|
97
|
+
return unless @wiring.respond_to?(:recover_channel_deliveries)
|
|
98
|
+
|
|
99
|
+
result = @wiring.recover_channel_deliveries
|
|
100
|
+
log("boot: channel replies re-dispatched — #{Array(result && result[:dispatched]).size}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Durability: without a durable backend, nothing is resumed after a
|
|
104
|
+
# restart — warns loudly at boot so we don't come up "without a net" by mistake. The
|
|
105
|
+
# test wiring (double) may not expose `durable?`; in that case, silence.
|
|
106
|
+
def warn_if_ephemeral
|
|
107
|
+
return unless @wiring.respond_to?(:durable?)
|
|
108
|
+
return if @wiring.durable?
|
|
109
|
+
|
|
110
|
+
log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
|
|
111
|
+
"not resume anything after a restart.")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def log(message)
|
|
115
|
+
@logger&.puts(message)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# the /v1 transport as a VALUE the host app mounts, instead of a
|
|
4
|
+
# server the engine starts.
|
|
5
|
+
#
|
|
6
|
+
# mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
|
|
7
|
+
#
|
|
8
|
+
# The assembly used to live inline in DSL::ServerBoot#run, welded to
|
|
9
|
+
# `Async::HTTP::Server.new(...).run` on the next line: a host that already owns a
|
|
10
|
+
# reactor and a router could not reach it. Nothing here is new behavior — the
|
|
11
|
+
# server boot now calls this instead of inlining it, which is what keeps the two
|
|
12
|
+
# from drifting.
|
|
13
|
+
#
|
|
14
|
+
# The Studio is deliberately NOT part of this (the embed contract): it is
|
|
15
|
+
# a class-level singleton, so it is one per process, and a host that wants the
|
|
16
|
+
# operator UI mounts `Studio::App` itself and accepts that limitation.
|
|
17
|
+
|
|
18
|
+
require_relative "app"
|
|
19
|
+
|
|
20
|
+
module Insika
|
|
21
|
+
module Server
|
|
22
|
+
# `handle` is anything that answers #runtime (a DSL Definition/System) or a
|
|
23
|
+
# DSL::Runtime itself. -> a Rack app (`#call(env)`).
|
|
24
|
+
#
|
|
25
|
+
# It is mount-safe: routing reads `path_info`, so `Rack::URLMap`/Rails' `mount`
|
|
26
|
+
# moving the prefix into SCRIPT_NAME leaves every route intact.
|
|
27
|
+
def self.rack_app(handle, token: nil, **config)
|
|
28
|
+
AppBuilder.new(handle, token: token, **config).app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Assembles Server::App from a graph. Also answers the two questions the boot
|
|
32
|
+
# banner asks (`workflows?`/`channels?`), so registering the env channels
|
|
33
|
+
# happens exactly once and in one place.
|
|
34
|
+
class AppBuilder
|
|
35
|
+
# Fixed local token: gates /v1 (Bearer) — and, under `serve`, logs into the
|
|
36
|
+
# Studio (cookie). Never a real secret; override with `token:`/ADMIN_TOKEN.
|
|
37
|
+
def initialize(handle, token: nil, **config)
|
|
38
|
+
@rt = handle.respond_to?(:runtime) ? handle.runtime : handle
|
|
39
|
+
@graph = @rt.graph
|
|
40
|
+
@token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :token
|
|
45
|
+
|
|
46
|
+
def app
|
|
47
|
+
tenancy = @config[:tenancy] || ENV["INSIKA_TENANCY"] || "single_tenant"
|
|
48
|
+
# WS1: the token store is handed over ONLY in multi_tenant mode — in
|
|
49
|
+
# single_tenant the classic gateway token is the only credential
|
|
50
|
+
# (passing the store would silently widen the surface).
|
|
51
|
+
store = tenancy == "multi_tenant" ? @graph.token_store : nil
|
|
52
|
+
@app ||= Insika::Server::App.new(
|
|
53
|
+
command_bus: @graph.bus, event_stream: @graph.event_stream,
|
|
54
|
+
session_store: @graph.session_store, task_store: @graph.task_store,
|
|
55
|
+
pending_action_store: @graph.pending_action_store,
|
|
56
|
+
provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
|
|
57
|
+
# GET /v1/agents/:id — the read-only capability view a case's `requires`
|
|
58
|
+
# resolves against.
|
|
59
|
+
profiles: @graph.profiles,
|
|
60
|
+
# the OSS onboarding surface (start.md + models.json + docs).
|
|
61
|
+
# This is the primary "build my first agent" target — models.json reports the
|
|
62
|
+
# DSL's stores + the agents this process serves (each id IS the `model`).
|
|
63
|
+
onboarding: build_onboarding,
|
|
64
|
+
# GET /v1/workflows + POST /v1/workflows/:name, opt-in by
|
|
65
|
+
# injection like every other edge — nil when the system declares none,
|
|
66
|
+
# so the routes simply do not exist (404, parity).
|
|
67
|
+
workflow_registry: (@graph.workflow_registry if workflows?),
|
|
68
|
+
# the bundled relay, when the env turns it on. Same rule as the
|
|
69
|
+
# OTEL bridge — a feature only `config.ru` can reach is a feature the
|
|
70
|
+
# docs are half-true about.
|
|
71
|
+
channels: (@graph.channel_registry if channels?),
|
|
72
|
+
config: { gateway_token: @token, tenancy: tenancy }.merge(@config),
|
|
73
|
+
token_store: store,
|
|
74
|
+
# a 500's error_ref must be findable in the process log.
|
|
75
|
+
logger: $stdout
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def workflows? = !@graph.workflow_registry.names.empty?
|
|
80
|
+
|
|
81
|
+
# Registers the env-configured channels once, and reports whether any exist.
|
|
82
|
+
def channels?
|
|
83
|
+
unless defined?(@channels_ready)
|
|
84
|
+
@channels_ready = true
|
|
85
|
+
relay = Insika::Channels::Relay.from_env(
|
|
86
|
+
http: Insika::HttpClient.new,
|
|
87
|
+
allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
|
|
88
|
+
allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
|
|
89
|
+
)
|
|
90
|
+
@graph.channel_registry.register(relay.id, relay) if relay
|
|
91
|
+
|
|
92
|
+
widget = Insika::Channels::Web.from_env(
|
|
93
|
+
chat_rate_limit: Insika::Channels::Web.limit_resolver(
|
|
94
|
+
profiles: @graph.profiles, settings_store: @rt.component(:settings_store)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
@graph.channel_registry.register(widget.id, widget) if widget
|
|
98
|
+
end
|
|
99
|
+
!@graph.channel_registry.names.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_onboarding
|
|
105
|
+
configs = @rt.packs.map(&:config)
|
|
106
|
+
Insika::Onboarding.standard(
|
|
107
|
+
root: File.expand_path("../../..", __dir__),
|
|
108
|
+
settings_store: @rt.component(:settings_store),
|
|
109
|
+
provider_store: @rt.component(:provider_store),
|
|
110
|
+
# EVERY agent this process serves — each id IS a `model` on
|
|
111
|
+
# /v1/responses, so a coding agent reading models.json sees the whole
|
|
112
|
+
# system, not just the first one.
|
|
113
|
+
agents: -> { configs.map { |c| { id: c[:id], model: c[:model], provider: c[:provider] } } }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Server
|
|
7
|
+
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that
|
|
8
|
+
# OpenClaw gateway consumers already speak.
|
|
9
|
+
#
|
|
10
|
+
# PURE module (no state, no framework): (a) translates the OpenAI
|
|
11
|
+
# Responses request → `:send_message` payload; (b) maps each turn Event →
|
|
12
|
+
# OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
|
|
13
|
+
# constitutional rule: no business logic, no store access here.
|
|
14
|
+
#
|
|
15
|
+
# Request: { model: "openclaw:<agent>", user: "<chat.id>", stream: true,
|
|
16
|
+
# input: "<string with already-composed blocks>" } + header
|
|
17
|
+
# X-Openclaw-Agent (agent fallback). The `input` enters VERBATIM as the
|
|
18
|
+
# turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
|
|
19
|
+
# composed by the consumer (the engine does not interpret them).
|
|
20
|
+
module Responses
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# -> { agent:, user:, message:, origin? } | raise ValidationError.
|
|
24
|
+
#
|
|
25
|
+
# `origin` is the consumer declaring WHO wrote the input it is sending. It
|
|
26
|
+
# matters here more than anywhere: this adapter's `input` is a STRING the
|
|
27
|
+
# consumer already composed out of context blocks plus the customer's text
|
|
28
|
+
# (`<memoria> …`, `<store_cep_required> …`), so a transcript reader cannot
|
|
29
|
+
# tell the two apart — the first refinement run over real traffic reported 219
|
|
30
|
+
# "the customer repeated themselves" that were the engine reading its own
|
|
31
|
+
# fragment back. A consumer that sends `origin: "engine"` on a composed turn
|
|
32
|
+
# gets that filtered structurally instead of by a regex on the leading tag.
|
|
33
|
+
# Omitted = a customer typed it, which is what every turn meant before.
|
|
34
|
+
def parse_request(body, req)
|
|
35
|
+
agent = body[:model].to_s.sub(/\Aopenclaw:/, "")
|
|
36
|
+
agent = req.get_header("HTTP_X_OPENCLAW_AGENT").to_s if agent.empty?
|
|
37
|
+
raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
|
|
38
|
+
|
|
39
|
+
user = body[:user].to_s
|
|
40
|
+
raise Insika::ValidationError, "user missing" if user.strip.empty?
|
|
41
|
+
|
|
42
|
+
message = extract_input(body[:input])
|
|
43
|
+
raise Insika::ValidationError, "input empty" if message.strip.empty?
|
|
44
|
+
|
|
45
|
+
out = { agent: agent.strip, user: user, message: message }
|
|
46
|
+
(origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
|
|
47
|
+
out
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# V1: `input` is a STRING (the dispatcher composes the blocks + user text). Tolerates
|
|
51
|
+
# an array of parts (OpenAI multimodal shape) by joining the texts.
|
|
52
|
+
def extract_input(input)
|
|
53
|
+
case input
|
|
54
|
+
when String then input
|
|
55
|
+
when Array
|
|
56
|
+
input.flat_map { |part| part.is_a?(Hash) ? (part[:text] || part["text"]) : part }
|
|
57
|
+
.compact.join("\n")
|
|
58
|
+
else input.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Turn Event -> OpenAI Responses SSE frame | nil (event with no
|
|
63
|
+
# counterpart: :task_started, :tool_result, :skill_activated, ...).
|
|
64
|
+
# Terminal events emit the final frame + `[DONE]` (close the stream).
|
|
65
|
+
def frame_for(event)
|
|
66
|
+
case event.type
|
|
67
|
+
when :content
|
|
68
|
+
sse("response.output_text.delta",
|
|
69
|
+
{ type: "response.output_text.delta", delta: event.data[:delta].to_s })
|
|
70
|
+
when :tool_call
|
|
71
|
+
sse("response.output_item.added",
|
|
72
|
+
{ type: "response.output_item.added",
|
|
73
|
+
item: { type: "function_call", name: event.data[:name].to_s } })
|
|
74
|
+
when :task_completed
|
|
75
|
+
completed(event) + done
|
|
76
|
+
when :task_failed
|
|
77
|
+
failed(event.data[:message] || "task failed") + done
|
|
78
|
+
when :task_cancelled
|
|
79
|
+
failed("task cancelled") + done
|
|
80
|
+
when :error
|
|
81
|
+
failed(event.data[:message] || "error") + done
|
|
82
|
+
when :thinking
|
|
83
|
+
# The provider's reasoning. Internal unless the AGENT opted in
|
|
84
|
+
# (`edge_stream thinking: true`), which tags the event. Even then it does
|
|
85
|
+
# NOT become answer text: it gets the Responses reasoning frame, so a
|
|
86
|
+
# consumer that only accumulates `output_text` deltas — a dispatcher
|
|
87
|
+
# that turns them into one WhatsApp message — is unaffected,
|
|
88
|
+
# and one that renders reasoning has something to render.
|
|
89
|
+
if public_delta(event)
|
|
90
|
+
sse("response.reasoning_summary_text.delta",
|
|
91
|
+
{ type: "response.reasoning_summary_text.delta", delta: event.data[:delta].to_s })
|
|
92
|
+
end
|
|
93
|
+
when :intermediate
|
|
94
|
+
# The model's own prose that did not turn out to be the answer — the
|
|
95
|
+
# narration of a message that also called a tool, or the reasoning-in-content
|
|
96
|
+
# a model emits when it has no tool to call. A real store's prompt sent 132
|
|
97
|
+
# deltas of an English monologue this way before TurnOutput held them back.
|
|
98
|
+
#
|
|
99
|
+
# NAMESPACED on purpose when published. There is no `response.*` event for
|
|
100
|
+
# "text the assistant said that is not the answer": in the real protocol that
|
|
101
|
+
# text IS `output_text.delta`, told apart only by an output-item index this
|
|
102
|
+
# adapter does not carry. So a `response.*` type here would be a lie a strict
|
|
103
|
+
# client would believe. `insika.*` is obviously ours and unknown types are
|
|
104
|
+
# ignored — which is the safe failure.
|
|
105
|
+
if public_delta(event)
|
|
106
|
+
sse("insika.intermediate.delta",
|
|
107
|
+
{ type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
|
|
108
|
+
end
|
|
109
|
+
when :guardrail_blocked, :guardrail_flagged
|
|
110
|
+
# audit events with no OpenAI Responses counterpart. On a BLOCK
|
|
111
|
+
# the safe reply still reaches the consumer through the normal :content
|
|
112
|
+
# deltas + :task_completed path (the turn completes gracefully), so there
|
|
113
|
+
# is nothing extra to translate here — the events live in /v1/events + the
|
|
114
|
+
# Studio + the trace. Explicit (not a fall-through) to keep the closed
|
|
115
|
+
# catalog honest.
|
|
116
|
+
nil
|
|
117
|
+
when :ttft
|
|
118
|
+
# the live TTFB signal (WS6, INSIKA_TURN_TIMING opt-in): the provider's
|
|
119
|
+
# ms-to-first-token, emitted when the first content chunk arrives.
|
|
120
|
+
# Namespaced insika.* — no OpenAI Responses counterpart; unknown types
|
|
121
|
+
# are ignored, the safe failure.
|
|
122
|
+
sse("insika.ttft", { type: "insika.ttft", ttft_ms: event.data[:ttft_ms].to_i })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Did the AGENT opt this channel in? The Executor tags the event (`edge_stream`)
|
|
127
|
+
# because this mapper is pure and static — no agent, no stores, no state. An
|
|
128
|
+
# untagged event is internal, which is the default and the safe reading — and
|
|
129
|
+
# "not published" must be nil, like every other unmapped event in the catalog.
|
|
130
|
+
def public_delta(event) = event.data[:public] == true
|
|
131
|
+
|
|
132
|
+
def completed(event)
|
|
133
|
+
response = {}
|
|
134
|
+
if (usage = event.data[:usage])
|
|
135
|
+
# `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
|
|
136
|
+
# usage (pure tokens in usage).
|
|
137
|
+
model = usage[:model] || usage["model"]
|
|
138
|
+
response[:usage] = usage.reject { |k, _| k.to_s == "model" }
|
|
139
|
+
response[:model] = model if model
|
|
140
|
+
end
|
|
141
|
+
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING). Absent
|
|
142
|
+
# by default — a non-standard sibling used only for TTFB diagnostics.
|
|
143
|
+
(timing = event.data[:timing]) && (response[:timing] = timing)
|
|
144
|
+
# WS5 stuck signal: an additive sibling the terminal frame carries when the
|
|
145
|
+
# agent ended the turn declaring it cannot proceed. Consumers that only read
|
|
146
|
+
# the OpenAI-shaped response.use it to run their escalation ("stuck" means
|
|
147
|
+
# what they decide it means, never the engine's business).
|
|
148
|
+
(outcome = event.data[:outcome]) && (response[:outcome] = outcome.to_s)
|
|
149
|
+
sse("response.completed", { type: "response.completed", response: response })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def failed(message)
|
|
153
|
+
sse("response.failed",
|
|
154
|
+
{ type: "response.failed", response: { error: { message: message.to_s } } })
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# event: + data: (the dispatcher reads both: `event:` and `type` in the JSON).
|
|
158
|
+
def sse(event_name, data)
|
|
159
|
+
"event: #{event_name}\ndata: #{JSON.generate(data)}\n\n"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def done = "data: [DONE]\n\n"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "async"
|
|
6
|
+
require "async/queue"
|
|
7
|
+
|
|
8
|
+
module Insika
|
|
9
|
+
module Server
|
|
10
|
+
# SSE response body (evolves `SSEStream`). `SSEStream`
|
|
11
|
+
# received a producer block (the Runner wrote into it); `SSEBody` DRAINS a
|
|
12
|
+
# `Subscription` from the EventStream: each subscriber has its
|
|
13
|
+
# own queue; `#each` blocks the CONSUMER's fiber until the subscription
|
|
14
|
+
# closes. The wire is EXACTLY `Event#to_h`.
|
|
15
|
+
class SSEBody
|
|
16
|
+
PING = ": ping\n\n" # SSE comment — doesn't pollute the consumer
|
|
17
|
+
|
|
18
|
+
# serialize: maps an Event -> String (SSE frame) OR nil (discarded
|
|
19
|
+
# event, no frame). Default = the canonical wire `data: <Event#to_h>`.
|
|
20
|
+
# The /v1/responses adapter injects a serializer that produces OpenAI
|
|
21
|
+
# Responses events (and skips those with no counterpart).
|
|
22
|
+
DEFAULT_SERIALIZE = ->(event) { "data: #{JSON.generate(event.to_h)}\n\n" }
|
|
23
|
+
|
|
24
|
+
# subscription: any object with #each (yields Events) and #close.
|
|
25
|
+
# heartbeat: seconds of silence before emitting a ping (15s
|
|
26
|
+
# clears 60s ALB/nginx idle timeouts with room to spare).
|
|
27
|
+
def initialize(subscription:, heartbeat: 15, serialize: nil)
|
|
28
|
+
@subscription = subscription
|
|
29
|
+
@heartbeat = heartbeat
|
|
30
|
+
@serialize = serialize || DEFAULT_SERIALIZE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Rack 3 STREAMING BODY (`#call(stream)`), NOT `#each`. Under
|
|
34
|
+
# protocol-rack/protocol-http1 (the stack of Async::HTTP::Server AND Falcon),
|
|
35
|
+
# a body that responds to `#each` is routed to Body::Enumerable, whose
|
|
36
|
+
# `read` runs the `#each` in a PLAIN Enumerator Fiber (not an Async::Task) —
|
|
37
|
+
# there `Async::Task.current` raises "No async task available", the loop died
|
|
38
|
+
# swallowed in the rescue and the body came out EMPTY. By exposing `#call` (and NOT `#each`),
|
|
39
|
+
# the body is routed to Body::Streaming, which schedules the block via
|
|
40
|
+
# Fiber.schedule under the reactor's scheduler — so the subscription drains and the
|
|
41
|
+
# frames actually reach the socket (incrementally).
|
|
42
|
+
#
|
|
43
|
+
# The `stream` (Protocol::HTTP::Body::Stream) responds to #write/#close. Drains the
|
|
44
|
+
# subscription DIRECTLY (no Async::Task.current): when this fiber blocks
|
|
45
|
+
# waiting for the next event, the scheduler runs the writer, which pushes the
|
|
46
|
+
# already-written frame to the socket. Heartbeat via Timeout.timeout (scheduler hook),
|
|
47
|
+
# which works in the scheduled fiber — keeps the connection alive while idle (L4).
|
|
48
|
+
def call(stream)
|
|
49
|
+
drain(stream)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# Client disconnected: `stream.write` raises when the socket closes.
|
|
52
|
+
# No exception escapes; the turn's task is NEVER cancelled here — the
|
|
53
|
+
# execution belongs to the runtime, not the connection (reconnect at /v1/events).
|
|
54
|
+
nil
|
|
55
|
+
ensure
|
|
56
|
+
@subscription.close
|
|
57
|
+
stream.close
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def drain(stream)
|
|
63
|
+
internal = Async::Queue.new
|
|
64
|
+
closed = Object.new # end-of-subscription sentinel
|
|
65
|
+
|
|
66
|
+
# Child fiber (scheduled on the reactor's scheduler): drains the subscription
|
|
67
|
+
# into an internal queue and, on close, pushes the sentinel. Isolates the
|
|
68
|
+
# subscription's blocking from the heartbeat loop. Ends on its own when
|
|
69
|
+
# `@subscription.close` (in #call's ensure) makes the `each` finish — no
|
|
70
|
+
# need to kill the fiber by hand.
|
|
71
|
+
Fiber.schedule do
|
|
72
|
+
@subscription.each { |event| internal.enqueue(event) }
|
|
73
|
+
ensure
|
|
74
|
+
internal.enqueue(closed)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
loop do
|
|
78
|
+
event =
|
|
79
|
+
begin
|
|
80
|
+
Timeout.timeout(@heartbeat) { internal.dequeue }
|
|
81
|
+
rescue Timeout::Error
|
|
82
|
+
:heartbeat # no event within `heartbeat`s -> ping
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if event.equal?(:heartbeat)
|
|
86
|
+
stream.write(PING)
|
|
87
|
+
elsif event.equal?(closed)
|
|
88
|
+
break
|
|
89
|
+
elsif (frame = @serialize.call(event))
|
|
90
|
+
stream.write(frame)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
module Server
|
|
8
|
+
# Edge resolution for WS1 (multi-tenant): `Authorization: Bearer <token>` ->
|
|
9
|
+
# a principal `{ role:, tenant_id: }`, resolved BEFORE the routes. Two modes,
|
|
10
|
+
# one gate:
|
|
11
|
+
#
|
|
12
|
+
# single_tenant (default) — no token store: the classic single operator
|
|
13
|
+
# credential (config[:gateway_token]) is the only thing that resolves.
|
|
14
|
+
# multi_tenant — tokens live in the TokenStore (per-tenant + operator).
|
|
15
|
+
# A configured gateway_token STILL resolves as operator (an existing
|
|
16
|
+
# deployment switching modes keeps its credential — additive, never
|
|
17
|
+
# a second-class path).
|
|
18
|
+
#
|
|
19
|
+
# Fail-closed BY CONSTRUCTION: no store and no configured token -> :disabled
|
|
20
|
+
# (503, never open). A revoked or unknown token -> :unauthorized. Pure module,
|
|
21
|
+
# testable without a Rack env.
|
|
22
|
+
module TenantAuth
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# gateway_token: config[:gateway_token] | nil. token_store: TokenStore |
|
|
26
|
+
# nil. header: raw Authorization value.
|
|
27
|
+
# -> :disabled | :unauthorized | { role: "operator"|"tenant", tenant_id: }
|
|
28
|
+
def check(gateway_token, token_store, header)
|
|
29
|
+
# Fail-closed FIRST (the construction rule): with no store AND no
|
|
30
|
+
# configured token the gateway is DISABLED (503) however the request
|
|
31
|
+
# looks — never "401: who are you facing a door that does not exist".
|
|
32
|
+
# A token_store present means the gateway IS configured (multi_tenant),
|
|
33
|
+
# with or without the legacy gateway token.
|
|
34
|
+
return :disabled if token_store.nil? && (gateway_token.nil? || gateway_token.empty?)
|
|
35
|
+
|
|
36
|
+
provided = header.to_s[/\ABearer (.+)\z/, 1]
|
|
37
|
+
return :unauthorized if provided.nil?
|
|
38
|
+
|
|
39
|
+
if token_store
|
|
40
|
+
record = token_store.resolve(provided)
|
|
41
|
+
unless record
|
|
42
|
+
# store miss -> the legacy gateway token still resolves as operator
|
|
43
|
+
# (an existing deployment switching modes keeps its credential).
|
|
44
|
+
return :unauthorized if gateway_token.nil? || gateway_token.empty?
|
|
45
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
46
|
+
|
|
47
|
+
return { role: "operator", tenant_id: nil }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return { role: record.role.to_s, tenant_id: record.tenant_id }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# classic mode (no store): the gateway token is the only credential.
|
|
54
|
+
# Constant-time comparison: the operator token doesn't leak via timing.
|
|
55
|
+
return :unauthorized unless Rack::Utils.secure_compare(gateway_token, provided)
|
|
56
|
+
|
|
57
|
+
{ role: "operator", tenant_id: nil }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|