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