diff --git a/CHANGELOG.md b/CHANGELOG.md index 92efda8..d974794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- Metadata headers are injected only into fetches targeting the script's + declared domain. Cross-origin fetches remain governed by browser CORS/CSP and + never receive Tap-configured credentials. + +### Fixed + +- Structured agent-browser batch failures are now preserved instead of being + reduced to an opaque `exit status 1` error. + ## [1.0.0] - 2026-08-11 ### Added diff --git a/README.md b/README.md index bbb2283..33011fa 100644 --- a/README.md +++ b/README.md @@ -119,13 +119,15 @@ The script name comes from its path. For example, */ async function(args) { - // fetch(...) runs in agent-browser; metadata headers are merged into requests. + // fetch(...) runs in agent-browser. Metadata headers are merged only into + // requests targeting the declared domain; browser CORS/CSP still applies. } ``` Environment variables are inferred from `${VAR}` references. A header is omitted when one of its referenced variables is unset. Resolved headers are -applied before navigation and cleared after script execution. +injected into same-domain script fetches and are never installed as +browser-wide navigation headers. ## Command map diff --git a/agentbrowser/client.go b/agentbrowser/client.go index ed8fd47..4ab1eb1 100644 --- a/agentbrowser/client.go +++ b/agentbrowser/client.go @@ -48,6 +48,15 @@ type batchResult struct { Error json.RawMessage `json:"error"` } +type batchCommandError struct { + index int + message string +} + +func (e *batchCommandError) Error() string { + return fmt.Sprintf("agent-browser batch command %d: %s", e.index, e.message) +} + // New creates a thin client. Binary lookup remains lazy so registry-only // commands such as `tap site list` work even before agent-browser is installed. func New(binary string) *Client { @@ -152,22 +161,43 @@ func (c *Client) OpenAndEval(ctx context.Context, url, script string, headers ma defer cancel() _, cleanupErr = c.runJSON(cleanupCtx, nil, "set", "headers", "{}", "--json") } + var value any + var resultErr error + if len(bytes.TrimSpace(out)) > 0 { + value, resultErr = decodeBatch(out, len(commands)) + } + var commandErr *batchCommandError + if resultErr != nil && (batchErr == nil || errors.As(resultErr, &commandErr)) { + // agent-browser batch --json writes structured command failures to + // stdout and exits non-zero. Prefer that actionable error over status 1. + return nil, errors.Join(resultErr, cleanupErr) + } if batchErr != nil || cleanupErr != nil { return nil, errors.Join(batchErr, cleanupErr) } + if resultErr != nil { + return nil, resultErr + } + if len(bytes.TrimSpace(out)) == 0 { + return nil, fmt.Errorf("agent-browser batch returned empty output") + } + return value, nil +} + +func decodeBatch(out []byte, want int) (any, error) { var results []batchResult if err := json.Unmarshal(out, &results); err != nil { return nil, fmt.Errorf("decode agent-browser batch: %w", err) } - if len(results) != len(commands) { - return nil, fmt.Errorf("agent-browser batch returned %d results, want %d", len(results), len(commands)) + if len(results) != want { + return nil, fmt.Errorf("agent-browser batch returned %d results, want %d", len(results), want) } for index, result := range results { if !result.Success { - return nil, fmt.Errorf("agent-browser batch command %d: %s", index+1, decodeError(result.Error)) + return nil, &batchCommandError{index: index + 1, message: decodeError(result.Error)} } } - return decodeEval(results[1].Result) + return decodeEval(results[want-1].Result) } func decodeEval(data json.RawMessage) (any, error) { @@ -265,7 +295,7 @@ func (c *Client) run(ctx context.Context, stdin []byte, args ...string) ([]byte, if message == "" { message = err.Error() } - return nil, stderr.Bytes(), fmt.Errorf("agent-browser %s: %s", strings.Join(args, " "), message) + return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("agent-browser %s: %s", strings.Join(args, " "), message) } return stdout.Bytes(), stderr.Bytes(), nil } diff --git a/agentbrowser/client_test.go b/agentbrowser/client_test.go index 53985a7..4f8716d 100644 --- a/agentbrowser/client_test.go +++ b/agentbrowser/client_test.go @@ -208,6 +208,32 @@ fi } } +func TestOpenAndEvalReturnsStructuredBatchFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + bin := filepath.Join(t.TempDir(), "agent-browser") + script := `#!/bin/sh +cat >/dev/null +printf '%s' '[{"success":true,"result":{}},{"success":false,"result":null,"error":"Evaluation error: TypeError: Failed to fetch"}]' +exit 1 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + _, err := New(bin).OpenAndEval(context.Background(), "https://example.com", "1", nil) + if err == nil { + t.Fatal("expected batch failure") + } + if !strings.Contains(err.Error(), "TypeError: Failed to fetch") { + t.Fatalf("error = %q, want structured evaluation error", err) + } + if strings.Contains(err.Error(), "exit status 1") { + t.Fatalf("error leaked opaque process status: %q", err) + } +} + func mustRead(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) diff --git a/script/parser.go b/script/parser.go index e75436a..9f73272 100644 --- a/script/parser.go +++ b/script/parser.go @@ -45,7 +45,7 @@ type Meta struct { Capabilities []string `json:"capabilities"` // AuthRequired indicates the script needs browser-based authentication. AuthRequired bool `json:"authRequired"` - // Headers are HTTP headers injected into every fetch() call made by the script. + // Headers are HTTP headers injected into fetch() calls targeting Domain. // Values may reference environment variables with ${VAR} syntax; headers whose // variable is unset are omitted entirely. See ResolveHeaders. Headers map[string]string `json:"headers"` diff --git a/skills/tap-web/references/script-development.md b/skills/tap-web/references/script-development.md index 6b80dfa..888f13b 100644 --- a/skills/tap-web/references/script-development.md +++ b/skills/tap-web/references/script-development.md @@ -45,8 +45,9 @@ async function(args) { `name`, `runtime`, and `env` are not metadata fields. Environment variables are inferred from `${VAR}` references in headers. Unresolved headers are omitted. -Metadata headers are applied before domain navigation, merged into every script -`fetch()` call, then cleared so credentials do not linger in the shared session. +Metadata headers are merged only into `fetch()` calls targeting the declared +domain. Cross-origin requests are not blocked by Tap, but they never receive +Tap-configured headers and remain subject to browser CORS/CSP. ## Errors diff --git a/tap.go b/tap.go index e11e73f..abbf4bd 100644 --- a/tap.go +++ b/tap.go @@ -98,7 +98,10 @@ func (c *Client) RunScript(ctx context.Context, name string, args map[string]str if err != nil { return nil, err } - return c.browser.OpenAndEval(ctx, navigationURL, program, headers) + // Metadata headers are injected by the generated fetch wrapper only after + // resolving the request URL. Do not install them as browser-wide navigation + // headers, where redirects and cross-origin requests could inherit them. + return c.browser.OpenAndEval(ctx, navigationURL, program, nil) } // Fetch extracts a URL through agent-browser. An empty URL reads the active tab @@ -124,17 +127,27 @@ func siteProgram(s *script.Script, args, headers map[string]string) (string, err if err != nil { return "", fmt.Errorf("marshal script headers: %w", err) } + domainJSON, err := json.Marshal(s.Meta.Domain) + if err != nil { + return "", fmt.Errorf("marshal script domain: %w", err) + } return fmt.Sprintf(`(async () => { const __tapArgs = %s; const __tapHeaders = %s; + const __tapDomain = %s; + const __tapHeaderOrigin = __tapDomain ? "https://" + __tapDomain : null; const __tapNativeFetch = globalThis.fetch.bind(globalThis); const fetch = (input, init = {}) => { - const headers = new Headers(init.headers || {}); - for (const [name, value] of Object.entries(__tapHeaders)) headers.set(name, value); + const url = new URL(input instanceof Request ? input.url : String(input), location.href); + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init.headers || {}).forEach((value, name) => headers.set(name, value)); + if (url.origin === __tapHeaderOrigin) { + for (const [name, value] of Object.entries(__tapHeaders)) headers.set(name, value); + } return __tapNativeFetch(input, {...init, headers}); }; return await (%s)(__tapArgs); -})()`, argsJSON, headersJSON, s.Body), nil +})()`, argsJSON, headersJSON, domainJSON, s.Body), nil } // ListScripts returns all available scripts sorted by name. diff --git a/tap_test.go b/tap_test.go index 4d26d0d..7f383bd 100644 --- a/tap_test.go +++ b/tap_test.go @@ -52,8 +52,8 @@ async function(args) { return {query: args.query}; }`) if err != nil { t.Fatal(err) } - if !strings.Contains(string(args), "set") { - t.Fatalf("site headers were not cleared: %s", args) + if strings.Contains(string(args), "set") { + t.Fatalf("site headers leaked into browser session: %s", args) } stdin, err := os.ReadFile(os.Getenv("STDIN_FILE")) if err != nil { @@ -63,7 +63,7 @@ async function(args) { return {query: args.query}; }`) if err := json.Unmarshal(stdin, &commands); err != nil { t.Fatal(err) } - if len(commands) != 2 || commands[0][2] != "--headers" { + if len(commands) != 2 || len(commands[0]) != 2 { t.Fatalf("unexpected orchestration: %#v", commands) } decoded, err := base64.StdEncoding.DecodeString(commands[1][2]) @@ -71,11 +71,14 @@ async function(args) { return {query: args.query}; }`) t.Fatal(err) } program := string(decoded) - for _, want := range []string{`"query":"hello"`, `"X-Key":"test-key"`, "globalThis.fetch.bind"} { + for _, want := range []string{`"query":"hello"`, `"X-Key":"test-key"`, `"example.com"`, "url.origin === __tapHeaderOrigin", "globalThis.fetch.bind"} { if !strings.Contains(program, want) { t.Fatalf("program missing %q", want) } } + if strings.Contains(program, "cross-origin fetch blocked") { + t.Fatal("Tap must not block cross-origin fetches") + } } func testClient(t *testing.T, content string) *Client {