@mindstone/mcp-server-browser-automation 0.2.0 → 0.2.2
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 +8 -15
- package/dist/path-safety.d.ts +41 -12
- package/dist/path-safety.js +131 -17
- package/dist/tools/interaction.js +22 -27
- package/dist/tools/observation.js +7 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Browser control you can watch: open pages, sign in, click around, fill forms, ta
|
|
|
9
9
|
|
|
10
10
|
## Status
|
|
11
11
|
|
|
12
|
-
- **Version:** [0.2.
|
|
12
|
+
- **Version:** [0.2.2](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-browser-automation)
|
|
13
13
|
- **Auth:** None ([`server.json`](./server.json))
|
|
14
14
|
- **Tools:** [21](./src/tools/) (navigation, observation, interaction, sessions, files)
|
|
15
15
|
- **Surface:** browser-automation
|
|
@@ -117,7 +117,6 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
117
117
|
|---|---|---|
|
|
118
118
|
| `AGENT_BROWSER_SESSION_NAME` | No | Session name for browser persistence (default: `mcp`) |
|
|
119
119
|
| `AGENT_BROWSER_SHOW_WINDOW` | No | Set to `false` to run without a visible browser window. Default is visible (`true`). |
|
|
120
|
-
| `BROWSER_AUTOMATION_ALLOW_EVAL` | No | Set to `1` to register the `browser_evaluate` tool. Off by default. See [Security notes](#security-notes). |
|
|
121
120
|
| `MCP_WORKSPACE_PATH` | No | Workspace directory that `browser_pdf` writes into and `browser_upload` reads from. Defaults to the system temp directory. See [Security notes](#security-notes). |
|
|
122
121
|
|
|
123
122
|
### MCP Host Configuration
|
|
@@ -133,7 +132,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
133
132
|
}
|
|
134
133
|
```
|
|
135
134
|
|
|
136
|
-
## Available Tools (
|
|
135
|
+
## Available Tools (21)
|
|
137
136
|
|
|
138
137
|
### Navigation
|
|
139
138
|
- **browser_navigate** — Navigate to a URL
|
|
@@ -157,7 +156,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
157
156
|
- **browser_select** — Select an option from a dropdown
|
|
158
157
|
- **browser_hover** — Hover over an element
|
|
159
158
|
- **browser_upload** — Upload workspace files to a file input (regular files only, staged privately before upload)
|
|
160
|
-
- **browser_evaluate** — Execute JavaScript in the page context (
|
|
159
|
+
- **browser_evaluate** — Execute JavaScript in the page context (on by default; `destructiveHint: true` so hosts can require confirmation — see [Security notes](#security-notes))
|
|
161
160
|
|
|
162
161
|
### Session Management
|
|
163
162
|
- **browser_tabs** — List open tabs or switch to a tab
|
|
@@ -177,15 +176,9 @@ The typical workflow uses accessibility snapshots for reliable element targeting
|
|
|
177
176
|
|
|
178
177
|
Browser automation has a large attack surface: the agent-browser CLI controls a real headless browser that loads URLs you pass it, runs page-side JavaScript, and persists cookies and session state across runs. Read this section before deploying.
|
|
179
178
|
|
|
180
|
-
### `browser_evaluate` is
|
|
179
|
+
### `browser_evaluate` runs by default — host confirmation is the gate
|
|
181
180
|
|
|
182
|
-
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to.
|
|
183
|
-
|
|
184
|
-
```bash
|
|
185
|
-
BROWSER_AUTOMATION_ALLOW_EVAL=1 mcp-server-browser-automation
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Without this env var, `browser_evaluate` is **not** in the tools list at all — the LLM cannot even see it. When enabled, the tool is marked so MCP hosts can require explicit user confirmation before each invocation.
|
|
181
|
+
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to. The tool is registered **unconditionally** (capability-first); the safeguard is the host's tool-approval layer. The tool is marked `destructiveHint: true`, so MCP hosts SHOULD require explicit user confirmation before each invocation. Do not configure a host to auto-approve this tool.
|
|
189
182
|
|
|
190
183
|
### URL scheme deny-list
|
|
191
184
|
|
|
@@ -207,7 +200,7 @@ To override the session name (for example, to keep separate profiles per project
|
|
|
207
200
|
### Recommended deployment posture
|
|
208
201
|
|
|
209
202
|
- **Run the connector against a separate browser profile** — a dedicated `AGENT_BROWSER_SESSION_NAME` per MCP host. Do not reuse your daily browser profile: the connector reads and overwrites cookies in whichever profile it is pointed at, and a malicious page can ride the existing session of any site you are logged into.
|
|
210
|
-
- **
|
|
203
|
+
- **Require host confirmation for `browser_evaluate`** — it runs by default; every call executes arbitrary JavaScript in the page context.
|
|
211
204
|
- **Require host confirmation** for `browser_authenticate` and any flow that may navigate to authenticated sites — otherwise prompt injection in fetched content can drive the browser at sites the user is logged into.
|
|
212
205
|
- **Returned page content is enveloped as untrusted** — accessibility snapshots, page text, titles, URLs, tab lists, and `browser_evaluate` outputs come from arbitrary websites and may contain prompt-injection attempts. The connector wraps them in `<untrusted-content source="…">` envelopes (with close-tag breakout escaping) so hosts and models treat them as data, not instructions. Keep that treatment on your side: don't strip the envelopes, and don't let page text alone trigger irreversible actions.
|
|
213
206
|
|
|
@@ -219,8 +212,8 @@ Both tools are marked `destructiveHint: true`, so hosts can require user confirm
|
|
|
219
212
|
|
|
220
213
|
Additional hardening:
|
|
221
214
|
|
|
222
|
-
- **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once, verified to be regular files (directories, devices, and FIFOs are refused), and copied into a fresh private staging directory that the CLI consumes
|
|
223
|
-
- **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
|
|
215
|
+
- **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once (`O_NOFOLLOW` + `O_NONBLOCK`, so a post-validation leaf-symlink swap fails and a planted FIFO cannot wedge the open), verified to be regular files (directories, devices, and FIFOs are refused), bound to a fresh confined resolution by device+inode (an intermediate-directory swap after validation redirects the resolution and is refused with `UPLOAD_SOURCE_CHANGED`), and copied into a fresh private staging directory that the CLI consumes. `browser_pdf` has the CLI write into a fresh private staging directory, then installs the PDF at the requested path: the destination directory's canonical identity is pinned before the CLI runs and re-verified before installing, so an intermediate directory swapped to a symlink mid-call is refused, and exclusive-create semantics refuse a file or symlink planted at the destination leaf instead of writing through it.
|
|
216
|
+
- **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`. Even then, a destination that is a directory is refused (`DESTINATION_IS_DIRECTORY`) — the overwrite delete is a bare unlink and never recurses.
|
|
224
217
|
- **Enveloped errors.** Error output from the `agent-browser` CLI can contain page-authored text; it is wrapped in `<untrusted-content>` envelopes (with close-tag breakout escaping) before reaching the model, and timeout errors do not echo the command's argument values.
|
|
225
218
|
|
|
226
219
|
## Licence
|
package/dist/path-safety.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
1
2
|
export type ResolveResult = {
|
|
2
3
|
ok: true;
|
|
3
4
|
path: string;
|
|
@@ -49,17 +50,35 @@ export declare function discardStagingDir(stagingDir: string): void;
|
|
|
49
50
|
* the CLI should consume.
|
|
50
51
|
*
|
|
51
52
|
* The source is validated inside the workspace root, then opened EXACTLY
|
|
52
|
-
* ONCE
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* ONCE with O_NOFOLLOW — a leaf swapped for a symlink between validation
|
|
54
|
+
* and open fails with ELOOP instead of being followed outside the
|
|
55
|
+
* workspace — and O_NONBLOCK, so a planted FIFO cannot wedge the connector
|
|
56
|
+
* by blocking the open until a writer appears. The fd is fstat-verified to
|
|
57
|
+
* be a regular file (directories, FIFOs, sockets, and devices pass
|
|
58
|
+
* realpathSync but must not become upload payloads), then bound to a fresh
|
|
59
|
+
* confined resolution of the path by dev+inode — an intermediate directory
|
|
60
|
+
* (or, on platforms without O_NOFOLLOW, the leaf itself) swapped for a
|
|
61
|
+
* symlink after validation redirects the resolution to a different inode
|
|
62
|
+
* and is refused. The content is streamed through the same fd into a
|
|
63
|
+
* private staging slot carrying over only the requested basename, so the
|
|
56
64
|
* CLI never re-opens the validated pathname and a post-validation swap of
|
|
57
65
|
* the original cannot redirect the upload.
|
|
66
|
+
*
|
|
67
|
+
* `openFile` is injectable for adversarial tests only; production callers
|
|
68
|
+
* use the default `fs.openSync`.
|
|
58
69
|
*/
|
|
59
|
-
export declare function stageUploadSource(inputPath: string, stagingDir: string, index: number): string;
|
|
70
|
+
export declare function stageUploadSource(inputPath: string, stagingDir: string, index: number, openFile?: typeof fs.openSync): string;
|
|
60
71
|
export interface PdfStagingTarget {
|
|
61
72
|
/** Validated final destination for the PDF. */
|
|
62
73
|
destPath: string;
|
|
74
|
+
/**
|
|
75
|
+
* Canonical realpath of destPath's parent directory, pinned at validation
|
|
76
|
+
* time (the parent is created before the CLI runs). installStagedFile
|
|
77
|
+
* re-verifies it before writing, so an intermediate directory swapped to
|
|
78
|
+
* a symlink during the CLI call is refused instead of followed outside
|
|
79
|
+
* the workspace.
|
|
80
|
+
*/
|
|
81
|
+
canonicalParentDir: string;
|
|
63
82
|
/** Private staging path the CLI should write to. */
|
|
64
83
|
stagingPath: string;
|
|
65
84
|
/** Remove with discardStagingDir once installed (or on failure). */
|
|
@@ -79,12 +98,22 @@ export declare function createPdfStagingTarget(filePath: string, overwrite: bool
|
|
|
79
98
|
/**
|
|
80
99
|
* Install a staged PDF at its validated destination.
|
|
81
100
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
101
|
+
* Two defences, one per path component class:
|
|
102
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
103
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
104
|
+
* already at the destination — including a planted symlink. With
|
|
105
|
+
* `overwrite`, the old entry is removed first and the same
|
|
106
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
107
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
108
|
+
* path.
|
|
109
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
110
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
111
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
112
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
113
|
+
* than followed outside the workspace — for the write and, with
|
|
114
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
115
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
116
|
+
* par with any local check-then-use race.)
|
|
88
117
|
*/
|
|
89
|
-
export declare function installStagedFile(
|
|
118
|
+
export declare function installStagedFile(target: PdfStagingTarget, overwrite: boolean): void;
|
|
90
119
|
//# sourceMappingURL=path-safety.d.ts.map
|
package/dist/path-safety.js
CHANGED
|
@@ -170,19 +170,37 @@ function sanitiseBasename(p, fallback) {
|
|
|
170
170
|
const base = path.basename(p);
|
|
171
171
|
return base === '' || base === '.' || base === '..' ? fallback : base;
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* A post-validation swap raced the upload source. Distinct from
|
|
175
|
+
* PATH_OUTSIDE_WORKSPACE: the path WAS inside the workspace at validation
|
|
176
|
+
* time, so retrying can succeed.
|
|
177
|
+
*/
|
|
178
|
+
function uploadSourceChangedError(inputPath) {
|
|
179
|
+
return new ConnectorError(`file_path changed while it was being validated: ${inputPath}`, 'UPLOAD_SOURCE_CHANGED', 'Retry the call, or pass a path to a regular file inside the workspace directory.');
|
|
180
|
+
}
|
|
173
181
|
/**
|
|
174
182
|
* Stage one `browser_upload` source into `stagingDir` and return the path
|
|
175
183
|
* the CLI should consume.
|
|
176
184
|
*
|
|
177
185
|
* The source is validated inside the workspace root, then opened EXACTLY
|
|
178
|
-
* ONCE
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
186
|
+
* ONCE with O_NOFOLLOW — a leaf swapped for a symlink between validation
|
|
187
|
+
* and open fails with ELOOP instead of being followed outside the
|
|
188
|
+
* workspace — and O_NONBLOCK, so a planted FIFO cannot wedge the connector
|
|
189
|
+
* by blocking the open until a writer appears. The fd is fstat-verified to
|
|
190
|
+
* be a regular file (directories, FIFOs, sockets, and devices pass
|
|
191
|
+
* realpathSync but must not become upload payloads), then bound to a fresh
|
|
192
|
+
* confined resolution of the path by dev+inode — an intermediate directory
|
|
193
|
+
* (or, on platforms without O_NOFOLLOW, the leaf itself) swapped for a
|
|
194
|
+
* symlink after validation redirects the resolution to a different inode
|
|
195
|
+
* and is refused. The content is streamed through the same fd into a
|
|
196
|
+
* private staging slot carrying over only the requested basename, so the
|
|
182
197
|
* CLI never re-opens the validated pathname and a post-validation swap of
|
|
183
198
|
* the original cannot redirect the upload.
|
|
199
|
+
*
|
|
200
|
+
* `openFile` is injectable for adversarial tests only; production callers
|
|
201
|
+
* use the default `fs.openSync`.
|
|
184
202
|
*/
|
|
185
|
-
export function stageUploadSource(inputPath, stagingDir, index) {
|
|
203
|
+
export function stageUploadSource(inputPath, stagingDir, index, openFile = fs.openSync) {
|
|
186
204
|
const resolved = resolveWorkspaceReadPath(inputPath);
|
|
187
205
|
if (!resolved.ok) {
|
|
188
206
|
throw new ConnectorError(resolved.error, 'PATH_OUTSIDE_WORKSPACE', 'Pass file paths inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset).');
|
|
@@ -190,12 +208,58 @@ export function stageUploadSource(inputPath, stagingDir, index) {
|
|
|
190
208
|
const slot = path.join(stagingDir, String(index));
|
|
191
209
|
fs.mkdirSync(slot, { mode: STAGING_DIR_MODE });
|
|
192
210
|
const stagingPath = path.join(slot, sanitiseBasename(resolved.path, `upload-${index}`));
|
|
193
|
-
|
|
211
|
+
// O_NOFOLLOW: resolved.path is canonical (no symlinks at validation
|
|
212
|
+
// time), so a symlink at the leaf here can only be a post-validation
|
|
213
|
+
// swap — refuse instead of following it outside the workspace.
|
|
214
|
+
// O_NONBLOCK: opening a FIFO O_RDONLY would otherwise block until a
|
|
215
|
+
// writer appears, letting a planted pipe wedge the connector; for
|
|
216
|
+
// regular files O_NONBLOCK is a no-op. (Either flag is undefined on
|
|
217
|
+
// platforms without it, where the composition degrades to a plain
|
|
218
|
+
// O_RDONLY — the dev+inode re-resolution below still refuses a swap
|
|
219
|
+
// there.)
|
|
220
|
+
let inFd;
|
|
221
|
+
try {
|
|
222
|
+
inFd = openFile(resolved.path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const code = err.code;
|
|
226
|
+
if (code === 'ENOENT') {
|
|
227
|
+
throw new ConnectorError(`File not found: ${inputPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Pass file paths inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset).');
|
|
228
|
+
}
|
|
229
|
+
if (code === 'ELOOP' || code === 'EMLINK' || code === 'EFTYPE' ||
|
|
230
|
+
code === 'EISDIR' || code === 'EPERM' || code === 'EACCES') {
|
|
231
|
+
// ELOOP/EMLINK/EFTYPE: the validated leaf was swapped for a symlink
|
|
232
|
+
// before we could open it (the O_NOFOLLOW errno varies by platform).
|
|
233
|
+
// EISDIR/EPERM/EACCES: swapped for a directory or an unreadable
|
|
234
|
+
// object. Either way, refuse rather than read.
|
|
235
|
+
throw uploadSourceChangedError(inputPath);
|
|
236
|
+
}
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
194
239
|
try {
|
|
195
240
|
const stat = fs.fstatSync(inFd);
|
|
196
241
|
if (!stat.isFile()) {
|
|
197
242
|
throw new ConnectorError(`file_path is not a regular file: ${inputPath}`, 'NOT_A_REGULAR_FILE', 'Pass a path to a regular file inside the workspace directory.');
|
|
198
243
|
}
|
|
244
|
+
// An intermediate directory swapped for a symlink after validation
|
|
245
|
+
// could have redirected the open itself (O_NOFOLLOW constrains only
|
|
246
|
+
// the final component), so bind the descriptor to a fresh confined
|
|
247
|
+
// resolution: the path must still resolve inside the workspace to the
|
|
248
|
+
// SAME dev+inode we opened. Bytes are read through the fd below, so a
|
|
249
|
+
// swap landing after this check cannot change the uploaded content.
|
|
250
|
+
const recheck = resolveWorkspaceReadPath(inputPath);
|
|
251
|
+
let fresh;
|
|
252
|
+
if (recheck.ok) {
|
|
253
|
+
try {
|
|
254
|
+
fresh = fs.statSync(recheck.path);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
fresh = undefined; // resolution failed — refuse below
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!fresh || fresh.dev !== stat.dev || fresh.ino !== stat.ino) {
|
|
261
|
+
throw uploadSourceChangedError(inputPath);
|
|
262
|
+
}
|
|
199
263
|
const outFd = fs.openSync(stagingPath, 'wx', STAGING_FILE_MODE);
|
|
200
264
|
try {
|
|
201
265
|
const chunk = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
|
|
@@ -233,9 +297,20 @@ export function createPdfStagingTarget(filePath, overwrite) {
|
|
|
233
297
|
if (!overwrite && fs.existsSync(resolved.path)) {
|
|
234
298
|
throw new ConnectorError(`file_path already exists: ${filePath}`, 'FILE_EXISTS', 'Pass overwrite: true to replace the existing file, or choose a different file_path.');
|
|
235
299
|
}
|
|
300
|
+
// Create the parent directory NOW, before the multi-second CLI call, and
|
|
301
|
+
// pin its canonical identity. resolved.path is canonical, so the realpath
|
|
302
|
+
// of the freshly prepared parent must equal it exactly; a mismatch means
|
|
303
|
+
// a component changed under us and the destination cannot be trusted.
|
|
304
|
+
const parentDir = path.dirname(resolved.path);
|
|
305
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
306
|
+
const canonicalParentDir = fs.realpathSync(parentDir);
|
|
307
|
+
if (canonicalParentDir !== parentDir) {
|
|
308
|
+
throw new ConnectorError(`file_path parent directory changed while it was being prepared: ${filePath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
309
|
+
}
|
|
236
310
|
const stagingDir = createStagingDir('browser-pdf-');
|
|
237
311
|
return {
|
|
238
312
|
destPath: resolved.path,
|
|
313
|
+
canonicalParentDir,
|
|
239
314
|
stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
|
|
240
315
|
stagingDir,
|
|
241
316
|
};
|
|
@@ -243,14 +318,25 @@ export function createPdfStagingTarget(filePath, overwrite) {
|
|
|
243
318
|
/**
|
|
244
319
|
* Install a staged PDF at its validated destination.
|
|
245
320
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
321
|
+
* Two defences, one per path component class:
|
|
322
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
323
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
324
|
+
* already at the destination — including a planted symlink. With
|
|
325
|
+
* `overwrite`, the old entry is removed first and the same
|
|
326
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
327
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
328
|
+
* path.
|
|
329
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
330
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
331
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
332
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
333
|
+
* than followed outside the workspace — for the write and, with
|
|
334
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
335
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
336
|
+
* par with any local check-then-use race.)
|
|
252
337
|
*/
|
|
253
|
-
export function installStagedFile(
|
|
338
|
+
export function installStagedFile(target, overwrite) {
|
|
339
|
+
const { stagingPath, destPath, canonicalParentDir } = target;
|
|
254
340
|
let staged;
|
|
255
341
|
try {
|
|
256
342
|
staged = fs.lstatSync(stagingPath);
|
|
@@ -261,12 +347,40 @@ export function installStagedFile(stagingPath, destPath, overwrite) {
|
|
|
261
347
|
if (!staged.isFile()) {
|
|
262
348
|
throw new ConnectorError('The agent-browser CLI did not produce a regular output file.', 'CLI_ERROR', 'Retry the call, or check that a page is open in the browser session.');
|
|
263
349
|
}
|
|
350
|
+
const parentDir = path.dirname(destPath);
|
|
351
|
+
let currentParentDir;
|
|
352
|
+
try {
|
|
353
|
+
currentParentDir = fs.realpathSync(parentDir);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
357
|
+
throw new ConnectorError(`Destination directory cannot be resolved (${reason}): ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
358
|
+
}
|
|
359
|
+
if (currentParentDir !== canonicalParentDir) {
|
|
360
|
+
throw new ConnectorError(`Destination directory changed while the PDF was being generated: ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
361
|
+
}
|
|
264
362
|
if (overwrite) {
|
|
265
|
-
|
|
363
|
+
// unlinkSync, not rmSync: only a regular file or symlink may ever be
|
|
364
|
+
// removed here. unlink refuses directories atomically in the kernel
|
|
365
|
+
// (no lstat pre-check to race, as rmSync's ERR_FS_EISDIR path has) and
|
|
366
|
+
// can never recurse — deleting a directory tree as a PDF-overwrite
|
|
367
|
+
// side effect would be far worse than refusing.
|
|
368
|
+
try {
|
|
369
|
+
fs.unlinkSync(destPath);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const code = err.code;
|
|
373
|
+
if (code === 'EISDIR' || code === 'EPERM') {
|
|
374
|
+
// EISDIR on Linux, EPERM on macOS: the destination is a directory.
|
|
375
|
+
throw new ConnectorError(`Destination is a directory, not a file: ${destPath}`, 'DESTINATION_IS_DIRECTORY', 'Choose a file_path that does not collide with an existing directory.');
|
|
376
|
+
}
|
|
377
|
+
if (code !== 'ENOENT') {
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
// ENOENT: nothing to overwrite — the exclusive-create below installs
|
|
381
|
+
// the staged file.
|
|
382
|
+
}
|
|
266
383
|
}
|
|
267
|
-
// Parent directories were validated as inside the workspace by
|
|
268
|
-
// resolveWorkspaceWritePath; create any missing tail before installing.
|
|
269
|
-
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
270
384
|
try {
|
|
271
385
|
fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
|
|
272
386
|
}
|
|
@@ -119,32 +119,27 @@ WORKFLOW: browser_snapshot → find input @ref → browser_fill`,
|
|
|
119
119
|
}));
|
|
120
120
|
// M3.12 — `browser_evaluate` lets the model run arbitrary JavaScript inside
|
|
121
121
|
// the page context, which is the security equivalent of giving it a shell
|
|
122
|
-
// on whatever site it has just navigated to.
|
|
123
|
-
//
|
|
124
|
-
// host
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}, withErrorHandling(async (args) => {
|
|
145
|
-
const result = await execAgentBrowser(['eval', args.script]);
|
|
146
|
-
return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
|
|
147
|
-
}));
|
|
148
|
-
}
|
|
122
|
+
// on whatever site it has just navigated to. The tool is registered
|
|
123
|
+
// unconditionally (capability-first): it carries `destructiveHint: true`
|
|
124
|
+
// so the host's tool-approval layer can require explicit user confirmation
|
|
125
|
+
// before each invocation.
|
|
126
|
+
server.registerTool('browser_evaluate', // eslint-disable-line @typescript-eslint/quotes
|
|
127
|
+
{
|
|
128
|
+
description: 'Execute JavaScript in the page context and return the result. ' +
|
|
129
|
+
'DESTRUCTIVE: this is equivalent to running arbitrary code with the privileges of the current page; ' +
|
|
130
|
+
'hosts SHOULD require user confirmation before each call.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
script: z.string().describe('JavaScript code to execute'),
|
|
133
|
+
},
|
|
134
|
+
annotations: {
|
|
135
|
+
readOnlyHint: false,
|
|
136
|
+
destructiveHint: true,
|
|
137
|
+
idempotentHint: false,
|
|
138
|
+
openWorldHint: true,
|
|
139
|
+
},
|
|
140
|
+
}, withErrorHandling(async (args) => {
|
|
141
|
+
const result = await execAgentBrowser(['eval', args.script]);
|
|
142
|
+
return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
|
|
143
|
+
}));
|
|
149
144
|
}
|
|
150
145
|
//# sourceMappingURL=interaction.js.map
|
|
@@ -105,7 +105,7 @@ Use this for reading and summarising page content — it returns clean text, unl
|
|
|
105
105
|
The file is written inside the workspace directory (MCP_WORKSPACE_PATH, or the system temp directory when unset) — file_path must resolve inside that workspace. Existing files are refused unless overwrite is set to true.`,
|
|
106
106
|
inputSchema: {
|
|
107
107
|
file_path: z.string().min(1).describe('Target file path for the PDF. Relative paths resolve inside the workspace directory; absolute paths must be inside it (e.g., "reports/page.pdf").'),
|
|
108
|
-
overwrite: z.boolean().optional().default(false).describe('Replace an existing file at file_path. Default: false — existing files are refused.'),
|
|
108
|
+
overwrite: z.boolean().optional().default(false).describe('Replace an existing file at file_path. Default: false — existing files are refused. A directory at file_path is refused even with overwrite: true.'),
|
|
109
109
|
},
|
|
110
110
|
annotations: {
|
|
111
111
|
readOnlyHint: false,
|
|
@@ -115,14 +115,15 @@ The file is written inside the workspace directory (MCP_WORKSPACE_PATH, or the s
|
|
|
115
115
|
openWorldHint: true,
|
|
116
116
|
},
|
|
117
117
|
}, withErrorHandling(async (args) => {
|
|
118
|
-
// The CLI writes into a private staging dir
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
118
|
+
// The CLI writes into a private staging dir. Install then re-verifies
|
|
119
|
+
// the destination directory's canonical identity (pinned before the
|
|
120
|
+
// CLI ran) and writes with exclusive-create, so a post-validation swap
|
|
121
|
+
// of an intermediate directory or the leaf is refused rather than
|
|
122
|
+
// followed outside the workspace.
|
|
122
123
|
const target = createPdfStagingTarget(args.file_path, args.overwrite);
|
|
123
124
|
try {
|
|
124
125
|
await execAgentBrowser(['pdf', target.stagingPath]);
|
|
125
|
-
installStagedFile(target
|
|
126
|
+
installStagedFile(target, args.overwrite);
|
|
126
127
|
return JSON.stringify({ ok: true, file_path: target.destPath });
|
|
127
128
|
}
|
|
128
129
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindstone/mcp-server-browser-automation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"mcpName": "io.github.mindstone/mcp-server-browser-automation",
|
|
5
5
|
"description": "Browser automation MCP server — visible-by-default browser control via accessibility snapshots, navigation, form filling, screenshots, and tab management. Set AGENT_BROWSER_SHOW_WINDOW=false to run quietly.",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|