@boardwalk-labs/runner 0.1.11 → 0.1.13
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/README.md +18 -0
- package/binding.gyp +12 -0
- package/dist/runtime/agent/budget.d.ts +5 -5
- package/dist/runtime/agent/budget.js +8 -8
- package/dist/runtime/agent/model_rates.js +4 -5
- package/dist/runtime/agent/secret_redactor.js +1 -1
- package/dist/runtime/browser_session.d.ts +59 -0
- package/dist/runtime/browser_session.js +188 -0
- package/dist/runtime/browser_session_backend.d.ts +44 -0
- package/dist/runtime/browser_session_backend.js +221 -0
- package/dist/runtime/cancel_watcher.js +1 -1
- package/dist/runtime/credit_watcher.d.ts +1 -1
- package/dist/runtime/credit_watcher.js +5 -5
- package/dist/runtime/freeze_coordinator.d.ts +6 -1
- package/dist/runtime/freeze_coordinator.js +18 -4
- package/dist/runtime/index.d.ts +11 -6
- package/dist/runtime/index.js +96 -41
- package/dist/runtime/leaf_executor.d.ts +2 -2
- package/dist/runtime/program_runner.d.ts +1 -1
- package/dist/runtime/program_runner.js +1 -1
- package/dist/runtime/program_worker.d.ts +9 -3
- package/dist/runtime/program_worker.js +18 -7
- package/dist/runtime/recording_secret_resolver.js +1 -1
- package/dist/runtime/run_abort.js +3 -3
- package/dist/runtime/runner_control_client.d.ts +29 -4
- package/dist/runtime/runner_control_client.js +76 -32
- package/dist/runtime/runtime_flusher.d.ts +1 -1
- package/dist/runtime/runtime_flusher.js +3 -3
- package/dist/runtime/support/index.js +5 -6
- package/dist/runtime/tools/artifacts.js +1 -1
- package/dist/runtime/tools/types.d.ts +5 -5
- package/dist/runtime/tools/types.js +7 -7
- package/dist/runtime/tools/web_search.js +5 -6
- package/dist/runtime/uniqueness_reseed.d.ts +7 -0
- package/dist/runtime/uniqueness_reseed.js +65 -0
- package/dist/runtime/wire/artifact_storage.js +2 -2
- package/dist/runtime/wire/inference_proxy.d.ts +1 -1
- package/dist/runtime/wire/inference_proxy.js +2 -2
- package/dist/runtime/workflow_host.d.ts +47 -1
- package/dist/runtime/workflow_host.js +100 -10
- package/native/reseed.c +52 -0
- package/package.json +15 -7
- package/prebuilds/linux-x64/@boardwalk-labs+runner.node +0 -0
package/README.md
CHANGED
|
@@ -38,6 +38,24 @@ finishes, nothing new is claimed. Useful flags: `--once` (execute one run, then
|
|
|
38
38
|
and `HTTPS_PROXY` set; the daemon and the run processes both honor it. Runs inherit this
|
|
39
39
|
machine's network — a model or service reachable from the box is reachable from the run.
|
|
40
40
|
|
|
41
|
+
### Browser tier (computer use)
|
|
42
|
+
|
|
43
|
+
`computer.openBrowser()` and `agent({ session })` drive an in-VM browser. On the hosted
|
|
44
|
+
runners the environment ships it; on a self-hosted machine you provide the pieces and point the
|
|
45
|
+
runner at them with a small env contract — the same contract the hosted image sets, so the code
|
|
46
|
+
path is identical:
|
|
47
|
+
|
|
48
|
+
| Variable | Meaning |
|
|
49
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
50
|
+
| `BOARDWALK_BROWSER_TIER=1` | Enable the per-run browser-session manager. Unset (the default) means `computer.openBrowser()` fails with a clear "not available on this runner". |
|
|
51
|
+
| `BOARDWALK_BROWSER_CHROME_PATH` | Path to a Chromium/Chrome binary the run launches with a CDP endpoint (headful, so it renders on a display). |
|
|
52
|
+
| `BOARDWALK_BROWSER_MCP_COMMAND` | Command to launch [Playwright MCP](https://github.com/microsoft/playwright-mcp) (e.g. a `playwright-mcp` bin, or `npx`). The runner attaches it to the browser over CDP. |
|
|
53
|
+
| `DISPLAY` | The X display the browser renders on (e.g. a headless `Xvfb :0`). |
|
|
54
|
+
|
|
55
|
+
So a self-hosted machine that wants the browser tier installs Chromium + Playwright MCP, runs a
|
|
56
|
+
display (e.g. `Xvfb`), and sets those variables. Nothing else changes; a machine without them
|
|
57
|
+
runs every non-browser workflow exactly as before.
|
|
58
|
+
|
|
41
59
|
## Security model
|
|
42
60
|
|
|
43
61
|
This part of the contract is settled, even though the client isn't built yet:
|
package/binding.gyp
ADDED
|
@@ -74,7 +74,7 @@ export declare class BudgetMeter {
|
|
|
74
74
|
constructor(opts: BudgetMeterOptions);
|
|
75
75
|
/**
|
|
76
76
|
* Add a usage delta to the accumulator + recompute USD. `realCostUsd`, when provided, is the EXACT
|
|
77
|
-
* upstream cost the broker observed for this turn (
|
|
77
|
+
* upstream cost the broker observed for this turn (the managed provider's per-request cost) — used
|
|
78
78
|
* verbatim so the `max_usd` cap tracks ACTUAL spend (already cache-discounted, model-correct).
|
|
79
79
|
* Omitted for a BYO turn (no upstream price) or a managed turn whose cost the broker couldn't read,
|
|
80
80
|
* which fall back to the representative-rate {@link costFor} estimate. Token counts accumulate on
|
|
@@ -83,7 +83,7 @@ export declare class BudgetMeter {
|
|
|
83
83
|
addUsage(delta: UsageDelta, realCostUsd?: number): void;
|
|
84
84
|
/**
|
|
85
85
|
* THIS SESSION's accumulator snapshot (excludes prior-session usage). Read by per-session token
|
|
86
|
-
* metering — which reports token deltas to
|
|
86
|
+
* metering — which reports token deltas to the platform (deferred; not yet wired into the brokered
|
|
87
87
|
* loop) — and by audit/post-run cost rollups. Cap enforcement uses {@link cumulative} instead.
|
|
88
88
|
*/
|
|
89
89
|
snapshot(): BudgetSnapshot;
|
|
@@ -92,7 +92,7 @@ export declare class BudgetMeter {
|
|
|
92
92
|
* construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
|
|
93
93
|
* this so a run that resumes after a sleep/wait/crash is bounded by its declared caps across
|
|
94
94
|
* the whole run — not once per session. `snapshot()` stays session-local because
|
|
95
|
-
* per-session token metering reports token deltas; seeding it would double-report
|
|
95
|
+
* per-session token metering reports token deltas; seeding it would double-report usage.
|
|
96
96
|
*/
|
|
97
97
|
cumulative(): BudgetSnapshot;
|
|
98
98
|
/**
|
|
@@ -116,8 +116,8 @@ export declare class BudgetMeter {
|
|
|
116
116
|
/**
|
|
117
117
|
* USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
|
|
118
118
|
* no real upstream cost for the turn (a BYO-provider turn, or a managed turn whose cost tap missed).
|
|
119
|
-
* Managed turns instead carry
|
|
120
|
-
* the loop can stamp the per-step
|
|
119
|
+
* Managed turns instead carry the managed provider's exact per-request cost through to {@link addUsage}. Public so
|
|
120
|
+
* the loop can stamp the per-step cost without re-deriving the rate table.
|
|
121
121
|
*/
|
|
122
122
|
costFor(delta: UsageDelta): number;
|
|
123
123
|
}
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// `AppError(BUDGET_EXCEEDED)` and the loop tears down with a `turn_ended
|
|
6
6
|
// reason='error'` event.
|
|
7
7
|
//
|
|
8
|
-
// USD here tracks the bill closely: for a MANAGED turn the meter is fed
|
|
9
|
-
//
|
|
8
|
+
// USD here tracks the bill closely: for a MANAGED turn the meter is fed the managed provider's exact
|
|
9
|
+
// per-request cost (already cache-discounted + model-correct), so the `max_usd` cap reflects real spend —
|
|
10
10
|
// not a hand-rolled token estimate. Only a BYO turn (no upstream price) or a managed turn whose cost
|
|
11
11
|
// the broker couldn't read falls back to a single flat representative rate
|
|
12
12
|
// (`model_rates.ts::BUDGET_GUARDRAIL_RATE`), since a per-`agent()`-model run has no one model. Actual
|
|
13
|
-
// billing
|
|
13
|
+
// billing is metered by the platform, not by the runner. The meter takes
|
|
14
14
|
// the fallback rate as a constructor arg so tests inject a fixed rate.
|
|
15
15
|
import { AppError, ErrorCode } from "../support/index.js";
|
|
16
16
|
const ZERO_PRIOR = {
|
|
@@ -44,7 +44,7 @@ export class BudgetMeter {
|
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
46
|
* Add a usage delta to the accumulator + recompute USD. `realCostUsd`, when provided, is the EXACT
|
|
47
|
-
* upstream cost the broker observed for this turn (
|
|
47
|
+
* upstream cost the broker observed for this turn (the managed provider's per-request cost) — used
|
|
48
48
|
* verbatim so the `max_usd` cap tracks ACTUAL spend (already cache-discounted, model-correct).
|
|
49
49
|
* Omitted for a BYO turn (no upstream price) or a managed turn whose cost the broker couldn't read,
|
|
50
50
|
* which fall back to the representative-rate {@link costFor} estimate. Token counts accumulate on
|
|
@@ -59,7 +59,7 @@ export class BudgetMeter {
|
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* THIS SESSION's accumulator snapshot (excludes prior-session usage). Read by per-session token
|
|
62
|
-
* metering — which reports token deltas to
|
|
62
|
+
* metering — which reports token deltas to the platform (deferred; not yet wired into the brokered
|
|
63
63
|
* loop) — and by audit/post-run cost rollups. Cap enforcement uses {@link cumulative} instead.
|
|
64
64
|
*/
|
|
65
65
|
snapshot() {
|
|
@@ -78,7 +78,7 @@ export class BudgetMeter {
|
|
|
78
78
|
* construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
|
|
79
79
|
* this so a run that resumes after a sleep/wait/crash is bounded by its declared caps across
|
|
80
80
|
* the whole run — not once per session. `snapshot()` stays session-local because
|
|
81
|
-
* per-session token metering reports token deltas; seeding it would double-report
|
|
81
|
+
* per-session token metering reports token deltas; seeding it would double-report usage.
|
|
82
82
|
*/
|
|
83
83
|
cumulative() {
|
|
84
84
|
const s = this.snapshot();
|
|
@@ -159,8 +159,8 @@ export class BudgetMeter {
|
|
|
159
159
|
/**
|
|
160
160
|
* USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
|
|
161
161
|
* no real upstream cost for the turn (a BYO-provider turn, or a managed turn whose cost tap missed).
|
|
162
|
-
* Managed turns instead carry
|
|
163
|
-
* the loop can stamp the per-step
|
|
162
|
+
* Managed turns instead carry the managed provider's exact per-request cost through to {@link addUsage}. Public so
|
|
163
|
+
* the loop can stamp the per-step cost without re-deriving the rate table.
|
|
164
164
|
*/
|
|
165
165
|
costFor(delta) {
|
|
166
166
|
const inputCost = ((delta.inputTokens ?? 0) * this.rate.inputPerMillion) / 1_000_000;
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// The flat budget-guardrail rate — the FALLBACK basis for the in-run `max_usd` cap (BudgetMeter).
|
|
2
2
|
//
|
|
3
|
-
// A MANAGED turn now caps on
|
|
3
|
+
// A MANAGED turn now caps on the managed provider's EXACT per-request cost, forwarded from the broker to
|
|
4
4
|
// the worker (inference_proxy result frame → BudgetMeter.addUsage `realCostUsd`), so the cap tracks
|
|
5
5
|
// real spend — already cache-discounted + model-correct. This flat rate is used ONLY when a turn has
|
|
6
6
|
// no upstream cost: a BYO-provider turn (the org pays its own key), or a managed turn whose cost the
|
|
7
|
-
// broker couldn't read. It bounds runaway loops; it is NOT the bill. Actual billing is
|
|
8
|
-
//
|
|
9
|
-
// run_usage_service). the platform spec
|
|
7
|
+
// broker couldn't read. It bounds runaway loops; it is NOT the bill. Actual billing is metered by the
|
|
8
|
+
// platform, not by the runner.
|
|
10
9
|
//
|
|
11
10
|
// Deliberately NOT a per-model table with a silent fallback: a lookup that returns a Sonnet-class
|
|
12
11
|
// default for any unknown id only created the ILLUSION of precision. The real per-request cost (above)
|
|
13
12
|
// is the precise path; this rate is just the model-agnostic backstop. Un-metered managed models are
|
|
14
|
-
// rejected fail-closed at resolution
|
|
13
|
+
// rejected fail-closed at resolution by the broker, so the cap never has to price
|
|
15
14
|
// a model we don't support.
|
|
16
15
|
/**
|
|
17
16
|
* The flat representative rate (USD / million tokens) the BudgetMeter applies to the `max_usd` cap
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SecretRedactor — scrubs known secret values from everything bound for an LLM
|
|
1
|
+
// SecretRedactor — scrubs known secret values from everything bound for an LLM.
|
|
2
2
|
//
|
|
3
3
|
// The architectural guarantee is structural: secrets live ONLY in the workflow PROGRAM (the trusted
|
|
4
4
|
// deterministic tool layer), which holds them via `secrets.get` / `ctx.secrets.resolve`. The
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ArtifactRef, BrowserSession, BrowserSessionOptions, McpServerRef } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
/** One MCP tool result content block (the subset we consume). */
|
|
3
|
+
export interface McpContentBlock {
|
|
4
|
+
type: string;
|
|
5
|
+
text?: string;
|
|
6
|
+
/** base64 for `type: "image"`. */
|
|
7
|
+
data?: string;
|
|
8
|
+
mimeType?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface McpToolResult {
|
|
11
|
+
content: readonly McpContentBlock[];
|
|
12
|
+
isError: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** A live MCP client bound to one session's Playwright MCP server (the PROGRAM's channel). */
|
|
15
|
+
export interface SessionMcpCaller {
|
|
16
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<McpToolResult>;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** A launched browser process: the CDP endpoint + the Playwright MCP HTTP URL the agent binds to. */
|
|
20
|
+
export interface BrowserProcess {
|
|
21
|
+
/** e.g. `http://127.0.0.1:9222` (program-internal, never exposed). */
|
|
22
|
+
readonly cdpUrl: string;
|
|
23
|
+
/** e.g. `http://127.0.0.1:9333/mcp` — the agent's MCP endpoint. */
|
|
24
|
+
readonly mcpUrl: string;
|
|
25
|
+
/** Terminate Chromium + the Playwright MCP server. Idempotent. */
|
|
26
|
+
kill: () => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** Spawns Chromium (headful on the guest display) + a per-session Playwright MCP attached to it,
|
|
29
|
+
* with the arbitrary-JS tools (`browser_evaluate`, `browser_run_code_unsafe`) DISABLED for the
|
|
30
|
+
* agent — page-eval stays program-only. Production impl in browser_session_backend.ts. */
|
|
31
|
+
export interface BrowserBackend {
|
|
32
|
+
launch: (opts: BrowserSessionOptions | undefined) => Promise<BrowserProcess>;
|
|
33
|
+
}
|
|
34
|
+
/** Store a captured screenshot as a run artifact; returns its ref (the same store `artifacts.write` uses). */
|
|
35
|
+
export type ScreenshotArtifactWriter = (name: string, contentType: string, base64: string, metadata: Record<string, unknown>) => Promise<ArtifactRef>;
|
|
36
|
+
export interface BrowserSessionManagerDeps {
|
|
37
|
+
backend: BrowserBackend;
|
|
38
|
+
/** Open the PROGRAM's MCP client to a session's Playwright MCP HTTP URL. */
|
|
39
|
+
connect: (mcpUrl: string) => Promise<SessionMcpCaller>;
|
|
40
|
+
writeArtifact: ScreenshotArtifactWriter;
|
|
41
|
+
/** Monotonic session-id source (injected for determinism in tests). */
|
|
42
|
+
nextId: () => string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Per-run manager of browser sessions. Opens them (spawn + connect + handle), resolves a session to
|
|
46
|
+
* its agent-facing MCP ref for `agent({ session })` binding, and reaps every open session at run end.
|
|
47
|
+
*/
|
|
48
|
+
export declare class BrowserSessionManager {
|
|
49
|
+
private readonly deps;
|
|
50
|
+
private readonly sessions;
|
|
51
|
+
constructor(deps: BrowserSessionManagerDeps);
|
|
52
|
+
open(opts?: BrowserSessionOptions): Promise<BrowserSession>;
|
|
53
|
+
/** The agent-facing MCP ref for a session handle, or null if it isn't a live session of this run
|
|
54
|
+
* (e.g. already closed, or a foreign object). The host appends this to the leaf's `mcp`. */
|
|
55
|
+
mcpRefFor(session: BrowserSession): McpServerRef | null;
|
|
56
|
+
/** Tear down every still-open session. Called once when the run ends (best-effort per session). */
|
|
57
|
+
closeAll(): Promise<void>;
|
|
58
|
+
private reap;
|
|
59
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Browser-tier computer use — the runtime backing for `computer.openBrowser()` (the SDK surface)
|
|
2
|
+
// and `agent({ session })`. See docs/COMPUTER_USE_SESSION.md.
|
|
3
|
+
//
|
|
4
|
+
// A session is a program-owned, in-VM Chromium with a CDP endpoint, plus a per-session Playwright
|
|
5
|
+
// MCP HTTP server attached to that endpoint. The agent drives the browser through that MCP server
|
|
6
|
+
// (the ENGINE connects to it — binding a session is just adding its http MCP ref to the leaf's
|
|
7
|
+
// `mcp`, which passes assertHostedMcpAllowed unchanged since it's http + a valid localhost URL).
|
|
8
|
+
// The trusted PROGRAM drives/inspects the same browser through this module's own MCP client (the
|
|
9
|
+
// `BrowserSession` handle methods).
|
|
10
|
+
//
|
|
11
|
+
// The process spawn (Chromium + Playwright MCP, on the guest display) and the MCP client are behind
|
|
12
|
+
// injected seams (`BrowserBackend`, `SessionMcpCaller`) so the manager + handle logic is unit-tested
|
|
13
|
+
// without a guest; the production seams (browser_session_backend.ts) run against the guest image.
|
|
14
|
+
import { AppError, ErrorCode } from "./support/index.js";
|
|
15
|
+
/** Browser tools hidden from the AGENT: arbitrary page JS (`browser_evaluate`) and arbitrary
|
|
16
|
+
* Playwright driver code (`browser_run_code_unsafe`). The engine drops them from the agent's tool
|
|
17
|
+
* set (McpServerRef.excludeTools), while the trusted program keeps them via its own MCP client (the
|
|
18
|
+
* SessionMcpCaller connects to the same server directly, unfiltered) — so `session.eval()` still
|
|
19
|
+
* works and the model can't run arbitrary code. Least privilege; see docs/COMPUTER_USE_SESSION.md. */
|
|
20
|
+
const AGENT_EXCLUDED_BROWSER_TOOLS = ["browser_evaluate", "browser_run_code_unsafe"];
|
|
21
|
+
/**
|
|
22
|
+
* Per-run manager of browser sessions. Opens them (spawn + connect + handle), resolves a session to
|
|
23
|
+
* its agent-facing MCP ref for `agent({ session })` binding, and reaps every open session at run end.
|
|
24
|
+
*/
|
|
25
|
+
export class BrowserSessionManager {
|
|
26
|
+
deps;
|
|
27
|
+
sessions = new Map();
|
|
28
|
+
constructor(deps) {
|
|
29
|
+
this.deps = deps;
|
|
30
|
+
}
|
|
31
|
+
async open(opts) {
|
|
32
|
+
const id = this.deps.nextId();
|
|
33
|
+
const proc = await this.deps.backend.launch(opts);
|
|
34
|
+
let caller;
|
|
35
|
+
try {
|
|
36
|
+
caller = await this.deps.connect(proc.mcpUrl);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
await proc.kill();
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const mcpRef = {
|
|
43
|
+
name: `browser-${id}`,
|
|
44
|
+
transport: "http",
|
|
45
|
+
url: proc.mcpUrl,
|
|
46
|
+
excludeTools: AGENT_EXCLUDED_BROWSER_TOOLS,
|
|
47
|
+
};
|
|
48
|
+
const handle = makeBrowserSessionHandle(id, caller, this.deps.writeArtifact, () => this.reap(id));
|
|
49
|
+
this.sessions.set(id, { handle, mcpRef, proc, caller });
|
|
50
|
+
return handle;
|
|
51
|
+
}
|
|
52
|
+
/** The agent-facing MCP ref for a session handle, or null if it isn't a live session of this run
|
|
53
|
+
* (e.g. already closed, or a foreign object). The host appends this to the leaf's `mcp`. */
|
|
54
|
+
mcpRefFor(session) {
|
|
55
|
+
return this.sessions.get(session.id)?.mcpRef ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Tear down every still-open session. Called once when the run ends (best-effort per session). */
|
|
58
|
+
async closeAll() {
|
|
59
|
+
const open = [...this.sessions.keys()];
|
|
60
|
+
await Promise.allSettled(open.map((id) => this.reap(id)));
|
|
61
|
+
}
|
|
62
|
+
async reap(id) {
|
|
63
|
+
const s = this.sessions.get(id);
|
|
64
|
+
if (s === undefined)
|
|
65
|
+
return;
|
|
66
|
+
this.sessions.delete(id);
|
|
67
|
+
// Close the client first (unblocks the server), then kill the processes. Both best-effort.
|
|
68
|
+
await Promise.allSettled([s.caller.close(), s.proc.kill()]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Text of a tool result (all text blocks joined). */
|
|
72
|
+
function textOf(result) {
|
|
73
|
+
return result.content
|
|
74
|
+
.map((c) => (typeof c.text === "string" ? c.text : ""))
|
|
75
|
+
.filter((t) => t.length > 0)
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
function assertOk(result, tool) {
|
|
79
|
+
if (result.isError) {
|
|
80
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, `browser ${tool} failed: ${textOf(result)}`, {
|
|
81
|
+
kind: "browser_tool_error",
|
|
82
|
+
tool,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
/** Parse a single `Field: value` line out of a Playwright MCP `browser_snapshot` header. */
|
|
88
|
+
function fieldFromSnapshot(snapshot, field) {
|
|
89
|
+
const re = new RegExp(`^\\s*-?\\s*${field}:\\s*(.+)$`, "m");
|
|
90
|
+
return re.exec(snapshot)?.[1]?.trim() ?? "";
|
|
91
|
+
}
|
|
92
|
+
function makeBrowserSessionHandle(id, caller, writeArtifact, onClose) {
|
|
93
|
+
const call = (name, args = {}) => caller.callTool(name, args);
|
|
94
|
+
let closed = false;
|
|
95
|
+
const live = () => {
|
|
96
|
+
if (closed) {
|
|
97
|
+
throw new AppError(ErrorCode.VALIDATION_FAILED, `browser session ${id} is closed`, {
|
|
98
|
+
kind: "browser_session_closed",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
async navigate(url) {
|
|
105
|
+
live();
|
|
106
|
+
assertOk(await call("browser_navigate", { url }), "navigate");
|
|
107
|
+
},
|
|
108
|
+
async url() {
|
|
109
|
+
live();
|
|
110
|
+
return fieldFromSnapshot(textOf(assertOk(await call("browser_snapshot"), "snapshot")), "Page URL");
|
|
111
|
+
},
|
|
112
|
+
async title() {
|
|
113
|
+
live();
|
|
114
|
+
return fieldFromSnapshot(textOf(assertOk(await call("browser_snapshot"), "snapshot")), "Page Title");
|
|
115
|
+
},
|
|
116
|
+
async screenshot(opts) {
|
|
117
|
+
live();
|
|
118
|
+
const res = assertOk(await call("browser_take_screenshot", { fullPage: opts?.fullPage ?? false }), "screenshot");
|
|
119
|
+
const image = res.content.find((c) => c.type === "image" && typeof c.data === "string");
|
|
120
|
+
if (image?.data === undefined) {
|
|
121
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, "browser screenshot returned no image", {
|
|
122
|
+
kind: "browser_screenshot_no_image",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return await writeArtifact(`screenshot-${id}-${Date.now().toString(36)}.png`, image.mimeType ?? "image/png", image.data, { kind: "screenshot", session_id: id });
|
|
126
|
+
},
|
|
127
|
+
async console() {
|
|
128
|
+
live();
|
|
129
|
+
return parseConsole(textOf(assertOk(await call("browser_console_messages"), "console")));
|
|
130
|
+
},
|
|
131
|
+
async network() {
|
|
132
|
+
live();
|
|
133
|
+
return parseNetwork(textOf(assertOk(await call("browser_network_requests"), "network")));
|
|
134
|
+
},
|
|
135
|
+
async eval(expression) {
|
|
136
|
+
live();
|
|
137
|
+
// Playwright MCP's browser_evaluate takes a `function` (a JS arrow) — wrap the expression.
|
|
138
|
+
const res = assertOk(await call("browser_evaluate", { function: `() => (${expression})` }), "eval");
|
|
139
|
+
const text = textOf(res).trim();
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(text);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
async close() {
|
|
148
|
+
if (closed)
|
|
149
|
+
return;
|
|
150
|
+
closed = true;
|
|
151
|
+
await onClose();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const CONSOLE_LINE = /^\s*\[?(LOG|INFO|WARN(?:ING)?|ERROR|DEBUG)\]?\s*[:-]?\s*(.*)$/i;
|
|
156
|
+
function parseConsole(text) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const line of text.split("\n")) {
|
|
159
|
+
if (line.trim().length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const m = CONSOLE_LINE.exec(line);
|
|
162
|
+
const level = (m?.[1] ?? "log").toLowerCase();
|
|
163
|
+
out.push({
|
|
164
|
+
level: level.startsWith("warn")
|
|
165
|
+
? "warn"
|
|
166
|
+
: (["log", "info", "error", "debug"].includes(level)
|
|
167
|
+
? level
|
|
168
|
+
: "log"),
|
|
169
|
+
text: m?.[2] ?? line.trim(),
|
|
170
|
+
timestamp: 0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
const NETWORK_LINE = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s+(\S+)(?:.*?\b(\d{3})\b)?/;
|
|
176
|
+
function parseNetwork(text) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const line of text.split("\n")) {
|
|
179
|
+
const m = NETWORK_LINE.exec(line);
|
|
180
|
+
if (m === null)
|
|
181
|
+
continue;
|
|
182
|
+
const entry = { method: m[1], url: m[2], timestamp: 0 };
|
|
183
|
+
if (m[3] !== undefined)
|
|
184
|
+
entry.status = Number(m[3]);
|
|
185
|
+
out.push(entry);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BrowserBackend, McpContentBlock, SessionMcpCaller } from "./browser_session.js";
|
|
2
|
+
/**
|
|
3
|
+
* The guest-image contract for the browser tier — the values the runner-images rootfs sets so this
|
|
4
|
+
* backend can launch Chromium + Playwright MCP without any network fetch (the run's egress is proxied,
|
|
5
|
+
* so a runtime `npx` download would fail). All but `chromePath` have safe defaults.
|
|
6
|
+
*/
|
|
7
|
+
export interface GuestBrowserConfig {
|
|
8
|
+
/** Absolute path to the Chromium/Chrome binary the image ships (BOARDWALK_BROWSER_CHROME_PATH). */
|
|
9
|
+
chromePath: string;
|
|
10
|
+
/** X display Chromium renders on (headful, so the desktop tier can mirror it). DISPLAY, default ":0". */
|
|
11
|
+
display: string;
|
|
12
|
+
/** How to launch the PRE-INSTALLED Playwright MCP. The per-session flags (--port/--cdp-endpoint/
|
|
13
|
+
* --config) are appended. Default runs the pinned package via npx with fetching disabled. */
|
|
14
|
+
mcpCommand: string;
|
|
15
|
+
mcpBaseArgs: readonly string[];
|
|
16
|
+
/** Milliseconds to wait for Chromium's CDP endpoint / the MCP server to answer before failing. */
|
|
17
|
+
readyTimeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
/** True when the runner image declares the browser stack present (BOARDWALK_BROWSER_TIER=1). */
|
|
20
|
+
export declare function browserTierEnabled(env: NodeJS.ProcessEnv): boolean;
|
|
21
|
+
/** Read the guest browser config from env, or null when the tier is disabled / no Chrome path is set. */
|
|
22
|
+
export declare function loadGuestBrowserConfig(env: NodeJS.ProcessEnv): GuestBrowserConfig | null;
|
|
23
|
+
/** Bind an ephemeral port, read it, release it — then hand it to the child. A tiny TOCTOU window, but
|
|
24
|
+
* each run owns its VM/container so nothing else competes for the loopback port. */
|
|
25
|
+
export declare function freePort(): Promise<number>;
|
|
26
|
+
/** Poll an HTTP URL until it answers (any status < 500 counts as "up" — Playwright MCP's localhost
|
|
27
|
+
* guard returns 403 to a bare GET, which still proves the server is listening). */
|
|
28
|
+
export declare function waitForHttp(url: string, timeoutMs: number): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Build the production BrowserBackend. Each `launch` allocates two loopback ports, spawns a
|
|
31
|
+
* program-owned Chromium (CDP endpoint, headful on the guest display, isolated profile) and a
|
|
32
|
+
* per-session Playwright MCP HTTP server attached to it, waits for both to answer, and returns the
|
|
33
|
+
* handle the manager wraps. On any failure it tears down everything it started (no leaked processes /
|
|
34
|
+
* temp profiles).
|
|
35
|
+
*/
|
|
36
|
+
export declare function makeGuestBrowserBackend(cfg: GuestBrowserConfig): BrowserBackend;
|
|
37
|
+
/** Adapt one MCP tool-result content block (the SDK's loose shape) to the subset browser_session.ts reads. */
|
|
38
|
+
export declare function toContentBlock(block: unknown): McpContentBlock;
|
|
39
|
+
/**
|
|
40
|
+
* The program's MCP client factory: open a StreamableHTTP client to a session's Playwright MCP server
|
|
41
|
+
* (the trusted PROGRAM's channel, distinct from the engine's separate connection the AGENT uses). Wraps
|
|
42
|
+
* the SDK Client behind the manager's `SessionMcpCaller` seam. `mcpUrl` must be the `localhost` form.
|
|
43
|
+
*/
|
|
44
|
+
export declare function connectSessionMcp(mcpUrl: string): Promise<SessionMcpCaller>;
|