fix(subagents): resolve child zot executable through PATH
- commit
- 3ef5f9b79fb873e420e3e2c08b4f73d987b8880b
- author
- brice · · 2026-08-05 05:15 UTC
- parent
- 3464d10
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +98 -2
M packages/agent/subagents/runner.go +58 -2
| @@ -164,6 +164,54 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||
| 164 | 164 | return args | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | // resolveSubagentExecutable finds a runnable zot binary without assuming a | |
| 168 | + | // particular installation directory. A parent process can outlive the file | |
| 169 | + | // it was started from (for example after a reinstall), so the current | |
| 170 | + | // executable and argv[0] are tried first and PATH is used as the fallback. | |
| 171 | + | // Keeping the lookup on PATH is important: it covers GOBIN, user-local bin | |
| 172 | + | // directories, package-manager locations, and system installs without | |
| 173 | + | // hard-coding any of them. | |
| 174 | + | func resolveSubagentExecutable(self, argv0 string, lookPath func(string) (string, error)) (string, error) { | |
| 175 | + | if lookPath == nil { | |
| 176 | + | lookPath = exec.LookPath | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | candidates := make([]string, 0, 5) | |
| 180 | + | addCandidate := func(candidate string) { | |
| 181 | + | if candidate == "" || candidate == "." { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | for _, existing := range candidates { | |
| 185 | + | if existing == candidate { | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | candidates = append(candidates, candidate) | |
| 190 | + | } | |
| 191 | + | addCandidate(self) | |
| 192 | + | addCandidate(argv0) | |
| 193 | + | if self != "" { | |
| 194 | + | addCandidate(filepath.Base(self)) | |
| 195 | + | } | |
| 196 | + | if argv0 != "" { | |
| 197 | + | addCandidate(filepath.Base(argv0)) | |
| 198 | + | } | |
| 199 | + | addCandidate("zot") | |
| 200 | + | ||
| 201 | + | var lookupErrors []string | |
| 202 | + | for _, candidate := range candidates { | |
| 203 | + | resolved, err := lookPath(candidate) | |
| 204 | + | if err == nil { | |
| 205 | + | return resolved, nil | |
| 206 | + | } | |
| 207 | + | lookupErrors = append(lookupErrors, fmt.Sprintf("%q: %v", candidate, err)) | |
| 208 | + | } | |
| 209 | + | if len(lookupErrors) == 0 { | |
| 210 | + | return "", fmt.Errorf("locate zot executable: no executable candidates") | |
| 211 | + | } | |
| 212 | + | return "", fmt.Errorf("locate zot executable: %s", strings.Join(lookupErrors, "; ")) | |
| 213 | + | } | |
| 214 | + | ||
| 167 | 215 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |
| 168 | 216 | // SessionPath resolution order: | |
| 169 | 217 | // 1. explicit r.SessionPath set by the test / caller | |
| @@ -204,9 +252,17 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||
| 204 | 252 | ||
| 205 | 253 | args := r.Command | |
| 206 | 254 | if len(args) == 0 { | |
| 207 | - | exe, err := os.Executable() | |
| 255 | + | self, selfErr := os.Executable() | |
| 256 | + | argv0 := "" | |
| 257 | + | if len(os.Args) > 0 { | |
| 258 | + | argv0 = os.Args[0] | |
| 259 | + | } | |
| 260 | + | exe, err := resolveSubagentExecutable(self, argv0, exec.LookPath) | |
| 208 | 261 | if err != nil { | |
| 209 | - | return fmt.Errorf("locate self: %w", err) | |
| 262 | + | if selfErr != nil { | |
| 263 | + | return fmt.Errorf("locate zot executable (os.Executable: %v): %w", selfErr, err) | |
| 264 | + | } | |
| 265 | + | return err | |
| 210 | 266 | } | |
| 211 | 267 | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | |
| 212 | 268 | } | |
| @@ -164,6 +164,54 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||||
| 164 | return args | 164 | return args | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | + | // resolveSubagentExecutable finds a runnable zot binary without assuming a | |||
| 168 | + | // particular installation directory. A parent process can outlive the file | |||
| 169 | + | // it was started from (for example after a reinstall), so the current | |||
| 170 | + | // executable and argv[0] are tried first and PATH is used as the fallback. | |||
| 171 | + | // Keeping the lookup on PATH is important: it covers GOBIN, user-local bin | |||
| 172 | + | // directories, package-manager locations, and system installs without | |||
| 173 | + | // hard-coding any of them. | |||
| 174 | + | func resolveSubagentExecutable(self, argv0 string, lookPath func(string) (string, error)) (string, error) { | |||
| 175 | + | if lookPath == nil { | |||
| 176 | + | lookPath = exec.LookPath | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | candidates := make([]string, 0, 5) | |||
| 180 | + | addCandidate := func(candidate string) { | |||
| 181 | + | if candidate == "" || candidate == "." { | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | for _, existing := range candidates { | |||
| 185 | + | if existing == candidate { | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | candidates = append(candidates, candidate) | |||
| 190 | + | } | |||
| 191 | + | addCandidate(self) | |||
| 192 | + | addCandidate(argv0) | |||
| 193 | + | if self != "" { | |||
| 194 | + | addCandidate(filepath.Base(self)) | |||
| 195 | + | } | |||
| 196 | + | if argv0 != "" { | |||
| 197 | + | addCandidate(filepath.Base(argv0)) | |||
| 198 | + | } | |||
| 199 | + | addCandidate("zot") | |||
| 200 | + | ||||
| 201 | + | var lookupErrors []string | |||
| 202 | + | for _, candidate := range candidates { | |||
| 203 | + | resolved, err := lookPath(candidate) | |||
| 204 | + | if err == nil { | |||
| 205 | + | return resolved, nil | |||
| 206 | + | } | |||
| 207 | + | lookupErrors = append(lookupErrors, fmt.Sprintf("%q: %v", candidate, err)) | |||
| 208 | + | } | |||
| 209 | + | if len(lookupErrors) == 0 { | |||
| 210 | + | return "", fmt.Errorf("locate zot executable: no executable candidates") | |||
| 211 | + | } | |||
| 212 | + | return "", fmt.Errorf("locate zot executable: %s", strings.Join(lookupErrors, "; ")) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 167 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | 215 | func (r *execRunner) Run(ctx context.Context, sink Sink) error { | ||
| 168 | // SessionPath resolution order: | 216 | // SessionPath resolution order: | ||
| 169 | // 1. explicit r.SessionPath set by the test / caller | 217 | // 1. explicit r.SessionPath set by the test / caller | ||
| @@ -204,9 +252,17 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error { | |||||
| 204 | 252 | ||||
| 205 | args := r.Command | 253 | args := r.Command | ||
| 206 | if len(args) == 0 { | 254 | if len(args) == 0 { | ||
| 207 | - | exe, err := os.Executable() | 255 | + | self, selfErr := os.Executable() |
| 256 | + | argv0 := "" | |||
| 257 | + | if len(os.Args) > 0 { | |||
| 258 | + | argv0 = os.Args[0] | |||
| 259 | + | } | |||
| 260 | + | exe, err := resolveSubagentExecutable(self, argv0, exec.LookPath) | |||
| 208 | if err != nil { | 261 | if err != nil { | ||
| 209 | - | return fmt.Errorf("locate self: %w", err) | 262 | + | if selfErr != nil { |
| 263 | + | return fmt.Errorf("locate zot executable (os.Executable: %v): %w", selfErr, err) | |||
| 264 | + | } | |||
| 265 | + | return err | |||
| 210 | } | 266 | } | ||
| 211 | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | 267 | args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath) | ||
| 212 | } | 268 | } | ||
M packages/agent/subagents/runner_test.go +40 -0
| @@ -194,6 +194,46 @@ func TestSubagentWorkerArgs(t *testing.T) { | |||
| 194 | 194 | } | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | + | func TestResolveSubagentExecutablePrefersCurrentExecutable(t *testing.T) { | |
| 198 | + | var calls []string | |
| 199 | + | got, err := resolveSubagentExecutable("/current/bin/zot", "/old/bin/zot", func(candidate string) (string, error) { | |
| 200 | + | calls = append(calls, candidate) | |
| 201 | + | if candidate == "/current/bin/zot" { | |
| 202 | + | return candidate, nil | |
| 203 | + | } | |
| 204 | + | return "", os.ErrNotExist | |
| 205 | + | }) | |
| 206 | + | if err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | if got != "/current/bin/zot" { | |
| 210 | + | t.Fatalf("executable = %q, want current executable", got) | |
| 211 | + | } | |
| 212 | + | if len(calls) != 1 || calls[0] != "/current/bin/zot" { | |
| 213 | + | t.Fatalf("lookup calls = %v, want only the current executable", calls) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) { | |
| 218 | + | var calls []string | |
| 219 | + | got, err := resolveSubagentExecutable("/removed/.local/bin/zot", "/removed/.local/bin/zot", func(candidate string) (string, error) { | |
| 220 | + | calls = append(calls, candidate) | |
| 221 | + | if candidate == "zot" { | |
| 222 | + | return "/active/go/bin/zot", nil | |
| 223 | + | } | |
| 224 | + | return "", os.ErrNotExist | |
| 225 | + | }) | |
| 226 | + | if err != nil { | |
| 227 | + | t.Fatal(err) | |
| 228 | + | } | |
| 229 | + | if got != "/active/go/bin/zot" { | |
| 230 | + | t.Fatalf("executable = %q, want PATH fallback", got) | |
| 231 | + | } | |
| 232 | + | if len(calls) != 2 || calls[0] != "/removed/.local/bin/zot" || calls[1] != "zot" { | |
| 233 | + | t.Fatalf("lookup calls = %v, want stale path followed by PATH lookup", calls) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 197 | 237 | func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) { | |
| 198 | 238 | args := subagentWorkerArgs(subagentWorkerArgsOpts{ | |
| 199 | 239 | Exe: "/zot", | |
| @@ -194,6 +194,46 @@ func TestSubagentWorkerArgs(t *testing.T) { | |||||
| 194 | } | 194 | } | ||
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | + | func TestResolveSubagentExecutablePrefersCurrentExecutable(t *testing.T) { | |||
| 198 | + | var calls []string | |||
| 199 | + | got, err := resolveSubagentExecutable("/current/bin/zot", "/old/bin/zot", func(candidate string) (string, error) { | |||
| 200 | + | calls = append(calls, candidate) | |||
| 201 | + | if candidate == "/current/bin/zot" { | |||
| 202 | + | return candidate, nil | |||
| 203 | + | } | |||
| 204 | + | return "", os.ErrNotExist | |||
| 205 | + | }) | |||
| 206 | + | if err != nil { | |||
| 207 | + | t.Fatal(err) | |||
| 208 | + | } | |||
| 209 | + | if got != "/current/bin/zot" { | |||
| 210 | + | t.Fatalf("executable = %q, want current executable", got) | |||
| 211 | + | } | |||
| 212 | + | if len(calls) != 1 || calls[0] != "/current/bin/zot" { | |||
| 213 | + | t.Fatalf("lookup calls = %v, want only the current executable", calls) | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) { | |||
| 218 | + | var calls []string | |||
| 219 | + | got, err := resolveSubagentExecutable("/removed/.local/bin/zot", "/removed/.local/bin/zot", func(candidate string) (string, error) { | |||
| 220 | + | calls = append(calls, candidate) | |||
| 221 | + | if candidate == "zot" { | |||
| 222 | + | return "/active/go/bin/zot", nil | |||
| 223 | + | } | |||
| 224 | + | return "", os.ErrNotExist | |||
| 225 | + | }) | |||
| 226 | + | if err != nil { | |||
| 227 | + | t.Fatal(err) | |||
| 228 | + | } | |||
| 229 | + | if got != "/active/go/bin/zot" { | |||
| 230 | + | t.Fatalf("executable = %q, want PATH fallback", got) | |||
| 231 | + | } | |||
| 232 | + | if len(calls) != 2 || calls[0] != "/removed/.local/bin/zot" || calls[1] != "zot" { | |||
| 233 | + | t.Fatalf("lookup calls = %v, want stale path followed by PATH lookup", calls) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 197 | func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) { | 237 | func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) { | ||
| 198 | args := subagentWorkerArgs(subagentWorkerArgsOpts{ | 238 | args := subagentWorkerArgs(subagentWorkerArgsOpts{ | ||
| 199 | Exe: "/zot", | 239 | Exe: "/zot", | ||