woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
class PublishedIndex
|
|
8
|
+
# Reads one type's unit file directly from a pinned payload, bypassing
|
|
9
|
+
# {Woods::MCP::IndexReader#find_unit}'s identifier-only map, which lets a
|
|
10
|
+
# later `TYPE_DIRS` entry silently overwrite an earlier one when two
|
|
11
|
+
# types share an identifier.
|
|
12
|
+
#
|
|
13
|
+
# Split out of {PublishedIndex} for the same reason as {EdgeShaper}: a
|
|
14
|
+
# pure function of the payload directory and the reader's own per-type
|
|
15
|
+
# index, with no need to touch the retention lock.
|
|
16
|
+
module TypedUnitReader
|
|
17
|
+
# @param payload_dir [Pathname] the pinned generation's payload directory
|
|
18
|
+
# @param reader [Woods::MCP::IndexReader]
|
|
19
|
+
# @param identifier [String]
|
|
20
|
+
# @param type [String] singular type name
|
|
21
|
+
# @return [Hash, nil] string-keyed unit, or nil when this type has no
|
|
22
|
+
# such identifier
|
|
23
|
+
def self.call(payload_dir, reader, identifier, type)
|
|
24
|
+
dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type]
|
|
25
|
+
return nil unless dir
|
|
26
|
+
return nil unless reader.list_units(type: type).any? { |entry| entry['identifier'] == identifier }
|
|
27
|
+
|
|
28
|
+
path = payload_dir.join(dir, filename_for(identifier))
|
|
29
|
+
return nil unless path.file?
|
|
30
|
+
|
|
31
|
+
JSON.parse(path.binread.force_encoding(Encoding::UTF_8))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The on-disk filename for a unit, matching
|
|
35
|
+
# `Woods::MCP::IndexReader#build_identifier_map`'s naming exactly.
|
|
36
|
+
#
|
|
37
|
+
# @param identifier [String]
|
|
38
|
+
# @return [String]
|
|
39
|
+
def self.filename_for(identifier)
|
|
40
|
+
base = identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
41
|
+
digest = Digest::SHA256.hexdigest(identifier)[0, 8]
|
|
42
|
+
"#{base}_#{digest}.json"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :filename_for
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require_relative 'generation'
|
|
7
|
+
require_relative 'payload_store'
|
|
8
|
+
require_relative 'mcp/index_reader'
|
|
9
|
+
require_relative 'published_index/edge_shaper'
|
|
10
|
+
require_relative 'published_index/generation_catalog'
|
|
11
|
+
require_relative 'published_index/typed_unit_reader'
|
|
12
|
+
|
|
13
|
+
module Woods
|
|
14
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
15
|
+
|
|
16
|
+
# A small, stable Ruby API over a published Woods index for tools that are
|
|
17
|
+
# not MCP clients: RuboCop cops, CI gate scripts, `woods:check:*` tasks (#280).
|
|
18
|
+
#
|
|
19
|
+
# Three things it does that the MCP reader does not expose directly:
|
|
20
|
+
#
|
|
21
|
+
# * pin one published generation (the current one, or a retained older
|
|
22
|
+
# `payloads/gen-N`) for the reader's whole lifetime, so a check reads one
|
|
23
|
+
# consistent snapshot and can compare two generations against each other;
|
|
24
|
+
# * hold that generation's {Woods::PayloadStore} retention lock for as long
|
|
25
|
+
# as the reader is open, so a concurrent publish's pruning cannot remove
|
|
26
|
+
# the payload out from under it;
|
|
27
|
+
# * iterate edges with their attributes as plain hashes, and hand out a
|
|
28
|
+
# checksum keyed to the pinned payload for RuboCop's
|
|
29
|
+
# `external_dependency_checksum`.
|
|
30
|
+
#
|
|
31
|
+
# Everything is read-only and needs no Rails. Unit hashes are string-keyed,
|
|
32
|
+
# exactly as written on disk. Unlike {Woods::MCP::IndexReader}, a
|
|
33
|
+
# {PublishedIndex} never auto-refreshes: every fact it returns comes from
|
|
34
|
+
# the one generation it opened with, for its entire lifetime.
|
|
35
|
+
#
|
|
36
|
+
# Holds an open file handle (a shared lock on the pinned generation's
|
|
37
|
+
# `manifest.json`) for as long as the reader is open. Use the block form or
|
|
38
|
+
# call {#close} explicitly; do not let an instance leak past the scope that
|
|
39
|
+
# needs it.
|
|
40
|
+
#
|
|
41
|
+
# **Not thread-safe.** A single instance is meant for one script or cop
|
|
42
|
+
# process reading one generation; it keeps no mutex around its lock file or
|
|
43
|
+
# its underlying {Woods::MCP::IndexReader}. Give each thread its own
|
|
44
|
+
# {PublishedIndex} rather than sharing one.
|
|
45
|
+
#
|
|
46
|
+
# @example A cop keyed on the index
|
|
47
|
+
# Woods::PublishedIndex.open(Rails.root.join('tmp/woods')) do |index|
|
|
48
|
+
# index.table_database_map # => { "orders" => "primary", "events" => "analytics" }
|
|
49
|
+
# index.external_dependency_checksum # => "9f2c..." (changes on every publish)
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# @example Comparing two generations
|
|
53
|
+
# before = Woods::PublishedIndex.new(dir, generation: 41)
|
|
54
|
+
# after = Woods::PublishedIndex.new(dir, generation: 42)
|
|
55
|
+
# begin
|
|
56
|
+
# # ...
|
|
57
|
+
# ensure
|
|
58
|
+
# before.close
|
|
59
|
+
# after.close
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
class PublishedIndex
|
|
63
|
+
# Raised when `generation.json` exists but cannot be parsed. Distinct from
|
|
64
|
+
# a *missing* pointer file, which means a flat (pre-2.0) index and is not
|
|
65
|
+
# an error.
|
|
66
|
+
class CorruptPointerError < Woods::Error; end
|
|
67
|
+
|
|
68
|
+
# @return [Pathname] the index root passed to {#initialize}
|
|
69
|
+
attr_reader :index_dir
|
|
70
|
+
|
|
71
|
+
# Published generation numbers, ascending.
|
|
72
|
+
#
|
|
73
|
+
# A generation counts as published only when its number is at or below
|
|
74
|
+
# the pointer `generation.json` currently names AND its payload directory
|
|
75
|
+
# holds a `manifest.json`. A directory numbered above the pointer, or one
|
|
76
|
+
# without a manifest (an interrupted or failed publish), is never listed.
|
|
77
|
+
#
|
|
78
|
+
# @param index_dir [String, Pathname]
|
|
79
|
+
# @return [Array<Integer>]
|
|
80
|
+
# @raise [CorruptPointerError] when `generation.json` exists but will not parse
|
|
81
|
+
def self.available_generations(index_dir)
|
|
82
|
+
GenerationCatalog.available(Pathname.new(index_dir.to_s))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Open a reader, yield it, and guarantee the generation lock is released.
|
|
86
|
+
#
|
|
87
|
+
# Without a block this behaves exactly like {.new}: the caller owns the
|
|
88
|
+
# lock and must call {#close}.
|
|
89
|
+
#
|
|
90
|
+
# @param (see #initialize)
|
|
91
|
+
# @yieldparam index [PublishedIndex]
|
|
92
|
+
# @return [PublishedIndex] when no block is given
|
|
93
|
+
# @return [Object] the block's return value, when a block is given
|
|
94
|
+
def self.open(index_dir, generation: nil)
|
|
95
|
+
index = new(index_dir, generation: generation)
|
|
96
|
+
return index unless block_given?
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
yield index
|
|
100
|
+
ensure
|
|
101
|
+
index.close
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param index_dir [String, Pathname] the index root (holds generation.json)
|
|
106
|
+
# @param generation [Integer, nil] pin a published generation; defaults to
|
|
107
|
+
# the currently published one
|
|
108
|
+
# @raise [ArgumentError] when the index, or the requested generation, is
|
|
109
|
+
# not published
|
|
110
|
+
# @raise [CorruptPointerError] when `generation.json` exists but will not parse
|
|
111
|
+
def initialize(index_dir, generation: nil)
|
|
112
|
+
@index_dir = Pathname.new(index_dir.to_s)
|
|
113
|
+
@lock_file = nil
|
|
114
|
+
pointer = GenerationCatalog.pointer(@index_dir)
|
|
115
|
+
|
|
116
|
+
if pointer.number.zero? && generation.nil?
|
|
117
|
+
initialize_flat_index
|
|
118
|
+
else
|
|
119
|
+
initialize_generation(generation || pointer.number)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
open_reader!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Integer] the generation being read; 0 for a flat index
|
|
126
|
+
# @return [Pathname] the directory the units are read from
|
|
127
|
+
attr_reader :generation_number, :payload_dir
|
|
128
|
+
|
|
129
|
+
# Release the retention lock held on the pinned generation, if any.
|
|
130
|
+
#
|
|
131
|
+
# Safe to call more than once. A flat index (generation 0) holds no lock,
|
|
132
|
+
# so this is a no-op for it.
|
|
133
|
+
#
|
|
134
|
+
# @return [void]
|
|
135
|
+
def close
|
|
136
|
+
@lock_file&.flock(File::LOCK_UN)
|
|
137
|
+
@lock_file&.close
|
|
138
|
+
@lock_file = nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Hash] parsed manifest.json
|
|
142
|
+
def manifest
|
|
143
|
+
@reader.manifest
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Look up one unit by identifier.
|
|
147
|
+
#
|
|
148
|
+
# `Woods::MCP::IndexReader#find_unit` (used when +type+ is nil) keys its
|
|
149
|
+
# identifier map on identifier alone: if two type directories both list
|
|
150
|
+
# the same identifier, whichever type sorts last in
|
|
151
|
+
# `Woods::MCP::IndexReader::TYPE_DIRS` wins, silently. Pass +type+ to read
|
|
152
|
+
# that type's unit file directly and skip the collision.
|
|
153
|
+
#
|
|
154
|
+
# @param identifier [String]
|
|
155
|
+
# @param type [String, Symbol, nil] singular type name; disambiguates an
|
|
156
|
+
# identifier shared by more than one type
|
|
157
|
+
# @return [Hash, nil] string-keyed unit, or nil
|
|
158
|
+
def unit(identifier, type: nil)
|
|
159
|
+
return @reader.find_unit(identifier) if type.nil?
|
|
160
|
+
|
|
161
|
+
TypedUnitReader.call(@payload_dir, @reader, identifier, type.to_s)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Index entries, each with a `'type'` key added.
|
|
165
|
+
#
|
|
166
|
+
# @param type [String, Symbol, nil] singular type name to restrict to
|
|
167
|
+
# @return [Array<Hash>]
|
|
168
|
+
def units(type: nil)
|
|
169
|
+
dirs = if type
|
|
170
|
+
dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type.to_s]
|
|
171
|
+
dir ? [dir] : []
|
|
172
|
+
else
|
|
173
|
+
Woods::MCP::IndexReader::TYPE_DIRS
|
|
174
|
+
end
|
|
175
|
+
dirs.flat_map do |dir|
|
|
176
|
+
@reader.list_units(type: Woods::MCP::IndexReader::DIR_TO_TYPE[dir])
|
|
177
|
+
.map { |entry| entry.merge('type' => Woods::MCP::IndexReader::DIR_TO_TYPE[dir]) }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Every forward edge in the graph, primary nodes and variants alike.
|
|
182
|
+
#
|
|
183
|
+
# An identifier shared by more than one type contributes one edge per
|
|
184
|
+
# owning type; two edges are never folded into one just because they look
|
|
185
|
+
# alike once reduced to `{from, to, via, through, through_db, disable_joins}`.
|
|
186
|
+
#
|
|
187
|
+
# @param via [String, Symbol, nil] restrict to one relationship label
|
|
188
|
+
# @return [Array<Hash>] `{ from:, to:, via:, through:, through_db:, disable_joins: }`
|
|
189
|
+
def edges(via: nil)
|
|
190
|
+
wanted = via&.to_s
|
|
191
|
+
EdgeShaper.call(@reader.raw_graph_data).select { |edge| wanted.nil? || edge[:via] == wanted }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @yieldparam edge [Hash] see {#edges}
|
|
195
|
+
# @return [void]
|
|
196
|
+
def each_edge(via: nil, &block)
|
|
197
|
+
edges(via: via).each(&block)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @param identifier [String]
|
|
201
|
+
# @param via [String, Symbol, nil]
|
|
202
|
+
# @return [Array<String>] identifiers that depend on `identifier`
|
|
203
|
+
def dependents_of(identifier, via: nil)
|
|
204
|
+
return Array((@reader.raw_graph_data['reverse'] || {})[identifier]).dup if via.nil?
|
|
205
|
+
|
|
206
|
+
edges(via: via).select { |edge| edge[:to] == identifier }.map { |edge| edge[:from] }.uniq
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# table name => database name, from model units that carry
|
|
210
|
+
# `metadata.database` (Rails 6.1+ extractions).
|
|
211
|
+
#
|
|
212
|
+
# Reads every model unit once; cache it in a cop.
|
|
213
|
+
#
|
|
214
|
+
# @return [Hash{String => String}]
|
|
215
|
+
def table_database_map
|
|
216
|
+
units(type: 'model').each_with_object({}) do |entry, map|
|
|
217
|
+
data = unit(entry['identifier'], type: 'model')
|
|
218
|
+
next unless data
|
|
219
|
+
|
|
220
|
+
table = data.dig('metadata', 'table_name')
|
|
221
|
+
database = data.dig('metadata', 'database')
|
|
222
|
+
map[table] = database if table && database
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A digest of the pinned payload's `manifest.json`. RuboCop re-runs a cop
|
|
227
|
+
# on every file when this value changes, so keying on the manifest that
|
|
228
|
+
# was rewritten by the publish this reader is pinned to catches every
|
|
229
|
+
# publish, whether the reader ended up on a flat index or a numbered
|
|
230
|
+
# generation.
|
|
231
|
+
#
|
|
232
|
+
# @return [String] SHA-256 hex
|
|
233
|
+
def external_dependency_checksum
|
|
234
|
+
Digest::SHA256.file(@payload_dir.join('manifest.json').to_s).hexdigest
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
# @return [void]
|
|
240
|
+
def initialize_flat_index
|
|
241
|
+
raise ArgumentError, "No manifest.json found in: #{@index_dir}" unless @index_dir.join('manifest.json').file?
|
|
242
|
+
|
|
243
|
+
@generation_number = 0
|
|
244
|
+
@payload_dir = @index_dir
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @param number [Integer]
|
|
248
|
+
# @return [void]
|
|
249
|
+
# @raise [ArgumentError] when +number+ is not a published generation
|
|
250
|
+
def initialize_generation(number)
|
|
251
|
+
dir = Woods::PayloadStore.new(@index_dir).path_for(number)
|
|
252
|
+
unless self.class.available_generations(@index_dir).include?(number)
|
|
253
|
+
raise ArgumentError, "Generation #{number} is not published under #{@index_dir} (expected #{dir.basename})"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
@generation_number = number
|
|
257
|
+
@payload_dir = dir
|
|
258
|
+
acquire_retention_lock!
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Hold the same shared advisory lock on the generation's `manifest.json`
|
|
262
|
+
# that {Woods::MCP::IndexReader} takes to survive concurrent retention
|
|
263
|
+
# (see `PayloadStore#prune`'s doc comment on the lock protocol), but for
|
|
264
|
+
# the whole lifetime of this reader rather than one pinned read. Kept
|
|
265
|
+
# open until {#close}.
|
|
266
|
+
#
|
|
267
|
+
# @return [void]
|
|
268
|
+
def acquire_retention_lock!
|
|
269
|
+
@lock_file = File.open(@payload_dir.join('manifest.json').to_s, File::RDONLY)
|
|
270
|
+
@lock_file.flock(File::LOCK_SH)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Build the underlying reader, releasing any retention lock already
|
|
274
|
+
# acquired ({#initialize_generation}) before letting the failure
|
|
275
|
+
# propagate. Without this a `PublishedIndex` that fails to finish
|
|
276
|
+
# constructing would leak an open advisory lock for the life of the
|
|
277
|
+
# process.
|
|
278
|
+
#
|
|
279
|
+
# @return [void]
|
|
280
|
+
def open_reader!
|
|
281
|
+
@reader = Woods::MCP::IndexReader.new(@payload_dir.to_s, auto_refresh: false)
|
|
282
|
+
rescue Exception # rubocop:disable Lint/RescueException -- release the lock for every failure mode, then re-raise unchanged
|
|
283
|
+
close
|
|
284
|
+
raise
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
data/lib/woods/railtie.rb
CHANGED
|
@@ -1,16 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'woods/railtie_support'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
# Railtie integrates Woods into Rails applications.
|
|
5
7
|
# Loads rake tasks automatically when the gem is bundled.
|
|
6
|
-
#
|
|
8
|
+
#
|
|
9
|
+
# == Console MCP (#183)
|
|
10
|
+
#
|
|
11
|
+
# The console stack (OriginGuard -> BearerAuth -> Console::RackMiddleware)
|
|
12
|
+
# is mounted unconditionally, but every decision is deferred to request
|
|
13
|
+
# time: railtie initializers run before `:load_config_initializers`, so
|
|
14
|
+
# flags and tokens set in `config/initializers/woods.rb` (the
|
|
15
|
+
# generator-created location) do not exist yet when middleware arguments
|
|
16
|
+
# are captured. Both guards are scoped to `console_mcp_path` — requests
|
|
17
|
+
# outside it pass through untouched — and while `console_mcp_enabled` is
|
|
18
|
+
# false the whole stack passes through, so an unconfigured host app is
|
|
19
|
+
# completely unaffected. Boot-time validation of the settled configuration
|
|
20
|
+
# happens in `after_initialize` via {Woods::RailtieSupport}.
|
|
21
|
+
#
|
|
22
|
+
# == Session tracer
|
|
23
|
+
#
|
|
24
|
+
# SessionTracer::Middleware requires a store at construction, so it cannot
|
|
25
|
+
# be mounted lazily. It stays conditionally mounted, and the
|
|
26
|
+
# `after_initialize` hook warns when the flag was set too late to mount
|
|
27
|
+
# (i.e. in config/initializers instead of config/application.rb).
|
|
7
28
|
class Railtie < Rails::Railtie
|
|
8
29
|
rake_tasks do
|
|
9
30
|
load File.expand_path('../tasks/woods.rake', __dir__)
|
|
31
|
+
# Loaded here too, or `woods:evaluate` exists on no host — the file
|
|
32
|
+
# shipped in the gem for its whole life without ever being loaded, so
|
|
33
|
+
# the retrieval-quality harness had no entry point at all (#212).
|
|
34
|
+
load File.expand_path('../tasks/woods_evaluation.rake', __dir__)
|
|
35
|
+
# Read-only checks over retained generations (#280); no Rails boot.
|
|
36
|
+
load File.expand_path('../tasks/woods_checks.rake', __dir__)
|
|
10
37
|
end
|
|
11
38
|
|
|
12
39
|
initializer 'woods.session_tracer' do |app|
|
|
13
|
-
config
|
|
40
|
+
# RailtieSupport.config initializes defaults when the host app never
|
|
41
|
+
# called Woods.configure — this initializer runs in every app.
|
|
42
|
+
config = Woods::RailtieSupport.config
|
|
43
|
+
Woods::RailtieSupport.session_tracer_state = nil
|
|
14
44
|
next unless config.session_tracer_enabled
|
|
15
45
|
|
|
16
46
|
if defined?(Rails) && Rails.env.production? && !config.session_tracer_allow_production
|
|
@@ -21,6 +51,7 @@ module Woods
|
|
|
21
51
|
else
|
|
22
52
|
warn msg
|
|
23
53
|
end
|
|
54
|
+
Woods::RailtieSupport.session_tracer_state = :skipped_production
|
|
24
55
|
next
|
|
25
56
|
end
|
|
26
57
|
|
|
@@ -32,47 +63,55 @@ module Woods
|
|
|
32
63
|
session_id_proc: config.session_id_proc,
|
|
33
64
|
exclude_paths: config.session_exclude_paths
|
|
34
65
|
)
|
|
66
|
+
Woods::RailtieSupport.session_tracer_state = :mounted
|
|
35
67
|
end
|
|
36
68
|
|
|
37
69
|
initializer 'woods.console_mcp' do |app|
|
|
38
|
-
config = Woods.configuration
|
|
39
|
-
next unless config.console_mcp_enabled
|
|
40
|
-
|
|
41
70
|
require 'woods/console/rack_middleware'
|
|
42
71
|
require 'woods/mcp/bearer_auth'
|
|
43
72
|
require 'woods/mcp/origin_guard'
|
|
44
73
|
|
|
45
|
-
token
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'to a 32+ character random string.'
|
|
54
|
-
raise Woods::ConfigurationError, msg if production
|
|
55
|
-
|
|
56
|
-
# Non-prod without a token: refuse to wire the middleware at all.
|
|
57
|
-
# Earlier iterations fell through and installed the RackMiddleware
|
|
58
|
-
# with ZERO auth/origin guard in front of it — a binding on 0.0.0.0
|
|
59
|
-
# (common in devcontainers/docker-compose) would expose an
|
|
60
|
-
# unauthenticated SQL-bearing endpoint to every local process.
|
|
61
|
-
# Fail-closed: warn and skip.
|
|
62
|
-
warn "#{msg} Refusing to mount the Console MCP middleware until a token is configured."
|
|
63
|
-
next
|
|
64
|
-
end
|
|
74
|
+
# Mounted unconditionally; enablement, token, and allow-list are all
|
|
75
|
+
# resolved at request time (see RailtieSupport) so values set in
|
|
76
|
+
# config/initializers take effect. The path is the one thing captured
|
|
77
|
+
# now — changing console_mcp_path must happen in config/application.rb
|
|
78
|
+
# (after_initialize warns otherwise).
|
|
79
|
+
path = Woods::RailtieSupport.config.console_mcp_path
|
|
80
|
+
Woods::RailtieSupport.console_mounted_path = path
|
|
81
|
+
enabled = Woods::RailtieSupport.console_enabled_proc
|
|
65
82
|
|
|
66
83
|
# Origin guard first — rejects cross-origin POSTs before any auth cost.
|
|
67
|
-
# BearerAuth next — requires `Authorization: Bearer <token>`
|
|
68
|
-
|
|
69
|
-
|
|
84
|
+
# BearerAuth next — requires `Authorization: Bearer <token>` at the
|
|
85
|
+
# console path, failing closed (401) while no usable token is
|
|
86
|
+
# configured. Both are scoped to the console path and inert while
|
|
87
|
+
# console_mcp_enabled is false.
|
|
88
|
+
app.middleware.use(
|
|
89
|
+
Woods::MCP::OriginGuard,
|
|
90
|
+
allowed_origins: Woods::RailtieSupport.console_allowed_origins_proc,
|
|
91
|
+
path: path,
|
|
92
|
+
enabled: enabled
|
|
93
|
+
)
|
|
94
|
+
app.middleware.use(
|
|
95
|
+
Woods::MCP::BearerAuth,
|
|
96
|
+
token: Woods::RailtieSupport.console_token_proc,
|
|
97
|
+
path: path,
|
|
98
|
+
enabled: enabled
|
|
99
|
+
)
|
|
70
100
|
|
|
71
101
|
app.middleware.use(
|
|
72
102
|
Woods::Console::RackMiddleware,
|
|
73
|
-
path:
|
|
74
|
-
embedded_read_tools:
|
|
103
|
+
path: path,
|
|
104
|
+
embedded_read_tools: Woods::RailtieSupport.console_read_tools_proc
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs after :load_config_initializers, when the configuration is final —
|
|
109
|
+
# this is where boot-time validation belongs (#183).
|
|
110
|
+
config.after_initialize do
|
|
111
|
+
Woods::RailtieSupport.verify_console_configuration!(
|
|
112
|
+
production: defined?(Rails) && Rails.env.production?
|
|
75
113
|
)
|
|
114
|
+
Woods::RailtieSupport.verify_session_tracer_configuration!
|
|
76
115
|
end
|
|
77
116
|
end
|
|
78
117
|
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
# Rails-independent logic behind {Woods::Railtie}.
|
|
5
|
+
#
|
|
6
|
+
# Rails railtie initializers run before `:load_config_initializers`, so
|
|
7
|
+
# anything the railtie reads from Woods.configuration at initializer time
|
|
8
|
+
# sees the defaults — not the values set in `config/initializers/woods.rb`
|
|
9
|
+
# (the generator-created location every doc names). The railtie therefore
|
|
10
|
+
# mounts the console stack unconditionally and defers every decision to
|
|
11
|
+
# request time via the procs below, and validates the settled
|
|
12
|
+
# configuration from an `after_initialize` hook (#183).
|
|
13
|
+
#
|
|
14
|
+
# This module carries no Rails dependency so the gem's unit specs can
|
|
15
|
+
# exercise the request-time procs and boot-time verification without
|
|
16
|
+
# loading Rails::Railtie.
|
|
17
|
+
module RailtieSupport
|
|
18
|
+
# Boot-time message for a console enabled without a token. Raised in
|
|
19
|
+
# production; warned everywhere else. Only the HTTP transport enforces
|
|
20
|
+
# the token ({Woods::MCP::BearerAuth} fails closed with 401 at the console
|
|
21
|
+
# path); the stdio server never sees a bearer header, so for it a missing
|
|
22
|
+
# token is a production-boot failure, not a request failure.
|
|
23
|
+
MISSING_TOKEN_MESSAGE =
|
|
24
|
+
'[Woods Console] console_mcp_token is not set — Console MCP is a high-privilege ' \
|
|
25
|
+
'endpoint that runs SQL and model introspection against the live database. ' \
|
|
26
|
+
'Set Woods.configuration.console_mcp_token (or WOODS_CONSOLE_MCP_TOKEN env var) ' \
|
|
27
|
+
'to a 32+ character random string.'
|
|
28
|
+
|
|
29
|
+
# Transport qualifier appended to {MISSING_TOKEN_MESSAGE} outside
|
|
30
|
+
# production (G-3). The token authenticates the *HTTP* Console transport
|
|
31
|
+
# only: {Woods::Console::RackMiddleware} and {Woods::MCP::BearerAuth} are
|
|
32
|
+
# what return the 401. The stdio transport (`rake woods:console`,
|
|
33
|
+
# `exe/woods-console`) speaks over a pipe and neither sends nor consumes a
|
|
34
|
+
# bearer token, so an unqualified "requests will be refused (401)" sent
|
|
35
|
+
# stdio-only operators configuring a token their setup never uses.
|
|
36
|
+
MISSING_TOKEN_TRANSPORT_NOTE =
|
|
37
|
+
'HTTP Console MCP requests will be refused (401) until one is set. The stdio ' \
|
|
38
|
+
'transport (rake woods:console) does not transmit or use a bearer token, so a ' \
|
|
39
|
+
'stdio-only setup still works — set the token before exposing the HTTP endpoint. ' \
|
|
40
|
+
'Production boot will raise while Console MCP is enabled without a token.'
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# @return [String, nil] path the console stack was mounted at (captured
|
|
44
|
+
# at railtie-initializer time). Used by
|
|
45
|
+
# {.verify_console_configuration!} to detect a `console_mcp_path`
|
|
46
|
+
# set too late to take effect.
|
|
47
|
+
attr_accessor :console_mounted_path
|
|
48
|
+
|
|
49
|
+
# @return [Symbol, nil] `:mounted` when the session tracer middleware
|
|
50
|
+
# was inserted, `:skipped_production` when the production guard
|
|
51
|
+
# refused it, nil when the flag was off at railtie-initializer time.
|
|
52
|
+
attr_accessor :session_tracer_state
|
|
53
|
+
|
|
54
|
+
# Request-time predicate handed to the console guards and consulted by
|
|
55
|
+
# {Woods::Console::RackMiddleware}. Reads the live configuration on
|
|
56
|
+
# every call, so a flag set after the middleware was inserted (e.g. in
|
|
57
|
+
# config/initializers) still takes effect.
|
|
58
|
+
#
|
|
59
|
+
# @return [Proc] arity-0 proc returning the current enabled flag
|
|
60
|
+
def console_enabled_proc
|
|
61
|
+
-> { config.console_mcp_enabled }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Deferred bearer token for {Woods::MCP::BearerAuth}, resolved on each
|
|
65
|
+
# guarded request.
|
|
66
|
+
#
|
|
67
|
+
# @return [Proc] arity-0 proc returning the configured token (or nil)
|
|
68
|
+
def console_token_proc
|
|
69
|
+
-> { config.console_mcp_token }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deferred origin allow-list for {Woods::MCP::OriginGuard}, resolved on
|
|
73
|
+
# the first guarded request.
|
|
74
|
+
#
|
|
75
|
+
# @return [Proc] arity-0 proc returning the configured allow-list
|
|
76
|
+
def console_allowed_origins_proc
|
|
77
|
+
-> { Array(config.console_mcp_allowed_origins) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deferred read-tools flag for {Woods::Console::RackMiddleware},
|
|
81
|
+
# resolved when the embedded server is first built.
|
|
82
|
+
#
|
|
83
|
+
# @return [Proc] arity-0 proc returning the configured flag
|
|
84
|
+
def console_read_tools_proc
|
|
85
|
+
-> { config.console_embedded_read_tools }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The live configuration, initialized to defaults when the host never
|
|
89
|
+
# called Woods.configure — the console stack is mounted in every app
|
|
90
|
+
# now, so nothing here may assume a configure block ran.
|
|
91
|
+
#
|
|
92
|
+
# @return [Woods::Configuration]
|
|
93
|
+
def config
|
|
94
|
+
Woods.configuration || Woods.configure
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Boot-time console validation, run from `after_initialize` once the
|
|
98
|
+
# final configuration is known. With the console enabled and no usable
|
|
99
|
+
# token, every HTTP request at the console path will be refused with
|
|
100
|
+
# 401 — production refuses to boot instead (matching the pre-#183
|
|
101
|
+
# fail-closed posture), other environments warn loudly. The stdio
|
|
102
|
+
# server carries no bearer check, so the warning says which transport
|
|
103
|
+
# it is about rather than claiming every request fails. A token shorter than
|
|
104
|
+
# {Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH} is a misconfiguration and
|
|
105
|
+
# raises in every environment (as the eager BearerAuth constructor
|
|
106
|
+
# used to). Also warns when `console_mcp_path` changed after the stack
|
|
107
|
+
# was mounted.
|
|
108
|
+
#
|
|
109
|
+
# @param production [Boolean] whether the app runs in production
|
|
110
|
+
# @return [void]
|
|
111
|
+
# @raise [Woods::ConfigurationError] on a missing token in production,
|
|
112
|
+
# or a too-short token anywhere
|
|
113
|
+
def verify_console_configuration!(production:)
|
|
114
|
+
warn_late_console_path(config)
|
|
115
|
+
return unless config.console_mcp_enabled
|
|
116
|
+
|
|
117
|
+
require 'woods/mcp/bearer_auth'
|
|
118
|
+
token = config.console_mcp_token.to_s
|
|
119
|
+
if token.empty?
|
|
120
|
+
raise Woods::ConfigurationError, MISSING_TOKEN_MESSAGE if production
|
|
121
|
+
|
|
122
|
+
warn "#{MISSING_TOKEN_MESSAGE} #{MISSING_TOKEN_TRANSPORT_NOTE}"
|
|
123
|
+
elsif token.length < Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH
|
|
124
|
+
raise Woods::ConfigurationError,
|
|
125
|
+
'[Woods Console] console_mcp_token is shorter than ' \
|
|
126
|
+
"#{Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH} characters; generate one with " \
|
|
127
|
+
'`SecureRandom.hex(32)` (or `rake woods:generate_token`).'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Boot-time session tracer validation, run from `after_initialize`.
|
|
132
|
+
# Unlike the console stack, SessionTracer::Middleware requires a store
|
|
133
|
+
# at construction, so it cannot be mounted lazily — when the flag was
|
|
134
|
+
# set too late for the railtie initializer to see it, warn with the
|
|
135
|
+
# remediation instead of silently doing nothing (#183).
|
|
136
|
+
#
|
|
137
|
+
# @return [void]
|
|
138
|
+
def verify_session_tracer_configuration!
|
|
139
|
+
return unless config.session_tracer_enabled
|
|
140
|
+
return unless session_tracer_state.nil?
|
|
141
|
+
|
|
142
|
+
warn '[Woods] session_tracer_enabled is set but the middleware was not mounted: ' \
|
|
143
|
+
'the flag was enabled after Rails railtie initializers ran (config/initializers/ ' \
|
|
144
|
+
'runs later). Set session_tracer_enabled and session_store in ' \
|
|
145
|
+
'config/application.rb, then restart.'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Warn when the configured console path no longer matches the path the
|
|
151
|
+
# stack was mounted at — the path was set after railtie initializers
|
|
152
|
+
# captured the middleware arguments.
|
|
153
|
+
#
|
|
154
|
+
# @param config [Woods::Configuration]
|
|
155
|
+
# @return [void]
|
|
156
|
+
def warn_late_console_path(config)
|
|
157
|
+
mounted = console_mounted_path
|
|
158
|
+
return if mounted.nil? || mounted == config.console_mcp_path
|
|
159
|
+
|
|
160
|
+
warn "[Woods Console] console_mcp_path is #{config.console_mcp_path.inspect} but the " \
|
|
161
|
+
"console stack was mounted at #{mounted.inspect} — the path was changed after " \
|
|
162
|
+
'Rails railtie initializers ran. Set console_mcp_path in config/application.rb, ' \
|
|
163
|
+
'then restart.'
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
# Maintainer-only release machinery, deliberately not packaged with the gem.
|
|
5
|
+
# These files back `release:prepare` and `release:reopen`, which only ever run
|
|
6
|
+
# from a source checkout of this repository.
|
|
7
|
+
module Release
|
|
8
|
+
# Every refusal the release flow raises. `lib/tasks/release.rake` rescues
|
|
9
|
+
# this one class and turns it into a clean abort.
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
end
|
|
12
|
+
end
|