diff --git a/Dockerfile b/Dockerfile index 1924fcef5a..a67659f493 100644 --- a/Dockerfile +++ b/Dockerfile @@ -236,6 +236,11 @@ FROM runner-base AS runner-cli # runner-base runs. USER root +# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over +# CDP without installing a second browser in this container. +COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core +COPY --from=builder /app/node_modules/playwright ./node_modules/playwright + # Install system dependencies required by openclaw (git+ssh references). RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ diff --git a/bin/chatgpt-web-codex-mcp.mjs b/bin/chatgpt-web-codex-mcp.mjs new file mode 100644 index 0000000000..6a686fb256 --- /dev/null +++ b/bin/chatgpt-web-codex-mcp.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); + +export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) { + const candidates = [ + join( + rootDir, + "dist", + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.js" + ), + join( + rootDir, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.ts" + ), + ]; + return candidates.find((candidate) => exists(candidate)) ?? null; +} + +export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) { + const socketIndex = args.indexOf("--broker-socket"); + const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined; + if (!brokerSocketPath) throw new Error("--broker-socket is required"); + const entry = resolveChatGptWebCodexMcpEntry(rootDir); + if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found"); + if (entry.endsWith(".ts")) { + const { register } = await import("node:module"); + register("tsx/esm", pathToFileURL(`${rootDir}/`)); + } + const module = await import(pathToFileURL(entry).href); + await module.runChatGptMcpServer({ brokerSocketPath }); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + startChatGptWebCodexMcp().catch((error) => { + console.error( + `ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}` + ); + process.exit(1); + }); +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f65c2b1335..f5e4e7d826 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -46,6 +46,8 @@ services: depends_on: redis: condition: service_healthy + chatgpt-web-codex-browser: + condition: service_started build: context: . target: runner-cli @@ -67,6 +69,7 @@ services: - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} + - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" @@ -80,7 +83,19 @@ services: retries: 3 start_period: 15s + chatgpt-web-codex-browser: + build: + context: . + dockerfile: docker/chatgpt-web-codex-browser/Dockerfile + image: omniroute:chatgpt-web-codex-browser + restart: unless-stopped + shm_size: "2gb" + volumes: + - chatgpt-web-codex-browser-prod-data:/browser-profile + volumes: + chatgpt-web-codex-browser-prod-data: + name: omniroute-chatgpt-web-codex-browser-prod-data omniroute-prod-data: name: omniroute-prod-data redis-prod-data: diff --git a/docker-compose.yml b/docker-compose.yml index b20b067788..b77542aaa2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,21 @@ services: args: OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:web + depends_on: + chatgpt-web-codex-browser: + condition: service_started + environment: + - DATA_DIR=/app/data + - PORT=${PORT:-20128} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} + - API_PORT=${API_PORT:-20129} + - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} + - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" @@ -105,6 +120,20 @@ services: profiles: - web + # Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser + # UI port is published to the host. + chatgpt-web-codex-browser: + build: + context: . + dockerfile: docker/chatgpt-web-codex-browser/Dockerfile + image: omniroute:chatgpt-web-codex-browser + restart: unless-stopped + shm_size: "2gb" + volumes: + - chatgpt-web-codex-browser-data:/browser-profile + profiles: + - web + # ── Profile: cli (CLIs installed inside container) ───────────────── omniroute-cli: <<: *common @@ -252,6 +281,8 @@ services: - cliproxyapi volumes: + chatgpt-web-codex-browser-data: + name: omniroute-chatgpt-web-codex-browser-data cliproxyapi-data: name: cliproxyapi-data redis-data: diff --git a/docker/chatgpt-web-codex-browser/Dockerfile b/docker/chatgpt-web-codex-browser/Dockerfile new file mode 100644 index 0000000000..b2b3024592 --- /dev/null +++ b/docker/chatgpt-web-codex-browser/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/playwright:v1.62.0-noble + +USER root +RUN mkdir -p /browser-profile && chown -R pwuser:pwuser /browser-profile +COPY --chown=pwuser:pwuser docker/chatgpt-web-codex-browser/cdp-proxy.mjs /opt/cdp-proxy.mjs +USER pwuser + +EXPOSE 9223 + +CMD ["/bin/sh", "-lc", "node /opt/cdp-proxy.mjs & exec $(find /ms-playwright -path '*/chrome-linux/chrome' -type f | head -n 1) --headless=new --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/browser-profile about:blank"] diff --git a/docker/chatgpt-web-codex-browser/cdp-proxy.mjs b/docker/chatgpt-web-codex-browser/cdp-proxy.mjs new file mode 100644 index 0000000000..a340348803 --- /dev/null +++ b/docker/chatgpt-web-codex-browser/cdp-proxy.mjs @@ -0,0 +1,72 @@ +import http from "node:http"; +import net from "node:net"; + +const listenPort = 9223; +const upstreamHost = "127.0.0.1"; +const upstreamPort = 9222; + +function proxyHeaders(headers) { + const next = { ...headers, host: `${upstreamHost}:${upstreamPort}` }; + delete next.connection; + delete next.upgrade; + return next; +} + +const server = http.createServer((request, response) => { + const upstream = http.request( + { + host: upstreamHost, + port: upstreamPort, + method: request.method, + path: request.url, + headers: proxyHeaders(request.headers), + }, + (upstreamResponse) => { + const chunks = []; + upstreamResponse.on("data", (chunk) => chunks.push(chunk)); + upstreamResponse.on("end", () => { + let body = Buffer.concat(chunks); + const contentType = String(upstreamResponse.headers["content-type"] || ""); + if (contentType.includes("application/json")) { + body = Buffer.from( + body + .toString("utf8") + .replaceAll(`ws://${upstreamHost}:${upstreamPort}`, `ws://${request.headers.host}`) + ); + } + const headers = { ...upstreamResponse.headers, "content-length": String(body.length) }; + response.writeHead(upstreamResponse.statusCode || 502, headers); + response.end(body); + }); + } + ); + upstream.on("error", () => { + response.writeHead(503, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "CDP browser is starting" })); + }); + request.pipe(upstream); +}); + +server.on("upgrade", (request, socket, head) => { + const upstream = net.connect(upstreamPort, upstreamHost, () => { + const upgradeHeaders = { + ...request.headers, + host: `${upstreamHost}:${upstreamPort}`, + connection: "Upgrade", + upgrade: "websocket", + }; + const headers = Object.entries(upgradeHeaders) + .flatMap(([name, value]) => + Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`] + ) + .join("\r\n"); + upstream.write( + `${request.method} ${request.url} HTTP/${request.httpVersion}\r\n${headers}\r\n\r\n` + ); + if (head.length > 0) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + upstream.on("error", () => socket.destroy()); +}); + +server.listen(listenPort, "0.0.0.0"); diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index f61c7041ca..3d138e3c0f 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -156,6 +156,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "responses-ws-proxy.mjs"], dest: ["responses-ws-proxy.mjs"], }, + { + label: "ChatGPT Web Codex MCP tunnel entrypoint", + src: ["bin", "chatgpt-web-codex-mcp.mjs"], + dest: ["bin", "chatgpt-web-codex-mcp.mjs"], + }, { label: "webdav-handler (server-ws.mjs dependency)", src: ["scripts", "dev", "webdav-handler.mjs"], diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 1decf97ff2..7d081890a1 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -41,6 +41,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "head-response-guard.cjs", "http-method-guard.cjs", "open-sse/mcp-server/server.js", + "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", // LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads // (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server. "open-sse/services/compression/engines/llmlingua/onnxWorker.js", @@ -48,6 +49,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "peer-stamp.mjs", "main-server-timeouts.mjs", "responses-ws-proxy.mjs", + "bin/chatgpt-web-codex-mcp.mjs", "scripts/dev/sync-env.mjs", "scripts/dev/tls-options.mjs", "server.js", @@ -86,7 +88,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "LICENSE", "README.md", + "THIRD_PARTY_NOTICES.md", "bin/aliasResolver.mjs", + "bin/chatgpt-web-codex-mcp.mjs", // #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL // js/incomplete-url-substring-sanitization (the old code built a // `data:text/javascript,...` URL dynamically). Loaded via pathToFileURL() at @@ -157,6 +161,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", + "dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", "dist/open-sse/services/compression/rules/en/filler.json", "dist/server.js", "dist/server-ws.mjs", diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index bd8612adfa..c1167ff470 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -254,6 +254,42 @@ if (existsSync(mcpSrcFile)) { } } +const chatGptWebCodexMcpSrcFile = join( + ROOT, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.ts" +); +const chatGptWebCodexMcpDestFile = join( + DIST_DIR, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.js" +); +if (existsSync(chatGptWebCodexMcpSrcFile)) { + console.log(" 🔨 Bundling ChatGPT Web (Codex) MCP bridge..."); + mkdirSync(dirname(chatGptWebCodexMcpDestFile), { recursive: true }); + execFileSync( + NPX_BIN, + [ + "esbuild", + "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts", + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + "--outfile=dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", + ], + { cwd: ROOT, stdio: "inherit" } + ); +} + // ── Step 8.6: Bundle LLMLingua ONNX worker ──────────────────────────── // The worker is spawned via worker_threads at a path the Next.js bundler cannot // statically trace, so it must ship as a standalone .js (mirrors the MCP-server diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 1b585e4618..7e2a5fbc15 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -585,12 +585,18 @@ class ResponsesWsSession { // preparedContext, but never touches this.upstream/this.upstreamReady; the caller decides // whether a new upstream socket is needed. async runPrepare(message, responseBody) { - const prepared = await callInternal(this.fetchImpl, this.baseUrl, this.bridgeSecret, "prepare", { - requestUrl: this.requestUrl, - headers: getAuthHeaders(this.requestUrl, this.requestHeaders), - message, - response: responseBody, - }); + const prepared = await callInternal( + this.fetchImpl, + this.baseUrl, + this.bridgeSecret, + "prepare", + { + requestUrl: this.requestUrl, + headers: getAuthHeaders(this.requestUrl, this.requestHeaders), + message, + response: responseBody, + } + ); if (!prepared.ok) { const message2 = @@ -602,6 +608,7 @@ class ResponsesWsSession { const error = new Error(message2); error.code = code; error.status = prepared.status; + if (code === "responses_websocket_http_fallback") error.httpFallback = true; throw error; } @@ -716,11 +723,28 @@ class ResponsesWsSession { // otherwise every turn after the first bypasses the whole pipeline. This reuses // the already-established upstream transport; it must NOT recreate the socket. const prepared = await this.runPrepare(message, nextTurnBody); - this.upstream.send(jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response))); + this.upstream.send( + jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response)) + ); return; } this.upstream.send(jsonStringifySafe(message)); } catch (error) { + if (error?.httpFallback) { + const failurePayload = this.sendFailure( + "responses_websocket_http_fallback", + "Retry this request over HTTP/SSE Responses" + ); + void this.persistHistory({ + status: 426, + success: false, + errorCode: "responses_websocket_http_fallback", + errorMessage: "HTTP/SSE Responses transport required", + terminalMessage: failurePayload, + }); + this.close(1013, "http_fallback_required"); + return; + } const code = error?.code || "upstream_websocket_connect_failed"; const messageText = error instanceof Error ? error.message : String(error); const failurePayload = this.sendFailure(code, messageText);