@flrande/browserctl 0.4.0-dev.15.1 → 0.5.0-dev.19.1

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.
Files changed (52) hide show
  1. package/apps/browserctl/src/commands/act.test.ts +71 -0
  2. package/apps/browserctl/src/commands/act.ts +45 -1
  3. package/apps/browserctl/src/commands/command-wrappers.test.ts +302 -0
  4. package/apps/browserctl/src/commands/console-list.test.ts +102 -0
  5. package/apps/browserctl/src/commands/console-list.ts +89 -1
  6. package/apps/browserctl/src/commands/har-export.test.ts +112 -0
  7. package/apps/browserctl/src/commands/har-export.ts +120 -0
  8. package/apps/browserctl/src/commands/memory-delete.ts +20 -0
  9. package/apps/browserctl/src/commands/memory-inspect.ts +20 -0
  10. package/apps/browserctl/src/commands/memory-list.ts +90 -0
  11. package/apps/browserctl/src/commands/memory-mode-set.ts +29 -0
  12. package/apps/browserctl/src/commands/memory-purge.ts +16 -0
  13. package/apps/browserctl/src/commands/memory-resolve.ts +56 -0
  14. package/apps/browserctl/src/commands/memory-status.ts +16 -0
  15. package/apps/browserctl/src/commands/memory-ttl-set.ts +28 -0
  16. package/apps/browserctl/src/commands/memory-upsert.ts +142 -0
  17. package/apps/browserctl/src/commands/network-list.test.ts +110 -0
  18. package/apps/browserctl/src/commands/network-list.ts +112 -0
  19. package/apps/browserctl/src/commands/session-drop.test.ts +36 -0
  20. package/apps/browserctl/src/commands/session-drop.ts +16 -0
  21. package/apps/browserctl/src/commands/session-list.test.ts +81 -0
  22. package/apps/browserctl/src/commands/session-list.ts +70 -0
  23. package/apps/browserctl/src/commands/trace-get.test.ts +61 -0
  24. package/apps/browserctl/src/commands/trace-get.ts +62 -0
  25. package/apps/browserctl/src/commands/wait-element.test.ts +80 -0
  26. package/apps/browserctl/src/commands/wait-element.ts +76 -0
  27. package/apps/browserctl/src/commands/wait-text.test.ts +110 -0
  28. package/apps/browserctl/src/commands/wait-text.ts +93 -0
  29. package/apps/browserctl/src/commands/wait-url.test.ts +80 -0
  30. package/apps/browserctl/src/commands/wait-url.ts +76 -0
  31. package/apps/browserctl/src/main.dispatch.test.ts +206 -1
  32. package/apps/browserctl/src/main.test.ts +30 -0
  33. package/apps/browserctl/src/main.ts +246 -4
  34. package/apps/browserd/src/container.ts +1603 -48
  35. package/apps/browserd/src/main.test.ts +538 -1
  36. package/apps/browserd/src/tool-matrix.test.ts +492 -3
  37. package/package.json +5 -1
  38. package/packages/core/src/driver.ts +1 -1
  39. package/packages/core/src/index.ts +1 -0
  40. package/packages/core/src/navigation-memory.test.ts +259 -0
  41. package/packages/core/src/navigation-memory.ts +360 -0
  42. package/packages/core/src/session-store.test.ts +33 -0
  43. package/packages/core/src/session-store.ts +111 -6
  44. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +112 -2
  45. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +233 -10
  46. package/packages/driver-managed/src/managed-driver.test.ts +124 -0
  47. package/packages/driver-managed/src/managed-driver.ts +233 -17
  48. package/packages/driver-managed/src/managed-local-driver.test.ts +104 -2
  49. package/packages/driver-managed/src/managed-local-driver.ts +232 -10
  50. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +112 -2
  51. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +232 -10
  52. package/packages/transport-mcp-stdio/src/tool-map.ts +18 -1
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  DriverRegistry,
3
3
  SessionStore,
4
+ createNavigationMemoryStore,
4
5
  type BrowserDriver,
5
- type BrowserDriverScreenshot
6
+ type BrowserDriverScreenshot,
7
+ type NavigationMemoryMode,
8
+ type NavigationSignal
6
9
  } from "../../../packages/core/src";
7
10
  import {
8
11
  createChromeRelayDriver,
@@ -23,11 +26,12 @@ import {
23
26
  type ToolCallArgs,
24
27
  type ToolMap
25
28
  } from "../../../packages/transport-mcp-stdio/src";
26
- import { win32 as windowsPath } from "node:path";
29
+ import { posix as posixPath, win32 as windowsPath } from "node:path";
27
30
  import { createChromeRelayExtensionBridge } from "./chrome-relay-extension-bridge";
28
31
 
29
32
  type CapabilityScope = "read" | "act" | "upload" | "download";
30
33
  type ChromeRelayMode = "cdp" | "extension";
34
+ type DomainAllowlistMode = "off" | "enforce";
31
35
 
32
36
  export type BrowserdConfig = {
33
37
  chromeRelayUrl: string;
@@ -41,6 +45,18 @@ export type BrowserdConfig = {
41
45
  downloadRoot?: string;
42
46
  authToken?: string;
43
47
  authScopes: CapabilityScope[];
48
+ sessionTtlMs: number;
49
+ sessionCleanupIntervalMs: number;
50
+ domainAllowlistMode: DomainAllowlistMode;
51
+ domainAllowlist: string[];
52
+ sessionMaxTotal: number;
53
+ sessionMaxPerTenant: number;
54
+ sessionRequireTenantPrefix: boolean;
55
+ tenantAllowlist: string[];
56
+ memoryEnabled: boolean;
57
+ memoryMode: NavigationMemoryMode;
58
+ memoryTtlDays: number;
59
+ memoryPath: string;
44
60
  managedLocalLaunch: {
45
61
  browserName: ManagedLocalBrowserName;
46
62
  headless: boolean;
@@ -61,6 +77,14 @@ const DEFAULT_CHROME_RELAY_EXTENSION_TOKEN = "browserctl-relay";
61
77
  const DEFAULT_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS = 5_000;
62
78
  const DEFAULT_NETWORK_WAIT_TIMEOUT_MS = 10_000;
63
79
  const DEFAULT_NETWORK_WAIT_POLL_MS = 200;
80
+ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1_000;
81
+ const DEFAULT_SESSION_CLEANUP_INTERVAL_MS = 60 * 1_000;
82
+ const DEFAULT_DOMAIN_ALLOWLIST_MODE: DomainAllowlistMode = "off";
83
+ const DEFAULT_SESSION_MAX_TOTAL = 200;
84
+ const DEFAULT_SESSION_MAX_PER_TENANT = 50;
85
+ const DEFAULT_MEMORY_MODE: NavigationMemoryMode = "ask";
86
+ const DEFAULT_MEMORY_TTL_DAYS = 30;
87
+ const DEFAULT_MEMORY_PATH = ".browserctl-runtime/navigation-memory.json";
64
88
  const ALL_CAPABILITY_SCOPES: readonly CapabilityScope[] = ["read", "act", "upload", "download"];
65
89
  const TOOL_SCOPE_BY_NAME: Readonly<Record<string, CapabilityScope>> = {
66
90
  "browser.status": "read",
@@ -76,6 +100,11 @@ const TOOL_SCOPE_BY_NAME: Readonly<Record<string, CapabilityScope>> = {
76
100
  "browser.dom.queryAll": "read",
77
101
  "browser.element.screenshot": "read",
78
102
  "browser.a11y.snapshot": "read",
103
+ "browser.wait.element": "read",
104
+ "browser.wait.text": "read",
105
+ "browser.wait.url": "read",
106
+ "browser.network.list": "read",
107
+ "browser.network.harExport": "read",
79
108
  "browser.act": "act",
80
109
  "browser.upload.arm": "upload",
81
110
  "browser.dialog.arm": "act",
@@ -90,7 +119,19 @@ const TOOL_SCOPE_BY_NAME: Readonly<Record<string, CapabilityScope>> = {
90
119
  "browser.frame.list": "read",
91
120
  "browser.frame.snapshot": "read",
92
121
  "browser.console.list": "read",
93
- "browser.network.responseBody": "read"
122
+ "browser.network.responseBody": "read",
123
+ "browser.memory.status": "read",
124
+ "browser.memory.resolve": "read",
125
+ "browser.memory.upsert": "act",
126
+ "browser.memory.list": "read",
127
+ "browser.memory.inspect": "read",
128
+ "browser.memory.delete": "act",
129
+ "browser.memory.purge": "act",
130
+ "browser.memory.mode.set": "act",
131
+ "browser.memory.ttl.set": "act",
132
+ "browser.trace.get": "read",
133
+ "browser.session.list": "read",
134
+ "browser.session.drop": "act"
94
135
  };
95
136
 
96
137
  function resolveNonEmptyString(value: string | undefined): string | undefined {
@@ -133,6 +174,15 @@ function parseOptionalNumber(value: string | undefined): number | undefined {
133
174
  return parsedNumber;
134
175
  }
135
176
 
177
+ function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
178
+ const parsedNumber = parseOptionalNumber(value);
179
+ if (parsedNumber === undefined || parsedNumber <= 0) {
180
+ return undefined;
181
+ }
182
+
183
+ return Math.trunc(parsedNumber);
184
+ }
185
+
136
186
  function parseCsv(value: string | undefined): string[] {
137
187
  const rawValue = resolveNonEmptyString(value);
138
188
  if (rawValue === undefined) {
@@ -190,6 +240,24 @@ function parseChromeRelayMode(value: string | undefined): ChromeRelayMode {
190
240
  return DEFAULT_CHROME_RELAY_MODE;
191
241
  }
192
242
 
243
+ function parseDomainAllowlistMode(value: string | undefined): DomainAllowlistMode {
244
+ const normalizedValue = value?.trim().toLowerCase();
245
+ if (normalizedValue === "off" || normalizedValue === "enforce") {
246
+ return normalizedValue;
247
+ }
248
+
249
+ return DEFAULT_DOMAIN_ALLOWLIST_MODE;
250
+ }
251
+
252
+ function parseNavigationMemoryMode(value: string | undefined): NavigationMemoryMode {
253
+ const normalizedValue = value?.trim().toLowerCase();
254
+ if (normalizedValue === "off" || normalizedValue === "ask" || normalizedValue === "auto") {
255
+ return normalizedValue;
256
+ }
257
+
258
+ return DEFAULT_MEMORY_MODE;
259
+ }
260
+
193
261
  export type BrowserdContainer = {
194
262
  config: BrowserdConfig;
195
263
  drivers: Map<string, BrowserDriver>;
@@ -210,6 +278,9 @@ export function loadBrowserdConfig(
210
278
 
211
279
  const defaultDriver =
212
280
  resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ?? DEFAULT_CONFIG_DRIVER_KEY;
281
+ const domainAllowlistMode = parseDomainAllowlistMode(env.BROWSERD_DOMAIN_ALLOWLIST_MODE);
282
+ const memoryEnabled = parseBooleanFlag(env.BROWSERD_MEMORY_ENABLED, true);
283
+ const memoryMode = parseNavigationMemoryMode(env.BROWSERD_MEMORY_MODE);
213
284
 
214
285
  return {
215
286
  chromeRelayUrl: env.BROWSERD_CHROME_RELAY_URL ?? "http://127.0.0.1:9223",
@@ -226,6 +297,22 @@ export function loadBrowserdConfig(
226
297
  downloadRoot: resolveNonEmptyString(env.BROWSERD_DOWNLOAD_ROOT),
227
298
  authToken: resolveNonEmptyString(env.BROWSERD_AUTH_TOKEN),
228
299
  authScopes: parseCapabilityScopes(env.BROWSERD_AUTH_SCOPES),
300
+ sessionTtlMs: parseOptionalNumber(env.BROWSERD_SESSION_TTL_MS) ?? DEFAULT_SESSION_TTL_MS,
301
+ sessionCleanupIntervalMs:
302
+ parseOptionalNumber(env.BROWSERD_SESSION_CLEANUP_INTERVAL_MS) ??
303
+ DEFAULT_SESSION_CLEANUP_INTERVAL_MS,
304
+ domainAllowlistMode,
305
+ domainAllowlist: parseCsv(env.BROWSERD_DOMAIN_ALLOWLIST).map((entry) => entry.toLowerCase()),
306
+ sessionMaxTotal:
307
+ parseOptionalPositiveInteger(env.BROWSERD_SESSION_MAX_TOTAL) ?? DEFAULT_SESSION_MAX_TOTAL,
308
+ sessionMaxPerTenant:
309
+ parseOptionalPositiveInteger(env.BROWSERD_SESSION_MAX_PER_TENANT) ?? DEFAULT_SESSION_MAX_PER_TENANT,
310
+ sessionRequireTenantPrefix: parseBooleanFlag(env.BROWSERD_SESSION_REQUIRE_TENANT_PREFIX, false),
311
+ tenantAllowlist: parseCsv(env.BROWSERD_TENANT_ALLOWLIST).map((entry) => entry.toLowerCase()),
312
+ memoryEnabled,
313
+ memoryMode,
314
+ memoryTtlDays: parseOptionalNumber(env.BROWSERD_MEMORY_TTL_DAYS) ?? DEFAULT_MEMORY_TTL_DAYS,
315
+ memoryPath: resolveNonEmptyString(env.BROWSERD_MEMORY_PATH) ?? DEFAULT_MEMORY_PATH,
229
316
  managedLocalLaunch: {
230
317
  browserName: parseManagedLocalBrowserName(env.BROWSERD_MANAGED_LOCAL_BROWSER),
231
318
  headless: parseBooleanFlag(env.BROWSERD_MANAGED_LOCAL_HEADLESS, DEFAULT_MANAGED_LOCAL_HEADLESS),
@@ -493,6 +580,229 @@ function resolveOptionalIntegerArg(
493
580
  return resolved;
494
581
  }
495
582
 
583
+ function resolveOptionalTimestampArg(
584
+ args: ToolCallArgs,
585
+ key: string
586
+ ): ToolResponse<number | undefined> {
587
+ const value = args[key];
588
+ if (value === undefined) {
589
+ return createOk(undefined);
590
+ }
591
+
592
+ if (typeof value !== "string") {
593
+ return createErr(
594
+ ErrorCode.E_INVALID_ARG,
595
+ `${key} must be an ISO timestamp string when provided.`
596
+ );
597
+ }
598
+
599
+ const trimmedValue = value.trim();
600
+ if (trimmedValue.length === 0) {
601
+ return createErr(
602
+ ErrorCode.E_INVALID_ARG,
603
+ `${key} must be an ISO timestamp string when provided.`
604
+ );
605
+ }
606
+
607
+ const parsedValue = Date.parse(trimmedValue);
608
+ if (!Number.isFinite(parsedValue)) {
609
+ return createErr(
610
+ ErrorCode.E_INVALID_ARG,
611
+ `${key} must be a valid ISO timestamp string.`
612
+ );
613
+ }
614
+
615
+ return createOk(parsedValue);
616
+ }
617
+
618
+ function mapNavigationMemoryError(error: unknown): ToolResponse<never> {
619
+ const message = toErrorMessage(error);
620
+ if (
621
+ message === "Navigation memory write requires confirmation in ask mode." ||
622
+ message === "Navigation memory write is disabled when mode is off."
623
+ ) {
624
+ return createErr(ErrorCode.E_PERMISSION, message);
625
+ }
626
+
627
+ return createErr(ErrorCode.E_INTERNAL, message);
628
+ }
629
+
630
+ function resolveNavigationMemoryScope(
631
+ args: ToolCallArgs
632
+ ): ToolResponse<{ domain: string; profileId: string; intentKey: string }> {
633
+ const domain = requireStringArg(args, "domain");
634
+ if (!domain.ok) {
635
+ return domain;
636
+ }
637
+
638
+ const profileId = requireStringArg(args, "profileId");
639
+ if (!profileId.ok) {
640
+ return profileId;
641
+ }
642
+
643
+ const intentKey = requireStringArg(args, "intentKey");
644
+ if (!intentKey.ok) {
645
+ return intentKey;
646
+ }
647
+
648
+ return createOk({
649
+ domain: domain.data,
650
+ profileId: profileId.data,
651
+ intentKey: intentKey.data
652
+ });
653
+ }
654
+
655
+ function resolveNavigationMemoryMode(args: ToolCallArgs): ToolResponse<NavigationMemoryMode> {
656
+ const mode = requireStringArg(args, "mode");
657
+ if (!mode.ok) {
658
+ return mode;
659
+ }
660
+
661
+ if (mode.data !== "off" && mode.data !== "ask" && mode.data !== "auto") {
662
+ return createErr(ErrorCode.E_INVALID_ARG, "mode must be one of: off, ask, auto.");
663
+ }
664
+
665
+ return createOk(mode.data);
666
+ }
667
+
668
+ const MAX_NAVIGATION_SIGNAL_LENGTH = 256;
669
+ const MAX_NAVIGATION_SELECTOR_TOKEN_COUNT = 16;
670
+ const STRUCTURED_SELECTOR_MARKER = /[#.\[:>+~*=]/;
671
+ const SIMPLE_SELECTOR_TOKEN_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
672
+
673
+ function containsControlWhitespace(value: string): boolean {
674
+ return /[\r\n\t]/.test(value);
675
+ }
676
+
677
+ function isStructuredRoutePattern(value: string): boolean {
678
+ if (value.length === 0 || value.length > MAX_NAVIGATION_SIGNAL_LENGTH) {
679
+ return false;
680
+ }
681
+
682
+ if (containsControlWhitespace(value) || /\s/.test(value)) {
683
+ return false;
684
+ }
685
+
686
+ return (
687
+ value.startsWith("/") ||
688
+ value.startsWith("http://") ||
689
+ value.startsWith("https://") ||
690
+ value.includes("://") ||
691
+ value.includes("?") ||
692
+ value.includes("#") ||
693
+ value.includes("*")
694
+ );
695
+ }
696
+
697
+ function isStructuredSelectorPattern(value: string): boolean {
698
+ if (value.length === 0 || value.length > MAX_NAVIGATION_SIGNAL_LENGTH) {
699
+ return false;
700
+ }
701
+
702
+ if (containsControlWhitespace(value)) {
703
+ return false;
704
+ }
705
+
706
+ const tokens = value.split(/\s+/).filter((token) => token.length > 0);
707
+ if (tokens.length === 0 || tokens.length > MAX_NAVIGATION_SELECTOR_TOKEN_COUNT) {
708
+ return false;
709
+ }
710
+
711
+ if (tokens.length === 1 && SIMPLE_SELECTOR_TOKEN_PATTERN.test(tokens[0])) {
712
+ return true;
713
+ }
714
+
715
+ return STRUCTURED_SELECTOR_MARKER.test(value);
716
+ }
717
+
718
+ function resolveNavigationSignals(args: ToolCallArgs): ToolResponse<NavigationSignal[]> {
719
+ const rawSignals = args.signals;
720
+ if (!Array.isArray(rawSignals)) {
721
+ return createErr(ErrorCode.E_INVALID_ARG, "signals is required and must be an array.");
722
+ }
723
+
724
+ const signals: NavigationSignal[] = [];
725
+ for (const [index, rawSignal] of rawSignals.entries()) {
726
+ if (!isObjectRecord(rawSignal)) {
727
+ return createErr(
728
+ ErrorCode.E_INVALID_ARG,
729
+ `signals[${index}] must be an object.`
730
+ );
731
+ }
732
+
733
+ const kind = rawSignal.kind;
734
+ if (kind === "urlPattern" || kind === "selector") {
735
+ const value = rawSignal.value;
736
+ if (typeof value !== "string" || value.trim().length === 0) {
737
+ return createErr(
738
+ ErrorCode.E_INVALID_ARG,
739
+ `signals[${index}].value must be a non-empty string.`
740
+ );
741
+ }
742
+
743
+ const normalizedValue = value.trim();
744
+ if (
745
+ (kind === "urlPattern" && !isStructuredRoutePattern(normalizedValue)) ||
746
+ (kind === "selector" && !isStructuredSelectorPattern(normalizedValue))
747
+ ) {
748
+ const expectedFormat =
749
+ kind === "urlPattern"
750
+ ? "a structured URL pattern like /route/* or https://host/path*"
751
+ : "a structured selector like #id, .class, [data-x], or main .item";
752
+ return createErr(
753
+ ErrorCode.E_INVALID_ARG,
754
+ `signals[${index}].value must be ${expectedFormat}.`
755
+ );
756
+ }
757
+
758
+ signals.push({
759
+ kind,
760
+ value: normalizedValue
761
+ });
762
+ continue;
763
+ }
764
+
765
+ if (kind === "hop") {
766
+ const from = rawSignal.from;
767
+ const to = rawSignal.to;
768
+ if (
769
+ typeof from !== "string" ||
770
+ from.trim().length === 0 ||
771
+ typeof to !== "string" ||
772
+ to.trim().length === 0
773
+ ) {
774
+ return createErr(
775
+ ErrorCode.E_INVALID_ARG,
776
+ `signals[${index}] hop requires non-empty string from/to values.`
777
+ );
778
+ }
779
+
780
+ const normalizedFrom = from.trim();
781
+ const normalizedTo = to.trim();
782
+ if (!isStructuredRoutePattern(normalizedFrom) || !isStructuredRoutePattern(normalizedTo)) {
783
+ return createErr(
784
+ ErrorCode.E_INVALID_ARG,
785
+ `signals[${index}] hop from/to must be structured route patterns like /from and /to.`
786
+ );
787
+ }
788
+
789
+ signals.push({
790
+ kind: "hop",
791
+ from: normalizedFrom,
792
+ to: normalizedTo
793
+ });
794
+ continue;
795
+ }
796
+
797
+ return createErr(
798
+ ErrorCode.E_INVALID_ARG,
799
+ `signals[${index}].kind must be one of: urlPattern, selector, hop.`
800
+ );
801
+ }
802
+
803
+ return createOk(signals);
804
+ }
805
+
496
806
  function resolveDriverActionData(
497
807
  actionResult: unknown,
498
808
  actionType: string
@@ -599,6 +909,214 @@ function readNetworkRequestSummaries(snapshot: unknown): NetworkRequestSummary[]
599
909
  return summaries;
600
910
  }
601
911
 
912
+ type NetworkSummaryFilters = {
913
+ urlContains?: string;
914
+ method?: string;
915
+ status?: number;
916
+ sinceMs?: number;
917
+ limit?: number;
918
+ };
919
+
920
+ type ConsoleEntry = {
921
+ type?: string;
922
+ text?: string;
923
+ timestamp?: string;
924
+ [key: string]: unknown;
925
+ };
926
+
927
+ type ConsoleFilters = {
928
+ type?: string;
929
+ contains?: string;
930
+ sinceMs?: number;
931
+ limit?: number;
932
+ };
933
+
934
+ function readConsoleEntries(value: unknown): ConsoleEntry[] {
935
+ if (!Array.isArray(value)) {
936
+ return [];
937
+ }
938
+
939
+ return value
940
+ .filter(isObjectRecord)
941
+ .map((entry) => ({ ...entry }));
942
+ }
943
+
944
+ function applyLimitToTail<TValue>(values: TValue[], limit: number | undefined): TValue[] {
945
+ if (limit === undefined || values.length <= limit) {
946
+ return values;
947
+ }
948
+
949
+ return values.slice(values.length - limit);
950
+ }
951
+
952
+ function filterConsoleEntries(
953
+ entries: ConsoleEntry[],
954
+ filters: ConsoleFilters
955
+ ): ConsoleEntry[] {
956
+ const normalizedType = filters.type?.toLowerCase();
957
+ const normalizedContains = filters.contains?.toLowerCase();
958
+
959
+ const filtered = entries.filter((entry) => {
960
+ if (
961
+ normalizedType !== undefined &&
962
+ (typeof entry.type !== "string" || entry.type.toLowerCase() !== normalizedType)
963
+ ) {
964
+ return false;
965
+ }
966
+
967
+ if (
968
+ normalizedContains !== undefined &&
969
+ (typeof entry.text !== "string" || !entry.text.toLowerCase().includes(normalizedContains))
970
+ ) {
971
+ return false;
972
+ }
973
+
974
+ if (filters.sinceMs !== undefined) {
975
+ const timestampMs =
976
+ typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
977
+ if (!Number.isFinite(timestampMs) || timestampMs < filters.sinceMs) {
978
+ return false;
979
+ }
980
+ }
981
+
982
+ return true;
983
+ });
984
+
985
+ return applyLimitToTail(filtered, filters.limit);
986
+ }
987
+
988
+ function filterNetworkSummaries(
989
+ summaries: NetworkRequestSummary[],
990
+ filters: NetworkSummaryFilters
991
+ ): NetworkRequestSummary[] {
992
+ const normalizedMethod = filters.method?.toUpperCase();
993
+ const filtered = summaries.filter((summary) => {
994
+ if (filters.urlContains !== undefined && !summary.url.includes(filters.urlContains)) {
995
+ return false;
996
+ }
997
+
998
+ if (
999
+ normalizedMethod !== undefined &&
1000
+ (summary.method === undefined || summary.method.toUpperCase() !== normalizedMethod)
1001
+ ) {
1002
+ return false;
1003
+ }
1004
+
1005
+ if (filters.status !== undefined && summary.status !== filters.status) {
1006
+ return false;
1007
+ }
1008
+
1009
+ if (filters.sinceMs !== undefined) {
1010
+ const timestampMs =
1011
+ summary.timestamp === undefined ? Number.NaN : Date.parse(summary.timestamp);
1012
+ if (!Number.isFinite(timestampMs) || timestampMs < filters.sinceMs) {
1013
+ return false;
1014
+ }
1015
+ }
1016
+
1017
+ return true;
1018
+ });
1019
+
1020
+ return applyLimitToTail(filtered, filters.limit);
1021
+ }
1022
+
1023
+ function createHarLog(
1024
+ summaries: NetworkRequestSummary[],
1025
+ includeBodies: boolean,
1026
+ readBody: (requestId: string) => { body: string; encoding: "utf8" | "base64" } | undefined
1027
+ ): Record<string, unknown> {
1028
+ const startedDateTime =
1029
+ summaries[0]?.timestamp ?? new Date().toISOString();
1030
+ const entries = summaries.map((summary) => {
1031
+ const bodyPayload = includeBodies ? readBody(summary.requestId) : undefined;
1032
+ const bodyText = bodyPayload?.body;
1033
+ const bodyEncoding = bodyPayload?.encoding;
1034
+ const bodySize = bodyText === undefined ? -1 : bodyText.length;
1035
+
1036
+ return {
1037
+ startedDateTime: summary.timestamp ?? startedDateTime,
1038
+ time: 0,
1039
+ request: {
1040
+ method: summary.method ?? "GET",
1041
+ url: summary.url,
1042
+ httpVersion: "HTTP/1.1",
1043
+ headers: [],
1044
+ queryString: [],
1045
+ cookies: [],
1046
+ headersSize: -1,
1047
+ bodySize: -1
1048
+ },
1049
+ response: {
1050
+ status: summary.status ?? 0,
1051
+ statusText: "",
1052
+ httpVersion: "HTTP/1.1",
1053
+ headers: [],
1054
+ cookies: [],
1055
+ content: {
1056
+ size: bodySize,
1057
+ mimeType: "",
1058
+ ...(bodyText !== undefined ? { text: bodyText } : {}),
1059
+ ...(bodyEncoding !== undefined ? { encoding: bodyEncoding } : {})
1060
+ },
1061
+ redirectURL: "",
1062
+ headersSize: -1,
1063
+ bodySize
1064
+ },
1065
+ cache: {},
1066
+ timings: {
1067
+ send: 0,
1068
+ wait: 0,
1069
+ receive: 0
1070
+ }
1071
+ };
1072
+ });
1073
+
1074
+ return {
1075
+ log: {
1076
+ version: "1.2",
1077
+ creator: {
1078
+ name: "browserctl",
1079
+ version: "0.5.0"
1080
+ },
1081
+ pages: [
1082
+ {
1083
+ id: "page:1",
1084
+ startedDateTime,
1085
+ title: "browserctl",
1086
+ pageTimings: {}
1087
+ }
1088
+ ],
1089
+ entries
1090
+ }
1091
+ };
1092
+ }
1093
+
1094
+ function readSnapshotUrl(snapshot: unknown): string | undefined {
1095
+ if (!isObjectRecord(snapshot)) {
1096
+ return undefined;
1097
+ }
1098
+
1099
+ const value = snapshot.url;
1100
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
1101
+ }
1102
+
1103
+ function readSnapshotHtml(snapshot: unknown): string | undefined {
1104
+ if (!isObjectRecord(snapshot)) {
1105
+ return undefined;
1106
+ }
1107
+
1108
+ const value = snapshot.html;
1109
+ return typeof value === "string" ? value : undefined;
1110
+ }
1111
+
1112
+ function readElementList(value: unknown): Array<Record<string, unknown>> {
1113
+ if (!Array.isArray(value)) {
1114
+ return [];
1115
+ }
1116
+
1117
+ return value.filter(isObjectRecord);
1118
+ }
1119
+
602
1120
  function delay(ms: number): Promise<void> {
603
1121
  return new Promise((resolve) => {
604
1122
  setTimeout(resolve, ms);
@@ -643,22 +1161,247 @@ function authorizeToolCall(
643
1161
  return createOk(null);
644
1162
  }
645
1163
 
646
- function resolveWindowsPathWithinRoot(
1164
+ function resolveSessionTenant(sessionId: string): string | undefined {
1165
+ const delimiterIndex = sessionId.indexOf(":");
1166
+ if (delimiterIndex <= 0) {
1167
+ return undefined;
1168
+ }
1169
+
1170
+ const tenant = sessionId.slice(0, delimiterIndex).trim();
1171
+ return tenant.length === 0 ? undefined : tenant.toLowerCase();
1172
+ }
1173
+
1174
+ function authorizeSessionDrop(
1175
+ requesterSessionId: string,
1176
+ sessionIdToDelete: string
1177
+ ): ToolResponse<null> {
1178
+ if (requesterSessionId === sessionIdToDelete) {
1179
+ return createOk(null);
1180
+ }
1181
+
1182
+ const requesterTenant = resolveSessionTenant(requesterSessionId);
1183
+ const targetTenant = resolveSessionTenant(sessionIdToDelete);
1184
+ if (
1185
+ requesterTenant === undefined ||
1186
+ targetTenant === undefined ||
1187
+ requesterTenant !== targetTenant
1188
+ ) {
1189
+ return createErr(
1190
+ ErrorCode.E_PERMISSION,
1191
+ "browser.session.drop can only delete sessions in the same tenant."
1192
+ );
1193
+ }
1194
+
1195
+ return createOk(null);
1196
+ }
1197
+
1198
+ function parseHostnameFromUrl(rawUrl: string): ToolResponse<string> {
1199
+ const trimmedUrl = rawUrl.trim();
1200
+ if (trimmedUrl.length === 0) {
1201
+ return createErr(ErrorCode.E_INVALID_ARG, "URL must be a non-empty string.");
1202
+ }
1203
+
1204
+ let parsedUrl: URL;
1205
+ try {
1206
+ parsedUrl = new URL(trimmedUrl);
1207
+ } catch {
1208
+ return createErr(ErrorCode.E_INVALID_ARG, `Invalid URL: ${trimmedUrl}`);
1209
+ }
1210
+
1211
+ if (parsedUrl.hostname.trim().length === 0) {
1212
+ return createErr(ErrorCode.E_INVALID_ARG, `URL hostname is missing: ${trimmedUrl}`);
1213
+ }
1214
+
1215
+ return createOk(parsedUrl.hostname.toLowerCase());
1216
+ }
1217
+
1218
+ function isHostnameAllowed(hostname: string, allowlistEntry: string): boolean {
1219
+ if (allowlistEntry.startsWith("*.")) {
1220
+ const suffix = allowlistEntry.slice(2);
1221
+ if (suffix.length === 0) {
1222
+ return false;
1223
+ }
1224
+
1225
+ return hostname === suffix || hostname.endsWith(`.${suffix}`);
1226
+ }
1227
+
1228
+ return hostname === allowlistEntry;
1229
+ }
1230
+
1231
+ function resolveRestrictedUrlFromToolArgs(
1232
+ toolName: string,
1233
+ args: ToolCallArgs
1234
+ ): ToolResponse<string | undefined> {
1235
+ if (toolName === "browser.tab.open") {
1236
+ const url = args.url;
1237
+ if (typeof url !== "string" || url.trim().length === 0) {
1238
+ return createErr(ErrorCode.E_INVALID_ARG, "url is required and must be a non-empty string.");
1239
+ }
1240
+
1241
+ return createOk(url.trim());
1242
+ }
1243
+
1244
+ if (toolName === "browser.cookie.set") {
1245
+ const url = args.url;
1246
+ if (url === undefined) {
1247
+ return createOk(undefined);
1248
+ }
1249
+
1250
+ if (typeof url !== "string" || url.trim().length === 0) {
1251
+ return createErr(
1252
+ ErrorCode.E_INVALID_ARG,
1253
+ "url must be a non-empty string when provided."
1254
+ );
1255
+ }
1256
+
1257
+ return createOk(url.trim());
1258
+ }
1259
+
1260
+ if (toolName === "browser.act") {
1261
+ const action = args.action;
1262
+ if (!isObjectRecord(action)) {
1263
+ return createOk(undefined);
1264
+ }
1265
+
1266
+ const actionType = typeof action.type === "string" ? action.type.trim().toLowerCase() : "";
1267
+ if (actionType !== "navigate" && actionType !== "goto") {
1268
+ return createOk(undefined);
1269
+ }
1270
+
1271
+ const payload = action.payload;
1272
+ if (!isObjectRecord(payload)) {
1273
+ return createErr(ErrorCode.E_INVALID_ARG, "action.payload.url is required for navigate action.");
1274
+ }
1275
+
1276
+ const url = payload.url;
1277
+ if (typeof url !== "string" || url.trim().length === 0) {
1278
+ return createErr(ErrorCode.E_INVALID_ARG, "action.payload.url is required for navigate action.");
1279
+ }
1280
+
1281
+ return createOk(url.trim());
1282
+ }
1283
+
1284
+ return createOk(undefined);
1285
+ }
1286
+
1287
+ function authorizeDomainAllowlist(
1288
+ toolName: string,
1289
+ args: ToolCallArgs,
1290
+ config: BrowserdConfig
1291
+ ): ToolResponse<null> {
1292
+ if (config.domainAllowlistMode !== "enforce") {
1293
+ return createOk(null);
1294
+ }
1295
+
1296
+ if (config.domainAllowlist.length === 0) {
1297
+ return createErr(
1298
+ ErrorCode.E_PERMISSION,
1299
+ "Domain allowlist mode is enforce but BROWSERD_DOMAIN_ALLOWLIST is empty."
1300
+ );
1301
+ }
1302
+
1303
+ const restrictedUrl = resolveRestrictedUrlFromToolArgs(toolName, args);
1304
+ if (!restrictedUrl.ok) {
1305
+ return restrictedUrl;
1306
+ }
1307
+ if (restrictedUrl.data === undefined) {
1308
+ return createOk(null);
1309
+ }
1310
+
1311
+ const hostname = parseHostnameFromUrl(restrictedUrl.data);
1312
+ if (!hostname.ok) {
1313
+ return hostname;
1314
+ }
1315
+
1316
+ const matched = config.domainAllowlist.some((entry) => isHostnameAllowed(hostname.data, entry));
1317
+ if (!matched) {
1318
+ return createErr(
1319
+ ErrorCode.E_PERMISSION,
1320
+ `URL host is outside domain allowlist: ${hostname.data}`
1321
+ );
1322
+ }
1323
+
1324
+ return createOk(null);
1325
+ }
1326
+
1327
+ function authorizeSessionGovernance(
1328
+ args: ToolCallArgs,
1329
+ sessions: SessionStore,
1330
+ config: BrowserdConfig
1331
+ ): ToolResponse<null> {
1332
+ const sessionId = args.sessionId;
1333
+ const tenant = resolveSessionTenant(sessionId);
1334
+ if (config.sessionRequireTenantPrefix && tenant === undefined) {
1335
+ return createErr(
1336
+ ErrorCode.E_PERMISSION,
1337
+ "Session governance requires tenant-prefixed sessionId format: <tenant>:<session>."
1338
+ );
1339
+ }
1340
+
1341
+ if (
1342
+ config.tenantAllowlist.length > 0 &&
1343
+ (tenant === undefined || !config.tenantAllowlist.includes(tenant))
1344
+ ) {
1345
+ return createErr(
1346
+ ErrorCode.E_PERMISSION,
1347
+ `Tenant is not allowed for sessionId: ${sessionId}`
1348
+ );
1349
+ }
1350
+
1351
+ if (sessions.has(sessionId)) {
1352
+ return createOk(null);
1353
+ }
1354
+
1355
+ const activeSessions = sessions.listSnapshots();
1356
+ if (activeSessions.length >= config.sessionMaxTotal) {
1357
+ return createErr(
1358
+ ErrorCode.E_CONFLICT,
1359
+ `Active session limit exceeded: ${config.sessionMaxTotal}.`
1360
+ );
1361
+ }
1362
+
1363
+ if (tenant !== undefined) {
1364
+ const tenantSessionCount = activeSessions.reduce((count, snapshot) => {
1365
+ const snapshotTenant = resolveSessionTenant(snapshot.state.sessionId);
1366
+ return snapshotTenant === tenant ? count + 1 : count;
1367
+ }, 0);
1368
+ if (tenantSessionCount >= config.sessionMaxPerTenant) {
1369
+ return createErr(
1370
+ ErrorCode.E_CONFLICT,
1371
+ `Active session limit exceeded for tenant ${tenant}: ${config.sessionMaxPerTenant}.`
1372
+ );
1373
+ }
1374
+ }
1375
+
1376
+ return createOk(null);
1377
+ }
1378
+
1379
+ function isWindowsPathStyle(pathValue: string): boolean {
1380
+ return (
1381
+ /^[a-zA-Z]:[\\/]/.test(pathValue) ||
1382
+ pathValue.startsWith("\\\\") ||
1383
+ pathValue.includes("\\")
1384
+ );
1385
+ }
1386
+
1387
+ function resolvePathWithinRoot(
647
1388
  inputPath: string,
648
1389
  rootPath: string,
649
1390
  context: "upload" | "download"
650
1391
  ): ToolResponse<string> {
651
- const resolvedRoot = windowsPath.resolve(rootPath);
652
- const resolvedPath = windowsPath.isAbsolute(inputPath)
653
- ? windowsPath.resolve(inputPath)
654
- : windowsPath.resolve(resolvedRoot, inputPath);
655
- const relativePath = windowsPath.relative(
656
- resolvedRoot.toLowerCase(),
657
- resolvedPath.toLowerCase()
658
- );
1392
+ const useWindowsSemantics = isWindowsPathStyle(rootPath);
1393
+ const pathRuntime = useWindowsSemantics ? windowsPath : posixPath;
1394
+ const resolvedRoot = pathRuntime.resolve(rootPath);
1395
+ const resolvedPath = pathRuntime.isAbsolute(inputPath)
1396
+ ? pathRuntime.resolve(inputPath)
1397
+ : pathRuntime.resolve(resolvedRoot, inputPath);
1398
+
1399
+ const comparableRoot = useWindowsSemantics ? resolvedRoot.toLowerCase() : resolvedRoot;
1400
+ const comparablePath = useWindowsSemantics ? resolvedPath.toLowerCase() : resolvedPath;
1401
+ const relativePath = pathRuntime.relative(comparableRoot, comparablePath);
659
1402
  const isInRoot =
660
1403
  relativePath === "" ||
661
- (!relativePath.startsWith("..") && !windowsPath.isAbsolute(relativePath));
1404
+ (!relativePath.startsWith("..") && !pathRuntime.isAbsolute(relativePath));
662
1405
  if (!isInRoot) {
663
1406
  return createErr(
664
1407
  ErrorCode.E_PERMISSION,
@@ -679,7 +1422,7 @@ function applyUploadRoot(files: string[], uploadRoot: string | undefined): ToolR
679
1422
 
680
1423
  const rootedFiles: string[] = [];
681
1424
  for (const file of files) {
682
- const rootedPath = resolveWindowsPathWithinRoot(file, uploadRoot, "upload");
1425
+ const rootedPath = resolvePathWithinRoot(file, uploadRoot, "upload");
683
1426
  if (!rootedPath.ok) {
684
1427
  return rootedPath;
685
1428
  }
@@ -707,26 +1450,46 @@ function normalizeDownloadPayload(
707
1450
  }
708
1451
 
709
1452
  const download = { ...rawDownload };
710
- const downloadPath =
711
- requestedPath ??
712
- (typeof download.path === "string" && download.path.trim().length > 0
1453
+ const actualPath =
1454
+ typeof download.path === "string" && download.path.trim().length > 0
713
1455
  ? download.path.trim()
714
- : undefined);
715
- if (downloadPath === undefined) {
1456
+ : undefined;
1457
+ if (actualPath === undefined) {
716
1458
  return createErr(
717
1459
  ErrorCode.E_INTERNAL,
718
1460
  "Driver download payload is missing path required for allowlist enforcement."
719
1461
  );
720
1462
  }
721
1463
 
722
- const rootedPath = resolveWindowsPathWithinRoot(downloadPath, downloadRoot, "download");
723
- if (!rootedPath.ok) {
724
- return rootedPath;
1464
+ const rootedActualPath = resolvePathWithinRoot(actualPath, downloadRoot, "download");
1465
+ if (!rootedActualPath.ok) {
1466
+ return rootedActualPath;
1467
+ }
1468
+
1469
+ if (requestedPath !== undefined) {
1470
+ const rootedRequestedPath = resolvePathWithinRoot(requestedPath, downloadRoot, "download");
1471
+ if (!rootedRequestedPath.ok) {
1472
+ return rootedRequestedPath;
1473
+ }
1474
+
1475
+ const useWindowsSemantics = isWindowsPathStyle(downloadRoot);
1476
+ const comparableActualPath = useWindowsSemantics
1477
+ ? rootedActualPath.data.toLowerCase()
1478
+ : rootedActualPath.data;
1479
+ const comparableRequestedPath = useWindowsSemantics
1480
+ ? rootedRequestedPath.data.toLowerCase()
1481
+ : rootedRequestedPath.data;
1482
+ if (comparableActualPath !== comparableRequestedPath) {
1483
+ return createErr(
1484
+ ErrorCode.E_INTERNAL,
1485
+ `Driver failed to persist download to requested path: ${rootedRequestedPath.data}`
1486
+ );
1487
+ }
725
1488
  }
726
1489
 
727
1490
  return createOk({
728
1491
  ...download,
729
- path: rootedPath.data
1492
+ path: rootedActualPath.data
730
1493
  });
731
1494
  }
732
1495
 
@@ -736,7 +1499,186 @@ function createBrowserdToolMap(
736
1499
  defaultDriverKey: string,
737
1500
  config: BrowserdConfig
738
1501
  ): ToolMap {
1502
+ type TraceStep = {
1503
+ index: number;
1504
+ timestamp: string;
1505
+ tool: string;
1506
+ ok: boolean;
1507
+ durationMs: number;
1508
+ targetId?: string;
1509
+ profile?: string;
1510
+ errorCode?: string;
1511
+ };
1512
+ type TraceKeyResponse = {
1513
+ stepIndex: number;
1514
+ kind: string;
1515
+ timestamp: string;
1516
+ data: Record<string, unknown>;
1517
+ };
1518
+ type TraceScreenshot = {
1519
+ stepIndex: number;
1520
+ timestamp: string;
1521
+ tool: string;
1522
+ targetId?: string;
1523
+ };
1524
+ type SessionTraceState = {
1525
+ nextStepIndex: number;
1526
+ steps: TraceStep[];
1527
+ keyResponses: TraceKeyResponse[];
1528
+ screenshots: TraceScreenshot[];
1529
+ };
1530
+ const traceStateBySession = new Map<string, SessionTraceState>();
1531
+ const TRACE_EVENT_LIMIT = 500;
1532
+
1533
+ const getOrCreateSessionTraceState = (sessionId: string): SessionTraceState => {
1534
+ const existing = traceStateBySession.get(sessionId);
1535
+ if (existing !== undefined) {
1536
+ return existing;
1537
+ }
1538
+
1539
+ const created: SessionTraceState = {
1540
+ nextStepIndex: 1,
1541
+ steps: [],
1542
+ keyResponses: [],
1543
+ screenshots: []
1544
+ };
1545
+ traceStateBySession.set(sessionId, created);
1546
+ return created;
1547
+ };
1548
+
1549
+ const trimTraceState = (state: SessionTraceState): void => {
1550
+ if (state.steps.length > TRACE_EVENT_LIMIT) {
1551
+ state.steps.splice(0, state.steps.length - TRACE_EVENT_LIMIT);
1552
+ }
1553
+ if (state.keyResponses.length > TRACE_EVENT_LIMIT) {
1554
+ state.keyResponses.splice(0, state.keyResponses.length - TRACE_EVENT_LIMIT);
1555
+ }
1556
+ if (state.screenshots.length > TRACE_EVENT_LIMIT) {
1557
+ state.screenshots.splice(0, state.screenshots.length - TRACE_EVENT_LIMIT);
1558
+ }
1559
+ };
1560
+
1561
+ const recordTrace = (
1562
+ sessionId: string,
1563
+ toolName: string,
1564
+ args: ToolCallArgs,
1565
+ response: ToolResponse<Record<string, unknown>> | ToolResponse<unknown>,
1566
+ startedAtMs: number,
1567
+ finishedAtMs: number
1568
+ ): void => {
1569
+ const state = getOrCreateSessionTraceState(sessionId);
1570
+ const stepIndex = state.nextStepIndex;
1571
+ state.nextStepIndex += 1;
1572
+ const timestamp = new Date(startedAtMs).toISOString();
1573
+ const targetId = typeof args.targetId === "string" ? args.targetId : undefined;
1574
+ const profile = typeof args.profile === "string" ? args.profile : undefined;
1575
+ const errorCode =
1576
+ !response.ok && isObjectRecord(response.error) && typeof response.error.code === "string"
1577
+ ? response.error.code
1578
+ : undefined;
1579
+
1580
+ state.steps.push({
1581
+ index: stepIndex,
1582
+ timestamp,
1583
+ tool: toolName,
1584
+ ok: response.ok,
1585
+ durationMs: Math.max(0, finishedAtMs - startedAtMs),
1586
+ ...(targetId !== undefined ? { targetId } : {}),
1587
+ ...(profile !== undefined ? { profile } : {}),
1588
+ ...(errorCode !== undefined ? { errorCode } : {})
1589
+ });
1590
+
1591
+ if (
1592
+ response.ok &&
1593
+ (toolName === "browser.screenshot" || toolName === "browser.element.screenshot")
1594
+ ) {
1595
+ state.screenshots.push({
1596
+ stepIndex,
1597
+ timestamp,
1598
+ tool: toolName,
1599
+ ...(targetId !== undefined ? { targetId } : {})
1600
+ });
1601
+ }
1602
+
1603
+ if (response.ok && isObjectRecord(response.data)) {
1604
+ if (toolName === "browser.network.waitFor" && isObjectRecord(response.data.request)) {
1605
+ state.keyResponses.push({
1606
+ stepIndex,
1607
+ kind: "network.waitFor",
1608
+ timestamp,
1609
+ data: {
1610
+ ...response.data.request
1611
+ }
1612
+ });
1613
+ }
1614
+
1615
+ if (toolName === "browser.network.responseBody") {
1616
+ const requestId = response.data.requestId;
1617
+ const encoding = response.data.encoding;
1618
+ const body = response.data.body;
1619
+ state.keyResponses.push({
1620
+ stepIndex,
1621
+ kind: "network.responseBody",
1622
+ timestamp,
1623
+ data: {
1624
+ ...(typeof requestId === "string" ? { requestId } : {}),
1625
+ ...(typeof encoding === "string" ? { encoding } : {}),
1626
+ ...(typeof body === "string" ? { size: body.length } : {})
1627
+ }
1628
+ });
1629
+ }
1630
+
1631
+ if (toolName === "browser.network.list") {
1632
+ const requests = response.data.requests;
1633
+ state.keyResponses.push({
1634
+ stepIndex,
1635
+ kind: "network.list",
1636
+ timestamp,
1637
+ data: {
1638
+ count: Array.isArray(requests) ? requests.length : 0
1639
+ }
1640
+ });
1641
+ }
1642
+
1643
+ if (toolName === "browser.network.harExport") {
1644
+ const har = response.data.har;
1645
+ const entriesCount =
1646
+ isObjectRecord(har) &&
1647
+ isObjectRecord(har.log) &&
1648
+ Array.isArray(har.log.entries)
1649
+ ? har.log.entries.length
1650
+ : 0;
1651
+ state.keyResponses.push({
1652
+ stepIndex,
1653
+ kind: "network.harExport",
1654
+ timestamp,
1655
+ data: {
1656
+ entries: entriesCount
1657
+ }
1658
+ });
1659
+ }
1660
+
1661
+ if (toolName === "browser.memory.resolve") {
1662
+ state.keyResponses.push({
1663
+ stepIndex,
1664
+ kind: "memory.resolve",
1665
+ timestamp,
1666
+ data: {
1667
+ memoryHit: response.data.hit === true
1668
+ }
1669
+ });
1670
+ }
1671
+ }
1672
+
1673
+ trimTraceState(state);
1674
+ };
1675
+
739
1676
  const toolMap = buildToolMap();
1677
+ const navigationMemoryStore = createNavigationMemoryStore({
1678
+ path: config.memoryPath,
1679
+ mode: config.memoryEnabled ? config.memoryMode : "off",
1680
+ ttlDays: config.memoryTtlDays
1681
+ });
740
1682
  const allowedScopes = new Set<CapabilityScope>(config.authScopes);
741
1683
  const runWithDefaultDriver = <TData>(
742
1684
  args: ToolCallArgs,
@@ -799,36 +1741,242 @@ function createBrowserdToolMap(
799
1741
  status
800
1742
  });
801
1743
  } catch (error) {
802
- return mapDriverError(error);
1744
+ return mapDriverError(error);
1745
+ }
1746
+ });
1747
+
1748
+ toolMap.set("browser.profile.use", async (args) => {
1749
+ const requestedProfile = requireStringArg(args, "profile");
1750
+ if (!requestedProfile.ok) {
1751
+ return requestedProfile;
1752
+ }
1753
+
1754
+ if (!drivers.has(requestedProfile.data)) {
1755
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown driver profile: ${requestedProfile.data}`);
1756
+ }
1757
+
1758
+ sessions.useProfile(args.sessionId, requestedProfile.data);
1759
+ return createOk({
1760
+ profile: requestedProfile.data
1761
+ });
1762
+ });
1763
+
1764
+ toolMap.set("browser.profile.list", async (args) =>
1765
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1766
+ const profiles = await driver.listProfiles();
1767
+ return createOk({
1768
+ driver: driverKey,
1769
+ profiles
1770
+ });
1771
+ })
1772
+ );
1773
+
1774
+ toolMap.set("browser.session.list", async (args) => {
1775
+ const tenant = resolveOptionalStringArg(args, "tenant");
1776
+ if (!tenant.ok) {
1777
+ return tenant;
1778
+ }
1779
+
1780
+ const limit = resolveOptionalIntegerArg(args, "limit", 1);
1781
+ if (!limit.ok) {
1782
+ return limit;
1783
+ }
1784
+
1785
+ const normalizedTenant = tenant.data?.toLowerCase();
1786
+ const sessionsPayload = sessions
1787
+ .listSnapshots()
1788
+ .map((snapshot) => ({
1789
+ sessionId: snapshot.state.sessionId,
1790
+ tenant: resolveSessionTenant(snapshot.state.sessionId),
1791
+ profile: snapshot.state.profile,
1792
+ targetId: snapshot.state.targetId,
1793
+ touchedAt: new Date(snapshot.touchedAt).toISOString()
1794
+ }))
1795
+ .filter((entry) =>
1796
+ normalizedTenant === undefined ? true : entry.tenant === normalizedTenant
1797
+ )
1798
+ .sort((left, right) => right.touchedAt.localeCompare(left.touchedAt));
1799
+
1800
+ const limitedSessions =
1801
+ limit.data === undefined ? sessionsPayload : sessionsPayload.slice(0, limit.data);
1802
+ return createOk({
1803
+ sessions: limitedSessions,
1804
+ totalActive: sessions.size()
1805
+ });
1806
+ });
1807
+
1808
+ toolMap.set("browser.session.drop", async (args) => {
1809
+ const sessionIdToDelete = requireStringArg(args, "sessionIdToDelete");
1810
+ if (!sessionIdToDelete.ok) {
1811
+ return sessionIdToDelete;
1812
+ }
1813
+
1814
+ const dropAuthorization = authorizeSessionDrop(args.sessionId, sessionIdToDelete.data);
1815
+ if (!dropAuthorization.ok) {
1816
+ return dropAuthorization;
1817
+ }
1818
+
1819
+ return createOk({
1820
+ sessionIdToDelete: sessionIdToDelete.data,
1821
+ dropped: sessions.delete(sessionIdToDelete.data)
1822
+ });
1823
+ });
1824
+
1825
+ toolMap.set("browser.memory.status", async () => createOk(navigationMemoryStore.status()));
1826
+
1827
+ toolMap.set("browser.memory.resolve", async (args) => {
1828
+ const scope = resolveNavigationMemoryScope(args);
1829
+ if (!scope.ok) {
1830
+ return scope;
1831
+ }
1832
+
1833
+ try {
1834
+ return createOk(await navigationMemoryStore.resolve(scope.data));
1835
+ } catch (error) {
1836
+ return mapNavigationMemoryError(error);
1837
+ }
1838
+ });
1839
+
1840
+ toolMap.set("browser.memory.upsert", async (args) => {
1841
+ const scope = resolveNavigationMemoryScope(args);
1842
+ if (!scope.ok) {
1843
+ return scope;
1844
+ }
1845
+
1846
+ const signals = resolveNavigationSignals(args);
1847
+ if (!signals.ok) {
1848
+ return signals;
1849
+ }
1850
+
1851
+ const confidence = resolveOptionalNumberArg(args, "confidence");
1852
+ if (!confidence.ok) {
1853
+ return confidence;
1854
+ }
1855
+
1856
+ const confirmed = args.confirmed;
1857
+ if (confirmed !== undefined && typeof confirmed !== "boolean") {
1858
+ return createErr(ErrorCode.E_INVALID_ARG, "confirmed must be a boolean when provided.");
1859
+ }
1860
+
1861
+ try {
1862
+ return createOk(
1863
+ await navigationMemoryStore.upsert({
1864
+ ...scope.data,
1865
+ signals: signals.data,
1866
+ ...(confidence.data !== undefined ? { confidence: confidence.data } : {}),
1867
+ ...(confirmed !== undefined ? { confirmed } : {})
1868
+ })
1869
+ );
1870
+ } catch (error) {
1871
+ return mapNavigationMemoryError(error);
1872
+ }
1873
+ });
1874
+
1875
+ toolMap.set("browser.memory.list", async (args) => {
1876
+ const domain = resolveOptionalStringArg(args, "domain");
1877
+ if (!domain.ok) {
1878
+ return domain;
1879
+ }
1880
+
1881
+ const profileId = resolveOptionalStringArg(args, "profileId");
1882
+ if (!profileId.ok) {
1883
+ return profileId;
1884
+ }
1885
+
1886
+ const intentKey = resolveOptionalStringArg(args, "intentKey");
1887
+ if (!intentKey.ok) {
1888
+ return intentKey;
1889
+ }
1890
+
1891
+ try {
1892
+ const entries = await navigationMemoryStore.list({
1893
+ ...(domain.data !== undefined ? { domain: domain.data } : {}),
1894
+ ...(profileId.data !== undefined ? { profileId: profileId.data } : {}),
1895
+ ...(intentKey.data !== undefined ? { intentKey: intentKey.data } : {})
1896
+ });
1897
+ return createOk({ entries });
1898
+ } catch (error) {
1899
+ return mapNavigationMemoryError(error);
1900
+ }
1901
+ });
1902
+
1903
+ toolMap.set("browser.memory.inspect", async (args) => {
1904
+ const id = requireStringArg(args, "id");
1905
+ if (!id.ok) {
1906
+ return id;
1907
+ }
1908
+
1909
+ try {
1910
+ return createOk({
1911
+ entry: await navigationMemoryStore.inspect(id.data)
1912
+ });
1913
+ } catch (error) {
1914
+ return mapNavigationMemoryError(error);
1915
+ }
1916
+ });
1917
+
1918
+ toolMap.set("browser.memory.delete", async (args) => {
1919
+ const id = requireStringArg(args, "id");
1920
+ if (!id.ok) {
1921
+ return id;
1922
+ }
1923
+
1924
+ try {
1925
+ return createOk({
1926
+ deleted: await navigationMemoryStore.delete(id.data)
1927
+ });
1928
+ } catch (error) {
1929
+ return mapNavigationMemoryError(error);
1930
+ }
1931
+ });
1932
+
1933
+ toolMap.set("browser.memory.purge", async () => {
1934
+ try {
1935
+ return createOk({
1936
+ purged: await navigationMemoryStore.purgeExpired()
1937
+ });
1938
+ } catch (error) {
1939
+ return mapNavigationMemoryError(error);
1940
+ }
1941
+ });
1942
+
1943
+ toolMap.set("browser.memory.mode.set", async (args) => {
1944
+ if (!config.memoryEnabled) {
1945
+ return createErr(
1946
+ ErrorCode.E_PERMISSION,
1947
+ "Navigation memory is disabled by BROWSERD_MEMORY_ENABLED=false."
1948
+ );
1949
+ }
1950
+
1951
+ const mode = resolveNavigationMemoryMode(args);
1952
+ if (!mode.ok) {
1953
+ return mode;
1954
+ }
1955
+
1956
+ try {
1957
+ return createOk(await navigationMemoryStore.setMode(mode.data));
1958
+ } catch (error) {
1959
+ return mapNavigationMemoryError(error);
803
1960
  }
804
1961
  });
805
1962
 
806
- toolMap.set("browser.profile.use", async (args) => {
807
- const requestedProfile = requireStringArg(args, "profile");
808
- if (!requestedProfile.ok) {
809
- return requestedProfile;
1963
+ toolMap.set("browser.memory.ttl.set", async (args) => {
1964
+ const ttlDays = resolveOptionalIntegerArg(args, "ttlDays", 0);
1965
+ if (!ttlDays.ok) {
1966
+ return ttlDays;
810
1967
  }
811
1968
 
812
- if (!drivers.has(requestedProfile.data)) {
813
- return createErr(ErrorCode.E_NOT_FOUND, `Unknown driver profile: ${requestedProfile.data}`);
1969
+ if (ttlDays.data === undefined) {
1970
+ return createErr(ErrorCode.E_INVALID_ARG, "ttlDays is required and must be an integer greater than or equal to 0.");
814
1971
  }
815
1972
 
816
- sessions.useProfile(args.sessionId, requestedProfile.data);
817
- return createOk({
818
- profile: requestedProfile.data
819
- });
1973
+ try {
1974
+ return createOk(await navigationMemoryStore.setTtlDays(ttlDays.data));
1975
+ } catch (error) {
1976
+ return mapNavigationMemoryError(error);
1977
+ }
820
1978
  });
821
1979
 
822
- toolMap.set("browser.profile.list", async (args) =>
823
- runWithDefaultDriver(args, async (driver, driverKey) => {
824
- const profiles = await driver.listProfiles();
825
- return createOk({
826
- driver: driverKey,
827
- profiles
828
- });
829
- })
830
- );
831
-
832
1980
  toolMap.set("browser.tab.list", async (args) =>
833
1981
  runWithDefaultDriver(args, async (driver, driverKey) => {
834
1982
  const tabs = await driver.listTabs();
@@ -1270,7 +2418,7 @@ function createBrowserdToolMap(
1270
2418
  return requestedPath;
1271
2419
  }
1272
2420
 
1273
- const download = await driver.waitDownload(targetId.data);
2421
+ const download = await driver.waitDownload(targetId.data, undefined, requestedPath.data);
1274
2422
  const normalizedDownload = normalizeDownloadPayload(
1275
2423
  download,
1276
2424
  requestedPath.data,
@@ -1288,6 +2436,211 @@ function createBrowserdToolMap(
1288
2436
  })
1289
2437
  );
1290
2438
 
2439
+ toolMap.set("browser.wait.element", async (args) =>
2440
+ runWithDefaultDriver(args, async (driver, driverKey) => {
2441
+ const targetId = resolveTargetId(args, sessions);
2442
+ if (!targetId.ok) {
2443
+ return targetId;
2444
+ }
2445
+
2446
+ const selector = requireStringArg(args, "selector");
2447
+ if (!selector.ok) {
2448
+ return selector;
2449
+ }
2450
+
2451
+ const timeoutMs = resolveOptionalIntegerArg(args, "timeoutMs", 1);
2452
+ if (!timeoutMs.ok) {
2453
+ return timeoutMs;
2454
+ }
2455
+
2456
+ const pollMs = resolveOptionalIntegerArg(args, "pollMs", 1);
2457
+ if (!pollMs.ok) {
2458
+ return pollMs;
2459
+ }
2460
+
2461
+ const maxWaitMs = timeoutMs.data ?? DEFAULT_NETWORK_WAIT_TIMEOUT_MS;
2462
+ const intervalMs = pollMs.data ?? DEFAULT_NETWORK_WAIT_POLL_MS;
2463
+ const startTime = Date.now();
2464
+
2465
+ while (Date.now() - startTime <= maxWaitMs) {
2466
+ const actionResult = await driver.act(
2467
+ {
2468
+ type: "domQuery",
2469
+ payload: {
2470
+ selector: selector.data
2471
+ }
2472
+ },
2473
+ targetId.data
2474
+ );
2475
+ const data = resolveDriverActionData(actionResult, "domQuery");
2476
+ if (!data.ok) {
2477
+ return data;
2478
+ }
2479
+
2480
+ if (data.data.found === true) {
2481
+ return createOk({
2482
+ driver: driverKey,
2483
+ targetId: targetId.data,
2484
+ selector: selector.data,
2485
+ element: data.data,
2486
+ elapsedMs: Date.now() - startTime
2487
+ });
2488
+ }
2489
+
2490
+ await delay(intervalMs);
2491
+ }
2492
+
2493
+ return createErr(
2494
+ ErrorCode.E_TIMEOUT,
2495
+ `Timed out waiting for element: ${selector.data}`
2496
+ );
2497
+ })
2498
+ );
2499
+
2500
+ toolMap.set("browser.wait.text", async (args) =>
2501
+ runWithDefaultDriver(args, async (driver, driverKey) => {
2502
+ const targetId = resolveTargetId(args, sessions);
2503
+ if (!targetId.ok) {
2504
+ return targetId;
2505
+ }
2506
+
2507
+ const text = requireStringArg(args, "text");
2508
+ if (!text.ok) {
2509
+ return text;
2510
+ }
2511
+
2512
+ const selector = resolveOptionalStringArg(args, "selector");
2513
+ if (!selector.ok) {
2514
+ return selector;
2515
+ }
2516
+
2517
+ const timeoutMs = resolveOptionalIntegerArg(args, "timeoutMs", 1);
2518
+ if (!timeoutMs.ok) {
2519
+ return timeoutMs;
2520
+ }
2521
+
2522
+ const pollMs = resolveOptionalIntegerArg(args, "pollMs", 1);
2523
+ if (!pollMs.ok) {
2524
+ return pollMs;
2525
+ }
2526
+
2527
+ const maxWaitMs = timeoutMs.data ?? DEFAULT_NETWORK_WAIT_TIMEOUT_MS;
2528
+ const intervalMs = pollMs.data ?? DEFAULT_NETWORK_WAIT_POLL_MS;
2529
+ const startTime = Date.now();
2530
+
2531
+ while (Date.now() - startTime <= maxWaitMs) {
2532
+ if (selector.data !== undefined) {
2533
+ const actionResult = await driver.act(
2534
+ {
2535
+ type: "domQueryAll",
2536
+ payload: {
2537
+ selector: selector.data
2538
+ }
2539
+ },
2540
+ targetId.data
2541
+ );
2542
+ const data = resolveDriverActionData(actionResult, "domQueryAll");
2543
+ if (!data.ok) {
2544
+ return data;
2545
+ }
2546
+
2547
+ const elements = readElementList(data.data.elements);
2548
+ const matchingElement = elements.find(
2549
+ (element) =>
2550
+ typeof element.text === "string" &&
2551
+ element.text.includes(text.data)
2552
+ );
2553
+ if (matchingElement !== undefined) {
2554
+ return createOk({
2555
+ driver: driverKey,
2556
+ targetId: targetId.data,
2557
+ text: text.data,
2558
+ selector: selector.data,
2559
+ element: matchingElement,
2560
+ elapsedMs: Date.now() - startTime
2561
+ });
2562
+ }
2563
+ } else {
2564
+ const snapshot = await driver.snapshot(targetId.data);
2565
+ if (!isTargetKnownSnapshot(snapshot)) {
2566
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
2567
+ }
2568
+
2569
+ const html = readSnapshotHtml(snapshot);
2570
+ if (html !== undefined && html.includes(text.data)) {
2571
+ return createOk({
2572
+ driver: driverKey,
2573
+ targetId: targetId.data,
2574
+ text: text.data,
2575
+ elapsedMs: Date.now() - startTime,
2576
+ matchedIn: "snapshot.html"
2577
+ });
2578
+ }
2579
+ }
2580
+
2581
+ await delay(intervalMs);
2582
+ }
2583
+
2584
+ return createErr(
2585
+ ErrorCode.E_TIMEOUT,
2586
+ `Timed out waiting for text: ${text.data}`
2587
+ );
2588
+ })
2589
+ );
2590
+
2591
+ toolMap.set("browser.wait.url", async (args) =>
2592
+ runWithDefaultDriver(args, async (driver, driverKey) => {
2593
+ const targetId = resolveTargetId(args, sessions);
2594
+ if (!targetId.ok) {
2595
+ return targetId;
2596
+ }
2597
+
2598
+ const urlPattern = requireStringArg(args, "urlPattern");
2599
+ if (!urlPattern.ok) {
2600
+ return urlPattern;
2601
+ }
2602
+
2603
+ const timeoutMs = resolveOptionalIntegerArg(args, "timeoutMs", 1);
2604
+ if (!timeoutMs.ok) {
2605
+ return timeoutMs;
2606
+ }
2607
+
2608
+ const pollMs = resolveOptionalIntegerArg(args, "pollMs", 1);
2609
+ if (!pollMs.ok) {
2610
+ return pollMs;
2611
+ }
2612
+
2613
+ const maxWaitMs = timeoutMs.data ?? DEFAULT_NETWORK_WAIT_TIMEOUT_MS;
2614
+ const intervalMs = pollMs.data ?? DEFAULT_NETWORK_WAIT_POLL_MS;
2615
+ const startTime = Date.now();
2616
+
2617
+ while (Date.now() - startTime <= maxWaitMs) {
2618
+ const snapshot = await driver.snapshot(targetId.data);
2619
+ if (!isTargetKnownSnapshot(snapshot)) {
2620
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
2621
+ }
2622
+
2623
+ const currentUrl = readSnapshotUrl(snapshot);
2624
+ if (currentUrl !== undefined && currentUrl.includes(urlPattern.data)) {
2625
+ return createOk({
2626
+ driver: driverKey,
2627
+ targetId: targetId.data,
2628
+ urlPattern: urlPattern.data,
2629
+ currentUrl,
2630
+ elapsedMs: Date.now() - startTime
2631
+ });
2632
+ }
2633
+
2634
+ await delay(intervalMs);
2635
+ }
2636
+
2637
+ return createErr(
2638
+ ErrorCode.E_TIMEOUT,
2639
+ `Timed out waiting for URL: ${urlPattern.data}`
2640
+ );
2641
+ })
2642
+ );
2643
+
1291
2644
  toolMap.set("browser.console.list", async (args) =>
1292
2645
  runWithDefaultDriver(args, async (driver, driverKey) => {
1293
2646
  const targetId = resolveTargetId(args, sessions);
@@ -1295,16 +2648,45 @@ function createBrowserdToolMap(
1295
2648
  return targetId;
1296
2649
  }
1297
2650
 
2651
+ const consoleType = resolveOptionalStringArg(args, "type");
2652
+ if (!consoleType.ok) {
2653
+ return consoleType;
2654
+ }
2655
+
2656
+ const contains = resolveOptionalStringArg(args, "contains");
2657
+ if (!contains.ok) {
2658
+ return contains;
2659
+ }
2660
+
2661
+ const since = resolveOptionalTimestampArg(args, "since");
2662
+ if (!since.ok) {
2663
+ return since;
2664
+ }
2665
+
2666
+ const limit = resolveOptionalIntegerArg(args, "limit", 1);
2667
+ if (!limit.ok) {
2668
+ return limit;
2669
+ }
2670
+
1298
2671
  const snapshot = await driver.snapshot(targetId.data);
1299
2672
  if (!isTargetKnownSnapshot(snapshot)) {
1300
2673
  return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
1301
2674
  }
1302
2675
 
1303
2676
  if (hasConsoleEntriesGetter(driver)) {
2677
+ const entries = filterConsoleEntries(
2678
+ readConsoleEntries(driver.getConsoleEntries(targetId.data)),
2679
+ {
2680
+ type: consoleType.data,
2681
+ contains: contains.data,
2682
+ sinceMs: since.data,
2683
+ limit: limit.data
2684
+ }
2685
+ );
1304
2686
  return createOk({
1305
2687
  driver: driverKey,
1306
2688
  targetId: targetId.data,
1307
- entries: driver.getConsoleEntries(targetId.data)
2689
+ entries
1308
2690
  });
1309
2691
  }
1310
2692
 
@@ -1316,6 +2698,128 @@ function createBrowserdToolMap(
1316
2698
  })
1317
2699
  );
1318
2700
 
2701
+ toolMap.set("browser.network.list", async (args) =>
2702
+ runWithDefaultDriver(args, async (driver, driverKey) => {
2703
+ const targetId = resolveTargetId(args, sessions);
2704
+ if (!targetId.ok) {
2705
+ return targetId;
2706
+ }
2707
+
2708
+ const urlContains = resolveOptionalStringArg(args, "urlContains");
2709
+ if (!urlContains.ok) {
2710
+ return urlContains;
2711
+ }
2712
+
2713
+ const method = resolveOptionalStringArg(args, "method");
2714
+ if (!method.ok) {
2715
+ return method;
2716
+ }
2717
+
2718
+ const status = resolveOptionalIntegerArg(args, "status", 100);
2719
+ if (!status.ok) {
2720
+ return status;
2721
+ }
2722
+
2723
+ const since = resolveOptionalTimestampArg(args, "since");
2724
+ if (!since.ok) {
2725
+ return since;
2726
+ }
2727
+
2728
+ const limit = resolveOptionalIntegerArg(args, "limit", 1);
2729
+ if (!limit.ok) {
2730
+ return limit;
2731
+ }
2732
+
2733
+ const snapshot = await driver.snapshot(targetId.data);
2734
+ if (!isTargetKnownSnapshot(snapshot)) {
2735
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
2736
+ }
2737
+
2738
+ const requests = filterNetworkSummaries(readNetworkRequestSummaries(snapshot), {
2739
+ urlContains: urlContains.data,
2740
+ method: method.data,
2741
+ status: status.data,
2742
+ sinceMs: since.data,
2743
+ limit: limit.data
2744
+ });
2745
+
2746
+ return createOk({
2747
+ driver: driverKey,
2748
+ targetId: targetId.data,
2749
+ requests
2750
+ });
2751
+ })
2752
+ );
2753
+
2754
+ toolMap.set("browser.network.harExport", async (args) =>
2755
+ runWithDefaultDriver(args, async (driver, driverKey) => {
2756
+ const targetId = resolveTargetId(args, sessions);
2757
+ if (!targetId.ok) {
2758
+ return targetId;
2759
+ }
2760
+
2761
+ const includeBodiesRaw = args.includeBodies;
2762
+ if (includeBodiesRaw !== undefined && typeof includeBodiesRaw !== "boolean") {
2763
+ return createErr(
2764
+ ErrorCode.E_INVALID_ARG,
2765
+ "includeBodies must be a boolean when provided."
2766
+ );
2767
+ }
2768
+ const includeBodies = includeBodiesRaw === true;
2769
+
2770
+ const urlContains = resolveOptionalStringArg(args, "urlContains");
2771
+ if (!urlContains.ok) {
2772
+ return urlContains;
2773
+ }
2774
+
2775
+ const method = resolveOptionalStringArg(args, "method");
2776
+ if (!method.ok) {
2777
+ return method;
2778
+ }
2779
+
2780
+ const status = resolveOptionalIntegerArg(args, "status", 100);
2781
+ if (!status.ok) {
2782
+ return status;
2783
+ }
2784
+
2785
+ const since = resolveOptionalTimestampArg(args, "since");
2786
+ if (!since.ok) {
2787
+ return since;
2788
+ }
2789
+
2790
+ const limit = resolveOptionalIntegerArg(args, "limit", 1);
2791
+ if (!limit.ok) {
2792
+ return limit;
2793
+ }
2794
+
2795
+ const snapshot = await driver.snapshot(targetId.data);
2796
+ if (!isTargetKnownSnapshot(snapshot)) {
2797
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
2798
+ }
2799
+
2800
+ const filteredSummaries = filterNetworkSummaries(readNetworkRequestSummaries(snapshot), {
2801
+ urlContains: urlContains.data,
2802
+ method: method.data,
2803
+ status: status.data,
2804
+ sinceMs: since.data,
2805
+ limit: limit.data
2806
+ });
2807
+ const har = createHarLog(filteredSummaries, includeBodies, (requestId) => {
2808
+ if (!hasNetworkResponseBodyGetter(driver)) {
2809
+ return undefined;
2810
+ }
2811
+
2812
+ return driver.getNetworkResponseBody(requestId, targetId.data);
2813
+ });
2814
+
2815
+ return createOk({
2816
+ driver: driverKey,
2817
+ targetId: targetId.data,
2818
+ har
2819
+ });
2820
+ })
2821
+ );
2822
+
1319
2823
  toolMap.set("browser.network.waitFor", async (args) =>
1320
2824
  runWithDefaultDriver(args, async (driver, driverKey) => {
1321
2825
  const targetId = resolveTargetId(args, sessions);
@@ -1447,14 +2951,53 @@ function createBrowserdToolMap(
1447
2951
  })
1448
2952
  );
1449
2953
 
2954
+ toolMap.set("browser.trace.get", async (args) => {
2955
+ const limit = resolveOptionalIntegerArg(args, "limit", 1);
2956
+ if (!limit.ok) {
2957
+ return limit;
2958
+ }
2959
+
2960
+ const traceState = traceStateBySession.get(args.sessionId);
2961
+ const steps = applyLimitToTail(traceState?.steps ?? [], limit.data);
2962
+ const keyResponses = applyLimitToTail(traceState?.keyResponses ?? [], limit.data);
2963
+ const screenshots = applyLimitToTail(traceState?.screenshots ?? [], limit.data);
2964
+
2965
+ return createOk({
2966
+ sessionId: args.sessionId,
2967
+ steps,
2968
+ keyResponses,
2969
+ screenshots
2970
+ });
2971
+ });
2972
+
1450
2973
  for (const [toolName, handler] of [...toolMap.entries()]) {
1451
2974
  toolMap.set(toolName, async (args) => {
2975
+ const startedAtMs = Date.now();
1452
2976
  const authorization = authorizeToolCall(args, toolName, config.authToken, allowedScopes);
1453
2977
  if (!authorization.ok) {
2978
+ const finishedAtMs = Date.now();
2979
+ recordTrace(args.sessionId, toolName, args, authorization, startedAtMs, finishedAtMs);
1454
2980
  return authorization;
1455
2981
  }
1456
2982
 
1457
- return await handler(args);
2983
+ const sessionAuthorization = authorizeSessionGovernance(args, sessions, config);
2984
+ if (!sessionAuthorization.ok) {
2985
+ const finishedAtMs = Date.now();
2986
+ recordTrace(args.sessionId, toolName, args, sessionAuthorization, startedAtMs, finishedAtMs);
2987
+ return sessionAuthorization;
2988
+ }
2989
+
2990
+ const domainAuthorization = authorizeDomainAllowlist(toolName, args, config);
2991
+ if (!domainAuthorization.ok) {
2992
+ const finishedAtMs = Date.now();
2993
+ recordTrace(args.sessionId, toolName, args, domainAuthorization, startedAtMs, finishedAtMs);
2994
+ return domainAuthorization;
2995
+ }
2996
+
2997
+ const response = await handler(args);
2998
+ const finishedAtMs = Date.now();
2999
+ recordTrace(args.sessionId, toolName, args, response, startedAtMs, finishedAtMs);
3000
+ return response;
1458
3001
  });
1459
3002
  }
1460
3003
 
@@ -1462,11 +3005,23 @@ function createBrowserdToolMap(
1462
3005
  }
1463
3006
 
1464
3007
  export function createContainer(config: BrowserdConfig = loadBrowserdConfig()): BrowserdContainer {
1465
- const sessions = new SessionStore();
3008
+ const sessions = new SessionStore({
3009
+ ttlMs: config.sessionTtlMs
3010
+ });
1466
3011
  const driverRegistry = new DriverRegistry();
1467
3012
  const drivers = new Map<string, BrowserDriver>();
1468
3013
  const cleanupHandlers: Array<() => void> = [];
1469
3014
 
3015
+ if (config.sessionTtlMs > 0 && config.sessionCleanupIntervalMs > 0) {
3016
+ const cleanupTimer = setInterval(() => {
3017
+ sessions.cleanupExpired();
3018
+ }, config.sessionCleanupIntervalMs);
3019
+ cleanupTimer.unref?.();
3020
+ cleanupHandlers.push(() => {
3021
+ clearInterval(cleanupTimer);
3022
+ });
3023
+ }
3024
+
1470
3025
  function registerDriver(driverKey: string, driver: BrowserDriver): void {
1471
3026
  driverRegistry.register(driverKey, driver);
1472
3027
  drivers.set(driverKey, driver);