Class: KairosMcp::Daemon::AttachServer

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

Overview

P2.7 AttachServer: HTTP/SSE control-plane for an already-running daemon.

Design rationale (design v0.2 §6):

- Local-only control plane. Binds to 127.0.0.1 ONLY (never 0.0.0.0).
- Bearer-token auth. Token is generated at startup, stored 0600 at
  .kairos/run/attach_token, rotated on SIGUSR2 with a 60s grace window
  during which both the old and new tokens are accepted.
- CommandMailbox pattern: HTTP handler threads ONLY enqueue commands
  into daemon.mailbox. The single daemon event-loop thread is the
  sole consumer. Handlers never mutate daemon state directly.
- Read-only endpoints (/v1/status, /v1/mandates, /v1/events) are
  allowed to snapshot data, because reads are safe across threads.

Endpoints:

GET  /v1/status             — daemon status snapshot (read-only)
GET  /v1/mandates           — list active + queued mandates (read-only)
POST /v1/mandates           — enqueue :create_mandate command
POST /v1/mandates/:id/stop  — enqueue :stop_mandate command
GET  /v1/events             — SSE stream (stub in P2.7; full in P2.8)
POST /v1/admin/reload       — enqueue :reload
POST /v1/admin/shutdown     — enqueue :shutdown

Constant Summary collapse

DEFAULT_PORT =
9847
DEFAULT_HOST =
'127.0.0.1'
DEFAULT_ROTATE_INTERVAL =

24h

24 * 60 * 60
DEFAULT_GRACE_PERIOD =

seconds

60
DEFAULT_TOKEN_REL_PATH =
'.kairos/run/attach_token'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(daemon:, root: Dir.pwd, token_path: nil, rotate_interval: DEFAULT_ROTATE_INTERVAL, grace_period: DEFAULT_GRACE_PERIOD, logger: nil, clock: nil) ⇒ AttachServer

Returns a new instance of AttachServer.

Parameters:

  • daemon (#mailbox, #status_snapshot)

    the daemon instance to forward commands to. Only ‘mailbox` (for writes) and `status_snapshot` (for reads) are required.

  • root (String) (defaults to: Dir.pwd)

    workspace root for resolving the token path

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

    override token path (tests)

  • rotate_interval (Integer) (defaults to: DEFAULT_ROTATE_INTERVAL)

    seconds between automatic rotations

  • grace_period (Integer) (defaults to: DEFAULT_GRACE_PERIOD)

    seconds the previous token remains valid

  • logger (#info, #warn, #error) (defaults to: nil)

    logger (optional)

  • clock (#call) (defaults to: nil)

    callable returning current Time (tests)



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 50

def initialize(daemon:,
               root: Dir.pwd,
               token_path: nil,
               rotate_interval: DEFAULT_ROTATE_INTERVAL,
               grace_period: DEFAULT_GRACE_PERIOD,
               logger: nil,
               clock: nil)
  @daemon = daemon
  @root = root
  @token_path = token_path || File.join(@root, DEFAULT_TOKEN_REL_PATH)
  @rotate_interval = Integer(rotate_interval)
  @grace_period = Integer(grace_period)
  @logger = logger
  @clock = clock || -> { Time.now.utc }

  @token_mutex = Mutex.new
  @current_token = nil
  @previous_token = nil
  @previous_expires_at = nil
  @token_rotated_at = nil

  @server = nil
  @thread = nil
  @host = nil
  @port = nil
end

Instance Attribute Details

#daemonObject (readonly)

Returns the value of attribute daemon.



39
40
41
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 39

def daemon
  @daemon
end

#hostObject (readonly)

Returns the value of attribute host.



39
40
41
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 39

def host
  @host
end

#portObject (readonly)

Returns the value of attribute port.



39
40
41
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 39

def port
  @port
end

#token_pathObject (readonly)

Returns the value of attribute token_path.



39
40
41
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 39

def token_path
  @token_path
end

Instance Method Details

#current_tokenObject

Currently-active token (what write-to-disk represents).



180
181
182
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 180

def current_token
  @token_mutex.synchronize { @current_token }
end

#generate_token!String

Generate a fresh token, rotating the current one into the grace window. Writes the new token to disk with 0600 permissions atomically.

Returns:

  • (String)

    the new token



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 160

def generate_token!
  new_token = SecureRandom.hex(32)
  now = @clock.call

  @token_mutex.synchronize do
    if @current_token
      @previous_token = @current_token
      @previous_expires_at = now + @grace_period
    end
    @current_token = new_token
    @token_rotated_at = now
  end

  write_token_file(new_token)
  log(:info, 'attach_server_token_rotated',
      grace_until: @previous_expires_at&.iso8601)
  new_token
end

#start(port: DEFAULT_PORT, host: DEFAULT_HOST) ⇒ Object

Start the server on host/port. Returns once WEBrick is accepting connections. Runs the accept loop in a background thread.

Parameters:

  • port (Integer) (defaults to: DEFAULT_PORT)

    TCP port (0 = pick a random free port)

  • host (String) (defaults to: DEFAULT_HOST)

    bind address — MUST be loopback-only



84
85
86
87
88
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 84

def start(port: DEFAULT_PORT, host: DEFAULT_HOST)
  raise 'AttachServer already started' if @server

  unless loopback_address?(host)
    raise ArgumentError,
          "AttachServer refuses to bind to #{host.inspect}: must be loopback"
  end

  generate_token!

  # WEBrick logger that discards output unless a real logger was given.
  wlog = WEBrick::Log.new(File::NULL, WEBrick::Log::WARN)
  alog = []

  @server = WEBrick::HTTPServer.new(
    BindAddress: host,
    Port: port,
    Logger: wlog,
    AccessLog: alog,
    StartCallback: nil,
    DoNotReverseLookup: true
  )

  mount_routes!

  @host = @server.config[:BindAddress]
  @port = @server.config[:Port]

  @thread = Thread.new do
    begin
      @server.start
    rescue StandardError => e
      log(:error, 'attach_server_thread_crashed', error: "#{e.class}: #{e.message}")
    end
  end
  @thread.name = 'kairos-attach-server' if @thread.respond_to?(:name=)

  # Wait briefly until the listener is actually up. WEBrick sets the
  # status to :Running once ready; poll with a short timeout.
  wait_until_listening!

  log(:info, 'attach_server_started', host: @host, port: @port,
                                     token_path: @token_path)
  true
end

#stopObject

Shut down WEBrick and join the accept thread. Safe to call twice.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 131

def stop
  return unless @server

  begin
    @server.shutdown
  rescue StandardError
    # ignore — server may already be mid-shutdown
  end

  if @thread
    begin
      @thread.join(5)
    rescue StandardError
      # ignore
    end
  end

  @server = nil
  @thread = nil
  log(:info, 'attach_server_stopped')
  true
end

#valid_token?(token) ⇒ Boolean

True if ‘token` is the current token, or a previous token still in its grace window.

Returns:

  • (Boolean)


186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/kairos_mcp/daemon/attach_server.rb', line 186

def valid_token?(token)
  return false if token.nil? || token.empty?

  @token_mutex.synchronize do
    return true if @current_token && secure_compare(token, @current_token)

    if @previous_token && @previous_expires_at
      return false if @clock.call >= @previous_expires_at

      return true if secure_compare(token, @previous_token)
    end
    false
  end
end