返回 DeepSeek-Reasonix
launcher_lock_test.go
根目录 / internal / plugin / launcher_lock_test.go
1 package plugin
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "reflect"
10 "runtime"
11 "strings"
12 "testing"
13
14 "reasonix/internal/mcplaunch"
15 )
16
17 func TestStoredNPXLauncherLockUsesExactOfflinePackage(t *testing.T) {
18 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
19 lock := mcplaunch.LauncherLock{
20 Server: "search", Locator: digestText("@scope/server"), ResolvedVersion: "@scope/server@1.2.3", ContentSHA256: digestText("integrity"),
21 }
22 if err := manager.PutLauncherLock(lock); err != nil {
23 t.Fatal(err)
24 }
25 spec := Spec{Name: "search", Command: "npx", Args: []string{"-y", "@scope/server", "--stdio"}, LaunchManager: manager}
26 locked, err := applyStoredLauncherLock(spec)
27 if err != nil {
28 t.Fatal(err)
29 }
30 want := []string{"-y", "--offline", "@scope/server@1.2.3", "--stdio"}
31 if !reflect.DeepEqual(locked.LaunchArgs, want) {
32 t.Fatalf("launch args = %v, want %v", locked.LaunchArgs, want)
33 }
34 if wantIdentity := []string{"-y", "@scope/server@1.2.3", "--stdio"}; !reflect.DeepEqual(locked.LauncherIdentityArgs, wantIdentity) {
35 t.Fatalf("launcher identity args = %v, want %v", locked.LauncherIdentityArgs, wantIdentity)
36 }
37 if locked.LauncherDigest == "" {
38 t.Fatal("launcher digest is empty")
39 }
40 if SchemaCacheKey(locked) != SchemaCacheKey(spec) {
41 t.Fatal("host-local launcher lock changed the schema cache key")
42 }
43 }
44
45 func TestStoredLauncherEnforcementFlagPreservesAuthorizedIdentity(t *testing.T) {
46 cases := []struct {
47 name, command, server, locator, resolved, enforcementFlag string
48 args []string
49 }{
50 {
51 name: "npx", command: "npx", server: "chrome-devtools",
52 locator: "chrome-devtools-mcp@latest", resolved: "chrome-devtools-mcp@1.6.0",
53 enforcementFlag: "--offline", args: []string{"-y", "chrome-devtools-mcp@latest", "--slim"},
54 },
55 {
56 name: "bunx", command: "bunx", server: "browser",
57 locator: "browser-mcp@latest", resolved: "browser-mcp@2.3.4",
58 enforcementFlag: "--no-install", args: []string{"browser-mcp@latest", "--stdio"},
59 },
60 {
61 name: "uvx", command: "uvx", server: "python-tools",
62 locator: "python-tools", resolved: "python-tools==3.2.1",
63 enforcementFlag: "--offline", args: []string{"python-tools", "--stdio"},
64 },
65 }
66 for _, tc := range cases {
67 t.Run(tc.name, func(t *testing.T) {
68 dir := t.TempDir()
69 command := filepath.Join(dir, tc.command)
70 if runtime.GOOS == "windows" {
71 command += ".exe"
72 }
73 if err := os.WriteFile(command, []byte("test launcher"), 0o755); err != nil {
74 t.Fatal(err)
75 }
76 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), dir)
77 lock := mcplaunch.LauncherLock{
78 Server: tc.server, Locator: digestText(tc.locator),
79 ResolvedVersion: tc.resolved, ContentSHA256: digestText("integrity"),
80 Workspace: manager.WorkspaceFingerprint(),
81 }
82 spec := Spec{
83 Name: tc.server, Command: command, Args: tc.args,
84 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
85 }
86 locator, mutable := mutableLauncherLocator(spec)
87 if !mutable {
88 t.Fatalf("%s launcher was not recognized as mutable", tc.command)
89 }
90 preflight := spec
91 applyLauncherResolution(&preflight, locator, lock, false)
92 approvedIdentity, err := projectLaunchIdentityDigest(context.Background(), preflight)
93 if err != nil {
94 t.Fatal(err)
95 }
96 if err := manager.Authorize(spec.Name, spec.ConfigSource, approvedIdentity); err != nil {
97 t.Fatal(err)
98 }
99 if err := manager.PutLauncherLock(lock); err != nil {
100 t.Fatal(err)
101 }
102 locked, err := applyStoredLauncherLock(spec)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if !stringSliceContains(locked.LaunchArgs, tc.enforcementFlag) || stringSliceContains(locked.LauncherIdentityArgs, tc.enforcementFlag) {
107 t.Fatalf("launch args = %v, identity args = %v, enforcement flag = %q", locked.LaunchArgs, locked.LauncherIdentityArgs, tc.enforcementFlag)
108 }
109 restartIdentity, err := projectLaunchIdentityDigest(context.Background(), locked)
110 if err != nil {
111 t.Fatal(err)
112 }
113 if restartIdentity != approvedIdentity {
114 t.Fatalf("stored-lock identity changed after enforcement: approved=%s restart=%s", approvedIdentity, restartIdentity)
115 }
116 if authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, restartIdentity); err != nil || !authorized || changed {
117 t.Fatalf("stored-lock launch authorization = (authorized=%v, changed=%v, err=%v)", authorized, changed, err)
118 }
119 })
120 }
121 }
122
123 func TestStoredUVXFromLauncherLockKeepsFromValueAdjacent(t *testing.T) {
124 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
125 lock := mcplaunch.LauncherLock{
126 Server: "python-tools", Locator: digestText("python-tools"),
127 ResolvedVersion: "python-tools==3.2.1", ContentSHA256: digestText("integrity"),
128 }
129 if err := manager.PutLauncherLock(lock); err != nil {
130 t.Fatal(err)
131 }
132 spec := Spec{
133 Name: "python-tools", Command: "uvx",
134 Args: []string{"--from", "python-tools", "python-tools-server", "--stdio"},
135 LaunchManager: manager,
136 }
137 locked, err := applyStoredLauncherLock(spec)
138 if err != nil {
139 t.Fatal(err)
140 }
141 wantLaunch := []string{"--offline", "--from", "python-tools==3.2.1", "python-tools-server", "--stdio"}
142 if !reflect.DeepEqual(locked.LaunchArgs, wantLaunch) {
143 t.Fatalf("launch args = %v, want %v", locked.LaunchArgs, wantLaunch)
144 }
145 wantIdentity := []string{"--from", "python-tools==3.2.1", "python-tools-server", "--stdio"}
146 if !reflect.DeepEqual(locked.LauncherIdentityArgs, wantIdentity) {
147 t.Fatalf("launcher identity args = %v, want %v", locked.LauncherIdentityArgs, wantIdentity)
148 }
149 }
150
151 func stringSliceContains(values []string, want string) bool {
152 for _, value := range values {
153 if value == want {
154 return true
155 }
156 }
157 return false
158 }
159
160 func TestMutableLauncherRejectsAmbiguousFlagValue(t *testing.T) {
161 locator, mutable := mutableLauncherLocator(Spec{Command: "npx", Args: []string{"--node-options", "--inspect", "server"}})
162 if !mutable || locator.value != "" {
163 t.Fatalf("ambiguous locator = %+v, mutable=%v", locator, mutable)
164 }
165 }
166
167 func TestResolvePyPIPackagePinsVersionAndFileDigests(t *testing.T) {
168 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169 if r.URL.Path != "/demo/json" {
170 t.Fatalf("request path = %q", r.URL.Path)
171 }
172 _, _ = w.Write([]byte(`{"info":{"version":"2.4.1"},"urls":[{"digests":{"sha256":"bbb"}},{"digests":{"sha256":"aaa"}}]}`))
173 }))
174 defer server.Close()
175 oldBase := pypiBaseURL
176 pypiBaseURL = server.URL
177 defer func() { pypiBaseURL = oldBase }()
178 resolved, digest, err := resolvePyPIPackage(context.Background(), "demo")
179 if err != nil {
180 t.Fatal(err)
181 }
182 if resolved != "demo==2.4.1" || digest != digestText("aaa\nbbb") {
183 t.Fatalf("resolution = %q %q", resolved, digest)
184 }
185 }
186
187 func TestResolveExactGitLocatorDoesNotNeedNetwork(t *testing.T) {
188 commit := "0123456789abcdef0123456789abcdef01234567"
189 locator := "git+https://example.invalid/server.git@" + commit
190 resolved, digest, err := resolveGitLocator(context.Background(), Spec{}, locator)
191 if err != nil {
192 t.Fatal(err)
193 }
194 if resolved != commit || digest != digestText(commit) {
195 t.Fatalf("resolution = %q %q", resolved, digest)
196 }
197 }
198
199 func TestGitLauncherLockDoesNotPersistCredentialedLocator(t *testing.T) {
200 home := t.TempDir()
201 manager := mcplaunch.NewManager(filepath.Join(home, mcplaunch.StateFilename), "/workspace")
202 locator := "git+https://user:secret-token@example.test/server.git@main"
203 commit := "0123456789abcdef0123456789abcdef01234567"
204 lock := mcplaunch.LauncherLock{
205 Server: "git-server", Locator: digestText(locator), ResolvedVersion: commit, ContentSHA256: digestText(commit),
206 }
207 if err := manager.PutLauncherLock(lock); err != nil {
208 t.Fatal(err)
209 }
210 body, err := os.ReadFile(manager.Path())
211 if err != nil {
212 t.Fatal(err)
213 }
214 if strings.Contains(string(body), "secret-token") || strings.Contains(string(body), "user:") {
215 t.Fatal("launcher security state persisted URL credentials")
216 }
217 spec := Spec{Name: "git-server", Command: "npx", Args: []string{locator}, LaunchManager: manager}
218 got, err := applyStoredLauncherLock(spec)
219 if err != nil {
220 t.Fatal(err)
221 }
222 want := "git+https://user:secret-token@example.test/server.git@" + commit
223 if len(got.LaunchArgs) != 2 || got.LaunchArgs[1] != want || got.LaunchArgs[0] != "--offline" {
224 t.Fatalf("reconstructed git launch args = %v, want offline + exact original locator", got.LaunchArgs)
225 }
226 }
227
228 func TestNPMPackageName(t *testing.T) {
229 cases := map[string]string{
230 "server": "server",
231 "server@^1": "server",
232 "@scope/server": "@scope/server",
233 "@scope/server@1.2.3": "@scope/server",
234 "file:../server": "",
235 "github/acme": "",
236 }
237 for input, want := range cases {
238 if got := npmPackageName(input); got != want {
239 t.Errorf("npmPackageName(%q) = %q, want %q", input, got, want)
240 }
241 }
242 }
243
244 func TestFullGitCommitAcceptsOnlyCompleteObjectNames(t *testing.T) {
245 sha1Commit := strings.Repeat("0123456789", 4) // 40 hex
246 sha256Commit := strings.Repeat("0123456789abcdef", 4) // 64 hex
247 for value, want := range map[string]bool{
248 sha1Commit: true,
249 sha256Commit: true,
250 sha1Commit[:39]: false, // abbreviation
251 sha1Commit + "a": false, // 41-hex custom ref: resolve via ls-remote
252 sha256Commit[:63]: false,
253 sha256Commit + "a": false,
254 "main": false,
255 "": false,
256 } {
257 if got := fullGitCommit.MatchString(value); got != want {
258 t.Errorf("fullGitCommit(%d hex %q...) = %v, want %v", len(value), value[:min(8, len(value))], got, want)
259 }
260 }
261
262 }
263
264 func TestResolvePyPIPackageRejectsWildcardBeforeNetwork(t *testing.T) {
265 if _, _, err := resolvePyPIPackage(context.Background(), "server==2.4.*"); err == nil || !strings.Contains(err.Error(), "wildcard") {
266 t.Fatalf("wildcard uvx locator resolved: %v", err)
267 }
268 }
269
269 lines GO