woods 2.0.0.beta4 → 2.0.1
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 +495 -471
- data/CONTRIBUTING.md +12 -2
- data/README.md +11 -26
- data/docs/AGENT_GUIDE.md +31 -12
- data/docs/AGENT_SETUP.md +17 -10
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +13 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +44 -27
- data/docs/CONSOLE_MCP_SETUP.md +95 -16
- data/docs/DOCKER_SETUP.md +15 -0
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +14 -2
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +8 -3
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_HTTP_TRANSPORT.md +54 -2
- data/docs/MCP_SERVERS.md +28 -11
- data/docs/MCP_TOOL_COOKBOOK.md +1 -1
- data/docs/MCP_WORKTREE_SETUP.md +13 -1
- data/docs/PUBLISHED_INDEX.md +1 -1
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +57 -8
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +133 -37
- data/docs/UPGRADING_TO_2.md +69 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-http +16 -9
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/cache/cache_middleware.rb +18 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +401 -43
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +39 -10
- 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/console/stdio_transport.rb +27 -0
- data/lib/woods/extractor.rb +25 -7
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +55 -7
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
- data/plugin/skills/woods-diagnose/SKILL.md +88 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
- data/plugin/skills/woods-setup/SKILL.md +66 -4
- metadata +37 -5
data/lib/woods/extractor.rb
CHANGED
|
@@ -2103,10 +2103,28 @@ module Woods
|
|
|
2103
2103
|
end
|
|
2104
2104
|
|
|
2105
2105
|
@git_available = complete_git_history?
|
|
2106
|
+
rescue Errno::ENOENT
|
|
2107
|
+
warn_missing_git_executable
|
|
2108
|
+
@git_available = false
|
|
2106
2109
|
rescue StandardError
|
|
2107
2110
|
@git_available = false
|
|
2108
2111
|
end
|
|
2109
2112
|
|
|
2113
|
+
# An explicit Git directory still requests history when .git is not mounted
|
|
2114
|
+
# at Rails.root. Ordinary source archives remain a supported, quiet path.
|
|
2115
|
+
#
|
|
2116
|
+
# @return [void]
|
|
2117
|
+
def warn_missing_git_executable
|
|
2118
|
+
configured = [GitCommand::OVERRIDE_KEY, 'GIT_DIR'].any? { |key| !ENV[key].to_s.empty? }
|
|
2119
|
+
return unless configured || File.exist?(File.join(Rails.root.to_s, '.git'))
|
|
2120
|
+
|
|
2121
|
+
Rails.logger.warn(
|
|
2122
|
+
'[Woods] Git history unavailable: git executable was not found in PATH. ' \
|
|
2123
|
+
'Extraction continues without per-unit Git metadata. Install Git in the extraction environment ' \
|
|
2124
|
+
'and run a full extraction to refresh Git metadata.'
|
|
2125
|
+
)
|
|
2126
|
+
end
|
|
2127
|
+
|
|
2110
2128
|
# A shallow HEAD resolves but represents an incomplete ancestry. Do not
|
|
2111
2129
|
# turn that boundary into apparent one-commit/new-file churn facts.
|
|
2112
2130
|
def complete_git_history?
|
|
@@ -2134,19 +2152,19 @@ module Woods
|
|
|
2134
2152
|
cause = error.to_s.lines.first.to_s.strip
|
|
2135
2153
|
Rails.logger.warn(
|
|
2136
2154
|
'[Woods] git cannot resolve HEAD for this working tree, so no unit will carry git ' \
|
|
2137
|
-
"metadata: #{cause}.
|
|
2138
|
-
'
|
|
2139
|
-
|
|
2155
|
+
"metadata: #{cause}. For a linked worktree, mount the complete shared .git directory " \
|
|
2156
|
+
'at its original path, or set WOODS_GIT_DIR to /mounted-common/worktrees/<id> within ' \
|
|
2157
|
+
'the relocated complete layout. Derive <id> from the worktree Git metadata, not its branch name; ' \
|
|
2158
|
+
"selecting /mounted-common itself uses the primary checkout's HEAD."
|
|
2140
2159
|
)
|
|
2141
2160
|
end
|
|
2142
2161
|
|
|
2143
2162
|
# The git command line every enrichment call runs.
|
|
2144
2163
|
#
|
|
2145
2164
|
# `-C <root>` keeps the result independent of the process working
|
|
2146
|
-
# directory. `WOODS_GIT_DIR` wins when set
|
|
2147
|
-
#
|
|
2148
|
-
#
|
|
2149
|
-
# (B-181).
|
|
2165
|
+
# directory. `WOODS_GIT_DIR` wins when set and selects that git directory's
|
|
2166
|
+
# HEAD. A relocated linked worktree needs its worktree-specific directory
|
|
2167
|
+
# inside the complete shared Git layout (B-181).
|
|
2150
2168
|
#
|
|
2151
2169
|
# @param args [Array<String>] git arguments
|
|
2152
2170
|
# @return [Array<String>] full argv
|
data/lib/woods/git_command.rb
CHANGED
|
@@ -7,18 +7,17 @@ module Woods
|
|
|
7
7
|
# than at the process working directory, so extraction launched from another
|
|
8
8
|
# checkout never reports that checkout's history.
|
|
9
9
|
#
|
|
10
|
-
# `WOODS_GIT_DIR` wins when set
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# `GIT_COMMON_DIR` does not override (B-181, B-186).
|
|
10
|
+
# `WOODS_GIT_DIR` wins when set and selects HEAD exactly as `--git-dir`
|
|
11
|
+
# does. For a container over a linked worktree, select its private directory
|
|
12
|
+
# within the complete mounted Git layout (shared objects, refs and worktrees).
|
|
13
|
+
# Selecting the shared root instead selects the primary checkout's HEAD.
|
|
14
|
+
# A same-path mount that resolves the worktree's .git pointer needs no override.
|
|
16
15
|
#
|
|
17
16
|
# Three call sites use this, and the documentation promises all three:
|
|
18
17
|
# per-unit enrichment (`Extractor`), manifest provenance (`GitProvenance`),
|
|
19
18
|
# and the `woods:incremental` diff range (`lib/tasks/woods.rake`).
|
|
20
19
|
module GitCommand
|
|
21
|
-
# Environment variable naming the
|
|
20
|
+
# Environment variable naming the explicitly selected git directory.
|
|
22
21
|
OVERRIDE_KEY = 'WOODS_GIT_DIR'
|
|
23
22
|
|
|
24
23
|
module_function
|
data/lib/woods/git_provenance.rb
CHANGED
|
@@ -85,12 +85,10 @@ module Woods
|
|
|
85
85
|
''
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# +WOODS_GIT_DIR+
|
|
89
|
-
# set.
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# private git directory reaches the shared one through a relative
|
|
93
|
-
# +commondir+ pointer that resolves outside the mount.
|
|
88
|
+
# +WOODS_GIT_DIR+ selects a git directory and its HEAD outright and wins when
|
|
89
|
+
# set. A relocated linked worktree needs its private directory inside the
|
|
90
|
+
# complete mounted Git layout, preserving access to shared objects and refs.
|
|
91
|
+
# Selecting the shared root instead uses the primary checkout's HEAD.
|
|
94
92
|
#
|
|
95
93
|
# @param args [Array<String>] git arguments
|
|
96
94
|
# @return [Array<String>] full argv
|
|
@@ -55,7 +55,7 @@ module Woods
|
|
|
55
55
|
expected = resolve_token
|
|
56
56
|
header = env['HTTP_AUTHORIZATION'].to_s
|
|
57
57
|
# Only the ASCII scheme changes case; do not normalize or trim the token.
|
|
58
|
-
presented = header.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header
|
|
58
|
+
presented = header.b.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header.byteslice(7..) : nil
|
|
59
59
|
|
|
60
60
|
if expected && presented && Rack::Utils.secure_compare(expected, presented)
|
|
61
61
|
@app.call(env)
|
|
@@ -176,7 +176,9 @@ module Woods
|
|
|
176
176
|
retriever = build_retriever_from_config(config, resolved, artifact, state)
|
|
177
177
|
probe_and_mark_state(config, state)
|
|
178
178
|
derive_state_from_store_health(state)
|
|
179
|
-
|
|
179
|
+
corpus = retriever.corpus_status(include_types: false) if retriever.respond_to?(:corpus_status)
|
|
180
|
+
corpus_note = corpus ? "; corpus: #{corpus[:state]}" : ''
|
|
181
|
+
warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})#{corpus_note}"
|
|
180
182
|
|
|
181
183
|
[retriever, state]
|
|
182
184
|
end
|
|
@@ -11,7 +11,7 @@ module Woods
|
|
|
11
11
|
|
|
12
12
|
WORKFLOW = <<~TEXT
|
|
13
13
|
Start with woods_status: check index readiness, generation freshness and relevant type counts before relying on results.
|
|
14
|
-
For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions,
|
|
14
|
+
For exact names, discover identifiers with search (prefer literal exact_prefix/exact_suffix), then inspect with lookup. For conceptual questions, check retrieval mode and data in woods_status before codebase_retrieve; structural readiness alone is insufficient. Otherwise use search and lookup.
|
|
15
15
|
Follow dependencies or dependents at depth 1 or 2; narrow types and via before paging. Respect partial results and limits: a missing match is not proof of absence, and a partial traversal does not establish every dependent or leaf.
|
|
16
16
|
Verify important conclusions against current source and tests. Recorded relationships and inferred downstream impact do not prove runtime execution or test coverage.
|
|
17
17
|
Registration does not authorize extraction, configuration changes, or live Console access. Use only the tools registered here and operate within the user's authorized scope.
|
|
@@ -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
|
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative '../atomic_file'
|
|
|
12
12
|
require_relative '../generation'
|
|
13
13
|
require_relative '../tasks'
|
|
14
14
|
require_relative '../watch/status'
|
|
15
|
+
require_relative '../watch/supervision_status'
|
|
15
16
|
require_relative '../filename_utils'
|
|
16
17
|
require_relative '../update_check'
|
|
17
18
|
require_relative '../retrieval/source_evidence'
|
|
@@ -928,6 +929,7 @@ module Woods
|
|
|
928
929
|
coerce = method(:coerce_array)
|
|
929
930
|
stale_check = method(:stale_index_result?)
|
|
930
931
|
degraded_response = method(:degraded_retrieval_response)
|
|
932
|
+
corpus_status = method(:retriever_corpus_status)
|
|
931
933
|
retrieval_mode = retriever.respond_to?(:mode) ? retriever.mode : :semantic
|
|
932
934
|
server.define_tool(
|
|
933
935
|
name: 'codebase_retrieve',
|
|
@@ -954,13 +956,13 @@ module Woods
|
|
|
954
956
|
'rails_source, test_mapping, etc.). Overrides the default test_mapping exclusion. ' \
|
|
955
957
|
'Lexical mode and explicit package/path scopes filter before limits and omit the global rank table. ' \
|
|
956
958
|
'In semantic mode, when the unfiltered top-K has no requested type, the retriever ' \
|
|
957
|
-
'falls back to rank-within-type
|
|
958
|
-
'the requested type exist in the index. The response appends a "Type rank ' \
|
|
959
|
+
'falls back to rank-within-type over the available vectors. The response appends a "Type rank ' \
|
|
959
960
|
'context" table with per-type: source, rank in unfiltered top-K, global_k, ' \
|
|
960
|
-
'total_of_type
|
|
961
|
+
'total_of_type (retrieval metadata records, not structural or embedded units). ' \
|
|
962
|
+
'Read source to tell the cases apart: in_top_k (strong match), ' \
|
|
961
963
|
'within_type_fallback (weak match surfaced by the fallback), outside_top_k ' \
|
|
962
|
-
'(
|
|
963
|
-
'(zero
|
|
964
|
+
'(retrieval metadata has this type but other requested types filled the result), absent ' \
|
|
965
|
+
'(zero retrieval metadata records of this type; structural units may still exist).'
|
|
964
966
|
},
|
|
965
967
|
packages: { type: 'array', items: { type: 'string' },
|
|
966
968
|
description: 'Exact published nearest package owners. OR within the list; AND with paths and type eligibility.' },
|
|
@@ -1015,6 +1017,16 @@ module Woods
|
|
|
1015
1017
|
)
|
|
1016
1018
|
end
|
|
1017
1019
|
if retriever
|
|
1020
|
+
corpus = corpus_status.call(retriever, include_types: false)
|
|
1021
|
+
if corpus && corpus[:state] == 'empty'
|
|
1022
|
+
next respond_err.call(
|
|
1023
|
+
'Semantic retrieval has no vectors or retrieval metadata. The structural index may still be ready. ' \
|
|
1024
|
+
'Run `woods:embed` in the application environment and reload the index, or use ' \
|
|
1025
|
+
'explicit `WOODS_RETRIEVAL_MODE=lexical` and restart for retrieval without embeddings. ' \
|
|
1026
|
+
'Use `search` and `lookup` for structural discovery.',
|
|
1027
|
+
code: :empty_index, tool: 'codebase_retrieve', corpus: corpus
|
|
1028
|
+
)
|
|
1029
|
+
end
|
|
1018
1030
|
begin
|
|
1019
1031
|
scope_options = if Retrieval::Scope.requested?(packages: packages, source_paths: source_paths)
|
|
1020
1032
|
{ packages: packages, source_paths: source_paths }
|
|
@@ -1138,12 +1150,15 @@ module Woods
|
|
|
1138
1150
|
|
|
1139
1151
|
server.define_tool(
|
|
1140
1152
|
name: 'trace_flow',
|
|
1141
|
-
description: 'Trace
|
|
1153
|
+
description: 'Trace a source-derived flow from an exact indexed unit, optionally scoped by #method. ' \
|
|
1154
|
+
'Receiverless local calls may remain unexpanded; this is not proof of runtime execution.',
|
|
1142
1155
|
input_schema: {
|
|
1143
1156
|
properties: {
|
|
1144
1157
|
entry_point: {
|
|
1145
1158
|
type: 'string',
|
|
1146
|
-
description: '
|
|
1159
|
+
description: 'Exact UnitIdentifier, optionally followed by #method (e.g., UsersController#create ' \
|
|
1160
|
+
'or CheckoutService#order). Bare names identify units, including factories; ' \
|
|
1161
|
+
'a bare method name does not locate its owning class. Use search and lookup first.'
|
|
1147
1162
|
},
|
|
1148
1163
|
depth: {
|
|
1149
1164
|
type: 'integer',
|
|
@@ -2075,7 +2090,10 @@ module Woods
|
|
|
2075
2090
|
name: 'woods_status',
|
|
2076
2091
|
description: 'Diagnose whether the Woods index and server are healthy. Returns extraction metadata ' \
|
|
2077
2092
|
'(last run, unit counts, git SHA, staleness in seconds), retriever/embedding configuration, ' \
|
|
2078
|
-
'bootstrap state (hydrated / degraded / failed + reason), feature flags
|
|
2093
|
+
'bootstrap state (hydrated / degraded / failed + reason), and feature flags. ' \
|
|
2094
|
+
'Top-level `ready` describes structural index availability. ' \
|
|
2095
|
+
'Semantic corpus diagnostics report local vector/metadata record counts separately; ' \
|
|
2096
|
+
'unknown counts are null, and nonempty counts do not prove complete embedding coverage. ' \
|
|
2079
2097
|
'Includes source-content freshness; quick scans have a 250ms budget, explicit deep scans have 5s. ' \
|
|
2080
2098
|
'Incomplete evidence is unknown. Call this first on cold connect.',
|
|
2081
2099
|
input_schema: { type: 'object', properties: {
|
|
@@ -2139,10 +2157,11 @@ module Woods
|
|
|
2139
2157
|
},
|
|
2140
2158
|
index: index_section(manifest, extracted_at, staleness, index_dir, reader, source_check),
|
|
2141
2159
|
watch: watch_section(index_dir),
|
|
2160
|
+
supervision: index_dir ? Woods::Watch::SupervisionStatus.read(index_dir.to_s) : { records: [] },
|
|
2142
2161
|
retriever: {
|
|
2143
2162
|
configured: !retriever.nil?,
|
|
2144
2163
|
class: retriever&.class&.name,
|
|
2145
|
-
**(retriever
|
|
2164
|
+
**retriever_status_fields(retriever)
|
|
2146
2165
|
},
|
|
2147
2166
|
bootstrap: bootstrap_state&.to_h,
|
|
2148
2167
|
features: retrieval_features(config, resolved, retriever)
|
|
@@ -2151,6 +2170,19 @@ module Woods
|
|
|
2151
2170
|
|
|
2152
2171
|
private
|
|
2153
2172
|
|
|
2173
|
+
def retriever_status_fields(retriever)
|
|
2174
|
+
return { mode: 'lexical' } if retriever.respond_to?(:mode) && retriever.mode == :lexical
|
|
2175
|
+
|
|
2176
|
+
corpus = retriever_corpus_status(retriever)
|
|
2177
|
+
corpus ? { corpus: corpus } : {}
|
|
2178
|
+
end
|
|
2179
|
+
|
|
2180
|
+
def retriever_corpus_status(retriever, include_types: true)
|
|
2181
|
+
return if retriever.respond_to?(:mode) && retriever.mode == :lexical
|
|
2182
|
+
|
|
2183
|
+
retriever.corpus_status(include_types: include_types) if retriever.respond_to?(:corpus_status)
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2154
2186
|
# Assemble the +index+ sub-hash of woods_status, including a staleness
|
|
2155
2187
|
# gate that compares +manifest.git_sha+ against the current HEAD. The
|
|
2156
2188
|
# manifest captures +git_sha+ / +gemfile_lock_sha+ / +schema_sha+ at
|
|
@@ -114,6 +114,7 @@ module Woods
|
|
|
114
114
|
warn_late_console_path(config)
|
|
115
115
|
return unless config.console_mcp_enabled && config.console_mcp_http_enabled
|
|
116
116
|
|
|
117
|
+
verify_console_origins!
|
|
117
118
|
require 'woods/mcp/bearer_auth'
|
|
118
119
|
token = config.console_mcp_token.to_s
|
|
119
120
|
if token.empty?
|
|
@@ -147,6 +148,13 @@ module Woods
|
|
|
147
148
|
|
|
148
149
|
private
|
|
149
150
|
|
|
151
|
+
def verify_console_origins!
|
|
152
|
+
require 'woods/mcp/origin_policy'
|
|
153
|
+
Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
|
|
154
|
+
rescue ArgumentError => e
|
|
155
|
+
raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
150
158
|
# Warn when the configured console path no longer matches the path the
|
|
151
159
|
# stack was mounted at — the path was set after railtie initializers
|
|
152
160
|
# captured the middleware arguments.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Retrieval
|
|
5
|
+
# Bounded, local diagnostics: never probe remote stores or providers.
|
|
6
|
+
# Nonempty counts establish presence, not alignment, completeness, or
|
|
7
|
+
# provider health. Metadata-only corpora can still serve non-vector paths.
|
|
8
|
+
module CorpusStatus
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @return [Hash] state and independently known local store statistics
|
|
12
|
+
def build(vector_store, metadata_store, include_types: true)
|
|
13
|
+
vectors = local_stats(vector_store, include_types: include_types)
|
|
14
|
+
metadata = local_stats(metadata_store, include_types: include_types)
|
|
15
|
+
{ state: state(vectors[:count], metadata[:count]), vectors: vectors, metadata: metadata }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def local_stats(store, include_types:)
|
|
19
|
+
return unknown unless store.respond_to?(:local_corpus_stats)
|
|
20
|
+
|
|
21
|
+
stats = store.local_corpus_stats(include_types: include_types)
|
|
22
|
+
return unknown unless stats[:count].is_a?(Integer) && stats[:count] >= 0
|
|
23
|
+
|
|
24
|
+
stats
|
|
25
|
+
rescue StandardError, NotImplementedError
|
|
26
|
+
unknown
|
|
27
|
+
end
|
|
28
|
+
private_class_method :local_stats
|
|
29
|
+
|
|
30
|
+
def unknown
|
|
31
|
+
{ count: nil, by_type: nil, untyped_count: nil }
|
|
32
|
+
end
|
|
33
|
+
private_class_method :unknown
|
|
34
|
+
|
|
35
|
+
def state(vectors, metadata)
|
|
36
|
+
return 'unknown' if vectors.nil? || metadata.nil?
|
|
37
|
+
return 'empty' if vectors.zero? && metadata.zero?
|
|
38
|
+
return 'metadata_only' if vectors.zero?
|
|
39
|
+
return 'vectors_only' if metadata.zero?
|
|
40
|
+
|
|
41
|
+
'nonempty'
|
|
42
|
+
end
|
|
43
|
+
private_class_method :state
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|