codex-control-chrome-mcp 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +28 -3
- package/docs/install.md +58 -16
- package/package.json +1 -1
- package/src/cli.js +68 -12
- package/src/constants.js +56 -33
- package/src/host-identity.js +126 -0
- package/src/installer.js +172 -42
- package/src/mcp-server.js +46 -2
- package/src/native-host.js +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ This project follows semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.2.0] - 2026-06-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Microsoft Edge support as a first-class browser alongside Chrome. `install-native-host`, `uninstall-native-host`, and `status` accept `--browser <chrome|edge|all>`.
|
|
14
|
+
- Auto-detection: without `--browser`, `install-native-host` installs for every supported browser whose profile directory exists, `uninstall-native-host` targets every browser it previously installed for, and `status` reports both browsers.
|
|
15
|
+
- The MCP stdio server's self-heal now re-asserts the manifest for every browser it manages, not just Chrome.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Native host install state is now stored per browser (`install-state.<browser>.json` / `config.<browser>.json`). Chrome keeps the original unsuffixed filenames, so existing installs remain valid; Edge no longer clobbers Chrome's restore and self-heal metadata.
|
|
20
|
+
- `status` output now nests install state under each browser (`install.chrome`, `install.edge`). The `chrome_status` MCP tool additionally reports `manifestPaths` for every supported browser.
|
|
21
|
+
|
|
22
|
+
## [1.1.0] - 2026-06-27
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Self-healing native host registration: the MCP stdio server re-asserts the manifest on startup when a Codex update reverts it (opt out with `CODEX_CONTROL_CHROME_NO_AUTO_REGISTER=1`).
|
|
27
|
+
- Robust identification of OpenAI's genuine host via stable signals (manifest name + extension origin, bundle path, binary format, and the macOS OpenAI code signature) instead of the binary filename.
|
|
28
|
+
- `status` now reports `registered` and a `classification` of the live manifest/host.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Proxy target resolution preferred a stale recorded path over the live manifest, so a renamed/removed official host (e.g. `extension-host` → `Codex for Chrome`) could be recorded as the proxy target. It now prefers the live official manifest and skips paths that no longer exist.
|
|
33
|
+
|
|
9
34
|
## [1.0.1] - 2026-06-26
|
|
10
35
|
|
|
11
36
|
### Added
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ This is an independent community project. It is not affiliated with OpenAI, Code
|
|
|
20
20
|
The current implementation provides:
|
|
21
21
|
|
|
22
22
|
- official `@modelcontextprotocol/sdk` stdio server
|
|
23
|
-
- Chrome Native Messaging host mode
|
|
23
|
+
- Chrome and Microsoft Edge Native Messaging host mode
|
|
24
24
|
- install/uninstall commands with manifest backup
|
|
25
25
|
- JSON-RPC bridge socket under `/tmp/codex-browser-use`
|
|
26
26
|
- Chrome tab, navigation, screenshot, CDP, and event MCP tools
|
|
@@ -36,16 +36,33 @@ The current implementation provides:
|
|
|
36
36
|
|
|
37
37
|
Node.js 20 or newer is required. npm Trusted Publishing for releases uses Node.js 24 in GitHub Actions.
|
|
38
38
|
|
|
39
|
+
## Browser Support
|
|
40
|
+
|
|
41
|
+
Both Chromium browsers use the same Codex extension and the same `chrome-extension://` origin, so a single manifest works for either.
|
|
42
|
+
|
|
43
|
+
| Browser | Status | Notes |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| Google Chrome | Tested | Default target. |
|
|
46
|
+
| Microsoft Edge | Supported | Same extension and native-messaging mechanism; installs to the Edge `NativeMessagingHosts` directory. |
|
|
47
|
+
|
|
48
|
+
Commands accept `--browser <chrome\|edge\|all>`. Without the flag, `install-native-host` installs for every supported browser whose profile directory exists, `uninstall-native-host` targets every browser it previously installed for, and `status` reports both.
|
|
49
|
+
|
|
39
50
|
## Quick Start
|
|
40
51
|
|
|
41
|
-
Install and enable the [Codex Chrome Extension](https://chromewebstore.google.com/detail/hehggadaopoacecdllhhajmbjkdcmajg) in the Chrome profile you want to automate.
|
|
52
|
+
Install and enable the [Codex Chrome Extension](https://chromewebstore.google.com/detail/hehggadaopoacecdllhhajmbjkdcmajg) in the Chrome or Edge profile you want to automate.
|
|
42
53
|
|
|
43
|
-
Install the
|
|
54
|
+
Install the native host (auto-detects the browsers present):
|
|
44
55
|
|
|
45
56
|
```bash
|
|
46
57
|
npx -y codex-control-chrome-mcp@latest install-native-host
|
|
47
58
|
```
|
|
48
59
|
|
|
60
|
+
To target one browser explicitly:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx -y codex-control-chrome-mcp@latest install-native-host --browser edge
|
|
64
|
+
```
|
|
65
|
+
|
|
49
66
|
Configure your Agent to use the MCP stdio server:
|
|
50
67
|
|
|
51
68
|
```json
|
|
@@ -144,6 +161,14 @@ node ./bin/codex-control-chrome-mcp.js --native-host
|
|
|
144
161
|
|
|
145
162
|
## Troubleshooting
|
|
146
163
|
|
|
164
|
+
`status` reports, per browser (under `install.chrome` / `install.edge`), a `registered` flag and a `classification` of the live manifest. When `registered` is `false` and `classification.kind` is `official-openai`, a Codex update has reclaimed that browser's manifest (see below). `classification` identifies OpenAI's genuine host by stable signals — the `com.openai.codexextension` name, the official extension origin, the bundle path, the binary format, and (on macOS) the OpenAI code signature — not by filename.
|
|
165
|
+
|
|
166
|
+
### A Codex update stopped the bridge
|
|
167
|
+
|
|
168
|
+
Codex App/extension updates re-register their own `com.openai.codexextension` native-messaging manifest, which overwrites this project's proxy registration (the bundled host binary has even been renamed across releases). The symptom is `status` showing `sockets: []`, `registered: false`, and a manifest `path` back inside the Codex bundle.
|
|
169
|
+
|
|
170
|
+
The MCP stdio server now **self-heals**: on startup it re-asserts the manifest when it detects it was reverted, so you usually do not need to re-run `install-native-host`. Because Chrome only reads the manifest when the extension reconnects, **reload the Codex Chrome Extension (or restart Chrome) once** after a Codex update to bring the bridge back. Set `CODEX_CONTROL_CHROME_NO_AUTO_REGISTER=1` to disable the auto re-register.
|
|
171
|
+
|
|
147
172
|
If `status` shows no bridge sockets, reload Chrome or the Codex Chrome Extension after installing the native host.
|
|
148
173
|
|
|
149
174
|
If Codex App integration stops working, uninstall this native host to restore the backed-up manifest:
|
package/docs/install.md
CHANGED
|
@@ -24,47 +24,76 @@ This is an independent community project. It is not affiliated with OpenAI, Code
|
|
|
24
24
|
| Platform | Status | Notes |
|
|
25
25
|
| --- | --- | --- |
|
|
26
26
|
| macOS | Tested | Primary supported platform. |
|
|
27
|
-
| Linux | Experimental | Uses the
|
|
28
|
-
| Windows | Unsupported |
|
|
27
|
+
| Linux | Experimental | Uses the browser's user-level Native Messaging host directory. |
|
|
28
|
+
| Windows | Unsupported | Native Messaging registration uses registry keys on Windows and is not implemented yet. |
|
|
29
|
+
|
|
30
|
+
## Browser Support
|
|
31
|
+
|
|
32
|
+
| Browser | Status | Notes |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| Google Chrome | Tested | Default target. |
|
|
35
|
+
| Microsoft Edge | Supported | Same Codex extension and `chrome-extension://` origin; installs to Edge's `NativeMessagingHosts` directory. |
|
|
36
|
+
|
|
37
|
+
Commands accept `--browser <chrome|edge|all>`. Without it, `install-native-host` auto-detects the browsers present on the machine, `uninstall-native-host` targets every browser it previously installed for, and `status` reports both browsers.
|
|
29
38
|
|
|
30
39
|
## Install
|
|
31
40
|
|
|
32
|
-
First install and enable the [Codex Chrome Extension](https://chromewebstore.google.com/detail/hehggadaopoacecdllhhajmbjkdcmajg) in the Chrome profile you want to automate.
|
|
41
|
+
First install and enable the [Codex Chrome Extension](https://chromewebstore.google.com/detail/hehggadaopoacecdllhhajmbjkdcmajg) in the Chrome or Edge profile you want to automate.
|
|
33
42
|
|
|
34
43
|
```bash
|
|
35
44
|
npx -y codex-control-chrome-mcp@latest install-native-host
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
Without `--browser`, this installs for every supported browser whose profile directory exists. Pass `--browser <chrome|edge|all>` to target one explicitly.
|
|
48
|
+
|
|
49
|
+
This writes a stable per-browser launcher (Edge uses the `-edge` suffix):
|
|
39
50
|
|
|
40
51
|
```text
|
|
41
52
|
~/.codex-control-chrome-mcp/native-host-launcher
|
|
53
|
+
~/.codex-control-chrome-mcp/native-host-launcher-edge
|
|
42
54
|
```
|
|
43
55
|
|
|
44
|
-
The launcher invokes:
|
|
56
|
+
The launcher invokes (the `--browser` flag tells the host which browser's config to read for proxy mode):
|
|
45
57
|
|
|
46
58
|
```bash
|
|
47
|
-
npx -y codex-control-chrome-mcp@
|
|
59
|
+
npx -y codex-control-chrome-mcp@latest --native-host --browser chrome "$@"
|
|
48
60
|
```
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
For each selected browser the installer:
|
|
51
63
|
|
|
52
|
-
1. Reads the existing `com.openai.codexextension.json
|
|
64
|
+
1. Reads the existing `com.openai.codexextension.json` in that browser's directory.
|
|
53
65
|
2. Backs it up in the same directory with a timestamped suffix.
|
|
54
66
|
3. Writes a new manifest pointing to this project's CLI.
|
|
55
|
-
4. Records restore metadata in `~/.codex-control-chrome-mcp/install-state.json
|
|
56
|
-
5. Records the
|
|
67
|
+
4. Records restore metadata in `~/.codex-control-chrome-mcp/install-state.json` (Chrome) or `install-state.<browser>.json` (other browsers), so browsers never clobber each other's state.
|
|
68
|
+
5. Records the genuine OpenAI host path for proxy mode when available.
|
|
69
|
+
|
|
70
|
+
### Identifying the official host
|
|
71
|
+
|
|
72
|
+
The installer identifies OpenAI's genuine host with stable signals instead of the
|
|
73
|
+
binary filename (OpenAI has renamed it across releases, e.g. `extension-host` →
|
|
74
|
+
`Codex for Chrome`):
|
|
75
|
+
|
|
76
|
+
- the manifest `name` (`com.openai.codexextension`) and the official extension origin,
|
|
77
|
+
- the bundle directory structure (`.../openai-bundled/.../extension-host/...`),
|
|
78
|
+
- the binary format (a compiled Mach-O/ELF executable, not a shell script),
|
|
79
|
+
- on macOS, the OpenAI code signature (Developer ID Team `2DC432GLL2`).
|
|
57
80
|
|
|
58
|
-
|
|
81
|
+
When choosing the proxy target it prefers the **live** manifest (most up to date) and
|
|
82
|
+
skips paths that no longer exist, so a renamed or removed host is never recorded as the
|
|
83
|
+
official target. `status` reports these signals under `classification`.
|
|
84
|
+
|
|
85
|
+
Example manifest paths on macOS:
|
|
59
86
|
|
|
60
87
|
```text
|
|
61
88
|
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.openai.codexextension.json
|
|
89
|
+
~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/com.openai.codexextension.json
|
|
62
90
|
```
|
|
63
91
|
|
|
64
|
-
Example manifest
|
|
92
|
+
Example manifest paths on Linux:
|
|
65
93
|
|
|
66
94
|
```text
|
|
67
95
|
~/.config/google-chrome/NativeMessagingHosts/com.openai.codexextension.json
|
|
96
|
+
~/.config/microsoft-edge/NativeMessagingHosts/com.openai.codexextension.json
|
|
68
97
|
```
|
|
69
98
|
|
|
70
99
|
## MCP Client Config
|
|
@@ -105,7 +134,7 @@ For local development:
|
|
|
105
134
|
npx -y codex-control-chrome-mcp@latest uninstall-native-host
|
|
106
135
|
```
|
|
107
136
|
|
|
108
|
-
This restores the backed-up official manifest if one was recorded.
|
|
137
|
+
This restores the backed-up official manifest if one was recorded. Without `--browser` it uninstalls from every browser it previously installed for; pass `--browser <chrome|edge|all>` to scope it.
|
|
109
138
|
|
|
110
139
|
Use `--force` only if the current manifest was manually edited and you still want to restore/remove it:
|
|
111
140
|
|
|
@@ -119,16 +148,28 @@ npx -y codex-control-chrome-mcp@latest uninstall-native-host --force
|
|
|
119
148
|
npx -y codex-control-chrome-mcp@latest status
|
|
120
149
|
```
|
|
121
150
|
|
|
122
|
-
This prints
|
|
151
|
+
This prints, per browser under `install.chrome` / `install.edge`:
|
|
123
152
|
|
|
124
153
|
- active native host manifest
|
|
125
154
|
- saved install state
|
|
126
|
-
-
|
|
155
|
+
- the `registered` flag and live-manifest `classification`
|
|
156
|
+
|
|
157
|
+
and the discovered bridge sockets under `/tmp/codex-browser-use`. Pass `--browser` to scope it to one browser.
|
|
127
158
|
|
|
128
159
|
## Operational Notes
|
|
129
160
|
|
|
130
161
|
Chrome launches the native host only when the extension connects. If no bridge socket is visible, reload Chrome or the Codex extension after installing the manifest.
|
|
131
162
|
|
|
163
|
+
### Auto re-register after Codex updates
|
|
164
|
+
|
|
165
|
+
A Codex App/extension update re-registers its own `com.openai.codexextension` manifest and reverts this project's proxy registration. To avoid a manual repair each time, the MCP stdio server re-asserts the manifest on startup when it detects it was reverted (only if it was installed before — it never bootstraps a fresh install silently). Logs go to stderr only.
|
|
166
|
+
|
|
167
|
+
Re-asserting the manifest takes effect on the extension's **next** Native Messaging connection, so reload the Codex Chrome Extension or restart Chrome once after a Codex update. Disable this behavior with:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
CODEX_CONTROL_CHROME_NO_AUTO_REGISTER=1
|
|
171
|
+
```
|
|
172
|
+
|
|
132
173
|
If the extension is missing, install the [Codex Chrome Extension](https://chromewebstore.google.com/detail/hehggadaopoacecdllhhajmbjkdcmajg) from Chrome Web Store and enable it in the target Chrome profile.
|
|
133
174
|
|
|
134
175
|
Stopping the MCP stdio process does not uninstall the native host. Uninstall explicitly to restore the original manifest.
|
|
@@ -142,7 +183,8 @@ If `status` shows no sockets:
|
|
|
142
183
|
1. Confirm Chrome is running with the profile where the extension is installed.
|
|
143
184
|
2. Reload or disable/enable the Codex Chrome Extension.
|
|
144
185
|
3. Run `npx -y codex-control-chrome-mcp@latest status`.
|
|
145
|
-
4. Check that the
|
|
186
|
+
4. Check `install.<browser>.registered`. If it is `false` and `classification.kind` is `official-openai`, a Codex update reclaimed that browser's manifest. The stdio server re-asserts it on next startup; reload the extension or restart the browser to reconnect (or run `install-native-host` to repair immediately).
|
|
187
|
+
5. When `registered` is `true`, the active manifest points to `~/.codex-control-chrome-mcp/native-host-launcher` (or the `-edge` variant, or your local launcher) and the extension just needs to connect.
|
|
146
188
|
|
|
147
189
|
If Codex App integration is affected, uninstall this project:
|
|
148
190
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { runMcpServer } from "./mcp-server.js";
|
|
2
2
|
import { runNativeHost } from "./native-host.js";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
detectInstalledBrowsers,
|
|
5
|
+
detectManagedBrowsers,
|
|
6
|
+
installNativeHost,
|
|
7
|
+
nativeHostInstallStatus,
|
|
8
|
+
uninstallNativeHost,
|
|
9
|
+
} from "./installer.js";
|
|
10
|
+
import { DEFAULT_BROWSER, SUPPORTED_BROWSERS } from "./constants.js";
|
|
4
11
|
import { listBridgeSockets } from "./bridge-client.js";
|
|
5
12
|
import { probeLengthPrefixedSocket } from "./probe.js";
|
|
6
13
|
|
|
@@ -14,7 +21,10 @@ export async function runCli(argv) {
|
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
if (args.includes("--native-host") || looksLikeChromeOrigin(command)) {
|
|
17
|
-
|
|
24
|
+
// Chrome/Edge append the extension origin as a positional arg, so find it
|
|
25
|
+
// by shape rather than position (the `--browser` flag shifts the indices).
|
|
26
|
+
const origin = args.find(looksLikeChromeOrigin) ?? argv[3];
|
|
27
|
+
await runNativeHost({ origin, browser: normalizeBrowser(readOption(args, "--browser")) });
|
|
18
28
|
return;
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -22,20 +32,31 @@ export async function runCli(argv) {
|
|
|
22
32
|
case "install-native-host": {
|
|
23
33
|
const binPath = readOption(args, "--bin");
|
|
24
34
|
const noProxy = args.includes("--no-proxy");
|
|
25
|
-
const
|
|
35
|
+
const browsers = await resolveBrowsers(readOption(args, "--browser"), "install");
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const browser of browsers) {
|
|
38
|
+
result[browser] = await installNativeHost({ binPath, proxy: !noProxy, browser });
|
|
39
|
+
}
|
|
26
40
|
printJson(result);
|
|
27
41
|
return;
|
|
28
42
|
}
|
|
29
43
|
case "uninstall-native-host": {
|
|
30
|
-
const
|
|
44
|
+
const force = args.includes("--force");
|
|
45
|
+
const browsers = await resolveBrowsers(readOption(args, "--browser"), "uninstall");
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const browser of browsers) {
|
|
48
|
+
result[browser] = await uninstallNativeHost({ force, browser });
|
|
49
|
+
}
|
|
31
50
|
printJson(result);
|
|
32
51
|
return;
|
|
33
52
|
}
|
|
34
53
|
case "status": {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
const browsers = await resolveBrowsers(readOption(args, "--browser"), "status");
|
|
55
|
+
const install = {};
|
|
56
|
+
for (const browser of browsers) {
|
|
57
|
+
install[browser] = await nativeHostInstallStatus({ browser });
|
|
58
|
+
}
|
|
59
|
+
printJson({ install, sockets: await listBridgeSockets() });
|
|
39
60
|
return;
|
|
40
61
|
}
|
|
41
62
|
case "probe-socket": {
|
|
@@ -66,6 +87,39 @@ function readOption(args, name) {
|
|
|
66
87
|
return index >= 0 ? args[index + 1] : undefined;
|
|
67
88
|
}
|
|
68
89
|
|
|
90
|
+
function normalizeBrowser(value) {
|
|
91
|
+
if (value == null) return DEFAULT_BROWSER;
|
|
92
|
+
const browser = String(value).toLowerCase();
|
|
93
|
+
if (!SUPPORTED_BROWSERS.includes(browser)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Unsupported browser: ${value}. Supported values: ${SUPPORTED_BROWSERS.join(", ")}, all.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return browser;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map an optional `--browser` selector to the list of browsers a command acts
|
|
103
|
+
* on. With no selector each command auto-detects: install targets browsers
|
|
104
|
+
* present on the machine, uninstall targets browsers we previously installed,
|
|
105
|
+
* and status reports every supported browser.
|
|
106
|
+
*/
|
|
107
|
+
async function resolveBrowsers(selector, mode) {
|
|
108
|
+
if (selector && selector !== "all") {
|
|
109
|
+
return [normalizeBrowser(selector)];
|
|
110
|
+
}
|
|
111
|
+
if (selector === "all" || mode === "status") {
|
|
112
|
+
return [...SUPPORTED_BROWSERS];
|
|
113
|
+
}
|
|
114
|
+
if (mode === "install") {
|
|
115
|
+
const present = await detectInstalledBrowsers();
|
|
116
|
+
return present.length ? present : [DEFAULT_BROWSER];
|
|
117
|
+
}
|
|
118
|
+
// uninstall
|
|
119
|
+
const managed = await detectManagedBrowsers();
|
|
120
|
+
return managed.length ? managed : [DEFAULT_BROWSER];
|
|
121
|
+
}
|
|
122
|
+
|
|
69
123
|
function printJson(value) {
|
|
70
124
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
71
125
|
}
|
|
@@ -76,14 +130,16 @@ function printHelp() {
|
|
|
76
130
|
Usage:
|
|
77
131
|
codex-control-chrome-mcp --stdio
|
|
78
132
|
codex-control-chrome-mcp --native-host
|
|
79
|
-
codex-control-chrome-mcp install-native-host [--bin /path/to/codex-control-chrome-mcp] [--no-proxy]
|
|
80
|
-
codex-control-chrome-mcp uninstall-native-host [--force]
|
|
81
|
-
codex-control-chrome-mcp status
|
|
133
|
+
codex-control-chrome-mcp install-native-host [--bin /path/to/codex-control-chrome-mcp] [--no-proxy] [--browser <chrome|edge|all>]
|
|
134
|
+
codex-control-chrome-mcp uninstall-native-host [--force] [--browser <chrome|edge|all>]
|
|
135
|
+
codex-control-chrome-mcp status [--browser <chrome|edge|all>]
|
|
82
136
|
codex-control-chrome-mcp probe-socket <socket-path>
|
|
83
137
|
|
|
84
138
|
Notes:
|
|
85
139
|
--stdio is the MCP server mode for Agent tools.
|
|
86
|
-
--native-host is normally launched by Chrome through Native Messaging.
|
|
140
|
+
--native-host is normally launched by Chrome/Edge through Native Messaging.
|
|
87
141
|
install-native-host without --bin writes a stable launcher that runs npx.
|
|
142
|
+
--browser selects a browser. Without it, install/uninstall auto-detect the
|
|
143
|
+
browsers present (or previously installed) and status reports both.
|
|
88
144
|
`);
|
|
89
145
|
}
|
package/src/constants.js
CHANGED
|
@@ -10,38 +10,61 @@ export const APP_STATE_DIR = path.join(os.homedir(), ".codex-control-chrome-mcp"
|
|
|
10
10
|
export const APP_CONFIG_PATH = path.join(APP_STATE_DIR, "config.json");
|
|
11
11
|
export const INSTALL_STATE_PATH = path.join(APP_STATE_DIR, "install-state.json");
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
13
|
+
// Chromium browsers that ship the Codex extension and use the same Native
|
|
14
|
+
// Messaging mechanism. Edge installs Chrome Web Store extensions under the
|
|
15
|
+
// same `chrome-extension://<id>` origin, so one manifest works for both.
|
|
16
|
+
export const DEFAULT_BROWSER = "chrome";
|
|
17
|
+
export const SUPPORTED_BROWSERS = ["chrome", "edge"];
|
|
18
|
+
|
|
19
|
+
// Per-platform location of each browser's user profile directory. The Native
|
|
20
|
+
// Messaging host manifest lives in a `NativeMessagingHosts` subdirectory, and
|
|
21
|
+
// the profile directory's existence is our "is this browser installed" signal.
|
|
22
|
+
// (On Windows native hosts are registered through the registry, not a file in
|
|
23
|
+
// this directory, which is why Windows is unsupported for now.)
|
|
24
|
+
const BROWSER_PROFILE_SEGMENTS = {
|
|
25
|
+
chrome: {
|
|
26
|
+
darwin: ["Library", "Application Support", "Google", "Chrome"],
|
|
27
|
+
linux: [".config", "google-chrome"],
|
|
28
|
+
win32: ["AppData", "Local", "Google", "Chrome"],
|
|
29
|
+
},
|
|
30
|
+
edge: {
|
|
31
|
+
darwin: ["Library", "Application Support", "Microsoft Edge"],
|
|
32
|
+
linux: [".config", "microsoft-edge"],
|
|
33
|
+
win32: ["AppData", "Local", "Microsoft", "Edge"],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function browserProfileDir(browser = DEFAULT_BROWSER) {
|
|
38
|
+
const perPlatform = BROWSER_PROFILE_SEGMENTS[browser];
|
|
39
|
+
if (!perPlatform) {
|
|
40
|
+
throw new Error(`Unsupported browser: ${browser}`);
|
|
41
|
+
}
|
|
42
|
+
const segments = perPlatform[process.platform];
|
|
43
|
+
if (!segments) {
|
|
44
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
46
45
|
}
|
|
46
|
+
return path.join(os.homedir(), ...segments);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function nativeHostManifestPath(browser = DEFAULT_BROWSER) {
|
|
50
|
+
return path.join(
|
|
51
|
+
browserProfileDir(browser),
|
|
52
|
+
"NativeMessagingHosts",
|
|
53
|
+
`${CODEX_NATIVE_HOST_NAME}.json`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Chrome keeps the original unsuffixed state filenames so installs from before
|
|
58
|
+
// multi-browser support remain valid; other browsers get a per-browser suffix
|
|
59
|
+
// so a second install never clobbers the first one's restore/self-heal metadata.
|
|
60
|
+
export function configPathFor(browser = DEFAULT_BROWSER) {
|
|
61
|
+
return browser === DEFAULT_BROWSER
|
|
62
|
+
? APP_CONFIG_PATH
|
|
63
|
+
: path.join(APP_STATE_DIR, `config.${browser}.json`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function installStatePathFor(browser = DEFAULT_BROWSER) {
|
|
67
|
+
return browser === DEFAULT_BROWSER
|
|
68
|
+
? INSTALL_STATE_PATH
|
|
69
|
+
: path.join(APP_STATE_DIR, `install-state.${browser}.json`);
|
|
47
70
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { APP_STATE_DIR, CODEX_EXTENSION_ID, CODEX_NATIVE_HOST_NAME } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
// Description we stamp on manifests we own. Used as one (not the only) signal.
|
|
7
|
+
export const MANIFEST_DESCRIPTION = "Codex Control Chrome MCP native messaging host";
|
|
8
|
+
|
|
9
|
+
/** A manifest this project wrote (description marker, or path inside our state dir). */
|
|
10
|
+
export function isProjectManifest(manifest) {
|
|
11
|
+
if (!manifest || typeof manifest !== "object") return false;
|
|
12
|
+
if (manifest.description === MANIFEST_DESCRIPTION) return true;
|
|
13
|
+
return isOurLauncherPath(manifest.path);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when a host path points at a launcher we generated under APP_STATE_DIR. */
|
|
17
|
+
export function isOurLauncherPath(hostPath) {
|
|
18
|
+
if (typeof hostPath !== "string" || hostPath.length === 0) return false;
|
|
19
|
+
const resolved = path.resolve(hostPath);
|
|
20
|
+
const stateRoot = path.resolve(APP_STATE_DIR) + path.sep;
|
|
21
|
+
return resolved === path.resolve(APP_STATE_DIR) || resolved.startsWith(stateRoot);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Positive identification of OpenAI's genuine Codex native-host *manifest*,
|
|
26
|
+
* using stable identifiers (host name + the official extension id) rather than
|
|
27
|
+
* the binary filename (which OpenAI has renamed across releases).
|
|
28
|
+
*/
|
|
29
|
+
export function isOfficialCodexManifest(manifest) {
|
|
30
|
+
if (!manifest || typeof manifest !== "object") return false;
|
|
31
|
+
if (isProjectManifest(manifest)) return false;
|
|
32
|
+
if (manifest.name !== CODEX_NATIVE_HOST_NAME) return false;
|
|
33
|
+
const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : [];
|
|
34
|
+
return origins.some((origin) => originMatchesCodexExtension(origin));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function originMatchesCodexExtension(origin) {
|
|
38
|
+
return typeof origin === "string" && origin.includes(`chrome-extension://${CODEX_EXTENSION_ID}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stable directory-structure signature of the bundled Codex host (filename-agnostic). */
|
|
42
|
+
export function hostPathLooksOfficial(hostPath) {
|
|
43
|
+
if (typeof hostPath !== "string" || hostPath.length === 0) return false;
|
|
44
|
+
const normalized = path.resolve(hostPath).split(path.sep).join("/").toLowerCase();
|
|
45
|
+
return normalized.includes("/openai-bundled/") && normalized.includes("/extension-host/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAGIC = {
|
|
49
|
+
"7f454c46": "elf", // ELF
|
|
50
|
+
feedface: "macho", // Mach-O 32 LE
|
|
51
|
+
feedfacf: "macho", // Mach-O 64 LE
|
|
52
|
+
cefaedfe: "macho", // Mach-O 32 BE
|
|
53
|
+
cffaedfe: "macho", // Mach-O 64 BE
|
|
54
|
+
cafebabe: "macho", // universal (fat) — also Java .class, acceptable for a host binary
|
|
55
|
+
bebafeca: "macho",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Inspect the host file: does it exist, is it a compiled binary or a shell script? */
|
|
59
|
+
export async function inspectHostBinary(hostPath) {
|
|
60
|
+
if (typeof hostPath !== "string" || hostPath.length === 0) {
|
|
61
|
+
return { exists: false, kind: "missing", executable: false };
|
|
62
|
+
}
|
|
63
|
+
let handle;
|
|
64
|
+
try {
|
|
65
|
+
const stat = await fs.stat(hostPath);
|
|
66
|
+
if (!stat.isFile()) return { exists: true, kind: "unknown", executable: false };
|
|
67
|
+
handle = await fs.open(hostPath, "r");
|
|
68
|
+
const buf = Buffer.alloc(4);
|
|
69
|
+
const { bytesRead } = await handle.read(buf, 0, 4, 0);
|
|
70
|
+
const head = buf.subarray(0, bytesRead);
|
|
71
|
+
let kind = "unknown";
|
|
72
|
+
if (head.length >= 2 && head[0] === 0x23 && head[1] === 0x21) kind = "script"; // #!
|
|
73
|
+
else if (head.length === 4 && MAGIC[head.toString("hex")]) kind = MAGIC[head.toString("hex")];
|
|
74
|
+
else if (head.length >= 2 && head[0] === 0x4d && head[1] === 0x5a) kind = "pe"; // MZ
|
|
75
|
+
return { exists: true, kind, executable: (stat.mode & 0o111) !== 0 };
|
|
76
|
+
} catch {
|
|
77
|
+
return { exists: false, kind: "missing", executable: false };
|
|
78
|
+
} finally {
|
|
79
|
+
await handle?.close().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Best-effort macOS code-signature check; the strongest OpenAI signal when available. */
|
|
84
|
+
export function verifyOpenAiCodesign(hostPath) {
|
|
85
|
+
if (process.platform !== "darwin" || typeof hostPath !== "string" || !hostPath) {
|
|
86
|
+
return Promise.resolve({ available: false });
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
execFile("codesign", ["-dvvv", hostPath], { timeout: 4000 }, (error, stdout, stderr) => {
|
|
90
|
+
const out = `${stdout || ""}${stderr || ""}`; // codesign -d writes to stderr
|
|
91
|
+
if (!out) return resolve({ available: false, error: error?.message });
|
|
92
|
+
const teamId = (out.match(/TeamIdentifier=(\S+)/) || [])[1] || null;
|
|
93
|
+
const authority = (out.match(/Authority=([^\r\n]+)/) || [])[1] || null;
|
|
94
|
+
const openai = /openai/i.test(out);
|
|
95
|
+
resolve({ available: true, signed: Boolean(authority), openai, teamId, authority });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Full classification of a (manifest, hostPath) pair against stable signals.
|
|
102
|
+
* `trustedOfficial` means: looks like OpenAI's real host and is safe to back up /
|
|
103
|
+
* proxy to. We require the manifest-level identity plus at least one host-level
|
|
104
|
+
* signal (bundle path, compiled binary, or an OpenAI code signature).
|
|
105
|
+
*/
|
|
106
|
+
export async function classifyHost({ manifest, hostPath } = {}) {
|
|
107
|
+
const resolvedPath = hostPath ?? manifest?.path ?? null;
|
|
108
|
+
const ours = isProjectManifest(manifest) || isOurLauncherPath(resolvedPath);
|
|
109
|
+
const officialManifest = isOfficialCodexManifest(manifest);
|
|
110
|
+
const pathOfficial = hostPathLooksOfficial(resolvedPath);
|
|
111
|
+
const binary = await inspectHostBinary(resolvedPath);
|
|
112
|
+
const codesign = await verifyOpenAiCodesign(resolvedPath);
|
|
113
|
+
|
|
114
|
+
const hostSignal = pathOfficial || codesign.openai === true || binary.kind === "macho" || binary.kind === "elf";
|
|
115
|
+
const trustedOfficial = !ours && officialManifest && hostSignal;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
ours,
|
|
119
|
+
officialManifest,
|
|
120
|
+
pathOfficial,
|
|
121
|
+
binary,
|
|
122
|
+
codesign,
|
|
123
|
+
trustedOfficial,
|
|
124
|
+
kind: ours ? "ours" : trustedOfficial ? "official-openai" : "unknown",
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/installer.js
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
APP_CONFIG_PATH,
|
|
5
4
|
APP_STATE_DIR,
|
|
6
5
|
CODEX_EXTENSION_ID,
|
|
7
6
|
CODEX_NATIVE_HOST_NAME,
|
|
8
|
-
|
|
7
|
+
DEFAULT_BROWSER,
|
|
8
|
+
SUPPORTED_BROWSERS,
|
|
9
|
+
browserProfileDir,
|
|
10
|
+
configPathFor,
|
|
11
|
+
installStatePathFor,
|
|
9
12
|
nativeHostManifestPath,
|
|
10
13
|
} from "./constants.js";
|
|
14
|
+
import {
|
|
15
|
+
MANIFEST_DESCRIPTION,
|
|
16
|
+
classifyHost,
|
|
17
|
+
inspectHostBinary,
|
|
18
|
+
isOfficialCodexManifest,
|
|
19
|
+
isOurLauncherPath,
|
|
20
|
+
isProjectManifest,
|
|
21
|
+
} from "./host-identity.js";
|
|
11
22
|
import { PACKAGE_NAME, PACKAGE_VERSION } from "./package-meta.js";
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
export async function installNativeHost({
|
|
25
|
+
binPath,
|
|
26
|
+
proxy = true,
|
|
27
|
+
browser = DEFAULT_BROWSER,
|
|
28
|
+
paths = defaultInstallPaths(browser),
|
|
29
|
+
} = {}) {
|
|
16
30
|
const { manifestPath, appStateDir, configPath, installStatePath } = paths;
|
|
31
|
+
const browserId = paths.browser ?? browser;
|
|
17
32
|
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
18
33
|
await fs.mkdir(appStateDir, { recursive: true });
|
|
19
34
|
|
|
20
35
|
const hostPath = binPath
|
|
21
|
-
? await createLocalLauncher(path.resolve(binPath), appStateDir)
|
|
22
|
-
: await createNpxLauncher(appStateDir);
|
|
36
|
+
? await createLocalLauncher(path.resolve(binPath), appStateDir, browserId)
|
|
37
|
+
: await createNpxLauncher(appStateDir, browserId);
|
|
23
38
|
const existing = await readJsonIfExists(manifestPath);
|
|
24
39
|
const previousState = await readJsonIfExists(installStatePath);
|
|
25
40
|
const previousBackup = previousState?.backupPath
|
|
@@ -34,23 +49,9 @@ export async function installNativeHost({ binPath, proxy = true, paths = default
|
|
|
34
49
|
backupPath = previousState.backupPath;
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} else if (
|
|
41
|
-
proxy &&
|
|
42
|
-
typeof previousState?.officialHostPath === "string" &&
|
|
43
|
-
path.resolve(previousState.officialHostPath) !== path.resolve(hostPath)
|
|
44
|
-
) {
|
|
45
|
-
officialHostPath = previousState.officialHostPath;
|
|
46
|
-
} else if (
|
|
47
|
-
proxy &&
|
|
48
|
-
existing?.path &&
|
|
49
|
-
!existingIsOurs &&
|
|
50
|
-
path.resolve(existing.path) !== path.resolve(hostPath)
|
|
51
|
-
) {
|
|
52
|
-
officialHostPath = existing.path;
|
|
53
|
-
}
|
|
52
|
+
const officialHostPath = proxy
|
|
53
|
+
? await resolveOfficialHostPath({ existing, existingIsOurs, previousState, previousBackup, hostPath })
|
|
54
|
+
: null;
|
|
54
55
|
|
|
55
56
|
const manifest = {
|
|
56
57
|
allowed_origins: [`chrome-extension://${CODEX_EXTENSION_ID}/`],
|
|
@@ -63,8 +64,10 @@ export async function installNativeHost({ binPath, proxy = true, paths = default
|
|
|
63
64
|
|
|
64
65
|
const config = {
|
|
65
66
|
installedAt: new Date().toISOString(),
|
|
67
|
+
browser: browserId,
|
|
66
68
|
manifestPath,
|
|
67
69
|
hostPath,
|
|
70
|
+
binPath: binPath ? path.resolve(binPath) : null,
|
|
68
71
|
launchMode: binPath ? "direct-bin" : "npx-launcher",
|
|
69
72
|
backupPath,
|
|
70
73
|
officialHostPath,
|
|
@@ -75,7 +78,90 @@ export async function installNativeHost({ binPath, proxy = true, paths = default
|
|
|
75
78
|
return config;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the genuine OpenAI host to proxy to. Authority order:
|
|
83
|
+
* 1. the live manifest, when it is verifiably OpenAI's (most up to date),
|
|
84
|
+
* 2. the previously recorded official host,
|
|
85
|
+
* 3. the backed-up manifest's path.
|
|
86
|
+
* Self-references and our own launcher are dropped, and a candidate whose file
|
|
87
|
+
* still exists is preferred so a renamed/removed host (e.g. after a Codex
|
|
88
|
+
* update) never gets recorded as the official target.
|
|
89
|
+
*/
|
|
90
|
+
async function resolveOfficialHostPath({ existing, existingIsOurs, previousState, previousBackup, hostPath }) {
|
|
91
|
+
const candidates = [];
|
|
92
|
+
if (existing?.path && !existingIsOurs && isOfficialCodexManifest(existing)) {
|
|
93
|
+
candidates.push(existing.path);
|
|
94
|
+
}
|
|
95
|
+
if (typeof previousState?.officialHostPath === "string") {
|
|
96
|
+
candidates.push(previousState.officialHostPath);
|
|
97
|
+
}
|
|
98
|
+
if (previousBackup?.path && !isProjectManifest(previousBackup)) {
|
|
99
|
+
candidates.push(previousBackup.path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const unique = [];
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
const resolved = path.resolve(candidate);
|
|
106
|
+
if (resolved === path.resolve(hostPath) || isOurLauncherPath(candidate) || seen.has(resolved)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
seen.add(resolved);
|
|
110
|
+
unique.push(candidate);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const candidate of unique) {
|
|
114
|
+
if ((await inspectHostBinary(candidate)).exists) return candidate;
|
|
115
|
+
}
|
|
116
|
+
// None of the candidates exist on disk; fall back to the most authoritative
|
|
117
|
+
// one (the live official manifest) for diagnostics rather than inventing null.
|
|
118
|
+
return unique[0] ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Idempotently re-assert a previously-established install. Used on stdio
|
|
123
|
+
* startup so a Codex update that reclaims the manifest self-heals without a
|
|
124
|
+
* manual `install-native-host`. Never bootstraps a fresh install silently.
|
|
125
|
+
*/
|
|
126
|
+
export async function ensureNativeHostRegistered({
|
|
127
|
+
browser = DEFAULT_BROWSER,
|
|
128
|
+
paths = defaultInstallPaths(browser),
|
|
129
|
+
} = {}) {
|
|
130
|
+
const { manifestPath, installStatePath } = paths;
|
|
131
|
+
const state = await readJsonIfExists(installStatePath);
|
|
132
|
+
if (!state || typeof state.hostPath !== "string") {
|
|
133
|
+
return { action: "skip", reason: "not-installed" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const manifest = await readJsonIfExists(manifestPath);
|
|
137
|
+
const pointsToUs =
|
|
138
|
+
isProjectManifest(manifest) &&
|
|
139
|
+
typeof manifest?.path === "string" &&
|
|
140
|
+
path.resolve(manifest.path) === path.resolve(state.hostPath);
|
|
141
|
+
const launcherExists = (await inspectHostBinary(state.hostPath)).exists;
|
|
142
|
+
if (pointsToUs && launcherExists) {
|
|
143
|
+
return { action: "ok", reason: "already-registered" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = await installNativeHost({
|
|
147
|
+
binPath: state.launchMode === "direct-bin" ? state.binPath ?? undefined : undefined,
|
|
148
|
+
proxy: state.proxy !== false,
|
|
149
|
+
paths,
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
action: "re-registered",
|
|
153
|
+
reason: launcherExists ? "manifest-reverted" : "launcher-missing",
|
|
154
|
+
manifestPath,
|
|
155
|
+
hostPath: result.hostPath,
|
|
156
|
+
officialHostPath: result.officialHostPath,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function uninstallNativeHost({
|
|
161
|
+
force = false,
|
|
162
|
+
browser = DEFAULT_BROWSER,
|
|
163
|
+
paths = defaultInstallPaths(browser),
|
|
164
|
+
} = {}) {
|
|
79
165
|
const { manifestPath, installStatePath } = paths;
|
|
80
166
|
const state = await readJsonIfExists(installStatePath);
|
|
81
167
|
const manifest = await readJsonIfExists(manifestPath);
|
|
@@ -99,19 +185,60 @@ export async function uninstallNativeHost({ force = false, paths = defaultInstal
|
|
|
99
185
|
return { ok: true, action: "removed", manifestPath };
|
|
100
186
|
}
|
|
101
187
|
|
|
102
|
-
export async function nativeHostInstallStatus(
|
|
103
|
-
|
|
188
|
+
export async function nativeHostInstallStatus({
|
|
189
|
+
browser = DEFAULT_BROWSER,
|
|
190
|
+
paths = defaultInstallPaths(browser),
|
|
191
|
+
} = {}) {
|
|
192
|
+
const { manifestPath, installStatePath } = paths;
|
|
104
193
|
const manifest = await readJsonIfExists(manifestPath);
|
|
105
194
|
const state = await readJsonIfExists(installStatePath);
|
|
106
|
-
|
|
195
|
+
const classification = await classifyHost({ manifest, hostPath: manifest?.path });
|
|
196
|
+
const registered =
|
|
197
|
+
isProjectManifest(manifest) &&
|
|
198
|
+
typeof state?.hostPath === "string" &&
|
|
199
|
+
typeof manifest?.path === "string" &&
|
|
200
|
+
path.resolve(manifest.path) === path.resolve(state.hostPath);
|
|
201
|
+
return { browser: paths.browser ?? browser, manifestPath, manifest, state, registered, classification };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Browsers whose user profile directory exists on this machine (install targets). */
|
|
205
|
+
export async function detectInstalledBrowsers() {
|
|
206
|
+
const present = [];
|
|
207
|
+
for (const browser of SUPPORTED_BROWSERS) {
|
|
208
|
+
let dir;
|
|
209
|
+
try {
|
|
210
|
+
dir = browserProfileDir(browser);
|
|
211
|
+
} catch {
|
|
212
|
+
continue; // unsupported platform for this browser
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
await fs.access(dir);
|
|
216
|
+
present.push(browser);
|
|
217
|
+
} catch {
|
|
218
|
+
// profile directory absent -> browser not installed (or never launched)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return present;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Browsers we have previously installed for (an install-state file exists). */
|
|
225
|
+
export async function detectManagedBrowsers() {
|
|
226
|
+
const managed = [];
|
|
227
|
+
for (const browser of SUPPORTED_BROWSERS) {
|
|
228
|
+
if (await readJsonIfExists(installStatePathFor(browser))) {
|
|
229
|
+
managed.push(browser);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return managed;
|
|
107
233
|
}
|
|
108
234
|
|
|
109
|
-
function defaultInstallPaths() {
|
|
235
|
+
function defaultInstallPaths(browser = DEFAULT_BROWSER) {
|
|
110
236
|
return {
|
|
111
|
-
|
|
237
|
+
browser,
|
|
238
|
+
manifestPath: nativeHostManifestPath(browser),
|
|
112
239
|
appStateDir: APP_STATE_DIR,
|
|
113
|
-
configPath:
|
|
114
|
-
installStatePath:
|
|
240
|
+
configPath: configPathFor(browser),
|
|
241
|
+
installStatePath: installStatePathFor(browser),
|
|
115
242
|
};
|
|
116
243
|
}
|
|
117
244
|
|
|
@@ -125,33 +252,36 @@ async function backupManifest(manifestPath) {
|
|
|
125
252
|
return backupPath;
|
|
126
253
|
}
|
|
127
254
|
|
|
128
|
-
|
|
255
|
+
// Each browser gets its own launcher so the native host knows which browser's
|
|
256
|
+
// config to read for proxy mode. Chrome keeps the original unsuffixed filename
|
|
257
|
+
// so manifests written before multi-browser support still resolve.
|
|
258
|
+
function launcherFileName(base, browser) {
|
|
259
|
+
return browser === DEFAULT_BROWSER ? base : `${base}-${browser}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function createNpxLauncher(appStateDir, browser = DEFAULT_BROWSER) {
|
|
129
263
|
if (process.platform === "win32") {
|
|
130
264
|
throw new Error("NPX native host launcher installation is not implemented for Windows yet.");
|
|
131
265
|
}
|
|
132
|
-
const launcherPath = path.join(appStateDir, "native-host-launcher");
|
|
266
|
+
const launcherPath = path.join(appStateDir, launcherFileName("native-host-launcher", browser));
|
|
133
267
|
const script = `#!/usr/bin/env sh
|
|
134
|
-
exec npx -y ${PACKAGE_NAME}@${PACKAGE_VERSION} --native-host "$@"
|
|
268
|
+
exec npx -y ${PACKAGE_NAME}@${PACKAGE_VERSION} --native-host --browser ${browser} "$@"
|
|
135
269
|
`;
|
|
136
270
|
await fs.writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
|
|
137
271
|
await fs.chmod(launcherPath, 0o755);
|
|
138
272
|
return launcherPath;
|
|
139
273
|
}
|
|
140
274
|
|
|
141
|
-
function
|
|
142
|
-
return manifest?.description === MANIFEST_DESCRIPTION;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function createLocalLauncher(binPath, appStateDir) {
|
|
275
|
+
async function createLocalLauncher(binPath, appStateDir, browser = DEFAULT_BROWSER) {
|
|
146
276
|
if (process.platform === "win32") {
|
|
147
277
|
return binPath;
|
|
148
278
|
}
|
|
149
279
|
if (!binPath.endsWith(".js")) {
|
|
150
280
|
return binPath;
|
|
151
281
|
}
|
|
152
|
-
const launcherPath = path.join(appStateDir, "native-host-local-launcher");
|
|
282
|
+
const launcherPath = path.join(appStateDir, launcherFileName("native-host-local-launcher", browser));
|
|
153
283
|
const script = `#!/usr/bin/env sh
|
|
154
|
-
exec ${shellQuote(process.execPath)} ${shellQuote(binPath)} "$@"
|
|
284
|
+
exec ${shellQuote(process.execPath)} ${shellQuote(binPath)} --browser ${browser} "$@"
|
|
155
285
|
`;
|
|
156
286
|
await fs.writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
|
|
157
287
|
await fs.chmod(launcherPath, 0o755);
|
package/src/mcp-server.js
CHANGED
|
@@ -3,10 +3,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { connectBridge, listBridgeSockets } from "./bridge-client.js";
|
|
6
|
-
import { nativeHostManifestPath } from "./constants.js";
|
|
6
|
+
import { SUPPORTED_BROWSERS, nativeHostManifestPath } from "./constants.js";
|
|
7
|
+
import { detectManagedBrowsers, ensureNativeHostRegistered } from "./installer.js";
|
|
7
8
|
import { PACKAGE_NAME, PACKAGE_VERSION } from "./package-meta.js";
|
|
8
9
|
|
|
9
10
|
export async function runMcpServer() {
|
|
11
|
+
await autoRegisterNativeHost();
|
|
10
12
|
const runtime = new ChromeUseRuntime();
|
|
11
13
|
const server = new McpServer({
|
|
12
14
|
name: PACKAGE_NAME,
|
|
@@ -19,6 +21,45 @@ export async function runMcpServer() {
|
|
|
19
21
|
await server.connect(transport);
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* On stdio startup, re-assert our native-host manifest if a Codex update (or
|
|
26
|
+
* anything else) reverted it. Best-effort and silent: it only logs to stderr
|
|
27
|
+
* (stdout is the MCP JSON-RPC channel) and never blocks server startup.
|
|
28
|
+
* Opt out with CODEX_CONTROL_CHROME_NO_AUTO_REGISTER=1.
|
|
29
|
+
*
|
|
30
|
+
* Note: re-asserting the manifest only takes effect on the Chrome extension's
|
|
31
|
+
* next Native Messaging connection (reload the extension or restart Chrome).
|
|
32
|
+
*/
|
|
33
|
+
async function autoRegisterNativeHost() {
|
|
34
|
+
if (process.env.CODEX_CONTROL_CHROME_NO_AUTO_REGISTER === "1") return;
|
|
35
|
+
let browsers = [];
|
|
36
|
+
try {
|
|
37
|
+
browsers = await detectManagedBrowsers();
|
|
38
|
+
} catch {
|
|
39
|
+
browsers = [];
|
|
40
|
+
}
|
|
41
|
+
for (const browser of browsers) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await ensureNativeHostRegistered({ browser });
|
|
44
|
+
if (result.action === "re-registered") {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`[codex-control-chrome-mcp] native host manifest for ${browser} was ${result.reason}; ` +
|
|
47
|
+
`re-registered it. Reload the Codex extension (or restart ${browserLabel(browser)}) ` +
|
|
48
|
+
"to reconnect the bridge.\n",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`[codex-control-chrome-mcp] auto-register (${browser}) skipped: ${error?.message ?? error}\n`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function browserLabel(browser) {
|
|
60
|
+
return browser === "edge" ? "Edge" : "Chrome";
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
class ChromeUseRuntime {
|
|
23
64
|
constructor() {
|
|
24
65
|
this.sessionId = `codex-control-chrome-mcp-${crypto.randomUUID()}`;
|
|
@@ -55,11 +96,14 @@ function registerTools(server, runtime) {
|
|
|
55
96
|
server.registerTool(
|
|
56
97
|
"chrome_status",
|
|
57
98
|
{
|
|
58
|
-
description: "Show native host manifest
|
|
99
|
+
description: "Show native host manifest paths and discovered bridge sockets.",
|
|
59
100
|
inputSchema: {},
|
|
60
101
|
},
|
|
61
102
|
async () => toolResult({
|
|
62
103
|
manifestPath: nativeHostManifestPath(),
|
|
104
|
+
manifestPaths: Object.fromEntries(
|
|
105
|
+
SUPPORTED_BROWSERS.map((browser) => [browser, nativeHostManifestPath(browser)]),
|
|
106
|
+
),
|
|
63
107
|
sockets: await listBridgeSockets(),
|
|
64
108
|
}),
|
|
65
109
|
);
|
package/src/native-host.js
CHANGED
|
@@ -3,12 +3,17 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
BRIDGE_SOCKET_DIR,
|
|
8
|
+
BRIDGE_SOCKET_PREFIX,
|
|
9
|
+
DEFAULT_BROWSER,
|
|
10
|
+
configPathFor,
|
|
11
|
+
} from "./constants.js";
|
|
7
12
|
import { LengthPrefixedJsonPeer } from "./framing.js";
|
|
8
13
|
|
|
9
14
|
const LOCAL_ID_PREFIX = "codex-control-chrome-mcp:";
|
|
10
15
|
|
|
11
|
-
export async function runNativeHost({ origin = process.argv[2] } = {}) {
|
|
16
|
+
export async function runNativeHost({ origin = process.argv[2], browser = DEFAULT_BROWSER } = {}) {
|
|
12
17
|
await fs.mkdir(BRIDGE_SOCKET_DIR, { recursive: true });
|
|
13
18
|
const socketPath = path.join(
|
|
14
19
|
BRIDGE_SOCKET_DIR,
|
|
@@ -21,7 +26,7 @@ export async function runNativeHost({ origin = process.argv[2] } = {}) {
|
|
|
21
26
|
name: "chrome-extension",
|
|
22
27
|
});
|
|
23
28
|
|
|
24
|
-
const proxyPeer = await maybeStartOfficialProxy(origin);
|
|
29
|
+
const proxyPeer = await maybeStartOfficialProxy(origin, browser);
|
|
25
30
|
const bridge = new NativeHostBridge({ extensionPeer, proxyPeer });
|
|
26
31
|
await bridge.listen(socketPath);
|
|
27
32
|
|
|
@@ -201,8 +206,8 @@ class NativeHostBridge {
|
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
|
|
204
|
-
async function maybeStartOfficialProxy(origin) {
|
|
205
|
-
const config = await readJsonIfExists(
|
|
209
|
+
async function maybeStartOfficialProxy(origin, browser = DEFAULT_BROWSER) {
|
|
210
|
+
const config = await readJsonIfExists(configPathFor(browser));
|
|
206
211
|
const officialHostPath = config?.officialHostPath;
|
|
207
212
|
if (typeof officialHostPath !== "string" || officialHostPath.length === 0) {
|
|
208
213
|
return null;
|