woods 2.0.0 → 2.1.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 +108 -0
- data/CONTRIBUTING.md +135 -10
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -18
- data/docs/CONSOLE_MCP_SETUP.md +141 -12
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +59 -2
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +184 -9
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/exe/woods-mcp-http +16 -9
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +68 -41
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +431 -48
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +68 -11
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +336 -2
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
- data/plugin/skills/woods-setup/SKILL.md +56 -1
- metadata +34 -5
|
@@ -46,12 +46,15 @@ module Woods
|
|
|
46
46
|
|
|
47
47
|
# Most output directories contain one unit type. RailsSourceExtractor
|
|
48
48
|
# emits gem_source units in rails_source/, while GraphQL publishes four
|
|
49
|
-
# subtypes in graphql/.
|
|
50
|
-
#
|
|
49
|
+
# subtypes in graphql/. Search accepts directory-family aliases and
|
|
50
|
+
# concrete types, and returns each unit's actual public type.
|
|
51
51
|
UNIT_TYPES_BY_DIR = DIR_TO_TYPE.transform_values { |type| [type].freeze }
|
|
52
52
|
.merge('rails_source' => %w[rails_source gem_source].freeze,
|
|
53
53
|
'graphql' => %w[graphql_type graphql_mutation graphql_resolver graphql_query].freeze)
|
|
54
54
|
.freeze
|
|
55
|
+
UNIT_TYPE_TO_DIR = UNIT_TYPES_BY_DIR.each_with_object({}) do |(directory, types), result|
|
|
56
|
+
types.each { |type| result[type] = directory }
|
|
57
|
+
end.freeze
|
|
55
58
|
|
|
56
59
|
# Maximum number of loaded unit files to cache in memory.
|
|
57
60
|
MAX_UNIT_CACHE = 50
|
|
@@ -375,14 +378,14 @@ module Woods
|
|
|
375
378
|
def find_unit(identifier, type: nil)
|
|
376
379
|
if type
|
|
377
380
|
return with_pinned_generation do
|
|
378
|
-
dir =
|
|
381
|
+
dir = UNIT_TYPE_TO_DIR[type] || TYPE_TO_DIR[type]
|
|
379
382
|
next nil unless dir
|
|
380
383
|
raise IOError, "symlink unit directory: #{dir}" if current_payload_dir.join(dir).symlink?
|
|
381
384
|
|
|
382
385
|
next nil unless search_index_entries(dir).any? { |entry| entry['identifier'] == identifier }
|
|
383
386
|
|
|
384
387
|
unit = read_published_unit(dir, identifier)
|
|
385
|
-
unit if unit['type'] == type
|
|
388
|
+
unit if unit['type'] == type || !UNIT_TYPE_TO_DIR.key?(type)
|
|
386
389
|
end
|
|
387
390
|
end
|
|
388
391
|
ensure_fresh!
|
|
@@ -489,6 +492,7 @@ module Woods
|
|
|
489
492
|
# @api private
|
|
490
493
|
def search_within_pin(query = nil, types: nil, fields: %w[identifier], limit: 20,
|
|
491
494
|
exact_prefix: nil, exact_suffix: nil, packages: nil, source_paths: nil)
|
|
495
|
+
types = normalize_search_types(types)
|
|
492
496
|
scope = search_scope(packages, source_paths, types)
|
|
493
497
|
prefix = exact_prefix.blank? ? nil : exact_prefix.downcase
|
|
494
498
|
suffix = exact_suffix.blank? ? nil : exact_suffix.downcase
|
|
@@ -508,7 +512,7 @@ module Woods
|
|
|
508
512
|
phase2_scanned = 0
|
|
509
513
|
|
|
510
514
|
begin
|
|
511
|
-
dirs =
|
|
515
|
+
dirs = types ? types.map { |type| UNIT_TYPE_TO_DIR.fetch(type) }.uniq : TYPE_DIRS
|
|
512
516
|
# Identifier matches retain priority. Deep candidates are interleaved
|
|
513
517
|
# across types so an early large directory cannot consume their budget.
|
|
514
518
|
phase2_queues = {}
|
|
@@ -516,7 +520,13 @@ module Woods
|
|
|
516
520
|
dirs.each do |dir|
|
|
517
521
|
type_name = DIR_TO_TYPE[dir]
|
|
518
522
|
entries = search_index_entries(dir)
|
|
519
|
-
entries =
|
|
523
|
+
entries = if scope
|
|
524
|
+
scoped_search_entries(entries, dir, scope)
|
|
525
|
+
elsif types
|
|
526
|
+
typed_search_entries(entries, dir, types, results)
|
|
527
|
+
else
|
|
528
|
+
entries
|
|
529
|
+
end
|
|
520
530
|
if entries.size > 1
|
|
521
531
|
matching_count = entries.count do |entry|
|
|
522
532
|
identifier_passes_filters?(entry['identifier'], pattern, prefix, suffix)
|
|
@@ -527,11 +537,13 @@ module Woods
|
|
|
527
537
|
end
|
|
528
538
|
|
|
529
539
|
entries.each do |entry|
|
|
530
|
-
type_name = entry.fetch('scope_type', DIR_TO_TYPE[dir])
|
|
531
540
|
id = entry['identifier']
|
|
532
541
|
next unless identifier_passes_prefix_suffix?(id, prefix, suffix)
|
|
533
542
|
|
|
534
543
|
if fields.include?('identifier') && pattern.match?(id)
|
|
544
|
+
type_name = search_entry_type(entry, dir, results)
|
|
545
|
+
next unless type_name
|
|
546
|
+
|
|
535
547
|
results.add(identifier: id, type: type_name, match_field: 'identifier')
|
|
536
548
|
throw :search_done if results.result_limit_reached?
|
|
537
549
|
|
|
@@ -539,7 +551,7 @@ module Woods
|
|
|
539
551
|
end
|
|
540
552
|
next unless fields.include?('metadata') || fields.include?('source_code')
|
|
541
553
|
|
|
542
|
-
(phase2_queues[dir] ||= []) << [
|
|
554
|
+
(phase2_queues[dir] ||= []) << [entry, dir]
|
|
543
555
|
end
|
|
544
556
|
end
|
|
545
557
|
|
|
@@ -553,10 +565,21 @@ module Woods
|
|
|
553
565
|
results.stop('scan_budget')
|
|
554
566
|
throw :search_done
|
|
555
567
|
end
|
|
556
|
-
|
|
568
|
+
entry, dir = queue.shift
|
|
569
|
+
id = entry['identifier']
|
|
557
570
|
progressed = true
|
|
558
571
|
phase2_scanned += 1
|
|
559
|
-
|
|
572
|
+
type_name = search_entry_type(entry, dir, results)
|
|
573
|
+
next unless type_name
|
|
574
|
+
|
|
575
|
+
unit = if scope
|
|
576
|
+
scope.metadata_store.find(StorageIdentity.key(id, type_name))
|
|
577
|
+
else
|
|
578
|
+
readable_search_unit(type_name, id, results)
|
|
579
|
+
end
|
|
580
|
+
next unless unit
|
|
581
|
+
|
|
582
|
+
type_name = unit['type']
|
|
560
583
|
field = if fields.include?('source_code') && unit['source_code'] && pattern.match?(unit['source_code'])
|
|
561
584
|
'source_code'
|
|
562
585
|
elsif fields.include?('metadata') && unit['metadata'] && pattern.match?(unit['metadata'].to_json)
|
|
@@ -593,6 +616,53 @@ module Woods
|
|
|
593
616
|
end
|
|
594
617
|
private :search_scope
|
|
595
618
|
|
|
619
|
+
def normalize_search_types(types)
|
|
620
|
+
return nil if types.nil? || types == []
|
|
621
|
+
unless types.is_a?(Array) && types.all?(String)
|
|
622
|
+
raise ArgumentError, 'search types must be an array of type names'
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
types.flat_map do |type|
|
|
626
|
+
if TYPE_TO_DIR.key?(type)
|
|
627
|
+
UNIT_TYPES_BY_DIR.fetch(TYPE_TO_DIR.fetch(type))
|
|
628
|
+
elsif UNIT_TYPE_TO_DIR.key?(type)
|
|
629
|
+
[type]
|
|
630
|
+
else
|
|
631
|
+
raise ArgumentError, "unknown search type: #{type}"
|
|
632
|
+
end
|
|
633
|
+
end.uniq
|
|
634
|
+
end
|
|
635
|
+
private :normalize_search_types
|
|
636
|
+
|
|
637
|
+
# Modern summaries carry their public type. Resolve legacy family types
|
|
638
|
+
# only when a type filter or matching candidate needs that identity.
|
|
639
|
+
def search_entry_type(entry, dir, results)
|
|
640
|
+
actual = entry['scope_type'] || entry['type']
|
|
641
|
+
possible = UNIT_TYPES_BY_DIR.fetch(dir)
|
|
642
|
+
actual ||= possible.one? ? possible.first : readable_search_unit(DIR_TO_TYPE.fetch(dir), entry['identifier'], results)&.fetch('type')
|
|
643
|
+
return actual if possible.include?(actual)
|
|
644
|
+
|
|
645
|
+
results.stop('unreadable_or_corrupt_source')
|
|
646
|
+
nil
|
|
647
|
+
end
|
|
648
|
+
private :search_entry_type
|
|
649
|
+
|
|
650
|
+
def readable_search_unit(type, identifier, results)
|
|
651
|
+
load_search_unit(type, identifier)
|
|
652
|
+
rescue JSON::ParserError, IOError, SystemCallError
|
|
653
|
+
results.stop('unreadable_or_corrupt_source')
|
|
654
|
+
nil
|
|
655
|
+
end
|
|
656
|
+
private :readable_search_unit
|
|
657
|
+
|
|
658
|
+
def typed_search_entries(entries, dir, types, results)
|
|
659
|
+
entries.filter_map do |entry|
|
|
660
|
+
actual = search_entry_type(entry, dir, results)
|
|
661
|
+
entry.merge('scope_type' => actual) if types.include?(actual)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
private :typed_search_entries
|
|
665
|
+
|
|
596
666
|
def scoped_search_entries(entries, dir, scope)
|
|
597
667
|
entries.flat_map do |entry|
|
|
598
668
|
UNIT_TYPES_BY_DIR.fetch(dir).filter_map do |type|
|
|
@@ -870,7 +940,7 @@ module Woods
|
|
|
870
940
|
expected_dir.join('manifest.json').to_s, File::RDONLY
|
|
871
941
|
)
|
|
872
942
|
file.flock(File::LOCK_SH)
|
|
873
|
-
marker =
|
|
943
|
+
marker = published_marker
|
|
874
944
|
resolved = resolve_payload_dir(marker)
|
|
875
945
|
marker_still_matches = marker.number.zero? || (same_generation?(marker) && resolved == expected_dir)
|
|
876
946
|
if marker_still_matches && expected_dir.directory?
|
|
@@ -881,13 +951,16 @@ module Woods
|
|
|
881
951
|
file.flock(File::LOCK_UN)
|
|
882
952
|
file.close
|
|
883
953
|
load_generation(marker) unless marker.number.zero?
|
|
954
|
+
rescue Woods::Generation::InvalidMarker
|
|
955
|
+
file&.close unless file&.closed?
|
|
956
|
+
raise
|
|
884
957
|
rescue Errno::ENOENT
|
|
885
958
|
file&.close unless file&.closed?
|
|
886
959
|
return if saw_missing_manifest && missing_manifest_dir == expected_dir
|
|
887
960
|
|
|
888
961
|
saw_missing_manifest = true
|
|
889
962
|
missing_manifest_dir = expected_dir
|
|
890
|
-
marker =
|
|
963
|
+
marker = published_marker
|
|
891
964
|
load_generation(marker) unless marker.number.zero?
|
|
892
965
|
end
|
|
893
966
|
end
|
|
@@ -934,13 +1007,26 @@ module Woods
|
|
|
934
1007
|
return nil if @pin_depth.positive?
|
|
935
1008
|
|
|
936
1009
|
signature = generation_signature
|
|
937
|
-
|
|
1010
|
+
if signature.nil?
|
|
1011
|
+
published_marker if @loaded_generation
|
|
1012
|
+
return nil
|
|
1013
|
+
end
|
|
1014
|
+
return nil if signature == @generation_signature
|
|
938
1015
|
|
|
1016
|
+
marker = published_marker
|
|
1017
|
+
resolve_payload_dir(marker)
|
|
1018
|
+
load_generation(marker) unless marker.number.zero? || same_generation?(marker)
|
|
939
1019
|
@generation_signature = signature
|
|
940
|
-
|
|
941
|
-
|
|
1020
|
+
@loaded_generation
|
|
1021
|
+
end
|
|
942
1022
|
|
|
943
|
-
|
|
1023
|
+
def published_marker
|
|
1024
|
+
marker = @generation.current!
|
|
1025
|
+
if marker.number.zero? && @loaded_generation&.positive? && current_payload_dir != @index_dir
|
|
1026
|
+
raise Woods::Generation::InvalidMarker, 'Published generation marker is missing'
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
marker
|
|
944
1030
|
end
|
|
945
1031
|
|
|
946
1032
|
# Adopt one already-read marker as the reader's loaded generation.
|
|
@@ -949,8 +1035,9 @@ module Woods
|
|
|
949
1035
|
# @param marker [Woods::Generation::Marker]
|
|
950
1036
|
# @return [void]
|
|
951
1037
|
def load_generation(marker)
|
|
1038
|
+
directory = resolve_payload_dir(marker)
|
|
952
1039
|
reload!
|
|
953
|
-
@payload_dir =
|
|
1040
|
+
@payload_dir = directory
|
|
954
1041
|
@loaded_token = marker.token
|
|
955
1042
|
@loaded_generation = marker.number
|
|
956
1043
|
end
|
|
@@ -968,7 +1055,7 @@ module Woods
|
|
|
968
1055
|
|
|
969
1056
|
marker = Woods::Generation.new(output_dir: @index_dir).current
|
|
970
1057
|
resolve_payload_dir(marker).join('manifest.json').file?
|
|
971
|
-
rescue TypeError, NoMethodError
|
|
1058
|
+
rescue TypeError, NoMethodError, Woods::Generation::InvalidMarker
|
|
972
1059
|
# Match Bootstrapper's startup preflight for malformed marker shapes.
|
|
973
1060
|
# Keep this local: errors during later generation refresh still surface.
|
|
974
1061
|
false
|
|
@@ -995,7 +1082,12 @@ module Woods
|
|
|
995
1082
|
# @param marker [Woods::Generation::Marker]
|
|
996
1083
|
# @return [Pathname]
|
|
997
1084
|
def resolve_payload_dir(marker)
|
|
998
|
-
Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
|
|
1085
|
+
directory = Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
|
|
1086
|
+
if marker.payload && !marker.payload.empty? && directory == @index_dir
|
|
1087
|
+
raise Woods::Generation::InvalidMarker, 'Published generation payload is unavailable or outside the index'
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
directory
|
|
999
1091
|
end
|
|
1000
1092
|
|
|
1001
1093
|
# Compare the *token*, not the number.
|
|
@@ -1103,9 +1195,11 @@ module Woods
|
|
|
1103
1195
|
# generation file
|
|
1104
1196
|
def generation_signature
|
|
1105
1197
|
stat = File.stat(@generation.path)
|
|
1106
|
-
[stat.mtime.to_f, stat.size, stat.ino]
|
|
1107
|
-
rescue
|
|
1198
|
+
[stat.mtime.to_f, stat.ctime.to_f, stat.size, stat.ino]
|
|
1199
|
+
rescue Errno::ENOENT
|
|
1108
1200
|
nil
|
|
1201
|
+
rescue SystemCallError
|
|
1202
|
+
raise Woods::Generation::InvalidMarker, 'Published generation marker is unavailable'
|
|
1109
1203
|
end
|
|
1110
1204
|
|
|
1111
1205
|
# Case-insensitive literal prefix/suffix check on an identifier.
|
|
@@ -1211,8 +1305,9 @@ module Woods
|
|
|
1211
1305
|
# Read the selected type bucket, never the identifier-only lookup map.
|
|
1212
1306
|
# Keep the existing per-file signature checks and LRU cache for deep reads.
|
|
1213
1307
|
def load_search_unit(type, identifier)
|
|
1214
|
-
|
|
1215
|
-
|
|
1308
|
+
directory = TYPE_TO_DIR[type] || UNIT_TYPE_TO_DIR.fetch(type)
|
|
1309
|
+
data = load_unit(directory, unit_filename(identifier))
|
|
1310
|
+
unless valid_published_unit?(data, directory, identifier)
|
|
1216
1311
|
raise IOError, "typed unit identity mismatch: #{type}:#{identifier}"
|
|
1217
1312
|
end
|
|
1218
1313
|
|
|
@@ -1263,7 +1358,13 @@ module Woods
|
|
|
1263
1358
|
data = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
1264
1359
|
raise IOError, "non-regular unit file: #{dir}/#{filename}" unless file.stat.file?
|
|
1265
1360
|
|
|
1266
|
-
|
|
1361
|
+
# Decode the checked descriptor's bytes as UTF-8, independent of the
|
|
1362
|
+
# process locale. JSON parsers may accept invalid UTF-8 verbatim, so
|
|
1363
|
+
# validate before parsing instead of replacing malformed source bytes.
|
|
1364
|
+
content = file.binmode.read.force_encoding(Encoding::UTF_8)
|
|
1365
|
+
raise JSON::ParserError, "invalid UTF-8 in unit file: #{dir}/#{filename}" unless content.valid_encoding?
|
|
1366
|
+
|
|
1367
|
+
JSON.parse(content)
|
|
1267
1368
|
end
|
|
1268
1369
|
unless valid_published_unit?(data, dir, identifier)
|
|
1269
1370
|
raise IOError, "typed unit identity mismatch: #{dir}/#{filename}"
|
|
@@ -64,6 +64,15 @@ module Woods
|
|
|
64
64
|
return super unless reader && tool_names&.include?(request[:name])
|
|
65
65
|
|
|
66
66
|
reader.with_pinned_generation { super }
|
|
67
|
+
rescue Woods::Generation::InvalidMarker
|
|
68
|
+
message = 'Published generation marker is unavailable or malformed; run woods:validate and restore or rebuild the index.'
|
|
69
|
+
meta = { error_code: :corrupt_artifact, tool: request[:name] }
|
|
70
|
+
meta[:completeness] = SearchResults.unavailable if request[:name] == 'search'
|
|
71
|
+
::MCP::Tool::Response.new(
|
|
72
|
+
[{ type: 'text', text: message }], error: true,
|
|
73
|
+
structured_content: { text: message },
|
|
74
|
+
meta: meta
|
|
75
|
+
).to_h
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
def read_resource_contents(request, **kwargs)
|
|
@@ -71,6 +80,13 @@ module Woods
|
|
|
71
80
|
return super unless reader
|
|
72
81
|
|
|
73
82
|
reader.with_pinned_generation { super }
|
|
83
|
+
rescue Woods::Generation::InvalidMarker => e
|
|
84
|
+
raise ::MCP::Server::RequestHandlerError.new(
|
|
85
|
+
'Published generation marker is unavailable or malformed.', request,
|
|
86
|
+
error_type: :internal_error, original_error: e,
|
|
87
|
+
error_code: ::JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
88
|
+
error_data: { uri: request[:uri], error_code: 'corrupt_artifact' }
|
|
89
|
+
)
|
|
74
90
|
end
|
|
75
91
|
end
|
|
76
92
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
-
require_relative '
|
|
5
|
+
require_relative 'origin_policy'
|
|
6
6
|
|
|
7
7
|
module Woods
|
|
8
8
|
module MCP
|
|
@@ -21,17 +21,13 @@ module Woods
|
|
|
21
21
|
# also requiring Host to appear in the allow-list (or to be a loopback
|
|
22
22
|
# address), we close that gap even when Rails is bound to 0.0.0.0.
|
|
23
23
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# matters.
|
|
24
|
+
# Cross-origin entries match exactly. A portless entry also permits
|
|
25
|
+
# same-authority requests on other ports; cross-port browser clients need
|
|
26
|
+
# their actual origin explicitly configured, as required by the SDK.
|
|
28
27
|
#
|
|
29
28
|
# Also answers CORS preflight (OPTIONS) with the matching allow-list.
|
|
30
29
|
class OriginGuard
|
|
31
|
-
DEFAULT_ALLOWED =
|
|
32
|
-
http://localhost http://127.0.0.1 http://[::1]
|
|
33
|
-
https://localhost https://127.0.0.1 https://[::1]
|
|
34
|
-
].freeze
|
|
30
|
+
DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
|
|
35
31
|
|
|
36
32
|
# Hosts that always pass the Host-header check even without an explicit
|
|
37
33
|
# allow-list entry — they resolve to loopback by definition and cannot
|
|
@@ -56,6 +52,7 @@ module Woods
|
|
|
56
52
|
# which runs after Rails railtie initializers captured the middleware
|
|
57
53
|
# arguments — still takes effect (#183). Empty/nil falls back to
|
|
58
54
|
# {DEFAULT_ALLOWED}.
|
|
55
|
+
# @param policy [OriginPolicy, nil] Captured policy also passed to the SDK
|
|
59
56
|
# @param path [String, nil] When set, only requests whose PATH_INFO
|
|
60
57
|
# starts with this prefix are guarded — everything else passes
|
|
61
58
|
# straight through to the app. Nil (the default) guards every request.
|
|
@@ -82,27 +79,35 @@ module Woods
|
|
|
82
79
|
method = env['REQUEST_METHOD']
|
|
83
80
|
host = env['HTTP_HOST']
|
|
84
81
|
|
|
85
|
-
return forbidden
|
|
86
|
-
return forbidden_host
|
|
82
|
+
return forbidden unless policy.origin_allowed?(origin, host: host)
|
|
83
|
+
return forbidden_host unless policy.host_allowed?(host)
|
|
87
84
|
|
|
88
85
|
return preflight(origin) if method == 'OPTIONS'
|
|
89
86
|
|
|
90
87
|
status, headers, body = @app.call(env)
|
|
91
|
-
headers = cors_headers(origin).merge(headers) if origin
|
|
88
|
+
headers = cors_headers(origin).merge(headers) if origin
|
|
92
89
|
[status, headers, body]
|
|
93
90
|
end
|
|
94
91
|
|
|
92
|
+
# The same lazily captured immutable policy is passed to the transport.
|
|
93
|
+
# @return [OriginPolicy]
|
|
94
|
+
def policy
|
|
95
|
+
return @policy if @policy
|
|
96
|
+
|
|
97
|
+
@policy_mutex.synchronize do
|
|
98
|
+
@policy ||= OriginPolicy.new(allowed_origins: @allowed_source.call)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
95
102
|
private
|
|
96
103
|
|
|
97
|
-
def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
|
|
104
|
+
def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil, policy: nil)
|
|
98
105
|
@app = app
|
|
99
106
|
@path = path
|
|
100
107
|
@enabled = enabled
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
build_allow_list(allowed_origins)
|
|
105
|
-
end
|
|
108
|
+
@policy = policy
|
|
109
|
+
@policy_mutex = Mutex.new
|
|
110
|
+
@allowed_source = allowed_origins.respond_to?(:call) ? allowed_origins : -> { allowed_origins }
|
|
106
111
|
end
|
|
107
112
|
|
|
108
113
|
# @param env [Hash] Rack environment
|
|
@@ -114,66 +119,8 @@ module Woods
|
|
|
114
119
|
true
|
|
115
120
|
end
|
|
116
121
|
|
|
117
|
-
# Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
|
|
118
|
-
#
|
|
119
|
-
# @param origins [Array<String>, nil]
|
|
120
|
-
# @return [Array<String>] the normalized allow-list
|
|
121
|
-
def build_allow_list(origins)
|
|
122
|
-
allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
|
|
123
|
-
allowed = DEFAULT_ALLOWED.dup if allowed.empty?
|
|
124
|
-
@allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
|
|
125
|
-
@allowed = allowed
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# The allow-list, resolving a deferred source on first use. Memoized —
|
|
129
|
-
# configuration is settled by the time the first request arrives.
|
|
130
|
-
#
|
|
131
|
-
# @return [Array<String>]
|
|
132
|
-
def allowed
|
|
133
|
-
@allowed || build_allow_list(@allowed_source.call)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# @return [Array<String>] hosts extracted from the allow-list
|
|
137
|
-
def allowed_hosts
|
|
138
|
-
allowed
|
|
139
|
-
@allowed_hosts
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def normalize(origin)
|
|
143
|
-
origin.to_s.sub(%r{/\z}, '').downcase
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def extract_host(origin)
|
|
147
|
-
host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
|
|
148
|
-
host.empty? ? nil : host
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def host_allowed?(host)
|
|
152
|
-
# Canonicalize (strip port, trailing dot, IPv6 brackets) via the
|
|
153
|
-
# shared helper so Qdrant and OriginGuard stay in sync on bypass
|
|
154
|
-
# notations. `normalized` keeps the port for literal allow-list
|
|
155
|
-
# lookups; `bare` drops it for loopback matching.
|
|
156
|
-
normalized = host.to_s.downcase.sub(/\.\z/, '')
|
|
157
|
-
bare = Util::HostGuard.canonicalize(host)
|
|
158
|
-
|
|
159
|
-
# Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
|
|
160
|
-
# would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
|
|
161
|
-
# bypassing the loopback allow-list.
|
|
162
|
-
return false if Util::HostGuard.suspicious_numeric_host?(bare)
|
|
163
|
-
|
|
164
|
-
return true if LOOPBACK_HOSTS.include?(bare)
|
|
165
|
-
|
|
166
|
-
allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def origin_allowed?(origin)
|
|
170
|
-
return false if origin.match?(/[[:cntrl:]]/)
|
|
171
|
-
|
|
172
|
-
allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
|
|
173
|
-
end
|
|
174
|
-
|
|
175
122
|
def preflight(origin)
|
|
176
|
-
headers = origin
|
|
123
|
+
headers = origin ? cors_headers(origin) : {}
|
|
177
124
|
[204, headers, []]
|
|
178
125
|
end
|
|
179
126
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative '../util/host_guard'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module MCP
|
|
8
|
+
# Immutable HTTP policy shared by Woods preflight and the MCP transport.
|
|
9
|
+
# Explicit cross-origin entries normalize HTTP(S) default ports. A portless entry also permits
|
|
10
|
+
# same-authority requests on another port, which the SDK accepts natively.
|
|
11
|
+
# No request header is rewritten and SDK rebinding protection stays enabled.
|
|
12
|
+
class OriginPolicy
|
|
13
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
14
|
+
DEFAULT_ORIGINS = %w[
|
|
15
|
+
http://localhost http://127.0.0.1 http://[::1]
|
|
16
|
+
https://localhost https://127.0.0.1 https://[::1]
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# @param allowed_origins [Array<String>, nil] Explicit browser origins
|
|
20
|
+
def initialize(allowed_origins: nil)
|
|
21
|
+
entries = Array(allowed_origins).compact.reject do |entry|
|
|
22
|
+
entry.to_s.valid_encoding? && entry.to_s.strip.empty?
|
|
23
|
+
end
|
|
24
|
+
@explicit_origins = entries.map { |entry| configured_origin(entry) }.uniq.freeze
|
|
25
|
+
@allowed = (@explicit_origins.empty? ? DEFAULT_ORIGINS : @explicit_origins).freeze
|
|
26
|
+
@allowed_hosts = @explicit_origins.map do |origin|
|
|
27
|
+
host = authority(origin)
|
|
28
|
+
# SDK bare IPv6 host entries omit brackets; host:port entries retain
|
|
29
|
+
# them. URI#host alone retains brackets and loses explicit ports.
|
|
30
|
+
(host.end_with?(']') ? host[1...-1] : host).freeze
|
|
31
|
+
end.uniq.freeze
|
|
32
|
+
@transport_options = { allowed_origins: transport_origins, allowed_hosts: @allowed_hosts }.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] Constructor options supported by the MCP SDK
|
|
37
|
+
attr_reader :transport_options
|
|
38
|
+
|
|
39
|
+
# @param host [String, nil] Unmodified HTTP Host header
|
|
40
|
+
# @return [Boolean] Whether the request authority is allowed
|
|
41
|
+
def host_allowed?(host)
|
|
42
|
+
return true if host.nil?
|
|
43
|
+
return false unless host.is_a?(String) && host.valid_encoding?
|
|
44
|
+
|
|
45
|
+
parsed = parsed_origin("http://#{host}")
|
|
46
|
+
return false unless parsed
|
|
47
|
+
|
|
48
|
+
hostname = parsed.hostname.downcase
|
|
49
|
+
return false if Util::HostGuard.suspicious_numeric_host?(hostname)
|
|
50
|
+
return true if LOOPBACK_HOSTS.include?(hostname)
|
|
51
|
+
|
|
52
|
+
@allowed_hosts.include?(host.downcase) || @allowed_hosts.include?(hostname)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param origin [String, nil] Unmodified HTTP Origin header
|
|
56
|
+
# @param host [String, nil] Unmodified HTTP Host header
|
|
57
|
+
# @return [Boolean] Whether preflight and SDK dispatch can both accept it
|
|
58
|
+
def origin_allowed?(origin, host:)
|
|
59
|
+
return true if origin.nil?
|
|
60
|
+
return false unless parsed_origin(origin)
|
|
61
|
+
|
|
62
|
+
normalized = normalized_origin(origin)
|
|
63
|
+
return true if @explicit_origins.include?(normalized)
|
|
64
|
+
return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
|
|
65
|
+
return false unless host.is_a?(String) && host.valid_encoding?
|
|
66
|
+
|
|
67
|
+
# Matches the SDK's same-authority rule. The origin's scheme selects
|
|
68
|
+
# its default port; request.scheme is unreliable behind reverse proxies.
|
|
69
|
+
default_port = normalized.start_with?('https://') ? ':443' : ':80'
|
|
70
|
+
authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def configured_origin(entry)
|
|
76
|
+
raw = entry.to_s
|
|
77
|
+
normalized = raw.downcase.sub(%r{/\z}, '') if raw.valid_encoding?
|
|
78
|
+
unless parsed_origin(normalized)
|
|
79
|
+
label = raw.b.byteslice(0, 160).inspect
|
|
80
|
+
raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
normalized_origin(normalized).freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalized_origin(origin)
|
|
87
|
+
value = origin.downcase
|
|
88
|
+
value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The SDK compares configured origins literally. Include equivalent
|
|
92
|
+
# default-port spellings without rewriting the incoming request headers.
|
|
93
|
+
def transport_origins
|
|
94
|
+
@explicit_origins.flat_map do |origin|
|
|
95
|
+
parsed = parsed_origin(origin)
|
|
96
|
+
default = parsed.scheme == 'https' ? 443 : 80
|
|
97
|
+
parsed.port == default ? [origin, "#{origin}:#{default}"] : [origin]
|
|
98
|
+
end.uniq.map(&:freeze).freeze
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def authority(origin)
|
|
102
|
+
origin.sub(%r{\Ahttps?://}, '')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Parse only serialized HTTP origins, never URLs with paths, userinfo,
|
|
106
|
+
# query strings, fragments or whitespace. The comparison layer handles
|
|
107
|
+
# default-port equivalence separately from parsing.
|
|
108
|
+
def parsed_origin(origin)
|
|
109
|
+
return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
|
|
110
|
+
return if origin.match?(/[[:space:][:cntrl:]]/)
|
|
111
|
+
|
|
112
|
+
parsed = URI.parse(origin)
|
|
113
|
+
return unless %w[http https].include?(parsed.scheme&.downcase)
|
|
114
|
+
return unless parsed.host && !parsed.host.empty?
|
|
115
|
+
return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
|
|
116
|
+
return unless parsed.port&.between?(1, 65_535)
|
|
117
|
+
|
|
118
|
+
parsed
|
|
119
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../../source_contributors'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module MCP
|
|
5
7
|
module Renderers
|
|
@@ -16,7 +18,11 @@ module Woods
|
|
|
16
18
|
lines = []
|
|
17
19
|
lines << "## #{data['identifier']} (#{data['type']})"
|
|
18
20
|
lines << ''
|
|
19
|
-
|
|
21
|
+
if SourceContributors.multiple?(data)
|
|
22
|
+
lines << SourceContributors.label(data)
|
|
23
|
+
elsif data['file_path']
|
|
24
|
+
lines << "**File:** `#{data['file_path']}`"
|
|
25
|
+
end
|
|
20
26
|
lines << "**Namespace:** #{data['namespace']}" if data['namespace']
|
|
21
27
|
lines << ''
|
|
22
28
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../../source_contributors'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module MCP
|
|
5
7
|
module Renderers
|
|
@@ -14,7 +16,7 @@ module Woods
|
|
|
14
16
|
lines = []
|
|
15
17
|
lines << "#{data['identifier']} (#{data['type']})"
|
|
16
18
|
lines << DIVIDER
|
|
17
|
-
lines <<
|
|
19
|
+
lines << SourceContributors.label(data) if data['file_path']
|
|
18
20
|
lines << "Namespace: #{data['namespace']}" if data['namespace']
|
|
19
21
|
lines << ''
|
|
20
22
|
|
|
@@ -65,7 +65,13 @@ module Woods
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
result[:partial] = true unless complete
|
|
68
|
-
|
|
68
|
+
unless complete
|
|
69
|
+
result[:hint] = if @reason == 'unreadable_or_corrupt_source'
|
|
70
|
+
'Run woods:validate and repair or republish unreadable unit artifacts.'
|
|
71
|
+
else
|
|
72
|
+
NARROWING_HINT
|
|
73
|
+
end
|
|
74
|
+
end
|
|
69
75
|
result[:note] = note unless note.nil? || note.empty?
|
|
70
76
|
result
|
|
71
77
|
end
|