codex-grok-bridge 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 deximple
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.
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # Double-click to open the dedicated Codex window with Grok 4.6 available.
3
+ # Resolves everything from this file's own location, so the checkout can live
4
+ # anywhere and be moved without editing this launcher.
5
+ set -eu
6
+ ROOT="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
7
+ NODE="${NODE:-$(command -v node || true)}"
8
+ [ -n "$NODE" ] || { echo "no node on PATH; set NODE=/path/to/node" >&2; exit 1; }
9
+ exec "$NODE" "$ROOT/scripts/launch-desktop.mjs"
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # codex-grok-bridge
2
+
3
+ Run **Grok 4.6 as the model inside Codex**, with Codex still owning tools,
4
+ permissions, history and MCP. No xAI API key: inference goes through the login
5
+ session of the installed `grok` CLI.
6
+
7
+ ```
8
+ Codex UI/CLI → app-server → codex-wrapper.mjs (adds grok-4.6 to the model list)
9
+ → localhost /v1/responses (the bridge)
10
+ → cli-chat-proxy.grok.com
11
+ → Codex executes every tool call; results return as the next input
12
+ ```
13
+
14
+ **Requirements** — macOS, Node.js ≥ 22, `/Applications/Codex.app`, the `grok`
15
+ CLI at `~/.grok/bin/grok` with a completed `grok login`. Zero runtime
16
+ dependencies; the whole bridge is thirteen files under `src/`.
17
+
18
+ **Quick start**
19
+
20
+ ```sh
21
+ npm install -g codex-grok-bridge # macOS, Node ≥ 22
22
+ codex-grok # Codex in the terminal, Grok 4.6 selected
23
+ ```
24
+
25
+ From a checkout:
26
+
27
+ ```sh
28
+ git clone https://github.com/deximple/codex-grok-bridge.git
29
+ cd codex-grok-bridge
30
+ npm test # 130 tests, no network, no inference
31
+ node scripts/codex-grok.mjs
32
+ ```
33
+
34
+ That is the whole setup for terminal use — the bridge registers itself as a
35
+ model provider for the Codex process it launches and tears down with it.
36
+
37
+ `scripts/install-codex-grok-app.sh` keeps a **separate** `Codex Grok.app`
38
+ bundle in sync with a checkout, so the desktop app can use the bridge too. It
39
+ updates an existing bundle; it does not create one, and it will not touch the
40
+ normal `Codex.app`. It never deletes a live bundle and refuses to run while a
41
+ Codex Grok window is open. ESM is not hot-reloaded, so reopen the window after
42
+ an update.
43
+
44
+ **Docs** — `docs/HANDOFF.md` is the short version (state, pitfalls, open items).
45
+ `docs/solution-20260909.md` is the long one: how a fixed 15-second failure was
46
+ traced, what it turned out to be, and every measurement behind the fixes.
47
+ `docs/experiments/` reproduces those measurements.
48
+
49
+ **Diagnostics** — every turn is recorded to
50
+ `~/.local/share/codex-grok-bridge/logs/bridge.jsonl`: structural facts only,
51
+ never prompt text, tool output or tokens. Start there when something breaks.
52
+
53
+ ---
54
+
55
+ 아래는 한국어 상세 설명입니다.
56
+
57
+ ## Codex + Grok Build CLI
58
+
59
+ Grok 4.6 / xAI를 Codex 모델 목록에 추가하고, Grok의 도구 요청을 Codex가 실행하도록 연결하는 로컬 확장입니다. xAI API 키를 사용하지 않습니다. 설치된 `grok` CLI의 로그인 세션으로 추론합니다.
60
+
61
+ ## 실행
62
+
63
+ 이 폴더의 **Open Codex with Grok.command**를 더블 클릭합니다. 별도로 열린 Codex 창에서 **새 작업을 시작하기 전에 Grok 4.6 / xAI를 선택**하세요. 기존 GPT 모델도 목록에 유지됩니다. 현재 실행 중인 일반 Codex 창에는 이 확장이 주입되지 않습니다.
64
+
65
+ 터미널에서는 다음과 같이 실행할 수 있습니다.
66
+
67
+ ```sh
68
+ npm install -g codex-grok-bridge
69
+ codex-grok
70
+ codex-grok exec --skip-git-repo-check --sandbox workspace-write '작업 내용'
71
+ ```
72
+
73
+ 체크아웃에서는 `node scripts/codex-grok.mjs`가 같은 진입점입니다. 필요한 설치: Node.js 22 이상, `/Applications/Codex.app`, `~/.grok/bin/grok`, 완료된 `grok login`. `.command` 런처는 자기 위치를 기준으로 경로를 잡으므로 폴더를 옮겨도 수정할 필요가 없습니다. `NODE=/path/to/node`로 Node를 지정할 수 있습니다.
74
+
75
+ ## 연결 방식
76
+
77
+ 기본 경로는 Codex-GPT와 같은 Responses 루프입니다. Grok 네이티브 도구는 실행하지 않습니다. 인증은 `grok login` 세션이며 `XAI_API_KEY`를 쓰지 않습니다.
78
+
79
+ 1. `CODEX_CLI_PATH`로 지정한 래퍼가 Codex app-server를 실행합니다.
80
+ 2. 래퍼가 모델 목록에 Grok를 추가하고 새 Grok 작업의 제공자를 `grok_build_cli`로 설정합니다.
81
+ 3. Codex의 `/v1/responses` 요청은 localhost 변환기로 갑니다.
82
+ 4. 변환기는 Codex 도구를 function calling으로 옮긴 뒤 `cli-chat-proxy.grok.com` Responses 스트림을 그대로 이어줍니다. MCP/셸/패치 실행은 Codex가 합니다.
83
+ 5. 도구 결과는 다음 Codex 요청의 `input`으로 다시 들어갑니다. GPT 경로와 같은 에이전트 루프입니다.
84
+
85
+ 상류 연결은 `node:http(s)`와 keep-alive `Agent`(소켓 30초 유지, 최대 4개)로 직접 엽니다. DNS는 5분 TTL로 캐시하며, 조회가 실패해도 유효한 캐시가 있으면 그것으로 버팁니다. 도구 실행으로 5–20초가 비는 사이에 소켓이 닫혀 매번 새로 이름을 찾는 상황을 피하기 위한 것입니다. `GROK_BRIDGE_TRANSPORT=fetch`로 이전 `fetch` 경로로 되돌릴 수 있습니다.
86
+
87
+ Codex에 첫 SSE 블록을 쓰기 **전**에 죽은 요청은 한 번 다시 보냅니다. 아직 아무것도 전달하지 않았으므로 재전송이 대화를 오염시키지 않기 때문입니다. 첫 블록을 쓴 뒤에는 절대 재시도하지 않고, 422 같은 결정적 거절과 사용자 중단도 재시도하지 않습니다.
88
+
89
+ 문제가 있으면 `GROK_BRIDGE_INFERENCE=cli`로 이전 CLI 봉투 경로를 쓸 수 있습니다. CLI 경로는 매 턴 전체 JSON을 프롬프트로 넣기 때문에 토큰과 지연이 큽니다.
90
+
91
+ 설치된 Codex 앱 번들, 코드 서명, `~/.codex/config.toml`, Grok 인증 파일은 수정하지 않습니다. 전용 창은 `~/.local/share/codex-grok-bridge/desktop`에 UI 데이터를 저장하고 기존 Codex 홈의 계정·작업·설정을 공유합니다. 따라서 실제 사용자 작업 내용과 설정 변경은 다른 Codex 창에서도 보일 수 있습니다.
92
+
93
+ ## 확인된 동작과 제한
94
+
95
+ - 실제 app-server에서 GPT 6개 모델과 `grok-4.6`의 혼합 목록 및 Grok 제공자 라우팅 확인.
96
+ - 실제 Grok CLI → Codex `exec_command` → 실행 결과 → Grok 최종 응답을 확인. 결과: `BRIDGE_TOOL_OK`.
97
+ - 별도로 연 Codex 창에 `Grok 4.6 / xAI Extra High` 표시 확인.
98
+ - 브리지는 상류로 보내는 `instructions` 끝에 출처 한 줄(`Transport: …`)을 붙입니다. Codex는 provider·model을 프롬프트에 넣지 않기 때문에, 이 줄이 없으면 "Grok이 붙었는지"를 모델이 확인할 방법이 없어 설정 파일을 뒤지거나 답을 얼버무립니다. 대화 내용을 판단하는 것이 아니라 전송이 자기 출처를 밝히는 것이며, `user-agent` 헤더와 같은 범주입니다.
99
+ - Codex `reasoning` 항목의 평문 요약은 상류로 전달합니다. 암호화된 `encrypted_content`와 Codex 자체 아이템 id는 제거합니다. 여러 번 호출이 이어지는 턴에서 모델이 자기 추론을 이어받게 하기 위한 것으로, 실제 상류가 이 형태를 수락하는지 확인했습니다.
100
+ - 추론은 변환기당 **동시 4건**까지 실행하고, 그 이상은 큐(기본 8)에서 대기합니다. 큐까지 가득 찼을 때만 `429`를 냅니다. 대기가 거절보다 나은 이유는 브리지가 이미 응답 헤더를 보냈고 keepalive로 스트림을 살려 두기 때문입니다. 동시 1로 조이면 Codex `spawn_agent` 자식 추론이 부모 턴과 겹쳐 죽습니다.
101
+ - 일반 함수 도구, namespace 함수 도구, freeform 커스텀 도구를 변환합니다. 파일 변경·MCP 등은 Codex가 노출한 도구 및 권한 범위에서 사용할 수 있지만, 개별 기능을 모두 실검증한 것은 아닙니다.
102
+ - 이미지 첨부·인식을 지원합니다. PNG/JPEG/WebP, **이미지당 10 MiB, 요청 전체 20 MiB**, 서로 다른 이미지 4장까지, PNG는 32메가픽셀까지입니다. 한도는 추측이 아니라 실측입니다 — 상류가 12.5 MiB PNG를 받아 답하는 것을 확인하고 그 아래로 잡았습니다.
103
+ - **쓸 수 없는 첨부 하나가 대화를 죽이지 않습니다.** 브리지는 대화 전체를 훑기 때문에, 예전에는 한도를 넘는 이미지가 히스토리에 한 번 들어가면 이후 모든 턴이 영구히 400으로 실패했습니다. 지금은 그런 첨부만 이유를 밝힌 텍스트로 바꾸고 나머지는 그대로 보냅니다. 쓸 수 있는 이미지는 `input_image` 그대로 넘어가며(Grok가 직접 읽습니다), 원격 URL은 가져오지 않습니다.
104
+ - 음성, 클라우드 작업, 영상 생성은 아직 보장하지 않습니다.
105
+ - 기본 Responses 경로는 스트림을 전달합니다. 프롬프트 캐시 키는 Codex `prompt_cache_key`를 `x-grok-conv-id`로 넘깁니다.
106
+ - cli-chat-proxy는 function tool 339개 요청을 수락했습니다. 공개 API 문서의 200개 한도는 이 로그인 경로에 적용되지 않습니다.
107
+ - 저장된 루트 작업이 대기 중이면 GPT ↔ Grok 전환을 지원합니다. `turn/start`와 설정 변경 API는 제공자를 바꾸지 못하므로, 래퍼가 구독 해제 → 같은 ID로 제공자를 지정해 재개 → 제공자·권한 확인 후 원래 요청을 전달합니다. 실행 중인 작업, 임시 작업, 하위 에이전트는 제공자 전환을 거부하며, 다른 구독자가 전환을 막으면 추론을 보내지 않습니다. 첫 턴이 저장되기 전에는 새 작업을 시작할 때 원하는 모델을 선택하세요.
108
+ - **이미지 생성도 Grok이 합니다.** 브리지가 상류 요청에 `{ type: "image_generation" }`을 직접 선언하므로, Codex가 이미지 도구를 노출하지 않아도 Grok이 서버 쪽에서 생성합니다. 돌아온 바이트는 `~/.local/share/codex-grok-bridge/generated-images/`에 0600으로 저장하고, Codex에는 파일 경로를 알려주는 assistant 메시지로 전달합니다. Codex가 모르는 `response.image_generation_call.*` 이벤트는 걸러냅니다. `GROK_BRIDGE_IMAGE_GEN=off`로 끕니다.
109
+ - **한계도 실측했습니다.** Grok의 `image_generation`은 텍스트→이미지 생성만 제대로 됩니다.
110
+
111
+ | 기능 | 결과 |
112
+ |---|---|
113
+ | 텍스트→이미지 생성 | 됨 |
114
+ | 투명 배경 | **안 됨.** 항상 JPEG로 오고 알파 채널이 없습니다. 모델은 "투명 배경"이라고 말하면서 **체커보드를 그림에 칠해서** 보냅니다 |
115
+ | 도구 파라미터(`background`, `output_format`) | 거부되지 않고 **조용히 무시**됩니다 |
116
+ | 이미지 편집(image-to-image) | **진짜 편집이 아닙니다.** 입력 이미지를 텍스트로 묘사해 재생성하므로 구도·크기·해상도가 달라집니다 |
117
+
118
+ 그래서 브리지는 저장한 파일의 실제 포맷을 확인해, 알파가 없으면 "이 포맷은 알파 채널이 없으니 투명하다고 설명하지 말라"고 모델에게 명시합니다. 투명 배경이나 정확한 인페인팅이 꼭 필요하면 Codex의 OpenAI 경로를 쓰는 편이 맞습니다.
119
+ - 이게 없으면 Codex의 `imagegen` 시스템 스킬이 OpenAI 경로(내장 `image_gen` 또는 `OPENAI_API_KEY` + `gpt-image-*`)로 갑니다. 실제로 Codex가 이 프로바이더에 보내는 263개 도구 중 이미지 생성 도구는 하나도 없습니다 — `view_image`뿐입니다. 즉 추론은 Grok인데 그림만 다른 벤더에서 나오는 상태가 됩니다.
120
+ - CLI 폴백은 응답이 끝난 뒤 Codex에 결과를 전달하므로 토큰 단위 실시간 출력이 없습니다. 요청당 3분 제한입니다.
121
+ - 앱 업데이트가 `CODEX_CLI_PATH`나 app-server 프로토콜을 바꾸면 재검증이 필요합니다. 검증 버전: Codex 0.153.4 / 앱 26.901.51231, Grok CLI 1.0.24, Node 22.23.0.
122
+
123
+ ## 진단 로그
124
+
125
+ 브리지는 매 턴을 `~/.local/share/codex-grok-bridge/logs/bridge.jsonl`에 한 줄씩 기록합니다(디렉터리 0700, 파일 0600, 4 MiB 초과 시 `.1`로 1회 회전). 끄려면 `GROK_BRIDGE_DIAGNOSTICS=off`.
126
+
127
+ ```jsonl
128
+ {"at":"…","event":"turn_ok","mode":"proxy","elapsedMs":14118,"requestBytes":469749,"items":4,"tools":29}
129
+ {"at":"…","event":"turn_failed","kind":"dns","signature":"TypeError <- Error[EAI_AGAIN]","elapsedMs":15071,…}
130
+ ```
131
+
132
+ 구조적 사실만 남깁니다 — 시각, 성공/실패, 오류 분류, 오류 체인의 이름·코드, 경과 시간, 요청 바이트, 아이템·도구 개수. **프롬프트 본문, 도구 출력, 상류 응답 본문, 토큰은 기록하지 않습니다.** `detail`에 들어가는 오류 메시지는 Bearer 토큰·JWT·API 키·홈 경로를 치환한 뒤 400자로 자릅니다.
133
+
134
+ 성공 턴도 남기는 이유는, 실패 시 로그가 비어 있다는 사실 자체가 "브리지가 호출되지도 않았다"는 진단이 되기 때문입니다.
135
+
136
+ `event: "turn_failed"`의 `kind`는 다음 중 하나입니다 — `aborted`(사용자가 중단), `auth`(로그인 만료), `dns`, `connect`, `upstream_timeout`, `upstream_closed`, `upstream_protocol`, `payload`, `internal`. Codex UI에는 같은 분류가 `bridge_<kind>` 코드와 함께 표시됩니다.
137
+
138
+ ## 검증
139
+
140
+ ```sh
141
+ npm test # 130건, 외부 추론 없음
142
+ npm run test:coverage # line/branch/function 80% 게이트
143
+ npm run verify:app-server # 실제 app-server 라우팅. 설치된 앱 번들에서도 실행됩니다
144
+ npm audit --omit=dev
145
+ ```
146
+
147
+ ## 설치와 갱신
148
+
149
+ ```sh
150
+ sh scripts/install-codex-grok-app.sh # 브리지 JS만 동기화 (기본)
151
+ sh scripts/install-codex-grok-app.sh --full # 런처 applet 재빌드 + 서명까지
152
+ ```
153
+
154
+ 기본 동작은 번들의 `src/`·`scripts/`를 이 저장소와 일치시키는 것입니다. 디렉터리를 지우지 않고, 저장소에 없는 파일만 골라서 제거하며, 끝에 내용이 일치하는지 확인합니다. Codex Grok 창이 열려 있으면 거부합니다(`--force`로 무시). ESM은 핫리로드되지 않으므로 **동기화 후 창을 다시 열어야** 새 코드가 적용됩니다.
155
+
156
+ 테스트는 외부 추론을 호출하지 않습니다. `verify:app-server`는 실제 app-server에 모델 목록과 임시 작업 생성을 요청하며, 모델 추론은 하지 않습니다. 실제 CLI 검증은 사용자 계정 사용량을 소비합니다.
157
+
158
+ 변환기는 loopback에만 바인딩하며, 추론 요청에 매 실행마다 생성한 임시 토큰을 요구합니다. 브라우저 Origin 요청, 지원하지 않는 모델, 과대 요청, 알 수 없는 도구 호출은 거부합니다. Grok 프롬프트 임시 파일은 0600 권한으로 생성하고 종료 후 제거합니다. CLI 오류 원문과 인증 정보는 응답에 포함하지 않습니다. Codex와 Grok 자체의 대화 저장 정책은 그대로 적용됩니다.
159
+
160
+ 중지하려면 확장으로 연 Codex 창을 종료하세요. 일반 Codex는 기존 아이콘으로 실행하면 됩니다. 자동 시작 서비스나 전역 환경변수는 등록하지 않았습니다.
161
+
162
+ 참고: [Grok Build headless scripting](https://docs.x.ai/build/cli/headless-scripting), [Grok Build source](https://github.com/xai-org/grok-build), [Codex source](https://github.com/openai/codex).
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "codex-grok-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Run Grok 4.6 as the model inside Codex, with Codex still owning tools, permissions, history and MCP. Uses the grok login session, not an API key.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "deximple",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "os": [
12
+ "darwin"
13
+ ],
14
+ "bin": {
15
+ "codex-grok": "scripts/codex-grok.mjs"
16
+ },
17
+ "keywords": [
18
+ "codex",
19
+ "grok",
20
+ "xai",
21
+ "model-provider",
22
+ "responses-api",
23
+ "agent-harness"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/deximple/codex-grok-bridge.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/deximple/codex-grok-bridge/issues"
31
+ },
32
+ "homepage": "https://github.com/deximple/codex-grok-bridge#readme",
33
+ "files": [
34
+ "src",
35
+ "scripts/codex-grok.mjs",
36
+ "scripts/codex-wrapper.mjs",
37
+ "scripts/install-codex-grok-app.sh",
38
+ "scripts/launch-desktop.mjs",
39
+ "Open Codex with Grok.command",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "scripts": {
44
+ "start": "node scripts/codex-grok.mjs",
45
+ "desktop": "node scripts/launch-desktop.mjs",
46
+ "verify:app-server": "node scripts/verify-app-server.mjs",
47
+ "prepublishOnly": "npm test",
48
+ "test": "node --test",
49
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='src/*.mjs' --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80"
50
+ }
51
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { startRuntime } from "../src/runtime.mjs";
4
+ const runtime = await startRuntime();
5
+ const userArgs = process.argv.slice(2);
6
+ const overrides = [
7
+ ...runtime.args,
8
+ "-c",
9
+ 'model_provider="grok_build_cli"',
10
+ "-c",
11
+ `model_catalog_json=${JSON.stringify(runtime.catalogPath)}`,
12
+ "-m",
13
+ "grok-4.6",
14
+ ];
15
+ const args =
16
+ userArgs[0] === "exec"
17
+ ? [userArgs[0], ...overrides, ...userArgs.slice(1)]
18
+ : [...overrides, ...userArgs];
19
+ const child = spawn("/Applications/Codex.app/Contents/Resources/codex", args, {
20
+ stdio: "inherit",
21
+ env: { ...process.env, CODEX_GROK_BRIDGE_TOKEN: runtime.token },
22
+ });
23
+ for (const signal of ["SIGINT", "SIGTERM"])
24
+ process.on(signal, () => child.kill(signal));
25
+ child.on("error", () => process.stderr.write("Could not start Codex\n"));
26
+ child.on("close", async (code) => {
27
+ await runtime.close();
28
+ process.exit(code ?? 1);
29
+ });
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { randomUUID } from "node:crypto";
5
+ import { startRuntime } from "../src/runtime.mjs";
6
+ import { Router } from "../src/router.mjs";
7
+
8
+ const binary = "/Applications/Codex.app/Contents/Resources/codex";
9
+ const args = process.argv.slice(2);
10
+ if (!args.includes("app-server")) {
11
+ const child = spawn(binary, args, { stdio: "inherit" });
12
+ child.on("exit", (code) => process.exit(code ?? 1));
13
+ } else {
14
+ const runtime = await startRuntime();
15
+ const router = new Router(runtime.catalogPath);
16
+ const child = spawn(binary, [...args, ...runtime.args], {
17
+ stdio: ["pipe", "pipe", "inherit"],
18
+ env: { ...process.env, CODEX_GROK_BRIDGE_TOKEN: runtime.token },
19
+ });
20
+ const send = (message) =>
21
+ process.stdout.write(JSON.stringify(message) + "\n");
22
+ const pending = new Map();
23
+ const timedOut = new Set();
24
+ const queues = new Map();
25
+ const write = (message) => child.stdin.write(JSON.stringify(router.outgoing(message)) + "\n");
26
+ const rpc = (method, params) => new Promise((resolve, reject) => {
27
+ const id = `grok-bridge-${randomUUID()}`;
28
+ const timer = setTimeout(() => {
29
+ pending.delete(id);
30
+ router.pending.delete(id);
31
+ timedOut.add(id);
32
+ reject(new Error(`Provider transition timed out: ${method}`));
33
+ }, 30000);
34
+ pending.set(id, (message) => {
35
+ clearTimeout(timer);
36
+ message.error ? reject(new Error(message.error.message)) : resolve(message.result);
37
+ });
38
+ write({ id, method, params });
39
+ });
40
+ const input = createInterface({ input: process.stdin });
41
+ input.on("line", (line) => {
42
+ let message;
43
+ try {
44
+ message = JSON.parse(line);
45
+ } catch {
46
+ process.stderr.write("Grok wrapper: invalid client message\n");
47
+ return;
48
+ }
49
+ const threadId = message.method && message.params?.threadId;
50
+ if (!threadId) {
51
+ try {
52
+ write(message);
53
+ } catch {
54
+ process.stderr.write("Grok wrapper: app-server is not writable\n");
55
+ if (message.id !== undefined)
56
+ send({ id: message.id, error: { code: -32603, message: "app-server is not writable" } });
57
+ }
58
+ return;
59
+ }
60
+ const next = (queues.get(threadId) ?? Promise.resolve())
61
+ .then(async () => {
62
+ await router.prepare(message, rpc);
63
+ write(message);
64
+ })
65
+ .catch((error) => {
66
+ if (message.id !== undefined)
67
+ send({ id: message.id, error: { code: -32600, message: error.message } });
68
+ })
69
+ .finally(() => {
70
+ if (queues.get(threadId) === next) queues.delete(threadId);
71
+ });
72
+ queues.set(threadId, next);
73
+ });
74
+ createInterface({ input: child.stdout }).on("line", (line) => {
75
+ try {
76
+ const message = router.incoming(JSON.parse(line));
77
+ if (!message.method && pending.has(message.id)) {
78
+ pending.get(message.id)(message);
79
+ pending.delete(message.id);
80
+ } else if (!message.method && timedOut.delete(message.id)) {
81
+ process.stderr.write("Grok wrapper: dropped late internal reply\n");
82
+ } else send(message);
83
+ } catch {
84
+ process.stderr.write("Grok wrapper: invalid app-server message\n");
85
+ }
86
+ });
87
+ input.on("close", () => child.stdin.end());
88
+ for (const signal of ["SIGTERM", "SIGINT"])
89
+ process.on(signal, () => child.kill(signal));
90
+ child.on("error", () =>
91
+ process.stderr.write("Could not start bundled Codex\n"),
92
+ );
93
+ child.on("close", async (code) => {
94
+ input.close();
95
+ await runtime.close();
96
+ process.exit(code ?? 1);
97
+ });
98
+ }
@@ -0,0 +1,94 @@
1
+ #!/bin/sh
2
+ # Install or update the bridge inside /Applications/Codex Grok.app.
3
+ #
4
+ # Default is a JS-only sync: the bundle's scripts/ and src/ are brought in line
5
+ # with this checkout, in place. The previous version began with `rm -rf` on the
6
+ # directory that CODEX_CLI_PATH points into, which destroys a running app's
7
+ # entrypoint; nothing here removes a directory, and stale files are pruned one
8
+ # by one only when the repo no longer has them.
9
+ #
10
+ # install-codex-grok-app.sh sync bridge JS (safe, fast)
11
+ # install-codex-grok-app.sh --full also rebuild the launcher applet and sign
12
+ # install-codex-grok-app.sh --force proceed even if a Codex Grok window is open
13
+ #
14
+ # ESM is not hot-reloaded: an open Codex Grok window keeps running the code it
15
+ # started with, so restart the window after a sync.
16
+ set -eu
17
+
18
+ ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
19
+ # CODEX_GROK_APP exists so this script can be exercised against a scratch copy
20
+ # instead of the installed app.
21
+ APP="${CODEX_GROK_APP:-/Applications/Codex Grok.app}"
22
+ BRIDGE="$APP/Contents/Resources/bridge"
23
+ USER_DATA="$HOME/.local/share/codex-grok-bridge/desktop"
24
+
25
+ FULL=0
26
+ FORCE=0
27
+ for arg in "$@"; do
28
+ case "$arg" in
29
+ --full) FULL=1 ;;
30
+ --force) FORCE=1 ;;
31
+ -h|--help) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
32
+ *) echo "unknown option: $arg" >&2; exit 2 ;;
33
+ esac
34
+ done
35
+
36
+ if [ ! -d "$APP/Contents" ]; then
37
+ echo "missing $APP" >&2
38
+ exit 1
39
+ fi
40
+
41
+ if [ "$FORCE" -eq 0 ] && pgrep -f -- "--user-data-dir=$USER_DATA" >/dev/null 2>&1; then
42
+ echo "Codex Grok is running. Close its window first, or pass --force to swap" >&2
43
+ echo "the files under it (the running window keeps its loaded code either way)." >&2
44
+ exit 1
45
+ fi
46
+
47
+ mkdir -p "$BRIDGE/scripts" "$BRIDGE/src"
48
+ cp "$ROOT"/scripts/*.mjs "$BRIDGE/scripts/"
49
+ cp "$ROOT"/src/*.mjs "$BRIDGE/src/"
50
+
51
+ # Prune only files this checkout no longer has. Never remove the directory.
52
+ for dir in scripts src; do
53
+ for installed in "$BRIDGE/$dir"/*.mjs; do
54
+ [ -e "$installed" ] || continue
55
+ if [ ! -e "$ROOT/$dir/$(basename "$installed")" ]; then
56
+ rm -f "$installed"
57
+ echo "pruned $dir/$(basename "$installed")"
58
+ fi
59
+ done
60
+ done
61
+
62
+ status=0
63
+ for dir in scripts src; do
64
+ for source in "$ROOT/$dir"/*.mjs; do
65
+ name="$(basename "$source")"
66
+ if ! cmp -s "$source" "$BRIDGE/$dir/$name"; then
67
+ echo "MISMATCH $dir/$name" >&2
68
+ status=1
69
+ fi
70
+ done
71
+ done
72
+ [ "$status" -eq 0 ] || { echo "bundle did not match the checkout" >&2; exit 1; }
73
+
74
+ if [ "$FULL" -eq 1 ]; then
75
+ NODE="${NODE:-$(command -v node || true)}"
76
+ [ -n "$NODE" ] || { echo "no node on PATH; set NODE=/path/to/node" >&2; exit 1; }
77
+ if [ -L "$NODE" ]; then
78
+ NODE="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$NODE")"
79
+ fi
80
+ mkdir -p "$APP/Contents/Resources/Scripts"
81
+ LAUNCH="$BRIDGE/scripts/launch-desktop.mjs"
82
+ TMP="$(mktemp -d)"
83
+ trap 'rm -rf "$TMP"' EXIT
84
+ osacompile -o "$TMP/tmp.app" -e "do shell script \"$NODE \\\"$LAUNCH\\\"\""
85
+ cp "$TMP/tmp.app/Contents/MacOS/applet" "$APP/Contents/MacOS/applet"
86
+ cp "$TMP/tmp.app/Contents/Resources/Scripts/main.scpt" "$APP/Contents/Resources/Scripts/main.scpt"
87
+ chmod +x "$APP/Contents/MacOS/applet"
88
+ xattr -cr "$APP" 2>/dev/null || true
89
+ codesign --force --deep -s - "$APP" >/dev/null
90
+ echo "rebuilt launcher with node $NODE"
91
+ fi
92
+
93
+ echo "bridge in $APP matches $ROOT"
94
+ echo "restart any open Codex Grok window to load it"
@@ -0,0 +1,102 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const userData = path.join(homedir(), ".local/share/codex-grok-bridge/desktop");
8
+ const logFile = path.join(homedir(), ".local/share/codex-grok-bridge/launch.log");
9
+ mkdirSync(userData, { recursive: true, mode: 0o700 });
10
+ mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 });
11
+
12
+ function log(message) {
13
+ appendFileSync(logFile, `${new Date().toISOString()} ${message}\n`);
14
+ }
15
+
16
+ function grokPid() {
17
+ const out = execFileSync("/bin/ps", ["-axo", "pid=,command="], {
18
+ encoding: "utf8",
19
+ });
20
+ for (const line of out.split("\n")) {
21
+ if (
22
+ line.includes(`--user-data-dir=${userData}`) &&
23
+ /MacOS\/(ChatGPT|Codex)(\s|$)/.test(line) &&
24
+ !line.includes("Helper") &&
25
+ !line.includes("crashpad")
26
+ )
27
+ return Number(line.trim().split(/\s+/)[0]);
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function activate(pid) {
33
+ execFileSync("/usr/bin/osascript", [
34
+ "-e",
35
+ `tell application "System Events"
36
+ set p to first process whose unix id is ${pid}
37
+ set visible of p to true
38
+ set frontmost of p to true
39
+ end tell`,
40
+ ]);
41
+ }
42
+
43
+ function notify(text) {
44
+ try {
45
+ execFileSync("/usr/bin/osascript", [
46
+ "-e",
47
+ `display notification ${JSON.stringify(text)} with title "Codex Grok"`,
48
+ ]);
49
+ } catch {}
50
+ }
51
+
52
+ const wrapper = fileURLToPath(new URL("./codex-wrapper.mjs", import.meta.url));
53
+ try {
54
+ const running = grokPid();
55
+ if (running) {
56
+ log(`activate pid=${running}`);
57
+ try {
58
+ activate(running);
59
+ notify("Codex Grok 창을 앞으로 가져왔습니다.");
60
+ process.exit(0);
61
+ } catch (error) {
62
+ log(`activate failed: ${error.message}`);
63
+ }
64
+ }
65
+ log(`start wrapper=${wrapper}`);
66
+ const child = spawn(
67
+ "/usr/bin/open",
68
+ [
69
+ "-n",
70
+ "--env",
71
+ `CODEX_CLI_PATH=${wrapper}`,
72
+ "--env",
73
+ "CODEX_APP_SERVER_FORCE_CLI=1",
74
+ "--env",
75
+ `PATH=${path.dirname(process.execPath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
76
+ "--env",
77
+ `CODEX_ELECTRON_USER_DATA_PATH=${userData}`,
78
+ "/Applications/Codex.app",
79
+ "--args",
80
+ `--user-data-dir=${userData}`,
81
+ ],
82
+ { stdio: "inherit" },
83
+ );
84
+ child.on("exit", (code) => {
85
+ log(`open exit=${code}`);
86
+ const pid = grokPid();
87
+ if (pid) {
88
+ try {
89
+ activate(pid);
90
+ } catch (error) {
91
+ log(`post-open activate failed: ${error.message}`);
92
+ }
93
+ } else {
94
+ notify("Codex Grok을 열지 못했습니다. launch.log를 확인하세요.");
95
+ }
96
+ process.exit(code ?? 1);
97
+ });
98
+ } catch (error) {
99
+ log(`fatal: ${error.message}`);
100
+ notify(error.message);
101
+ process.exit(1);
102
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export class GrokAuthError extends Error {}
6
+
7
+ function sessionEntries(auth) {
8
+ if (!auth || typeof auth !== "object") return [];
9
+ return Object.values(auth).filter(
10
+ (value) =>
11
+ value &&
12
+ typeof value === "object" &&
13
+ typeof value.key === "string" &&
14
+ value.key.length > 0,
15
+ );
16
+ }
17
+
18
+ export function readGrokBearerToken(home = homedir()) {
19
+ let auth;
20
+ try {
21
+ auth = JSON.parse(readFileSync(join(home, ".grok/auth.json"), "utf8"));
22
+ } catch {
23
+ throw new GrokAuthError("Grok login required");
24
+ }
25
+ const entries = sessionEntries(auth);
26
+ if (!entries.length) throw new GrokAuthError("Grok login required");
27
+ entries.sort((left, right) =>
28
+ String(right.expires_at ?? "").localeCompare(String(left.expires_at ?? "")),
29
+ );
30
+ const session = entries[0];
31
+ return {
32
+ token: session.key,
33
+ userId: typeof session.user_id === "string" ? session.user_id : null,
34
+ };
35
+ }