codex-webstrapper 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.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 codex-webstrap contributors
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/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # codex-webstrap
2
+
3
+ ![Frosted Sidebar Demo](assets/frosted-sidebar.gif)
4
+
5
+ `codex-webstrap` is a macOS wrapper that lets you run the Codex desktop client UI in a browser while keeping backend execution local.
6
+
7
+ This started as a personal project to remotely access the Codex desktop experience; it is open sourced so others can use and improve it.
8
+
9
+ ## What It Is
10
+
11
+
12
+ https://github.com/user-attachments/assets/24e023ee-6a74-448c-892d-9fc1964bd10c
13
+
14
+
15
+ Codex desktop is not a pure web app. The renderer expects Electron preload APIs, IPC, local process control, worker threads, and desktop-only integrations.
16
+
17
+ `codex-webstrap` makes browser access possible by:
18
+
19
+ 1. Serving Codex's bundled web assets.
20
+ 2. Injecting a browser shim that emulates key `electronBridge` preload methods.
21
+ 3. Bridging renderer messages over WebSocket to local backend handlers.
22
+ 4. Forwarding app protocol traffic to local `codex app-server` and UDS IPC where available.
23
+
24
+ Default endpoint: `http://127.0.0.1:8080`
25
+
26
+ ## Architecture
27
+
28
+ ### Core Components
29
+
30
+ - `bin/codex-webstrap.sh`
31
+ - CLI entrypoint and env/arg normalization.
32
+ - `src/server.mjs`
33
+ - HTTP + WS host, auth gating, startup orchestration.
34
+ - `src/auth.mjs`
35
+ - Persistent token bootstrap + `cw_session` cookie sessions.
36
+ - `src/assets.mjs`
37
+ - Discovers Codex app bundle, extracts/caches `app.asar` assets, patches `index.html`.
38
+ - `src/bridge-shim.js`
39
+ - Browser-side Electron preload compatibility layer (`window.electronBridge`).
40
+ - `src/ipc-uds.mjs`
41
+ - Framed UDS client (`length-prefix + JSON`) for `codex-ipc`.
42
+ - `src/app-server.mjs`
43
+ - Local `codex app-server` process manager over stdio JSON-RPC.
44
+ - `src/message-router.mjs`
45
+ - Message dispatch, terminal lifecycle, worker bridge, unsupported fallbacks.
46
+
47
+ ### Runtime Flow
48
+
49
+ 1. Wrapper starts Node server and loads config.
50
+ 2. Auth token is created/read from token file.
51
+ 3. Codex app assets are extracted to a versioned cache directory.
52
+ 4. Patched `index.html` is served with shim injection.
53
+ 5. Browser opens `/`, shim connects to `ws://<host>/__webstrapper/bridge`.
54
+ 6. Renderer messages are routed to:
55
+ - app-server JSON-RPC (`thread/*`, turns, config, etc.)
56
+ - UDS broadcast forwarding where relevant
57
+ - terminal sessions (`spawn`, `write`, `close`)
58
+ - git worker bridge path
59
+ 7. Results are sent back as bridge envelopes and posted to the renderer via `window.postMessage`.
60
+
61
+ ## Why This Is Not "Native Web"
62
+
63
+ Codex desktop behavior depends on Electron/main-process features unavailable to normal browser JavaScript, including:
64
+
65
+ - preload-only bridge APIs
66
+ - local privileged process orchestration
67
+ - desktop IPC channels
68
+ - local worker/protocol assumptions
69
+
70
+ This project provides near-parity by emulation/bridging, not by removing those dependencies.
71
+
72
+ ## API Surface
73
+
74
+ ### CLI
75
+
76
+ ```bash
77
+ codex-webstrap [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
78
+ ```
79
+
80
+ ### Environment Overrides
81
+
82
+ - `CODEX_WEBSTRAP_PORT`
83
+ - `CODEX_WEBSTRAP_BIND`
84
+ - `CODEX_WEBSTRAP_TOKEN_FILE`
85
+ - `CODEX_WEBSTRAP_CODEX_APP`
86
+ - `CODEX_WEBSTRAP_INTERNAL_WS_PORT`
87
+
88
+ ### HTTP Endpoints
89
+
90
+ - `GET /`
91
+ - `GET /__webstrapper/shim.js`
92
+ - `GET /__webstrapper/healthz`
93
+ - `GET /__webstrapper/auth?token=...`
94
+
95
+ ### WebSocket Endpoint
96
+
97
+ - `GET /__webstrapper/bridge`
98
+
99
+ ### Bridge Envelope Types
100
+
101
+ - `view-message`
102
+ - `main-message`
103
+ - `worker-message`
104
+ - `worker-event`
105
+ - `bridge-error`
106
+ - `bridge-ready`
107
+
108
+ ## Setup
109
+
110
+ ### Prerequisites
111
+
112
+ - macOS
113
+ - Node.js 20+
114
+ - Installed Codex app bundle at `/Applications/Codex.app` (or pass `--codex-app`)
115
+ - `codex` CLI available in `PATH` (or set `CODEX_CLI_PATH`)
116
+
117
+ ### Install
118
+
119
+ ```bash
120
+ npm install
121
+ ```
122
+
123
+ Global CLI install:
124
+
125
+ ```bash
126
+ npm install -g codex-webstrapper
127
+ ```
128
+
129
+ ### Run
130
+
131
+ With global install:
132
+
133
+ ```bash
134
+ codex-webstrap --port 8080 --bind 127.0.0.1
135
+ ```
136
+
137
+ From local checkout:
138
+
139
+ ```bash
140
+ ./bin/codex-webstrap.sh --port 8080 --bind 127.0.0.1
141
+ ```
142
+
143
+ Optional auto-open:
144
+
145
+ ```bash
146
+ codex-webstrap --open
147
+ ```
148
+
149
+ ## Authentication Model
150
+
151
+ 1. On first run, a random token is persisted at `~/.codex-webstrap/token` (default path).
152
+ 2. You authenticate once via:
153
+
154
+ ```bash
155
+ open "http://127.0.0.1:8080/__webstrapper/auth?token=$(cat ~/.codex-webstrap/token)"
156
+ ```
157
+
158
+ 3. Server sets `cw_session` cookie (`HttpOnly`, `SameSite=Lax`, scoped to `/`).
159
+ 4. UI and bridge endpoints require a valid session cookie.
160
+
161
+ ## Security Risks and Recommendations
162
+
163
+ This project can expose powerful local capabilities if misconfigured. Treat it as sensitive software.
164
+
165
+ ### Primary Risks
166
+
167
+ - Remote users with valid session can operate Codex UI features and local workflows.
168
+ - Token bootstrap URL can be leaked via shell history, logs, screenshots, or shared links.
169
+ - Binding to non-local interfaces increases attack surface.
170
+ - No built-in TLS termination. Plain HTTP should not be exposed directly to the public internet.
171
+
172
+ ### Recommended Safe Usage
173
+
174
+ - Keep default bind: `127.0.0.1` unless remote access is required.
175
+ - If remote access is needed, use a private overlay network (for example Tailscale/WireGuard) and not public port-forwarding.
176
+ - Do not share token values in chat, screenshots, logs, issue reports, or commit history.
177
+ - Rotate token file if exposure is suspected:
178
+
179
+ ```bash
180
+ rm -f ~/.codex-webstrap/token
181
+ ```
182
+
183
+ Then restart wrapper to generate a new token.
184
+
185
+ - Consider external TLS/auth proxy if you must serve beyond localhost.
186
+
187
+ ## Functional Coverage Notes
188
+
189
+ Implemented coverage includes:
190
+
191
+ - core message routing (`ready`, `fetch`, `mcp-*`, `terminal-*`, `persisted-atom-*`, `shared-object-*`)
192
+ - thread lifecycle actions including archive/unarchive pathing
193
+ - worker message support (including git worker bridge)
194
+ - browser equivalents for desktop-only UX events (open links, diff/plan summaries)
195
+ - graceful unsupported handling for non-web-native desktop actions
196
+
197
+ Unknown message types produce structured `bridge-error` responses and do not crash the session.
198
+
199
+ ## Development
200
+
201
+ ### Run Tests
202
+
203
+ ```bash
204
+ npm test
205
+ ```
206
+
207
+ ### Worktree Bootstrap
208
+
209
+ Bootstrap env/secrets from another worktree checkout:
210
+
211
+ ```bash
212
+ ./scripts/worktree-bootstrap.sh --dry-run
213
+ ./scripts/worktree-bootstrap.sh --mode symlink
214
+ ```
215
+
216
+ Core paths are configured via:
217
+
218
+ - `scripts/worktree-secrets.manifest`
219
+
220
+ Codex setup-script compatible command:
221
+
222
+ ```bash
223
+ ./scripts/worktree-bootstrap.sh --mode symlink --overwrite backup --extras on --install on --checks on
224
+ ```
225
+
226
+ ### Typical Troubleshooting
227
+
228
+ - `401 unauthorized`
229
+ - Authenticate first via `/__webstrapper/auth?token=...`.
230
+ - UI loads but actions fail
231
+ - Check `GET /__webstrapper/healthz` for app-server/UDS readiness.
232
+ - Codex app not found
233
+ - Pass `--codex-app /path/to/Codex.app`.
234
+ - `codex` CLI spawn failures
235
+ - Ensure `codex` is on `PATH` or set `CODEX_CLI_PATH`.
236
+
237
+ ## License
238
+
239
+ MIT. See `LICENSE.md`.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
6
+
7
+ PORT="${CODEX_WEBSTRAP_PORT:-8080}"
8
+ BIND="${CODEX_WEBSTRAP_BIND:-127.0.0.1}"
9
+ OPEN_FLAG="0"
10
+ TOKEN_FILE="${CODEX_WEBSTRAP_TOKEN_FILE:-}"
11
+ CODEX_APP="${CODEX_WEBSTRAP_CODEX_APP:-}"
12
+ INTERNAL_WS_PORT="${CODEX_WEBSTRAP_INTERNAL_WS_PORT:-38080}"
13
+
14
+ while [[ $# -gt 0 ]]; do
15
+ case "$1" in
16
+ --port)
17
+ PORT="$2"
18
+ shift 2
19
+ ;;
20
+ --bind)
21
+ BIND="$2"
22
+ shift 2
23
+ ;;
24
+ --open)
25
+ OPEN_FLAG="1"
26
+ shift
27
+ ;;
28
+ --token-file)
29
+ TOKEN_FILE="$2"
30
+ shift 2
31
+ ;;
32
+ --codex-app)
33
+ CODEX_APP="$2"
34
+ shift 2
35
+ ;;
36
+ --help|-h)
37
+ cat <<USAGE
38
+ Usage: $(basename "$0") [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
39
+
40
+ Env overrides:
41
+ CODEX_WEBSTRAP_PORT
42
+ CODEX_WEBSTRAP_BIND
43
+ CODEX_WEBSTRAP_TOKEN_FILE
44
+ CODEX_WEBSTRAP_CODEX_APP
45
+ CODEX_WEBSTRAP_INTERNAL_WS_PORT
46
+ USAGE
47
+ exit 0
48
+ ;;
49
+ *)
50
+ echo "Unknown argument: $1" >&2
51
+ exit 1
52
+ ;;
53
+ esac
54
+ done
55
+
56
+ export CODEX_WEBSTRAP_PORT="$PORT"
57
+ export CODEX_WEBSTRAP_BIND="$BIND"
58
+ export CODEX_WEBSTRAP_TOKEN_FILE="$TOKEN_FILE"
59
+ export CODEX_WEBSTRAP_CODEX_APP="$CODEX_APP"
60
+ export CODEX_WEBSTRAP_INTERNAL_WS_PORT="$INTERNAL_WS_PORT"
61
+ export CODEX_WEBSTRAP_OPEN="$OPEN_FLAG"
62
+
63
+ exec node "${ROOT_DIR}/src/server.mjs"
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "codex-webstrapper",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Web wrapper for Codex desktop assets with bridge + token auth",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "codex-webstrap": "./bin/codex-webstrap.sh"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "LICENSE.md",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node src/server.mjs",
18
+ "test": "node --test"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@electron/asar": "^4.0.1",
25
+ "ws": "^8.18.0"
26
+ }
27
+ }
@@ -0,0 +1,289 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { spawn } from "node:child_process";
3
+
4
+ import { createLogger, toErrorMessage } from "./util.mjs";
5
+
6
+ export class AppServerManager extends EventEmitter {
7
+ constructor({
8
+ internalPort = 38080,
9
+ codexCliPath = process.env.CODEX_CLI_PATH || "codex",
10
+ logger
11
+ } = {}) {
12
+ super();
13
+ this.internalPort = internalPort;
14
+ this.codexCliPath = codexCliPath;
15
+ this.logger = logger || createLogger("app-server");
16
+
17
+ this.proc = null;
18
+ this.connected = false;
19
+ this.initialized = false;
20
+ this.transportKind = "stdio";
21
+
22
+ this.nextId = 1;
23
+ this.pending = new Map();
24
+ this.connectingPromise = null;
25
+ this.stopped = false;
26
+ this.stdoutBuffer = "";
27
+ }
28
+
29
+ getState() {
30
+ return {
31
+ connected: this.connected,
32
+ initialized: this.initialized,
33
+ transportKind: this.transportKind,
34
+ wsUrl: null
35
+ };
36
+ }
37
+
38
+ async start() {
39
+ this.stopped = false;
40
+
41
+ if (this.connected && this.initialized) {
42
+ return;
43
+ }
44
+
45
+ if (this.connectingPromise) {
46
+ return this.connectingPromise;
47
+ }
48
+
49
+ this.connectingPromise = this._startInternal().finally(() => {
50
+ this.connectingPromise = null;
51
+ });
52
+
53
+ return this.connectingPromise;
54
+ }
55
+
56
+ async _startInternal() {
57
+ if (this.proc && !this.proc.killed && this.connected) {
58
+ return;
59
+ }
60
+
61
+ await this._spawnStdioProcess();
62
+ await this._initializeProtocol();
63
+ }
64
+
65
+ stop() {
66
+ this.stopped = true;
67
+
68
+ this._rejectAllPending(new Error("App server stopped"));
69
+
70
+ if (this.proc && !this.proc.killed) {
71
+ this.proc.kill();
72
+ }
73
+
74
+ this.proc = null;
75
+ this.connected = false;
76
+ this.initialized = false;
77
+ this.stdoutBuffer = "";
78
+ this._emitConnectionChanged();
79
+ }
80
+
81
+ async sendRequest(method, params, options = {}) {
82
+ if (!options.skipReadyCheck) {
83
+ await this._ensureReady();
84
+ } else if (!(this.connected && this.proc && this.proc.stdin && !this.proc.stdin.destroyed)) {
85
+ throw new Error("App server stdio is not connected");
86
+ }
87
+
88
+ const id = options.id ?? this.nextId++;
89
+ const payload = { id, method, params };
90
+
91
+ return new Promise((resolve, reject) => {
92
+ const timer = setTimeout(() => {
93
+ this.pending.delete(id);
94
+ reject(new Error(`app-server request timeout: ${method}`));
95
+ }, options.timeoutMs || 15000);
96
+
97
+ this.pending.set(id, { resolve, reject, timer, method });
98
+
99
+ try {
100
+ this._sendJson(payload);
101
+ } catch (error) {
102
+ clearTimeout(timer);
103
+ this.pending.delete(id);
104
+ reject(error);
105
+ }
106
+ });
107
+ }
108
+
109
+ async sendNotification(method, params) {
110
+ await this._ensureReady();
111
+ this._sendJson({ method, params });
112
+ }
113
+
114
+ async sendRaw(message) {
115
+ if (!message || typeof message !== "object") {
116
+ throw new Error("sendRaw expects an object");
117
+ }
118
+
119
+ if (Object.prototype.hasOwnProperty.call(message, "id") && message.method) {
120
+ return this.sendRequest(message.method, message.params, {
121
+ id: message.id,
122
+ timeoutMs: 15000
123
+ });
124
+ }
125
+
126
+ await this._ensureReady();
127
+ this._sendJson(message);
128
+ return null;
129
+ }
130
+
131
+ async _ensureReady() {
132
+ if (this.connected && this.initialized && this.proc && this.proc.stdin && !this.proc.stdin.destroyed) {
133
+ return;
134
+ }
135
+
136
+ await this.start();
137
+
138
+ if (!(this.connected && this.initialized)) {
139
+ throw new Error("App server is not connected");
140
+ }
141
+ }
142
+
143
+ async _spawnStdioProcess() {
144
+ if (this.proc && !this.proc.killed) {
145
+ return;
146
+ }
147
+
148
+ const args = ["app-server", "--analytics-default-enabled"];
149
+
150
+ this.logger.info("Starting codex app-server (stdio)", {
151
+ codexCliPath: this.codexCliPath,
152
+ args
153
+ });
154
+
155
+ const proc = spawn(this.codexCliPath, args, {
156
+ stdio: ["pipe", "pipe", "pipe"],
157
+ env: process.env
158
+ });
159
+
160
+ proc.stderr?.on("data", (chunk) => {
161
+ const line = chunk.toString("utf8").trim();
162
+ if (line) {
163
+ this.logger.debug("app-server stderr", { line });
164
+ }
165
+ });
166
+
167
+ proc.stdout?.on("data", (chunk) => {
168
+ this._handleStdoutChunk(chunk.toString("utf8"));
169
+ });
170
+
171
+ proc.on("error", (error) => {
172
+ this.logger.error("app-server spawn failed", { error: toErrorMessage(error) });
173
+ });
174
+
175
+ proc.on("exit", (code, signal) => {
176
+ this.logger.warn("app-server exited", { code, signal });
177
+ this.proc = null;
178
+ this.connected = false;
179
+ this.initialized = false;
180
+ this.stdoutBuffer = "";
181
+ this._rejectAllPending(new Error("App server process exited"));
182
+ this._emitConnectionChanged();
183
+ });
184
+
185
+ this.proc = proc;
186
+ this.connected = true;
187
+ this.initialized = false;
188
+ this._emitConnectionChanged();
189
+ }
190
+
191
+ _handleStdoutChunk(chunk) {
192
+ this.stdoutBuffer += chunk;
193
+
194
+ for (;;) {
195
+ const newlineIndex = this.stdoutBuffer.indexOf("\n");
196
+ if (newlineIndex < 0) {
197
+ break;
198
+ }
199
+
200
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
201
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
202
+
203
+ if (!line) {
204
+ continue;
205
+ }
206
+
207
+ let payload;
208
+ try {
209
+ payload = JSON.parse(line);
210
+ } catch (error) {
211
+ this.logger.warn("Dropping non-JSON app-server line", {
212
+ line,
213
+ error: toErrorMessage(error)
214
+ });
215
+ continue;
216
+ }
217
+
218
+ this._handleIncoming(payload);
219
+ }
220
+ }
221
+
222
+ _handleIncoming(payload) {
223
+ this.emit("message", payload);
224
+
225
+ if (payload.id != null) {
226
+ const pending = this.pending.get(payload.id);
227
+ if (pending) {
228
+ clearTimeout(pending.timer);
229
+ this.pending.delete(payload.id);
230
+ pending.resolve(payload);
231
+ return;
232
+ }
233
+ }
234
+
235
+ if (payload.method) {
236
+ if (payload.id != null) {
237
+ this.emit("request", payload);
238
+ return;
239
+ }
240
+ this.emit("notification", payload);
241
+ }
242
+ }
243
+
244
+ async _initializeProtocol() {
245
+ const initializeResponse = await this.sendRequest(
246
+ "initialize",
247
+ {
248
+ clientInfo: {
249
+ name: "codex_webstrapper",
250
+ title: "Codex Webstrapper",
251
+ version: "0.1.0"
252
+ },
253
+ capabilities: {
254
+ experimentalApi: true
255
+ }
256
+ },
257
+ { skipReadyCheck: true }
258
+ );
259
+
260
+ if (initializeResponse?.error) {
261
+ throw new Error(`App server initialize failed: ${JSON.stringify(initializeResponse.error)}`);
262
+ }
263
+
264
+ this._sendJson({ method: "initialized", params: {} });
265
+ this.initialized = true;
266
+ this._emitConnectionChanged();
267
+ this.emit("initialized");
268
+ }
269
+
270
+ _sendJson(payload) {
271
+ if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) {
272
+ throw new Error("App server stdin is not writable");
273
+ }
274
+
275
+ this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
276
+ }
277
+
278
+ _rejectAllPending(error) {
279
+ for (const pending of this.pending.values()) {
280
+ clearTimeout(pending.timer);
281
+ pending.reject(error);
282
+ }
283
+ this.pending.clear();
284
+ }
285
+
286
+ _emitConnectionChanged() {
287
+ this.emit("connection-changed", this.getState());
288
+ }
289
+ }