@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.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/dist/shared/protocol.d.ts +4 -0
- package/dist/src/bridge/datadir.d.ts +39 -0
- package/dist/src/bridge/datadir.js +66 -0
- package/dist/src/bridge/server.d.ts +16 -3
- package/dist/src/bridge/server.js +67 -19
- package/dist/src/bridge/tasks.d.ts +44 -0
- package/dist/src/bridge/tasks.js +131 -0
- package/dist/src/bridge/workspace.d.ts +54 -0
- package/dist/src/bridge/workspace.js +162 -0
- package/dist/src/cli.js +106 -1
- package/dist/src/config.d.ts +19 -1
- package/dist/src/config.js +52 -0
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +29 -4
- package/dist/src/executor/types.d.ts +13 -0
- package/dist/src/mcp/tools.js +67 -8
- package/extension-dist/background.js +52 -8
- package/extension-dist/options.html +7 -0
- package/extension-dist/options.js +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/workspace.ts — the *active* task workspace (mutable, process-global)
|
|
4
|
+
* plus the memory writers that persist a task's artifacts into it.
|
|
5
|
+
*
|
|
6
|
+
* Modeled on the executor singleton in `executor/manager.ts`: the boot path
|
|
7
|
+
* installs an initial workspace, and the runtime tools (`profile_use`/`task_new`)
|
|
8
|
+
* swap it via {@link switchWorkspace}. Tool handlers reach the current workspace
|
|
9
|
+
* through {@link getActiveWorkspace} so downloads, extracted results, screenshots,
|
|
10
|
+
* and the action log all land under `profiles/<profile>/tasks/<task>/`.
|
|
11
|
+
*
|
|
12
|
+
* Every writer here is BEST-EFFORT: a failed persist (full disk, races a task
|
|
13
|
+
* switch) is logged to stderr and swallowed so it can never break a tool call.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.setActiveWorkspace = setActiveWorkspace;
|
|
17
|
+
exports.getActiveWorkspace = getActiveWorkspace;
|
|
18
|
+
exports.peekActiveWorkspace = peekActiveWorkspace;
|
|
19
|
+
exports.resetActiveWorkspaceForTesting = resetActiveWorkspaceForTesting;
|
|
20
|
+
exports.switchWorkspace = switchWorkspace;
|
|
21
|
+
exports.saveResult = saveResult;
|
|
22
|
+
exports.saveScreenshot = saveScreenshot;
|
|
23
|
+
exports.captureDownload = captureDownload;
|
|
24
|
+
exports.appendHistory = appendHistory;
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
26
|
+
const node_path_1 = require("node:path");
|
|
27
|
+
const config_1 = require("../config");
|
|
28
|
+
const download_1 = require("../../shared/download");
|
|
29
|
+
const datadir_1 = require("./datadir");
|
|
30
|
+
/** stderr only (never stdout in stdio mode); local to avoid an import cycle with mcp/server. */
|
|
31
|
+
function logErr(message) {
|
|
32
|
+
process.stderr.write(`[chrome-mcp] ${message}\n`);
|
|
33
|
+
}
|
|
34
|
+
let active = null;
|
|
35
|
+
/** Monotonic counter so saved result/screenshot filenames sort in capture order. */
|
|
36
|
+
let seq = 0;
|
|
37
|
+
/** Install the initial workspace at boot (from cli.ts). */
|
|
38
|
+
function setActiveWorkspace(w) {
|
|
39
|
+
active = w;
|
|
40
|
+
}
|
|
41
|
+
/** The current workspace. Throws if the boot path never installed one. */
|
|
42
|
+
function getActiveWorkspace() {
|
|
43
|
+
if (!active)
|
|
44
|
+
throw new Error('no active workspace (server not fully booted)');
|
|
45
|
+
return active;
|
|
46
|
+
}
|
|
47
|
+
/** The current workspace, or null before boot completes (for non-throwing callers). */
|
|
48
|
+
function peekActiveWorkspace() {
|
|
49
|
+
return active;
|
|
50
|
+
}
|
|
51
|
+
/** Reset for tests. */
|
|
52
|
+
function resetActiveWorkspaceForTesting() {
|
|
53
|
+
active = null;
|
|
54
|
+
seq = 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Switch the active profile and/or task, creating the workspace dirs if new, and
|
|
58
|
+
* make it current. Names are sanitized to a single safe path segment (reusing
|
|
59
|
+
* {@link sanitizeName}) so a tool argument can never escape the data dir. Returns
|
|
60
|
+
* the resolved workspace.
|
|
61
|
+
*/
|
|
62
|
+
function switchWorkspace(opts) {
|
|
63
|
+
const cur = getActiveWorkspace();
|
|
64
|
+
const profile = opts.profile === undefined ? cur.profile : (0, config_1.sanitizeName)(opts.profile, 'profile');
|
|
65
|
+
// A profile switch resets the task to "default" unless the caller names one.
|
|
66
|
+
const task = opts.task !== undefined
|
|
67
|
+
? (0, config_1.sanitizeName)(opts.task, 'task')
|
|
68
|
+
: opts.profile !== undefined
|
|
69
|
+
? 'default'
|
|
70
|
+
: cur.task;
|
|
71
|
+
const w = (0, datadir_1.ensureWorkspace)(cur.dataDir, profile, task, {
|
|
72
|
+
...opts.meta,
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
active = w;
|
|
76
|
+
return w;
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Memory writers — persist a task's artifacts under its workspace.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
/** A short safe stem like `0007-get_text`; the seq keeps capture order + uniqueness. */
|
|
82
|
+
function stem(tool) {
|
|
83
|
+
seq += 1;
|
|
84
|
+
const n = String(seq).padStart(4, '0');
|
|
85
|
+
return `${n}-${tool.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Save an extracted-content result (get_text / read_as_markdown / extract_links)
|
|
89
|
+
* into the active task's `results/`. `ext` is the file extension without a dot.
|
|
90
|
+
* Returns the path written, or null if persisted nowhere (no workspace / error).
|
|
91
|
+
*/
|
|
92
|
+
function saveResult(tool, ext, body) {
|
|
93
|
+
const w = peekActiveWorkspace();
|
|
94
|
+
if (!w)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
const path = (0, node_path_1.join)(w.resultsDir, `${stem(tool)}.${ext}`);
|
|
98
|
+
(0, node_fs_1.writeFileSync)(path, body, { mode: 0o600 });
|
|
99
|
+
return path;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
logErr(`results save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
|
|
107
|
+
function saveScreenshot(dataBase64) {
|
|
108
|
+
const w = peekActiveWorkspace();
|
|
109
|
+
if (!w)
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}.png`);
|
|
113
|
+
(0, node_fs_1.writeFileSync)(path, Buffer.from(dataBase64, 'base64'), { mode: 0o600 });
|
|
114
|
+
return path;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logErr(`screenshot save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Move a file Chrome saved to the user's Downloads dir into the active task's
|
|
123
|
+
* `downloads/`. The name is re-hardened via {@link sanitizeDownloadName} and the
|
|
124
|
+
* size is capped at {@link MAX_DOWNLOAD_BYTES} (over-cap files are left where they
|
|
125
|
+
* are and rejected). Falls back to copy+unlink when source and destination are on
|
|
126
|
+
* different filesystems (rename's `EXDEV`). Returns the final path and byte size.
|
|
127
|
+
*/
|
|
128
|
+
function captureDownload(sourcePath, suggestedName) {
|
|
129
|
+
const w = getActiveWorkspace();
|
|
130
|
+
const bytes = (0, node_fs_1.statSync)(sourcePath).size;
|
|
131
|
+
if (bytes > download_1.MAX_DOWNLOAD_BYTES) {
|
|
132
|
+
throw new Error(`download exceeds the ${download_1.MAX_DOWNLOAD_BYTES}-byte cap (left at ${sourcePath})`);
|
|
133
|
+
}
|
|
134
|
+
const name = (0, download_1.sanitizeDownloadName)(suggestedName ?? (0, node_path_1.basename)(sourcePath));
|
|
135
|
+
const dest = (0, node_path_1.join)(w.downloadDir, name);
|
|
136
|
+
try {
|
|
137
|
+
(0, node_fs_1.renameSync)(sourcePath, dest);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
(0, node_fs_1.copyFileSync)(sourcePath, dest);
|
|
141
|
+
try {
|
|
142
|
+
(0, node_fs_1.unlinkSync)(sourcePath);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* best effort: a left-behind source is harmless */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { path: dest, bytes: (0, node_fs_1.statSync)(dest).size };
|
|
149
|
+
}
|
|
150
|
+
/** Append one action record to the active task's `history.jsonl`. */
|
|
151
|
+
function appendHistory(entry) {
|
|
152
|
+
const w = peekActiveWorkspace();
|
|
153
|
+
if (!w)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
(0, node_fs_1.appendFileSync)(w.historyPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
logErr(`history append failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=workspace.js.map
|
package/dist/src/cli.js
CHANGED
|
@@ -13,10 +13,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
const node_fs_1 = require("node:fs");
|
|
14
14
|
const node_path_1 = require("node:path");
|
|
15
15
|
const config_1 = require("./config");
|
|
16
|
+
const tasks_1 = require("./bridge/tasks");
|
|
16
17
|
const manager_1 = require("./executor/manager");
|
|
17
18
|
const select_1 = require("./executor/select");
|
|
18
19
|
const server_1 = require("./bridge/server");
|
|
19
20
|
const datadir_1 = require("./bridge/datadir");
|
|
21
|
+
const workspace_1 = require("./bridge/workspace");
|
|
20
22
|
const auth_1 = require("./bridge/auth");
|
|
21
23
|
const server_2 = require("./mcp/server");
|
|
22
24
|
/** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
|
|
@@ -49,7 +51,96 @@ function version() {
|
|
|
49
51
|
return '0.0.0';
|
|
50
52
|
}
|
|
51
53
|
}
|
|
54
|
+
/** Render a byte count as a short human string (1.2 MB, 904 KB, …). */
|
|
55
|
+
function humanBytes(n) {
|
|
56
|
+
if (n < 1024)
|
|
57
|
+
return `${n} B`;
|
|
58
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
59
|
+
let v = n / 1024;
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
62
|
+
v /= 1024;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
66
|
+
}
|
|
67
|
+
/** Pull `--flag value` / `--flag` out of an arg list, leaving positionals. */
|
|
68
|
+
function flag(args, name) {
|
|
69
|
+
const i = args.indexOf(name);
|
|
70
|
+
if (i === -1)
|
|
71
|
+
return undefined;
|
|
72
|
+
return args[i + 1];
|
|
73
|
+
}
|
|
74
|
+
function hasFlag(args, name) {
|
|
75
|
+
return args.includes(name);
|
|
76
|
+
}
|
|
77
|
+
const TASKS_HELP = `chrome-mcp tasks — inspect and prune per-profile task workspaces.
|
|
78
|
+
|
|
79
|
+
Usage:
|
|
80
|
+
chrome-mcp tasks list [--json] [--data-dir <path>]
|
|
81
|
+
chrome-mcp tasks gc [--older-than <days>] [--keep <n>] [--profile <name>]
|
|
82
|
+
[--dry-run] [--data-dir <path>]
|
|
83
|
+
|
|
84
|
+
gc removes a task only when it is NOT among the newest --keep AND is older than
|
|
85
|
+
--older-than. At least one of --older-than / --keep is required.
|
|
86
|
+
`;
|
|
87
|
+
/**
|
|
88
|
+
* Handle the `tasks` subcommand family (list / gc). Returns true if argv was a
|
|
89
|
+
* tasks command (so main() should not start the server), false otherwise.
|
|
90
|
+
*/
|
|
91
|
+
function runTasksCommand(argv) {
|
|
92
|
+
if (argv[0] !== 'tasks')
|
|
93
|
+
return false;
|
|
94
|
+
const args = argv.slice(1);
|
|
95
|
+
const sub = args[0];
|
|
96
|
+
const dataDirFlag = flag(args, '--data-dir');
|
|
97
|
+
if (dataDirFlag)
|
|
98
|
+
process.env.CHROME_MCP_DATA = dataDirFlag;
|
|
99
|
+
const dataDir = (0, config_1.resolveDataDir)();
|
|
100
|
+
if (sub === 'list') {
|
|
101
|
+
const tasks = (0, tasks_1.listTasks)(dataDir);
|
|
102
|
+
if (hasFlag(args, '--json')) {
|
|
103
|
+
process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (tasks.length === 0) {
|
|
107
|
+
process.stdout.write('no tasks found.\n');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write('PROFILE\tTASK\tCREATED\tFILES\tSIZE\n');
|
|
111
|
+
for (const t of tasks) {
|
|
112
|
+
process.stdout.write(`${t.profile}\t${t.task}\t${t.createdAt}\t${t.downloads}\t${humanBytes(t.bytes)}\n`);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (sub === 'gc') {
|
|
117
|
+
const olderThan = flag(args, '--older-than');
|
|
118
|
+
const keep = flag(args, '--keep');
|
|
119
|
+
if (olderThan === undefined && keep === undefined) {
|
|
120
|
+
process.stderr.write('tasks gc: pass --older-than <days> and/or --keep <n>.\n');
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const opts = {
|
|
125
|
+
olderThanDays: olderThan === undefined ? undefined : Number.parseFloat(olderThan),
|
|
126
|
+
keep: keep === undefined ? undefined : Number.parseInt(keep, 10),
|
|
127
|
+
profile: flag(args, '--profile'),
|
|
128
|
+
dryRun: hasFlag(args, '--dry-run'),
|
|
129
|
+
};
|
|
130
|
+
const { removed, freedBytes } = (0, tasks_1.gcTasks)(dataDir, opts, Date.now());
|
|
131
|
+
const prefix = opts.dryRun ? '[dry-run] would remove' : 'removed';
|
|
132
|
+
process.stdout.write(`${prefix} ${removed.length} task(s), ${humanBytes(freedBytes)}\n`);
|
|
133
|
+
for (const t of removed) {
|
|
134
|
+
process.stdout.write(` ${t.profile}/${t.task} (${humanBytes(t.bytes)})\n`);
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write(TASKS_HELP);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
52
141
|
async function main() {
|
|
142
|
+
if (runTasksCommand(process.argv.slice(2)))
|
|
143
|
+
return;
|
|
53
144
|
const cfg = (0, config_1.parseArgs)(process.argv.slice(2));
|
|
54
145
|
if (cfg.showHelp) {
|
|
55
146
|
process.stdout.write(config_1.HELP_TEXT);
|
|
@@ -93,6 +184,19 @@ async function main() {
|
|
|
93
184
|
});
|
|
94
185
|
return;
|
|
95
186
|
}
|
|
187
|
+
// Each profile is an identity (its own Chrome user-data dir); each task is a
|
|
188
|
+
// run whose downloads/meta.json live under it. Handshake stays at the data-dir
|
|
189
|
+
// root (auth is machine-scoped, not per-profile). Fold any pre-0.6 flat layout
|
|
190
|
+
// into profiles/default/ first — before ensureWorkspace creates the targets.
|
|
191
|
+
for (const m of (0, datadir_1.migrateLegacyLayout)(dataDir))
|
|
192
|
+
(0, server_2.logErr)(`migrated legacy layout: ${m}`);
|
|
193
|
+
const workspace = (0, datadir_1.ensureWorkspace)(dataDir, cfg.profile, cfg.task, {
|
|
194
|
+
version: version(),
|
|
195
|
+
pid: process.pid,
|
|
196
|
+
createdAt: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
(0, workspace_1.setActiveWorkspace)(workspace);
|
|
199
|
+
(0, server_2.logErr)(`workspace: profile "${workspace.profile}" task "${workspace.task}" → ${workspace.taskDir}`);
|
|
96
200
|
(0, manager_1.configureManager)({
|
|
97
201
|
policy: cfg.policy,
|
|
98
202
|
select: (0, select_1.createSelector)({
|
|
@@ -102,7 +206,8 @@ async function main() {
|
|
|
102
206
|
cdp: {
|
|
103
207
|
mode: cfg.cdpEndpoint ? 'connect' : 'launch',
|
|
104
208
|
cdpEndpoint: cfg.cdpEndpoint,
|
|
105
|
-
userDataDir:
|
|
209
|
+
userDataDir: workspace.profileDir,
|
|
210
|
+
downloadDir: workspace.downloadDir,
|
|
106
211
|
headless: cfg.headless,
|
|
107
212
|
},
|
|
108
213
|
}),
|
package/dist/src/config.d.ts
CHANGED
|
@@ -12,8 +12,12 @@ export type BackendPreference = 'extension' | 'cdp';
|
|
|
12
12
|
export interface CliConfig {
|
|
13
13
|
/** Port the bridge binds; 0 = ephemeral (written to the handshake file). */
|
|
14
14
|
wsPort: number;
|
|
15
|
-
/** Directory holding handshake.json and the
|
|
15
|
+
/** Directory holding handshake.json and the per-profile workspaces. */
|
|
16
16
|
dataDir: string;
|
|
17
|
+
/** Browser profile (identity). Selects `profiles/<profile>/` under the data dir. */
|
|
18
|
+
profile: string;
|
|
19
|
+
/** Task (run). Artifacts land in `profiles/<profile>/tasks/<task>/`. */
|
|
20
|
+
task: string;
|
|
17
21
|
/** Resolved, fully-defaulted security policy. */
|
|
18
22
|
policy: Policy;
|
|
19
23
|
/** Whether to fall back to a Playwright-driven Chromium when no extension is paired. */
|
|
@@ -34,6 +38,20 @@ export interface CliConfig {
|
|
|
34
38
|
}
|
|
35
39
|
/** Resolve the data dir: `$CHROME_MCP_DATA` or `~/.chrome-mcp`. */
|
|
36
40
|
export declare function resolveDataDir(): string;
|
|
41
|
+
/**
|
|
42
|
+
* Reduce an arbitrary label to a single safe path segment: no separators, no
|
|
43
|
+
* `..` traversal, no leading dots. Profile/task names become directories under
|
|
44
|
+
* the data dir, so they must never escape it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function sanitizeName(name: string, kind: 'profile' | 'task'): string;
|
|
47
|
+
/** Active profile: `$CHROME_MCP_PROFILE` (set by `--profile`) or "default". */
|
|
48
|
+
export declare function resolveProfile(): string;
|
|
49
|
+
/** Active task: `$CHROME_MCP_TASK` (set by `--task`) or "default". */
|
|
50
|
+
export declare function resolveTask(): string;
|
|
51
|
+
/** `profiles/<profile>/` — holds the Chrome user-data dir and this profile's tasks. */
|
|
52
|
+
export declare function resolveProfileDir(dataDir: string, profile: string): string;
|
|
53
|
+
/** `profiles/<profile>/tasks/<task>/` — per-run artifacts (downloads, meta.json). */
|
|
54
|
+
export declare function resolveTaskDir(dataDir: string, profile: string, task: string): string;
|
|
37
55
|
/**
|
|
38
56
|
* Parse argv (the slice AFTER `node script`, i.e. `process.argv.slice(2)`) plus
|
|
39
57
|
* env into a fully-resolved `CliConfig`. Pure except for the optional policy-file
|
package/dist/src/config.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
11
|
exports.HELP_TEXT = void 0;
|
|
12
12
|
exports.resolveDataDir = resolveDataDir;
|
|
13
|
+
exports.sanitizeName = sanitizeName;
|
|
14
|
+
exports.resolveProfile = resolveProfile;
|
|
15
|
+
exports.resolveTask = resolveTask;
|
|
16
|
+
exports.resolveProfileDir = resolveProfileDir;
|
|
17
|
+
exports.resolveTaskDir = resolveTaskDir;
|
|
13
18
|
exports.parseArgs = parseArgs;
|
|
14
19
|
const node_os_1 = require("node:os");
|
|
15
20
|
const node_path_1 = require("node:path");
|
|
@@ -20,6 +25,36 @@ const policy_1 = require("./security/policy");
|
|
|
20
25
|
function resolveDataDir() {
|
|
21
26
|
return process.env.CHROME_MCP_DATA ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.chrome-mcp');
|
|
22
27
|
}
|
|
28
|
+
const DEFAULT_PROFILE = 'default';
|
|
29
|
+
const DEFAULT_TASK = 'default';
|
|
30
|
+
/**
|
|
31
|
+
* Reduce an arbitrary label to a single safe path segment: no separators, no
|
|
32
|
+
* `..` traversal, no leading dots. Profile/task names become directories under
|
|
33
|
+
* the data dir, so they must never escape it.
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeName(name, kind) {
|
|
36
|
+
const clean = name.trim().replace(/[^A-Za-z0-9._-]/g, '-').replace(/^\.+/, '');
|
|
37
|
+
if (!clean || clean === '.' || clean === '..') {
|
|
38
|
+
throw new Error(`invalid ${kind} name: ${JSON.stringify(name)}`);
|
|
39
|
+
}
|
|
40
|
+
return clean;
|
|
41
|
+
}
|
|
42
|
+
/** Active profile: `$CHROME_MCP_PROFILE` (set by `--profile`) or "default". */
|
|
43
|
+
function resolveProfile() {
|
|
44
|
+
return sanitizeName(process.env.CHROME_MCP_PROFILE ?? DEFAULT_PROFILE, 'profile');
|
|
45
|
+
}
|
|
46
|
+
/** Active task: `$CHROME_MCP_TASK` (set by `--task`) or "default". */
|
|
47
|
+
function resolveTask() {
|
|
48
|
+
return sanitizeName(process.env.CHROME_MCP_TASK ?? DEFAULT_TASK, 'task');
|
|
49
|
+
}
|
|
50
|
+
/** `profiles/<profile>/` — holds the Chrome user-data dir and this profile's tasks. */
|
|
51
|
+
function resolveProfileDir(dataDir, profile) {
|
|
52
|
+
return (0, node_path_1.join)(dataDir, 'profiles', profile);
|
|
53
|
+
}
|
|
54
|
+
/** `profiles/<profile>/tasks/<task>/` — per-run artifacts (downloads, meta.json). */
|
|
55
|
+
function resolveTaskDir(dataDir, profile, task) {
|
|
56
|
+
return (0, node_path_1.join)(resolveProfileDir(dataDir, profile), 'tasks', task);
|
|
57
|
+
}
|
|
23
58
|
function readPolicyFile(path) {
|
|
24
59
|
const raw = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
25
60
|
const parsed = JSON.parse(raw);
|
|
@@ -66,6 +101,12 @@ function parseArgs(argv) {
|
|
|
66
101
|
case '--data-dir':
|
|
67
102
|
process.env.CHROME_MCP_DATA = requireValue(argv[++i], '--data-dir');
|
|
68
103
|
break;
|
|
104
|
+
case '--profile':
|
|
105
|
+
process.env.CHROME_MCP_PROFILE = requireValue(argv[++i], '--profile');
|
|
106
|
+
break;
|
|
107
|
+
case '--task':
|
|
108
|
+
process.env.CHROME_MCP_TASK = requireValue(argv[++i], '--task');
|
|
109
|
+
break;
|
|
69
110
|
case '--policy':
|
|
70
111
|
policyFile = readPolicyFile(requireValue(argv[++i], '--policy'));
|
|
71
112
|
break;
|
|
@@ -131,6 +172,8 @@ function parseArgs(argv) {
|
|
|
131
172
|
return {
|
|
132
173
|
wsPort,
|
|
133
174
|
dataDir: resolveDataDir(),
|
|
175
|
+
profile: resolveProfile(),
|
|
176
|
+
task: resolveTask(),
|
|
134
177
|
policy,
|
|
135
178
|
cdpFallback,
|
|
136
179
|
cdpEndpoint,
|
|
@@ -180,10 +223,19 @@ function requireLogLevel(value) {
|
|
|
180
223
|
exports.HELP_TEXT = `chrome-mcp — drive a real Chrome browser over MCP.
|
|
181
224
|
|
|
182
225
|
Usage: chrome-mcp [options]
|
|
226
|
+
chrome-mcp tasks list [--json]
|
|
227
|
+
chrome-mcp tasks gc [--older-than <days>] [--keep <n>] [--profile <name>] [--dry-run]
|
|
183
228
|
|
|
184
229
|
Connection:
|
|
185
230
|
--port <n> WebSocket bridge port (default ${protocol_1.DEFAULT_WS_PORT}; 0 = ephemeral)
|
|
186
231
|
--data-dir <path> Override the data dir (default ~/.chrome-mcp)
|
|
232
|
+
--profile <name> Default browser profile / identity (default "default").
|
|
233
|
+
Artifacts live under profiles/<name>/. At runtime, switch
|
|
234
|
+
with the profile_use tool. Several browsers can pair to the
|
|
235
|
+
SAME port+token at once, each declaring its own Profile in
|
|
236
|
+
the extension Options; tools route to the active profile.
|
|
237
|
+
--task <name> Task label (default "default"). Downloads and a meta.json
|
|
238
|
+
land in profiles/<profile>/tasks/<task>/.
|
|
187
239
|
--print-pairing Write the handshake and print its path, then exit
|
|
188
240
|
--persist-token Reuse a stable on-disk token across restarts so the
|
|
189
241
|
extension never has to re-pair (default: fresh per boot).
|
|
@@ -13,6 +13,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
13
13
|
private readonly bridge;
|
|
14
14
|
readonly backend: BackendKind;
|
|
15
15
|
constructor(bridge: BridgeServer);
|
|
16
|
+
/** The profile this executor routes to = the active task workspace's profile
|
|
17
|
+
* (set by `profile_use`). Falls back to "default" before a workspace exists. */
|
|
18
|
+
private activeProfile;
|
|
16
19
|
private send;
|
|
17
20
|
status(): ExecutorStatus;
|
|
18
21
|
ensureReady(): Promise<void>;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.ExtensionExecutor = void 0;
|
|
13
13
|
const types_1 = require("./types");
|
|
14
|
+
const workspace_1 = require("../bridge/workspace");
|
|
14
15
|
/** Flatten a Target into the params a wire command carries. */
|
|
15
16
|
function targetParams(t) {
|
|
16
17
|
if (!t)
|
|
@@ -23,17 +24,26 @@ class ExtensionExecutor {
|
|
|
23
24
|
constructor(bridge) {
|
|
24
25
|
this.bridge = bridge;
|
|
25
26
|
}
|
|
27
|
+
/** The profile this executor routes to = the active task workspace's profile
|
|
28
|
+
* (set by `profile_use`). Falls back to "default" before a workspace exists. */
|
|
29
|
+
activeProfile() {
|
|
30
|
+
return (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default';
|
|
31
|
+
}
|
|
26
32
|
send(method, params, opts) {
|
|
27
|
-
return this.bridge.sendCommand(method, params, opts);
|
|
33
|
+
return this.bridge.sendCommand(method, params, { ...opts, profile: this.activeProfile() });
|
|
28
34
|
}
|
|
29
35
|
status() {
|
|
30
|
-
const
|
|
36
|
+
const profile = this.activeProfile();
|
|
37
|
+
const connected = this.bridge.hasConnection(profile);
|
|
31
38
|
return {
|
|
32
39
|
ready: connected,
|
|
33
40
|
backend: this.backend,
|
|
34
41
|
activeTabId: null, // not known synchronously
|
|
35
42
|
extensionConnected: connected,
|
|
36
43
|
cdpAttached: false,
|
|
44
|
+
detail: connected ? undefined : `active profile "${profile}" has no paired browser`,
|
|
45
|
+
activeProfile: profile,
|
|
46
|
+
connectedProfiles: this.bridge.connectedProfiles(),
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
async ensureReady() {
|
|
@@ -41,7 +51,7 @@ class ExtensionExecutor {
|
|
|
41
51
|
// already confirmed an extension is paired + responsive before picking us.
|
|
42
52
|
}
|
|
43
53
|
async ping(deadlineMs = 800) {
|
|
44
|
-
if (!this.bridge.
|
|
54
|
+
if (!this.bridge.hasConnection(this.activeProfile()))
|
|
45
55
|
return false;
|
|
46
56
|
try {
|
|
47
57
|
await this.send('ping_probe', {}, { timeoutMs: deadlineMs });
|
|
@@ -131,7 +141,22 @@ class ExtensionExecutor {
|
|
|
131
141
|
}
|
|
132
142
|
// -- privileged ---------------------------------------------------------
|
|
133
143
|
async download(args) {
|
|
134
|
-
|
|
144
|
+
const res = (await this.send('download_file', { url: args.url, ...targetParams(args.target), suggestedName: args.suggestedName }, { tabId: args.tabId }));
|
|
145
|
+
// The extension can only write to the user's Downloads dir; relocate the file
|
|
146
|
+
// into the active task's downloads/ so each task collects its own artifacts.
|
|
147
|
+
// If the move fails, keep Chrome's path rather than reporting a phantom failure.
|
|
148
|
+
if (res.sourcePath) {
|
|
149
|
+
try {
|
|
150
|
+
const moved = (0, workspace_1.captureDownload)(res.sourcePath, res.suggestedName);
|
|
151
|
+
return { ...res, path: moved.path, bytes: moved.bytes, sourcePath: undefined };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Capture failed (e.g. over the size cap): leave the file where Chrome put
|
|
155
|
+
// it and report that path instead of failing the call.
|
|
156
|
+
return { ...res, sourcePath: undefined };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return res;
|
|
135
160
|
}
|
|
136
161
|
async uploadFile(t, files, opts) {
|
|
137
162
|
return (await this.send('upload_file', { ...targetParams(t), files }, { tabId: opts?.tabId }));
|
|
@@ -86,6 +86,15 @@ export interface DownloadResult {
|
|
|
86
86
|
bytes: number;
|
|
87
87
|
mimeType?: string;
|
|
88
88
|
suggestedName?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Extension backend only: the absolute path where Chrome first saved the file
|
|
91
|
+
* (the user's Downloads dir). The server moves it into the active task's
|
|
92
|
+
* `downloads/` and clears this field, so it is never present in a final result
|
|
93
|
+
* returned to the MCP client.
|
|
94
|
+
*/
|
|
95
|
+
sourcePath?: string;
|
|
96
|
+
/** Extension backend only: the chrome.downloads id, surfaced for diagnostics. */
|
|
97
|
+
downloadId?: number;
|
|
89
98
|
}
|
|
90
99
|
/** One interactive/landmark element in an accessibility snapshot. `ref` is stable until the tab navigates. */
|
|
91
100
|
export interface SnapshotNode {
|
|
@@ -128,6 +137,10 @@ export interface ExecutorStatus {
|
|
|
128
137
|
detail?: string;
|
|
129
138
|
extensionConnected: boolean;
|
|
130
139
|
cdpAttached: boolean;
|
|
140
|
+
/** Extension backend: the profile tools currently route to (set by profile_use). */
|
|
141
|
+
activeProfile?: string;
|
|
142
|
+
/** Extension backend: every profile with a live browser right now. */
|
|
143
|
+
connectedProfiles?: string[];
|
|
131
144
|
}
|
|
132
145
|
export interface Executor {
|
|
133
146
|
readonly backend: BackendKind;
|