Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 35 additions & 5 deletions agentbrowser/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
26 changes: 26 additions & 0 deletions agentbrowser/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion script/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
5 changes: 3 additions & 2 deletions skills/tap-web/references/script-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 17 additions & 4 deletions tap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions tap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -63,19 +63,22 @@ 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])
if err != nil {
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 {
Expand Down
Loading