From 951decdecb5297f97e2f04e6832d849cbcbd4fba Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 11 Aug 2026 11:01:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20enforce=20site=20exec?= =?UTF-8?q?ution=20origins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 +++++ README.md | 7 ++- agentbrowser/client.go | 40 ++++++++++-- agentbrowser/client_test.go | 26 ++++++++ script/parser.go | 62 ++++++++++++++++++- script/parser_test.go | 57 ++++++++++++++++- .../tap-web/references/script-development.md | 10 ++- tap.go | 22 +++++-- tap_test.go | 9 ++- 9 files changed, 232 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92efda8..7e10bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- Site script `domain` metadata is now validated and enforced as the exact + HTTPS execution origin. Cross-origin `fetch()` calls are blocked before + configured headers can be attached. + +### Added + +- Site scripts may declare a same-origin `startPath` when the domain root does + not provide a stable execution page. + +### 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..a95abf1 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ The script name comes from its path. For example, */ async function(args) { - // fetch(...) runs in agent-browser; metadata headers are merged into requests. + // fetch(...) must stay on https://mcp.exa.ai; metadata headers are merged + // only after Tap verifies that exact origin. } ``` @@ -127,6 +128,10 @@ 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. +`domain` is the exact HTTPS execution host, not a display label. Cross-origin +fetches are rejected. `startPath` is optional and must be a path on that domain; +use it when the domain root redirects away from the required origin. + ## Command map ```text 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..7d4724e 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: Tap cross-origin fetch blocked"}]' +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(), "Tap cross-origin fetch blocked") { + 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..57248ec 100644 --- a/script/parser.go +++ b/script/parser.go @@ -4,6 +4,8 @@ package script import ( "encoding/json" "fmt" + "net" + "net/url" "os" "regexp" "sort" @@ -34,8 +36,12 @@ type Meta struct { Name string `json:"-"` // Description is a short human-readable summary shown in `tap site list`. Description string `json:"description"` - // Domain is the primary API domain, used for display only. + // Domain is the exact HTTPS execution host. Site fetches are restricted to + // this origin before configured headers are injected. Domain string `json:"domain"` + // StartPath is an optional same-origin navigation target. It is useful when + // a domain root redirects away from the execution origin. + StartPath string `json:"startPath"` // Args declares the named arguments the script accepts. Each key maps to an // ArgDef describing whether it is required and what it represents. Args map[string]ArgDef `json:"args"` @@ -66,6 +72,9 @@ func Parse(content string) (*Script, error) { if err != nil { return nil, fmt.Errorf("parse meta: %w", err) } + if err := meta.validate(); err != nil { + return nil, fmt.Errorf("validate meta: %w", err) + } body, err := parseBody(content) if err != nil { @@ -107,6 +116,57 @@ func parseMeta(content string) (*Meta, error) { return &meta, nil } +func (m *Meta) validate() error { + domain := m.Domain + if domain == "" { + return fmt.Errorf("domain is required") + } + if domain != strings.ToLower(domain) || strings.TrimSpace(domain) != domain { + return fmt.Errorf("domain must be a lowercase hostname: %q", domain) + } + if net.ParseIP(domain) != nil { + return fmt.Errorf("domain must be a hostname, not an IP address: %q", domain) + } + if len(domain) > 253 { + return fmt.Errorf("domain exceeds 253 characters") + } + labels := strings.Split(domain, ".") + if len(labels) < 2 { + return fmt.Errorf("domain must be a fully qualified hostname: %q", domain) + } + for _, label := range labels { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("invalid domain label in %q", domain) + } + for _, char := range label { + if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { + return fmt.Errorf("invalid character in domain %q", domain) + } + } + } + if m.StartPath != "" { + start, err := url.Parse(m.StartPath) + if err != nil || !strings.HasPrefix(m.StartPath, "/") || start.IsAbs() || start.Host != "" || start.Fragment != "" { + return fmt.Errorf("startPath must be an absolute path on domain %q: %q", domain, m.StartPath) + } + } + return nil +} + +// Origin returns the exact origin available to site fetches. +func (m *Meta) Origin() string { + return "https://" + m.Domain +} + +// ExecutionURL returns the same-origin page Tap opens before evaluation. +func (m *Meta) ExecutionURL() string { + path := m.StartPath + if path == "" { + path = "/" + } + return m.Origin() + path +} + // ResolveHeaders copies Headers and interpolates ${ENV_VAR} values via os.Getenv. // Headers referencing unset environment variables are skipped entirely. func (m *Meta) ResolveHeaders() map[string]string { diff --git a/script/parser_test.go b/script/parser_test.go index 82e47dc..4e8d3ca 100644 --- a/script/parser_test.go +++ b/script/parser_test.go @@ -1,6 +1,7 @@ package script import ( + "strings" "testing" ) @@ -70,7 +71,8 @@ func TestParse_UnclosedMeta(t *testing.T) { func TestParse_NoBody(t *testing.T) { _, err := Parse(`/* @meta { - "description": "empty" + "description": "empty", + "domain": "example.com" } */`) if err == nil { @@ -78,6 +80,59 @@ func TestParse_NoBody(t *testing.T) { } } +func TestParse_RejectsInvalidDomain(t *testing.T) { + tests := []struct { + name string + domain string + }{ + {name: "missing"}, + {name: "scheme", domain: "https://example.com"}, + {name: "path", domain: "example.com/api"}, + {name: "port", domain: "example.com:8443"}, + {name: "uppercase", domain: "Example.com"}, + {name: "IP address", domain: "127.0.0.1"}, + {name: "single label", domain: "localhost"}, + {name: "leading hyphen", domain: "-api.example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := `/* @meta +{"description":"invalid domain","domain":"` + tt.domain + `","args":{}} +*/ +async function(args) { return args; }` + _, err := Parse(content) + if err == nil || !strings.Contains(err.Error(), "domain") { + t.Fatalf("Parse() error = %v, want domain validation error", err) + } + }) + } +} + +func TestParse_ValidatesStartPath(t *testing.T) { + valid := `/* @meta +{"description":"valid path","domain":"example.com","startPath":"/api/bootstrap?format=json","args":{}} +*/ +async function(args) { return args; }` + script, err := Parse(valid) + if err != nil { + t.Fatal(err) + } + if got := script.Meta.ExecutionURL(); got != "https://example.com/api/bootstrap?format=json" { + t.Fatalf("ExecutionURL() = %q", got) + } + + for _, path := range []string{"api", "https://other.example/api", "//other.example/api", "/api#fragment"} { + content := `/* @meta +{"description":"invalid path","domain":"example.com","startPath":"` + path + `","args":{}} +*/ +async function(args) { return args; }` + _, err := Parse(content) + if err == nil || !strings.Contains(err.Error(), "startPath") { + t.Fatalf("Parse(startPath=%q) error = %v", path, err) + } + } +} + func TestMeta_ResolveHeaders_AllSet(t *testing.T) { t.Setenv("API_KEY", "secret123") t.Setenv("USER_ID", "42") diff --git a/skills/tap-web/references/script-development.md b/skills/tap-web/references/script-development.md index 6b80dfa..3fc34ea 100644 --- a/skills/tap-web/references/script-development.md +++ b/skills/tap-web/references/script-development.md @@ -25,6 +25,7 @@ JS { "description": "Search example.com", "domain": "example.com", + "startPath": "/app", "args": { "query": {"required": true, "description": "Search query"} }, @@ -45,8 +46,13 @@ 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. +`domain` is required and defines the exact HTTPS execution origin. Every script +`fetch()` must resolve to that origin; Tap rejects cross-origin requests before +attaching metadata headers. `startPath` is optional and must stay on `domain`. +Use it when the domain root redirects away from the execution origin. + +Metadata headers are applied before navigation, merged into same-origin script +`fetch()` calls, then cleared so credentials do not linger in the shared session. ## Errors diff --git a/tap.go b/tap.go index e11e73f..9ab32d9 100644 --- a/tap.go +++ b/tap.go @@ -89,10 +89,7 @@ func (c *Client) RunScript(ctx context.Context, name string, args map[string]str defer cancel() } - navigationURL := "about:blank" - if s.Meta.Domain != "" { - navigationURL = "https://" + s.Meta.Domain - } + navigationURL := s.Meta.ExecutionURL() headers := s.Meta.ResolveHeaders() program, err := siteProgram(s, args, headers) if err != nil { @@ -124,17 +121,30 @@ func siteProgram(s *script.Script, args, headers map[string]string) (string, err if err != nil { return "", fmt.Errorf("marshal script headers: %w", err) } + originJSON, err := json.Marshal(s.Meta.Origin()) + if err != nil { + return "", fmt.Errorf("marshal script origin: %w", err) + } return fmt.Sprintf(`(async () => { const __tapArgs = %s; const __tapHeaders = %s; + const __tapOrigin = %s; + if (location.origin !== __tapOrigin) { + throw new Error("Tap execution origin mismatch: expected " + __tapOrigin + ", got " + location.origin); + } const __tapNativeFetch = globalThis.fetch.bind(globalThis); const fetch = (input, init = {}) => { - const headers = new Headers(init.headers || {}); + const url = new URL(input instanceof Request ? input.url : String(input), location.href); + if (url.origin !== __tapOrigin) { + throw new Error("Tap cross-origin fetch blocked: " + url.origin + " (declared origin: " + __tapOrigin + ")"); + } + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init.headers || {}).forEach((value, name) => headers.set(name, value)); 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, originJSON, s.Body), nil } // ListScripts returns all available scripts sorted by name. diff --git a/tap_test.go b/tap_test.go index 4d26d0d..1756333 100644 --- a/tap_test.go +++ b/tap_test.go @@ -71,7 +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"`, + `"https://example.com"`, + "Tap execution origin mismatch", + "Tap cross-origin fetch blocked", + "globalThis.fetch.bind", + } { if !strings.Contains(program, want) { t.Fatalf("program missing %q", want) } From efdc801283cbfaece45e10a3bce33e5ebc669b9a Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 11 Aug 2026 11:38:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20normalize=20bb-sites?= =?UTF-8?q?=20metadata=20during=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/bb-sites-compat.json | 23 ++ .github/scripts/sync-bb-sites.mjs | 239 ++++++++++++------ .github/scripts/sync-bb-sites.test.mjs | 120 +++++++++ .github/workflows/ci.yml | 11 + .github/workflows/sync-sites.yml | 5 +- CHANGELOG.md | 4 + .../tap-web/references/script-development.md | 5 +- 7 files changed, 324 insertions(+), 83 deletions(-) create mode 100644 .github/scripts/bb-sites-compat.json create mode 100644 .github/scripts/sync-bb-sites.test.mjs diff --git a/.github/scripts/bb-sites-compat.json b/.github/scripts/bb-sites-compat.json new file mode 100644 index 0000000..5c3e93a --- /dev/null +++ b/.github/scripts/bb-sites-compat.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "scripts": { + "hackernews/thread": { + "match": { + "domain": "news.ycombinator.com" + }, + "set": { + "domain": "hacker-news.firebaseio.com", + "startPath": "/v0/topstories.json" + } + }, + "hackernews/top": { + "match": { + "domain": "news.ycombinator.com" + }, + "set": { + "domain": "hacker-news.firebaseio.com", + "startPath": "/v0/topstories.json" + } + } + } +} diff --git a/.github/scripts/sync-bb-sites.mjs b/.github/scripts/sync-bb-sites.mjs index 6dbc2c0..5e0df0f 100644 --- a/.github/scripts/sync-bb-sites.mjs +++ b/.github/scripts/sync-bb-sites.mjs @@ -1,132 +1,209 @@ #!/usr/bin/env node /** - * Parse site scripts and POST them to the tap web API batch endpoint. - * Accepts one or more directories; later directories override earlier ones - * when script names collide (matching the CLI registry priority). + * Import bb-sites plus optional higher-priority local directories and POST the + * resulting catalog to Tap. Tap-specific metadata normalization is applied + * only to the first (bb-sites) directory; upstream source files stay intact. * - * Usage: node sync-bb-sites.mjs [ ...] + * Usage: node sync-bb-sites.mjs [--compat ] [ ...] * * Env: * TAP_SCRIPTS_SECRET - shared secret for X-Tap-Secret header * TAP_API_URL - batch endpoint (default: https://tap.vaayne.com/api/batch) */ -import { readdir, readFile } from "node:fs/promises" -import { join } from "node:path" -import { createHash } from "node:crypto" +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { pathToFileURL } from "node:url"; -const sitesDirs = process.argv.slice(2) -if (sitesDirs.length === 0) { - console.error("Usage: node sync-bb-sites.mjs [ ...]") - process.exit(1) +const metaPattern = /\/\*\s*@meta\s*\n([\s\S]*?)\*\//; +const allowedCompatibilityFields = new Set(["domain", "startPath"]); + +export function parseMeta(content) { + const match = content.match(metaPattern); + if (!match) return null; + try { + return JSON.parse(match[1]); + } catch { + return null; + } } -const secret = process.env.TAP_SCRIPTS_SECRET -if (!secret) { - console.error("TAP_SCRIPTS_SECRET is required") - process.exit(1) +export function applyMetadataOverride(content, override) { + for (const field of Object.keys(override)) { + if (!allowedCompatibilityFields.has(field)) { + throw new Error(`unsupported compatibility field: ${field}`); + } + } + + const match = content.match(metaPattern); + const meta = parseMeta(content); + if (!match || !meta) throw new Error("cannot override invalid @meta block"); + + const normalized = `/* @meta\n${JSON.stringify({ ...meta, ...override }, null, 2)}\n*/`; + return ( + content.slice(0, match.index) + + normalized + + content.slice(match.index + match[0].length) + ); } -const apiUrl = - process.env.TAP_API_URL || "https://tap.vaayne.com/api/batch" +export function applyCompatibilityPolicy(content, policy) { + const policyFields = Object.keys(policy); + if ( + policyFields.some((field) => field !== "match" && field !== "set") || + !policy.match || + !policy.set + ) { + throw new Error("compatibility policy requires only match and set objects"); + } + const meta = parseMeta(content); + if (!meta) throw new Error("cannot match invalid @meta block"); + for (const [field, expected] of Object.entries(policy.match)) { + if (!allowedCompatibilityFields.has(field)) { + throw new Error(`unsupported compatibility match field: ${field}`); + } + if (meta[field] !== expected) { + throw new Error( + `stale compatibility policy: expected ${field}=${JSON.stringify(expected)}, got ${JSON.stringify(meta[field])}`, + ); + } + } + return applyMetadataOverride(content, policy.set); +} -/** - * Parse the /* @meta ... * / block from a script file. - */ -function parseMeta(content) { - const match = content.match(/\/\*\s*@meta\s*\n([\s\S]*?)\*\//) - if (!match) return null - try { - return JSON.parse(match[1]) - } catch { - return null +export async function loadCompatibility(path) { + if (!path) return {}; + const manifest = JSON.parse(await readFile(path, "utf8")); + if ( + manifest.version !== 1 || + !manifest.scripts || + Array.isArray(manifest.scripts) + ) { + throw new Error(`invalid compatibility manifest: ${path}`); } + return manifest.scripts; } -/** - * Discover all .js files under dir organized as site/action.js. - */ -async function discoverScripts(dir) { - const scripts = [] - let entries +/** Discover all site/action.js scripts under a directory. */ +export async function discoverScripts(dir, compatibility = {}) { + const scripts = []; + let entries; try { - entries = await readdir(dir, { withFileTypes: true }) + entries = await readdir(dir, { withFileTypes: true }); } catch { - return scripts + return scripts; } for (const entry of entries) { - if (!entry.isDirectory()) continue - const site = entry.name - if (site.startsWith(".") || site === "node_modules") continue - - const siteDir = join(dir, site) - const files = await readdir(siteDir) + if (!entry.isDirectory()) continue; + const site = entry.name; + if (site.startsWith(".") || site === "node_modules") continue; + const siteDir = join(dir, site); + const files = await readdir(siteDir); for (const file of files) { - if (!file.endsWith(".js")) continue - const filePath = join(siteDir, file) - const content = await readFile(filePath, "utf-8") - const meta = parseMeta(content) - if (!meta || !meta.name) { - console.warn(`Skipping ${site}/${file}: no valid @meta block`) - continue + if (!file.endsWith(".js")) continue; + const filePath = join(siteDir, file); + let content = await readFile(filePath, "utf8"); + let meta = parseMeta(content); + if (!meta) { + console.warn(`Skipping ${site}/${file}: no valid @meta block`); + continue; } + const name = meta.name || `${site}/${basename(file, ".js")}`; - const hash = createHash("sha256").update(content).digest("hex") + if (compatibility[name]) { + content = applyCompatibilityPolicy(content, compatibility[name]); + meta = parseMeta(content); + } scripts.push({ - name: meta.name, + name, site, content, - hash, + hash: createHash("sha256").update(content).digest("hex"), description: meta.description || "", domain: meta.domain || "", args: JSON.stringify(meta.args || {}), capabilities: meta.capabilities || [], example: meta.example || "", readOnly: meta.readOnly ?? true, - }) + }); } } - return scripts + return scripts; } -async function main() { - const byName = new Map() - for (const dir of sitesDirs) { - const found = await discoverScripts(dir) - for (const s of found) { - byName.set(s.name, s) - } - console.log(`${dir}: ${found.length} scripts`) +function parseArgs(args) { + let compatibilityPath = ""; + if (args[0] === "--compat") { + if (!args[1]) throw new Error("--compat requires a manifest path"); + compatibilityPath = args[1]; + args = args.slice(2); } - const scripts = [...byName.values()] - if (scripts.length === 0) { - console.error("No scripts found") - process.exit(1) + if (args.length === 0) { + throw new Error( + "Usage: node sync-bb-sites.mjs [--compat ] [ ...]", + ); + } + return { compatibilityPath, sitesDirs: args }; +} + +export async function buildCatalog(sitesDirs, compatibility) { + const byName = new Map(); + for (const [index, dir] of sitesDirs.entries()) { + // Compatibility policy belongs to the imported bb-sites source. Later Tap + // directories remain authoritative overrides and are never rewritten. + const found = await discoverScripts(dir, index === 0 ? compatibility : {}); + for (const script of found) byName.set(script.name, script); + console.log(`${dir}: ${found.length} scripts`); + + if (index === 0) { + const imported = new Set(found.map((script) => script.name)); + const missing = Object.keys(compatibility).filter( + (name) => !imported.has(name), + ); + if (missing.length > 0) { + throw new Error( + `stale bb-sites compatibility entries: ${missing.join(", ")}`, + ); + } + } } + return [...byName.values()]; +} - console.log(`Total: ${scripts.length} scripts, posting to ${apiUrl}`) +export async function main(args = process.argv.slice(2)) { + const { compatibilityPath, sitesDirs } = parseArgs(args); + const secret = process.env.TAP_SCRIPTS_SECRET; + if (!secret) throw new Error("TAP_SCRIPTS_SECRET is required"); - const resp = await fetch(apiUrl, { + const compatibility = await loadCompatibility(compatibilityPath); + const scripts = await buildCatalog(sitesDirs, compatibility); + if (scripts.length === 0) throw new Error("No scripts found"); + + const apiUrl = process.env.TAP_API_URL || "https://tap.vaayne.com/api/batch"; + console.log(`Total: ${scripts.length} scripts, posting to ${apiUrl}`); + const response = await fetch(apiUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - "X-Tap-Secret": secret, - }, + headers: { "Content-Type": "application/json", "X-Tap-Secret": secret }, body: JSON.stringify({ scripts }), - }) - - const body = await resp.text() - if (!resp.ok) { - console.error(`Batch update failed: HTTP ${resp.status}`) - console.error(body) - process.exit(1) + }); + const body = await response.text(); + if (!response.ok) { + throw new Error(`Batch update failed: HTTP ${response.status}\n${body}`); } - - console.log(`Success: ${body}`) + console.log(`Success: ${body}`); } -main() +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/sync-bb-sites.test.mjs b/.github/scripts/sync-bb-sites.test.mjs new file mode 100644 index 0000000..d8c2658 --- /dev/null +++ b/.github/scripts/sync-bb-sites.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + applyMetadataOverride, + applyCompatibilityPolicy, + buildCatalog, + discoverScripts, + parseMeta, +} from "./sync-bb-sites.mjs"; + +const source = `/* @meta +{ + "name": "hackernews/top", + "description": "HN top stories", + "domain": "news.ycombinator.com", + "args": {"count": {"required": false}} +} +*/ +async function(args) { + return fetch('https://hacker-news.firebaseio.com/v0/topstories.json'); +} +`; + +test("metadata overrides preserve upstream code and unrelated fields", () => { + const normalized = applyMetadataOverride(source, { + domain: "hacker-news.firebaseio.com", + startPath: "/v0/topstories.json", + }); + const meta = parseMeta(normalized); + + assert.equal(meta.name, "hackernews/top"); + assert.equal(meta.description, "HN top stories"); + assert.deepEqual(meta.args, { count: { required: false } }); + assert.equal(meta.domain, "hacker-news.firebaseio.com"); + assert.equal(meta.startPath, "/v0/topstories.json"); + assert.match( + normalized, + /return fetch\('https:\/\/hacker-news\.firebaseio\.com/, + ); +}); + +test("metadata overrides reject fields outside the execution policy", () => { + assert.throws( + () => + applyMetadataOverride(source, { headers: { Authorization: "secret" } }), + /unsupported compatibility field: headers/, + ); +}); + +test("compatibility policy fails when upstream metadata changes", () => { + assert.throws( + () => + applyCompatibilityPolicy(source, { + match: { domain: "already-fixed.example" }, + set: { domain: "hacker-news.firebaseio.com" }, + }), + /stale compatibility policy: expected domain="already-fixed.example", got "news.ycombinator.com"/, + ); +}); + +test("Tap scripts derive their names from paths", async () => { + const root = await mkdtemp(join(tmpdir(), "tap-native-sites-")); + await mkdir(join(root, "example"), { recursive: true }); + await writeFile( + join(root, "example", "search.js"), + source.replace(' "name": "hackernews/top",\n', ""), + ); + + const scripts = await discoverScripts(root); + assert.equal(scripts.length, 1); + assert.equal(scripts[0].name, "example/search"); +}); + +test("compatibility applies only to imported bb-sites, not Tap overrides", async () => { + const root = await mkdtemp(join(tmpdir(), "tap-bb-sites-")); + const upstream = join(root, "bb-sites"); + const local = join(root, "sites"); + await mkdir(join(upstream, "hackernews"), { recursive: true }); + await mkdir(join(local, "hackernews"), { recursive: true }); + await writeFile(join(upstream, "hackernews", "top.js"), source); + const localSource = source.replace("HN top stories", "Tap override"); + await writeFile(join(local, "hackernews", "top.js"), localSource); + + const compatibility = { + "hackernews/top": { + match: { domain: "news.ycombinator.com" }, + set: { + domain: "hacker-news.firebaseio.com", + startPath: "/v0/topstories.json", + }, + }, + }; + const imported = await discoverScripts(upstream, compatibility); + assert.equal( + parseMeta(imported[0].content).domain, + "hacker-news.firebaseio.com", + ); + + const catalog = await buildCatalog([upstream, local], compatibility); + assert.equal(catalog.length, 1); + assert.equal(catalog[0].description, "Tap override"); + assert.equal(parseMeta(catalog[0].content).domain, "news.ycombinator.com"); +}); + +test("stale compatibility entries fail the import", async () => { + const root = await mkdtemp(join(tmpdir(), "tap-bb-sites-empty-")); + await assert.rejects( + buildCatalog([root], { + "missing/script": { + match: { domain: "old.example.com" }, + set: { domain: "new.example.com" }, + }, + }), + /stale bb-sites compatibility entries: missing\/script/, + ); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af74330..bd8e559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,17 @@ permissions: contents: read jobs: + catalog: + name: Catalog compatibility + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Test bb-sites compatibility layer + run: node --test .github/scripts/sync-bb-sites.test.mjs + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/sync-sites.yml b/.github/workflows/sync-sites.yml index 68399cf..83ae0d7 100644 --- a/.github/workflows/sync-sites.yml +++ b/.github/workflows/sync-sites.yml @@ -28,4 +28,7 @@ jobs: env: TAP_SCRIPTS_SECRET: ${{ secrets.TAP_SCRIPTS_SECRET }} TAP_API_URL: https://tap.vaayne.com/api/batch - run: node .github/scripts/sync-bb-sites.mjs bb-sites sites + run: >- + node .github/scripts/sync-bb-sites.mjs + --compat .github/scripts/bb-sites-compat.json + bb-sites sites diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e10bac..822ed43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Site scripts may declare a same-origin `startPath` when the domain root does not provide a stable execution page. +- bb-sites imports support guarded Tap-specific metadata normalization without + modifying or vendoring upstream scripts. ### Fixed - Structured agent-browser batch failures are now preserved instead of being reduced to an opaque `exit status 1` error. +- Tap-owned scripts without redundant metadata `name` fields are now included + by the catalog sync job using their path-derived names. ## [1.0.0] - 2026-08-11 diff --git a/skills/tap-web/references/script-development.md b/skills/tap-web/references/script-development.md index 3fc34ea..70d932d 100644 --- a/skills/tap-web/references/script-development.md +++ b/skills/tap-web/references/script-development.md @@ -63,4 +63,7 @@ return {error: 'Missing argument: query'}; return {error: 'HTTP 401', hint: 'Authenticate in the current agent-browser session'}; ``` -Scripts are contributed upstream to [bb-sites](https://github.com/epiral/bb-sites). +Tap imports scripts compatible with [bb-sites](https://github.com/epiral/bb-sites), +but Tap's strict execution-origin policy is separate from the bb-sites contract. +Tap-specific metadata normalization belongs in +`.github/scripts/bb-sites-compat.json`, not in upstream scripts. From b98a2e406501180355458d1f28b1b9ab2f08603c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 11 Aug 2026 11:40:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20separate?= =?UTF-8?q?=20catalog=20and=20execution=20domains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/bb-sites-compat.json | 4 +- .github/scripts/sync-bb-sites.mjs | 7 ++- .github/scripts/sync-bb-sites.test.mjs | 14 +++-- README.md | 9 ++- script/parser.go | 55 +++++++++++++------ script/parser_test.go | 15 ++++- .../tap-web/references/script-development.md | 3 +- 7 files changed, 73 insertions(+), 34 deletions(-) diff --git a/.github/scripts/bb-sites-compat.json b/.github/scripts/bb-sites-compat.json index 5c3e93a..eda19bf 100644 --- a/.github/scripts/bb-sites-compat.json +++ b/.github/scripts/bb-sites-compat.json @@ -6,7 +6,7 @@ "domain": "news.ycombinator.com" }, "set": { - "domain": "hacker-news.firebaseio.com", + "executionDomain": "hacker-news.firebaseio.com", "startPath": "/v0/topstories.json" } }, @@ -15,7 +15,7 @@ "domain": "news.ycombinator.com" }, "set": { - "domain": "hacker-news.firebaseio.com", + "executionDomain": "hacker-news.firebaseio.com", "startPath": "/v0/topstories.json" } } diff --git a/.github/scripts/sync-bb-sites.mjs b/.github/scripts/sync-bb-sites.mjs index 5e0df0f..9358603 100644 --- a/.github/scripts/sync-bb-sites.mjs +++ b/.github/scripts/sync-bb-sites.mjs @@ -17,7 +17,8 @@ import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; const metaPattern = /\/\*\s*@meta\s*\n([\s\S]*?)\*\//; -const allowedCompatibilityFields = new Set(["domain", "startPath"]); +const allowedMatchFields = new Set(["domain", "executionDomain", "startPath"]); +const allowedSetFields = new Set(["executionDomain", "startPath"]); export function parseMeta(content) { const match = content.match(metaPattern); @@ -31,7 +32,7 @@ export function parseMeta(content) { export function applyMetadataOverride(content, override) { for (const field of Object.keys(override)) { - if (!allowedCompatibilityFields.has(field)) { + if (!allowedSetFields.has(field)) { throw new Error(`unsupported compatibility field: ${field}`); } } @@ -60,7 +61,7 @@ export function applyCompatibilityPolicy(content, policy) { const meta = parseMeta(content); if (!meta) throw new Error("cannot match invalid @meta block"); for (const [field, expected] of Object.entries(policy.match)) { - if (!allowedCompatibilityFields.has(field)) { + if (!allowedMatchFields.has(field)) { throw new Error(`unsupported compatibility match field: ${field}`); } if (meta[field] !== expected) { diff --git a/.github/scripts/sync-bb-sites.test.mjs b/.github/scripts/sync-bb-sites.test.mjs index d8c2658..9bca767 100644 --- a/.github/scripts/sync-bb-sites.test.mjs +++ b/.github/scripts/sync-bb-sites.test.mjs @@ -27,7 +27,7 @@ async function(args) { test("metadata overrides preserve upstream code and unrelated fields", () => { const normalized = applyMetadataOverride(source, { - domain: "hacker-news.firebaseio.com", + executionDomain: "hacker-news.firebaseio.com", startPath: "/v0/topstories.json", }); const meta = parseMeta(normalized); @@ -35,7 +35,8 @@ test("metadata overrides preserve upstream code and unrelated fields", () => { assert.equal(meta.name, "hackernews/top"); assert.equal(meta.description, "HN top stories"); assert.deepEqual(meta.args, { count: { required: false } }); - assert.equal(meta.domain, "hacker-news.firebaseio.com"); + assert.equal(meta.domain, "news.ycombinator.com"); + assert.equal(meta.executionDomain, "hacker-news.firebaseio.com"); assert.equal(meta.startPath, "/v0/topstories.json"); assert.match( normalized, @@ -56,7 +57,7 @@ test("compatibility policy fails when upstream metadata changes", () => { () => applyCompatibilityPolicy(source, { match: { domain: "already-fixed.example" }, - set: { domain: "hacker-news.firebaseio.com" }, + set: { executionDomain: "hacker-news.firebaseio.com" }, }), /stale compatibility policy: expected domain="already-fixed.example", got "news.ycombinator.com"/, ); @@ -89,14 +90,15 @@ test("compatibility applies only to imported bb-sites, not Tap overrides", async "hackernews/top": { match: { domain: "news.ycombinator.com" }, set: { - domain: "hacker-news.firebaseio.com", + executionDomain: "hacker-news.firebaseio.com", startPath: "/v0/topstories.json", }, }, }; const imported = await discoverScripts(upstream, compatibility); + assert.equal(parseMeta(imported[0].content).domain, "news.ycombinator.com"); assert.equal( - parseMeta(imported[0].content).domain, + parseMeta(imported[0].content).executionDomain, "hacker-news.firebaseio.com", ); @@ -112,7 +114,7 @@ test("stale compatibility entries fail the import", async () => { buildCatalog([root], { "missing/script": { match: { domain: "old.example.com" }, - set: { domain: "new.example.com" }, + set: { executionDomain: "new.example.com" }, }, }), /stale bb-sites compatibility entries: missing\/script/, diff --git a/README.md b/README.md index a95abf1..e506804 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,12 @@ 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. -`domain` is the exact HTTPS execution host, not a display label. Cross-origin -fetches are rejected. `startPath` is optional and must be a path on that domain; -use it when the domain root redirects away from the required origin. +`domain` is Tap's default exact HTTPS execution host. Cross-origin fetches are +rejected. `startPath` is optional and must be a path on the execution domain; +use it when the root redirects away from the required origin. + +For imported catalogs whose `domain` has different semantics, Tap's ingestion +layer may add `executionDomain` while preserving the source metadata. ## Command map diff --git a/script/parser.go b/script/parser.go index 57248ec..e6203ae 100644 --- a/script/parser.go +++ b/script/parser.go @@ -36,9 +36,11 @@ type Meta struct { Name string `json:"-"` // Description is a short human-readable summary shown in `tap site list`. Description string `json:"description"` - // Domain is the exact HTTPS execution host. Site fetches are restricted to - // this origin before configured headers are injected. + // Domain is the script's catalog host and the default HTTPS execution host. Domain string `json:"domain"` + // ExecutionDomain is a Tap-specific execution host override used by source + // compatibility adapters. Site fetches are restricted to this exact origin. + ExecutionDomain string `json:"executionDomain"` // StartPath is an optional same-origin navigation target. It is useful when // a domain root redirects away from the execution origin. StartPath string `json:"startPath"` @@ -117,45 +119,64 @@ func parseMeta(content string) (*Meta, error) { } func (m *Meta) validate() error { - domain := m.Domain - if domain == "" { + if m.Domain == "" { return fmt.Errorf("domain is required") } + if err := validateDomain("domain", m.Domain); err != nil { + return err + } + if m.ExecutionDomain != "" { + if err := validateDomain("executionDomain", m.ExecutionDomain); err != nil { + return err + } + } + domain := m.effectiveExecutionDomain() + if m.StartPath != "" { + start, err := url.Parse(m.StartPath) + if err != nil || !strings.HasPrefix(m.StartPath, "/") || start.IsAbs() || start.Host != "" || start.Fragment != "" { + return fmt.Errorf("startPath must be an absolute path on execution domain %q: %q", domain, m.StartPath) + } + } + return nil +} + +func validateDomain(field, domain string) error { if domain != strings.ToLower(domain) || strings.TrimSpace(domain) != domain { - return fmt.Errorf("domain must be a lowercase hostname: %q", domain) + return fmt.Errorf("%s must be a lowercase hostname: %q", field, domain) } if net.ParseIP(domain) != nil { - return fmt.Errorf("domain must be a hostname, not an IP address: %q", domain) + return fmt.Errorf("%s must be a hostname, not an IP address: %q", field, domain) } if len(domain) > 253 { - return fmt.Errorf("domain exceeds 253 characters") + return fmt.Errorf("%s exceeds 253 characters", field) } labels := strings.Split(domain, ".") if len(labels) < 2 { - return fmt.Errorf("domain must be a fully qualified hostname: %q", domain) + return fmt.Errorf("%s must be a fully qualified hostname: %q", field, domain) } for _, label := range labels { if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return fmt.Errorf("invalid domain label in %q", domain) + return fmt.Errorf("invalid %s label in %q", field, domain) } for _, char := range label { if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { - return fmt.Errorf("invalid character in domain %q", domain) + return fmt.Errorf("invalid character in %s %q", field, domain) } } } - if m.StartPath != "" { - start, err := url.Parse(m.StartPath) - if err != nil || !strings.HasPrefix(m.StartPath, "/") || start.IsAbs() || start.Host != "" || start.Fragment != "" { - return fmt.Errorf("startPath must be an absolute path on domain %q: %q", domain, m.StartPath) - } - } return nil } +func (m *Meta) effectiveExecutionDomain() string { + if m.ExecutionDomain != "" { + return m.ExecutionDomain + } + return m.Domain +} + // Origin returns the exact origin available to site fetches. func (m *Meta) Origin() string { - return "https://" + m.Domain + return "https://" + m.effectiveExecutionDomain() } // ExecutionURL returns the same-origin page Tap opens before evaluation. diff --git a/script/parser_test.go b/script/parser_test.go index 4e8d3ca..7e07520 100644 --- a/script/parser_test.go +++ b/script/parser_test.go @@ -110,14 +110,14 @@ async function(args) { return args; }` func TestParse_ValidatesStartPath(t *testing.T) { valid := `/* @meta -{"description":"valid path","domain":"example.com","startPath":"/api/bootstrap?format=json","args":{}} +{"description":"valid path","domain":"example.com","executionDomain":"api.example.com","startPath":"/api/bootstrap?format=json","args":{}} */ async function(args) { return args; }` script, err := Parse(valid) if err != nil { t.Fatal(err) } - if got := script.Meta.ExecutionURL(); got != "https://example.com/api/bootstrap?format=json" { + if got := script.Meta.ExecutionURL(); got != "https://api.example.com/api/bootstrap?format=json" { t.Fatalf("ExecutionURL() = %q", got) } @@ -133,6 +133,17 @@ async function(args) { return args; }` } } +func TestParse_RejectsInvalidExecutionDomain(t *testing.T) { + content := `/* @meta +{"description":"invalid execution domain","domain":"example.com","executionDomain":"https://api.example.com","args":{}} +*/ +async function(args) { return args; }` + _, err := Parse(content) + if err == nil || !strings.Contains(err.Error(), "executionDomain") { + t.Fatalf("Parse() error = %v, want executionDomain validation error", err) + } +} + func TestMeta_ResolveHeaders_AllSet(t *testing.T) { t.Setenv("API_KEY", "secret123") t.Setenv("USER_ID", "42") diff --git a/skills/tap-web/references/script-development.md b/skills/tap-web/references/script-development.md index 70d932d..a33e1c4 100644 --- a/skills/tap-web/references/script-development.md +++ b/skills/tap-web/references/script-development.md @@ -66,4 +66,5 @@ return {error: 'HTTP 401', hint: 'Authenticate in the current agent-browser sess Tap imports scripts compatible with [bb-sites](https://github.com/epiral/bb-sites), but Tap's strict execution-origin policy is separate from the bb-sites contract. Tap-specific metadata normalization belongs in -`.github/scripts/bb-sites-compat.json`, not in upstream scripts. +`.github/scripts/bb-sites-compat.json`, not in upstream scripts. It may set +`executionDomain` without changing the imported catalog's `domain`. From 4adc47c6342e9ab2c78c44bff315fd852a7b840d Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 11 Aug 2026 11:53:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20keep=20bb-?= =?UTF-8?q?sites=20domain=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/bb-sites-compat.json | 23 -- .github/scripts/sync-bb-sites.mjs | 240 ++++++------------ .github/scripts/sync-bb-sites.test.mjs | 122 --------- .github/workflows/ci.yml | 11 - .github/workflows/sync-sites.yml | 5 +- CHANGELOG.md | 15 +- README.md | 14 +- agentbrowser/client_test.go | 4 +- script/parser.go | 85 +------ script/parser_test.go | 68 +---- .../tap-web/references/script-development.md | 17 +- tap.go | 29 ++- tap_test.go | 18 +- 13 files changed, 121 insertions(+), 530 deletions(-) delete mode 100644 .github/scripts/bb-sites-compat.json delete mode 100644 .github/scripts/sync-bb-sites.test.mjs diff --git a/.github/scripts/bb-sites-compat.json b/.github/scripts/bb-sites-compat.json deleted file mode 100644 index eda19bf..0000000 --- a/.github/scripts/bb-sites-compat.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": 1, - "scripts": { - "hackernews/thread": { - "match": { - "domain": "news.ycombinator.com" - }, - "set": { - "executionDomain": "hacker-news.firebaseio.com", - "startPath": "/v0/topstories.json" - } - }, - "hackernews/top": { - "match": { - "domain": "news.ycombinator.com" - }, - "set": { - "executionDomain": "hacker-news.firebaseio.com", - "startPath": "/v0/topstories.json" - } - } - } -} diff --git a/.github/scripts/sync-bb-sites.mjs b/.github/scripts/sync-bb-sites.mjs index 9358603..6dbc2c0 100644 --- a/.github/scripts/sync-bb-sites.mjs +++ b/.github/scripts/sync-bb-sites.mjs @@ -1,210 +1,132 @@ #!/usr/bin/env node /** - * Import bb-sites plus optional higher-priority local directories and POST the - * resulting catalog to Tap. Tap-specific metadata normalization is applied - * only to the first (bb-sites) directory; upstream source files stay intact. + * Parse site scripts and POST them to the tap web API batch endpoint. + * Accepts one or more directories; later directories override earlier ones + * when script names collide (matching the CLI registry priority). * - * Usage: node sync-bb-sites.mjs [--compat ] [ ...] + * Usage: node sync-bb-sites.mjs [ ...] * * Env: * TAP_SCRIPTS_SECRET - shared secret for X-Tap-Secret header * TAP_API_URL - batch endpoint (default: https://tap.vaayne.com/api/batch) */ -import { createHash } from "node:crypto"; -import { readFile, readdir } from "node:fs/promises"; -import { basename, join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { readdir, readFile } from "node:fs/promises" +import { join } from "node:path" +import { createHash } from "node:crypto" -const metaPattern = /\/\*\s*@meta\s*\n([\s\S]*?)\*\//; -const allowedMatchFields = new Set(["domain", "executionDomain", "startPath"]); -const allowedSetFields = new Set(["executionDomain", "startPath"]); - -export function parseMeta(content) { - const match = content.match(metaPattern); - if (!match) return null; - try { - return JSON.parse(match[1]); - } catch { - return null; - } +const sitesDirs = process.argv.slice(2) +if (sitesDirs.length === 0) { + console.error("Usage: node sync-bb-sites.mjs [ ...]") + process.exit(1) } -export function applyMetadataOverride(content, override) { - for (const field of Object.keys(override)) { - if (!allowedSetFields.has(field)) { - throw new Error(`unsupported compatibility field: ${field}`); - } - } - - const match = content.match(metaPattern); - const meta = parseMeta(content); - if (!match || !meta) throw new Error("cannot override invalid @meta block"); - - const normalized = `/* @meta\n${JSON.stringify({ ...meta, ...override }, null, 2)}\n*/`; - return ( - content.slice(0, match.index) + - normalized + - content.slice(match.index + match[0].length) - ); +const secret = process.env.TAP_SCRIPTS_SECRET +if (!secret) { + console.error("TAP_SCRIPTS_SECRET is required") + process.exit(1) } -export function applyCompatibilityPolicy(content, policy) { - const policyFields = Object.keys(policy); - if ( - policyFields.some((field) => field !== "match" && field !== "set") || - !policy.match || - !policy.set - ) { - throw new Error("compatibility policy requires only match and set objects"); - } - const meta = parseMeta(content); - if (!meta) throw new Error("cannot match invalid @meta block"); - for (const [field, expected] of Object.entries(policy.match)) { - if (!allowedMatchFields.has(field)) { - throw new Error(`unsupported compatibility match field: ${field}`); - } - if (meta[field] !== expected) { - throw new Error( - `stale compatibility policy: expected ${field}=${JSON.stringify(expected)}, got ${JSON.stringify(meta[field])}`, - ); - } - } - return applyMetadataOverride(content, policy.set); -} +const apiUrl = + process.env.TAP_API_URL || "https://tap.vaayne.com/api/batch" -export async function loadCompatibility(path) { - if (!path) return {}; - const manifest = JSON.parse(await readFile(path, "utf8")); - if ( - manifest.version !== 1 || - !manifest.scripts || - Array.isArray(manifest.scripts) - ) { - throw new Error(`invalid compatibility manifest: ${path}`); +/** + * Parse the /* @meta ... * / block from a script file. + */ +function parseMeta(content) { + const match = content.match(/\/\*\s*@meta\s*\n([\s\S]*?)\*\//) + if (!match) return null + try { + return JSON.parse(match[1]) + } catch { + return null } - return manifest.scripts; } -/** Discover all site/action.js scripts under a directory. */ -export async function discoverScripts(dir, compatibility = {}) { - const scripts = []; - let entries; +/** + * Discover all .js files under dir organized as site/action.js. + */ +async function discoverScripts(dir) { + const scripts = [] + let entries try { - entries = await readdir(dir, { withFileTypes: true }); + entries = await readdir(dir, { withFileTypes: true }) } catch { - return scripts; + return scripts } for (const entry of entries) { - if (!entry.isDirectory()) continue; - const site = entry.name; - if (site.startsWith(".") || site === "node_modules") continue; + if (!entry.isDirectory()) continue + const site = entry.name + if (site.startsWith(".") || site === "node_modules") continue + + const siteDir = join(dir, site) + const files = await readdir(siteDir) - const siteDir = join(dir, site); - const files = await readdir(siteDir); for (const file of files) { - if (!file.endsWith(".js")) continue; - const filePath = join(siteDir, file); - let content = await readFile(filePath, "utf8"); - let meta = parseMeta(content); - if (!meta) { - console.warn(`Skipping ${site}/${file}: no valid @meta block`); - continue; + if (!file.endsWith(".js")) continue + const filePath = join(siteDir, file) + const content = await readFile(filePath, "utf-8") + const meta = parseMeta(content) + if (!meta || !meta.name) { + console.warn(`Skipping ${site}/${file}: no valid @meta block`) + continue } - const name = meta.name || `${site}/${basename(file, ".js")}`; - if (compatibility[name]) { - content = applyCompatibilityPolicy(content, compatibility[name]); - meta = parseMeta(content); - } + const hash = createHash("sha256").update(content).digest("hex") scripts.push({ - name, + name: meta.name, site, content, - hash: createHash("sha256").update(content).digest("hex"), + hash, description: meta.description || "", domain: meta.domain || "", args: JSON.stringify(meta.args || {}), capabilities: meta.capabilities || [], example: meta.example || "", readOnly: meta.readOnly ?? true, - }); + }) } } - return scripts; -} - -function parseArgs(args) { - let compatibilityPath = ""; - if (args[0] === "--compat") { - if (!args[1]) throw new Error("--compat requires a manifest path"); - compatibilityPath = args[1]; - args = args.slice(2); - } - if (args.length === 0) { - throw new Error( - "Usage: node sync-bb-sites.mjs [--compat ] [ ...]", - ); - } - return { compatibilityPath, sitesDirs: args }; + return scripts } -export async function buildCatalog(sitesDirs, compatibility) { - const byName = new Map(); - for (const [index, dir] of sitesDirs.entries()) { - // Compatibility policy belongs to the imported bb-sites source. Later Tap - // directories remain authoritative overrides and are never rewritten. - const found = await discoverScripts(dir, index === 0 ? compatibility : {}); - for (const script of found) byName.set(script.name, script); - console.log(`${dir}: ${found.length} scripts`); - - if (index === 0) { - const imported = new Set(found.map((script) => script.name)); - const missing = Object.keys(compatibility).filter( - (name) => !imported.has(name), - ); - if (missing.length > 0) { - throw new Error( - `stale bb-sites compatibility entries: ${missing.join(", ")}`, - ); - } +async function main() { + const byName = new Map() + for (const dir of sitesDirs) { + const found = await discoverScripts(dir) + for (const s of found) { + byName.set(s.name, s) } + console.log(`${dir}: ${found.length} scripts`) + } + const scripts = [...byName.values()] + if (scripts.length === 0) { + console.error("No scripts found") + process.exit(1) } - return [...byName.values()]; -} - -export async function main(args = process.argv.slice(2)) { - const { compatibilityPath, sitesDirs } = parseArgs(args); - const secret = process.env.TAP_SCRIPTS_SECRET; - if (!secret) throw new Error("TAP_SCRIPTS_SECRET is required"); - const compatibility = await loadCompatibility(compatibilityPath); - const scripts = await buildCatalog(sitesDirs, compatibility); - if (scripts.length === 0) throw new Error("No scripts found"); + console.log(`Total: ${scripts.length} scripts, posting to ${apiUrl}`) - const apiUrl = process.env.TAP_API_URL || "https://tap.vaayne.com/api/batch"; - console.log(`Total: ${scripts.length} scripts, posting to ${apiUrl}`); - const response = await fetch(apiUrl, { + const resp = await fetch(apiUrl, { method: "POST", - headers: { "Content-Type": "application/json", "X-Tap-Secret": secret }, + headers: { + "Content-Type": "application/json", + "X-Tap-Secret": secret, + }, body: JSON.stringify({ scripts }), - }); - const body = await response.text(); - if (!response.ok) { - throw new Error(`Batch update failed: HTTP ${response.status}\n${body}`); + }) + + const body = await resp.text() + if (!resp.ok) { + console.error(`Batch update failed: HTTP ${resp.status}`) + console.error(body) + process.exit(1) } - console.log(`Success: ${body}`); -} -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - console.error(error.message); - process.exitCode = 1; - }); + console.log(`Success: ${body}`) } + +main() diff --git a/.github/scripts/sync-bb-sites.test.mjs b/.github/scripts/sync-bb-sites.test.mjs deleted file mode 100644 index 9bca767..0000000 --- a/.github/scripts/sync-bb-sites.test.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; - -import { - applyMetadataOverride, - applyCompatibilityPolicy, - buildCatalog, - discoverScripts, - parseMeta, -} from "./sync-bb-sites.mjs"; - -const source = `/* @meta -{ - "name": "hackernews/top", - "description": "HN top stories", - "domain": "news.ycombinator.com", - "args": {"count": {"required": false}} -} -*/ -async function(args) { - return fetch('https://hacker-news.firebaseio.com/v0/topstories.json'); -} -`; - -test("metadata overrides preserve upstream code and unrelated fields", () => { - const normalized = applyMetadataOverride(source, { - executionDomain: "hacker-news.firebaseio.com", - startPath: "/v0/topstories.json", - }); - const meta = parseMeta(normalized); - - assert.equal(meta.name, "hackernews/top"); - assert.equal(meta.description, "HN top stories"); - assert.deepEqual(meta.args, { count: { required: false } }); - assert.equal(meta.domain, "news.ycombinator.com"); - assert.equal(meta.executionDomain, "hacker-news.firebaseio.com"); - assert.equal(meta.startPath, "/v0/topstories.json"); - assert.match( - normalized, - /return fetch\('https:\/\/hacker-news\.firebaseio\.com/, - ); -}); - -test("metadata overrides reject fields outside the execution policy", () => { - assert.throws( - () => - applyMetadataOverride(source, { headers: { Authorization: "secret" } }), - /unsupported compatibility field: headers/, - ); -}); - -test("compatibility policy fails when upstream metadata changes", () => { - assert.throws( - () => - applyCompatibilityPolicy(source, { - match: { domain: "already-fixed.example" }, - set: { executionDomain: "hacker-news.firebaseio.com" }, - }), - /stale compatibility policy: expected domain="already-fixed.example", got "news.ycombinator.com"/, - ); -}); - -test("Tap scripts derive their names from paths", async () => { - const root = await mkdtemp(join(tmpdir(), "tap-native-sites-")); - await mkdir(join(root, "example"), { recursive: true }); - await writeFile( - join(root, "example", "search.js"), - source.replace(' "name": "hackernews/top",\n', ""), - ); - - const scripts = await discoverScripts(root); - assert.equal(scripts.length, 1); - assert.equal(scripts[0].name, "example/search"); -}); - -test("compatibility applies only to imported bb-sites, not Tap overrides", async () => { - const root = await mkdtemp(join(tmpdir(), "tap-bb-sites-")); - const upstream = join(root, "bb-sites"); - const local = join(root, "sites"); - await mkdir(join(upstream, "hackernews"), { recursive: true }); - await mkdir(join(local, "hackernews"), { recursive: true }); - await writeFile(join(upstream, "hackernews", "top.js"), source); - const localSource = source.replace("HN top stories", "Tap override"); - await writeFile(join(local, "hackernews", "top.js"), localSource); - - const compatibility = { - "hackernews/top": { - match: { domain: "news.ycombinator.com" }, - set: { - executionDomain: "hacker-news.firebaseio.com", - startPath: "/v0/topstories.json", - }, - }, - }; - const imported = await discoverScripts(upstream, compatibility); - assert.equal(parseMeta(imported[0].content).domain, "news.ycombinator.com"); - assert.equal( - parseMeta(imported[0].content).executionDomain, - "hacker-news.firebaseio.com", - ); - - const catalog = await buildCatalog([upstream, local], compatibility); - assert.equal(catalog.length, 1); - assert.equal(catalog[0].description, "Tap override"); - assert.equal(parseMeta(catalog[0].content).domain, "news.ycombinator.com"); -}); - -test("stale compatibility entries fail the import", async () => { - const root = await mkdtemp(join(tmpdir(), "tap-bb-sites-empty-")); - await assert.rejects( - buildCatalog([root], { - "missing/script": { - match: { domain: "old.example.com" }, - set: { executionDomain: "new.example.com" }, - }, - }), - /stale bb-sites compatibility entries: missing\/script/, - ); -}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd8e559..af74330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,17 +10,6 @@ permissions: contents: read jobs: - catalog: - name: Catalog compatibility - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "24" - - name: Test bb-sites compatibility layer - run: node --test .github/scripts/sync-bb-sites.test.mjs - lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/sync-sites.yml b/.github/workflows/sync-sites.yml index 83ae0d7..68399cf 100644 --- a/.github/workflows/sync-sites.yml +++ b/.github/workflows/sync-sites.yml @@ -28,7 +28,4 @@ jobs: env: TAP_SCRIPTS_SECRET: ${{ secrets.TAP_SCRIPTS_SECRET }} TAP_API_URL: https://tap.vaayne.com/api/batch - run: >- - node .github/scripts/sync-bb-sites.mjs - --compat .github/scripts/bb-sites-compat.json - bb-sites sites + run: node .github/scripts/sync-bb-sites.mjs bb-sites sites diff --git a/CHANGELOG.md b/CHANGELOG.md index 822ed43..d974794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,23 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed -- Site script `domain` metadata is now validated and enforced as the exact - HTTPS execution origin. Cross-origin `fetch()` calls are blocked before - configured headers can be attached. - -### Added - -- Site scripts may declare a same-origin `startPath` when the domain root does - not provide a stable execution page. -- bb-sites imports support guarded Tap-specific metadata normalization without - modifying or vendoring upstream scripts. +- 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. -- Tap-owned scripts without redundant metadata `name` fields are now included - by the catalog sync job using their path-derived names. ## [1.0.0] - 2026-08-11 diff --git a/README.md b/README.md index e506804..33011fa 100644 --- a/README.md +++ b/README.md @@ -119,21 +119,15 @@ The script name comes from its path. For example, */ async function(args) { - // fetch(...) must stay on https://mcp.exa.ai; metadata headers are merged - // only after Tap verifies that exact origin. + // 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. - -`domain` is Tap's default exact HTTPS execution host. Cross-origin fetches are -rejected. `startPath` is optional and must be a path on the execution domain; -use it when the root redirects away from the required origin. - -For imported catalogs whose `domain` has different semantics, Tap's ingestion -layer may add `executionDomain` while preserving the source metadata. +injected into same-domain script fetches and are never installed as +browser-wide navigation headers. ## Command map diff --git a/agentbrowser/client_test.go b/agentbrowser/client_test.go index 7d4724e..4f8716d 100644 --- a/agentbrowser/client_test.go +++ b/agentbrowser/client_test.go @@ -215,7 +215,7 @@ func TestOpenAndEvalReturnsStructuredBatchFailure(t *testing.T) { 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: Tap cross-origin fetch blocked"}]' +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 { @@ -226,7 +226,7 @@ exit 1 if err == nil { t.Fatal("expected batch failure") } - if !strings.Contains(err.Error(), "Tap cross-origin fetch blocked") { + 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") { diff --git a/script/parser.go b/script/parser.go index e6203ae..9f73272 100644 --- a/script/parser.go +++ b/script/parser.go @@ -4,8 +4,6 @@ package script import ( "encoding/json" "fmt" - "net" - "net/url" "os" "regexp" "sort" @@ -36,14 +34,8 @@ type Meta struct { Name string `json:"-"` // Description is a short human-readable summary shown in `tap site list`. Description string `json:"description"` - // Domain is the script's catalog host and the default HTTPS execution host. + // Domain is the primary API domain, used for display only. Domain string `json:"domain"` - // ExecutionDomain is a Tap-specific execution host override used by source - // compatibility adapters. Site fetches are restricted to this exact origin. - ExecutionDomain string `json:"executionDomain"` - // StartPath is an optional same-origin navigation target. It is useful when - // a domain root redirects away from the execution origin. - StartPath string `json:"startPath"` // Args declares the named arguments the script accepts. Each key maps to an // ArgDef describing whether it is required and what it represents. Args map[string]ArgDef `json:"args"` @@ -53,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"` @@ -74,9 +66,6 @@ func Parse(content string) (*Script, error) { if err != nil { return nil, fmt.Errorf("parse meta: %w", err) } - if err := meta.validate(); err != nil { - return nil, fmt.Errorf("validate meta: %w", err) - } body, err := parseBody(content) if err != nil { @@ -118,76 +107,6 @@ func parseMeta(content string) (*Meta, error) { return &meta, nil } -func (m *Meta) validate() error { - if m.Domain == "" { - return fmt.Errorf("domain is required") - } - if err := validateDomain("domain", m.Domain); err != nil { - return err - } - if m.ExecutionDomain != "" { - if err := validateDomain("executionDomain", m.ExecutionDomain); err != nil { - return err - } - } - domain := m.effectiveExecutionDomain() - if m.StartPath != "" { - start, err := url.Parse(m.StartPath) - if err != nil || !strings.HasPrefix(m.StartPath, "/") || start.IsAbs() || start.Host != "" || start.Fragment != "" { - return fmt.Errorf("startPath must be an absolute path on execution domain %q: %q", domain, m.StartPath) - } - } - return nil -} - -func validateDomain(field, domain string) error { - if domain != strings.ToLower(domain) || strings.TrimSpace(domain) != domain { - return fmt.Errorf("%s must be a lowercase hostname: %q", field, domain) - } - if net.ParseIP(domain) != nil { - return fmt.Errorf("%s must be a hostname, not an IP address: %q", field, domain) - } - if len(domain) > 253 { - return fmt.Errorf("%s exceeds 253 characters", field) - } - labels := strings.Split(domain, ".") - if len(labels) < 2 { - return fmt.Errorf("%s must be a fully qualified hostname: %q", field, domain) - } - for _, label := range labels { - if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return fmt.Errorf("invalid %s label in %q", field, domain) - } - for _, char := range label { - if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { - return fmt.Errorf("invalid character in %s %q", field, domain) - } - } - } - return nil -} - -func (m *Meta) effectiveExecutionDomain() string { - if m.ExecutionDomain != "" { - return m.ExecutionDomain - } - return m.Domain -} - -// Origin returns the exact origin available to site fetches. -func (m *Meta) Origin() string { - return "https://" + m.effectiveExecutionDomain() -} - -// ExecutionURL returns the same-origin page Tap opens before evaluation. -func (m *Meta) ExecutionURL() string { - path := m.StartPath - if path == "" { - path = "/" - } - return m.Origin() + path -} - // ResolveHeaders copies Headers and interpolates ${ENV_VAR} values via os.Getenv. // Headers referencing unset environment variables are skipped entirely. func (m *Meta) ResolveHeaders() map[string]string { diff --git a/script/parser_test.go b/script/parser_test.go index 7e07520..82e47dc 100644 --- a/script/parser_test.go +++ b/script/parser_test.go @@ -1,7 +1,6 @@ package script import ( - "strings" "testing" ) @@ -71,8 +70,7 @@ func TestParse_UnclosedMeta(t *testing.T) { func TestParse_NoBody(t *testing.T) { _, err := Parse(`/* @meta { - "description": "empty", - "domain": "example.com" + "description": "empty" } */`) if err == nil { @@ -80,70 +78,6 @@ func TestParse_NoBody(t *testing.T) { } } -func TestParse_RejectsInvalidDomain(t *testing.T) { - tests := []struct { - name string - domain string - }{ - {name: "missing"}, - {name: "scheme", domain: "https://example.com"}, - {name: "path", domain: "example.com/api"}, - {name: "port", domain: "example.com:8443"}, - {name: "uppercase", domain: "Example.com"}, - {name: "IP address", domain: "127.0.0.1"}, - {name: "single label", domain: "localhost"}, - {name: "leading hyphen", domain: "-api.example.com"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - content := `/* @meta -{"description":"invalid domain","domain":"` + tt.domain + `","args":{}} -*/ -async function(args) { return args; }` - _, err := Parse(content) - if err == nil || !strings.Contains(err.Error(), "domain") { - t.Fatalf("Parse() error = %v, want domain validation error", err) - } - }) - } -} - -func TestParse_ValidatesStartPath(t *testing.T) { - valid := `/* @meta -{"description":"valid path","domain":"example.com","executionDomain":"api.example.com","startPath":"/api/bootstrap?format=json","args":{}} -*/ -async function(args) { return args; }` - script, err := Parse(valid) - if err != nil { - t.Fatal(err) - } - if got := script.Meta.ExecutionURL(); got != "https://api.example.com/api/bootstrap?format=json" { - t.Fatalf("ExecutionURL() = %q", got) - } - - for _, path := range []string{"api", "https://other.example/api", "//other.example/api", "/api#fragment"} { - content := `/* @meta -{"description":"invalid path","domain":"example.com","startPath":"` + path + `","args":{}} -*/ -async function(args) { return args; }` - _, err := Parse(content) - if err == nil || !strings.Contains(err.Error(), "startPath") { - t.Fatalf("Parse(startPath=%q) error = %v", path, err) - } - } -} - -func TestParse_RejectsInvalidExecutionDomain(t *testing.T) { - content := `/* @meta -{"description":"invalid execution domain","domain":"example.com","executionDomain":"https://api.example.com","args":{}} -*/ -async function(args) { return args; }` - _, err := Parse(content) - if err == nil || !strings.Contains(err.Error(), "executionDomain") { - t.Fatalf("Parse() error = %v, want executionDomain validation error", err) - } -} - func TestMeta_ResolveHeaders_AllSet(t *testing.T) { t.Setenv("API_KEY", "secret123") t.Setenv("USER_ID", "42") diff --git a/skills/tap-web/references/script-development.md b/skills/tap-web/references/script-development.md index a33e1c4..888f13b 100644 --- a/skills/tap-web/references/script-development.md +++ b/skills/tap-web/references/script-development.md @@ -25,7 +25,6 @@ JS { "description": "Search example.com", "domain": "example.com", - "startPath": "/app", "args": { "query": {"required": true, "description": "Search query"} }, @@ -46,13 +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. -`domain` is required and defines the exact HTTPS execution origin. Every script -`fetch()` must resolve to that origin; Tap rejects cross-origin requests before -attaching metadata headers. `startPath` is optional and must stay on `domain`. -Use it when the domain root redirects away from the execution origin. - -Metadata headers are applied before navigation, merged into same-origin script -`fetch()` calls, 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 @@ -63,8 +58,4 @@ return {error: 'Missing argument: query'}; return {error: 'HTTP 401', hint: 'Authenticate in the current agent-browser session'}; ``` -Tap imports scripts compatible with [bb-sites](https://github.com/epiral/bb-sites), -but Tap's strict execution-origin policy is separate from the bb-sites contract. -Tap-specific metadata normalization belongs in -`.github/scripts/bb-sites-compat.json`, not in upstream scripts. It may set -`executionDomain` without changing the imported catalog's `domain`. +Scripts are contributed upstream to [bb-sites](https://github.com/epiral/bb-sites). diff --git a/tap.go b/tap.go index 9ab32d9..abbf4bd 100644 --- a/tap.go +++ b/tap.go @@ -89,13 +89,19 @@ func (c *Client) RunScript(ctx context.Context, name string, args map[string]str defer cancel() } - navigationURL := s.Meta.ExecutionURL() + navigationURL := "about:blank" + if s.Meta.Domain != "" { + navigationURL = "https://" + s.Meta.Domain + } headers := s.Meta.ResolveHeaders() program, err := siteProgram(s, args, headers) 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 @@ -121,30 +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) } - originJSON, err := json.Marshal(s.Meta.Origin()) + domainJSON, err := json.Marshal(s.Meta.Domain) if err != nil { - return "", fmt.Errorf("marshal script origin: %w", err) + return "", fmt.Errorf("marshal script domain: %w", err) } return fmt.Sprintf(`(async () => { const __tapArgs = %s; const __tapHeaders = %s; - const __tapOrigin = %s; - if (location.origin !== __tapOrigin) { - throw new Error("Tap execution origin mismatch: expected " + __tapOrigin + ", got " + location.origin); - } + const __tapDomain = %s; + const __tapHeaderOrigin = __tapDomain ? "https://" + __tapDomain : null; const __tapNativeFetch = globalThis.fetch.bind(globalThis); const fetch = (input, init = {}) => { const url = new URL(input instanceof Request ? input.url : String(input), location.href); - if (url.origin !== __tapOrigin) { - throw new Error("Tap cross-origin fetch blocked: " + url.origin + " (declared origin: " + __tapOrigin + ")"); - } const headers = new Headers(input instanceof Request ? input.headers : undefined); new Headers(init.headers || {}).forEach((value, name) => headers.set(name, value)); - for (const [name, value] of Object.entries(__tapHeaders)) 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, originJSON, 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 1756333..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,18 +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"`, - `"https://example.com"`, - "Tap execution origin mismatch", - "Tap cross-origin fetch blocked", - "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 {