opencode-codex-control 0.0.0-tegami-trusted-publish-setup → 0.1.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/LICENSE +21 -0
- package/LICENSE-EXECUTOR +21 -0
- package/NOTICE +24 -0
- package/README.md +132 -6
- package/index.ts +1 -0
- package/package.json +54 -2
- package/src/codex/appserver.ts +415 -0
- package/src/codex/install.ts +122 -0
- package/src/codex/permissions.ts +80 -0
- package/src/codex/repl.ts +50 -0
- package/src/controller.ts +86 -0
- package/src/plugin.ts +90 -0
- package/src/tools/chrome.ts +379 -0
- package/src/tools/computer-use.ts +223 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saatvik Arya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/LICENSE-EXECUTOR
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rhys Sullivan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# NOTICE
|
|
2
|
+
|
|
3
|
+
This project is licensed under the MIT License (see `LICENSE`). It includes
|
|
4
|
+
code adapted from a third party, reproduced here with its license.
|
|
5
|
+
|
|
6
|
+
## Executor
|
|
7
|
+
|
|
8
|
+
Portions of this project are derived from **Executor**
|
|
9
|
+
(<https://github.com/RhysSullivan/executor>), which is licensed under the MIT
|
|
10
|
+
License, Copyright (c) 2026 Rhys Sullivan. The full license text is in
|
|
11
|
+
`LICENSE-EXECUTOR`.
|
|
12
|
+
|
|
13
|
+
Specifically, these files adapt Executor's MCP plugin SDK
|
|
14
|
+
(`packages/plugins/mcp/src/sdk/`):
|
|
15
|
+
|
|
16
|
+
| This project | Derived from Executor |
|
|
17
|
+
| ------------------------------- | -------------------------------- |
|
|
18
|
+
| `src/tools/computer-use.ts` | `codex-sky-tools.ts` |
|
|
19
|
+
| `src/tools/chrome.ts` | `codex-browser-tools.ts` |
|
|
20
|
+
| `src/codex/repl.ts` | `codex-repl.ts` |
|
|
21
|
+
| `src/codex/permissions.ts` | `codex-permissions.ts` |
|
|
22
|
+
|
|
23
|
+
The Chrome surface was retargeted to the current Chrome plugin API (the `ax`
|
|
24
|
+
and `playwright` namespaces) rather than Executor's original `dom_cua` surface.
|
package/README.md
CHANGED
|
@@ -1,6 +1,132 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
# opencode-codex-control
|
|
2
|
+
|
|
3
|
+
An [OpenCode](https://opencode.ai) V2 plugin that exposes **Codex Computer Use**
|
|
4
|
+
and **Codex Chrome** as native tools, by bridging Codex's `app-server` JSON-RPC.
|
|
5
|
+
|
|
6
|
+
> [!IMPORTANT]
|
|
7
|
+
> Unofficial. Not affiliated with OpenAI, the Codex team, or the OpenCode team.
|
|
8
|
+
> It drives an existing Codex install on your machine and requires the same
|
|
9
|
+
> plugins and macOS permissions Codex itself needs. Codex Computer Use is
|
|
10
|
+
> macOS-only; Chrome control depends on Codex's bundled Chrome plugin.
|
|
11
|
+
|
|
12
|
+
## Why a plugin and not an MCP server
|
|
13
|
+
|
|
14
|
+
Codex's Computer Use and Chrome plugins are not MCP servers. Computer Use ships
|
|
15
|
+
as a `node-repl` content variant, Chrome ships no server at all, and the
|
|
16
|
+
Computer Use service only honours calls from a Codex-host session. The working
|
|
17
|
+
path is `codex app-server` calling the bundled `node_repl` server's `js` tool,
|
|
18
|
+
which imports `@oai/sky` or Chrome's `browser-client.mjs`.
|
|
19
|
+
|
|
20
|
+
This plugin owns one `codex app-server` connection, compiles each typed tool
|
|
21
|
+
call into the JavaScript program that performs it, and registers the results
|
|
22
|
+
with OpenCode's tool registry. It has no runtime dependencies and spawns
|
|
23
|
+
nothing until the first tool call.
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
- `computer_use.*` — macOS desktop control: `list_apps`, `get_app_state`,
|
|
28
|
+
`click`, `type_text`, `press_key`, `paste`, `scroll`, `drag`, `select_text`,
|
|
29
|
+
`set_value`, `perform_secondary_action`.
|
|
30
|
+
- `chrome.*` — the real Chrome: `list_tabs`, `new_tab`, `navigate`, `page_info`,
|
|
31
|
+
`read_page`, `read_dom`, `click`, `type_text`, `press_key`, `scroll`,
|
|
32
|
+
`set_value`, `select_text`, `perform_secondary_action`, `drag`,
|
|
33
|
+
`find_elements`, `go_back`, `go_forward`, `reload`, `close_tab`,
|
|
34
|
+
`export_content`.
|
|
35
|
+
|
|
36
|
+
Interaction uses the accessibility (`ax`) API and `playwright` for reads; the
|
|
37
|
+
`dom_cua`/`cua` namespaces are filtered out on Chrome's extension backend.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- OpenCode V2 (`opencode2`).
|
|
42
|
+
- The `codex` CLI on `PATH` (or set `codexCli`).
|
|
43
|
+
- Computer Use: the shared "Codex Computer Use" app under `$CODEX_HOME`.
|
|
44
|
+
- Chrome: Codex's bundled Chrome plugin and the ChatGPT browser extension, used
|
|
45
|
+
once inside Codex.
|
|
46
|
+
|
|
47
|
+
Each tool reports an ordered setup path when its surface is not installed.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
From npm, in `~/.config/opencode/opencode.jsonc`:
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
{
|
|
55
|
+
"$schema": "https://opencode.ai/config.json",
|
|
56
|
+
"plugins": ["opencode-codex-control"]
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
From a local checkout (useful while developing):
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
"plugins": ["/absolute/path/to/opencode-codex-control"]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
```jsonc
|
|
71
|
+
{
|
|
72
|
+
"plugins": [
|
|
73
|
+
{
|
|
74
|
+
"package": "opencode-codex-control",
|
|
75
|
+
"options": {
|
|
76
|
+
"codexHome": "~/.codex",
|
|
77
|
+
"codexCli": "codex",
|
|
78
|
+
"computerUse": true,
|
|
79
|
+
"chrome": true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Permissions and approvals
|
|
87
|
+
|
|
88
|
+
Every tool carries `options.permission` (`computer_use` or `chrome`), so the
|
|
89
|
+
OpenCode permission layer is the consent boundary. To require a prompt for
|
|
90
|
+
either surface:
|
|
91
|
+
|
|
92
|
+
```jsonc
|
|
93
|
+
{
|
|
94
|
+
"permissions": [{ "action": "chrome", "resource": "*", "effect": "ask" }]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Codex's own approval prompts (for example Chrome's per-site access) arrive as
|
|
99
|
+
`mcpServer/elicitation/request`. Because the OpenCode tool call has already been
|
|
100
|
+
allowed, the plugin answers them automatically and logs the origin as
|
|
101
|
+
`[codex-control] Codex approval accept (…)`. Treat this as coarse consent for
|
|
102
|
+
now; finer-grained grants are a follow-up.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
bun install
|
|
108
|
+
bun run typecheck
|
|
109
|
+
bun test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`bun run smoke` exercises the real bridge end to end (it needs a working Codex
|
|
113
|
+
install and will launch apps or open browser tabs), so it is not part of CI.
|
|
114
|
+
|
|
115
|
+
## Layout
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
index.ts # entrypoint the OpenCode loader imports
|
|
119
|
+
src/plugin.ts # plugin definition + tool registration
|
|
120
|
+
src/controller.ts # connection lifecycle; call -> result
|
|
121
|
+
src/codex/appserver.ts # codex app-server child + JSON-RPC client
|
|
122
|
+
src/codex/install.ts # CLI/install discovery
|
|
123
|
+
src/codex/permissions.ts # macOS TCC denial -> setup instructions
|
|
124
|
+
src/codex/repl.ts # safe argument encoding + JSON result wrapper
|
|
125
|
+
src/tools/computer-use.ts
|
|
126
|
+
src/tools/chrome.ts
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License and attribution
|
|
130
|
+
|
|
131
|
+
MIT. Portions are adapted from [Executor](https://github.com/RhysSullivan/executor)
|
|
132
|
+
(MIT, Copyright (c) 2026 Rhys Sullivan) — see `NOTICE` and `LICENSE-EXECUTOR`.
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/plugin";
|
package/package.json
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "opencode-codex-control",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OpenCode plugin that exposes Codex Computer Use and Chrome as native tools",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Saatvik Arya",
|
|
9
|
+
"homepage": "https://github.com/aryasaatvik/opencode-codex-control#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/aryasaatvik/opencode-codex-control.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/aryasaatvik/opencode-codex-control/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"codex",
|
|
21
|
+
"computer-use",
|
|
22
|
+
"chrome",
|
|
23
|
+
"desktop-automation",
|
|
24
|
+
"browser-automation"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./index.ts"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.ts",
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"LICENSE-EXECUTOR",
|
|
35
|
+
"NOTICE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "bun test",
|
|
44
|
+
"smoke": "bun scripts/smoke.ts",
|
|
45
|
+
"tegami": "bun scripts/tegami.mts",
|
|
46
|
+
"version:packages": "bun run tegami version",
|
|
47
|
+
"release:check": "bun run typecheck && bun test",
|
|
48
|
+
"release": "bun run release:check && bun run tegami publish"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@opencode/plugin": "0.0.0-beta-19425",
|
|
52
|
+
"@types/bun": "1.3.9",
|
|
53
|
+
"@types/node": "24.12.2",
|
|
54
|
+
"tegami": "1.4.0",
|
|
55
|
+
"typescript": "5.8.2"
|
|
56
|
+
}
|
|
5
57
|
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// Codex app-server bridge.
|
|
2
|
+
//
|
|
3
|
+
// Computer Use and Chrome are not MCP servers a client can spawn. Since the
|
|
4
|
+
// 2026-08-28 Codex update the Computer Use service only honours calls from a
|
|
5
|
+
// session registered by a Codex host process, and Chrome ships no server at
|
|
6
|
+
// all. The supported path is `codex app-server` — Codex's own JSON-RPC front
|
|
7
|
+
// end — whose `mcpServer/tool/call` invokes a plugin tool directly with no
|
|
8
|
+
// model turn and no inference.
|
|
9
|
+
//
|
|
10
|
+
// Both surfaces are driven through the bundled `node_repl` server's `js`
|
|
11
|
+
// tool: the program compiled by `tools/computer-use`/`tools/chrome` runs
|
|
12
|
+
// inside it, imports `@oai/sky` or the Chrome browser client, and writes one
|
|
13
|
+
// JSON value back. Chrome additionally refuses to run without Codex turn
|
|
14
|
+
// metadata, which this bridge supplies because it starts no turns.
|
|
15
|
+
//
|
|
16
|
+
// The wire is newline-delimited JSON managed here. It is NOT MCP-shaped
|
|
17
|
+
// (`_meta: null`, its own notification families), so it cannot go through a
|
|
18
|
+
// generic MCP stdio transport.
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import { permissionFailure, permissionFailureMessage } from "./permissions";
|
|
24
|
+
|
|
25
|
+
export interface ElicitationRequest {
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly mode?: string;
|
|
28
|
+
readonly url?: string;
|
|
29
|
+
readonly meta: Record<string, unknown>;
|
|
30
|
+
readonly requestedSchema?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ElicitationAction = "accept" | "decline" | "cancel";
|
|
34
|
+
|
|
35
|
+
export interface CodexAppServerConfig {
|
|
36
|
+
/** The `codex` CLI to spawn. */
|
|
37
|
+
readonly codexCli: string;
|
|
38
|
+
/** Value for the child's `CODEX_HOME`; omitted leaves the inherited one. */
|
|
39
|
+
readonly codexHome?: string;
|
|
40
|
+
/**
|
|
41
|
+
* How to answer a plugin approval prompt. Defaults to accepting: the
|
|
42
|
+
* OpenCode tool call the user already allowed is the consent boundary, so
|
|
43
|
+
* Codex's own prompt is redundant here.
|
|
44
|
+
*/
|
|
45
|
+
readonly onElicitation?: (
|
|
46
|
+
request: ElicitationRequest,
|
|
47
|
+
) => ElicitationAction | Promise<ElicitationAction>;
|
|
48
|
+
/** Diagnostics: startup transitions and other notifications. */
|
|
49
|
+
readonly onLog?: (message: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ToolCallResult {
|
|
53
|
+
/** The joined text content of the tool call. */
|
|
54
|
+
readonly text: string;
|
|
55
|
+
readonly isError: boolean;
|
|
56
|
+
readonly structuredContent?: unknown;
|
|
57
|
+
readonly meta?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Pending {
|
|
61
|
+
resolve: (value: unknown) => void;
|
|
62
|
+
reject: (error: Error) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface RpcMessage {
|
|
66
|
+
id?: number | string;
|
|
67
|
+
method?: string;
|
|
68
|
+
params?: unknown;
|
|
69
|
+
result?: unknown;
|
|
70
|
+
error?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
74
|
+
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
|
75
|
+
|
|
76
|
+
/** `mcpServer/startupStatus/updated` — Codex reports every server it runs. */
|
|
77
|
+
const STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
|
|
78
|
+
|
|
79
|
+
export class CodexAppServer {
|
|
80
|
+
readonly #config: CodexAppServerConfig;
|
|
81
|
+
#child: ReturnType<typeof spawn> | undefined;
|
|
82
|
+
#stdoutBuffer = "";
|
|
83
|
+
#stderrTail = "";
|
|
84
|
+
#nextId = 1;
|
|
85
|
+
#threadId: string | undefined;
|
|
86
|
+
#starting: Promise<void> | undefined;
|
|
87
|
+
#ready = false;
|
|
88
|
+
#closed = false;
|
|
89
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
90
|
+
readonly #pending = new Map<number, Pending>();
|
|
91
|
+
|
|
92
|
+
constructor(config: CodexAppServerConfig) {
|
|
93
|
+
this.#config = config;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get threadId(): string | undefined {
|
|
97
|
+
return this.#threadId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Start and hand-shake on first use; a warm instance is a no-op. */
|
|
101
|
+
async ensureReady(): Promise<void> {
|
|
102
|
+
if (this.#closed) throw new Error("The Codex app-server connection has been closed.");
|
|
103
|
+
if (this.#ready) return;
|
|
104
|
+
this.#starting ??= this.#handshake();
|
|
105
|
+
await this.#starting;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async #handshake(): Promise<void> {
|
|
109
|
+
let child: ReturnType<typeof spawn>;
|
|
110
|
+
try {
|
|
111
|
+
child = spawn(this.#config.codexCli, ["app-server"], {
|
|
112
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
113
|
+
env: {
|
|
114
|
+
...process.env,
|
|
115
|
+
...(this.#config.codexHome === undefined ? {} : { CODEX_HOME: this.#config.codexHome }),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Could not start the Codex app-server with "${this.#config.codexCli}": ${
|
|
121
|
+
error instanceof Error ? error.message : String(error)
|
|
122
|
+
}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
this.#child = child;
|
|
126
|
+
child.stdout?.setEncoding("utf8");
|
|
127
|
+
child.stdout?.on("data", (chunk: string) => this.#onStdout(chunk));
|
|
128
|
+
child.stderr?.setEncoding("utf8");
|
|
129
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
130
|
+
// Keep a short tail so a startup failure can explain itself.
|
|
131
|
+
this.#stderrTail = (this.#stderrTail + chunk).slice(-2000);
|
|
132
|
+
});
|
|
133
|
+
child.stdin?.on("error", () => undefined);
|
|
134
|
+
child.stdout?.on("error", () => undefined);
|
|
135
|
+
child.on("error", (error: Error) => this.#failAll(error));
|
|
136
|
+
child.on("exit", () => this.#failAll(new Error("Codex app-server exited.")));
|
|
137
|
+
|
|
138
|
+
await this.#request(
|
|
139
|
+
"initialize",
|
|
140
|
+
{ clientInfo: { name: "opencode", title: "OpenCode", version: "0.1.0" } },
|
|
141
|
+
INITIALIZE_TIMEOUT_MS,
|
|
142
|
+
);
|
|
143
|
+
this.#notify("initialized", {});
|
|
144
|
+
const started = (await this.#request(
|
|
145
|
+
"thread/start",
|
|
146
|
+
// `approvalPolicy: "on-request"` is load-bearing: on a thread whose
|
|
147
|
+
// policy declines MCP elicitations, Codex answers every plugin prompt
|
|
148
|
+
// itself and never forwards it, surfacing as an unexplained denial on
|
|
149
|
+
// any tool that asks.
|
|
150
|
+
{ sessionStartSource: "startup", approvalPolicy: "on-request" },
|
|
151
|
+
INITIALIZE_TIMEOUT_MS,
|
|
152
|
+
)) as { thread?: { id?: string } } | undefined;
|
|
153
|
+
const threadId = started?.thread?.id;
|
|
154
|
+
if (typeof threadId !== "string") {
|
|
155
|
+
throw new Error("Codex app-server returned no thread id from thread/start.");
|
|
156
|
+
}
|
|
157
|
+
this.#threadId = threadId;
|
|
158
|
+
this.#ready = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Call one tool on one Codex MCP server. */
|
|
162
|
+
async callTool(
|
|
163
|
+
server: string,
|
|
164
|
+
tool: string,
|
|
165
|
+
args: unknown,
|
|
166
|
+
options?: { readonly timeoutMs?: number },
|
|
167
|
+
): Promise<ToolCallResult> {
|
|
168
|
+
await this.ensureReady();
|
|
169
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
170
|
+
return this.#serialize(async () => {
|
|
171
|
+
const reply = await this.#request(
|
|
172
|
+
"mcpServer/tool/call",
|
|
173
|
+
{
|
|
174
|
+
threadId: this.#threadId,
|
|
175
|
+
server,
|
|
176
|
+
tool,
|
|
177
|
+
arguments: args ?? {},
|
|
178
|
+
// Codex stamps a REPL call with the turn that issued it, and the
|
|
179
|
+
// Chrome client refuses to run without it. This bridge starts no
|
|
180
|
+
// turns, so it supplies the same shape: the pooled thread is the
|
|
181
|
+
// session, each call is its own turn.
|
|
182
|
+
_meta: {
|
|
183
|
+
"x-codex-turn-metadata": {
|
|
184
|
+
session_id: this.#threadId,
|
|
185
|
+
turn_id: randomUUID(),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
timeoutMs,
|
|
190
|
+
);
|
|
191
|
+
return normalizeToolCall(reply);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Run one JavaScript program in Codex's `node_repl`. */
|
|
196
|
+
async callJs(program: {
|
|
197
|
+
readonly code: string;
|
|
198
|
+
readonly title: string;
|
|
199
|
+
readonly timeoutMs?: number;
|
|
200
|
+
}): Promise<ToolCallResult> {
|
|
201
|
+
const timeoutMs = program.timeoutMs ?? 30_000;
|
|
202
|
+
return this.callTool(
|
|
203
|
+
"node_repl",
|
|
204
|
+
"js",
|
|
205
|
+
{
|
|
206
|
+
code: program.code,
|
|
207
|
+
title: program.title,
|
|
208
|
+
...(program.timeoutMs === undefined ? {} : { timeout_ms: program.timeoutMs }),
|
|
209
|
+
},
|
|
210
|
+
// Leave headroom under the caller so a slow page fails as a page error
|
|
211
|
+
// rather than an abandoned request.
|
|
212
|
+
{ timeoutMs: timeoutMs + 20_000 },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async close(): Promise<void> {
|
|
217
|
+
this.#closed = true;
|
|
218
|
+
const child = this.#child;
|
|
219
|
+
this.#child = undefined;
|
|
220
|
+
if (child === undefined) return;
|
|
221
|
+
child.stdin?.end();
|
|
222
|
+
child.kill("SIGTERM");
|
|
223
|
+
const escalate = setTimeout(() => child.kill("SIGKILL"), 3000);
|
|
224
|
+
escalate.unref?.();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Serialize calls: one real desktop and one real browser can only be
|
|
228
|
+
* driven by one action at a time, however many OpenCode sessions exist. */
|
|
229
|
+
#serialize<T>(fn: () => Promise<T>): Promise<T> {
|
|
230
|
+
const next = this.#queue.then(fn, fn);
|
|
231
|
+
this.#queue = next.then(
|
|
232
|
+
() => undefined,
|
|
233
|
+
() => undefined,
|
|
234
|
+
);
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#request(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
|
|
239
|
+
const child = this.#child;
|
|
240
|
+
if (child === undefined || this.#closed) {
|
|
241
|
+
return Promise.reject(new Error("Codex app-server is not running."));
|
|
242
|
+
}
|
|
243
|
+
const id = this.#nextId++;
|
|
244
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
this.#pending.delete(id);
|
|
247
|
+
const tail = this.#stderrTail.trim();
|
|
248
|
+
reject(
|
|
249
|
+
new Error(
|
|
250
|
+
`Codex app-server did not answer ${method} within ${timeoutMs}ms.${
|
|
251
|
+
tail.length === 0 ? "" : `\n${tail}`
|
|
252
|
+
}`,
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
}, timeoutMs);
|
|
256
|
+
this.#pending.set(id, {
|
|
257
|
+
resolve: (value) => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
resolve(value);
|
|
260
|
+
},
|
|
261
|
+
reject: (error) => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(error);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
this.#send({ jsonrpc: "2.0", id, method, params });
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#notify(method: string, params: unknown): void {
|
|
271
|
+
this.#send({ jsonrpc: "2.0", method, params });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#send(message: unknown): void {
|
|
275
|
+
this.#child?.stdin?.write(`${JSON.stringify(message)}\n`, () => undefined);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#onStdout(chunk: string): void {
|
|
279
|
+
this.#stdoutBuffer += chunk;
|
|
280
|
+
const lines = this.#stdoutBuffer.split("\n");
|
|
281
|
+
this.#stdoutBuffer = lines.pop() ?? "";
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
const trimmed = line.trim();
|
|
284
|
+
if (trimmed.length === 0) continue;
|
|
285
|
+
let message: RpcMessage;
|
|
286
|
+
try {
|
|
287
|
+
message = JSON.parse(trimmed) as RpcMessage;
|
|
288
|
+
} catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
this.#dispatch(message);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#dispatch(message: RpcMessage): void {
|
|
296
|
+
const isResponse =
|
|
297
|
+
message.method === undefined &&
|
|
298
|
+
typeof message.id === "number" &&
|
|
299
|
+
(message.result !== undefined || message.error !== undefined);
|
|
300
|
+
if (isResponse) {
|
|
301
|
+
const pending = this.#pending.get(message.id as number);
|
|
302
|
+
if (pending === undefined) return;
|
|
303
|
+
this.#pending.delete(message.id as number);
|
|
304
|
+
if (message.error === undefined) {
|
|
305
|
+
pending.resolve(message.result);
|
|
306
|
+
} else {
|
|
307
|
+
pending.reject(new Error(rpcErrorMessage(message.error)));
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (message.method !== undefined && message.id !== undefined) {
|
|
312
|
+
void this.#handleServerRequest(message);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (message.method !== undefined) {
|
|
316
|
+
this.#handleNotification(message.method, message.params);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async #handleServerRequest(message: RpcMessage): Promise<void> {
|
|
321
|
+
if (message.method === "mcpServer/elicitation/request") {
|
|
322
|
+
const action = await this.#answerElicitation(message.params);
|
|
323
|
+
this.#send({ jsonrpc: "2.0", id: message.id, result: { action } });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// The bridge starts no turns, so there is nothing else it can answer.
|
|
327
|
+
this.#send({
|
|
328
|
+
jsonrpc: "2.0",
|
|
329
|
+
id: message.id,
|
|
330
|
+
error: { code: -32601, message: `OpenCode does not handle ${message.method}` },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async #answerElicitation(rawParams: unknown): Promise<ElicitationAction> {
|
|
335
|
+
const params = (rawParams ?? {}) as Record<string, unknown>;
|
|
336
|
+
const meta =
|
|
337
|
+
params["_meta"] !== null && typeof params["_meta"] === "object"
|
|
338
|
+
? (params["_meta"] as Record<string, unknown>)
|
|
339
|
+
: {};
|
|
340
|
+
const connector =
|
|
341
|
+
typeof meta["connector_name"] === "string" ? (meta["connector_name"] as string) : undefined;
|
|
342
|
+
const message =
|
|
343
|
+
typeof params["message"] === "string"
|
|
344
|
+
? (params["message"] as string)
|
|
345
|
+
: `Approve this ${connector ?? "Codex"} request?`;
|
|
346
|
+
const request: ElicitationRequest = {
|
|
347
|
+
message,
|
|
348
|
+
mode: typeof params["mode"] === "string" ? (params["mode"] as string) : undefined,
|
|
349
|
+
url: typeof params["url"] === "string" ? (params["url"] as string) : undefined,
|
|
350
|
+
meta,
|
|
351
|
+
requestedSchema: params["requestedSchema"],
|
|
352
|
+
};
|
|
353
|
+
const decided = await this.#config.onElicitation?.(request);
|
|
354
|
+
this.#config.onLog?.(
|
|
355
|
+
`Codex approval ${decided ?? "accept"}${connector === undefined ? "" : ` (${connector})`}: ${message}`,
|
|
356
|
+
);
|
|
357
|
+
return decided ?? "accept";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#handleNotification(method: string, params: unknown): void {
|
|
361
|
+
if (method !== STARTUP_STATUS_METHOD) return;
|
|
362
|
+
const entry = (params ?? {}) as { name?: unknown; status?: unknown };
|
|
363
|
+
if (typeof entry.name !== "string" || typeof entry.status !== "string") return;
|
|
364
|
+
this.#config.onLog?.(`Codex MCP server ${entry.name} is ${entry.status}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#failAll(error: Error): void {
|
|
368
|
+
this.#ready = false;
|
|
369
|
+
for (const pending of this.#pending.values()) pending.reject(error);
|
|
370
|
+
this.#pending.clear();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const normalizeToolCall = (raw: unknown): ToolCallResult => {
|
|
375
|
+
const result = (raw ?? {}) as {
|
|
376
|
+
content?: unknown;
|
|
377
|
+
structuredContent?: unknown;
|
|
378
|
+
isError?: unknown;
|
|
379
|
+
_meta?: unknown;
|
|
380
|
+
};
|
|
381
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
382
|
+
const text = content
|
|
383
|
+
.map((block) => {
|
|
384
|
+
const b = block as { text?: unknown };
|
|
385
|
+
return typeof b.text === "string" ? b.text : "";
|
|
386
|
+
})
|
|
387
|
+
.filter((value) => value.length > 0)
|
|
388
|
+
.join("\n");
|
|
389
|
+
return {
|
|
390
|
+
text,
|
|
391
|
+
isError: result.isError === true,
|
|
392
|
+
...(result.structuredContent === undefined
|
|
393
|
+
? {}
|
|
394
|
+
: { structuredContent: result.structuredContent }),
|
|
395
|
+
...(result._meta === undefined ? {} : { meta: result._meta }),
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const rpcErrorMessage = (error: unknown): string => {
|
|
400
|
+
const e = (error ?? {}) as { message?: unknown; data?: unknown };
|
|
401
|
+
const message = typeof e.message === "string" ? e.message : JSON.stringify(error);
|
|
402
|
+
return typeof e.data === "string" && e.data.length > 0 ? `${message} (${e.data})` : message;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Throw an actionable error for a failed tool call, translating macOS
|
|
406
|
+
* permission denials into the exact System Settings entry to enable. */
|
|
407
|
+
export const toolCallError = (
|
|
408
|
+
result: ToolCallResult,
|
|
409
|
+
surface: string | undefined,
|
|
410
|
+
): Error | undefined => {
|
|
411
|
+
if (!result.isError) return undefined;
|
|
412
|
+
const text = result.text.length > 0 ? result.text : "The Codex tool call failed.";
|
|
413
|
+
const permission = permissionFailure(text, surface);
|
|
414
|
+
return new Error(permission === null ? text : permissionFailureMessage(permission));
|
|
415
|
+
};
|