返回 DeepSeek-Reasonix
launch.go
根目录 / internal / mcplaunch / launch.go
1 // Package mcplaunch stores exact project MCP launch authorizations and mutable
2 // launcher locks. It does not classify tools or contribute to provider-visible
3 // schemas; ordinary tool policy lives in the plugin and permission layers.
4 package mcplaunch
5
6 import (
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14 "path/filepath"
15 "runtime"
16 "sort"
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21
22 "reasonix/internal/fileutil"
23 )
24
25 const (
26 StoreVersion = 1
27 StateFilename = "mcp-security.json"
28 workspaceScope = "workspace"
29 )
30
31 // ProjectLaunchIdentity is the secret-free canonical input to an exact project
32 // server launch identity digest.
33 // Environment and header values are intentionally excluded so credential
34 // rotation does not invalidate an otherwise identical authorization.
35 type ProjectLaunchIdentity struct {
36 Server string `json:"server"`
37 Transport string `json:"transport"`
38 CommandPath string `json:"command_path,omitempty"`
39 CommandSHA256 string `json:"command_sha256,omitempty"`
40 Args []string `json:"args,omitempty"`
41 Dir string `json:"dir,omitempty"`
42 URL string `json:"url,omitempty"`
43 EnvKeys []string `json:"env_keys,omitempty"`
44 HeaderKeys []string `json:"header_keys,omitempty"`
45 LauncherDigest string `json:"launcher_digest,omitempty"`
46 }
47
48 type LauncherLock struct {
49 Server string `json:"server"`
50 Workspace string `json:"workspace_fingerprint,omitempty"`
51 Locator string `json:"locator"`
52 ResolvedVersion string `json:"resolved_version"`
53 ContentSHA256 string `json:"content_sha256"`
54 UpdatedAt time.Time `json:"updated_at"`
55 }
56
57 // LaunchGrant is durable consent to start one exact project-provided MCP.
58 // Scope remains in the JSON solely so previous Reasonix versions can read new
59 // writes; new code only creates workspace-scoped grants.
60 type LaunchGrant struct {
61 Scope string `json:"scope"`
62 WorkspaceFingerprint string `json:"workspace_fingerprint,omitempty"`
63 Server string `json:"server"`
64 ConfigSource string `json:"config_source"`
65 IdentityDigest string `json:"identity_fingerprint"`
66 CreatedAt time.Time `json:"created_at"`
67 }
68
69 // State stores server-level launch grants and exact mutable-launcher locks.
70 // Legacy per-tool reader receipts are deliberately not retained or consulted.
71 type State struct {
72 Version int `json:"version"`
73 LaunchGrants []LaunchGrant `json:"launch_grants,omitempty"`
74 LauncherLocks []LauncherLock `json:"launcher_locks,omitempty"`
75 LegacyImports json.RawMessage `json:"legacy_imports,omitempty"`
76 }
77
78 type Manager struct {
79 path string
80 workspaceFingerprint string
81 mu sync.Mutex
82 }
83
84 var managerRegistry struct {
85 sync.Mutex
86 items map[string]*Manager
87 }
88
89 func StatePath(reasonixHome string) string {
90 if strings.TrimSpace(reasonixHome) == "" {
91 return ""
92 }
93 return filepath.Join(reasonixHome, StateFilename)
94 }
95
96 func NewManager(path, workspace string) *Manager {
97 return &Manager{path: path, workspaceFingerprint: WorkspaceFingerprint(workspace)}
98 }
99
100 // ForWorkspace returns the process-shared manager for one Reasonix home and
101 // workspace so sibling tabs observe one launch authorization state.
102 func ForWorkspace(reasonixHome, workspace string) *Manager {
103 path := StatePath(reasonixHome)
104 workspaceFP := WorkspaceFingerprint(workspace)
105 key := path + "\x00" + workspaceFP
106 managerRegistry.Lock()
107 defer managerRegistry.Unlock()
108 if managerRegistry.items == nil {
109 managerRegistry.items = map[string]*Manager{}
110 }
111 if m := managerRegistry.items[key]; m != nil {
112 return m
113 }
114 m := &Manager{path: path, workspaceFingerprint: workspaceFP}
115 managerRegistry.items[key] = m
116 return m
117 }
118
119 func WorkspaceFingerprint(workspace string) string {
120 workspace = canonicalPath(workspace)
121 if workspace == "" {
122 return ""
123 }
124 return digestBytes([]byte(workspace))
125 }
126
127 func ProjectLaunchIdentityDigest(identity ProjectLaunchIdentity) (string, error) {
128 identity = normalizeIdentity(identity, runtime.GOOS == "windows")
129 // PackageDigest was never populated, but its empty field was part of the
130 // original canonical JSON. Keep that placeholder so existing project launch
131 // grants remain byte-for-byte valid after the internal cleanup.
132 payload := struct {
133 Server, Transport, CommandPath, CommandSHA256, Dir, URL string
134 Args, EnvKeys, HeaderKeys []string
135 PackageDigest, LauncherDigest string
136 }{
137 identity.Server, identity.Transport, identity.CommandPath, identity.CommandSHA256,
138 identity.Dir, identity.URL, identity.Args, identity.EnvKeys, identity.HeaderKeys,
139 "", identity.LauncherDigest,
140 }
141 body, err := json.Marshal(payload)
142 if err != nil {
143 return "", err
144 }
145 return digestBytes(body), nil
146 }
147
148 func normalizeIdentity(identity ProjectLaunchIdentity, envCaseInsensitive bool) ProjectLaunchIdentity {
149 identity.Server = strings.TrimSpace(identity.Server)
150 identity.Transport = normalizeTransport(identity.Transport)
151 identity.CommandPath = canonicalPath(identity.CommandPath)
152 identity.Dir = canonicalPath(identity.Dir)
153 identity.URL = strings.TrimSpace(identity.URL)
154 identity.Args = append([]string(nil), identity.Args...)
155 identity.EnvKeys = cleanStrings(identity.EnvKeys, envCaseInsensitive)
156 identity.HeaderKeys = cleanStrings(identity.HeaderKeys, true)
157 return identity
158 }
159
160 func FileSHA256(path string) (string, error) {
161 body, err := os.ReadFile(path)
162 if err != nil {
163 return "", err
164 }
165 return digestBytes(body), nil
166 }
167
168 func (m *Manager) Path() string { return m.path }
169
170 func (m *Manager) WorkspaceFingerprint() string { return m.workspaceFingerprint }
171
172 func (m *Manager) Load() (State, error) {
173 if strings.TrimSpace(m.path) == "" {
174 return State{Version: StoreVersion}, nil
175 }
176 body, err := os.ReadFile(m.path)
177 if err != nil {
178 if errors.Is(err, os.ErrNotExist) {
179 return State{Version: StoreVersion}, nil
180 }
181 return State{}, err
182 }
183 var state State
184 if err := json.Unmarshal(body, &state); err != nil {
185 return State{}, fmt.Errorf("parse MCP launch authorization state: %w", err)
186 }
187 if state.Version == 0 {
188 state.Version = StoreVersion
189 }
190 if state.Version != StoreVersion {
191 return State{}, fmt.Errorf("unsupported MCP launch authorization state version %d", state.Version)
192 }
193 normalizeState(&state)
194 return state, nil
195 }
196
197 // Authorize records durable workspace consent for one exact server identity.
198 func (m *Manager) Authorize(server, configSource, identityDigest string) error {
199 grant := LaunchGrant{
200 Scope: workspaceScope, WorkspaceFingerprint: m.workspaceFingerprint,
201 Server: strings.TrimSpace(server), ConfigSource: strings.TrimSpace(configSource),
202 IdentityDigest: strings.TrimSpace(identityDigest), CreatedAt: time.Now().UTC(),
203 }
204 if grant.Server == "" || grant.ConfigSource == "" || grant.IdentityDigest == "" {
205 return fmt.Errorf("MCP launch authorization requires server, config source, and identity")
206 }
207 m.mu.Lock()
208 defer m.mu.Unlock()
209 return m.updatePersistent(func(state *State) {
210 state.LaunchGrants = upsertLaunchGrant(state.LaunchGrants, grant)
211 })
212 }
213
214 // LaunchAuthorized checks exact server-level consent without starting the server.
215 func (m *Manager) LaunchAuthorized(server, configSource, identityDigest string) (authorized, changed bool, err error) {
216 server = strings.TrimSpace(server)
217 configSource = strings.TrimSpace(configSource)
218 identityDigest = strings.TrimSpace(identityDigest)
219 m.mu.Lock()
220 defer m.mu.Unlock()
221 state, err := m.Load()
222 if err != nil {
223 return false, false, err
224 }
225 for _, grant := range state.LaunchGrants {
226 if grant.Server != server || grant.ConfigSource != configSource || grant.WorkspaceFingerprint != m.workspaceFingerprint {
227 continue
228 }
229 if grant.IdentityDigest == identityDigest {
230 return true, false, nil
231 }
232 changed = true
233 }
234 return false, changed, nil
235 }
236
237 func (m *Manager) Revoke(server string) error {
238 server = strings.TrimSpace(server)
239 m.mu.Lock()
240 defer m.mu.Unlock()
241 return m.updatePersistent(func(state *State) {
242 out := state.LaunchGrants[:0]
243 for _, grant := range state.LaunchGrants {
244 if grant.Server == server && grant.WorkspaceFingerprint == m.workspaceFingerprint {
245 continue
246 }
247 out = append(out, grant)
248 }
249 state.LaunchGrants = out
250 })
251 }
252
253 func (m *Manager) GetLauncherLock(server, locator string) (LauncherLock, bool, error) {
254 m.mu.Lock()
255 defer m.mu.Unlock()
256 state, err := m.Load()
257 if err != nil {
258 return LauncherLock{}, false, err
259 }
260 for _, lock := range state.LauncherLocks {
261 if lock.Server == strings.TrimSpace(server) && lock.Locator == strings.TrimSpace(locator) && lock.Workspace == m.workspaceFingerprint {
262 return lock, true, nil
263 }
264 }
265 return LauncherLock{}, false, nil
266 }
267
268 func (m *Manager) PutLauncherLock(lock LauncherLock) error {
269 lock.Server = strings.TrimSpace(lock.Server)
270 lock.Locator = strings.TrimSpace(lock.Locator)
271 lock.ResolvedVersion = strings.TrimSpace(lock.ResolvedVersion)
272 lock.ContentSHA256 = strings.TrimSpace(lock.ContentSHA256)
273 if lock.Server == "" || lock.Locator == "" || lock.ResolvedVersion == "" || lock.ContentSHA256 == "" {
274 return fmt.Errorf("incomplete MCP launcher lock")
275 }
276 lock.Workspace = m.workspaceFingerprint
277 lock.UpdatedAt = time.Now().UTC()
278 m.mu.Lock()
279 defer m.mu.Unlock()
280 return m.updatePersistent(func(state *State) {
281 for i := range state.LauncherLocks {
282 if state.LauncherLocks[i].Server == lock.Server && state.LauncherLocks[i].Locator == lock.Locator && state.LauncherLocks[i].Workspace == lock.Workspace {
283 state.LauncherLocks[i] = lock
284 return
285 }
286 }
287 state.LauncherLocks = append(state.LauncherLocks, lock)
288 })
289 }
290
291 func LauncherLockFingerprint(lock LauncherLock) string {
292 payload := struct {
293 Server, Workspace, Locator, ResolvedVersion, ContentSHA256 string
294 }{lock.Server, lock.Workspace, lock.Locator, lock.ResolvedVersion, lock.ContentSHA256}
295 body, _ := json.Marshal(payload)
296 return digestBytes(body)
297 }
298
299 func (m *Manager) updatePersistent(update func(*State)) error {
300 if strings.TrimSpace(m.path) == "" {
301 return fmt.Errorf("MCP launch authorization state path is unavailable")
302 }
303 unlock, err := acquireFileLock(m.path+".lock", 2*time.Second)
304 if err != nil {
305 return err
306 }
307 defer unlock()
308 state, err := m.Load()
309 if err != nil {
310 return err
311 }
312 update(&state)
313 state.Version = StoreVersion
314 normalizeState(&state)
315 body, err := json.MarshalIndent(state, "", " ")
316 if err != nil {
317 return err
318 }
319 body = append(body, '\n')
320 return fileutil.AtomicWriteFile(m.path, body, 0o600)
321 }
322
323 func normalizeState(state *State) {
324 if state.Version == 0 {
325 state.Version = StoreVersion
326 }
327 state.LaunchGrants = dedupeLaunchGrants(state.LaunchGrants)
328 sort.Slice(state.LauncherLocks, func(i, j int) bool {
329 a, b := state.LauncherLocks[i], state.LauncherLocks[j]
330 if a.Server != b.Server {
331 return a.Server < b.Server
332 }
333 return a.Locator < b.Locator
334 })
335 sort.Slice(state.LaunchGrants, func(i, j int) bool {
336 a, b := state.LaunchGrants[i], state.LaunchGrants[j]
337 if a.Server != b.Server {
338 return a.Server < b.Server
339 }
340 return a.ConfigSource < b.ConfigSource
341 })
342 }
343
344 func upsertLaunchGrant(grants []LaunchGrant, grant LaunchGrant) []LaunchGrant {
345 for i := range grants {
346 if grants[i].WorkspaceFingerprint == grant.WorkspaceFingerprint && grants[i].Server == grant.Server && grants[i].ConfigSource == grant.ConfigSource {
347 grant.CreatedAt = grants[i].CreatedAt
348 grants[i] = grant
349 return grants
350 }
351 }
352 return append(grants, grant)
353 }
354
355 func dedupeLaunchGrants(grants []LaunchGrant) []LaunchGrant {
356 out := make([]LaunchGrant, 0, len(grants))
357 for _, grant := range grants {
358 out = upsertLaunchGrant(out, grant)
359 }
360 return out
361 }
362
363 func normalizeTransport(value string) string {
364 switch strings.ToLower(strings.TrimSpace(value)) {
365 case "", "stdio":
366 return "stdio"
367 case "http", "streamable-http", "streamable_http":
368 return "http"
369 default:
370 return strings.ToLower(strings.TrimSpace(value))
371 }
372 }
373
374 func canonicalPath(path string) string {
375 path = strings.TrimSpace(path)
376 if path == "" {
377 return ""
378 }
379 if abs, err := filepath.Abs(path); err == nil {
380 path = abs
381 }
382 if real, err := filepath.EvalSymlinks(path); err == nil {
383 path = real
384 }
385 return filepath.Clean(path)
386 }
387
388 func cleanStrings(values []string, fold bool) []string {
389 out := make([]string, 0, len(values))
390 for _, value := range values {
391 value = strings.TrimSpace(value)
392 if value == "" {
393 continue
394 }
395 if fold {
396 value = strings.ToLower(value)
397 }
398 out = append(out, value)
399 }
400 sort.Strings(out)
401 return compactStrings(out)
402 }
403
404 func compactStrings(values []string) []string {
405 if len(values) < 2 {
406 return values
407 }
408 out := values[:1]
409 for _, value := range values[1:] {
410 if value != out[len(out)-1] {
411 out = append(out, value)
412 }
413 }
414 return out
415 }
416
417 func digestBytes(body []byte) string {
418 sum := sha256.Sum256(body)
419 return hex.EncodeToString(sum[:])
420 }
421
422 func acquireFileLock(path string, wait time.Duration) (func(), error) {
423 token := make([]byte, 16)
424 if _, err := rand.Read(token); err != nil {
425 return nil, fmt.Errorf("generate MCP authorization lock owner: %w", err)
426 }
427 owner := []byte(fmt.Sprintf("%d %s\n", os.Getpid(), hex.EncodeToString(token)))
428 deadline := time.Now().Add(wait)
429 for {
430 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
431 return nil, err
432 }
433 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
434 if err == nil {
435 _, writeErr := f.Write(owner)
436 closeErr := f.Close()
437 if writeErr != nil || closeErr != nil {
438 _ = os.Remove(path)
439 if writeErr != nil {
440 return nil, writeErr
441 }
442 return nil, closeErr
443 }
444 return func() { removeOwnedFileLock(path, owner) }, nil
445 }
446 if !errors.Is(err, os.ErrExist) && !launchLockContention(err) {
447 return nil, err
448 }
449 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 30*time.Second {
450 current, readErr := os.ReadFile(path)
451 if readErr == nil && !fileLockOwnerAlive(current) {
452 removeOwnedFileLock(path, current)
453 continue
454 }
455 }
456 if time.Now().After(deadline) {
457 return nil, fmt.Errorf("timed out waiting for MCP authorization state lock")
458 }
459 time.Sleep(10 * time.Millisecond)
460 }
461 }
462
463 func fileLockOwnerAlive(owner []byte) bool {
464 fields := strings.Fields(string(owner))
465 if len(fields) == 0 {
466 return false
467 }
468 pid, err := strconv.Atoi(fields[0])
469 return err == nil && launchLockProcessAlive(pid)
470 }
471
472 func removeOwnedFileLock(path string, owner []byte) {
473 current, err := os.ReadFile(path)
474 if err != nil || string(current) != string(owner) {
475 return
476 }
477 _ = os.Remove(path)
478 }
479
479 lines GO