@farmslot/adapter-web 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@ All notable changes to `@farmslot/adapter-web` are tracked here.
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.2.0 - 2026-10-05
10
+
11
+ - Add `launch-browser` (`launchBrowser`: launch or release one isolated, detached, owned Chromium with an unpacked extension; `homeTabsToClose`) and `slot-title` (stamp the farm slot id into the extension home tab's title), moved from `@deeeed/metamask-harness`. Product knowledge is now passed in: the home page, its default title, the owner-root rule, a lock held for the launch, and the rerun command named in hints. `slot-title`'s `applyPersistentSlotTitle` takes `{ slotId, defaultTitle }`, `buildStampExpression(slotId, defaultTitle)` and `stampHomeTabsViaCdp({ homePage, defaultTitle, ... })` match it, and CDP stamping uses the global `WebSocket` (else `ws`), loaded only when it stamps.
12
+ - README: document the `browser-resolver.cjs` command (`resolve`, `load-unpacked`, `open-window`): arguments, output, exit codes and the 120 s bound.
13
+
9
14
  ## 0.1.0 - 2026-10-04
10
15
 
11
16
  - Add the package with the browser process and CDP layer moved from `@deeeed/metamask-harness`, unchanged in behaviour: `browser-resolver` (Chrome for Testing vs branded Chrome, probe cache, `runCli` entry), `browser-cdp` (deadline-bound browser CDP client, CDP-port ownership proof, `Extensions.loadUnpacked`, extension isolation), `chrome-args` (remote-debugging flags, runtime identity nonce, launch quarantine), `extension-id`, `macos-focus`, `playwright-cdp` (LavaMoat-safe page evaluation), `validation-process-ownership` and `validation-launch-supervisor`. Process, on-disk and environment names are Farmslot's: `--farmslot-runtime-nonce=`, `.farmslot-detached-launch-unproven`, `/tmp/farmslot-browser-validation-<uid>`, `~/.cache/farmslot/browser-probe.json`, `farmslot-browser-probe-*`, `FARMSLOT_FOCUS_HOLD`, `FARMSLOT_FOCUS_BROWSER` and `FARMSLOT_VALIDATION_PORT_LEASE`; remediation hints name no product command.
package/README.md CHANGED
@@ -18,14 +18,50 @@ Docs: https://farmslot.io/docs/reference/adapter-web
18
18
  | `browser-cdp` | browser-level CDP client with deadlines, CDP-port ownership proof, `Extensions.loadUnpacked`, window placement |
19
19
  | `chrome-args` | remote-debugging flags, isolated profile flags, runtime identity nonce, launch quarantine markers |
20
20
  | `extension-id` | Chromium extension id from a manifest key or an unpacked directory |
21
+ | `launch-browser` | launch (or release) one isolated, detached, owned Chromium with an unpacked extension |
21
22
  | `macos-focus` | capture the frontmost app before a headed launch and restore it if one of our browsers took it |
22
23
  | `page-target` | pick the page on an origin, preferring one whose URL carries a hash |
23
24
  | `playwright-cdp` | page evaluation over a raw CDP session (safe under LavaMoat scuttling) |
25
+ | `slot-title` | prefix the extension home tab's title with the farm slot id, kept across the page's own title resets |
24
26
  | `validation-process-ownership` | find and stop the processes that own a slot profile |
25
27
  | `validation-launch-supervisor` | supervised child for one validation launch: port lease, quarantine and cleanup |
26
28
 
27
29
  Sources are CommonJS (`src/*.cjs`) and ship as is; `yarn build` emits `.d.cts` declarations. Every library subpath can be `require`d or `import`ed with named imports, except `validation-launch-supervisor`: it is a child-process entry (it runs when loaded), so hosts `require.resolve` it and fork it.
28
30
 
31
+ ## Launching a browser
32
+
33
+ `launchBrowser` is synchronous and owns one launch end to end: it refuses a CDP port held by a process it cannot prove is its own, stops the previous owned browser of the profile, starts the browser detached (on macOS through Launch Services in the background), loads the extension, records the resolution and runtime identity under `runtimeDir`, and gives the front back if the new browser took it. `stopOnly: true` does only the release part.
34
+
35
+ ```js
36
+ const { launchBrowser } = require('@farmslot/adapter-web/launch-browser');
37
+
38
+ launchBrowser({
39
+ cdpPort: 9333,
40
+ chromeBin,
41
+ profile: `${runtimeDir}/profile`,
42
+ extensionDir: `${runtimeDir}/runtime-dist`,
43
+ runtimeDir,
44
+ chromeLog: `${runtimeDir}/logs/chrome.log`,
45
+ chromePid: `${runtimeDir}/logs/chrome.pid`,
46
+ // Product knowledge comes from the host:
47
+ homePage: 'home.html', // opened first, kept to one tab
48
+ defaultTitle: 'My Extension', // the page's own title; a slot-stamped tab is the one kept
49
+ acquireRuntimeLock: (dir) => lockBuild(dir), // returns its release
50
+ extensionOwnerRoot: (dir) => ownerRootFor(dir), // another way to prove a browser is ours
51
+ rerunCommand: 'my-harness launch',
52
+ });
53
+ ```
54
+
55
+ ## Resolver CLI
56
+
57
+ `browser-resolver.cjs` is also a command, for hosts that need a bounded child process (a hung CDP call can't stall the caller). Every command finishes within 120 s, exits 0 on success and 1 with the error message on stderr otherwise. Hosts can keep their own script path by calling `runCli(argv)`.
58
+
59
+ | command | output |
60
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
61
+ | `resolve [--target <checkout>] [--launch-method spawn\|launch-services] [--display headless\|headful] [--recorded <file>] [--out <file>]... [--json]` | stdout: the browser path, or the resolution record with `--json`; each `--out` gets the record; stderr: one `[browser]` summary line |
62
+ | `load-unpacked --port <cdp> --path <dist> --profile <dir> [--expect-id <id>] [--open-url <url>] [--timeout-ms <ms>]` | stdout: one JSON line `{ id, otherExtensions, owner }` |
63
+ | `open-window --port <cdp> --url <url>` | stdout: one JSON line `{ targetId }` for a new background window |
64
+
29
65
  ## Maintenance rules
30
66
 
31
67
  1. No product knowledge. A product's build locks, titles, fixtures and wallet state stay in its harness and reach these modules as arguments.
@@ -0,0 +1,106 @@
1
+ export type LaunchBrowserOptions = {
2
+ cdpPort: number;
3
+ chromeBin: string;
4
+ profile: string;
5
+ /**
6
+ * Unpacked extension to load.
7
+ */
8
+ extensionDir: string;
9
+ /**
10
+ * Holds browser-resolution.json and the runtime identity.
11
+ */
12
+ runtimeDir: string;
13
+ chromeLog: string;
14
+ chromePid: string;
15
+ /**
16
+ * Resolver record for this launch; reused when it names `chromeBin`.
17
+ */
18
+ browserResolution?: string | undefined;
19
+ /**
20
+ * First tab; defaults to the extension's `homePage`, else chrome://extensions/.
21
+ */
22
+ startUrl?: string | undefined;
23
+ /**
24
+ * Release the profile (owned browser stopped, singleton locks cleared) without launching.
25
+ */
26
+ stopOnly?: boolean | undefined;
27
+ /**
28
+ * Recreate the profile; it must be a dedicated child of `runtimeDir`.
29
+ */
30
+ resetProfile?: boolean | undefined;
31
+ /**
32
+ * Extension page (e.g. `home.html`) opened at start and kept to one tab.
33
+ */
34
+ homePage?: string | undefined;
35
+ /**
36
+ * That page's own title: a home tab with any other title (a slot-stamped one) is the one kept.
37
+ */
38
+ defaultTitle?: string | undefined;
39
+ /**
40
+ * Path prefix whose `--load-extension=` also proves a browser is ours.
41
+ */
42
+ extensionOwnerRoot?: ((extensionDir: string) => string | null) | undefined;
43
+ /**
44
+ * Taken once the profile, log and pid directories exist, before any browser is stopped or started; the returned release runs when the launch returns or throws.
45
+ */
46
+ acquireRuntimeLock?: ((runtimeDir: string) => () => void) | undefined;
47
+ /**
48
+ * Command named in "Next:" hints.
49
+ */
50
+ rerunCommand?: string | undefined;
51
+ /**
52
+ * macOS: wait before the one focus check (default 1000).
53
+ */
54
+ focusSettleMs?: number | undefined;
55
+ /**
56
+ * Progress lines (default stderr).
57
+ */
58
+ log?: ((line: string) => void) | undefined;
59
+ };
60
+ /**
61
+ * @typedef {object} LaunchBrowserOptions
62
+ * @property {number} cdpPort
63
+ * @property {string} chromeBin
64
+ * @property {string} profile
65
+ * @property {string} extensionDir Unpacked extension to load.
66
+ * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
67
+ * @property {string} chromeLog
68
+ * @property {string} chromePid
69
+ * @property {string} [browserResolution] Resolver record for this launch; reused when it names `chromeBin`.
70
+ * @property {string} [startUrl] First tab; defaults to the extension's `homePage`, else chrome://extensions/.
71
+ * @property {boolean} [stopOnly] Release the profile (owned browser stopped, singleton locks cleared) without launching.
72
+ * @property {boolean} [resetProfile] Recreate the profile; it must be a dedicated child of `runtimeDir`.
73
+ * @property {string} [homePage] Extension page (e.g. `home.html`) opened at start and kept to one tab.
74
+ * @property {string} [defaultTitle] That page's own title: a home tab with any other title (a slot-stamped one) is the one kept.
75
+ * @property {(extensionDir: string) => string | null} [extensionOwnerRoot] Path prefix whose `--load-extension=` also proves a browser is ours.
76
+ * @property {(runtimeDir: string) => () => void} [acquireRuntimeLock] Taken once the profile, log and pid directories exist, before any browser is stopped or started; the returned release runs when the launch returns or throws.
77
+ * @property {string} [rerunCommand] Command named in "Next:" hints.
78
+ * @property {number} [focusSettleMs] macOS: wait before the one focus check (default 1000).
79
+ * @property {(line: string) => void} [log] Progress lines (default stderr).
80
+ */
81
+ /**
82
+ * @param {LaunchBrowserOptions} options
83
+ * @returns {{ stopped: true } | { stopped: false, pid: number }}
84
+ */
85
+ export function launchBrowser(options: LaunchBrowserOptions): {
86
+ stopped: true;
87
+ } | {
88
+ stopped: false;
89
+ pid: number;
90
+ };
91
+ /**
92
+ * The page targets to close so one `homePage` tab of the extension remains and no
93
+ * disposable blank/new-tab page is left. The kept home tab is the first whose
94
+ * title is set and differs from `defaultTitle` (a slot-stamped tab), else the first.
95
+ * @param {unknown[]} targets CDP /json/list entries.
96
+ * @param {{ extensionId: string, homePage: string, defaultTitle?: string }} options
97
+ * @returns {{ id: string, url: string }[]}
98
+ */
99
+ export function homeTabsToClose(targets: unknown[], { extensionId, homePage, defaultTitle }: {
100
+ extensionId: string;
101
+ homePage: string;
102
+ defaultTitle?: string;
103
+ }): {
104
+ id: string;
105
+ url: string;
106
+ }[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @param {unknown} value
3
+ * @returns {string} The trimmed slot id, or '' when it is not a safe id.
4
+ */
5
+ export function sanitizeSlotId(value: unknown): string;
6
+ /**
7
+ * Slot id from RECIPE_SLOT_ID / SLOT_ID / FARMSLOT_SLOT_ID, else from
8
+ * `<target>/<runtimeDir>/agentic-runtime.json`; '' outside a farm slot.
9
+ * @param {string} [target]
10
+ * @param {string} [runtimeDir]
11
+ * @returns {string}
12
+ */
13
+ export function readSlotId(target?: string, runtimeDir?: string): string;
14
+ /**
15
+ * Serialized page-evaluation callback. Must stay a plain function (no closure).
16
+ * `defaultTitle` is the page's own title (as in launchBrowser), used when the document has none.
17
+ * @param {{ slotId: string, defaultTitle?: string }} options
18
+ * @returns {string}
19
+ */
20
+ export function applyPersistentSlotTitle({ slotId, defaultTitle }: {
21
+ slotId: string;
22
+ defaultTitle?: string;
23
+ }): string;
24
+ /**
25
+ * @param {string} slotId
26
+ * @param {string} [defaultTitle]
27
+ * @returns {string}
28
+ */
29
+ export function buildStampExpression(slotId: string, defaultTitle?: string): string;
30
+ /**
31
+ * Stamp every inspectable `homePage` tab of this extension over CDP.
32
+ * Attached tabs (no webSocketDebuggerUrl) are skipped — another client owns them.
33
+ * @param {{ cdpPort: number, extensionId: string, homePage: string, defaultTitle?: string, slotId?: string, target?: string, runtimeDir?: string }} options
34
+ * @returns {Promise<{ slotId: string, stamped: number, skipped: number, titles: string[] }>}
35
+ */
36
+ export function stampHomeTabsViaCdp(options: {
37
+ cdpPort: number;
38
+ extensionId: string;
39
+ homePage: string;
40
+ defaultTitle?: string;
41
+ slotId?: string;
42
+ target?: string;
43
+ runtimeDir?: string;
44
+ }): Promise<{
45
+ slotId: string;
46
+ stamped: number;
47
+ skipped: number;
48
+ titles: string[];
49
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/adapter-web",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Web platform adapter for Farmslot recipes: resolve and launch an isolated Chromium, own its CDP port, load unpacked extensions, and keep macOS focus.",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -20,10 +20,15 @@
20
20
  "types": "./dist/extension-id.d.cts",
21
21
  "default": "./src/extension-id.cjs"
22
22
  },
23
+ "./launch-browser": {
24
+ "types": "./dist/launch-browser.d.cts",
25
+ "default": "./src/launch-browser.cjs"
26
+ },
23
27
  "./macos-focus": {
24
28
  "types": "./dist/macos-focus.d.cts",
25
29
  "default": "./src/macos-focus.cjs"
26
30
  },
31
+ "./package.json": "./package.json",
27
32
  "./page-target": {
28
33
  "types": "./dist/page-target.d.cts",
29
34
  "default": "./src/page-target.cjs"
@@ -32,6 +37,10 @@
32
37
  "types": "./dist/playwright-cdp.d.cts",
33
38
  "default": "./src/playwright-cdp.cjs"
34
39
  },
40
+ "./slot-title": {
41
+ "types": "./dist/slot-title.d.cts",
42
+ "default": "./src/slot-title.cjs"
43
+ },
35
44
  "./validation-launch-supervisor": {
36
45
  "types": "./dist/validation-launch-supervisor.d.cts",
37
46
  "default": "./src/validation-launch-supervisor.cjs"
@@ -39,8 +48,7 @@
39
48
  "./validation-process-ownership": {
40
49
  "types": "./dist/validation-process-ownership.d.cts",
41
50
  "default": "./src/validation-process-ownership.cjs"
42
- },
43
- "./package.json": "./package.json"
51
+ }
44
52
  },
45
53
  "scripts": {
46
54
  "typecheck": "tsc --noEmit --project tsconfig.json && yarn build && tsc --project tsconfig.consumer.json",
@@ -0,0 +1,718 @@
1
+ 'use strict';
2
+ // launch-browser.cjs — per-instance isolated detached Chrome launch
3
+ //
4
+ // Starts a detached Chrome bound to 127.0.0.1:<cdpPort> with its own profile and
5
+ // an unpacked extension loaded: by --load-extension, or over CDP
6
+ // (Extensions.loadUnpacked) for branded Chrome, which ignores that flag. The
7
+ // browser choice comes from the resolver record (`browserResolution`) when it
8
+ // names `chromeBin`.
9
+ //
10
+ // Writes: the pid file, an append-only Chrome log, and under `runtimeDir` the
11
+ // resolution record (browser-resolution.json) and the runtime identity. Never
12
+ // touches the extension dir (read-only) or anything outside the paths it is given.
13
+ //
14
+ // Product knowledge comes from the caller: the extension page to open
15
+ // (`homePage`), that page's own title (`defaultTitle`), how to recognise an
16
+ // owned browser by its extension path (`extensionOwnerRoot`), a lock held for
17
+ // the whole launch (`acquireRuntimeLock`), and the rerun command for hints.
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync, spawn, spawnSync } = require('node:child_process');
23
+
24
+ const { cdpListenerPids } = require('./browser-cdp.cjs');
25
+ const {
26
+ CDP_LOAD_UNPACKED,
27
+ describeBrowser,
28
+ extensionLaunchArgs,
29
+ waitForCdpOwner,
30
+ } = require('./browser-resolver.cjs');
31
+ const {
32
+ automationRuntimeArgs,
33
+ clearDetachedLaunchUnproven,
34
+ clearValidationPortQuarantine,
35
+ createRuntimeIdentityNonce,
36
+ hasDetachedLaunchUnproven,
37
+ isolatedProfileArgs,
38
+ markDetachedLaunchUnproven,
39
+ markValidationPortLaunchUnproven,
40
+ readValidationPortQuarantine,
41
+ remoteDebuggingArgs,
42
+ removeRuntimeIdentity,
43
+ runtimeIdentityArgs,
44
+ validationLaunchQuarantineError,
45
+ writeRuntimeIdentity,
46
+ } = require('./chrome-args.cjs');
47
+ const { extensionIdFromExtensionDir } = require('./extension-id.cjs');
48
+ const {
49
+ captureMacFrontmost,
50
+ macBackgroundOpenArgs,
51
+ restoreFrontmostIfOurs,
52
+ } = require('./macos-focus.cjs');
53
+ const {
54
+ profileProcessPids,
55
+ stopProfileProcessesSync,
56
+ } = require('./validation-process-ownership.cjs');
57
+
58
+ // The resolver CLI runs as a bounded child for CDP calls the launch must not hang on.
59
+ const BROWSER_RESOLVER_CLI = require.resolve('./browser-resolver.cjs');
60
+
61
+ /**
62
+ * @typedef {object} LaunchBrowserOptions
63
+ * @property {number} cdpPort
64
+ * @property {string} chromeBin
65
+ * @property {string} profile
66
+ * @property {string} extensionDir Unpacked extension to load.
67
+ * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
68
+ * @property {string} chromeLog
69
+ * @property {string} chromePid
70
+ * @property {string} [browserResolution] Resolver record for this launch; reused when it names `chromeBin`.
71
+ * @property {string} [startUrl] First tab; defaults to the extension's `homePage`, else chrome://extensions/.
72
+ * @property {boolean} [stopOnly] Release the profile (owned browser stopped, singleton locks cleared) without launching.
73
+ * @property {boolean} [resetProfile] Recreate the profile; it must be a dedicated child of `runtimeDir`.
74
+ * @property {string} [homePage] Extension page (e.g. `home.html`) opened at start and kept to one tab.
75
+ * @property {string} [defaultTitle] That page's own title: a home tab with any other title (a slot-stamped one) is the one kept.
76
+ * @property {(extensionDir: string) => string | null} [extensionOwnerRoot] Path prefix whose `--load-extension=` also proves a browser is ours.
77
+ * @property {(runtimeDir: string) => () => void} [acquireRuntimeLock] Taken once the profile, log and pid directories exist, before any browser is stopped or started; the returned release runs when the launch returns or throws.
78
+ * @property {string} [rerunCommand] Command named in "Next:" hints.
79
+ * @property {number} [focusSettleMs] macOS: wait before the one focus check (default 1000).
80
+ * @property {(line: string) => void} [log] Progress lines (default stderr).
81
+ */
82
+
83
+ /**
84
+ * @param {LaunchBrowserOptions} options
85
+ * @returns {{ stopped: true } | { stopped: false, pid: number }}
86
+ */
87
+ function launchBrowser(options) {
88
+ const opts = normalizeOptions(options);
89
+ let release = null;
90
+ try {
91
+ return runLaunch(opts, () => {
92
+ if (opts.acquireRuntimeLock) release = opts.acquireRuntimeLock(opts.runtimeDir);
93
+ });
94
+ } finally {
95
+ if (typeof release === 'function') release();
96
+ }
97
+ }
98
+
99
+ // The rerun step named in "Next:" hints.
100
+ function rerunHint({ rerunCommand }) {
101
+ return rerunCommand ? `rerun: ${rerunCommand}` : 'rerun the launch';
102
+ }
103
+
104
+ function normalizeOptions(options) {
105
+ const opts = { ...options };
106
+ if (!Number.isInteger(opts.cdpPort) || opts.cdpPort <= 0)
107
+ throw new Error(`Invalid --cdp-port: ${opts.cdpPort ?? ''}`);
108
+ const flags = {
109
+ chromeBin: 'chrome-bin',
110
+ profile: 'profile',
111
+ extensionDir: 'extension-dir',
112
+ runtimeDir: 'runtime-dir',
113
+ chromeLog: 'chrome-log',
114
+ chromePid: 'chrome-pid',
115
+ };
116
+ for (const [key, flag] of Object.entries(flags)) {
117
+ if (!opts[key]) throw new Error(`Missing --${flag}`);
118
+ }
119
+ opts.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
120
+ return opts;
121
+ }
122
+
123
+ function runLaunch(opts, acquireLock) {
124
+ const { cdpPort, profile, extensionDir, runtimeDir, log } = opts;
125
+ const rerun = rerunHint(opts);
126
+ // stopOnly releases the profile before the extension snapshot exists on a first
127
+ // run, so launch-input validation applies only to a real launch.
128
+ if (!opts.stopOnly) {
129
+ if (!fs.existsSync(opts.chromeBin))
130
+ throw new Error(`Chrome binary not found: ${opts.chromeBin}`);
131
+ if (!fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
132
+ throw new Error(
133
+ `Extension dist manifest not found: ${path.join(extensionDir, 'manifest.json')}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ // Isolation guard: a standalone launch must use a per-checkout profile, never a
139
+ // shared (e.g. ~/.chrome-farmslot) or the OS-default browser profile. Farm dispatch
140
+ // injects its own already-isolated profile, so this only trips on a genuinely
141
+ // personal/shared path — protecting the user's browser data.
142
+ assertIsolatedProfile(profile);
143
+ if (opts.resetProfile) assertResettableProfile(profile, runtimeDir);
144
+
145
+ fs.mkdirSync(profile, { recursive: true });
146
+ fs.mkdirSync(path.dirname(opts.chromeLog), { recursive: true });
147
+ fs.mkdirSync(path.dirname(opts.chromePid), { recursive: true });
148
+ acquireLock();
149
+
150
+ const ownedBrowser = (pid) =>
151
+ processIsOwnedBrowser(pid, profile, extensionDir, opts.extensionOwnerRoot);
152
+
153
+ // Ownership guard: only ever take over a CDP endpoint we provably launched. A pid
154
+ // on this port is ours iff its command line loads our --user-data-dir; anything
155
+ // else is a browser this harness did not launch (possibly the user's personal
156
+ // Chrome), so we refuse rather than kill it or attach to it.
157
+ const listenerPids = cdpListenerPids(cdpPort);
158
+ const foreignPids = listenerPids.filter((pid) => !ownedBrowser(pid));
159
+ if (foreignPids.length > 0) {
160
+ throw new Error(
161
+ `Refusing to launch on CDP port ${cdpPort}: it is held by a browser this harness did not launch (pid ${foreignPids.join(', ')}). ` +
162
+ `Next: pick a free --cdp-port, or stop that browser.`,
163
+ );
164
+ }
165
+ const ownedPids = new Set(listenerPids);
166
+ for (const pid of profileProcessPids(profile)) ownedPids.add(pid);
167
+ const previousPid = readPidFile(opts.chromePid);
168
+ if (previousPid !== null && ownedBrowser(previousPid)) ownedPids.add(previousPid);
169
+ try {
170
+ removeRuntimeIdentity(runtimeDir);
171
+ } catch (error) {
172
+ const detail = error instanceof Error ? error.message : String(error);
173
+ throw new Error(
174
+ `Failed to invalidate Extension runtime identity under ${runtimeDir}: ${detail}. ` +
175
+ `Next: remove the invalid runtime identity and fix owner permissions, then ${rerun}`,
176
+ );
177
+ }
178
+ stopProfileProcessesSync(profile, { extraPids: [...ownedPids] });
179
+ if (opts.resetProfile) {
180
+ fs.rmSync(profile, { recursive: true, force: true });
181
+ fs.mkdirSync(profile, { recursive: true });
182
+ }
183
+ removeProfileSingletonLocks(profile);
184
+
185
+ // stopOnly: release the profile without launching. A caller that writes into
186
+ // the profile (fixture prefill into LevelDB) needs it unlocked first; a prior
187
+ // run's Chrome would hold the lock and the write fails.
188
+ if (opts.stopOnly) return { stopped: true };
189
+
190
+ const requestedProfileQuarantined = hasDetachedLaunchUnproven(profile);
191
+ const portQuarantine = readValidationPortQuarantine(cdpPort);
192
+ const validationPortLease = process.env.FARMSLOT_VALIDATION_PORT_LEASE;
193
+ const activeValidationLease = Boolean(
194
+ portQuarantine?.profile === path.resolve(profile) &&
195
+ portQuarantine.lease === validationPortLease &&
196
+ /^[a-f0-9]{64}$/u.test(validationPortLease || ''),
197
+ );
198
+ if (requestedProfileQuarantined || (portQuarantine && !activeValidationLease)) {
199
+ throw validationLaunchQuarantineError(
200
+ cdpPort,
201
+ portQuarantine?.profile || (requestedProfileQuarantined ? profile : undefined),
202
+ requestedProfileQuarantined ? profile : undefined,
203
+ );
204
+ }
205
+ const clearLaunchMarkers = () => {
206
+ clearDetachedLaunchUnproven(profile);
207
+ if (!activeValidationLease) clearValidationPortQuarantine(cdpPort, profile);
208
+ };
209
+
210
+ const initialUrl =
211
+ opts.startUrl || extensionPageUrl(extensionDir, opts.homePage) || 'chrome://extensions/';
212
+ const browser = launchBrowserResolution(opts.browserResolution, opts.chromeBin);
213
+ const loadsOverCdp = browser.extensionLoading === CDP_LOAD_UNPACKED;
214
+ let cdpLoad = null;
215
+ let owner = null;
216
+ const runtimeNonce = createRuntimeIdentityNonce();
217
+ const startedAt = Date.now();
218
+ const chromeArgs = [
219
+ `--user-data-dir=${profile}`,
220
+ // Debug-port trio (address + port + scoped allow-origins) from the shared module,
221
+ // so the DevTools origin allow-list can never drift away from the port.
222
+ ...remoteDebuggingArgs(cdpPort),
223
+ '--no-first-run',
224
+ '--disable-first-run-ui',
225
+ ...automationRuntimeArgs(),
226
+ ...runtimeIdentityArgs(runtimeNonce),
227
+ ...isolatedProfileArgs(),
228
+ '--disable-default-apps',
229
+ '--disable-popup-blocking',
230
+ '--disable-extensions-file-access-check',
231
+ '--disable-extensions-content-verification',
232
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
233
+ ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
234
+ ];
235
+ // Initial tab: the caller's URL when provided, else the extension home. The
236
+ // extension home avoids leaving disposable chrome://newtab/ or extensions
237
+ // manager tabs in the operator's slot browser. On macOS the browser starts
238
+ // through Launch Services in the background with no startup window (Chrome
239
+ // activates itself when it opens one), and the first window is opened over
240
+ // CDP as a background window. A CDP-loaded extension does not exist yet at
241
+ // startup, so its window is opened after the load.
242
+ const application =
243
+ process.platform === 'darwin' ? macApplicationForExecutable(opts.chromeBin) : null;
244
+ if (application) chromeArgs.push('--no-startup-window');
245
+ else chromeArgs.push(loadsOverCdp ? 'about:blank' : initialUrl);
246
+ const ownedListenerMissing = () =>
247
+ new Error(
248
+ `Chrome launched but did not expose an owned CDP listener on 127.0.0.1:${cdpPort}. ` +
249
+ `Next: inspect ${opts.chromeLog}, then ${rerun}`,
250
+ );
251
+ const logFd = fs.openSync(opts.chromeLog, 'a');
252
+ const previousFrontmost = captureMacFrontmost();
253
+ let browserPid;
254
+ try {
255
+ try {
256
+ beginDetachedLaunch(cdpPort, profile, activeValidationLease);
257
+ if (application) {
258
+ execFileSync('open', macBackgroundOpenArgs(application, chromeArgs), {
259
+ env: sanitizedChildEnv(),
260
+ stdio: ['ignore', logFd, logFd],
261
+ });
262
+ browserPid = waitForOwnedCdpPid(cdpPort, ownedBrowser);
263
+ if (browserPid === null) {
264
+ stopProfileProcessesSync(profile, { waitForAppearanceMs: 400 });
265
+ throw ownedListenerMissing();
266
+ }
267
+ } else {
268
+ const child = spawn(opts.chromeBin, chromeArgs, {
269
+ detached: true,
270
+ env: sanitizedChildEnv(),
271
+ stdio: ['ignore', logFd, logFd],
272
+ });
273
+ child.unref();
274
+ browserPid = waitForOwnedCdpPid(cdpPort, ownedBrowser);
275
+ if (browserPid === null) {
276
+ stopProfileProcessesSync(profile, { extraPids: child.pid ? [child.pid] : [] });
277
+ throw ownedListenerMissing();
278
+ }
279
+ }
280
+ if (loadsOverCdp)
281
+ cdpLoad = loadExtensionOverCdp(opts, browserPid, initialUrl, clearLaunchMarkers);
282
+ else if (application) openBackgroundWindow(opts, browserPid, initialUrl, clearLaunchMarkers);
283
+ owner = cdpLoad?.owner ?? ownerIdentity(cdpPort, profile, browserPid, log);
284
+ for (const problem of pruneExtraHomeTabs(cdpPort, extensionDir, opts)) {
285
+ log(`[launch] home-tab pruning: ${problem}`);
286
+ }
287
+ } finally {
288
+ fs.closeSync(logFd);
289
+ }
290
+ fs.writeFileSync(opts.chromePid, `${browserPid}\n`);
291
+ writeBrowserResolution(runtimeDir, {
292
+ ...browser,
293
+ launch: {
294
+ pid: browserPid,
295
+ cdpPort,
296
+ startedAt: new Date(startedAt).toISOString(),
297
+ ...(owner ? { profile: owner.profile, processStartedAt: owner.startedAt } : {}),
298
+ ...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
299
+ },
300
+ });
301
+ try {
302
+ writeRuntimeIdentity(runtimeDir, {
303
+ port: cdpPort,
304
+ pid: browserPid,
305
+ startedAt,
306
+ nonce: runtimeNonce,
307
+ });
308
+ clearLaunchMarkers();
309
+ } catch (error) {
310
+ terminatePids([browserPid]);
311
+ fs.rmSync(opts.chromePid, { force: true });
312
+ const detail = error instanceof Error ? error.message : String(error);
313
+ throw new Error(
314
+ `Failed to persist Extension runtime identity under ${runtimeDir}: ${detail}. ` +
315
+ `Next: fix owner write permissions for the runtime directory, then ${rerun}`,
316
+ );
317
+ }
318
+ if (process.platform === 'darwin') {
319
+ const wake = spawn('caffeinate', ['-dims', '-w', String(browserPid)], {
320
+ detached: true,
321
+ stdio: 'ignore',
322
+ });
323
+ wake.unref();
324
+ // Let the first window settle before the one focus check below.
325
+ const settleMs =
326
+ Number.isFinite(opts.focusSettleMs) && opts.focusSettleMs >= 0 ? opts.focusSettleMs : 1000;
327
+ if (settleMs > 0) spawnSync('sleep', [String(settleMs / 1000)]);
328
+ }
329
+ } finally {
330
+ // One check, at most one restore, and only if OUR browser (this launch's
331
+ // CDP owner pid) holds the front; any other app in front is left alone.
332
+ if (browserPid) {
333
+ const outcome = restoreFrontmostIfOurs(previousFrontmost, [Number(browserPid)]);
334
+ if (outcome === 'restored')
335
+ log(`[launch] focus: the slot browser took the front; restored ${previousFrontmost.name}`);
336
+ }
337
+ }
338
+ return { stopped: false, pid: browserPid };
339
+ }
340
+
341
+ // The first window of a browser started without one, opened in the background.
342
+ // Without it the slot has no window at all, so a failure fails the launch:
343
+ // the owned browser is stopped (its markers kept if it cannot be).
344
+ function openBackgroundWindow(opts, pid, url, clearLaunchMarkers) {
345
+ const result = spawnSync(
346
+ process.execPath,
347
+ [BROWSER_RESOLVER_CLI, 'open-window', '--port', String(opts.cdpPort), '--url', url],
348
+ { encoding: 'utf8', timeout: 30_000 },
349
+ );
350
+ if (result.status === 0) return;
351
+ const openError = (result.stderr || result.error?.message || `exit ${result.status}`).trim();
352
+ try {
353
+ stopProfileProcessesSync(opts.profile, { extraPids: [pid] });
354
+ } catch (stopError) {
355
+ throw new Error(
356
+ `Chrome started but its start window could not be opened (${openError}), and stopping it failed: ${stopError.message}. ` +
357
+ `The launch markers for port ${opts.cdpPort} and ${opts.profile} are kept. Next: stop pid ${pid}, then ${rerunHint(opts)}`,
358
+ );
359
+ }
360
+ clearLaunchMarkers();
361
+ throw new Error(
362
+ `Chrome started but its start window could not be opened: ${openError}. Next: inspect ${opts.chromeLog}, then ${rerunHint(opts)}`,
363
+ );
364
+ }
365
+
366
+ // The caller passes the record its resolver just wrote; reuse it when it names
367
+ // the same binary. A direct chromeBin without it is the caller's own explicit
368
+ // choice and is recorded as such, whatever an older record of this profile says.
369
+ function launchBrowserResolution(file, chromeBin) {
370
+ if (!file) return explicitResolution(chromeBin);
371
+ try {
372
+ const recorded = JSON.parse(fs.readFileSync(file, 'utf8'));
373
+ if (recorded && recorded.bin === chromeBin && typeof recorded.extensionLoading === 'string') {
374
+ const { launch: _previousLaunch, ...resolution } = recorded;
375
+ return resolution;
376
+ }
377
+ } catch {
378
+ // No resolver record for this launch; fall through to a direct description.
379
+ }
380
+ return explicitResolution(chromeBin);
381
+ }
382
+
383
+ function explicitResolution(chromeBin) {
384
+ return {
385
+ schemaVersion: 2,
386
+ ...describeBrowser(chromeBin),
387
+ source: '--chrome-bin',
388
+ mode: 'explicit',
389
+ selection: 'explicit',
390
+ resolvedAt: new Date().toISOString(),
391
+ };
392
+ }
393
+
394
+ function writeBrowserResolution(runtime, resolution) {
395
+ fs.writeFileSync(
396
+ path.join(runtime, 'browser-resolution.json'),
397
+ `${JSON.stringify(resolution, null, 2)}\n`,
398
+ );
399
+ }
400
+
401
+ function loadExtensionOverCdp(opts, pid, initialUrl, clearLaunchMarkers) {
402
+ const result = spawnSync(
403
+ process.execPath,
404
+ [
405
+ BROWSER_RESOLVER_CLI,
406
+ 'load-unpacked',
407
+ '--port',
408
+ String(opts.cdpPort),
409
+ '--path',
410
+ opts.extensionDir,
411
+ '--profile',
412
+ opts.profile,
413
+ '--open-url',
414
+ initialUrl,
415
+ ],
416
+ { encoding: 'utf8', timeout: 130_000 },
417
+ );
418
+ if (result.status === 0) {
419
+ const loaded = JSON.parse(result.stdout);
420
+ opts.log(`[launch] extension ${loaded.id} loaded over CDP`);
421
+ for (const entry of loaded.otherExtensions?.disabled ?? []) {
422
+ opts.log(`[launch] disabled ${entry.id} (${entry.name}) in the slot profile`);
423
+ }
424
+ for (const entry of loaded.otherExtensions?.policyPinned ?? []) {
425
+ opts.log(
426
+ `[launch] WARN: enterprise policy keeps ${entry.id} (${entry.name}) enabled in the slot profile`,
427
+ );
428
+ }
429
+ return loaded;
430
+ }
431
+ // Ownership was proven; once the browser is stopped the launch markers would
432
+ // only quarantine a port and profile that nothing holds. If it cannot be
433
+ // stopped, the markers stay and both failures are reported.
434
+ const loadError = (result.stderr || result.error?.message || '').trim();
435
+ try {
436
+ stopProfileProcessesSync(opts.profile, { extraPids: [pid] });
437
+ } catch (stopError) {
438
+ throw new Error(
439
+ `Chrome started but the extension did not load over CDP (${loadError}), and stopping it failed: ${stopError.message}. ` +
440
+ `The launch markers for port ${opts.cdpPort} and ${opts.profile} are kept. Next: stop pid ${pid}, then ${rerunHint(opts)}`,
441
+ );
442
+ }
443
+ clearLaunchMarkers();
444
+ throw new Error(
445
+ `Chrome started but the extension did not load over CDP: ${loadError}. ` +
446
+ `Next: inspect ${opts.chromeLog}, or set RECIPE_HARNESS_BROWSER=cft to use Chrome for Testing, then ${rerunHint(opts)}`,
447
+ );
448
+ }
449
+
450
+ // Identity of the browser that holds the profile (pid + start time), recorded
451
+ // so run evidence can later prove it names this exact process.
452
+ // Best effort, so a short budget: a browser that never proves ownership (a
453
+ // stub without a SingletonLock, say) costs seconds, not the full default.
454
+ function ownerIdentity(cdpPort, profile, pid, log) {
455
+ try {
456
+ const identity = waitForCdpOwner(cdpPort, profile, { timeoutMs: 15000 });
457
+ return identity.pid === pid ? identity : null;
458
+ } catch (error) {
459
+ log(`[launch] could not record the browser identity: ${error.message}`);
460
+ return null;
461
+ }
462
+ }
463
+
464
+ function sanitizedChildEnv() {
465
+ return {
466
+ ...process.env,
467
+ BUNDLED_DEBUGPY_PATH: undefined,
468
+ PYTHONHOME: undefined,
469
+ PYTHONPATH: undefined,
470
+ DYLD_LIBRARY_PATH: undefined,
471
+ DYLD_FALLBACK_LIBRARY_PATH: undefined,
472
+ DYLD_INSERT_LIBRARIES: undefined,
473
+ };
474
+ }
475
+
476
+ function beginDetachedLaunch(port, profile, activeLease) {
477
+ try {
478
+ if (!activeLease) markValidationPortLaunchUnproven(port, profile);
479
+ markDetachedLaunchUnproven(profile);
480
+ } catch (error) {
481
+ if (error.code === 'EEXIST') {
482
+ const quarantine = readValidationPortQuarantine(port);
483
+ throw validationLaunchQuarantineError(port, quarantine?.profile || profile);
484
+ }
485
+ throw error;
486
+ }
487
+ }
488
+
489
+ function macApplicationForExecutable(executable) {
490
+ let current = path.resolve(executable);
491
+ while (current !== path.dirname(current)) {
492
+ if (current.endsWith('.app')) return current;
493
+ current = path.dirname(current);
494
+ }
495
+ return null;
496
+ }
497
+
498
+ function waitForOwnedCdpPid(port, ownedBrowser) {
499
+ for (let i = 0; i < 300; i += 1) {
500
+ const pid = cdpListenerPids(port).find(ownedBrowser);
501
+ if (pid !== undefined) return pid;
502
+ spawnSync('sleep', ['0.1']);
503
+ }
504
+ return null;
505
+ }
506
+
507
+ function assertIsolatedProfile(profile) {
508
+ const resolved = path.resolve(profile);
509
+ const home = os.homedir();
510
+ const osDefaults = [
511
+ path.join(home, 'Library/Application Support/Google/Chrome'),
512
+ path.join(home, 'Library/Application Support/Chromium'),
513
+ path.join(home, 'Library/Application Support/Microsoft Edge'),
514
+ path.join(home, '.config/google-chrome'),
515
+ path.join(home, '.config/chromium'),
516
+ path.join(home, '.config/microsoft-edge'),
517
+ ];
518
+ const underDefault = (dir) => resolved === dir || resolved.startsWith(`${dir}${path.sep}`);
519
+ if (
520
+ resolved === home ||
521
+ resolved.includes(`${path.sep}.chrome-farmslot`) ||
522
+ osDefaults.some(underDefault)
523
+ ) {
524
+ throw new Error(
525
+ `Refusing to launch: --profile ${resolved} is a shared or default browser profile; ` +
526
+ `standalone launches must use a per-checkout profile under the harness runtime dir.`,
527
+ );
528
+ }
529
+ }
530
+
531
+ function assertResettableProfile(profile, expectedRuntimeDir) {
532
+ const resolved = path.resolve(profile);
533
+ const runtime = path.resolve(expectedRuntimeDir);
534
+ if (!resolved.startsWith(`${runtime}${path.sep}`)) {
535
+ throw new Error(
536
+ `Refusing to reset --profile ${resolved}: it must be a dedicated child of ${runtime}.`,
537
+ );
538
+ }
539
+ const runtimeStat = fs.lstatSync(runtime);
540
+ if (!runtimeStat.isDirectory() || runtimeStat.isSymbolicLink()) {
541
+ throw new Error(
542
+ `Refusing to reset --profile: runtime directory is not a regular directory: ${runtime}.`,
543
+ );
544
+ }
545
+ let current = runtime;
546
+ for (const segment of path.relative(runtime, resolved).split(path.sep)) {
547
+ current = path.join(current, segment);
548
+ if (!fs.existsSync(current)) break;
549
+ const stat = fs.lstatSync(current);
550
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
551
+ throw new Error(`Refusing to reset --profile: path contains an unsafe entry: ${current}.`);
552
+ }
553
+ }
554
+ }
555
+
556
+ // Proof of ownership: a process is ours iff its command line loads the exact
557
+ // --user-data-dir we launch with, or an extension under the caller's owner root.
558
+ // If the process can't be inspected we treat it as NOT ours (fail safe — never
559
+ // assume ownership of an opaque process).
560
+ function processIsOwnedBrowser(pid, profile, extensionDir, extensionOwnerRoot) {
561
+ const command = processCommand(pid);
562
+ if (!command) return false;
563
+ if (command.includes(`--user-data-dir=${profile}`)) return true;
564
+ const ownerRoot = extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
565
+ return Boolean(ownerRoot && command.includes(`--load-extension=${ownerRoot}`));
566
+ }
567
+
568
+ function processCommand(pid) {
569
+ if (!Number.isInteger(pid) || pid <= 0) return null;
570
+ try {
571
+ return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], {
572
+ encoding: 'utf8',
573
+ stdio: ['ignore', 'pipe', 'ignore'],
574
+ });
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ function readPidFile(file) {
581
+ if (!fs.existsSync(file)) return null;
582
+ const pid = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
583
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
584
+ }
585
+
586
+ function terminatePids(pids) {
587
+ const unique = [...new Set(pids)].filter((pid) => pid !== process.pid);
588
+ for (const pid of unique) signalPid(pid, 'SIGTERM');
589
+ for (let i = 0; i < 10 && unique.some(processRespondsToSignal); i += 1) {
590
+ spawnSync('sleep', ['0.2']);
591
+ }
592
+ for (const pid of unique) {
593
+ if (processRespondsToSignal(pid)) signalPid(pid, 'SIGKILL');
594
+ }
595
+ }
596
+
597
+ function signalPid(pid, signal) {
598
+ try {
599
+ process.kill(pid, signal);
600
+ return true;
601
+ } catch (error) {
602
+ if (error.code === 'ESRCH') return false;
603
+ throw error;
604
+ }
605
+ }
606
+
607
+ function processRespondsToSignal(pid) {
608
+ try {
609
+ process.kill(pid, 0);
610
+ return true;
611
+ } catch (error) {
612
+ if (error.code === 'ESRCH') return false;
613
+ throw error;
614
+ }
615
+ }
616
+
617
+ function removeProfileSingletonLocks(profile) {
618
+ for (const name of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
619
+ const file = path.join(profile, name);
620
+ if (fs.existsSync(file)) fs.rmSync(file, { force: true });
621
+ }
622
+ }
623
+
624
+ function extensionPageUrl(extensionDir, page) {
625
+ if (!page) return '';
626
+ const id = extensionIdFromExtensionDir(extensionDir);
627
+ return id ? `chrome-extension://${id}/${page}` : '';
628
+ }
629
+
630
+ function cdpHttp(port, pathname) {
631
+ const result = spawnSync(
632
+ process.execPath,
633
+ [
634
+ '-e',
635
+ `
636
+ const http = require('http');
637
+ http.get('http://127.0.0.1:${Number(port)}${pathname}', (res) => {
638
+ let body = '';
639
+ res.on('data', (chunk) => { body += chunk; });
640
+ res.on('end', () => {
641
+ process.stdout.write(body);
642
+ process.exitCode = res.statusCode === 200 ? 0 : 1;
643
+ });
644
+ }).on('error', () => process.exit(1));
645
+ `,
646
+ ],
647
+ { encoding: 'utf8', timeout: 4000 },
648
+ );
649
+ if (result.status !== 0) return null;
650
+ return result.stdout;
651
+ }
652
+
653
+ /**
654
+ * The page targets to close so one `homePage` tab of the extension remains and no
655
+ * disposable blank/new-tab page is left. The kept home tab is the first whose
656
+ * title is set and differs from `defaultTitle` (a slot-stamped tab), else the first.
657
+ * @param {unknown[]} targets CDP /json/list entries.
658
+ * @param {{ extensionId: string, homePage: string, defaultTitle?: string }} options
659
+ * @returns {{ id: string, url: string }[]}
660
+ */
661
+ function homeTabsToClose(targets, { extensionId, homePage, defaultTitle }) {
662
+ const pages = targets.filter(
663
+ (target) =>
664
+ target &&
665
+ target.type === 'page' &&
666
+ typeof target.id === 'string' &&
667
+ typeof target.url === 'string',
668
+ );
669
+ const homes = pages.filter(
670
+ (target) =>
671
+ target.url.startsWith(`chrome-extension://${extensionId}/`) &&
672
+ target.url.includes(`/${homePage}`),
673
+ );
674
+ if (homes.length === 0) return [];
675
+ const strays = pages.filter(
676
+ (target) =>
677
+ target.url === 'about:blank' ||
678
+ target.url === 'chrome://newtab/' ||
679
+ target.url.startsWith('chrome://new-tab-page'),
680
+ );
681
+ const keeper =
682
+ homes.find(
683
+ (target) =>
684
+ typeof target.title === 'string' &&
685
+ target.title.trim() !== '' &&
686
+ target.title.trim() !== defaultTitle,
687
+ ) ?? homes[0];
688
+ return [...homes, ...strays]
689
+ .filter((target) => target.id !== keeper.id)
690
+ .map((target) => ({ id: target.id, url: target.url }));
691
+ }
692
+
693
+ // Best effort by design: CDP ownership is already proven and the caller's
694
+ // readiness converges home tabs again, so a pruning failure must not fail the
695
+ // launch. Every failure is returned so the launch log says why a duplicate tab
696
+ // survived instead of hiding it.
697
+ function pruneExtraHomeTabs(port, extensionDir, { homePage, defaultTitle }) {
698
+ if (!homePage) return [];
699
+ const extensionId = extensionIdFromExtensionDir(extensionDir);
700
+ if (!extensionId) return ['extension id unavailable; skipped'];
701
+ const raw = cdpHttp(port, '/json/list');
702
+ if (raw === null) return [`GET http://127.0.0.1:${port}/json/list failed`];
703
+ let targets;
704
+ try {
705
+ targets = JSON.parse(raw);
706
+ } catch (error) {
707
+ return [`/json/list returned malformed JSON: ${error.message}`];
708
+ }
709
+ if (!Array.isArray(targets)) return ['/json/list did not return a target list'];
710
+ const problems = [];
711
+ for (const target of homeTabsToClose(targets, { extensionId, homePage, defaultTitle })) {
712
+ if (cdpHttp(port, `/json/close/${target.id}`) === null)
713
+ problems.push(`could not close ${target.url} (${target.id})`);
714
+ }
715
+ return problems;
716
+ }
717
+
718
+ module.exports = { launchBrowser, homeTabsToClose };
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Operator slot title for headed extension Chrome.
5
+ *
6
+ * Chrome's window label is the active tab's document.title. Farmslot stamps the
7
+ * extension's home page to "<slot-id> — <page title>" so operators can tell
8
+ * which slot owns a window when several farms run side by side.
9
+ *
10
+ * Stamping is best-effort: missing slot context or an uninspectable tab must
11
+ * never fail launch/readiness. A MutationObserver keeps the prefix across the
12
+ * page's own (SPA) title resets.
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const http = require('node:http');
17
+ const path = require('node:path');
18
+
19
+ /**
20
+ * @param {unknown} value
21
+ * @returns {string} The trimmed slot id, or '' when it is not a safe id.
22
+ */
23
+ function sanitizeSlotId(value) {
24
+ const slotId = typeof value === 'string' ? value.trim() : '';
25
+ return /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId) ? slotId : '';
26
+ }
27
+
28
+ /**
29
+ * Slot id from RECIPE_SLOT_ID / SLOT_ID / FARMSLOT_SLOT_ID, else from
30
+ * `<target>/<runtimeDir>/agentic-runtime.json`; '' outside a farm slot.
31
+ * @param {string} [target]
32
+ * @param {string} [runtimeDir]
33
+ * @returns {string}
34
+ */
35
+ function readSlotId(target, runtimeDir) {
36
+ for (const key of ['RECIPE_SLOT_ID', 'SLOT_ID', 'FARMSLOT_SLOT_ID']) {
37
+ const value = sanitizeSlotId(process.env[key]);
38
+ if (value) return value;
39
+ }
40
+ if (!target || !runtimeDir) return '';
41
+ try {
42
+ const runtimeContext = JSON.parse(
43
+ fs.readFileSync(path.join(target, runtimeDir, 'agentic-runtime.json'), 'utf8'),
44
+ );
45
+ return sanitizeSlotId(runtimeContext.slotId);
46
+ } catch {
47
+ // Standalone harness users do not need a custom browser title.
48
+ return '';
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Serialized page-evaluation callback. Must stay a plain function (no closure).
54
+ * `defaultTitle` is the page's own title (as in launchBrowser), used when the document has none.
55
+ * @param {{ slotId: string, defaultTitle?: string }} options
56
+ * @returns {string}
57
+ */
58
+ function applyPersistentSlotTitle({ slotId, defaultTitle = '' }) {
59
+ // Inline sanitize because the serialized function cannot call Node helpers.
60
+ const id =
61
+ typeof slotId === 'string' && /^[A-Za-z0-9._:-]{1,64}$/u.test(slotId.trim())
62
+ ? slotId.trim()
63
+ : '';
64
+ if (!id) return document.title;
65
+ window.__farmslotSlotId = id;
66
+ window.__farmslotSlotDefaultTitle = String(defaultTitle || '');
67
+
68
+ const stripSlotPrefixes = (title) => {
69
+ const ownTitle = window.__farmslotSlotDefaultTitle;
70
+ let base = String(title || ownTitle);
71
+ // Accept em dash, en dash, or hyphen — historical stampers mixed them.
72
+ for (let i = 0; i < 32; i += 1) {
73
+ const stripped = base.replace(/^.+?\s+[—–-]\s+/u, '');
74
+ if (stripped === base) break;
75
+ base = stripped;
76
+ }
77
+ return base || ownTitle;
78
+ };
79
+
80
+ const desiredTitle = () => {
81
+ const id = window.__farmslotSlotId;
82
+ if (!id) return document.title;
83
+ const base = stripSlotPrefixes(document.title);
84
+ if (base && base !== id) return `${id} — ${base}`;
85
+ // A bare id is this stamp's own output for an untitled page: never prefix it
86
+ // with itself, but complete it once the page title is known.
87
+ const ownTitle = window.__farmslotSlotDefaultTitle;
88
+ return ownTitle ? `${id} — ${ownTitle}` : id;
89
+ };
90
+
91
+ const setTitle = () => {
92
+ if (window.__farmslotSlotTitleLock) {
93
+ window.__farmslotSlotTitlePending = true;
94
+ return;
95
+ }
96
+ const next = desiredTitle();
97
+ if (document.title === next) return;
98
+ window.__farmslotSlotTitleLock = true;
99
+ try {
100
+ document.title = next;
101
+ } finally {
102
+ // Release after the title mutation is delivered so the observer cannot
103
+ // re-enter and stack prefixes while React also mutates the DOM. If a
104
+ // mutation arrived while locked, re-apply once after unlock.
105
+ queueMicrotask(() => {
106
+ window.__farmslotSlotTitleLock = false;
107
+ if (window.__farmslotSlotTitlePending) {
108
+ window.__farmslotSlotTitlePending = false;
109
+ setTitle();
110
+ }
111
+ });
112
+ }
113
+ };
114
+
115
+ setTitle();
116
+ if (!window.__farmslotSlotTitleObserver) {
117
+ let node = document.querySelector('title');
118
+ if (!node) {
119
+ node = document.createElement('title');
120
+ (document.head || document.documentElement).appendChild(node);
121
+ }
122
+ window.__farmslotSlotTitleObserver = new MutationObserver(setTitle);
123
+ // Observe only the <title> element — watching head/documentElement with
124
+ // subtree:true re-fires on every React DOM mutation and can livelock CDP.
125
+ window.__farmslotSlotTitleObserver.observe(node, {
126
+ childList: true,
127
+ characterData: true,
128
+ subtree: true,
129
+ });
130
+ }
131
+ return document.title;
132
+ }
133
+
134
+ /**
135
+ * @param {string} slotId
136
+ * @param {string} [defaultTitle]
137
+ * @returns {string}
138
+ */
139
+ function buildStampExpression(slotId, defaultTitle = '') {
140
+ const slot = sanitizeSlotId(slotId);
141
+ if (!slot) return 'document.title';
142
+ // Single source of truth: serialize the Playwright callback for CDP evaluate.
143
+ const argument = JSON.stringify({ slotId: slot, defaultTitle: String(defaultTitle) });
144
+ return `(${applyPersistentSlotTitle.toString()})(${argument})`;
145
+ }
146
+
147
+ function assertLocalUrl(url) {
148
+ if (!/^http:\/\/127\.0\.0\.1:\d+\//u.test(url)) {
149
+ throw new Error(`slot-title: refusing non-local URL: ${url}`);
150
+ }
151
+ }
152
+
153
+ function assertLocalWebSocketUrl(url) {
154
+ if (!/^ws:\/\/(?:127\.0\.0\.1|localhost):\d+\//u.test(String(url))) {
155
+ throw new Error(`slot-title: refusing non-local websocket URL: ${url}`);
156
+ }
157
+ }
158
+
159
+ function httpJson(url, timeoutMs = 3000) {
160
+ assertLocalUrl(url);
161
+ return new Promise((resolve, reject) => {
162
+ const req = http.get(url, (res) => {
163
+ let data = '';
164
+ res.setEncoding('utf8');
165
+ res.on('data', (chunk) => {
166
+ data += chunk;
167
+ });
168
+ res.on('end', () => {
169
+ try {
170
+ resolve(JSON.parse(data));
171
+ } catch (err) {
172
+ reject(new Error(`invalid JSON from ${url}: ${err.message}`));
173
+ }
174
+ });
175
+ });
176
+ req.setTimeout(timeoutMs, () => {
177
+ req.destroy(new Error(`timeout from ${url}`));
178
+ });
179
+ req.on('error', reject);
180
+ });
181
+ }
182
+
183
+ async function cdpEvaluate(webSocketDebuggerUrl, expression, timeoutMs = 5000) {
184
+ assertLocalWebSocketUrl(webSocketDebuggerUrl);
185
+ return new Promise((resolve, reject) => {
186
+ // Loaded only here, so the slot-id helpers work where `ws` is not installed.
187
+ const WebSocketImpl = globalThis.WebSocket ?? require('ws');
188
+ const ws = new WebSocketImpl(webSocketDebuggerUrl);
189
+ const timer = setTimeout(() => {
190
+ try {
191
+ ws.close();
192
+ } catch {
193
+ // Best-effort timeout cleanup.
194
+ }
195
+ reject(new Error('timeout evaluating extension page via CDP'));
196
+ }, timeoutMs);
197
+ const onOpen = () => {
198
+ ws.send(
199
+ JSON.stringify({
200
+ id: 1,
201
+ method: 'Runtime.evaluate',
202
+ params: { expression, awaitPromise: true, returnByValue: true },
203
+ }),
204
+ );
205
+ };
206
+ const onMessage = (event) => {
207
+ const raw = event?.data ?? event;
208
+ const msg = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw));
209
+ if (msg.id !== 1) return;
210
+ clearTimeout(timer);
211
+ ws.close();
212
+ if (msg.error) {
213
+ reject(new Error(msg.error.message || JSON.stringify(msg.error)));
214
+ return;
215
+ }
216
+ resolve(msg.result?.result?.value ?? null);
217
+ };
218
+ const onError = (err) => {
219
+ clearTimeout(timer);
220
+ reject(
221
+ new Error(
222
+ `CDP websocket error while stamping slot title: ${err?.message || err?.error?.message || err?.type || 'unknown'}`,
223
+ ),
224
+ );
225
+ };
226
+ ws.addEventListener('open', onOpen);
227
+ ws.addEventListener('message', onMessage);
228
+ ws.addEventListener('error', onError);
229
+ });
230
+ }
231
+
232
+ function isHomePage(target, extensionId, homePage) {
233
+ if (!target || target.type !== 'page' || typeof target.url !== 'string') return false;
234
+ if (!target.url.startsWith(`chrome-extension://${extensionId}/`)) return false;
235
+ return target.url.includes(`/${homePage}`);
236
+ }
237
+
238
+ /**
239
+ * Stamp every inspectable `homePage` tab of this extension over CDP.
240
+ * Attached tabs (no webSocketDebuggerUrl) are skipped — another client owns them.
241
+ * @param {{ cdpPort: number, extensionId: string, homePage: string, defaultTitle?: string, slotId?: string, target?: string, runtimeDir?: string }} options
242
+ * @returns {Promise<{ slotId: string, stamped: number, skipped: number, titles: string[] }>}
243
+ */
244
+ async function stampHomeTabsViaCdp(options) {
245
+ const {
246
+ target = process.cwd(),
247
+ cdpPort,
248
+ extensionId,
249
+ homePage,
250
+ defaultTitle = '',
251
+ slotId: slotIdOption,
252
+ runtimeDir,
253
+ } = options || {};
254
+ const slotId = sanitizeSlotId(slotIdOption) || readSlotId(target, runtimeDir);
255
+ const empty = { slotId: slotId || '', stamped: 0, skipped: 0, titles: [] };
256
+ if (!slotId || !cdpPort || !extensionId || !homePage) return empty;
257
+
258
+ let targets;
259
+ try {
260
+ targets = await httpJson(`http://127.0.0.1:${cdpPort}/json/list`);
261
+ } catch {
262
+ return empty;
263
+ }
264
+ if (!Array.isArray(targets)) return empty;
265
+
266
+ const homes = targets.filter((t) => isHomePage(t, extensionId, homePage));
267
+ const titles = [];
268
+ let stamped = 0;
269
+ let skipped = 0;
270
+ const expression = buildStampExpression(slotId, defaultTitle);
271
+
272
+ for (const home of homes) {
273
+ if (typeof home.webSocketDebuggerUrl !== 'string') {
274
+ skipped += 1;
275
+ continue;
276
+ }
277
+ try {
278
+ const title = await cdpEvaluate(home.webSocketDebuggerUrl, expression);
279
+ if (typeof title === 'string') {
280
+ titles.push(title);
281
+ stamped += 1;
282
+ } else {
283
+ skipped += 1;
284
+ }
285
+ } catch {
286
+ skipped += 1;
287
+ }
288
+ }
289
+
290
+ return { slotId, stamped, skipped, titles };
291
+ }
292
+
293
+ module.exports = {
294
+ sanitizeSlotId,
295
+ readSlotId,
296
+ applyPersistentSlotTitle,
297
+ buildStampExpression,
298
+ stampHomeTabsViaCdp,
299
+ };