@deepraxis/sdk 0.1.1-dev.f8aa5de → 0.1.1-dev.fee48ec

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ticket-944 — the `actions` driver: the Browser service driven over the public HTTP faces, with no Playwright and
3
+ * no `node:child_process`. A light machine cannot install `playwright-core` at all, so instead of asking to be
4
+ * upgraded it drives the same cloud session through:
5
+ *
6
+ * GET /browser/v1/sessions?name=<name>&status=RUNNING the live session this agent session already has
7
+ * POST /browser/v1/sessions open one under that name when there is none
8
+ * POST /browser/v1/sessions/<id>/actions run one page action (#942)
9
+ *
10
+ * The service executes each action on the session's own CDP control connection, so `open` / `click` / `fill` / …
11
+ * keep working on a machine with no browser stack of its own
12
+ * (`services/browser/src/domain/page-actions.ts`).
13
+ */
14
+ /** Where screenshots land, matching the Playwright driver's directory. */
15
+ export declare const SCREENSHOT_DIR = ".praxis/screenshots";
16
+ /** The two `fs/promises` members this driver uses; the light isolate provides `fs/promises`, never sync `fs`. */
17
+ export interface FsPromisesLike {
18
+ mkdir(path: string, options: {
19
+ recursive: true;
20
+ }): Promise<unknown>;
21
+ writeFile(path: string, data: Uint8Array): Promise<unknown>;
22
+ }
23
+ /** What the actions driver needs from the platform and the process. All of it is injectable for tests. */
24
+ export interface BrowserActionsDeps {
25
+ api: string;
26
+ token: string;
27
+ /** The cloud session name — the agent session id, exactly as the Playwright path's connect URL uses it. */
28
+ name: string;
29
+ labels: Record<string, string>;
30
+ /** Browser context id (bsc_…) carrying saved sign-ins; omitted without one. */
31
+ context?: string;
32
+ home: string;
33
+ fetch: typeof globalThis.fetch;
34
+ fs: FsPromisesLike;
35
+ now: () => number;
36
+ }
37
+ /** An existing tab as `tabs.list` reports it. */
38
+ export interface ActionTabInfo {
39
+ id: string;
40
+ url: string;
41
+ title: string;
42
+ active: boolean;
43
+ }
44
+ /** A page action's answer, structurally: every result kind carries at least `type`. */
45
+ type ActionAnswer = Record<string, unknown> & {
46
+ type?: unknown;
47
+ };
48
+ /** `~/.praxis/screenshots/<timestamp>-<tab>.png`, the Playwright driver's own naming. */
49
+ export declare const screenshotPath: (home: string, tab: string, now: number) => string;
50
+ /**
51
+ * base64 → bytes, without Buffer or atob: the light machine's isolate is promised neither, and the screenshot is
52
+ * the one binary this file writes. Characters outside the alphabet are ignored, as base64 decoders do.
53
+ */
54
+ export declare function decodeBase64(text: string): Uint8Array;
55
+ /** One tab handle: every method is one page action on the resolved session. */
56
+ export interface ActionsTab {
57
+ readonly id: string;
58
+ snapshot(): Promise<string>;
59
+ click(ref: string): Promise<void>;
60
+ fill(ref: string, text: string): Promise<void>;
61
+ press(key: string): Promise<void>;
62
+ scroll(direction: 'up' | 'down', pixels?: number): Promise<void>;
63
+ evaluate(expression: string): Promise<unknown>;
64
+ screenshot(options?: {
65
+ fullPage?: boolean;
66
+ }): Promise<{
67
+ path: string;
68
+ hint: string;
69
+ }>;
70
+ }
71
+ /** The page-action client: session resolution, one request per action, one tab handle per tab id. */
72
+ export interface BrowserActions {
73
+ sessionId(): Promise<string>;
74
+ list(): Promise<ActionTabInfo[]>;
75
+ tab(id: string): ActionsTab;
76
+ /** One already-built action, for the shapes no tab method covers. */
77
+ action(body: Record<string, unknown>): Promise<ActionAnswer>;
78
+ }
79
+ /**
80
+ * Builds the actions client. It never opens a connection, imports Playwright, or reads a Node builtin: a session
81
+ * id is resolved once per call from the name, and every action is a request that returns its own answer.
82
+ */
83
+ export declare function createBrowserActions(deps: BrowserActionsDeps): BrowserActions;
84
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ticket-944 — the `actions` driver: the Browser service driven over the public HTTP faces, with no Playwright and
3
+ * no `node:child_process`. A light machine cannot install `playwright-core` at all, so instead of asking to be
4
+ * upgraded it drives the same cloud session through:
5
+ *
6
+ * GET /browser/v1/sessions?name=<name>&status=RUNNING the live session this agent session already has
7
+ * POST /browser/v1/sessions open one under that name when there is none
8
+ * POST /browser/v1/sessions/<id>/actions run one page action (#942)
9
+ *
10
+ * The service executes each action on the session's own CDP control connection, so `open` / `click` / `fill` / …
11
+ * keep working on a machine with no browser stack of its own
12
+ * (`services/browser/src/domain/page-actions.ts`).
13
+ */
14
+ import { PraxisError } from "../core/errors.js";
15
+ /** The public own-face path prefix — the same one this SDK's connect URL is built on. */
16
+ const BROWSER_V1 = '/browser/v1';
17
+ /** Where screenshots land, matching the Playwright driver's directory. */
18
+ export const SCREENSHOT_DIR = '.praxis/screenshots';
19
+ /**
20
+ * A refusal becomes an error here and nowhere else: the server's own code, status and message travel unchanged,
21
+ * so ACTION_REF_STALE stays distinguishable from an outage (ticket-834's rule, for the actions door).
22
+ */
23
+ function failure(status, body) {
24
+ const record = body;
25
+ const code = typeof record?.code === 'string' ? record.code : undefined;
26
+ const message = typeof record?.message === 'string' ? record.message
27
+ : typeof record?.error === 'string' ? record.error
28
+ : `The browser action failed with HTTP ${status}.`;
29
+ return new PraxisError({ status, ...(code === undefined ? {} : { code }), message, raw: body });
30
+ }
31
+ /** `~/.praxis/screenshots/<timestamp>-<tab>.png`, the Playwright driver's own naming. */
32
+ export const screenshotPath = (home, tab, now) => `${home}/${SCREENSHOT_DIR}/${now}-${tab.slice(0, 8)}.png`;
33
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
34
+ /**
35
+ * base64 → bytes, without Buffer or atob: the light machine's isolate is promised neither, and the screenshot is
36
+ * the one binary this file writes. Characters outside the alphabet are ignored, as base64 decoders do.
37
+ */
38
+ export function decodeBase64(text) {
39
+ const clean = text.replace(/[^A-Za-z0-9+/]/g, '');
40
+ const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
41
+ let byte = 0;
42
+ let bits = 0;
43
+ let written = 0;
44
+ for (const character of clean) {
45
+ const value = ALPHABET.indexOf(character);
46
+ if (value < 0)
47
+ continue;
48
+ byte = (byte << 6) | value;
49
+ bits += 6;
50
+ if (bits >= 8) {
51
+ bits -= 8;
52
+ out[written++] = (byte >> bits) & 0xff;
53
+ }
54
+ }
55
+ return out.subarray(0, written);
56
+ }
57
+ /**
58
+ * Builds the actions client. It never opens a connection, imports Playwright, or reads a Node builtin: a session
59
+ * id is resolved once per call from the name, and every action is a request that returns its own answer.
60
+ */
61
+ export function createBrowserActions(deps) {
62
+ const request = async (path, init = {}) => {
63
+ const headers = {
64
+ Authorization: `Bearer ${deps.token}`,
65
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
66
+ ...(init.headers ?? {}),
67
+ };
68
+ let response;
69
+ try {
70
+ response = await deps.fetch(`${deps.api}${BROWSER_V1}${path}`, { ...init, headers });
71
+ }
72
+ catch (cause) {
73
+ throw new PraxisError({ status: 503, code: 'BROWSER_UNAVAILABLE', message: `Reaching the cloud browser failed: ${cause.message.split('\n')[0]}` });
74
+ }
75
+ const text = await response.text();
76
+ let body;
77
+ try {
78
+ body = text ? JSON.parse(text) : undefined;
79
+ }
80
+ catch {
81
+ body = text;
82
+ }
83
+ if (!response.ok)
84
+ throw failure(response.status, body);
85
+ return body;
86
+ };
87
+ /** Resolved once per client: one connect() is one program, and the session it named does not change inside it. */
88
+ let resolved;
89
+ /** The live session named for this agent session, else a new one under that name. */
90
+ const sessionId = async () => {
91
+ resolved ??= (async () => {
92
+ const found = (await request(`/sessions?name=${encodeURIComponent(deps.name)}&status=RUNNING`));
93
+ // A page holds only this account's sessions, but a name is what identifies ours: anything else is not it.
94
+ const live = (found?.data ?? []).find(row => typeof row.id === 'string' && row.name === deps.name);
95
+ if (live?.id)
96
+ return live.id;
97
+ const created = (await request('/sessions', {
98
+ method: 'POST',
99
+ body: JSON.stringify({
100
+ name: deps.name,
101
+ labels: deps.labels,
102
+ ...(deps.context === undefined ? {} : { browserSettings: { context: { id: deps.context, persist: true } } }),
103
+ }),
104
+ }));
105
+ if (typeof created?.id !== 'string')
106
+ throw new PraxisError({ status: 503, code: 'BROWSER_UNAVAILABLE', message: 'The cloud browser session could not be opened.' });
107
+ return created.id;
108
+ })();
109
+ // A failed resolution is not cached: the next action may find the service again.
110
+ return await resolved.catch(cause => { resolved = undefined; throw cause; });
111
+ };
112
+ const action = async (body) => {
113
+ const id = await sessionId();
114
+ return (await request(`/sessions/${encodeURIComponent(id)}/actions`, { method: 'POST', body: JSON.stringify(body) }));
115
+ };
116
+ const list = async () => {
117
+ const answer = await action({ type: 'tabs.list' });
118
+ return (answer.tabs ?? []).map(tab => ({ id: tab.id, url: tab.url, title: tab.title, active: tab.active }));
119
+ };
120
+ const tab = (id) => {
121
+ const on = (type, extra = {}) => action({ type, tab: id, ...extra });
122
+ return {
123
+ id,
124
+ // The server's own rendering; same reading model as the Playwright driver's ariaSnapshot({ mode: "ai" }).
125
+ snapshot: async () => String((await on('snapshot')).snapshot ?? ''),
126
+ click: async (ref) => { await on('click', { ref }); },
127
+ fill: async (ref, text) => { await on('fill', { ref, text }); },
128
+ press: async (key) => { await on('press', { key }); },
129
+ scroll: async (direction, pixels) => { await on('scroll', { direction, ...(pixels === undefined ? {} : { pixels }) }); },
130
+ evaluate: async (expression) => (await on('evaluate', { expression })).value,
131
+ screenshot: async (options) => {
132
+ const answer = await on('screenshot', options?.fullPage === true ? { fullPage: true } : {});
133
+ const path = screenshotPath(deps.home, id, deps.now());
134
+ await deps.fs.mkdir(`${deps.home}/${SCREENSHOT_DIR}`, { recursive: true });
135
+ await deps.fs.writeFile(path, decodeBase64(String(answer.data ?? '')));
136
+ return { path, hint: 'View this PNG with the Read tool.' };
137
+ },
138
+ };
139
+ };
140
+ return { sessionId, list, tab, action };
141
+ }
@@ -6,7 +6,7 @@ export declare const PLAYWRIGHT_CORE_VERSION = "1.63.0";
6
6
  export type PlaywrightPage = any;
7
7
  export interface BrowserTab {
8
8
  readonly id: string;
9
- /** The Playwright Page for locators and waits. */
9
+ /** The Playwright Page for locators and waits. Absent in the `actions` driver, where reading it throws. */
10
10
  readonly playwright: PlaywrightPage;
11
11
  goto(url: string): Promise<void>;
12
12
  back(): Promise<void>;
@@ -15,8 +15,18 @@ export interface BrowserTab {
15
15
  close(): Promise<void>;
16
16
  url(): Promise<string>;
17
17
  title(): Promise<string>;
18
- /** The page as a Playwright ARIA snapshot with [ref=eN] marks. */
18
+ /** The page as an accessibility snapshot with [ref=eN] marks: Playwright's, or the server's own text. */
19
19
  snapshot(): Promise<string>;
20
+ /** Clicks the element a snapshot marked `[ref=eN]`. */
21
+ click(ref: string): Promise<void>;
22
+ /** Fills the element a snapshot marked `[ref=eN]`. */
23
+ fill(ref: string, text: string): Promise<void>;
24
+ /** Presses one key (Enter, Tab, a single character). */
25
+ press(key: string): Promise<void>;
26
+ /** Scrolls the page by `pixels` (default 800) in one direction. */
27
+ scroll(direction: 'up' | 'down', pixels?: number): Promise<void>;
28
+ /** Evaluates an expression in the page and returns its value. */
29
+ evaluate(expression: string): Promise<unknown>;
20
30
  /** Writes a PNG under ~/.praxis/screenshots and returns its path; view it with the Read tool. */
21
31
  screenshot(options?: {
22
32
  fullPage?: boolean;
@@ -47,6 +57,7 @@ interface ContextsApi {
47
57
  query?: {
48
58
  limit?: number;
49
59
  cursor?: string;
60
+ name?: string;
50
61
  };
51
62
  }): Promise<{
52
63
  data: (ContextLike & {
@@ -61,6 +72,15 @@ interface ContextsApi {
61
72
  };
62
73
  }): Promise<ContextLike>;
63
74
  }
75
+ /** The playwright-core module shape the helper uses. */
76
+ export interface PlaywrightModule {
77
+ chromium: {
78
+ connectOverCDP(url: string, options: {
79
+ headers: Record<string, string>;
80
+ timeout?: number;
81
+ }): Promise<unknown>;
82
+ };
83
+ }
64
84
  /** What connect() needs from the platform client and the process. All of it is injectable for tests. */
65
85
  export interface BrowserConnectDeps {
66
86
  contexts: ContextsApi;
@@ -68,6 +88,10 @@ export interface BrowserConnectDeps {
68
88
  /** process.getBuiltinModule; undefined on runtimes without it. */
69
89
  builtin: ((name: string) => unknown) | undefined;
70
90
  now?: () => number;
91
+ /** An already-loaded playwright-core; the real loader finds or installs one and imports it. */
92
+ playwright?: PlaywrightModule;
93
+ /** The fetch the actions driver sends its requests with; globalThis.fetch by default. */
94
+ fetch?: typeof globalThis.fetch;
71
95
  }
72
96
  export declare const unavailable: (message: string) => PraxisError;
73
97
  /** Same signal the plugin installer and child_process use: the light machine asks to be upgraded and the program runs again. */
@@ -8,11 +8,14 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { PraxisError } from "../core/errors.js";
10
10
  import { BROWSER_DOCUMENTATION } from "./browser-documentation.js";
11
- import { agentContextName, buildConnectUrl, decideTabReuse, pickAgentContext } from "./browser-decisions.js";
11
+ import { createBrowserActions } from "./browser-actions.js";
12
+ import { agentContextName, buildConnectUrl, decideBrowserDriver, decideTabReuse, isDuplicateNameError, matchTargetsToPages, pickAgentContext, pickContextByName } from "./browser-decisions.js";
12
13
  /** Version of the Playwright client library: preinstalled in the base image, installed on demand elsewhere. */
13
14
  export const PLAYWRIGHT_CORE_VERSION = '1.63.0';
14
15
  const GLOBAL_PLAYWRIGHT = '/usr/local/lib/node_modules/playwright-core';
15
16
  const IDLE_MS = 300_000;
17
+ /** A scroll without a distance moves this far, the server's own default (PAGE_ACTION_LIMITS.scrollPixelsDefault). */
18
+ const SCROLL_PIXELS_DEFAULT = 800;
16
19
  export const unavailable = (message) => new PraxisError({ status: 503, code: 'BROWSER_UNAVAILABLE', message });
17
20
  /** Same signal the plugin installer and child_process use: the light machine asks to be upgraded and the program runs again. */
18
21
  export const needsFullMachine = () => Object.assign(new Error('The cloud browser needs a full sandbox — upgrade this instance'), { code: 'PROGRAM_NEEDS_FULL_MACHINE' });
@@ -32,7 +35,34 @@ function node(deps, name) {
32
35
  }
33
36
  throw needsFullMachine();
34
37
  }
38
+ /**
39
+ * Whether this runtime can load `node:child_process` — the one capability the Playwright driver needs and the
40
+ * actions driver does not. A refusal is a fact about the machine, never an error: it selects the driver.
41
+ */
42
+ function hasChildProcess(deps) {
43
+ if (!deps.builtin)
44
+ return false;
45
+ try {
46
+ return Boolean(deps.builtin('node:child_process'));
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /** An optional builtin: absent (or refused) answers undefined instead of throwing, for modules a driver may not need. */
53
+ function optionalNode(deps, name) {
54
+ if (!deps.builtin)
55
+ return undefined;
56
+ try {
57
+ return deps.builtin(name) ?? undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
35
63
  async function loadPlaywright(deps, home) {
64
+ if (deps.playwright)
65
+ return deps.playwright;
36
66
  node(deps, 'node:child_process'); // fails fast on a light machine, before any network work
37
67
  const fs = node(deps, 'node:fs');
38
68
  const entry = (root) => `${root}/index.mjs`;
@@ -49,22 +79,164 @@ async function loadPlaywright(deps, home) {
49
79
  }
50
80
  return await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `file://${entry(root)}`));
51
81
  }
82
+ /** The agent's own context, in one name-filtered request; the paged scan is only the pre-`name` server fallback. */
52
83
  async function ensureContext(deps, agentId) {
53
- const seen = [];
54
- let cursor;
55
- for (let page = 0; page < 20; page++) {
56
- const result = await deps.contexts.list({ query: { limit: 100, ...(cursor ? { cursor } : {}) } });
57
- seen.push(...result.data);
58
- const own = pickAgentContext(seen, agentId);
59
- if (own)
60
- return own.id;
61
- if (!result.nextCursor)
62
- break;
63
- cursor = result.nextCursor;
84
+ const name = agentContextName(agentId);
85
+ const byNameQuery = () => deps.contexts.list({ query: { name, limit: 1 } });
86
+ // A server that knows `name` answers with this agent's context or nothing; a non-empty answer of other rows means
87
+ // it ignored the filter, and only then is the paged scan worth its round trips.
88
+ const paged = async () => {
89
+ const seen = [];
90
+ let cursor;
91
+ for (let page = 0; page < 20; page++) {
92
+ const result = await deps.contexts.list({ query: { limit: 100, ...(cursor ? { cursor } : {}) } });
93
+ seen.push(...result.data);
94
+ const own = pickAgentContext(seen, agentId);
95
+ if (own)
96
+ return own.id;
97
+ if (!result.nextCursor)
98
+ break;
99
+ cursor = result.nextCursor;
100
+ }
101
+ return undefined;
102
+ };
103
+ const find = async () => {
104
+ const answer = (await byNameQuery()).data;
105
+ const byName = pickContextByName(answer, agentId);
106
+ if (byName)
107
+ return byName.id;
108
+ return answer.length > 0 ? paged() : undefined;
109
+ };
110
+ const existing = await find();
111
+ if (existing)
112
+ return existing;
113
+ try {
114
+ return (await deps.contexts.create({ body: { name, labels: { agent: agentId } } })).id;
115
+ }
116
+ catch (cause) {
117
+ // Names are unique per account, so this is a concurrent run winning the create: reuse what it made.
118
+ if (!isDuplicateNameError(cause))
119
+ throw cause;
120
+ const raced = await find();
121
+ if (raced)
122
+ return raced;
123
+ throw cause;
64
124
  }
65
- return (await deps.contexts.create({ body: { name: agentContextName(agentId), labels: { agent: agentId } } })).id;
66
125
  }
67
126
  const bounded = (work, ms) => Promise.race([work, new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), ms))]);
127
+ /**
128
+ * ticket-834 — an agent that is refused a browser must be able to tell "come back in 7 s" from "your key is
129
+ * wrong". Playwright's transport rejects with `WebSocket error: <url> <status> <statusWord>\n<body>` when the
130
+ * CDP upgrade is answered, wrapped by `browserType.connectOverCDP: `; the body is the only part that says
131
+ * which refusal it was and how long to wait, so parse it instead of reporting the first line as an outage.
132
+ */
133
+ function refusedConnect(cause) {
134
+ if (!(cause instanceof Error))
135
+ return null;
136
+ const match = /WebSocket error: \S+ (\d{3}) [^\n]*\n([\s\S]+)$/.exec(cause.message);
137
+ if (!match)
138
+ return null;
139
+ const status = Number(match[1]);
140
+ let body;
141
+ try {
142
+ body = JSON.parse(match[2].trim());
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ if (typeof body?.code !== 'string')
148
+ return null;
149
+ const retryAfter = typeof body.retryAfter === 'number' && Number.isFinite(body.retryAfter) ? body.retryAfter : null;
150
+ const message = typeof body.message === 'string' ? body.message : undefined;
151
+ return new PraxisError({
152
+ status,
153
+ code: body.code,
154
+ message: `${message ?? body.code}${retryAfter === null ? '' : ` Retry after ${retryAfter} s.`}`,
155
+ raw: body,
156
+ });
157
+ }
158
+ /**
159
+ * The light machine's browser: every call is one page action over HTTP, so nothing here loads playwright-core,
160
+ * reads `node:child_process`, or keeps a connection open. Tabs are addressed by the CDP target id the service
161
+ * reports, which is the same id the Playwright driver hands out.
162
+ */
163
+ async function connectActions(deps, settings) {
164
+ // fs/promises is what the light isolate provides (sync fs is refused there); screenshots go through it.
165
+ const fs = optionalNode(deps, 'node:fs/promises');
166
+ if (!fs)
167
+ throw unavailable('praxis.browser.connect() needs node:fs/promises on the session machine to save screenshots.');
168
+ const client = createBrowserActions({
169
+ api: settings.api, token: settings.token, name: settings.name,
170
+ labels: { agent_session: settings.name, agent: settings.agentId },
171
+ context: settings.context, home: settings.home, fs, now: settings.now,
172
+ // Bound: a Worker isolate (the light machine) throws "Illegal invocation" when fetch is called as a method of another object.
173
+ fetch: deps.fetch ?? globalThis.fetch.bind(globalThis),
174
+ });
175
+ const noPlaywright = () => new PraxisError({
176
+ status: 501, code: 'BROWSER_DRIVER_ACTIONS',
177
+ message: 'This machine drives the cloud browser through server-side page actions, so tab.playwright is not available. '
178
+ + 'Available: goto, back, forward, reload, url, title, snapshot, click(ref), fill(ref, text), press(key), scroll(direction, pixels), evaluate(expression), screenshot, close. '
179
+ + 'Run this program on an environment with the heavy machine template when a Playwright Page is required.',
180
+ });
181
+ /** One tab: the actions client plus the navigation shapes it does not own. */
182
+ const wrap = async (id) => {
183
+ const tab = client.tab(id);
184
+ /** Where the tab is now, in one action: evaluate answers url and title together. */
185
+ const located = () => tab.evaluate('({ url: location.href, title: document.title })');
186
+ return {
187
+ id,
188
+ get playwright() { throw noPlaywright(); },
189
+ goto: async (target) => { await client.action({ type: 'open', url: target, tab: id }); },
190
+ back: async () => { await client.action({ type: 'back', tab: id }); },
191
+ forward: async () => { await client.action({ type: 'forward', tab: id }); },
192
+ reload: async () => { await client.action({ type: 'reload', tab: id }); },
193
+ // The actions face has no close: report it, and leave the tab for the session to release.
194
+ close: async () => { throw unavailable('The server-side page actions have no close-tab action; leave the tab open and the cloud session releases it when it idles out.'); },
195
+ url: async () => String((await located()).url ?? ''),
196
+ title: async () => String((await located()).title ?? ''),
197
+ snapshot: () => tab.snapshot(),
198
+ click: ref => tab.click(ref),
199
+ fill: (ref, text) => tab.fill(ref, text),
200
+ press: key => tab.press(key),
201
+ scroll: (direction, pixels) => tab.scroll(direction, pixels ?? SCROLL_PIXELS_DEFAULT),
202
+ evaluate: expression => tab.evaluate(expression),
203
+ screenshot: options => tab.screenshot(options),
204
+ };
205
+ };
206
+ const ids = async () => (await client.list()).map(entry => entry.id);
207
+ const openTab = async (target, options) => {
208
+ const tabs = await client.list();
209
+ if (options?.reuseTab !== false) {
210
+ const reuse = decideTabReuse(target, tabs);
211
+ if (reuse) {
212
+ const tab = await wrap(reuse.id);
213
+ await tab.goto(target);
214
+ return tab;
215
+ }
216
+ }
217
+ const answer = await client.action({ type: 'open', url: target, ...(tabs.length === 0 ? {} : { newTab: true }) });
218
+ return await wrap(String(answer.tab ?? ''));
219
+ };
220
+ return {
221
+ tabs: {
222
+ list: () => client.list(),
223
+ get: async (id) => {
224
+ if (!(await ids()).includes(id))
225
+ throw new PraxisError({ status: 404, code: 'TAB_NOT_FOUND', message: `No tab with id ${id}; call browser.tabs.list() for the current tabs.` });
226
+ return await wrap(id);
227
+ },
228
+ new: async () => {
229
+ // `open` with `newTab` is the only way the actions face creates a page.
230
+ const answer = await client.action({ type: 'open', url: 'about:blank', newTab: true });
231
+ return await wrap(String(answer.tab ?? ''));
232
+ },
233
+ },
234
+ open: openTab,
235
+ documentation: () => BROWSER_DOCUMENTATION,
236
+ /** Drops this connection's session id only; the cloud session is left to idle out, as in the Playwright path. */
237
+ disconnect: async () => undefined,
238
+ };
239
+ }
68
240
  export async function connectBrowser(deps, options = {}) {
69
241
  const { PRAXIS_API: api, PRAXIS_RUN_TOKEN: token, PRAXIS_AGENT_ID: agentId, PRAXIS_SESSION_ID: sessionId } = deps.env;
70
242
  const missing = ['PRAXIS_API', 'PRAXIS_RUN_TOKEN', 'PRAXIS_AGENT_ID', 'PRAXIS_SESSION_ID'].filter(name => !deps.env[name]);
@@ -72,20 +244,52 @@ export async function connectBrowser(deps, options = {}) {
72
244
  throw unavailable(`praxis.browser.connect() runs inside an agent session; ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not set. Use praxis.browser.sessions.create() for a standalone cloud browser.`);
73
245
  }
74
246
  const home = deps.env.HOME ?? '/home/user';
75
- const playwright = await loadPlaywright(deps, home);
247
+ const now = deps.now ?? Date.now;
248
+ const driver = decideBrowserDriver({ override: deps.env.PRAXIS_BROWSER_DRIVER, childProcessAvailable: hasChildProcess(deps) });
76
249
  const context = options.context === false ? undefined : await ensureContext(deps, agentId);
250
+ // The light machine drives the browser the service already runs; it never asks to be upgraded.
251
+ if (driver === 'actions')
252
+ return await connectActions(deps, { api, token, name: sessionId, agentId, home, context, now });
253
+ const playwright = await loadPlaywright(deps, home);
77
254
  const url = buildConnectUrl({ api, name: sessionId, context, idleMs: IDLE_MS, labels: { agent_session: sessionId, agent: agentId } });
78
255
  let browser;
79
256
  try {
80
257
  browser = await playwright.chromium.connectOverCDP(url, { headers: { Authorization: `Bearer ${token}` }, timeout: 60_000 });
81
258
  }
82
259
  catch (cause) {
83
- throw unavailable(`Connecting to the cloud browser failed: ${cause.message.split('\n')[0]}`);
260
+ // A refusal that named itself travels as itself; anything else — a network error, a timeout — is the outage it looks like.
261
+ throw refusedConnect(cause) ?? unavailable(`Connecting to the cloud browser failed: ${cause.message.split('\n')[0]}`);
84
262
  }
85
263
  const fs = node(deps, 'node:fs');
86
- const now = deps.now ?? Date.now;
87
264
  const pages = () => browser.contexts().flatMap((c) => c.pages());
88
265
  const ids = new WeakMap();
266
+ /** One browser-level CDP session for the connection: `Target.getTargets` answers for every tab in one round trip. */
267
+ let cdp;
268
+ const browserSession = async () => {
269
+ if (!browser.newBrowserCDPSession)
270
+ return undefined; // older playwright-core: every page keeps the per-page lookup
271
+ if (!cdp) {
272
+ try {
273
+ cdp = await browser.newBrowserCDPSession();
274
+ }
275
+ catch {
276
+ return undefined;
277
+ } // a browser that refuses the session lists tabs the old way, it does not fail
278
+ }
279
+ return cdp;
280
+ };
281
+ /** CDP's page targets, or nothing when the browser session is unavailable — never a thrown listing. */
282
+ const pageTargets = async () => {
283
+ const session = await browserSession();
284
+ if (!session)
285
+ return undefined;
286
+ try {
287
+ return ((await session.send('Target.getTargets'))?.targetInfos ?? []);
288
+ }
289
+ catch {
290
+ return undefined;
291
+ }
292
+ };
89
293
  const idOf = async (page) => {
90
294
  const known = ids.get(page);
91
295
  if (known)
@@ -100,6 +304,25 @@ export async function connectBrowser(deps, options = {}) {
100
304
  await session.detach().catch(() => { });
101
305
  }
102
306
  };
307
+ /**
308
+ * Every page with its target id: the one `Target.getTargets` covers the pages it names unambiguously, and only
309
+ * the rest (another tab on the same url, a page it does not name) pay for their own `Target.getTargetInfo`.
310
+ * Matching itself makes no request, so `get` reads the map for free and only a miss asks the page again.
311
+ */
312
+ const observed = async () => {
313
+ const all = pages();
314
+ const urls = all.map(page => page.url());
315
+ const targets = await pageTargets();
316
+ const { matched } = matchTargetsToPages(targets ?? [], urls);
317
+ return all.map((page, index) => {
318
+ const target = matched.get(index);
319
+ if (target)
320
+ ids.set(page, target.targetId);
321
+ return { page, id: target?.targetId, url: target?.url ?? urls[index] ?? '', title: target?.title };
322
+ });
323
+ };
324
+ /** A page's target id, from the map when it named it or from its own CDP session otherwise. */
325
+ const idOfObserved = async (tab) => tab.id ?? await bounded(idOf(tab.page), 2000).catch(() => '');
103
326
  const wrap = async (page) => {
104
327
  const id = await idOf(page);
105
328
  return {
@@ -112,6 +335,12 @@ export async function connectBrowser(deps, options = {}) {
112
335
  url: async () => page.url(),
113
336
  title: async () => page.title(),
114
337
  snapshot: async () => page.ariaSnapshot({ mode: 'ai' }),
338
+ // `aria-ref=` is Playwright's own spelling of a snapshot ref, so the two drivers act on the same handle.
339
+ click: async (ref) => { await page.locator(`aria-ref=${ref}`).click(); },
340
+ fill: async (ref, text) => { await page.locator(`aria-ref=${ref}`).fill(text); },
341
+ press: async (key) => { await page.keyboard.press(key); },
342
+ scroll: async (direction, pixels) => { await page.mouse.wheel(0, direction === 'down' ? pixels ?? SCROLL_PIXELS_DEFAULT : -(pixels ?? SCROLL_PIXELS_DEFAULT)); },
343
+ evaluate: async (expression) => await page.evaluate(expression),
115
344
  screenshot: async (shot) => {
116
345
  const dir = `${home}/.praxis/screenshots`;
117
346
  fs.mkdirSync(dir, { recursive: true });
@@ -121,17 +350,20 @@ export async function connectBrowser(deps, options = {}) {
121
350
  },
122
351
  };
123
352
  };
124
- const list = async () => Promise.all(pages().map(async (page) => ({
125
- id: await idOf(page), url: page.url(),
126
- title: await bounded(page.title(), 2000).catch(() => ''),
127
- active: await bounded(page.evaluate(() => document.visibilityState === 'visible'), 2000).catch(() => false),
353
+ const list = async () => Promise.all((await observed()).map(async (tab) => ({
354
+ id: await idOfObserved(tab), url: tab.url,
355
+ // A matched target already carries the title; an unmapped page keeps asking its own page, as before.
356
+ title: tab.title ?? await bounded(tab.page.title(), 2000).catch(() => ''),
357
+ active: await bounded(tab.page.evaluate(() => document.visibilityState === 'visible'), 2000).catch(() => false),
128
358
  })));
129
359
  const get = async (id) => {
130
- for (const page of pages())
131
- if (await idOf(page) === id) {
132
- await page.bringToFront().catch(() => { });
133
- return wrap(page);
360
+ for (const tab of await observed()) {
361
+ const known = await idOfObserved(tab);
362
+ if (known === id && known) {
363
+ await tab.page.bringToFront().catch(() => { });
364
+ return wrap(tab.page);
134
365
  }
366
+ }
135
367
  throw new PraxisError({ status: 404, code: 'TAB_NOT_FOUND', message: `No tab with id ${id}; call browser.tabs.list() for the current tabs.` });
136
368
  };
137
369
  const create = async () => {
@@ -156,6 +388,12 @@ export async function connectBrowser(deps, options = {}) {
156
388
  return tab;
157
389
  },
158
390
  documentation: () => BROWSER_DOCUMENTATION,
159
- disconnect: async () => { await browser.close(); },
391
+ /** Drops this connection only; the cloud session and its tabs stay until it idles out. */
392
+ disconnect: async () => {
393
+ // browser.close() drops the whole CDP connection, the browser-level session with it; detaching
394
+ // it first only added one more cross-region round trip (+0.6–0.8 s on the #829 LAB).
395
+ cdp = undefined;
396
+ await browser.close();
397
+ },
160
398
  };
161
399
  }