surf-cli 2.14.0 → 2.15.0

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.
package/native/host.cjs CHANGED
@@ -48,6 +48,10 @@ const {
48
48
  const { resolveArgs, runPlaybookOp } = require("./playbook-runtime.cjs");
49
49
  const { resolveOp } = require("./playbooks.cjs");
50
50
  const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
51
+ const { BrowserScheduler } = require("./browser-scheduler.cjs");
52
+ const { BrowserSessionStore, validateSessionName } = require("./browser-session-store.cjs");
53
+ const { classifyTool } = require("./tool-scope.cjs");
54
+ const { fromExtensionError, surfError } = require("./surf-error.cjs");
51
55
  const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
52
56
  const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
53
57
  ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
@@ -379,6 +383,77 @@ const activeStreams = new Map();
379
383
  const socketContexts = new WeakMap();
380
384
  const socketWriters = new WeakMap();
381
385
  let requestCounter = 0;
386
+ const browserSessionStore = new BrowserSessionStore();
387
+ let browserIdentity = null;
388
+ const browserIdentityWaiters = new Set();
389
+ const transientFrameContexts = new Map();
390
+
391
+ function setBrowserIdentity(value) {
392
+ if (!value?.browserInstanceId || !value?.browserEpoch) return;
393
+ const identityChanged = browserIdentity && (
394
+ browserIdentity.browserInstanceId !== value.browserInstanceId ||
395
+ browserIdentity.browserEpoch !== value.browserEpoch
396
+ );
397
+ if (identityChanged) transientFrameContexts.clear();
398
+ browserIdentity = {
399
+ browserInstanceId: value.browserInstanceId,
400
+ browserEpoch: value.browserEpoch,
401
+ extensionVersion: value.extensionVersion,
402
+ protocolVersion: value.protocolVersion,
403
+ capabilities: Array.isArray(value.capabilities) ? value.capabilities : [],
404
+ };
405
+ for (const waiter of browserIdentityWaiters) waiter.resolve(browserIdentity);
406
+ browserIdentityWaiters.clear();
407
+ log(`Browser identity connected: ${browserIdentity.browserInstanceId} epoch=${browserIdentity.browserEpoch}`);
408
+ }
409
+
410
+ function requireBrowserIdentity(timeoutMs = 5000) {
411
+ if (browserIdentity) return Promise.resolve(browserIdentity);
412
+ return new Promise((resolve, reject) => {
413
+ const waiter = { resolve, reject, timer: null };
414
+ waiter.timer = setTimeout(() => {
415
+ browserIdentityWaiters.delete(waiter);
416
+ reject(surfError("extension_identity_missing", "Surf extension identity is unavailable. Restart the browser and retry."));
417
+ }, timeoutMs);
418
+ waiter.resolve = (identity) => {
419
+ clearTimeout(waiter.timer);
420
+ resolve(identity);
421
+ };
422
+ browserIdentityWaiters.add(waiter);
423
+ });
424
+ }
425
+
426
+ function handleTargetEvent(message) {
427
+ if (!browserIdentity) return;
428
+ try {
429
+ if (message.event === "tab-removed" && Number.isInteger(message.tabId)) {
430
+ clearTransientFrameContextsByTab(message.tabId);
431
+ browserSessionStore.invalidateByTab(browserIdentity, message.tabId, "tab_gone");
432
+ for (const [streamId, stream] of activeStreams) {
433
+ if (stream.tabId === message.tabId) stopActiveStream(streamId, { notifyExtension: false });
434
+ }
435
+ return;
436
+ }
437
+ if (message.event === "window-removed" && Number.isInteger(message.windowId)) {
438
+ clearTransientFrameContextsByWindow(message.windowId);
439
+ browserSessionStore.invalidateByWindow(browserIdentity, message.windowId, "window_gone");
440
+ for (const [streamId, stream] of activeStreams) {
441
+ if (stream.windowId === message.windowId) stopActiveStream(streamId, { notifyExtension: false });
442
+ }
443
+ return;
444
+ }
445
+ if ((message.event === "navigation" || message.event === "frame-reset") && Number.isInteger(message.tabId)) {
446
+ clearFrameContextsByTab(message.tabId, message.reason || message.event);
447
+ if (message.event === "frame-reset") return;
448
+ const patch = { lastValidatedAt: new Date().toISOString() };
449
+ if (typeof message.url === "string") patch.lastUrl = message.url;
450
+ if (typeof message.title === "string") patch.lastTitle = message.title;
451
+ browserSessionStore.updateTabMetadata(browserIdentity, message.tabId, patch);
452
+ }
453
+ } catch (error) {
454
+ log(`Target event state update failed: ${error.message}`);
455
+ }
456
+ }
382
457
 
383
458
  function auditSession(event) {
384
459
  const context = event.context;
@@ -392,10 +467,19 @@ function auditSession(event) {
392
467
  peer: context?.socket?.remoteAddress || "local",
393
468
  requestId: request?.id,
394
469
  tool: request?.tool,
470
+ session: request?.target?.session || event.session,
471
+ laneKey: request?.laneKey || event.laneKey,
472
+ scope: request?.scope || event.scope,
473
+ resourceKeys: request?.resourceKeys || event.resourceKeys,
474
+ queueMs: event.queueMs,
395
475
  elapsedMs: event.elapsedMs,
396
476
  })}`);
397
477
  }
398
478
 
479
+ const browserScheduler = new BrowserScheduler({
480
+ audit: (event) => auditSession(event),
481
+ });
482
+
399
483
  aiQueue = new BoundedAiQueue({
400
484
  maxQueued: 8,
401
485
  audit: (event) => auditSession(event),
@@ -458,9 +542,575 @@ function requestCallExtension(request, tool, message, timeoutMs = 30000, cleanup
458
542
  });
459
543
  }
460
544
 
545
+
546
+ async function requestExtensionOrThrow(request, tool, message, timeoutMs = 30000, cleanup = false) {
547
+ const result = await requestCallExtension(request, tool, message, timeoutMs, cleanup);
548
+ const error = fromExtensionError(result);
549
+ if (error) throw error;
550
+ return result;
551
+ }
552
+
553
+ function positiveId(value, name) {
554
+ if (value === undefined || value === null || value === "") return undefined;
555
+ const parsed = Number(value);
556
+ if (!Number.isInteger(parsed) || parsed <= 0) {
557
+ throw surfError("target_invalid", `${name} must be a positive integer`);
558
+ }
559
+ return parsed;
560
+ }
561
+
562
+ function targetLaneKey(identity, tabId) {
563
+ return `tab:${identity.browserEpoch}:${tabId}`;
564
+ }
565
+
566
+ const FRAME_CONTEXT_MESSAGE_TYPES = new Set([
567
+ "CLICK_TYPE", "CLICK_TYPE_SUBMIT", "AUTOCOMPLETE_SELECT", "SMART_TYPE",
568
+ "READ_PAGE", "GET_ELEMENT_COORDINATES", "FORM_INPUT", "EVAL_IN_PAGE",
569
+ "SCROLL_TO_ELEMENT", "LOCATE_ROLE", "LOCATE_TEXT", "LOCATE_LABEL",
570
+ "GET_ELEMENT_STYLES", "SELECT_OPTION", "CLICK_REF", "HOVER_REF",
571
+ "CLICK_SELECTOR", "WAIT_FOR_ELEMENT", "FORM_FILL", "UPLOAD_FILE", "SEARCH_PAGE",
572
+ ]);
573
+
574
+ function transientFrameContextKey(target) {
575
+ if (!target?.browserInstanceId || !target?.browserEpoch || !target?.tabId) return null;
576
+ return `${target.browserInstanceId}:${target.browserEpoch}:${target.tabId}`;
577
+ }
578
+
579
+ function clearTransientFrameContextsByTab(tabId) {
580
+ for (const [key, context] of transientFrameContexts) {
581
+ if (context.tabId === tabId) transientFrameContexts.delete(key);
582
+ }
583
+ }
584
+
585
+ function clearTransientFrameContextsByWindow(windowId) {
586
+ for (const [key, context] of transientFrameContexts) {
587
+ if (context.windowId === windowId) transientFrameContexts.delete(key);
588
+ }
589
+ }
590
+
591
+ function clearFrameContextsByTab(tabId, reason) {
592
+ clearTransientFrameContextsByTab(tabId);
593
+ if (!browserIdentity) return;
594
+ browserSessionStore.updateTabMetadata(browserIdentity, tabId, {
595
+ frameContext: null,
596
+ frameContextResetReason: reason,
597
+ frameContextResetAt: new Date().toISOString(),
598
+ });
599
+ }
600
+
601
+ function currentFrameContext(request) {
602
+ const target = request?.target;
603
+ if (!target?.tabId) return null;
604
+ if (target.session) {
605
+ const record = browserSessionStore.get(request.browserIdentity, target.session);
606
+ const context = record?.frameContext;
607
+ if (
608
+ context &&
609
+ context.browserEpoch === request.browserIdentity?.browserEpoch &&
610
+ context.tabId === target.tabId &&
611
+ Number.isInteger(context.frameId) &&
612
+ context.frameId > 0
613
+ ) return context;
614
+ return null;
615
+ }
616
+ const key = transientFrameContextKey(target);
617
+ return key ? transientFrameContexts.get(key) || null : null;
618
+ }
619
+
620
+ function applyFrameContextToMessage(request, extensionMessage) {
621
+ if (!extensionMessage || !FRAME_CONTEXT_MESSAGE_TYPES.has(extensionMessage.type)) return;
622
+ const context = currentFrameContext(request);
623
+ if (context) extensionMessage.frameId = context.frameId;
624
+ }
625
+
626
+ function persistFrameContext(request, frameId, url) {
627
+ const target = request?.target;
628
+ if (!target?.tabId || !Number.isInteger(frameId) || frameId <= 0) return;
629
+ const context = {
630
+ frameId,
631
+ url,
632
+ tabId: target.tabId,
633
+ windowId: target.windowId,
634
+ browserEpoch: request.browserIdentity?.browserEpoch,
635
+ selectedAt: new Date().toISOString(),
636
+ };
637
+ if (target.session) {
638
+ browserSessionStore.update(request.browserIdentity, target.session, {
639
+ frameContext: context,
640
+ frameContextResetReason: null,
641
+ frameContextResetAt: null,
642
+ });
643
+ return;
644
+ }
645
+ const key = transientFrameContextKey(target);
646
+ if (key) transientFrameContexts.set(key, context);
647
+ }
648
+
649
+ function clearFrameContextForRequest(request, reason) {
650
+ const target = request?.target;
651
+ if (!target?.tabId) return;
652
+ if (target.session) {
653
+ const record = browserSessionStore.get(request.browserIdentity, target.session);
654
+ if (record) {
655
+ browserSessionStore.update(request.browserIdentity, target.session, {
656
+ frameContext: null,
657
+ frameContextResetReason: reason,
658
+ frameContextResetAt: new Date().toISOString(),
659
+ });
660
+ }
661
+ return;
662
+ }
663
+ const key = transientFrameContextKey(target);
664
+ if (key) transientFrameContexts.delete(key);
665
+ }
666
+
667
+ function updateFrameContextFromResult(request, tool, result) {
668
+ if (!request || !result || result.error) return;
669
+ if (tool === "frame.switch" && Number.isInteger(result.frameId) && result.frameId > 0) {
670
+ persistFrameContext(request, result.frameId, result.url);
671
+ } else if (tool === "frame.main") {
672
+ clearFrameContextForRequest(request, "explicit-main-frame");
673
+ }
674
+ }
675
+
676
+ function handleFrameContextFailure(request, result) {
677
+ if (!request || result?.errorCode !== "frame_context_reset") return;
678
+ clearFrameContextForRequest(request, result.errorDetails?.reason || "frame-context-reset");
679
+ if (request.target?.session) {
680
+ result.errorDetails = {
681
+ ...(result.errorDetails || {}),
682
+ session: request.target.session,
683
+ recoveryCommand: `surf --session ${request.target.session} frame.list`,
684
+ };
685
+ }
686
+ }
687
+
688
+ function sessionQueueState(identity, record) {
689
+ const laneKey = targetLaneKey(identity, record.tabId);
690
+ const stats = browserScheduler.stats({ laneKey });
691
+ const activeOthers = stats.activeTabLanes.filter((entry) => entry.laneKey !== laneKey);
692
+ return {
693
+ laneKey,
694
+ active: stats.lane?.active || false,
695
+ queued: stats.lane?.queued || 0,
696
+ blockedBy: stats.lane?.blockedBy || null,
697
+ browserWriter: stats.writer,
698
+ queuedBrowserWriters: stats.queuedWriters,
699
+ otherActiveTabLanes: activeOthers,
700
+ totalQueued: stats.queued,
701
+ };
702
+ }
703
+
704
+ async function inspectBrowserTab(request, tabId) {
705
+ return requestExtensionOrThrow(request, "target.inspect", {
706
+ type: "TARGET_INSPECT",
707
+ tabId,
708
+ });
709
+ }
710
+
711
+ function sessionFailure(code, message, record) {
712
+ return surfError(code, message, {
713
+ session: record.name,
714
+ lastUrl: record.lastUrl,
715
+ target: { tabId: record.tabId, windowId: record.windowId },
716
+ browserEpoch: browserIdentity?.browserEpoch,
717
+ expectedBrowserEpoch: record.browserEpoch,
718
+ recoveryCommand: `surf session.reopen ${record.name}`,
719
+ });
720
+ }
721
+
722
+ async function resolveSessionTarget(request, identity, name) {
723
+ validateSessionName(name);
724
+ const record = browserSessionStore.get(identity, name);
725
+ if (!record) {
726
+ throw surfError("session_unknown", `Unknown session: ${name}`, {
727
+ session: name,
728
+ recoveryCommand: `surf session.ensure ${name} about:blank`,
729
+ });
730
+ }
731
+ if (record.browserEpoch !== identity.browserEpoch) {
732
+ throw sessionFailure(
733
+ "session_epoch_stale",
734
+ `Session ${record.name} belongs to an earlier browser run.`,
735
+ record,
736
+ );
737
+ }
738
+ if (record.invalidReason === "tab_gone" || record.invalidReason === "window_gone") {
739
+ throw sessionFailure("tab_gone", `The tab for session ${record.name} is gone.`, record);
740
+ }
741
+
742
+ let inspected;
743
+ try {
744
+ inspected = await inspectBrowserTab(request, record.tabId);
745
+ } catch (error) {
746
+ if (error?.code === "tab_gone") {
747
+ browserSessionStore.invalidateByTab(identity, record.tabId, "tab_gone");
748
+ throw sessionFailure("tab_gone", `The tab for session ${record.name} is gone.`, record);
749
+ }
750
+ throw error;
751
+ }
752
+ if (record.windowId && inspected.windowId !== record.windowId) {
753
+ throw surfError("binding_mismatch", `Session ${record.name} moved from window ${record.windowId} to ${inspected.windowId}.`, {
754
+ session: record.name,
755
+ target: { tabId: record.tabId, windowId: inspected.windowId },
756
+ recoveryCommand: `surf session.rebind ${record.name} --tab-id ${record.tabId} --replace`,
757
+ });
758
+ }
759
+ const updated = browserSessionStore.replace(identity, record.name, {
760
+ ...record,
761
+ lastUrl: inspected.url || record.lastUrl,
762
+ lastTitle: inspected.title || record.lastTitle,
763
+ lastValidatedAt: new Date().toISOString(),
764
+ });
765
+ return {
766
+ source: "session",
767
+ session: updated.name,
768
+ strict: true,
769
+ tabId: inspected.tabId,
770
+ windowId: inspected.windowId,
771
+ browserInstanceId: identity.browserInstanceId,
772
+ browserEpoch: identity.browserEpoch,
773
+ url: inspected.url,
774
+ title: inspected.title,
775
+ restricted: inspected.restricted,
776
+ };
777
+ }
778
+
779
+ async function resolveRequestTarget(msg, request, classification) {
780
+ if (classification.targetUse === "host") {
781
+ return { identity: browserIdentity, target: null };
782
+ }
783
+ const identity = await requireBrowserIdentity();
784
+ const args = msg.params?.args || {};
785
+ let sessionName = msg.target?.session || msg.session;
786
+ const sessionSource = msg.target?.source || msg.sessionSource || "explicit";
787
+ const rawTabId = msg.tabId ?? msg.params?.tabId ?? args.tabId;
788
+ const rawWindowId = msg.windowId ?? msg.params?.windowId ?? args.windowId;
789
+ const explicitTabId = positiveId(rawTabId, "tabId");
790
+ const explicitWindowId = positiveId(rawWindowId, "windowId");
791
+
792
+ if (classification.targetUse !== "default-tab") {
793
+ if (sessionName && sessionSource === "explicit" && !String(request.tool).startsWith("session.")) {
794
+ throw surfError("target_not_applicable", `--session does not apply to ${request.tool}`);
795
+ }
796
+ return { identity, target: null };
797
+ }
798
+
799
+ if (sessionName && (explicitTabId || explicitWindowId)) {
800
+ if (sessionSource === "environment") sessionName = undefined;
801
+ else {
802
+ throw surfError("ambiguous_target", "Use either --session or --tab-id/--window-id, not both.", {
803
+ session: sessionName,
804
+ });
805
+ }
806
+ }
807
+
808
+ let target;
809
+ if (sessionName) {
810
+ target = await resolveSessionTarget(request, identity, String(sessionName));
811
+ } else if (explicitTabId) {
812
+ const inspected = await inspectBrowserTab(request, explicitTabId);
813
+ target = {
814
+ source: "explicit-tab",
815
+ strict: true,
816
+ tabId: inspected.tabId,
817
+ windowId: inspected.windowId,
818
+ browserInstanceId: identity.browserInstanceId,
819
+ browserEpoch: identity.browserEpoch,
820
+ url: inspected.url,
821
+ title: inspected.title,
822
+ restricted: inspected.restricted,
823
+ };
824
+ } else {
825
+ const inspected = await requestExtensionOrThrow(request, "target.resolve", {
826
+ type: "TARGET_RESOLVE",
827
+ windowId: explicitWindowId,
828
+ allowCreate: true,
829
+ });
830
+ target = {
831
+ source: explicitWindowId ? "explicit-window" : "legacy-implicit",
832
+ strict: false,
833
+ tabId: inspected.tabId,
834
+ windowId: inspected.windowId,
835
+ browserInstanceId: identity.browserInstanceId,
836
+ browserEpoch: identity.browserEpoch,
837
+ url: inspected.url,
838
+ title: inspected.title,
839
+ restricted: inspected.restricted,
840
+ autoCreated: inspected.autoCreated,
841
+ };
842
+ }
843
+ msg.tabId = target.tabId;
844
+ msg.windowId = target.windowId;
845
+ return { identity, target };
846
+ }
847
+
848
+ async function prepareToolRequest(msg, request) {
849
+ const args = msg.params?.args || {};
850
+ const classification = classifyTool(request.tool, args);
851
+ request.scope = classification.scope;
852
+ request.classification = classification;
853
+ request.resourceKeys = classification.resourceKeys || [];
854
+ const { identity, target } = await resolveRequestTarget(msg, request, classification);
855
+ request.browserIdentity = identity;
856
+ request.target = target;
857
+ request.laneKey = target?.tabId ? targetLaneKey(identity, target.tabId) : undefined;
858
+ if (classification.scope === "provider") {
859
+ request.notice = `${request.tool} uses exclusive browser access; other Surf sessions will queue until it finishes.`;
860
+ }
861
+ request.admissionToken = await browserScheduler.acquire({
862
+ scope: classification.scope,
863
+ laneKey: request.laneKey,
864
+ resourceKeys: request.resourceKeys,
865
+ session: target?.session,
866
+ wait: msg.admission?.wait !== false,
867
+ signal: request.signal,
868
+ request,
869
+ });
870
+ request.queuedMs = Math.max(0, request.admissionToken.acquiredAt - request.admissionToken.queuedAt);
871
+ }
872
+
873
+ function releaseBrowserAdmission(request) {
874
+ if (!request?.admissionToken) return;
875
+ const token = request.admissionToken;
876
+ request.admissionToken = null;
877
+ token.release();
878
+ }
879
+
880
+ async function sessionRecordStatus(identity, request, record, refresh = false) {
881
+ let status = "live";
882
+ let inspected = null;
883
+ if (record.browserEpoch !== identity.browserEpoch) status = "epoch_stale";
884
+ else if (record.invalidReason === "tab_gone" || record.invalidReason === "window_gone") status = "tab_gone";
885
+ else if (refresh) {
886
+ try {
887
+ inspected = await inspectBrowserTab(request, record.tabId);
888
+ if (record.windowId && inspected.windowId !== record.windowId) status = "binding_mismatch";
889
+ else {
890
+ browserSessionStore.replace(identity, record.name, {
891
+ ...record,
892
+ lastUrl: inspected.url || record.lastUrl,
893
+ lastTitle: inspected.title || record.lastTitle,
894
+ lastValidatedAt: new Date().toISOString(),
895
+ });
896
+ }
897
+ } catch (error) {
898
+ if (error?.code === "tab_gone") {
899
+ browserSessionStore.invalidateByTab(identity, record.tabId, "tab_gone");
900
+ status = "tab_gone";
901
+ } else throw error;
902
+ }
903
+ }
904
+ return {
905
+ ...record,
906
+ status,
907
+ currentUrl: inspected?.url,
908
+ currentTitle: inspected?.title,
909
+ queue: sessionQueueState(identity, record),
910
+ };
911
+ }
912
+
913
+ async function createSessionBinding(request, identity, name, args, previous = null) {
914
+ const mode = args.tab === true ? "tab" : args.window === true ? "window" : previous?.mode || "window";
915
+ if (args.tab === true && args.window === true) {
916
+ throw surfError("session_mode_ambiguous", "Use either --window or --tab, not both.", { session: name });
917
+ }
918
+ const url = args.url || previous?.lastUrl || "about:blank";
919
+ const created = await requestExtensionOrThrow(request, "session.create", {
920
+ type: "SESSION_CREATE_TARGET",
921
+ name,
922
+ url,
923
+ mode,
924
+ focused: args.focused === true,
925
+ windowId: mode === "tab" ? positiveId(args.windowId ?? args["window-id"], "windowId") : undefined,
926
+ });
927
+ try {
928
+ const values = {
929
+ tabId: created.tabId,
930
+ windowId: created.windowId,
931
+ browserEpoch: identity.browserEpoch,
932
+ mode,
933
+ ownership: "surf-created",
934
+ lastUrl: created.url || url,
935
+ lastTitle: created.title,
936
+ groupId: created.groupId,
937
+ frameContext: null,
938
+ frameContextResetReason: null,
939
+ frameContextResetAt: null,
940
+ };
941
+ return previous
942
+ ? browserSessionStore.replace(identity, name, values)
943
+ : browserSessionStore.create(identity, name, values);
944
+ } catch (error) {
945
+ await requestExtensionOrThrow(request, "session.cleanup", {
946
+ type: "SESSION_CLOSE_TARGET",
947
+ tabId: created.tabId,
948
+ }, 30000, true).catch(() => {});
949
+ throw error;
950
+ }
951
+ }
952
+
953
+ async function handleBrowserSessionCommand(tool, args, request) {
954
+ const identity = await requireBrowserIdentity();
955
+ const name = args.name;
956
+ if (tool !== "session.list") validateSessionName(name);
957
+
958
+ if (tool === "session.new") {
959
+ if (browserSessionStore.get(identity, name)) {
960
+ throw surfError("session_exists", `Session already exists: ${name}`, {
961
+ session: name,
962
+ recoveryCommand: `surf session.ensure ${name}${args.url ? ` ${args.url}` : ""}`,
963
+ });
964
+ }
965
+ const record = await createSessionBinding(request, identity, name, args);
966
+ return { session: await sessionRecordStatus(identity, request, record, false), created: true };
967
+ }
968
+
969
+ if (tool === "session.ensure") {
970
+ const existing = browserSessionStore.get(identity, name);
971
+ if (!existing) {
972
+ const record = await createSessionBinding(request, identity, name, args);
973
+ return { session: await sessionRecordStatus(identity, request, record, false), created: true };
974
+ }
975
+ if (existing.browserEpoch === identity.browserEpoch && !existing.invalidReason) {
976
+ try {
977
+ const target = await resolveSessionTarget(request, identity, name);
978
+ const record = browserSessionStore.get(identity, name);
979
+ return { session: await sessionRecordStatus(identity, request, record, false), created: false, target };
980
+ } catch (error) {
981
+ if (error?.code !== "tab_gone" && error?.code !== "session_epoch_stale") throw error;
982
+ }
983
+ }
984
+ const record = await createSessionBinding(request, identity, name, args, existing);
985
+ return { session: await sessionRecordStatus(identity, request, record, false), created: false, reopened: true };
986
+ }
987
+
988
+ if (tool === "session.list") {
989
+ const records = browserSessionStore.list(identity);
990
+ const sessions = [];
991
+ for (const record of records) sessions.push(await sessionRecordStatus(identity, request, record, args.refresh === true));
992
+ return { sessions, browser: identity, scheduler: browserScheduler.stats() };
993
+ }
994
+
995
+ const existing = browserSessionStore.get(identity, name);
996
+ if (!existing) {
997
+ throw surfError("session_unknown", `Unknown session: ${name}`, {
998
+ session: name,
999
+ recoveryCommand: `surf session.ensure ${name} about:blank`,
1000
+ });
1001
+ }
1002
+
1003
+ if (tool === "session.info") {
1004
+ const session = await sessionRecordStatus(identity, request, existing, args.refresh === true);
1005
+ return {
1006
+ session,
1007
+ browser: identity,
1008
+ sharedProfile: "Cookies, authentication, same-origin storage, downloads, history, bookmarks, and other profile state are shared across Surf sessions.",
1009
+ };
1010
+ }
1011
+
1012
+ if (tool === "session.close") {
1013
+ const shouldCloseTarget = args["keep-target"] !== true && (
1014
+ args["close-target"] === true || existing.ownership === "surf-created"
1015
+ );
1016
+ if (shouldCloseTarget && existing.browserEpoch === identity.browserEpoch) {
1017
+ await requestExtensionOrThrow(request, "session.close", {
1018
+ type: "SESSION_CLOSE_TARGET",
1019
+ tabId: existing.tabId,
1020
+ }, 30000, true).catch((error) => {
1021
+ if (error?.code !== "tab_gone") throw error;
1022
+ });
1023
+ }
1024
+ browserSessionStore.remove(identity, name);
1025
+ return { success: true, name: existing.name, tabId: existing.tabId, targetClosed: shouldCloseTarget };
1026
+ }
1027
+
1028
+ if (tool === "session.rebind") {
1029
+ const tabId = positiveId(args.tabId ?? args["tab-id"], "tabId");
1030
+ if (!tabId) throw surfError("target_required", "session.rebind requires --tab-id <id>", { session: name });
1031
+ if (existing.browserEpoch === identity.browserEpoch && !existing.invalidReason && args.replace !== true) {
1032
+ throw surfError("session_live", `Session ${name} still has a live binding. Pass --replace to rebind it.`, {
1033
+ session: name,
1034
+ recoveryCommand: `surf session.rebind ${name} --tab-id ${tabId} --replace`,
1035
+ });
1036
+ }
1037
+ const conflict = browserSessionStore.findByTab(identity, tabId, name);
1038
+ if (conflict) {
1039
+ throw surfError("tab_already_bound", `Tab ${tabId} is already bound to session ${conflict.name}.`, {
1040
+ session: conflict.name,
1041
+ });
1042
+ }
1043
+ const inspected = await inspectBrowserTab(request, tabId);
1044
+ const record = browserSessionStore.replace(identity, name, {
1045
+ tabId,
1046
+ windowId: inspected.windowId,
1047
+ browserEpoch: identity.browserEpoch,
1048
+ mode: "tab",
1049
+ ownership: "adopted",
1050
+ lastUrl: inspected.url,
1051
+ lastTitle: inspected.title,
1052
+ frameContext: null,
1053
+ frameContextResetReason: null,
1054
+ frameContextResetAt: null,
1055
+ });
1056
+ return { session: await sessionRecordStatus(identity, request, record, false), rebound: true };
1057
+ }
1058
+
1059
+ if (tool === "session.reopen") {
1060
+ if (existing.browserEpoch === identity.browserEpoch && !existing.invalidReason && args.replace !== true) {
1061
+ try {
1062
+ await inspectBrowserTab(request, existing.tabId);
1063
+ throw surfError("session_live", `Session ${name} is still live. Pass --replace to reopen it.`, {
1064
+ session: name,
1065
+ recoveryCommand: `surf session.reopen ${name} --replace`,
1066
+ });
1067
+ } catch (error) {
1068
+ if (error?.code !== "tab_gone") throw error;
1069
+ }
1070
+ }
1071
+ if (args.replace === true && existing.ownership === "surf-created" && existing.browserEpoch === identity.browserEpoch) {
1072
+ await requestExtensionOrThrow(request, "session.close", {
1073
+ type: "SESSION_CLOSE_TARGET",
1074
+ tabId: existing.tabId,
1075
+ }, 30000, true).catch(() => {});
1076
+ }
1077
+ const record = await createSessionBinding(request, identity, name, args, existing);
1078
+ return { session: await sessionRecordStatus(identity, request, record, false), reopened: true };
1079
+ }
1080
+
1081
+ throw surfError("unknown_tool", `Unknown browser session command: ${tool}`);
1082
+ }
1083
+
1084
+ async function handleNamedTabCommand(tool, args, request) {
1085
+ const identity = await requireBrowserIdentity();
1086
+ if (tool === "tab.name" || tool === "tabs_register") {
1087
+ const name = args.name;
1088
+ validateSessionName(name);
1089
+ const target = request.target;
1090
+ if (!target?.tabId) throw surfError("target_required", "tab.name requires a resolved tab");
1091
+ return browserSessionStore.setNamedTab(identity, name, {
1092
+ tabId: target.tabId,
1093
+ windowId: target.windowId,
1094
+ lastUrl: target.url,
1095
+ lastTitle: target.title,
1096
+ });
1097
+ }
1098
+ if (tool === "tab.unname" || tool === "tabs_unregister") {
1099
+ const removed = browserSessionStore.removeNamedTab(identity, args.name);
1100
+ if (!removed) throw surfError("named_tab_unknown", `No named tab: ${args.name}`);
1101
+ return { success: true, name: removed.name };
1102
+ }
1103
+ if (tool === "tab.named" || tool === "tabs_list_named") {
1104
+ return { tabs: browserSessionStore.listNamedTabs(identity) };
1105
+ }
1106
+ return null;
1107
+ }
1108
+
461
1109
  async function executeMappedHostTool(request, tool, args, tabId) {
462
1110
  const extensionMsg = mapToolToMessage(tool, args, tabId);
463
1111
  if (!extensionMsg) throw new Error(`Unknown tool: ${tool}`);
1112
+ if (request.target?.strict) extensionMsg.strictTarget = true;
1113
+ applyFrameContextToMessage(request, extensionMsg);
464
1114
  if (extensionMsg.type === "UNSUPPORTED_ACTION") throw new Error(extensionMsg.message);
465
1115
  if (extensionMsg.type === "LOCAL_WAIT") {
466
1116
  await abortableDelay(extensionMsg.seconds * 1000, request.signal);
@@ -469,7 +1119,7 @@ async function executeMappedHostTool(request, tool, args, tabId) {
469
1119
  if (extensionMsg.type === "BATCH_EXECUTE" || extensionMsg.type.endsWith("_QUERY")) {
470
1120
  throw new Error(`tool ${tool} is not available inside a host-owned workflow`);
471
1121
  }
472
- return requestCallExtension(request, tool, extensionMsg, resolveRequestDeadlineMs(tool, args));
1122
+ return requestExtensionOrThrow(request, tool, extensionMsg, resolveRequestDeadlineMs(tool, args));
473
1123
  }
474
1124
 
475
1125
  async function executeNativePlaybook(request, handler, args, options = {}) {
@@ -595,6 +1245,7 @@ const sessionManager = new HostSessionManager({
595
1245
  };
596
1246
  cleanupRequestTransfers(request)
597
1247
  .then(() => {
1248
+ releaseBrowserAdmission(request);
598
1249
  sessionManager.complete(context, request.id, "hard-timeout");
599
1250
  if (!context.closed) return sendSocket(context.socket, response);
600
1251
  })
@@ -646,12 +1297,14 @@ function completeOwnedRequest(context, id, outcome) {
646
1297
  request.completionOutcome = outcome;
647
1298
  request.completionPromise = new Promise((resolve) => {
648
1299
  pendingToolRequests.onDrain(request, () => {
1300
+ releaseBrowserAdmission(request);
649
1301
  sessionManager.complete(context, id, request.completionOutcome);
650
1302
  resolve();
651
1303
  });
652
1304
  });
653
1305
  return request.completionPromise;
654
1306
  }
1307
+ releaseBrowserAdmission(request);
655
1308
  sessionManager.complete(context, id, outcome);
656
1309
  return Promise.resolve();
657
1310
  }
@@ -715,6 +1368,17 @@ function sendToolResponse(socket, id, result, error) {
715
1368
  : finalError ? "error" : "completed";
716
1369
  await completeOwnedRequest(context, id, outcome);
717
1370
  const response = { type: "tool_response", id };
1371
+ if (request?.target) {
1372
+ response.target = {
1373
+ source: request.target.source,
1374
+ session: request.target.session,
1375
+ tabId: request.target.tabId,
1376
+ windowId: request.target.windowId,
1377
+ browserEpoch: request.target.browserEpoch,
1378
+ queuedMs: request.queuedMs || 0,
1379
+ };
1380
+ }
1381
+ if (request?.notice) response.notice = request.notice;
718
1382
  if (formattedError) response.error = formattedError;
719
1383
  else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
720
1384
  if (!context?.closed) await sendSocket(socket, response);
@@ -729,15 +1393,28 @@ function stopActiveStream(streamId, { notifyExtension = true } = {}) {
729
1393
  if (notifyExtension) writeMessage({ type: "STREAM_STOP", streamId });
730
1394
  }
731
1395
 
732
- function handleStreamRequest(msg, socket) {
1396
+ async function resolveStreamRequest(msg) {
1397
+ const tool = msg.streamType === "STREAM_CONSOLE" ? "console" : "network";
1398
+ const controller = new AbortController();
1399
+ const request = { tool, signal: controller.signal };
1400
+ const classification = classifyTool(tool, msg.options || {});
1401
+ const { target } = await resolveRequestTarget(msg, request, classification);
1402
+ if (!target?.tabId) throw surfError("target_required", `${tool} stream requires a resolved tab`);
1403
+ return target;
1404
+ }
1405
+
1406
+ function handleStreamRequest(msg, socket, target) {
733
1407
  const { streamType, options, id: originalId } = msg;
734
- const tabId = msg.tabId;
1408
+ const tabId = target.tabId;
735
1409
  const streamId = ++requestCounter;
736
1410
 
737
1411
  activeStreams.set(streamId, {
738
1412
  socket,
739
1413
  originalId,
740
1414
  streamType,
1415
+ tabId,
1416
+ windowId: target.windowId,
1417
+ session: target.session,
741
1418
  });
742
1419
 
743
1420
  writeMessage({
@@ -745,9 +1422,20 @@ function handleStreamRequest(msg, socket) {
745
1422
  streamId,
746
1423
  options: options || {},
747
1424
  tabId,
1425
+ strictTarget: target.strict === true,
748
1426
  });
749
1427
 
750
- sendSocket(socket, { type: "stream_started", streamId }, { stream: true }).catch((error) => {
1428
+ sendSocket(socket, {
1429
+ type: "stream_started",
1430
+ streamId,
1431
+ target: {
1432
+ source: target.source,
1433
+ session: target.session,
1434
+ tabId: target.tabId,
1435
+ windowId: target.windowId,
1436
+ browserEpoch: target.browserEpoch,
1437
+ },
1438
+ }, { stream: true }).catch((error) => {
751
1439
  log(`Error sending stream_started: ${error.message}`);
752
1440
  stopActiveStream(streamId);
753
1441
  socket.destroy(error);
@@ -788,6 +1476,18 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
788
1476
  requestContext.args = args || {};
789
1477
  requestContext.activityStartedAt = new Date().toISOString();
790
1478
  if (!tool.startsWith("playbook.")) journalCommand(tool, args || {}, { tabId });
1479
+ if (tool.startsWith("session.")) {
1480
+ handleBrowserSessionCommand(tool, args || {}, requestContext)
1481
+ .then((result) => sendToolResponse(socket, originalId, result, null))
1482
+ .catch((error) => sendToolResponse(socket, originalId, null, error));
1483
+ return;
1484
+ }
1485
+ if (["tab.name", "tabs_register", "tab.unname", "tabs_unregister", "tab.named", "tabs_list_named"].includes(tool)) {
1486
+ handleNamedTabCommand(tool, args || {}, requestContext)
1487
+ .then((result) => sendToolResponse(socket, originalId, result, null))
1488
+ .catch((error) => sendToolResponse(socket, originalId, null, error));
1489
+ return;
1490
+ }
791
1491
  if (tool === "playbook.run") {
792
1492
  runHostPlaybook(msg, requestContext)
793
1493
  .then((result) => sendToolResponse(socket, originalId, { output: JSON.stringify(result) }, null))
@@ -806,6 +1506,8 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
806
1506
  sendToolResponse(socket, originalId, null, `Unknown tool: ${tool}`);
807
1507
  return;
808
1508
  }
1509
+ if (requestContext.target?.strict) extensionMsg.strictTarget = true;
1510
+ applyFrameContextToMessage(requestContext, extensionMsg);
809
1511
 
810
1512
  if (extensionMsg.type === "UNSUPPORTED_ACTION") {
811
1513
  sendToolResponse(socket, originalId, null, extensionMsg.message);
@@ -1535,25 +2237,39 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1535
2237
 
1536
2238
  if (extensionMsg.type === "NAMED_TAB_SWITCH" || extensionMsg.type === "NAMED_TAB_CLOSE") {
1537
2239
  const { name, type: opType } = extensionMsg;
1538
- requestCallExtension(
1539
- requestContext,
1540
- "tabs_get_by_name",
1541
- { type: "TABS_GET_BY_NAME", name },
1542
- ).then((result) => {
1543
- if (result.error || !result.tabId) {
1544
- throw new Error(result.error || `No tab found with name "${name}"`);
2240
+ (async () => {
2241
+ const identity = await requireBrowserIdentity();
2242
+ const named = browserSessionStore.getNamedTab(identity, name);
2243
+ if (!named) {
2244
+ throw surfError("named_tab_unknown", `No named tab: ${name}`, {
2245
+ recoveryCommand: "surf tab.named",
2246
+ });
2247
+ }
2248
+ if (named.browserEpoch !== identity.browserEpoch) {
2249
+ browserSessionStore.removeNamedTab(identity, name);
2250
+ throw surfError("named_tab_stale", `Named tab ${name} belongs to an earlier browser run.`, {
2251
+ recoveryCommand: `surf tab.name ${name} --tab-id ${named.tabId}`,
2252
+ });
2253
+ }
2254
+ try {
2255
+ await inspectBrowserTab(requestContext, named.tabId);
2256
+ } catch (error) {
2257
+ if (error?.code === "tab_gone") browserSessionStore.removeNamedTab(identity, name);
2258
+ throw error;
1545
2259
  }
1546
2260
  const actionType = opType === "NAMED_TAB_SWITCH" ? "SWITCH_TAB" : "CLOSE_TAB";
1547
2261
  const actionTool = opType === "NAMED_TAB_SWITCH" ? "switch_tab" : "close_tab";
1548
- return requestCallExtension(
2262
+ const result = await requestExtensionOrThrow(
1549
2263
  requestContext,
1550
2264
  actionTool,
1551
- { type: actionType, tabId: result.tabId },
2265
+ { type: actionType, tabId: named.tabId },
1552
2266
  30000,
1553
2267
  actionTool === "close_tab",
1554
2268
  );
1555
- }).then((result) => sendToolResponse(socket, originalId, result, result?.error || null))
1556
- .catch((error) => sendToolResponse(socket, originalId, null, error.message));
2269
+ if (actionTool === "close_tab") browserSessionStore.removeNamedTab(identity, name);
2270
+ return result;
2271
+ })().then((result) => sendToolResponse(socket, originalId, result, null))
2272
+ .catch((error) => sendToolResponse(socket, originalId, null, error));
1557
2273
  return;
1558
2274
  }
1559
2275
 
@@ -1605,6 +2321,8 @@ function executeBatch(actions, tabId, socket, originalId, requestContext = reque
1605
2321
  const toolArgs = mapBatchActionToArgs(action);
1606
2322
 
1607
2323
  const extensionMsg = mapToolToMessage(toolName, toolArgs, tabId);
2324
+ if (requestContext.target?.strict && extensionMsg) extensionMsg.strictTarget = true;
2325
+ applyFrameContextToMessage(requestContext, extensionMsg);
1608
2326
  if (!extensionMsg || extensionMsg.type === "UNSUPPORTED_ACTION") {
1609
2327
  results.push({ index: currentIndex, type: action.type, success: false, error: "Unsupported action" });
1610
2328
  sendToolResponse(socket, originalId, {
@@ -1706,6 +2424,16 @@ function processInput() {
1706
2424
  try {
1707
2425
  const msg = JSON.parse(jsonStr);
1708
2426
  log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
2427
+
2428
+ if (msg.type === "EXTENSION_HELLO") {
2429
+ setBrowserIdentity(msg);
2430
+ return;
2431
+ }
2432
+
2433
+ if (msg.type === "TARGET_EVENT") {
2434
+ handleTargetEvent(msg);
2435
+ return;
2436
+ }
1709
2437
 
1710
2438
  if (msg.type === "GET_AUTH") {
1711
2439
  log("Handling GET_AUTH from extension");
@@ -1786,6 +2514,8 @@ function processInput() {
1786
2514
  }
1787
2515
  return;
1788
2516
  }
2517
+ handleFrameContextFailure(pending.request, msg);
2518
+ updateFrameContextFromResult(pending.request, pending.tool, msg);
1789
2519
  if (pending.resolve || pending.onComplete) {
1790
2520
  pendingToolRequests.resolve(msg.id, msg);
1791
2521
  return;
@@ -1857,7 +2587,7 @@ function processInput() {
1857
2587
  .then(() => requestCallExtension(
1858
2588
  pending.request,
1859
2589
  "screenshot",
1860
- { type: "EXECUTE_SCREENSHOT", tabId },
2590
+ { type: "EXECUTE_SCREENSHOT", tabId, strictTarget: pending.request?.target?.strict === true },
1861
2591
  ))
1862
2592
  .then((screenshotMsg) => {
1863
2593
  if (screenshotMsg.base64) {
@@ -1916,7 +2646,7 @@ function processInput() {
1916
2646
  !msg.output && !msg.messages && !msg.requests;
1917
2647
 
1918
2648
  if (isPureError) {
1919
- sendToolResponse(socket, originalId, null, msg.error);
2649
+ sendToolResponse(socket, originalId, null, fromExtensionError(msg) || msg.error);
1920
2650
  } else {
1921
2651
  sendToolResponse(socket, originalId, msg, null);
1922
2652
  }
@@ -1946,6 +2676,12 @@ const connectedSockets = new Set();
1946
2676
 
1947
2677
  process.stdin.on("end", () => {
1948
2678
  log("stdin ended (extension disconnected), notifying clients");
2679
+ browserIdentity = null;
2680
+ for (const waiter of browserIdentityWaiters) {
2681
+ clearTimeout(waiter.timer);
2682
+ waiter.reject(surfError("extension_disconnected", "Surf extension disconnected."));
2683
+ }
2684
+ browserIdentityWaiters.clear();
1949
2685
  for (const socket of Array.from(connectedSockets)) {
1950
2686
  sendSocket(socket, {
1951
2687
  type: "extension_disconnected",
@@ -2066,8 +2802,9 @@ const handleClient = (socket) => {
2066
2802
  let request;
2067
2803
  try {
2068
2804
  const deadlineMs = TEST_REQUEST_DEADLINE_MS || resolveRequestDeadlineMs(tool, msg.params?.args);
2069
- request = await sessionManager.beginRequest(context, { id: msg.id, tool, deadlineMs });
2805
+ request = await sessionManager.beginRequest(context, { id: msg.id, tool, deadlineMs, skipLease: true });
2070
2806
  request.context = context;
2807
+ request.args = msg.params?.args || {};
2071
2808
  } catch (error) {
2072
2809
  if (transferState) await discardRequestTransfers(msg, transferState);
2073
2810
  await sendSocket(socket, { type: "tool_response", id: msg.id || null, error: { content: [{ type: "text", text: error.message }] } }).catch(() => {});
@@ -2078,8 +2815,11 @@ const handleClient = (socket) => {
2078
2815
  if (tool.startsWith("oracle.")) oracleHost.assertLocal(request);
2079
2816
  if (isRemote) {
2080
2817
  await applyRequestTransfers(msg, request, transferState, ensureTransferState);
2818
+ request.args = msg.params?.args || {};
2081
2819
  }
2082
2820
  throwIfAborted(request.signal, "Request cancelled");
2821
+ await prepareToolRequest(msg, request);
2822
+ throwIfAborted(request.signal, "Request cancelled");
2083
2823
  requestStorage.run(request, () => handleToolRequest(msg, socket, request));
2084
2824
  } catch (e) {
2085
2825
  await discardRequestTransfers(msg, transferState);
@@ -2100,7 +2840,14 @@ const handleClient = (socket) => {
2100
2840
  return;
2101
2841
  }
2102
2842
  log(`Handling stream_request: ${msg.streamType}`);
2103
- handleStreamRequest(msg, socket);
2843
+ try {
2844
+ const target = await resolveStreamRequest(msg);
2845
+ handleStreamRequest(msg, socket, target);
2846
+ } catch (error) {
2847
+ sessionManager.stopStream(context);
2848
+ const formatted = formatToolError(error);
2849
+ await sendSocket(socket, { type: "stream_error", error: formatted }).catch(() => {});
2850
+ }
2104
2851
  return;
2105
2852
  }
2106
2853