Class: KairosMcp::PluginProjector::HostProfile

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/plugin_projector.rb

Overview

Host-specific projection profile (2026-07-02 Codex support; 2026-07-10 add-on registration redesign, design v0.3 FROZEN: docs/drafts/multi_host_projection_addon_design_v0.3_FROZEN.md).

The core knows exactly one unit of host-specific knowledge — the profile (INV-H1). All host-varying behavior is carried BY the profile as data or callables (INV-H4): hooks_writer and agent_converter travel with the profile; the engine dispatches only through this abstraction and never branches on a host identity.

Profiles enter through a registry (INV-H3/H13). The built-in Claude Code default registers through the same mechanism as every add-on and merely registers first (INV-H2). Additional hosts (codex, opencode, ...) are contributed by add-on SkillSets that declare host_profiles files in their skillset.json; load_addons! discovers them uniformly for every access path that constructs a projector.

Defined Under Namespace

Classes: ConflictError, InvalidProfile

Constant Summary collapse

SAFE_OUTPUT_SUBDIR =

A safe output subdir is a single relative path segment with no traversal (e.g. '.claude', '.codex'). A safe context file is a bare filename.

/\A\.?[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
SAFE_CONTEXT_FILE =
/\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key:, output_subdir:, context_file:, instruction_mode_delivery:, manifest_suffix:, skill_projection:, agents_subdir:, aliases: [], requires_host: nil, hooks_writer: nil, agent_converter: nil, reload_hint: nil, mcp_config_writer: nil) ⇒ HostProfile

Returns a new instance of HostProfile.

Parameters:

  • skill_projection (Symbol)

    :own (project skills into this host) or :reuse_claude (host reads .claude/skills/ directly — skip skill projection).

  • agents_subdir (String)

    subdir under output_root for agents ('agents' or 'agent').

  • aliases (Array<String>) (defaults to: [])

    alternate lookup keys (e.g. 'claude_code').

  • requires_host (String, nil) (defaults to: nil)

    key of a prerequisite host whose projected artifacts must exist on disk before this host can project (INV-H5).

  • hooks_writer (#call, nil) (defaults to: nil)

    callable(projector, merged_hooks, outputs) owning this host's hooks delivery. nil → hooks are not deliverable for this host and are skipped with a warning.

  • agent_converter (#call, nil) (defaults to: nil)

    callable(content) reshaping an agent definition into this host's expected frontmatter. nil → verbatim.

  • reload_hint (String, nil) (defaults to: nil)

    host-specific message appended after a successful projection (e.g. Claude's "/reload-plugins" note). Carried as profile data so the tool never branches on a host name (INV-H1).

  • mcp_config_writer (#call, nil) (defaults to: nil)

    callable(projector, outputs) that projects this host's MCP-server connection config (so the host can call kairos-chain tools). nil → the host manages MCP registration itself. Travels with the profile (INV-H4).

Raises:

  • (InvalidProfile)

    when output_subdir or context_file would escape the project root (INV-H11): the containment boundary is checked at registration time, not discovered at projection time.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/kairos_mcp/plugin_projector.rb', line 89

def initialize(key:, output_subdir:, context_file:, instruction_mode_delivery:, manifest_suffix:,
               skill_projection:, agents_subdir:, aliases: [], requires_host: nil,
               hooks_writer: nil, agent_converter: nil, reload_hint: nil, mcp_config_writer: nil)
  unless output_subdir.is_a?(String) && output_subdir.match?(SAFE_OUTPUT_SUBDIR)
    raise InvalidProfile,
          "host #{key.inspect}: output_subdir #{output_subdir.inspect} is not a single safe relative segment (INV-H11)"
  end
  unless context_file.is_a?(String) && context_file.match?(SAFE_CONTEXT_FILE)
    raise InvalidProfile,
          "host #{key.inspect}: context_file #{context_file.inspect} is not a safe bare filename (INV-H11)"
  end
  @key = key
  @output_subdir = output_subdir
  @context_file = context_file
  @instruction_mode_delivery = instruction_mode_delivery
  @manifest_suffix = manifest_suffix
  @skill_projection = skill_projection
  @agents_subdir = agents_subdir
  @aliases = aliases
  @requires_host = requires_host
  @hooks_writer = hooks_writer
  @agent_converter = agent_converter
  @reload_hint = reload_hint
  @mcp_config_writer = mcp_config_writer
end

Instance Attribute Details

#agent_converterObject (readonly)

Returns the value of attribute agent_converter.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def agent_converter
  @agent_converter
end

#agents_subdirObject (readonly)

Returns the value of attribute agents_subdir.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def agents_subdir
  @agents_subdir
end

#aliasesObject (readonly)

Returns the value of attribute aliases.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def aliases
  @aliases
end

#context_fileObject (readonly)

Returns the value of attribute context_file.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def context_file
  @context_file
end

#hooks_writerObject (readonly)

Returns the value of attribute hooks_writer.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def hooks_writer
  @hooks_writer
end

#instruction_mode_deliveryObject (readonly)

Returns the value of attribute instruction_mode_delivery.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def instruction_mode_delivery
  @instruction_mode_delivery
end

#keyObject (readonly)

Returns the value of attribute key.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def key
  @key
end

#manifest_suffixObject (readonly)

Returns the value of attribute manifest_suffix.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def manifest_suffix
  @manifest_suffix
end

#mcp_config_writerObject (readonly)

Returns the value of attribute mcp_config_writer.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def mcp_config_writer
  @mcp_config_writer
end

#output_subdirObject (readonly)

Returns the value of attribute output_subdir.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def output_subdir
  @output_subdir
end

#reload_hintObject (readonly)

Returns the value of attribute reload_hint.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def reload_hint
  @reload_hint
end

#requires_hostObject (readonly)

Returns the value of attribute requires_host.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def requires_host
  @requires_host
end

#skill_projectionObject (readonly)

Returns the value of attribute skill_projection.



51
52
53
# File 'lib/kairos_mcp/plugin_projector.rb', line 51

def skill_projection
  @skill_projection
end

Class Method Details

.availableObject

Registered host keys, built-in default first (registration order).



146
147
148
# File 'lib/kairos_mcp/plugin_projector.rb', line 146

def available
  registry.keys
end

.claude_codeObject

Claude Code: .claude/ layout, CLAUDE.md with @-import pointer, hooks in settings.json. This is the bundled default — it registers through the same mechanism as every add-on, and merely registers first (INV-H2).



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/kairos_mcp/plugin_projector.rb', line 266

def self.claude_code
  lookup('claude') || register(
    new(key: 'claude', output_subdir: '.claude', context_file: 'CLAUDE.md',
        instruction_mode_delivery: :import, manifest_suffix: nil,
        skill_projection: :own, agents_subdir: 'agents',
        aliases: %w[claude_code claude-code],
        reload_hint: ' Run /reload-plugins to activate.',
        hooks_writer: ->(projector, merged_hooks, outputs) { projector.write_hooks_to_settings!(merged_hooks, outputs) }),
    source: 'core:bundled_default'
  )
end

.contained_realpath?(root, path) ⇒ Boolean

True if path resolves (following symlinks) to a location at or under the real path of root. Rejects symlink escapes that a lexical prefix check would miss (INV-H11). Non-existent paths fall back to lexical containment so an entry naming a not-yet-created file is still bounded.

Returns:

  • (Boolean)


233
234
235
# File 'lib/kairos_mcp/plugin_projector.rb', line 233

def contained_realpath?(root, path)
  PathContainment.contained?(root, path)
end

.for(host) ⇒ Object

Resolve or fail with an actionable message listing registered hosts.

Raises:

  • (ArgumentError)


163
164
165
166
167
168
169
170
# File 'lib/kairos_mcp/plugin_projector.rb', line 163

def for(host)
  profile = lookup(host)
  return profile if profile
  raise ArgumentError,
        "unknown projection host: #{host.inspect} (registered: #{available.join(', ')}). " \
        'Additional hosts are provided by add-on SkillSets — install the SkillSet ' \
        'for this host into .kairos/skillsets/ and retry.'
end

.load_addons!(data_dir) ⇒ Object

Discover add-on-contributed profiles under <data_dir>/skillsets/. Any skillset.json may declare "host_profiles": ["lib/....rb"]; each file is loaded once and registers its profile(s) through register. Malformed or escaping declarations are rejected here — at registration time, not at projection time (INV-H11). Idempotent per data_dir.



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/kairos_mcp/plugin_projector.rb', line 177

def load_addons!(data_dir)
  return if data_dir.nil?
  dir_key = File.expand_path(data_dir.to_s)
  @loaded_addon_dirs ||= {}
  return if @loaded_addon_dirs[dir_key]
  @loaded_addon_dirs[dir_key] = true

  Dir.glob(File.join(dir_key, 'skillsets', '*', 'skillset.json')).sort.each do |json_path|
    meta = begin
      JSON.parse(File.read(json_path))
    rescue JSON::ParserError, Errno::ENOENT => e
      warn "[HostProfile] WARNING: skipping #{json_path} (unreadable skillset.json: #{e.message})"
      next
    end
    ss_root = File.dirname(json_path)
    # Source is derived from the on-disk SkillSet directory, not from the
    # profile's self-declared string, so provenance is trustworthy (INV-H13).
    load_source = "skillset:#{File.basename(ss_root)}"
    Array(meta['host_profiles']).each do |rel|
      joined = File.join(ss_root, rel.to_s)
      unless contained_realpath?(ss_root, joined)
        warn "[HostProfile] WARNING: rejecting host_profiles entry #{rel.inspect} in #{json_path} (escapes the SkillSet directory)"
        next
      end

      # The gate and the require must name the SAME file. Resolving once
      # here is what makes that true: File.expand_path collapses ".."
      # lexically, so for an entry like "link/../../x.rb" it cancels the
      # symlink and lands somewhere the kernel would never go — outside
      # the SkillSet, past a gate that had just approved the resolved
      # path. Gate one path and require another and the gate is decoration.
      path = begin
        File.realpath(joined)
      rescue SystemCallError => e
        warn "[HostProfile] WARNING: skipping host_profiles entry #{rel.inspect} in #{json_path} (#{e.class}: #{e.message})"
        next
      end

      begin
        @active_load_source = load_source
        require path
      rescue ConflictError => e
        warn "[HostProfile] WARNING: #{e.message}"
      rescue StandardError, LoadError, SyntaxError => e
        warn "[HostProfile] WARNING: failed to load host profile #{path}: #{e.class}: #{e.message}"
      ensure
        @active_load_source = nil
      end
    end
  end
end

.lookup(host) ⇒ Object

Resolve a key or alias to a registered profile; nil when absent.



151
152
153
154
155
156
157
158
159
160
# File 'lib/kairos_mcp/plugin_projector.rb', line 151

def lookup(host)
  return host if host.is_a?(HostProfile)
  k = host.to_s
  entry = registry[k]
  return entry[:profile] if entry
  registry.each_value do |e|
    return e[:profile] if e[:profile].aliases.include?(k)
  end
  nil
end

.register(profile, source:) ⇒ Object

Register a profile. A second registration of an already-owned key from a different source is rejected deterministically (INV-H13). The source of an add-on registration is NOT the profile's self-declared string but the on-disk SkillSet identity established by load_addons! (via @active_load_source), so a squatter reusing a canonical source string cannot silently capture a host key.



129
130
131
132
133
134
135
136
137
138
139
# File 'lib/kairos_mcp/plugin_projector.rb', line 129

def register(profile, source:)
  effective_source = @active_load_source || source
  registry_entry = registry[profile.key]
  if registry_entry && registry_entry[:source] != effective_source
    raise ConflictError,
          "host profile #{profile.key.inspect} already registered by #{registry_entry[:source].inspect}; " \
          "rejecting conflicting registration from #{effective_source.inspect}"
  end
  registry[profile.key] = { profile: profile, source: effective_source }
  profile
end

.registered?(key) ⇒ Boolean

Returns:

  • (Boolean)


141
142
143
# File 'lib/kairos_mcp/plugin_projector.rb', line 141

def registered?(key)
  registry.key?(key.to_s)
end

.reset_addon_discovery!Object

Test seam: forget add-on discovery memoization (profiles stay registered).



238
239
240
# File 'lib/kairos_mcp/plugin_projector.rb', line 238

def reset_addon_discovery!
  @loaded_addon_dirs = {}
end

.reset_registry!Object

Test seam: clear the registry and re-register only the bundled default. NOTE: the registry is process-global, not scoped per data_dir. This is correct for the single-data_dir deployments (MCP server, CLI): every access path sees one registry (INV-H3). Under a shared-process multi-consumer transport (HTTP/multiuser) an add-on discovered for one data_dir would remain visible to another; per-tenant registry scoping is deferred to the multiuser track and is a precondition for enabling host add-ons there.



250
251
252
253
254
# File 'lib/kairos_mcp/plugin_projector.rb', line 250

def reset_registry!
  @registry = {}
  @loaded_addon_dirs = {}
  HostProfile.claude_code
end

Instance Method Details

#manifest_filename(base) ⇒ Object

Legacy manifest names carry no suffix so existing .kairos/ manifests keep working.



116
117
118
# File 'lib/kairos_mcp/plugin_projector.rb', line 116

def manifest_filename(base)
  @manifest_suffix ? "#{base}.#{@manifest_suffix}.json" : "#{base}.json"
end