unbrowse 3.0.2 → 3.1.0-experiments.5e7a7bb

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -497,7 +497,8 @@ async function apiRequest<T = unknown>(
497
497
  throw new Error("ToS update required. Restart unbrowse to accept new terms.");
498
498
  }
499
499
 
500
- // Handle x402 payment required — surface payment terms to the caller
500
+
501
+ // Handle x402 payment required — attempt lobster pay-and-retry before surfacing
501
502
  if (res.status === 402) {
502
503
  const paymentRequired = res.headers.get("PAYMENT-REQUIRED");
503
504
  const legacyPaymentTerms = res.headers.get("X-Payment-Required");
@@ -506,6 +507,29 @@ async function apiRequest<T = unknown>(
506
507
  : legacyPaymentTerms
507
508
  ? JSON.parse(legacyPaymentTerms)
508
509
  : (data as Record<string, unknown>).terms;
510
+
511
+ // Try lobster.cash automatic payment before throwing
512
+ try {
513
+ const { isLobsterAvailable, payAndRetry } = await import("../payments/lobster-pay.js");
514
+ if (isLobsterAvailable()) {
515
+ const fullUrl = `${API_URL}${path}`;
516
+ const paidResult = await payAndRetry<T>(fullUrl, {
517
+ body,
518
+ headers: {
519
+ "Content-Type": "application/json",
520
+ "Accept-Encoding": "gzip, deflate",
521
+ ...releaseAttestationHeaders,
522
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
523
+ },
524
+ });
525
+ if (paidResult) {
526
+ return { data: paidResult.data, headers: new Headers() };
527
+ }
528
+ }
529
+ } catch (payErr) {
530
+ console.warn(`[x402] lobster pay-and-retry failed: ${(payErr as Error).message}`);
531
+ }
532
+
509
533
  const err = new Error(`Payment required: ${(data as Record<string, unknown>).error ?? "This skill requires payment"}`);
510
534
  (err as Error & { x402: boolean; terms: unknown; status: number }).x402 = true;
511
535
  (err as Error & { terms: unknown }).terms = terms;
@@ -528,18 +552,10 @@ async function api<T = unknown>(method: string, path: string, body?: unknown, op
528
552
 
529
553
  // --- Install attribution ---
530
554
 
531
- function parseInstallAttribution(): { install_attribution?: Record<string, string>; landing_token?: string } {
532
- const result: { install_attribution?: Record<string, string>; landing_token?: string } = {};
533
- const b64 = process.env.UNBROWSE_ATTRIBUTION_B64;
534
- if (b64) {
535
- try {
536
- const decoded = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
537
- if (decoded && typeof decoded === "object") result.install_attribution = decoded;
538
- } catch { /* malformed — ignore */ }
539
- }
555
+ function parseInstallAttribution(): { landing_token?: string } {
540
556
  const token = process.env.UNBROWSE_LANDING_TOKEN;
541
- if (token && token.length < 2048) result.landing_token = token;
542
- return result;
557
+ if (token && token.length < 2048) return { landing_token: token };
558
+ return {};
543
559
  }
544
560
 
545
561
  // --- ToS acceptance ---
@@ -1882,28 +1882,40 @@ export async function executeEndpoint(
1882
1882
  wallet_configured: !!wallet.wallet_address,
1883
1883
  });
1884
1884
  if (gate.status === "payment_required" || gate.status === "wallet_not_configured" || gate.status === "insufficient_balance") {
1885
- const trace: ExecutionTrace = stampTrace({
1886
- trace_id: nanoid(),
1887
- skill_id: skill.skill_id,
1888
- endpoint_id: endpoint.endpoint_id,
1889
- started_at: new Date().toISOString(),
1890
- completed_at: new Date().toISOString(),
1891
- success: false,
1892
- status_code: 402,
1893
- error: "payment_required",
1894
- });
1895
- return {
1896
- trace,
1897
- result: {
1885
+ // If lobster wallet is available, let execution proceed —
1886
+ // the client-level apiRequest will handle 402 pay-and-retry automatically.
1887
+ let lobsterAvailable = false;
1888
+ try {
1889
+ const { isLobsterAvailable } = await import("../payments/lobster-pay.js");
1890
+ lobsterAvailable = isLobsterAvailable();
1891
+ } catch {}
1892
+
1893
+ if (lobsterAvailable && gate.status === "payment_required") {
1894
+ console.log(`[payment] ${skill.skill_id}: lobster available — proceeding with auto-pay`);
1895
+ } else {
1896
+ const trace: ExecutionTrace = stampTrace({
1897
+ trace_id: nanoid(),
1898
+ skill_id: skill.skill_id,
1899
+ endpoint_id: endpoint.endpoint_id,
1900
+ started_at: new Date().toISOString(),
1901
+ completed_at: new Date().toISOString(),
1902
+ success: false,
1903
+ status_code: 402,
1898
1904
  error: "payment_required",
1899
- price_usd: gate.requirement?.amount,
1900
- payment_status: gate.status,
1901
- message: gate.message,
1902
- wallet_provider: wallet.wallet_provider ?? "lobster.cash",
1903
- wallet_address: wallet.wallet_address,
1904
- indexing_fallback_available: true,
1905
- },
1906
- };
1905
+ });
1906
+ return {
1907
+ trace,
1908
+ result: {
1909
+ error: "payment_required",
1910
+ price_usd: gate.requirement?.amount,
1911
+ payment_status: gate.status,
1912
+ message: gate.message,
1913
+ wallet_provider: wallet.wallet_provider ?? "lobster.cash",
1914
+ wallet_address: wallet.wallet_address,
1915
+ indexing_fallback_available: true,
1916
+ },
1917
+ };
1918
+ }
1907
1919
  }
1908
1920
  }
1909
1921
 
@@ -2005,6 +2017,16 @@ export async function executeEndpoint(
2005
2017
  const epDomain = (() => { try { return new URL(endpoint.url_template).hostname; } catch { return skill.domain; } })();
2006
2018
  await reloadExecutionAuthState(skill, epDomain, authHeaders, cookies);
2007
2019
 
2020
+ // If endpoint has auth_tokens bindings and vault didn't provide the needed headers,
2021
+ // resolve them from the page source (meta tags, inline scripts, JS bundles)
2022
+ if (endpoint.auth_tokens?.length && Object.keys(authHeaders).length === 0) {
2023
+ try {
2024
+ const { resolveAuthTokens } = await import("./token-resolver.js");
2025
+ const resolved = await resolveAuthTokens(endpoint, cookies, authHeaders);
2026
+ Object.assign(authHeaders, resolved);
2027
+ } catch { /* token resolution is best-effort */ }
2028
+ }
2029
+
2008
2030
  log("exec", `endpoint ${endpoint.endpoint_id}: cookies=${cookies.length} authHeaders=${Object.keys(authHeaders).length} hasAuth=${cookies.length > 0 || Object.keys(authHeaders).length > 0}`);
2009
2031
 
2010
2032
  // BUG-006: Merge path_params defaults — user params override captured defaults
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Token resolver — resolves auth_tokens bindings at execute time.
3
+ *
4
+ * Given an endpoint with auth_tokens, loads the trigger page via Kuri,
5
+ * extracts fresh token values from HTML/JS sources, and returns headers
6
+ * ready to inject into the outgoing request.
7
+ *
8
+ * This closes the gap for sites that rotate CSRF tokens per page-load,
9
+ * keep bearer tokens in JS bundles, or hydrate tokens into inline scripts.
10
+ */
11
+
12
+ import type { EndpointDescriptor, AuthTokenBinding } from "../types/index.js";
13
+ import { extractTokenFromHtml, extractTokenFromBundle } from "../reverse-engineer/token-sources.js";
14
+ import * as kuri from "../kuri/client.js";
15
+
16
+ const RESOLVE_TIMEOUT_MS = 12000;
17
+
18
+ /**
19
+ * Resolve auth_tokens bindings by loading the trigger page and scraping
20
+ * token values from their known source locations.
21
+ *
22
+ * Returns a map of header-name → resolved-value for all successfully
23
+ * resolved header-type bindings. Returns empty map if no bindings or
24
+ * all fail.
25
+ */
26
+ export async function resolveAuthTokens(
27
+ endpoint: EndpointDescriptor,
28
+ cookies: Array<{ name: string; value: string; domain: string }>,
29
+ existingAuthHeaders: Record<string, string>,
30
+ ): Promise<Record<string, string>> {
31
+ const bindings = endpoint.auth_tokens;
32
+ if (!bindings || bindings.length === 0) return {};
33
+
34
+ const triggerUrl = endpoint.trigger_url;
35
+ if (!triggerUrl) return {};
36
+
37
+ // Only resolve header bindings that aren't already satisfied
38
+ const headerBindings = bindings.filter(
39
+ (b) => b.param_location === "header" && !existingAuthHeaders[b.param_name],
40
+ );
41
+ if (headerBindings.length === 0) return {};
42
+
43
+ const resolved: Record<string, string> = {};
44
+
45
+ try {
46
+ // Open a tab, inject cookies, navigate to trigger page
47
+ const tabId = await openResolverTab(triggerUrl, cookies);
48
+ if (!tabId) return {};
49
+
50
+ try {
51
+ await waitForLoad(tabId);
52
+
53
+ const html = await kuri.getPageHtml(tabId).catch(() => "");
54
+ if (typeof html !== "string" || !html.startsWith("<")) {
55
+ return {};
56
+ }
57
+
58
+ for (const binding of headerBindings) {
59
+ const value = resolveBinding(binding, html);
60
+ if (value) {
61
+ resolved[binding.param_name] = binding.param_name.toLowerCase() === "authorization"
62
+ ? (value.startsWith("Bearer ") ? value : `Bearer ${value}`)
63
+ : value;
64
+ }
65
+ }
66
+ } finally {
67
+ await kuri.closeTab(tabId).catch(() => {});
68
+ }
69
+ } catch {
70
+ // Tab open/nav failed — return whatever we resolved so far
71
+ }
72
+
73
+ return resolved;
74
+ }
75
+
76
+ function resolveBinding(binding: AuthTokenBinding, html: string): string | undefined {
77
+ for (const source of binding.sources) {
78
+ let value: string | undefined;
79
+
80
+ if (source.kind === "html-meta" || source.kind === "html-inline-script") {
81
+ value = extractTokenFromHtml(source, html);
82
+ }
83
+ // JS bundle resolution would require fetching the bundle URL —
84
+ // skip for now, HTML sources cover most cases (CSRF, inline tokens)
85
+
86
+ if (value && value.length >= 8) return value;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ async function openResolverTab(
92
+ url: string,
93
+ cookies: Array<{ name: string; value: string; domain: string }>,
94
+ ): Promise<string | undefined> {
95
+ try {
96
+ const tab = await kuri.newTab(url);
97
+ const tabId = typeof tab === "string" ? tab : (tab as { tab_id?: string })?.tab_id;
98
+ if (!tabId) return undefined;
99
+
100
+ if (cookies.length > 0) {
101
+ for (const c of cookies) {
102
+ await kuri.setCookie(tabId, c.name, c.value, c.domain).catch(() => {});
103
+ }
104
+ await kuri.navigate(tabId, url).catch(() => {});
105
+ }
106
+
107
+ return tabId;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ async function waitForLoad(tabId: string): Promise<void> {
114
+ const start = Date.now();
115
+ while (Date.now() - start < RESOLVE_TIMEOUT_MS) {
116
+ try {
117
+ const state = await kuri.evaluate(tabId, "document.readyState");
118
+ if (state === "complete" || state === "interactive") return;
119
+ } catch { /* page not ready */ }
120
+ await new Promise((r) => setTimeout(r, 500));
121
+ }
122
+ }
@@ -373,15 +373,20 @@ export function getEndpointDescriptionMetadata(endpoint: Pick<EndpointDescriptor
373
373
  warning?: string;
374
374
  } {
375
375
  const input = classifyDescriptionInput(endpoint.description);
376
- const display = (input.source === "agent"
377
- ? endpoint.description ?? ""
376
+ // Trust the LLM augmenter's semantic layer when it has run.
377
+ const agentAugmented = endpoint.semantic?.description_source === "agent";
378
+ // Schema-grounded auto descriptions (with real response fields) don't need
379
+ // external LLM review — the calling agent IS the LLM that reviews them.
380
+ const schemaGrounded = !!(endpoint.semantic?.example_fields?.length || endpoint.semantic?.response_summary);
381
+ const display = (input.source === "agent" || agentAugmented
382
+ ? endpoint.semantic?.description_out ?? endpoint.description ?? ""
378
383
  : endpoint.semantic?.description_out ?? endpoint.description ?? "").trim();
379
- const source = display ? (input.source === "agent" ? "agent" : "auto") : "missing";
384
+ const source = display ? (input.source === "agent" || agentAugmented ? "agent" : "auto") : "missing";
380
385
  return {
381
386
  display,
382
387
  source,
383
- needs_review: source !== "agent",
384
- ...(source !== "agent"
388
+ needs_review: source === "missing" || (source === "auto" && !schemaGrounded),
389
+ ...(source !== "agent" && !schemaGrounded
385
390
  ? { warning: input.warning ?? "Auto-generated description. Review before trusting or publishing." }
386
391
  : {}),
387
392
  };
@@ -931,9 +936,12 @@ export function inferEndpointSemantic(
931
936
  confidence: fields.length > 0 ? 0.8 : 0.4,
932
937
  observed_at: opts?.observedAt,
933
938
  sample_request_url: opts?.sampleRequestUrl,
939
+ auth_required: !!(
940
+ endpoint.auth_tokens?.length ||
941
+ Object.keys(endpoint.headers_template ?? {}).some((h) => /auth|csrf|token|bearer/i.test(h))
942
+ ),
934
943
  };
935
944
  }
936
-
937
945
  export function resolveEndpointSemantic(
938
946
  endpoint: EndpointDescriptor,
939
947
  opts?: {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Local per-agent impact log.
3
+ *
4
+ * Every resolve/execute appends one line here with the savings it delivered
5
+ * (time, tokens, dollars, browser avoided). `unbrowse stats` and the
6
+ * unbrowse_stats MCP tool read this file to show lifetime impact without
7
+ * needing a new backend endpoint.
8
+ *
9
+ * Format: JSONL at ~/.unbrowse/impact-log.jsonl (or $UNBROWSE_CONFIG_DIR).
10
+ * Rotates when the file exceeds ~5MB.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, appendFileSync, statSync, readFileSync, renameSync, unlinkSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+
17
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
18
+ const MAX_ROTATIONS = 3;
19
+
20
+ export interface ImpactLogEntry {
21
+ ts: string;
22
+ command: "resolve" | "execute" | "browse" | string;
23
+ source?: string;
24
+ domain?: string;
25
+ intent?: string;
26
+ skill_id?: string;
27
+ endpoint_id?: string;
28
+ time_saved_ms?: number;
29
+ time_saved_pct?: number;
30
+ tokens_saved?: number;
31
+ tokens_saved_pct?: number;
32
+ cost_saved_uc?: number;
33
+ browser_avoided?: boolean;
34
+ success?: boolean;
35
+ }
36
+
37
+ export interface ImpactSummary {
38
+ total_runs: number;
39
+ successful_runs: number;
40
+ browser_avoided_runs: number;
41
+ total_time_saved_ms: number;
42
+ total_tokens_saved: number;
43
+ total_cost_saved_uc: number;
44
+ avg_time_saved_pct: number;
45
+ avg_tokens_saved_pct: number;
46
+ by_source: Record<string, number>;
47
+ first_entry_at: string | null;
48
+ last_entry_at: string | null;
49
+ }
50
+
51
+ function getLogDir(): string {
52
+ if (process.env.UNBROWSE_CONFIG_DIR) return process.env.UNBROWSE_CONFIG_DIR;
53
+ const profile = process.env.UNBROWSE_PROFILE?.trim();
54
+ return profile
55
+ ? join(homedir(), ".unbrowse", "profiles", profile)
56
+ : join(homedir(), ".unbrowse");
57
+ }
58
+
59
+ export function getImpactLogPath(): string {
60
+ return join(getLogDir(), "impact-log.jsonl");
61
+ }
62
+
63
+ function ensureDir(path: string): void {
64
+ const dir = dirname(path);
65
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
+ }
67
+
68
+ function rotateIfNeeded(path: string): void {
69
+ try {
70
+ if (!existsSync(path)) return;
71
+ const size = statSync(path).size;
72
+ if (size < MAX_LOG_BYTES) return;
73
+ for (let i = MAX_ROTATIONS; i >= 1; i--) {
74
+ const older = `${path}.${i}`;
75
+ if (!existsSync(older)) continue;
76
+ if (i === MAX_ROTATIONS) {
77
+ try { unlinkSync(older); } catch {}
78
+ } else {
79
+ try { renameSync(older, `${path}.${i + 1}`); } catch {}
80
+ }
81
+ }
82
+ renameSync(path, `${path}.1`);
83
+ } catch {
84
+ // best-effort; never throw from logging
85
+ }
86
+ }
87
+
88
+ /** Append a single impact record. Fire-and-forget; never throws. */
89
+ export function appendImpact(entry: ImpactLogEntry): void {
90
+ try {
91
+ const hasSignal =
92
+ (entry.time_saved_ms ?? 0) > 0 ||
93
+ (entry.tokens_saved ?? 0) > 0 ||
94
+ (entry.cost_saved_uc ?? 0) > 0 ||
95
+ entry.browser_avoided === true;
96
+ if (!hasSignal) return;
97
+
98
+ const path = getImpactLogPath();
99
+ ensureDir(path);
100
+ rotateIfNeeded(path);
101
+ appendFileSync(path, JSON.stringify(entry) + "\n", "utf8");
102
+ } catch {
103
+ // never surface logging errors
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Extract an ImpactLogEntry from an orchestrator result's `impact` field.
109
+ * Returns null if the result has no impact data.
110
+ */
111
+ export function impactFromResult(
112
+ command: string,
113
+ result: unknown,
114
+ extras: { intent?: string; domain?: string; skill_id?: string; endpoint_id?: string } = {},
115
+ ): ImpactLogEntry | null {
116
+ if (!result || typeof result !== "object") return null;
117
+ const r = result as Record<string, unknown>;
118
+ const impact = (r.impact ?? null) as Record<string, unknown> | null;
119
+ if (!impact || typeof impact !== "object") return null;
120
+
121
+ const num = (v: unknown): number | undefined =>
122
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
123
+
124
+ return {
125
+ ts: new Date().toISOString(),
126
+ command,
127
+ source: typeof impact.source === "string" ? impact.source : undefined,
128
+ domain: extras.domain,
129
+ intent: extras.intent,
130
+ skill_id: extras.skill_id ?? (typeof r.skill_id === "string" ? r.skill_id : undefined),
131
+ endpoint_id: extras.endpoint_id ?? (typeof r.endpoint_id === "string" ? r.endpoint_id : undefined),
132
+ time_saved_ms: num(impact.time_saved_ms),
133
+ time_saved_pct: num(impact.time_saved_pct),
134
+ tokens_saved: num(impact.tokens_saved),
135
+ tokens_saved_pct: num(impact.tokens_saved_pct),
136
+ cost_saved_uc: num(impact.cost_saved_uc),
137
+ browser_avoided: impact.browser_avoided === true,
138
+ success: r.error == null,
139
+ };
140
+ }
141
+
142
+ /** Read and aggregate the full impact log (across rotations). Safe on missing file. */
143
+ export function readImpactSummary(): ImpactSummary {
144
+ const path = getImpactLogPath();
145
+ const summary: ImpactSummary = {
146
+ total_runs: 0,
147
+ successful_runs: 0,
148
+ browser_avoided_runs: 0,
149
+ total_time_saved_ms: 0,
150
+ total_tokens_saved: 0,
151
+ total_cost_saved_uc: 0,
152
+ avg_time_saved_pct: 0,
153
+ avg_tokens_saved_pct: 0,
154
+ by_source: {},
155
+ first_entry_at: null,
156
+ last_entry_at: null,
157
+ };
158
+
159
+ const files: string[] = [];
160
+ for (let i = MAX_ROTATIONS; i >= 1; i--) {
161
+ const rotated = `${path}.${i}`;
162
+ if (existsSync(rotated)) files.push(rotated);
163
+ }
164
+ if (existsSync(path)) files.push(path);
165
+ if (files.length === 0) return summary;
166
+
167
+ let timePctSum = 0;
168
+ let timePctCount = 0;
169
+ let tokenPctSum = 0;
170
+ let tokenPctCount = 0;
171
+
172
+ for (const file of files) {
173
+ let raw: string;
174
+ try {
175
+ raw = readFileSync(file, "utf8");
176
+ } catch {
177
+ continue;
178
+ }
179
+ for (const line of raw.split("\n")) {
180
+ const trimmed = line.trim();
181
+ if (!trimmed) continue;
182
+ let e: ImpactLogEntry;
183
+ try {
184
+ e = JSON.parse(trimmed) as ImpactLogEntry;
185
+ } catch {
186
+ continue;
187
+ }
188
+ summary.total_runs += 1;
189
+ if (e.success !== false) summary.successful_runs += 1;
190
+ if (e.browser_avoided) summary.browser_avoided_runs += 1;
191
+ summary.total_time_saved_ms += e.time_saved_ms ?? 0;
192
+ summary.total_tokens_saved += e.tokens_saved ?? 0;
193
+ summary.total_cost_saved_uc += e.cost_saved_uc ?? 0;
194
+ if (typeof e.time_saved_pct === "number") {
195
+ timePctSum += e.time_saved_pct;
196
+ timePctCount += 1;
197
+ }
198
+ if (typeof e.tokens_saved_pct === "number") {
199
+ tokenPctSum += e.tokens_saved_pct;
200
+ tokenPctCount += 1;
201
+ }
202
+ if (e.source) {
203
+ summary.by_source[e.source] = (summary.by_source[e.source] ?? 0) + 1;
204
+ }
205
+ if (!summary.first_entry_at || e.ts < summary.first_entry_at) summary.first_entry_at = e.ts;
206
+ if (!summary.last_entry_at || e.ts > summary.last_entry_at) summary.last_entry_at = e.ts;
207
+ }
208
+ }
209
+
210
+ summary.avg_time_saved_pct = timePctCount > 0 ? Math.round(timePctSum / timePctCount) : 0;
211
+ summary.avg_tokens_saved_pct = tokenPctCount > 0 ? Math.round(tokenPctSum / tokenPctCount) : 0;
212
+ return summary;
213
+ }
214
+
215
+ /** For tests only. */
216
+ export function _clearImpactLogForTests(): void {
217
+ const path = getImpactLogPath();
218
+ try {
219
+ if (existsSync(path)) unlinkSync(path);
220
+ for (let i = 1; i <= MAX_ROTATIONS + 1; i++) {
221
+ const rotated = `${path}.${i}`;
222
+ if (existsSync(rotated)) unlinkSync(rotated);
223
+ }
224
+ } catch {
225
+ // ignore
226
+ }
227
+ }
@@ -216,7 +216,10 @@ function falseyEnv(value: string | undefined): boolean {
216
216
  }
217
217
 
218
218
  export function resolveKuriLaunchConfig(env: NodeJS.ProcessEnv = process.env): KuriLaunchConfig {
219
- const headless = envFlag(env.KURI_HEADLESS ?? env.HEADLESS);
219
+ const explicitHeadless = env.KURI_HEADLESS ?? env.HEADLESS;
220
+ const headless = explicitHeadless !== undefined
221
+ ? envFlag(explicitHeadless)
222
+ : (process.platform === "linux" && !env.DISPLAY); // auto-headless when no display on Linux
220
223
  const cleanRoom = envFlag(env.UNBROWSE_LOCAL_ONLY) || envFlag(env.KURI_CLEAN_ROOM);
221
224
  const browserCookieOptOut = falseyEnv(env.UNBROWSE_IMPORT_BROWSER_COOKIES);
222
225
  const explicitAttach = envFlag(env.KURI_ATTACH_EXISTING_CHROME ?? env.UNBROWSE_ATTACH_EXISTING_CHROME);
@@ -240,6 +243,7 @@ function currentBundledKuriTarget(): string | null {
240
243
  if (process.platform === "darwin" && process.arch === "x64") return "darwin-x64";
241
244
  if (process.platform === "linux" && process.arch === "arm64") return "linux-arm64";
242
245
  if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
246
+ if (process.platform === "win32" && process.arch === "x64") return "win-x64";
243
247
  return null;
244
248
  }
245
249
 
@@ -82,11 +82,19 @@ export function mergeEndpoints(
82
82
  dom_extraction: ep.dom_extraction ?? dupe.dom_extraction,
83
83
  semantic: ep.semantic ?? dupe.semantic,
84
84
  response_schema: ep.response_schema ?? dupe.response_schema,
85
- headers_template: ep.headers_template ?? dupe.headers_template,
85
+ headers_template: Object.keys(ep.headers_template ?? {}).length > 0 ? ep.headers_template : dupe.headers_template,
86
86
  query: ep.query ?? dupe.query,
87
87
  path_params: ep.path_params ?? dupe.path_params,
88
88
  body: ep.body ?? dupe.body,
89
+ body_params: ep.body_params ?? dupe.body_params,
89
90
  trigger_url: ep.trigger_url ?? dupe.trigger_url,
91
+ csrf_plan: ep.csrf_plan ?? dupe.csrf_plan,
92
+ oauth_plan: ep.oauth_plan ?? dupe.oauth_plan,
93
+ search_form: ep.search_form ?? dupe.search_form,
94
+ policy: ep.policy ?? dupe.policy,
95
+ graph_visibility: ep.graph_visibility ?? dupe.graph_visibility,
96
+ corroboration: ep.corroboration ?? dupe.corroboration,
97
+ auth_tokens: ep.auth_tokens ?? dupe.auth_tokens,
90
98
  };
91
99
  }
92
100
  return merged;