Skip to content

Commit 346bad6

Browse files
Merge pull request #356 from natalie-o-perret/fix/discovery-auth-xdg-open-linux
fix: send auth credentials during spec discovery; fix xdg-open on Linux
2 parents 6f145c1 + 0bdb448 commit 346bad6

21 files changed

Lines changed: 1503 additions & 104 deletions

.agents/skills/rsh-review/SKILL.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Review code changes with a bug-finding mindset. Prioritize correctness, regressi
3636
4. Challenge assumptions around error paths, cancellation, timeouts, cleanup, concurrency, config precedence, and backward compatibility.
3737
5. Check whether tests cover intended behavior and important failure modes.
3838
6. Check whether `site/` docs or `docs/design/` should change.
39-
7. Report findings first, ordered by severity. Keep summaries brief.
39+
7. Keep the review scoped to the current PR unless the user asks for a broader audit. Distinguish required fixes from optional hardening or follow-on cleanup.
40+
8. Report findings first, ordered by severity. Keep summaries brief.
4041

4142
## Output Expectations
4243

@@ -47,6 +48,7 @@ Review code changes with a bug-finding mindset. Prioritize correctness, regressi
4748
- If no findings are present, say so explicitly and call out any residual risk or untested areas.
4849
- Do not pad the review with praise or low-value nits unless the user asks for them.
4950
- Do not report speculative issues unless there is a plausible failure mode in the changed code.
51+
- Do not turn a scoped PR review into general codebase cleanup. If you notice unrelated risks, list them as out-of-scope follow-ups only when they are important.
5052
- Treat missing coverage as a test gap unless the diff shows a confirmed bug.
5153

5254
## Severity Guide
@@ -105,6 +107,8 @@ Intentional formatter changes should usually come with targeted regression cover
105107

106108
Changes in these areas often regress behavior only in realistic end-to-end paths. Review interactions, not just isolated helpers.
107109

110+
For auth, redaction, and cache metadata changes, check both positive behavior and negative leakage boundaries: where credentials are applied, where they must not be applied, what gets persisted, and what appears in errors or traces. Keep findings tied to the PR's changed paths.
111+
108112
### Test buffer races
109113

110114
Tests that share a `bytes.Buffer` across concurrent writers can hide data races, especially when subprocess stderr/stdout is wired into test buffers.

.agents/skills/rsh-simplify/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ Identify and eliminate unnecessary complexity in the Restish codebase. Fewer lin
2727

2828
User experience comes first. Developer experience comes second. Simplifications that improve both are highest value. Simplifications that improve only developer experience at some user cost are not acceptable.
2929

30+
## Post-Review Shrink Pass
31+
32+
After a PR has gone through review/fix loops, do a focused simplification pass before final handoff when the diff has grown substantially. Compare `git diff --stat` or `git diff --shortstat` before and after. Target duplicated test setup, repeated assertions, over-large inline fixtures, and helper code that obscures the behavior under test. Prefer deleting ceremony over deleting coverage.
33+
34+
Good shrink-pass moves:
35+
36+
- Table-drive cases that share setup and assertion shape.
37+
- Add small helpers for repeated config/cache/server setup when they make tests read more directly.
38+
- Share behavioral assertions that are repeated verbatim.
39+
- Keep edge-case tests for auth, redaction, redirects, cache metadata, migrations, and subprocess behavior when those edge cases are the point of the PR.
40+
41+
Avoid code golf. A shorter diff that is harder to audit, especially around security/auth/cache behavior, is not a simplification.
42+
3043
## Process
3144

3245
### 1. Analyze

.agents/skills/rsh-test/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ Prefer plain Go tests with a small Restish-specific helper vocabulary over Gherk
5959

6060
- One test should usually cover one behavior from input through observable result.
6161
- Combine cases when they differ only by inputs and expected outputs. Split cases when setup or failure meaning differs.
62+
- For review-driven edge cases, first preserve the behavioral boundary, then reduce scaffolding. Table-drive repeated redirect, redaction, cache, migration, or auth-origin cases only when the setup and assertion shape truly match.
63+
- Use focused helpers for repeated config/cache/server setup, but keep the request, persisted state, error, or output assertion visible in the test.
64+
- When covering credential handling, assert both where credentials are applied and where they are not applied, plus what gets persisted and what appears in diagnostics.
6265
- Avoid asserting private call sequences unless ordering is the contract.
6366
- Avoid mocks for HTTP, files, and CLI I/O when standard library fakes or temp resources are clearer.
6467
- Keep fixtures tiny but believable: real OpenAPI fragments, real response headers, real shorthand, real config snippets.

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ The core design is a `CLI` struct in `internal/cli/cli.go` that owns all state
4848

4949
`TODO.md` and `review.md` are local working files for planning, code reviews, and in-progress implementation notes only. They must never be committed to git history. Keep them ignored/untracked, and if they are accidentally staged, unstage them before committing.
5050

51+
## PR Review and Cleanup Discipline
52+
53+
Keep review and fix loops scoped to the current PR's behavioral surface unless the user explicitly asks for broader cleanup. After substantial review loops, do a simplification pass before final handoff: reduce repeated scaffolding, table-drive genuinely similar cases, and consolidate helpers where that preserves readability. Do not shrink a PR by deleting important coverage or making security/auth/cache behavior harder to audit.
54+
5155
## Commit Messages
5256

5357
Use [Conventional Commits](https://www.conventionalcommits.org/) for all commits:

internal/auth/oauth_authcode.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,11 @@ func DefaultOpenBrowser(rawURL string) error {
776776
var openBrowserCommand = defaultOpenBrowserCommand
777777

778778
func defaultOpenBrowserCommand(rawURL string) *exec.Cmd {
779-
switch runtime.GOOS {
779+
return defaultOpenBrowserCommandForGOOS(runtime.GOOS, rawURL)
780+
}
781+
782+
func defaultOpenBrowserCommandForGOOS(goos, rawURL string) *exec.Cmd {
783+
switch goos {
780784
case "darwin":
781785
return exec.Command("open", "--", rawURL)
782786
case "windows":
@@ -785,7 +789,7 @@ func defaultOpenBrowserCommand(rawURL string) *exec.Cmd {
785789
// cmd /c start flags.
786790
return exec.Command("cmd", "/c", "start", "", "--", rawURL)
787791
default:
788-
return exec.Command("xdg-open", "--", rawURL)
792+
return exec.Command("xdg-open", rawURL)
789793
}
790794
}
791795

internal/auth/oauth_authcode_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"os"
2020
"os/exec"
2121
"path/filepath"
22+
"reflect"
2223
"runtime"
2324
"strings"
2425
"sync"
@@ -1199,13 +1200,26 @@ func TestDefaultOpenBrowserReturnsAfterStart(t *testing.T) {
11991200
}
12001201

12011202
func TestDefaultOpenBrowserCommandUsesArgumentSeparator(t *testing.T) {
1203+
if runtime.GOOS == "linux" {
1204+
// xdg-open does not support --, so we skip the separator check on Linux.
1205+
// Real OAuth URLs always start with https://, so this is safe in practice.
1206+
t.Skip("xdg-open does not accept --")
1207+
}
12021208
cmd := defaultOpenBrowserCommand("-https://example.com")
12031209
args := strings.Join(cmd.Args, "\x00")
12041210
if !strings.Contains(args, "\x00--\x00-https://example.com") {
12051211
t.Fatalf("browser command should pass -- before URL, got %#v", cmd.Args)
12061212
}
12071213
}
12081214

1215+
func TestDefaultOpenBrowserCommandLinuxUsesXDGOpenWithoutSeparator(t *testing.T) {
1216+
cmd := defaultOpenBrowserCommandForGOOS("linux", "https://example.com/callback?code=abc")
1217+
want := []string{"xdg-open", "https://example.com/callback?code=abc"}
1218+
if !reflect.DeepEqual(cmd.Args, want) {
1219+
t.Fatalf("linux browser command args = %#v, want %#v", cmd.Args, want)
1220+
}
1221+
}
1222+
12091223
func mustCallbackURL(t *testing.T, rawAuthorizeURL string) (string, string) {
12101224
t.Helper()
12111225
u, err := url.Parse(rawAuthorizeURL)

internal/cli/api.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
89
"net/http"
10+
"net/url"
911
"os"
1012
"reflect"
1113
"sort"
@@ -222,6 +224,7 @@ func (c *CLI) runAPISync(cmd *cobra.Command, args []string) error {
222224
if closer != nil {
223225
defer closer.Close()
224226
}
227+
fetch := c.discoveryFetcher(requestContext(cmd), apiName, apiCfg, profileName)
225228
discCfg := spec.DiscoverConfig{
226229
APIName: c.apiStateName(apiName),
227230
BaseURL: effectiveProfileBaseURL(apiCfg, profileName),
@@ -232,6 +235,7 @@ func (c *CLI) runAPISync(cmd *cobra.Command, args []string) error {
232235
ServerVariables: effectiveServerVariables(apiCfg, profileName),
233236
Version: Version,
234237
Transport: transport,
238+
Fetch: fetch,
235239
AllowCrossOrigin: apiCfg.AllowCrossOriginSpec || allowCrossOrigin,
236240
ForceRefresh: true,
237241
Trace: c.discoveryTrace(cmd),
@@ -336,6 +340,7 @@ func (c *CLI) runAPIConnect(cmd *cobra.Command, args []string) error {
336340
if closer != nil {
337341
defer closer.Close()
338342
}
343+
fetch := c.discoveryFetcher(requestContext(cmd), apiName, apiCfg, "default")
339344
discCfg := spec.DiscoverConfig{
340345
APIName: apiName,
341346
BaseURL: baseURL,
@@ -345,6 +350,7 @@ func (c *CLI) runAPIConnect(cmd *cobra.Command, args []string) error {
345350
ServerVariables: nil,
346351
Version: Version,
347352
Transport: transport,
353+
Fetch: fetch,
348354
AllowCrossOrigin: allowCrossOrigin,
349355
ForceRefresh: true,
350356
Trace: c.discoveryTrace(cmd),
@@ -1114,6 +1120,127 @@ func (c *CLI) discoveryTransport(ctx context.Context, apiCfg *config.APIConfig,
11141120
return transport, closer, nil
11151121
}
11161122

1123+
func (c *CLI) discoveryFetcher(ctx context.Context, apiName string, apiCfg *config.APIConfig, profileName string) spec.HTTPFetcher {
1124+
authOrigins := discoveryAuthOrigins(apiCfg, profileName)
1125+
return func(ctx context.Context, rawURL string, transport http.RoundTripper) (*http.Response, error) {
1126+
baseOpts, authOpts := c.discoveryRequestOptions(ctx, apiName, apiCfg, profileName, transport)
1127+
opts := baseOpts
1128+
if discoveryAuthAllowed(authOrigins, rawURL) {
1129+
opts = authOpts
1130+
}
1131+
return c.doDiscoveryRequest(ctx, http.MethodGet, rawURL, opts)
1132+
}
1133+
}
1134+
1135+
func (c *CLI) discoveryRequestOptions(ctx context.Context, apiName string, apiCfg *config.APIConfig, profileName string, transport http.RoundTripper) (request.Options, request.Options) {
1136+
if profileName == "" {
1137+
profileName = "default"
1138+
}
1139+
baseOpts := request.Options{
1140+
Transport: transport,
1141+
UserAgent: "restish/" + Version,
1142+
}
1143+
authOpts := baseOpts
1144+
if apiCfg != nil {
1145+
if apiCfg.PreserveHeaderCase {
1146+
authOpts.PreserveHeaderCase = true
1147+
}
1148+
prof := profileForName(apiCfg, profileName)
1149+
if prof != nil {
1150+
authOpts.Headers = append([]string(nil), prof.Headers...)
1151+
authOpts.Query = append([]string(nil), prof.Query...)
1152+
}
1153+
callbacks := c.authOnRequest(apiName, profileName, prof, authHandlerOptionsFromContext(ctx))
1154+
authOpts.OnRequest = callbacks.OnRequest
1155+
authOpts.OnUnauthorized = callbacks.OnUnauthorized
1156+
}
1157+
if authOpts.OnRequest != nil || len(c.pluginsByHook["request-middleware"]) > 0 {
1158+
origOnRequest := authOpts.OnRequest
1159+
authOpts.OnRequest = func(req *http.Request) error {
1160+
if origOnRequest != nil {
1161+
if err := origOnRequest(req); err != nil {
1162+
return err
1163+
}
1164+
}
1165+
return c.runRequestMiddlewarePlugins(req)
1166+
}
1167+
}
1168+
return baseOpts, authOpts
1169+
}
1170+
1171+
func (c *CLI) doDiscoveryRequest(ctx context.Context, method, rawURL string, opts request.Options) (*http.Response, error) {
1172+
resp, err := request.Do(ctx, method, rawURL, nil, opts)
1173+
if err != nil || resp == nil || resp.StatusCode != http.StatusUnauthorized || opts.OnUnauthorized == nil {
1174+
return resp, err
1175+
}
1176+
if resp.Body != nil {
1177+
_, _ = io.Copy(io.Discard, resp.Body)
1178+
_ = resp.Body.Close()
1179+
}
1180+
retryOpts := opts
1181+
onUnauthorized := retryOpts.OnUnauthorized
1182+
retryOpts.OnRequest = func(req *http.Request) error {
1183+
if err := onUnauthorized(req); err != nil {
1184+
return err
1185+
}
1186+
return c.runRequestMiddlewarePlugins(req)
1187+
}
1188+
retryOpts.OnUnauthorized = nil
1189+
return request.Do(ctx, method, rawURL, nil, retryOpts)
1190+
}
1191+
1192+
func authHandlerOptionsFromContext(ctx context.Context) authHandlerOptions {
1193+
gf := globalFlagsFromContext(ctx)
1194+
return authHandlerOptions{NoBrowser: gf.NoBrowser, Verbose: gf.Verbose > 0}
1195+
}
1196+
1197+
func discoveryAuthOrigins(apiCfg *config.APIConfig, profileName string) []*url.URL {
1198+
if apiCfg == nil {
1199+
return nil
1200+
}
1201+
var origins []*url.URL
1202+
addNormalized := func(raw string) {
1203+
if raw == "" {
1204+
return
1205+
}
1206+
if normalized, err := request.Normalize(raw, ""); err == nil {
1207+
raw = normalized
1208+
}
1209+
u, err := url.Parse(raw)
1210+
if err == nil && u.IsAbs() && (u.Scheme == "http" || u.Scheme == "https") {
1211+
origins = append(origins, u)
1212+
}
1213+
}
1214+
addExplicitURL := func(raw string) {
1215+
u, err := url.Parse(raw)
1216+
if err == nil && u.IsAbs() && (u.Scheme == "http" || u.Scheme == "https") {
1217+
origins = append(origins, u)
1218+
}
1219+
}
1220+
addNormalized(effectiveProfileBaseURL(apiCfg, profileName))
1221+
addNormalized(apiCfg.SpecURL)
1222+
for _, src := range apiCfg.SpecFiles {
1223+
addExplicitURL(src)
1224+
}
1225+
return origins
1226+
}
1227+
1228+
func discoveryAuthAllowed(origins []*url.URL, rawURL string) bool {
1229+
if normalized, err := request.Normalize(rawURL, ""); err == nil {
1230+
rawURL = normalized
1231+
}
1232+
u, err := url.Parse(rawURL)
1233+
if err != nil || !u.IsAbs() {
1234+
return false
1235+
}
1236+
for _, origin := range origins {
1237+
if request.SameOrigin(origin, u) {
1238+
return true
1239+
}
1240+
}
1241+
return false
1242+
}
1243+
11171244
// runAPIInspect prints the config for a named API as indented JSON,
11181245
// with secret auth params replaced by "***".
11191246
func (c *CLI) runAPIInspect(cmd *cobra.Command, args []string) error {

0 commit comments

Comments
 (0)