libretto 0.5.4 → 0.5.6

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 (101) hide show
  1. package/README.md +23 -10
  2. package/README.template.md +23 -10
  3. package/dist/cli/cli.js +10 -0
  4. package/dist/cli/commands/ai.js +77 -2
  5. package/dist/cli/commands/browser.js +71 -6
  6. package/dist/cli/commands/execution.js +101 -44
  7. package/dist/cli/commands/setup.js +376 -0
  8. package/dist/cli/commands/snapshot.js +2 -2
  9. package/dist/cli/commands/status.js +62 -0
  10. package/dist/cli/core/{snapshot-api-config.js → ai-model.js} +81 -7
  11. package/dist/cli/core/api-snapshot-analyzer.js +7 -5
  12. package/dist/cli/core/browser.js +81 -42
  13. package/dist/cli/core/{ai-config.js → config.js} +13 -79
  14. package/dist/cli/core/context.js +1 -25
  15. package/dist/cli/core/deploy-artifact.js +121 -61
  16. package/dist/cli/core/readonly-exec.js +231 -0
  17. package/dist/{shared/llm/client.js → cli/core/resolve-model.js} +4 -68
  18. package/dist/cli/core/session.js +44 -0
  19. package/dist/cli/core/skill-version.js +73 -0
  20. package/dist/cli/core/telemetry.js +1 -54
  21. package/dist/cli/index.js +1 -7
  22. package/dist/cli/router.js +4 -4
  23. package/dist/cli/workers/run-integration-runtime.js +29 -25
  24. package/dist/cli/workers/run-integration-worker-protocol.js +3 -2
  25. package/dist/index.d.ts +2 -4
  26. package/dist/index.js +2 -2
  27. package/dist/runtime/extract/extract.d.ts +2 -2
  28. package/dist/runtime/extract/extract.js +4 -2
  29. package/dist/runtime/extract/index.d.ts +1 -1
  30. package/dist/runtime/recovery/agent.d.ts +2 -3
  31. package/dist/runtime/recovery/agent.js +5 -3
  32. package/dist/runtime/recovery/errors.d.ts +2 -3
  33. package/dist/runtime/recovery/errors.js +4 -2
  34. package/dist/runtime/recovery/index.d.ts +1 -2
  35. package/dist/runtime/recovery/recovery.d.ts +2 -3
  36. package/dist/runtime/recovery/recovery.js +3 -3
  37. package/dist/shared/debug/pause.js +4 -21
  38. package/dist/shared/run/api.d.ts +2 -0
  39. package/dist/shared/run/browser.d.ts +4 -1
  40. package/dist/shared/run/browser.js +5 -3
  41. package/dist/shared/state/index.d.ts +1 -1
  42. package/dist/shared/state/index.js +2 -0
  43. package/dist/shared/state/session-state.d.ts +10 -1
  44. package/dist/shared/state/session-state.js +3 -0
  45. package/dist/shared/workflow/workflow.d.ts +2 -3
  46. package/dist/shared/workflow/workflow.js +16 -9
  47. package/package.json +3 -4
  48. package/scripts/postinstall.mjs +13 -11
  49. package/scripts/skills-libretto.mjs +14 -4
  50. package/skills/AGENTS.md +11 -0
  51. package/skills/libretto/SKILL.md +30 -9
  52. package/skills/libretto/references/auth-profiles.md +1 -1
  53. package/skills/libretto/references/code-generation-rules.md +6 -6
  54. package/skills/libretto/references/configuration-file-reference.md +11 -6
  55. package/skills/libretto-readonly/SKILL.md +95 -0
  56. package/src/cli/cli.ts +10 -0
  57. package/src/cli/commands/ai.ts +111 -1
  58. package/src/cli/commands/browser.ts +81 -7
  59. package/src/cli/commands/execution.ts +128 -61
  60. package/src/cli/commands/setup.ts +499 -0
  61. package/src/cli/commands/snapshot.ts +2 -2
  62. package/src/cli/commands/status.ts +77 -0
  63. package/src/cli/core/{snapshot-api-config.ts → ai-model.ts} +154 -14
  64. package/src/cli/core/api-snapshot-analyzer.ts +7 -5
  65. package/src/cli/core/browser.ts +107 -45
  66. package/src/cli/core/{ai-config.ts → config.ts} +13 -108
  67. package/src/cli/core/context.ts +1 -45
  68. package/src/cli/core/deploy-artifact.ts +141 -71
  69. package/src/cli/core/readonly-exec.ts +284 -0
  70. package/src/{shared/llm/client.ts → cli/core/resolve-model.ts} +3 -85
  71. package/src/cli/core/session.ts +62 -2
  72. package/src/cli/core/skill-version.ts +93 -0
  73. package/src/cli/core/telemetry.ts +0 -52
  74. package/src/cli/index.ts +0 -6
  75. package/src/cli/router.ts +4 -4
  76. package/src/cli/workers/run-integration-runtime.ts +36 -31
  77. package/src/cli/workers/run-integration-worker-protocol.ts +2 -1
  78. package/src/index.ts +1 -7
  79. package/src/runtime/extract/extract.ts +6 -5
  80. package/src/runtime/recovery/agent.ts +5 -4
  81. package/src/runtime/recovery/errors.ts +4 -3
  82. package/src/runtime/recovery/recovery.ts +4 -4
  83. package/src/shared/debug/pause.ts +4 -23
  84. package/src/shared/run/browser.ts +5 -1
  85. package/src/shared/state/index.ts +2 -0
  86. package/src/shared/state/session-state.ts +3 -0
  87. package/src/shared/workflow/workflow.ts +24 -15
  88. package/dist/cli/commands/init.js +0 -286
  89. package/dist/cli/commands/logs.js +0 -117
  90. package/dist/shared/llm/ai-sdk-adapter.d.ts +0 -22
  91. package/dist/shared/llm/ai-sdk-adapter.js +0 -49
  92. package/dist/shared/llm/client.d.ts +0 -13
  93. package/dist/shared/llm/index.d.ts +0 -5
  94. package/dist/shared/llm/index.js +0 -6
  95. package/dist/shared/llm/types.d.ts +0 -67
  96. package/dist/shared/llm/types.js +0 -0
  97. package/src/cli/commands/init.ts +0 -331
  98. package/src/cli/commands/logs.ts +0 -128
  99. package/src/shared/llm/ai-sdk-adapter.ts +0 -81
  100. package/src/shared/llm/index.ts +0 -3
  101. package/src/shared/llm/types.ts +0 -63
@@ -1,6 +1,4 @@
1
1
  import { Logger, createFileLogSink } from "../../shared/logger/index.js";
2
- import type { LLMClient } from "../../shared/llm/index.js";
3
- import type { LoggerApi } from "../../shared/logger/index.js";
4
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
3
  import { join } from "node:path";
6
4
  import { resolveLibrettoRepoRoot } from "../../shared/paths/repo-root.js";
@@ -72,13 +70,6 @@ export function createLoggerForSession(session: string): Logger {
72
70
  );
73
71
  }
74
72
 
75
- export async function closeLogger(
76
- logger: Logger | null | undefined,
77
- ): Promise<void> {
78
- if (!logger) return;
79
- await logger.close();
80
- }
81
-
82
73
  export async function withSessionLogger<T>(
83
74
  session: string,
84
75
  run: (logger: Logger) => Promise<T>,
@@ -87,41 +78,6 @@ export async function withSessionLogger<T>(
87
78
  try {
88
79
  return await run(logger);
89
80
  } finally {
90
- await closeLogger(logger);
81
+ await logger.close();
91
82
  }
92
83
  }
93
-
94
- let llmClientFactory:
95
- | ((logger: LoggerApi, model: string) => Promise<LLMClient>)
96
- | null = null;
97
-
98
- export function setLLMClientFactory(
99
- factory: (logger: LoggerApi, model: string) => Promise<LLMClient>,
100
- ): void {
101
- llmClientFactory = factory;
102
- }
103
-
104
- export function getLLMClientFactory():
105
- | ((logger: LoggerApi, model: string) => Promise<LLMClient>)
106
- | null {
107
- return llmClientFactory;
108
- }
109
-
110
- export function maybeConfigureLLMClientFactoryFromEnv(): void {
111
- if (llmClientFactory) return;
112
-
113
- const hasAnyCreds =
114
- process.env.GOOGLE_CLOUD_PROJECT ||
115
- process.env.GCLOUD_PROJECT ||
116
- process.env.ANTHROPIC_API_KEY ||
117
- process.env.OPENAI_API_KEY ||
118
- process.env.GEMINI_API_KEY ||
119
- process.env.GOOGLE_GENERATIVE_AI_API_KEY;
120
-
121
- if (!hasAnyCreds) return;
122
-
123
- setLLMClientFactory(async (_logger, model) => {
124
- const { createLLMClient } = await import("../../shared/llm/index.js");
125
- return createLLMClient(model);
126
- });
127
- }
@@ -10,11 +10,17 @@ import {
10
10
  rmSync,
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
+ import { createRequire, Module } from "node:module";
13
14
  import { tmpdir } from "node:os";
14
15
  import { dirname, isAbsolute, join, resolve } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { gzipSync } from "node:zlib";
17
18
  import { build } from "esbuild";
19
+ import {
20
+ getWorkflowFromModuleExports,
21
+ getWorkflowsFromModuleExports,
22
+ LIBRETTO_WORKFLOW_BRAND,
23
+ } from "../../shared/workflow/workflow.js";
18
24
 
19
25
  type PackageManifest = {
20
26
  name?: string;
@@ -81,6 +87,7 @@ const CURRENT_LIBRETTO_VERSION = readCurrentLibrettoVersion();
81
87
  const CURRENT_LIBRETTO_PACKAGE_DIR = fileURLToPath(
82
88
  new URL("../../..", import.meta.url),
83
89
  );
90
+ const require = createRequire(import.meta.url);
84
91
 
85
92
  function readCurrentLibrettoVersion(): string {
86
93
  const packageJsonPath = fileURLToPath(
@@ -654,46 +661,141 @@ function formatBuildError(error: unknown): string {
654
661
  .join("\n");
655
662
  }
656
663
 
657
- function extractExportNamesFromEsmBundle(bundleSource: string): string[] {
658
- const exportNames = new Set<string>();
664
+ function getGeneratedWorkflowExportName(index: number): string {
665
+ return `workflow_${index}`;
666
+ }
659
667
 
660
- for (const entry of bundleSource.matchAll(
661
- /export\s+(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g,
662
- )) {
663
- exportNames.add(entry[1]!);
668
+ function getPackageNameFromImportPath(importPath: string): string {
669
+ if (importPath.startsWith("@")) {
670
+ return importPath.split("/").slice(0, 2).join("/");
664
671
  }
672
+ return importPath.split("/")[0] ?? importPath;
673
+ }
665
674
 
666
- for (const entry of bundleSource.matchAll(/export\s+\{([^}]+)\};/g)) {
667
- const specifiers = entry[1]?.split(",") ?? [];
668
- for (const specifier of specifiers) {
669
- const trimmed = specifier.trim();
670
- if (!trimmed) {
671
- continue;
675
+ function createExternalDiscoveryStub(): object {
676
+ const stub = (() => createExternalDiscoveryStub()) as unknown as ((
677
+ ...args: unknown[]
678
+ ) => object) &
679
+ Record<PropertyKey, unknown>;
680
+
681
+ return new Proxy(stub, {
682
+ apply: () => createExternalDiscoveryStub(),
683
+ construct: () => createExternalDiscoveryStub(),
684
+ get: (_target, property) => {
685
+ if (property === "__esModule") {
686
+ return true;
672
687
  }
673
- const aliasMatch = trimmed.match(
674
- /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*|default)$/,
675
- );
676
- if (aliasMatch?.[2]) {
677
- exportNames.add(aliasMatch[2]);
678
- continue;
688
+ if (property === "default") {
689
+ return createExternalDiscoveryStub();
679
690
  }
680
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
681
- exportNames.add(trimmed);
691
+ if (property === Symbol.toPrimitive) {
692
+ return () => "";
682
693
  }
683
- }
694
+ return createExternalDiscoveryStub();
695
+ },
696
+ });
697
+ }
698
+
699
+ function createDiscoveryLibrettoModule(workflowNames: Set<string>): object {
700
+ const moduleShape: Record<PropertyKey, unknown> = {
701
+ LIBRETTO_WORKFLOW_BRAND,
702
+ workflow: (name: string) => {
703
+ workflowNames.add(name);
704
+ return {
705
+ [LIBRETTO_WORKFLOW_BRAND]: true,
706
+ name,
707
+ async run() {
708
+ return undefined;
709
+ },
710
+ };
711
+ },
712
+ };
713
+
714
+ return new Proxy(moduleShape, {
715
+ get(target, property) {
716
+ if (property in target) {
717
+ return target[property];
718
+ }
719
+ if (property === "__esModule") {
720
+ return true;
721
+ }
722
+ return createExternalDiscoveryStub();
723
+ },
724
+ });
725
+ }
726
+
727
+ function discoverBundledWorkflowNames(args: {
728
+ absEntryPoint: string;
729
+ absSourceDir: string;
730
+ bundleBuffer: Buffer;
731
+ externalPackages: ReadonlySet<string>;
732
+ }): string[] {
733
+ const discoveryPath = join(
734
+ args.absSourceDir,
735
+ `.libretto-deploy-discovery-${process.pid}-${Date.now()}.cjs`,
736
+ );
737
+ const originalRequire = Module.prototype.require;
738
+ const workflowNames = new Set<string>();
739
+ const discoveryLibrettoModule = createDiscoveryLibrettoModule(workflowNames);
740
+ let loadedModuleExports: Record<string, unknown> | null = null;
741
+
742
+ try {
743
+ writeFileSync(discoveryPath, args.bundleBuffer);
744
+ Module.prototype.require = function patchedRequire(id: string) {
745
+ const packageName = getPackageNameFromImportPath(id);
746
+ if (packageName === "libretto") {
747
+ return discoveryLibrettoModule;
748
+ }
749
+ if (packageName !== "libretto" && args.externalPackages.has(packageName)) {
750
+ return createExternalDiscoveryStub();
751
+ }
752
+ return originalRequire.call(this, id);
753
+ };
754
+ loadedModuleExports = require(discoveryPath) as Record<string, unknown>;
755
+ } catch (error) {
756
+ throw new Error(
757
+ `Failed to evaluate deploy entry point ${args.absEntryPoint} while discovering workflows.\n${formatBuildError(error)}`,
758
+ );
759
+ } finally {
760
+ Module.prototype.require = originalRequire;
761
+ delete (require.cache as Record<string, unknown> | undefined)?.[
762
+ discoveryPath
763
+ ];
764
+ rmSync(discoveryPath, { force: true });
684
765
  }
685
766
 
686
- if (/\bexport\s+default\b/m.test(bundleSource)) {
687
- exportNames.add("default");
767
+ const discoveredWorkflowNames = [...workflowNames].sort((left, right) =>
768
+ left.localeCompare(right),
769
+ );
770
+
771
+ if (discoveredWorkflowNames.length === 0) {
772
+ throw new Error(
773
+ `No workflows were found in ${args.absEntryPoint}. Import the workflow files you want to deploy from the entry point, or export a workflow directly from it.`,
774
+ );
688
775
  }
689
776
 
690
- return [...exportNames];
777
+ const exportedWorkflowNames = new Set(
778
+ getWorkflowsFromModuleExports(loadedModuleExports ?? {}).map(
779
+ (workflow) => workflow.name,
780
+ ),
781
+ );
782
+ const nonExportedWorkflowNames = discoveredWorkflowNames.filter(
783
+ (name) => !exportedWorkflowNames.has(name),
784
+ );
785
+
786
+ if (nonExportedWorkflowNames.length > 0) {
787
+ throw new Error(
788
+ `Workflows discovered in ${args.absEntryPoint} must be exported from the deploy entry point. Re-export them from the entry point or export them through a \`workflows\` object. Non-exported workflows: ${nonExportedWorkflowNames.join(", ")}`,
789
+ );
790
+ }
791
+
792
+ return discoveredWorkflowNames;
691
793
  }
692
794
 
693
795
  function createBootstrapSource(args: {
694
796
  bundleBuffer: Buffer;
695
797
  deploymentName: string;
696
- exportNames: readonly string[];
798
+ workflowNames: readonly string[];
697
799
  }): string {
698
800
  const bundleHash = createHash("sha256")
699
801
  .update(args.bundleBuffer)
@@ -703,17 +805,12 @@ function createBootstrapSource(args: {
703
805
  "base64",
704
806
  );
705
807
  const outputPrefix = `${normalizePackageName(args.deploymentName)}-`;
706
- const hasDefaultExport = args.exportNames.includes("default");
707
- const exportLines = args.exportNames
708
- .filter((name) => name !== "default")
808
+ const exportLines = args.workflowNames
709
809
  .map(
710
- (name) =>
711
- `export const ${name} = createWorkflowProxy(${JSON.stringify(name)});`,
810
+ (name, index) =>
811
+ `export const ${getGeneratedWorkflowExportName(index)} = createWorkflowProxy(${JSON.stringify(name)});`,
712
812
  )
713
813
  .join("\n");
714
- const defaultExportLine = hasDefaultExport
715
- ? 'export default createWorkflowProxy("default");'
716
- : "";
717
814
 
718
815
  // The deploy entrypoint is tiny on purpose. Hosted build imports this module
719
816
  // to discover workflow exports. The implementation bundle stays embedded in
@@ -724,7 +821,7 @@ import { existsSync, writeFileSync } from "node:fs";
724
821
  import { tmpdir } from "node:os";
725
822
  import { join } from "node:path";
726
823
  import { gunzipSync } from "node:zlib";
727
- import { workflow } from "libretto";
824
+ import { getWorkflowFromModuleExports, workflow } from "libretto";
728
825
 
729
826
  const BUNDLE_HASH = ${JSON.stringify(bundleHash)};
730
827
  const BUNDLE_GZIP_BASE64 = ${JSON.stringify(bundleBase64)};
@@ -747,13 +844,13 @@ function ensureBundleFile() {
747
844
  return BUNDLE_FILENAME;
748
845
  }
749
846
 
750
- function createWorkflowProxy(exportName) {
751
- return workflow(exportName, async (ctx, input) => {
847
+ function createWorkflowProxy(workflowName) {
848
+ return workflow(workflowName, async (ctx, input) => {
752
849
  const impl = nativeRequire(ensureBundleFile());
753
- const target = impl[exportName];
850
+ const target = getWorkflowFromModuleExports(impl, workflowName);
754
851
  if (!target || typeof target.run !== "function") {
755
852
  throw new Error(
756
- \`Expected workflow export "\${exportName}" to be available in the bundled deployment implementation.\`,
853
+ \`Expected exported workflow "\${workflowName}" to be available in the bundled deployment implementation.\`,
757
854
  );
758
855
  }
759
856
  return await target.run(ctx, input);
@@ -761,7 +858,6 @@ function createWorkflowProxy(exportName) {
761
858
  }
762
859
 
763
860
  ${exportLines}
764
- ${defaultExportLine}
765
861
  `;
766
862
  }
767
863
 
@@ -802,45 +898,19 @@ async function writeBundledDeployEntrypoint(args: {
802
898
  );
803
899
  }
804
900
 
805
- // A separate ESM bundle is used only to read the entry module's exported
806
- // workflow names. Scanning the CommonJS bundle would also see exports from
807
- // bundled dependencies, which is not the deploy surface.
808
- const exportBuild = await build({
809
- absWorkingDir: args.absSourceDir,
810
- bundle: true,
811
- entryPoints: [args.absEntryPoint],
812
- external: [...args.externalPackages],
813
- format: "esm",
814
- outfile: "entry-exports.js",
815
- platform: "node",
816
- plugins: [
817
- workspaceSourcePlugin(args.workspacePackages, args.externalPackages),
818
- ],
819
- splitting: false,
820
- target: "node20",
821
- write: false,
901
+ const workflowNames = discoverBundledWorkflowNames({
902
+ absEntryPoint: args.absEntryPoint,
903
+ absSourceDir: args.absSourceDir,
904
+ bundleBuffer: Buffer.from(bundledImplementation.contents),
905
+ externalPackages: args.externalPackages,
822
906
  });
823
907
 
824
- const bundledExports = exportBuild.outputFiles?.find((file) =>
825
- file.path.endsWith("entry-exports.js"),
826
- );
827
- if (!bundledExports) {
828
- throw new Error("Bundler did not produce an export analysis file.");
829
- }
830
-
831
- const exportNames = extractExportNamesFromEsmBundle(bundledExports.text);
832
- if (exportNames.length === 0) {
833
- throw new Error(
834
- `No named exports were found in ${args.absEntryPoint}. Hosted deploy expects the entry point to export one or more workflows.`,
835
- );
836
- }
837
-
838
908
  writeFileSync(
839
909
  join(args.outputDir, "index.js"),
840
910
  createBootstrapSource({
841
911
  bundleBuffer: Buffer.from(bundledImplementation.contents),
842
912
  deploymentName: args.deploymentName,
843
- exportNames,
913
+ workflowNames,
844
914
  }),
845
915
  );
846
916
  } catch (error) {
@@ -0,0 +1,284 @@
1
+ import type { Locator, Page } from "playwright";
2
+
3
+ const PAGE_READ_METHODS = new Set([
4
+ "url",
5
+ "title",
6
+ "content",
7
+ "pageErrors",
8
+ "viewportSize",
9
+ "waitForLoadState",
10
+ "waitForRequest",
11
+ "waitForResponse",
12
+ "waitForURL",
13
+ ]);
14
+ const PAGE_LOCATOR_FACTORY_METHODS = new Set([
15
+ "locator",
16
+ "getByRole",
17
+ "getByText",
18
+ "getByLabel",
19
+ "getByPlaceholder",
20
+ "getByAltText",
21
+ "getByTitle",
22
+ "getByTestId",
23
+ ]);
24
+
25
+ const PAGE_ALLOWED_PROPERTIES = new Set<string>([]);
26
+
27
+ const LOCATOR_READ_METHODS = new Set([
28
+ "textContent",
29
+ "innerText",
30
+ "allTextContents",
31
+ "allInnerTexts",
32
+ "ariaSnapshot",
33
+ "boundingBox",
34
+ "count",
35
+ "getAttribute",
36
+ "inputValue",
37
+ "isChecked",
38
+ "isDisabled",
39
+ "isEditable",
40
+ "isEnabled",
41
+ "isVisible",
42
+ "isHidden",
43
+ "waitFor",
44
+ ]);
45
+
46
+ const LOCATOR_FACTORY_METHODS = new Set([
47
+ "locator",
48
+ "getByRole",
49
+ "getByText",
50
+ "getByLabel",
51
+ "getByPlaceholder",
52
+ "getByAltText",
53
+ "getByTitle",
54
+ "getByTestId",
55
+ "filter",
56
+ "and",
57
+ "or",
58
+ "first",
59
+ "last",
60
+ "nth",
61
+ ]);
62
+
63
+ const LOCATOR_COLLECTION_FACTORY_METHODS = new Set(["all"]);
64
+
65
+ const LOCATOR_SCROLL_METHODS = new Set(["scrollIntoViewIfNeeded"]);
66
+
67
+ const LOCATOR_ALLOWED_PROPERTIES = new Set<string>([]);
68
+
69
+ type ReadonlyExecOptions = {
70
+ onActivity?: () => void;
71
+ };
72
+
73
+ const readonlyPageCache = new WeakMap<Page, Page>();
74
+ const readonlyLocatorCache = new WeakMap<Locator, Locator>();
75
+
76
+ function markActivity(onActivity?: () => void): void {
77
+ onActivity?.();
78
+ }
79
+
80
+ export class ReadonlyExecDeniedError extends Error {
81
+ constructor(message: string) {
82
+ super(`ReadonlyExecDenied: ${message}`);
83
+ this.name = "ReadonlyExecDenied";
84
+ }
85
+ }
86
+
87
+ function denyOperation(targetName: "page" | "locator", method: string): never {
88
+ throw new ReadonlyExecDeniedError(
89
+ `${targetName}.${method} is blocked in readonly-exec`,
90
+ );
91
+ }
92
+
93
+ export function wrapLocatorForReadonlyExec(
94
+ locator: Locator,
95
+ options: ReadonlyExecOptions = {},
96
+ ): Locator {
97
+ const cached = readonlyLocatorCache.get(locator);
98
+ if (cached) return cached;
99
+
100
+ const proxy = new Proxy(locator, {
101
+ get(target, prop, receiver) {
102
+ if (typeof prop !== "string") {
103
+ return Reflect.get(target, prop, receiver);
104
+ }
105
+
106
+ const value = Reflect.get(target, prop, target);
107
+ if (typeof value !== "function") {
108
+ if (LOCATOR_ALLOWED_PROPERTIES.has(prop)) {
109
+ return value;
110
+ }
111
+ return denyOperation("locator", prop);
112
+ }
113
+
114
+ if (LOCATOR_READ_METHODS.has(prop)) {
115
+ return (...args: unknown[]) => {
116
+ const result = value.apply(target, args);
117
+ markActivity(options.onActivity);
118
+ return result;
119
+ };
120
+ }
121
+
122
+ if (LOCATOR_FACTORY_METHODS.has(prop)) {
123
+ return (...args: unknown[]) => {
124
+ const nextLocator = value.apply(target, args) as Locator;
125
+ markActivity(options.onActivity);
126
+ return wrapLocatorForReadonlyExec(nextLocator, options);
127
+ };
128
+ }
129
+
130
+ if (LOCATOR_COLLECTION_FACTORY_METHODS.has(prop)) {
131
+ return async (...args: unknown[]) => {
132
+ const locators = (await value.apply(target, args)) as Locator[];
133
+ markActivity(options.onActivity);
134
+ return locators.map((locator) =>
135
+ wrapLocatorForReadonlyExec(locator, options),
136
+ );
137
+ };
138
+ }
139
+
140
+ if (LOCATOR_SCROLL_METHODS.has(prop)) {
141
+ return async (...args: unknown[]) => {
142
+ await value.apply(target, args);
143
+ markActivity(options.onActivity);
144
+ };
145
+ }
146
+
147
+ return (..._args: unknown[]) => denyOperation("locator", prop);
148
+ },
149
+ });
150
+
151
+ readonlyLocatorCache.set(locator, proxy as Locator);
152
+ return proxy as Locator;
153
+ }
154
+
155
+ export function wrapPageForReadonlyExec(
156
+ page: Page,
157
+ options: ReadonlyExecOptions = {},
158
+ ): Page {
159
+ const cached = readonlyPageCache.get(page);
160
+ if (cached) return cached;
161
+
162
+ const proxy = new Proxy(page, {
163
+ get(target, prop, receiver) {
164
+ if (typeof prop !== "string") {
165
+ return Reflect.get(target, prop, receiver);
166
+ }
167
+
168
+ const value = Reflect.get(target, prop, target);
169
+ if (typeof value !== "function") {
170
+ if (PAGE_ALLOWED_PROPERTIES.has(prop)) {
171
+ return value;
172
+ }
173
+ return denyOperation("page", prop);
174
+ }
175
+
176
+ if (PAGE_READ_METHODS.has(prop)) {
177
+ return (...args: unknown[]) => {
178
+ const result = value.apply(target, args);
179
+ markActivity(options.onActivity);
180
+ return result;
181
+ };
182
+ }
183
+
184
+ if (PAGE_LOCATOR_FACTORY_METHODS.has(prop)) {
185
+ return (...args: unknown[]) => {
186
+ const locator = value.apply(target, args) as Locator;
187
+ markActivity(options.onActivity);
188
+ return wrapLocatorForReadonlyExec(locator, options);
189
+ };
190
+ }
191
+
192
+ return (..._args: unknown[]) => denyOperation("page", prop);
193
+ },
194
+ });
195
+
196
+ readonlyPageCache.set(page, proxy as Page);
197
+ return proxy as Page;
198
+ }
199
+
200
+ function resolveRequestMethod(
201
+ input: RequestInfo | URL,
202
+ init?: RequestInit,
203
+ ): string {
204
+ const requestMethod =
205
+ typeof Request !== "undefined" && input instanceof Request
206
+ ? input.method
207
+ : undefined;
208
+ return (init?.method ?? requestMethod ?? "GET").toUpperCase();
209
+ }
210
+
211
+ function assertReadonlyRequestBodyAllowed(
212
+ input: RequestInfo | URL,
213
+ init?: RequestInit,
214
+ ): void {
215
+ if (init?.body !== undefined) {
216
+ throw new ReadonlyExecDeniedError(
217
+ "request bodies are blocked in readonly-exec",
218
+ );
219
+ }
220
+
221
+ if (
222
+ typeof Request !== "undefined" &&
223
+ input instanceof Request &&
224
+ input.body !== null
225
+ ) {
226
+ throw new ReadonlyExecDeniedError(
227
+ "request bodies are blocked in readonly-exec",
228
+ );
229
+ }
230
+ }
231
+
232
+ export function createReadonlyExecHelpers(
233
+ page: Page,
234
+ options: ReadonlyExecOptions = {},
235
+ ) {
236
+ const readonlyPage = wrapPageForReadonlyExec(page, options);
237
+ const execState: Record<string, unknown> = {};
238
+
239
+ return {
240
+ page: readonlyPage,
241
+ state: execState,
242
+ // Playwright has no native viewport scroll method — only locator.scrollIntoViewIfNeeded().
243
+ // Arbitrary scrolling requires page.evaluate(), which is blocked by the readonly proxy
244
+ // since it can run arbitrary code. This helper calls evaluate on the raw (unwrapped) page,
245
+ // scoped to just window.scrollBy.
246
+ scrollBy: async (deltaX: number, deltaY: number) => {
247
+ await page.evaluate(
248
+ ([x, y]) => {
249
+ window.scrollBy(x, y);
250
+ },
251
+ [deltaX, deltaY] as const,
252
+ );
253
+ markActivity(options.onActivity);
254
+ },
255
+ get: async (input: RequestInfo | URL, init?: RequestInit) => {
256
+ const method = resolveRequestMethod(input, init);
257
+ if (method !== "GET" && method !== "HEAD") {
258
+ throw new ReadonlyExecDeniedError(
259
+ `${method} requests are blocked in readonly-exec`,
260
+ );
261
+ }
262
+ assertReadonlyRequestBodyAllowed(input, init);
263
+ markActivity(options.onActivity);
264
+ return await fetch(input, {
265
+ ...init,
266
+ method,
267
+ });
268
+ },
269
+ // Shadows the global Node.js fetch to prevent unrestricted HTTP access.
270
+ // Without this, agent code would fall through to the global fetch (POST, PUT, DELETE, etc.).
271
+ fetch: () => {
272
+ throw new ReadonlyExecDeniedError(
273
+ "fetch is blocked in readonly-exec; use get() instead",
274
+ );
275
+ },
276
+ console,
277
+ setTimeout,
278
+ setInterval,
279
+ clearTimeout,
280
+ clearInterval,
281
+ URL,
282
+ Buffer,
283
+ };
284
+ }