返回 DeepSeek-Reasonix
launcher_lock.go
根目录 / internal / plugin / launcher_lock.go
1 package plugin
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strings"
17
18 "reasonix/internal/mcplaunch"
19 "reasonix/internal/secrets"
20 )
21
22 type launcherLocator struct {
23 kind string
24 value string
25 arg int
26 prefix string
27 command string
28 }
29
30 var (
31 pep508Package = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._-]*)(\[[^]]+\])?(?:==([^\s]+))?$`)
32 // fullGitCommit accepts exactly a complete SHA-1 (40 hex) or SHA-256
33 // (64 hex) object name. Intermediate lengths are abbreviations or custom
34 // refs, never a verified commit: they must resolve through git ls-remote.
35 // Mutable launcher locks require a complete immutable commit predicate.
36 fullGitCommit = regexp.MustCompile(`^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$`)
37 pypiBaseURL = "https://pypi.org/pypi"
38 )
39
40 // exactPEP440Version accepts only a single pinned version. `==2.4.*` is a
41 // PEP 440 wildcard range, not a pin, while exact prerelease, post, dev,
42 // epoch, and local segments remain valid.
43 func exactPEP440Version(version string) bool {
44 version = strings.TrimSpace(version)
45 return version != "" && !strings.Contains(version, "*")
46 }
47
48 func effectiveLaunchArgs(spec Spec) []string {
49 if spec.LaunchArgs != nil {
50 return spec.LaunchArgs
51 }
52 return spec.Args
53 }
54
55 func mutableLauncherLocator(spec Spec) (launcherLocator, bool) {
56 return launcherLocatorForSpec(spec)
57 }
58
59 func launcherLocatorForSpec(spec Spec) (launcherLocator, bool) {
60 command := strings.TrimSuffix(strings.ToLower(filepath.Base(strings.TrimSpace(spec.Command))), ".exe")
61 var kind string
62 switch command {
63 case "npx", "bunx", "uvx":
64 kind = command
65 default:
66 return launcherLocator{}, false
67 }
68 args := spec.Args
69 if kind == "uvx" {
70 for i, arg := range args {
71 if arg == "--from" && i+1 < len(args) {
72 return launcherLocator{kind: kind, value: args[i+1], arg: i + 1, command: command}, true
73 }
74 if strings.HasPrefix(arg, "--from=") {
75 return launcherLocator{kind: kind, value: strings.TrimPrefix(arg, "--from="), arg: i, prefix: "--from=", command: command}, true
76 }
77 }
78 }
79 for i, arg := range args {
80 if arg == "--" && i+1 < len(args) {
81 return launcherLocator{kind: kind, value: args[i+1], arg: i + 1, command: command}, true
82 }
83 if strings.HasPrefix(arg, "-") {
84 if strings.Contains(arg, "=") || safeLauncherFlag(kind, arg) {
85 continue
86 }
87 // Unknown flags may consume the following token. Refuse persistent
88 // authorization rather than accidentally pinning a flag value as the package.
89 return launcherLocator{kind: kind, command: command}, true
90 }
91 return launcherLocator{kind: kind, value: arg, arg: i, command: command}, true
92 }
93 return launcherLocator{kind: kind, command: command}, true
94 }
95
96 func safeLauncherFlag(kind, flag string) bool {
97 switch kind {
98 case "npx":
99 return flag == "-y" || flag == "--yes" || flag == "--quiet" || flag == "--silent" || flag == "--offline" || flag == "--prefer-offline"
100 case "bunx":
101 return flag == "--bun" || flag == "--no-install" || flag == "--silent"
102 case "uvx":
103 return flag == "--offline" || flag == "--refresh" || flag == "--no-cache"
104 default:
105 return false
106 }
107 }
108
109 func preparePersistentLauncher(ctx context.Context, spec Spec) (Spec, *mcplaunch.LauncherLock, error) {
110 locator, mutable := mutableLauncherLocator(spec)
111 if !mutable {
112 return spec, nil, nil
113 }
114 if strings.TrimSpace(locator.value) == "" {
115 return spec, nil, fmt.Errorf("%s package locator was not found", locator.kind)
116 }
117 resolved, digest, err := resolveLauncherLocator(ctx, spec, locator)
118 if err != nil {
119 return spec, nil, err
120 }
121 lock := &mcplaunch.LauncherLock{
122 Server: spec.Name, Locator: digestText(locator.value), ResolvedVersion: resolved, ContentSHA256: digest,
123 }
124 lock.Workspace = spec.LaunchManager.WorkspaceFingerprint()
125 applyLauncherResolution(&spec, locator, *lock, false)
126 return spec, lock, nil
127 }
128
129 func applyStoredLauncherLock(spec Spec) (Spec, error) {
130 if strings.TrimSpace(spec.LauncherDigest) != "" || spec.LaunchManager == nil {
131 return spec, nil
132 }
133 locator, mutable := mutableLauncherLocator(spec)
134 if !mutable || strings.TrimSpace(locator.value) == "" {
135 return spec, nil
136 }
137 lock, ok, err := spec.LaunchManager.GetLauncherLock(spec.Name, digestText(locator.value))
138 if err != nil || !ok {
139 return spec, err
140 }
141 applyLauncherResolution(&spec, locator, lock, true)
142 return spec, nil
143 }
144
145 func applyLauncherResolution(spec *Spec, locator launcherLocator, lock mcplaunch.LauncherLock, offline bool) {
146 args := append([]string(nil), spec.Args...)
147 resolved := lock.ResolvedVersion
148 if strings.HasPrefix(locator.value, "git+") && fullGitCommit.MatchString(resolved) {
149 if at := strings.LastIndex(locator.value, "@"); at > len("git+https://") {
150 resolved = locator.value[:at] + "@" + resolved
151 }
152 }
153 args[locator.arg] = locator.prefix + resolved
154 // Authorization is granted against the exact resolved package and its verified
155 // digest. The stored-lock start additionally injects --offline/--no-install
156 // to force that cached artifact, but this Reasonix-owned enforcement flag is
157 // not a change in the server the user approved. Preserve the canonical
158 // identity args before adding it so preflight and subsequent starts compare
159 // equal while the actual process still runs offline.
160 spec.LauncherIdentityArgs = append([]string(nil), args...)
161 if offline && !hasLauncherOfflineFlag(locator.kind, args) {
162 flag := "--offline"
163 if locator.kind == "bunx" {
164 flag = "--no-install"
165 }
166 insertAt := locator.arg
167 // For `uvx --from package command`, locator.arg points at the value of
168 // --from. Inserting there would split the option from its value and produce
169 // `--from --offline package`. Keep the pair adjacent by placing the
170 // enforcement flag before --from. The --from=package form already points at
171 // the whole option and needs no adjustment.
172 if locator.kind == "uvx" && insertAt > 0 && args[insertAt-1] == "--from" {
173 insertAt--
174 }
175 args = append(args[:insertAt], append([]string{flag}, args[insertAt:]...)...)
176 }
177 spec.LaunchArgs = args
178 spec.LauncherLocator = lock.Locator
179 spec.LauncherResolvedVersion = lock.ResolvedVersion
180 spec.LauncherDigest = mcplaunch.LauncherLockFingerprint(lock)
181 }
182
183 func hasLauncherOfflineFlag(kind string, args []string) bool {
184 for _, arg := range args {
185 if arg == "--offline" || (kind == "bunx" && arg == "--no-install") {
186 return true
187 }
188 }
189 return false
190 }
191
192 func resolveLauncherLocator(ctx context.Context, spec Spec, locator launcherLocator) (string, string, error) {
193 if strings.HasPrefix(locator.value, "git+") {
194 return resolveGitLocator(ctx, spec, locator.value)
195 }
196 switch locator.kind {
197 case "npx", "bunx":
198 return resolveNPMPackage(ctx, spec, locator.value)
199 case "uvx":
200 return resolvePyPIPackage(ctx, locator.value)
201 default:
202 return "", "", fmt.Errorf("unsupported mutable launcher %q", locator.kind)
203 }
204 }
205
206 func resolveNPMPackage(ctx context.Context, spec Spec, locator string) (string, string, error) {
207 name := npmPackageName(locator)
208 if name == "" {
209 return "", "", fmt.Errorf("unsupported npm package locator %q", locator)
210 }
211 env := mergeEnv(secrets.ProcessEnv(), spec.Env)
212 env = enrichStdioShellPATH(ctx, env)
213 npm, ok := lookPathInEnv("npm", env)
214 if !ok {
215 return "", "", fmt.Errorf("npm is required to lock %q", locator)
216 }
217 cmd := exec.CommandContext(ctx, npm, "view", locator, "version", "dist.integrity", "--json")
218 cmd.Env = env
219 out, err := cmd.Output()
220 if err != nil {
221 return "", "", fmt.Errorf("resolve npm package %q: %w", locator, err)
222 }
223 var result map[string]any
224 if err := json.Unmarshal(out, &result); err != nil {
225 return "", "", fmt.Errorf("parse npm resolution for %q: %w", locator, err)
226 }
227 version, _ := result["version"].(string)
228 integrity, _ := result["dist.integrity"].(string)
229 if integrity == "" {
230 if dist, ok := result["dist"].(map[string]any); ok {
231 integrity, _ = dist["integrity"].(string)
232 }
233 }
234 if version == "" || integrity == "" {
235 return "", "", fmt.Errorf("npm did not return an exact version and integrity for %q", locator)
236 }
237 return name + "@" + version, digestText(integrity), nil
238 }
239
240 func npmPackageName(locator string) string {
241 locator = strings.TrimSpace(locator)
242 if locator == "" || strings.Contains(locator, ":") || strings.Contains(locator, "/") && !strings.HasPrefix(locator, "@") {
243 return ""
244 }
245 if strings.HasPrefix(locator, "@") {
246 slash := strings.Index(locator, "/")
247 if slash < 2 {
248 return ""
249 }
250 if at := strings.LastIndex(locator, "@"); at > slash {
251 return locator[:at]
252 }
253 return locator
254 }
255 if at := strings.LastIndex(locator, "@"); at > 0 {
256 return locator[:at]
257 }
258 return locator
259 }
260
261 func resolvePyPIPackage(ctx context.Context, locator string) (string, string, error) {
262 match := pep508Package.FindStringSubmatch(strings.TrimSpace(locator))
263 if match == nil {
264 return "", "", fmt.Errorf("unsupported uvx package locator %q", locator)
265 }
266 name, extras, requestedVersion := match[1], match[2], match[3]
267 if requestedVersion != "" && !exactPEP440Version(requestedVersion) {
268 return "", "", fmt.Errorf("uvx locator %q uses a wildcard version; pin one exact version", locator)
269 }
270 endpoint := strings.TrimRight(pypiBaseURL, "/") + "/" + url.PathEscape(name)
271 if requestedVersion != "" {
272 endpoint += "/" + url.PathEscape(requestedVersion)
273 }
274 endpoint += "/json"
275 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
276 if err != nil {
277 return "", "", err
278 }
279 resp, err := http.DefaultClient.Do(req)
280 if err != nil {
281 return "", "", fmt.Errorf("resolve PyPI package %q: %w", locator, err)
282 }
283 defer resp.Body.Close()
284 if resp.StatusCode != http.StatusOK {
285 return "", "", fmt.Errorf("resolve PyPI package %q: %s", locator, resp.Status)
286 }
287 body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
288 if err != nil {
289 return "", "", err
290 }
291 var result struct {
292 Info struct {
293 Version string `json:"version"`
294 } `json:"info"`
295 URLs []struct {
296 Digests struct {
297 SHA256 string `json:"sha256"`
298 } `json:"digests"`
299 } `json:"urls"`
300 }
301 if err := json.Unmarshal(body, &result); err != nil {
302 return "", "", fmt.Errorf("parse PyPI resolution for %q: %w", locator, err)
303 }
304 version := strings.TrimSpace(result.Info.Version)
305 if requestedVersion != "" && version != requestedVersion {
306 return "", "", fmt.Errorf("PyPI resolved %q to unexpected version %q", locator, version)
307 }
308 var digests []string
309 for _, file := range result.URLs {
310 if value := strings.TrimSpace(file.Digests.SHA256); value != "" {
311 digests = append(digests, value)
312 }
313 }
314 sort.Strings(digests)
315 if version == "" || len(digests) == 0 {
316 return "", "", fmt.Errorf("PyPI did not return an exact version and file digests for %q", locator)
317 }
318 return name + extras + "==" + version, digestText(strings.Join(digests, "\n")), nil
319 }
320
321 func resolveGitLocator(ctx context.Context, spec Spec, locator string) (string, string, error) {
322 at := strings.LastIndex(locator, "@")
323 if at < len("git+https://") || at == len(locator)-1 {
324 return "", "", fmt.Errorf("git launcher locator %q requires an explicit ref", locator)
325 }
326 repo, ref := locator[:at], locator[at+1:]
327 if fullGitCommit.MatchString(ref) {
328 commit := strings.ToLower(ref)
329 return commit, digestText(commit), nil
330 }
331 env := mergeEnv(secrets.ProcessEnv(), spec.Env)
332 env = enrichStdioShellPATH(ctx, env)
333 git, ok := lookPathInEnv("git", env)
334 if !ok {
335 return "", "", fmt.Errorf("git is required to resolve %q", locator)
336 }
337 remote := strings.TrimPrefix(repo, "git+")
338 cmd := exec.CommandContext(ctx, git, "ls-remote", remote, ref)
339 cmd.Env = env
340 out, err := cmd.Output()
341 if err != nil {
342 return "", "", fmt.Errorf("resolve git ref %q: %w", locator, err)
343 }
344 fields := strings.Fields(string(out))
345 if len(fields) < 1 || !fullGitCommit.MatchString(fields[0]) {
346 return "", "", fmt.Errorf("git ref %q did not resolve to one exact commit", locator)
347 }
348 commit := strings.ToLower(fields[0])
349 return commit, digestText(commit), nil
350 }
351
352 func digestText(value string) string {
353 sum := sha256.Sum256([]byte(value))
354 return hex.EncodeToString(sum[:])
355 }
356
356 lines GO