icebear0828 Claude Opus 4.6 commited on
Commit
22a7de1
·
1 Parent(s): 0d2f54c

feat: desktop context injection, multi-turn sessions, TLS impersonation & auto-update

Browse files

- Inject Codex Desktop system prompt into every request for full feature parity
- Add previous_response_id for multi-turn conversation continuity
- Add curl-impersonate auto-detection with Chrome TLS fingerprint fallback
- Fix appcast XML parsing for element-style Sparkle tags
- Remove private maintenance scripts from public repo
- Add README with setup and usage instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.gitignore CHANGED
@@ -1,7 +1,26 @@
1
  node_modules/
2
  dist/
3
  data/
 
 
4
  .env
5
  *.log
6
  .asar-out/
 
7
  .claude/settings.local.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  node_modules/
2
  dist/
3
  data/
4
+ docs/
5
+ bin/
6
  .env
7
  *.log
8
  .asar-out/
9
+ tmp/
10
  .claude/settings.local.json
11
+
12
+ # Maintenance scripts (private — not for public repo)
13
+ scripts/extract-fingerprint.ts
14
+ scripts/full-update.ts
15
+ scripts/apply-update.ts
16
+ scripts/setup-curl.ts
17
+ scripts/check-update.ts
18
+ scripts/test-*.ts
19
+ scripts/cron-update.sh
20
+ config/extraction-patterns.yaml
21
+
22
+ # Design / internal files
23
+ stitch-designs/
24
+ stitch-screens/
25
+ CLAUDE.md
26
+ *.tar.gz
README.md ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codex Proxy
2
+
3
+ A reverse proxy that exposes the [Codex Desktop](https://openai.com/codex) API as an OpenAI-compatible `/v1/chat/completions` endpoint. Use any OpenAI-compatible client (Cursor, Continue, VS Code, etc.) with Codex models — for free.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ OpenAI-compatible client
9
+
10
+ POST /v1/chat/completions
11
+
12
+
13
+ ┌─────────────┐ POST /backend-api/codex/responses
14
+ │ Codex Proxy │ ──────────────────────────────────────► chatgpt.com
15
+ │ :8080 │ ◄────────────────────────────────────── (SSE stream)
16
+ └─────────────┘
17
+
18
+ SSE chat.completion.chunk
19
+
20
+
21
+ Client
22
+ ```
23
+
24
+ The proxy translates OpenAI Chat Completions format to the Codex Responses API format, handles authentication (OAuth PKCE), multi-account rotation, and Cloudflare bypass via curl subprocess.
25
+
26
+ ## Quick Start
27
+
28
+ ```bash
29
+ # 1. Install dependencies
30
+ npm install
31
+
32
+ # 2. Start the proxy (dev mode with hot reload)
33
+ npm run dev
34
+
35
+ # 3. Open the dashboard and log in with your ChatGPT account
36
+ # http://localhost:8080
37
+
38
+ # 4. Test a chat completion
39
+ curl http://localhost:8080/v1/chat/completions \
40
+ -H "Content-Type: application/json" \
41
+ -d '{
42
+ "model": "codex",
43
+ "messages": [{"role": "user", "content": "Hello!"}],
44
+ "stream": true
45
+ }'
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - **OpenAI-compatible API** — drop-in replacement for `/v1/chat/completions` and `/v1/models`
51
+ - **OAuth PKCE login** — native browser-based login, no manual token copying
52
+ - **Multi-account rotation** — add multiple ChatGPT accounts with automatic load balancing (`least_used` or `round_robin`)
53
+ - **Auto token refresh** — JWT tokens are refreshed automatically before expiry
54
+ - **Cloudflare bypass** — all upstream requests use curl subprocess with native TLS
55
+ - **Quota monitoring** — real-time Codex usage/quota display per account
56
+ - **Web dashboard** — manage accounts, view usage, and monitor status at `http://localhost:8080`
57
+ - **Auto-update detection** — polls the Codex Desktop appcast for new versions
58
+
59
+ ## Available Models
60
+
61
+ | Model ID | Alias | Description |
62
+ |----------|-------|-------------|
63
+ | `gpt-5.3-codex` | `codex` | Latest frontier agentic coding model (default) |
64
+ | `gpt-5.2-codex` | — | Previous generation coding model |
65
+ | `gpt-5.1-codex-max` | `codex-max` | Maximum capability coding model |
66
+ | `gpt-5.2` | — | General-purpose model |
67
+ | `gpt-5.1-codex-mini` | `codex-mini` | Lightweight, fast coding model |
68
+
69
+ ## API Usage
70
+
71
+ ### Chat Completions (streaming)
72
+
73
+ ```bash
74
+ curl http://localhost:8080/v1/chat/completions \
75
+ -H "Content-Type: application/json" \
76
+ -d '{
77
+ "model": "codex",
78
+ "messages": [
79
+ {"role": "system", "content": "You are a helpful coding assistant."},
80
+ {"role": "user", "content": "Write a Python function to check if a number is prime."}
81
+ ],
82
+ "stream": true
83
+ }'
84
+ ```
85
+
86
+ ### Chat Completions (non-streaming)
87
+
88
+ ```bash
89
+ curl http://localhost:8080/v1/chat/completions \
90
+ -H "Content-Type: application/json" \
91
+ -d '{
92
+ "model": "codex",
93
+ "messages": [{"role": "user", "content": "Hello!"}],
94
+ "stream": false
95
+ }'
96
+ ```
97
+
98
+ ### List Models
99
+
100
+ ```bash
101
+ curl http://localhost:8080/v1/models
102
+ ```
103
+
104
+ ### Check Account Quota
105
+
106
+ ```bash
107
+ curl "http://localhost:8080/auth/accounts?quota=true"
108
+ ```
109
+
110
+ ## Configuration
111
+
112
+ All configuration is in `config/default.yaml`:
113
+
114
+ | Section | Key Settings |
115
+ |---------|-------------|
116
+ | `api` | `base_url`, `timeout_seconds` |
117
+ | `client` | `originator`, `app_version`, `platform`, `arch` |
118
+ | `model` | `default` model, `default_reasoning_effort` |
119
+ | `auth` | `oauth_client_id`, `rotation_strategy`, `rate_limit_backoff_seconds` |
120
+ | `server` | `host`, `port`, `proxy_api_key` |
121
+
122
+ Environment variable overrides:
123
+
124
+ | Variable | Overrides |
125
+ |----------|-----------|
126
+ | `PORT` | `server.port` |
127
+ | `CODEX_PLATFORM` | `client.platform` |
128
+ | `CODEX_ARCH` | `client.arch` |
129
+ | `CODEX_JWT_TOKEN` | `auth.jwt_token` |
130
+
131
+ ## Client Setup Examples
132
+
133
+ ### Cursor
134
+
135
+ Settings > Models > OpenAI API Base:
136
+ ```
137
+ http://localhost:8080/v1
138
+ ```
139
+
140
+ ### Continue (VS Code)
141
+
142
+ `~/.continue/config.json`:
143
+ ```json
144
+ {
145
+ "models": [{
146
+ "title": "Codex",
147
+ "provider": "openai",
148
+ "model": "codex",
149
+ "apiBase": "http://localhost:8080/v1"
150
+ }]
151
+ }
152
+ ```
153
+
154
+ ## Scripts
155
+
156
+ | Command | Description |
157
+ |---------|-------------|
158
+ | `npm run dev` | Start dev server with hot reload |
159
+ | `npm run build` | Compile TypeScript to `dist/` |
160
+ | `npm start` | Run compiled server |
161
+ | `npm run check-update` | Check for new Codex Desktop versions |
162
+ | `npm run extract -- --path <asar>` | Extract fingerprint from Codex app |
163
+ | `npm run apply-update` | Apply extracted fingerprint updates |
164
+
165
+ ## License
166
+
167
+ For personal use only. This project is not affiliated with OpenAI.
config/default.yaml CHANGED
@@ -4,8 +4,8 @@ api:
4
 
5
  client:
6
  originator: "Codex Desktop"
7
- app_version: "260202.0859"
8
- build_number: "517"
9
  platform: "darwin"
10
  arch: "arm64"
11
 
@@ -36,6 +36,12 @@ session:
36
  ttl_minutes: 60
37
  cleanup_interval_minutes: 5
38
 
 
 
 
 
 
 
39
  streaming:
40
  status_as_content: false
41
  chunk_size: 100
 
4
 
5
  client:
6
  originator: "Codex Desktop"
7
+ app_version: "26.217.1959"
8
+ build_number: "669"
9
  platform: "darwin"
10
  arch: "arm64"
11
 
 
36
  ttl_minutes: 60
37
  cleanup_interval_minutes: 5
38
 
39
+ tls:
40
+ # curl binary path. "auto" = detect bin/curl-impersonate-chrome, fallback to system curl
41
+ curl_binary: "auto"
42
+ # Chrome profile for --impersonate flag (auto-detected when curl-impersonate supports it)
43
+ impersonate_profile: "chrome136"
44
+
45
  streaming:
46
  status_as_content: false
47
  chunk_size: 100
config/extraction-patterns.yaml DELETED
@@ -1,74 +0,0 @@
1
- # Regex patterns for extracting values from Codex Desktop's main.js
2
- # These patterns are applied to the beautified (js-beautify) output.
3
- # Update these when the minified variable names change across versions.
4
- #
5
- # NOTE: Use single-quoted YAML strings for regex patterns. In YAML single quotes,
6
- # backslashes are literal (no escaping needed). Only '' is special (escaped single quote).
7
-
8
- # Version info from package.json (no regex needed — direct JSON parse)
9
- package_json:
10
- version_key: "version"
11
- build_number_key: "codexBuildNumber"
12
- sparkle_feed_key: "codexSparkleFeedUrl"
13
-
14
- # Patterns for main.js content extraction
15
- main_js:
16
- # API base URL
17
- api_base_url:
18
- pattern: 'https://[^"]+/backend-api'
19
- description: "WHAM API base URL"
20
-
21
- # Originator header value — in beautified code: variable = "Codex Desktop";
22
- originator:
23
- pattern: '= "(Codex [^"]+)"'
24
- group: 1
25
- description: "originator header sent with requests"
26
-
27
- # Model IDs — match quoted gpt-X.Y(-codex)(-variant) strings
28
- # In beautified code: = "gpt-5.3-codex", LF = "gpt-5.1-codex-mini", etc.
29
- models:
30
- pattern: '"(gpt-[0-9]+[.][0-9]+(?:-codex)?(?:-[a-z]+)?)"'
31
- group: 1
32
- global: true
33
- description: "All model ID strings"
34
-
35
- # WHAM API endpoint paths
36
- wham_endpoints:
37
- pattern: '"(/wham/[^"]+)"'
38
- group: 1
39
- global: true
40
- description: "WHAM API endpoint paths"
41
-
42
- # User-Agent template
43
- user_agent:
44
- pattern: "Codex Desktop/"
45
- description: "User-Agent prefix check"
46
-
47
- # Desktop context system prompt (variable oF)
48
- desktop_context:
49
- start_marker: "# Codex desktop context"
50
- end_marker: "</app-context>"
51
- description: "Full desktop context system prompt"
52
-
53
- # Title generation prompt (function KZ)
54
- title_generation:
55
- start_marker: "You are a helpful assistant. You will be presented with a user prompt"
56
- end_pattern: '.join('
57
- description: "Task title generation system prompt"
58
-
59
- # PR generation prompt (function n9)
60
- pr_generation:
61
- start_marker: "You are a helpful assistant. Generate a pull request title"
62
- end_pattern: '.join('
63
- description: "PR title/body generation system prompt"
64
-
65
- # Automation response template (constant qK)
66
- automation_response:
67
- start_marker: "Response MUST end with a remark-directive block"
68
- end_pattern: "^`;$"
69
- description: "Automation response template with directive rules"
70
-
71
- # Info.plist keys (macOS only)
72
- info_plist:
73
- version_key: "CFBundleShortVersionString"
74
- build_key: "CFBundleVersion"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "name": "codex-proxy",
3
  "version": "1.0.0",
4
- "description": "Reverse proxy that exposes Codex Desktop WHAM API as OpenAI-compatible /v1/chat/completions",
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/index.ts",
@@ -11,7 +11,10 @@
11
  "check-update:watch": "tsx scripts/check-update.ts --watch",
12
  "extract": "tsx scripts/extract-fingerprint.ts",
13
  "apply-update": "tsx scripts/apply-update.ts",
14
- "apply-update:dry": "tsx scripts/apply-update.ts --dry-run"
 
 
 
15
  },
16
  "dependencies": {
17
  "hono": "^4.0.0",
 
1
  {
2
  "name": "codex-proxy",
3
  "version": "1.0.0",
4
+ "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions",
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/index.ts",
 
11
  "check-update:watch": "tsx scripts/check-update.ts --watch",
12
  "extract": "tsx scripts/extract-fingerprint.ts",
13
  "apply-update": "tsx scripts/apply-update.ts",
14
+ "apply-update:dry": "tsx scripts/apply-update.ts --dry-run",
15
+ "setup": "tsx scripts/setup-curl.ts",
16
+ "update": "tsx scripts/full-update.ts",
17
+ "postinstall": "tsx scripts/setup-curl.ts"
18
  },
19
  "dependencies": {
20
  "hono": "^4.0.0",
public/dashboard.html CHANGED
@@ -808,15 +808,18 @@
808
  const model = escapeHtml(document.getElementById('defaultModel').value);
809
 
810
  document.getElementById('code-python').innerHTML =
811
- `import openai
812
 
813
- openai.api_base = "${baseUrl}"
814
- openai.api_key = "${apiKey}"
815
- response = openai.ChatCompletion.create(
 
 
 
816
  model="${model}",
817
  messages=[
818
  {"role": "user", "content": "Explain quantum computing in simple terms"}
819
- ]
820
  )
821
 
822
  print(response.choices[0].message.content)
 
808
  const model = escapeHtml(document.getElementById('defaultModel').value);
809
 
810
  document.getElementById('code-python').innerHTML =
811
+ `from openai import OpenAI
812
 
813
+ client = OpenAI(
814
+ base_url="${baseUrl}",
815
+ api_key="${apiKey}",
816
+ )
817
+
818
+ response = client.chat.completions.create(
819
  model="${model}",
820
  messages=[
821
  {"role": "user", "content": "Explain quantum computing in simple terms"}
822
+ ],
823
  )
824
 
825
  print(response.choices[0].message.content)
public/login.html DELETED
@@ -1,527 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Codex Proxy - Login</title>
7
- <style>
8
- * { margin: 0; padding: 0; box-sizing: border-box; }
9
- body {
10
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
- background: #0d1117;
12
- color: #c9d1d9;
13
- min-height: 100vh;
14
- display: flex;
15
- align-items: center;
16
- justify-content: center;
17
- }
18
- .container {
19
- max-width: 420px;
20
- width: 100%;
21
- padding: 2rem;
22
- }
23
- .logo {
24
- text-align: center;
25
- margin-bottom: 2rem;
26
- }
27
- .logo h1 {
28
- font-size: 1.5rem;
29
- color: #58a6ff;
30
- font-weight: 600;
31
- }
32
- .logo p {
33
- color: #8b949e;
34
- margin-top: 0.5rem;
35
- font-size: 0.9rem;
36
- }
37
- .card {
38
- background: #161b22;
39
- border: 1px solid #30363d;
40
- border-radius: 12px;
41
- padding: 2rem;
42
- }
43
- .btn {
44
- display: block;
45
- width: 100%;
46
- padding: 12px 16px;
47
- border: none;
48
- border-radius: 8px;
49
- font-size: 1rem;
50
- font-weight: 500;
51
- cursor: pointer;
52
- text-align: center;
53
- text-decoration: none;
54
- transition: background 0.2s;
55
- }
56
- .btn-primary {
57
- background: #238636;
58
- color: #fff;
59
- }
60
- .btn-primary:hover {
61
- background: #2ea043;
62
- }
63
- .btn-primary:disabled {
64
- opacity: 0.5;
65
- cursor: not-allowed;
66
- }
67
- .divider {
68
- text-align: center;
69
- margin: 1.5rem 0;
70
- color: #484f58;
71
- font-size: 0.85rem;
72
- }
73
- .divider::before, .divider::after {
74
- content: '';
75
- display: inline-block;
76
- width: 30%;
77
- height: 1px;
78
- background: #30363d;
79
- vertical-align: middle;
80
- margin: 0 0.5rem;
81
- }
82
- .input-group {
83
- margin-bottom: 1rem;
84
- }
85
- .input-group label {
86
- display: block;
87
- font-size: 0.85rem;
88
- color: #8b949e;
89
- margin-bottom: 0.5rem;
90
- }
91
- .input-group textarea {
92
- width: 100%;
93
- padding: 10px;
94
- background: #0d1117;
95
- border: 1px solid #30363d;
96
- border-radius: 6px;
97
- color: #c9d1d9;
98
- font-family: monospace;
99
- font-size: 0.85rem;
100
- resize: vertical;
101
- min-height: 80px;
102
- }
103
- .input-group textarea:focus {
104
- outline: none;
105
- border-color: #58a6ff;
106
- }
107
- .error {
108
- color: #f85149;
109
- font-size: 0.85rem;
110
- margin-top: 0.5rem;
111
- display: none;
112
- }
113
- .success {
114
- color: #3fb950;
115
- font-size: 0.85rem;
116
- margin-top: 0.5rem;
117
- display: none;
118
- }
119
- .help {
120
- margin-top: 1rem;
121
- font-size: 0.8rem;
122
- color: #484f58;
123
- line-height: 1.5;
124
- }
125
- #pasteSection {
126
- display: none;
127
- }
128
- .paste-instructions {
129
- background: #0d1117;
130
- border: 1px solid #30363d;
131
- border-radius: 6px;
132
- padding: 0.75rem;
133
- margin-bottom: 1rem;
134
- font-size: 0.82rem;
135
- line-height: 1.5;
136
- color: #8b949e;
137
- }
138
- .paste-instructions strong {
139
- color: #c9d1d9;
140
- }
141
- .paste-instructions ol {
142
- margin: 0.5rem 0 0 1.2rem;
143
- }
144
- .btn-secondary {
145
- display: block;
146
- width: 100%;
147
- padding: 12px 16px;
148
- border: 1px solid #30363d;
149
- border-radius: 8px;
150
- background: #21262d;
151
- color: #c9d1d9;
152
- font-size: 1rem;
153
- font-weight: 500;
154
- cursor: pointer;
155
- text-align: center;
156
- transition: background 0.2s;
157
- }
158
- .btn-secondary:hover { background: #30363d; }
159
- .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
160
- .device-code-box {
161
- background: #0d1117;
162
- border: 1px solid #30363d;
163
- border-radius: 8px;
164
- padding: 1.5rem;
165
- text-align: center;
166
- margin-top: 1rem;
167
- display: none;
168
- }
169
- .device-code-box .user-code {
170
- font-family: monospace;
171
- font-size: 2rem;
172
- font-weight: 700;
173
- color: #58a6ff;
174
- letter-spacing: 0.15em;
175
- margin: 0.75rem 0;
176
- }
177
- .device-code-box .verify-link {
178
- color: #3fb950;
179
- text-decoration: none;
180
- font-size: 0.9rem;
181
- }
182
- .device-code-box .verify-link:hover { text-decoration: underline; }
183
- .device-code-box .status-text {
184
- color: #8b949e;
185
- font-size: 0.82rem;
186
- margin-top: 0.75rem;
187
- }
188
- .spinner {
189
- display: inline-block;
190
- width: 14px;
191
- height: 14px;
192
- border: 2px solid rgba(255,255,255,0.3);
193
- border-top-color: #fff;
194
- border-radius: 50%;
195
- animation: spin 0.8s linear infinite;
196
- vertical-align: middle;
197
- margin-right: 6px;
198
- }
199
- @keyframes spin { to { transform: rotate(360deg); } }
200
- </style>
201
- </head>
202
- <body>
203
- <div class="container">
204
- <div class="logo">
205
- <h1>Codex Proxy</h1>
206
- <p>OpenAI-compatible API for Codex Desktop</p>
207
- </div>
208
- <div class="card">
209
- <button class="btn btn-primary" id="loginBtn" onclick="startLogin()">
210
- Login with ChatGPT
211
- </button>
212
-
213
- <div id="pasteSection">
214
- <div class="paste-instructions">
215
- <strong>Remote login:</strong> If the popup shows an error or you're on a different machine:
216
- <ol>
217
- <li>Complete login in the popup</li>
218
- <li>Copy the full URL from the popup's address bar<br>(starts with <code>http://localhost:...</code>)</li>
219
- <li>Paste it below and click Submit</li>
220
- </ol>
221
- </div>
222
- <div class="input-group">
223
- <label>Paste the callback URL here</label>
224
- <textarea id="callbackInput" placeholder="http://localhost:54321/auth/callback?code=...&state=..."></textarea>
225
- </div>
226
- <button class="btn btn-primary" id="relayBtn" onclick="submitRelay()">
227
- Submit Callback URL
228
- </button>
229
- <div class="error" id="relayError"></div>
230
- <div class="success" id="relaySuccess"></div>
231
- </div>
232
-
233
- <div class="divider">or</div>
234
-
235
- <button class="btn btn-primary" id="deviceCodeBtn" onclick="startDeviceCode()">
236
- Sign in with Device Code
237
- </button>
238
- <div class="device-code-box" id="deviceCodeBox">
239
- <div style="color:#8b949e;font-size:0.85rem">Enter this code at:</div>
240
- <a class="verify-link" id="deviceVerifyLink" href="#" target="_blank" rel="noopener"></a>
241
- <div class="user-code" id="deviceUserCode"></div>
242
- <div class="status-text" id="deviceStatus"><span class="spinner"></span> Waiting for authorization...</div>
243
- <div class="error" id="deviceError"></div>
244
- <div class="success" id="deviceSuccess"></div>
245
- </div>
246
-
247
- <div style="margin-top:0.75rem">
248
- <button class="btn-secondary" id="cliImportBtn" onclick="importCli()">
249
- Import CLI Token (~/.codex/auth.json)
250
- </button>
251
- <div class="error" id="cliError"></div>
252
- <div class="success" id="cliSuccess"></div>
253
- </div>
254
-
255
- <div class="divider">or</div>
256
-
257
- <div id="manualSection">
258
- <div class="input-group">
259
- <label>Paste your ChatGPT access token</label>
260
- <textarea id="tokenInput" placeholder="eyJhbGciOiJSUzI1NiIs..."></textarea>
261
- </div>
262
- <button class="btn btn-primary" id="submitToken" onclick="submitToken()">
263
- Submit Token
264
- </button>
265
- <div class="error" id="errorMsg"></div>
266
- <div class="success" id="successMsg"></div>
267
- <div class="help">
268
- To get your token: Open ChatGPT in browser &rarr; DevTools (F12) &rarr;
269
- Application &rarr; Cookies &rarr; find <code>__Secure-next-auth.session-token</code>
270
- or check Network tab for <code>Authorization: Bearer ...</code> header.
271
- </div>
272
- </div>
273
- </div>
274
- </div>
275
-
276
- <script>
277
- let pollTimer = null;
278
-
279
- // Listen for postMessage from OAuth callback popup (cross-port communication)
280
- window.addEventListener('message', (event) => {
281
- if (event.data?.type === 'oauth-callback-success') {
282
- if (pollTimer) clearInterval(pollTimer);
283
- window.location.href = '/';
284
- }
285
- });
286
-
287
- async function startLogin() {
288
- const btn = document.getElementById('loginBtn');
289
- btn.disabled = true;
290
- btn.innerHTML = '<span class="spinner"></span> Opening login...';
291
-
292
- try {
293
- const resp = await fetch('/auth/login-start', { method: 'POST' });
294
- const data = await resp.json();
295
-
296
- if (!resp.ok || !data.authUrl) {
297
- throw new Error(data.error || 'Failed to start login');
298
- }
299
-
300
- // Open Auth0 in popup
301
- const popup = window.open(data.authUrl, 'oauth_login', 'width=600,height=700,scrollbars=yes');
302
-
303
- // Show paste section
304
- document.getElementById('pasteSection').style.display = 'block';
305
- btn.innerHTML = 'Login with ChatGPT';
306
- btn.disabled = false;
307
-
308
- // Poll auth status — if callback server handles it, we auto-redirect
309
- startPolling();
310
-
311
- } catch (err) {
312
- btn.innerHTML = 'Login with ChatGPT';
313
- btn.disabled = false;
314
- const errEl = document.getElementById('relayError');
315
- errEl.textContent = err.message;
316
- errEl.style.display = 'block';
317
- document.getElementById('pasteSection').style.display = 'block';
318
- }
319
- }
320
-
321
- function startPolling() {
322
- if (pollTimer) clearInterval(pollTimer);
323
- pollTimer = setInterval(async () => {
324
- try {
325
- const resp = await fetch('/auth/status');
326
- const data = await resp.json();
327
- if (data.authenticated) {
328
- clearInterval(pollTimer);
329
- window.location.href = '/';
330
- }
331
- } catch {}
332
- }, 2000);
333
-
334
- // Stop polling after 5 minutes
335
- setTimeout(() => {
336
- if (pollTimer) clearInterval(pollTimer);
337
- }, 5 * 60 * 1000);
338
- }
339
-
340
- async function submitRelay() {
341
- const callbackUrl = document.getElementById('callbackInput').value.trim();
342
- const errEl = document.getElementById('relayError');
343
- const successEl = document.getElementById('relaySuccess');
344
- errEl.style.display = 'none';
345
- successEl.style.display = 'none';
346
-
347
- if (!callbackUrl) {
348
- errEl.textContent = 'Please paste the callback URL';
349
- errEl.style.display = 'block';
350
- return;
351
- }
352
-
353
- const btn = document.getElementById('relayBtn');
354
- btn.disabled = true;
355
- btn.innerHTML = '<span class="spinner"></span> Exchanging...';
356
-
357
- try {
358
- const resp = await fetch('/auth/code-relay', {
359
- method: 'POST',
360
- headers: { 'Content-Type': 'application/json' },
361
- body: JSON.stringify({ callbackUrl }),
362
- });
363
- const data = await resp.json();
364
-
365
- if (resp.ok && data.success) {
366
- successEl.textContent = 'Login successful! Redirecting...';
367
- successEl.style.display = 'block';
368
- if (pollTimer) clearInterval(pollTimer);
369
- setTimeout(() => window.location.href = '/', 1000);
370
- } else {
371
- errEl.textContent = data.error || 'Failed to exchange code';
372
- errEl.style.display = 'block';
373
- }
374
- } catch (err) {
375
- errEl.textContent = 'Network error: ' + err.message;
376
- errEl.style.display = 'block';
377
- } finally {
378
- btn.innerHTML = 'Submit Callback URL';
379
- btn.disabled = false;
380
- }
381
- }
382
-
383
- // ── Device Code Flow ──────────────────────────────
384
- let devicePollTimer = null;
385
-
386
- async function startDeviceCode() {
387
- const btn = document.getElementById('deviceCodeBtn');
388
- const box = document.getElementById('deviceCodeBox');
389
- const errEl = document.getElementById('deviceError');
390
- const successEl = document.getElementById('deviceSuccess');
391
- const statusEl = document.getElementById('deviceStatus');
392
- errEl.style.display = 'none';
393
- successEl.style.display = 'none';
394
-
395
- btn.disabled = true;
396
- btn.innerHTML = '<span class="spinner"></span> Requesting code...';
397
-
398
- try {
399
- const resp = await fetch('/auth/device-login', { method: 'POST' });
400
- const data = await resp.json();
401
-
402
- if (!resp.ok || !data.userCode) {
403
- throw new Error(data.error || 'Failed to request device code');
404
- }
405
-
406
- // Show the code box
407
- box.style.display = 'block';
408
- document.getElementById('deviceUserCode').textContent = data.userCode;
409
- const link = document.getElementById('deviceVerifyLink');
410
- link.href = data.verificationUriComplete || data.verificationUri;
411
- link.textContent = data.verificationUri;
412
- statusEl.innerHTML = '<span class="spinner"></span> Waiting for authorization...';
413
-
414
- btn.innerHTML = 'Sign in with Device Code';
415
- btn.disabled = false;
416
-
417
- // Start polling
418
- const interval = (data.interval || 5) * 1000;
419
- const deviceCode = data.deviceCode;
420
- if (devicePollTimer) clearInterval(devicePollTimer);
421
-
422
- devicePollTimer = setInterval(async () => {
423
- try {
424
- const pollResp = await fetch('/auth/device-poll/' + encodeURIComponent(deviceCode));
425
- const pollData = await pollResp.json();
426
-
427
- if (pollData.success) {
428
- clearInterval(devicePollTimer);
429
- statusEl.innerHTML = '';
430
- successEl.textContent = 'Login successful! Redirecting...';
431
- successEl.style.display = 'block';
432
- if (pollTimer) clearInterval(pollTimer);
433
- setTimeout(() => window.location.href = '/', 1000);
434
- } else if (pollData.error) {
435
- clearInterval(devicePollTimer);
436
- statusEl.innerHTML = '';
437
- errEl.textContent = pollData.error;
438
- errEl.style.display = 'block';
439
- }
440
- // if pollData.pending, keep polling
441
- } catch {}
442
- }, interval);
443
-
444
- // Stop after expiry
445
- setTimeout(() => {
446
- if (devicePollTimer) {
447
- clearInterval(devicePollTimer);
448
- statusEl.textContent = 'Code expired. Please try again.';
449
- }
450
- }, (data.expiresIn || 900) * 1000);
451
-
452
- } catch (err) {
453
- btn.innerHTML = 'Sign in with Device Code';
454
- btn.disabled = false;
455
- errEl.textContent = err.message;
456
- errEl.style.display = 'block';
457
- }
458
- }
459
-
460
- // ── CLI Token Import ─────────────────────────────
461
- async function importCli() {
462
- const btn = document.getElementById('cliImportBtn');
463
- const errEl = document.getElementById('cliError');
464
- const successEl = document.getElementById('cliSuccess');
465
- errEl.style.display = 'none';
466
- successEl.style.display = 'none';
467
- btn.disabled = true;
468
- btn.textContent = 'Importing...';
469
-
470
- try {
471
- const resp = await fetch('/auth/import-cli', { method: 'POST' });
472
- const data = await resp.json();
473
-
474
- if (resp.ok && data.success) {
475
- successEl.textContent = 'CLI token imported! Redirecting...';
476
- successEl.style.display = 'block';
477
- if (pollTimer) clearInterval(pollTimer);
478
- setTimeout(() => window.location.href = '/', 1000);
479
- } else {
480
- errEl.textContent = data.error || 'Failed to import CLI token';
481
- errEl.style.display = 'block';
482
- }
483
- } catch (err) {
484
- errEl.textContent = 'Network error: ' + err.message;
485
- errEl.style.display = 'block';
486
- } finally {
487
- btn.textContent = 'Import CLI Token (~/.codex/auth.json)';
488
- btn.disabled = false;
489
- }
490
- }
491
-
492
- async function submitToken() {
493
- const token = document.getElementById('tokenInput').value.trim();
494
- const errorEl = document.getElementById('errorMsg');
495
- const successEl = document.getElementById('successMsg');
496
- errorEl.style.display = 'none';
497
- successEl.style.display = 'none';
498
-
499
- if (!token) {
500
- errorEl.textContent = 'Please paste a token';
501
- errorEl.style.display = 'block';
502
- return;
503
- }
504
-
505
- try {
506
- const resp = await fetch('/auth/token', {
507
- method: 'POST',
508
- headers: { 'Content-Type': 'application/json' },
509
- body: JSON.stringify({ token }),
510
- });
511
- const data = await resp.json();
512
- if (resp.ok) {
513
- successEl.textContent = 'Login successful! Redirecting...';
514
- successEl.style.display = 'block';
515
- setTimeout(() => window.location.href = '/', 1000);
516
- } else {
517
- errorEl.textContent = data.error || 'Invalid token';
518
- errorEl.style.display = 'block';
519
- }
520
- } catch (err) {
521
- errorEl.textContent = 'Network error: ' + err.message;
522
- errorEl.style.display = 'block';
523
- }
524
- }
525
- </script>
526
- </body>
527
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/apply-update.ts DELETED
@@ -1,354 +0,0 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * apply-update.ts — Compares extracted fingerprint with current config and applies updates.
4
- *
5
- * Usage:
6
- * npx tsx scripts/apply-update.ts [--dry-run]
7
- *
8
- * --dry-run: Show what would change without modifying files.
9
- */
10
-
11
- import { readFileSync, writeFileSync, existsSync } from "fs";
12
- import { resolve } from "path";
13
- import { createHash } from "crypto";
14
- import yaml from "js-yaml";
15
-
16
- const ROOT = resolve(import.meta.dirname, "..");
17
- const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
- const FINGERPRINT_PATH = resolve(ROOT, "config/fingerprint.yaml");
19
- const EXTRACTED_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
20
- const MODELS_PATH = resolve(ROOT, "config/models.yaml");
21
- const PROMPTS_DIR = resolve(ROOT, "config/prompts");
22
-
23
- type ChangeType = "auto" | "semi-auto" | "alert";
24
-
25
- interface Change {
26
- type: ChangeType;
27
- category: string;
28
- description: string;
29
- current: string;
30
- updated: string;
31
- file: string;
32
- }
33
-
34
- interface ExtractedFingerprint {
35
- app_version: string;
36
- build_number: string;
37
- api_base_url: string | null;
38
- originator: string | null;
39
- models: string[];
40
- wham_endpoints: string[];
41
- user_agent_contains: string;
42
- prompts: {
43
- desktop_context_hash: string | null;
44
- desktop_context_path: string | null;
45
- title_generation_hash: string | null;
46
- title_generation_path: string | null;
47
- pr_generation_hash: string | null;
48
- pr_generation_path: string | null;
49
- automation_response_hash: string | null;
50
- automation_response_path: string | null;
51
- };
52
- }
53
-
54
- function loadExtracted(): ExtractedFingerprint {
55
- if (!existsSync(EXTRACTED_PATH)) {
56
- throw new Error(
57
- `No extracted fingerprint found at ${EXTRACTED_PATH}.\n` +
58
- `Run: npm run extract -- --path <codex-path>`
59
- );
60
- }
61
- return JSON.parse(readFileSync(EXTRACTED_PATH, "utf-8"));
62
- }
63
-
64
- function loadCurrentConfig(): Record<string, unknown> {
65
- return yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
66
- }
67
-
68
- function detectChanges(extracted: ExtractedFingerprint): Change[] {
69
- const changes: Change[] = [];
70
- const config = loadCurrentConfig();
71
- const client = config.client as Record<string, string>;
72
-
73
- // Version
74
- if (extracted.app_version !== client.app_version) {
75
- changes.push({
76
- type: "auto",
77
- category: "version",
78
- description: "App version changed",
79
- current: client.app_version,
80
- updated: extracted.app_version,
81
- file: CONFIG_PATH,
82
- });
83
- }
84
-
85
- // Build number
86
- if (extracted.build_number !== client.build_number) {
87
- changes.push({
88
- type: "auto",
89
- category: "build",
90
- description: "Build number changed",
91
- current: client.build_number,
92
- updated: extracted.build_number,
93
- file: CONFIG_PATH,
94
- });
95
- }
96
-
97
- // Originator
98
- if (extracted.originator && extracted.originator !== client.originator) {
99
- changes.push({
100
- type: "auto",
101
- category: "originator",
102
- description: "Originator header changed",
103
- current: client.originator,
104
- updated: extracted.originator,
105
- file: CONFIG_PATH,
106
- });
107
- }
108
-
109
- // API base URL
110
- const api = config.api as Record<string, string>;
111
- if (extracted.api_base_url && extracted.api_base_url !== api.base_url) {
112
- changes.push({
113
- type: "alert",
114
- category: "api_url",
115
- description: "API base URL changed (CRITICAL)",
116
- current: api.base_url,
117
- updated: extracted.api_base_url,
118
- file: CONFIG_PATH,
119
- });
120
- }
121
-
122
- // Models — compare against config/models.yaml
123
- const modelsYaml = yaml.load(readFileSync(MODELS_PATH, "utf-8")) as {
124
- models: { id: string }[];
125
- };
126
- const currentModels = modelsYaml.models.map((m) => m.id);
127
- const extractedModels = extracted.models.filter((m) => m.includes("codex") || currentModels.includes(m));
128
-
129
- const newModels = extractedModels.filter((m) => !currentModels.includes(m));
130
- const removedModels = currentModels.filter((m) => !extractedModels.includes(m));
131
-
132
- if (newModels.length > 0) {
133
- changes.push({
134
- type: "semi-auto",
135
- category: "models_added",
136
- description: `New models found: ${newModels.join(", ")}`,
137
- current: currentModels.join(", "),
138
- updated: extractedModels.join(", "),
139
- file: MODELS_PATH,
140
- });
141
- }
142
-
143
- if (removedModels.length > 0) {
144
- changes.push({
145
- type: "semi-auto",
146
- category: "models_removed",
147
- description: `Models removed: ${removedModels.join(", ")}`,
148
- current: currentModels.join(", "),
149
- updated: extractedModels.join(", "),
150
- file: MODELS_PATH,
151
- });
152
- }
153
-
154
- // WHAM endpoints — check for new ones
155
- const knownEndpoints = [
156
- "/wham/tasks", "/wham/environments", "/wham/accounts/check",
157
- "/wham/usage",
158
- ];
159
- const newEndpoints = extracted.wham_endpoints.filter(
160
- (ep) => !knownEndpoints.some((k) => ep.startsWith(k))
161
- );
162
- if (newEndpoints.length > 0) {
163
- changes.push({
164
- type: "alert",
165
- category: "endpoints",
166
- description: `New WHAM endpoints found: ${newEndpoints.join(", ")}`,
167
- current: "(known set)",
168
- updated: newEndpoints.join(", "),
169
- file: "src/proxy/wham-api.ts",
170
- });
171
- }
172
-
173
- // System prompts — check hash changes
174
- const promptConfigs = [
175
- { name: "desktop-context", hash: extracted.prompts.desktop_context_hash, path: extracted.prompts.desktop_context_path },
176
- { name: "title-generation", hash: extracted.prompts.title_generation_hash, path: extracted.prompts.title_generation_path },
177
- { name: "pr-generation", hash: extracted.prompts.pr_generation_hash, path: extracted.prompts.pr_generation_path },
178
- { name: "automation-response", hash: extracted.prompts.automation_response_hash, path: extracted.prompts.automation_response_path },
179
- ];
180
-
181
- for (const { name, hash, path } of promptConfigs) {
182
- if (!hash || !path) continue;
183
-
184
- const configPromptPath = resolve(PROMPTS_DIR, `${name}.md`);
185
- if (!existsSync(configPromptPath)) {
186
- changes.push({
187
- type: "semi-auto",
188
- category: `prompt_${name}`,
189
- description: `New prompt file: ${name}.md (not in config/prompts/)`,
190
- current: "(missing)",
191
- updated: hash,
192
- file: configPromptPath,
193
- });
194
- continue;
195
- }
196
-
197
- const currentContent = readFileSync(configPromptPath, "utf-8");
198
- const currentHash = `sha256:${createHash("sha256").update(currentContent).digest("hex").slice(0, 16)}`;
199
-
200
- if (currentHash !== hash) {
201
- changes.push({
202
- type: "semi-auto",
203
- category: `prompt_${name}`,
204
- description: `System prompt changed: ${name}`,
205
- current: currentHash,
206
- updated: hash,
207
- file: configPromptPath,
208
- });
209
- }
210
- }
211
-
212
- return changes;
213
- }
214
-
215
- function applyAutoChanges(changes: Change[], dryRun: boolean): void {
216
- const autoChanges = changes.filter((c) => c.type === "auto");
217
-
218
- if (autoChanges.length === 0) {
219
- console.log("\n No auto-applicable changes.");
220
- return;
221
- }
222
-
223
- // Group config changes
224
- const configChanges = autoChanges.filter((c) => c.file === CONFIG_PATH);
225
-
226
- if (configChanges.length > 0 && !dryRun) {
227
- let configContent = readFileSync(CONFIG_PATH, "utf-8");
228
-
229
- for (const change of configChanges) {
230
- switch (change.category) {
231
- case "version":
232
- configContent = configContent.replace(
233
- /app_version:\s*"[^"]+"/,
234
- `app_version: "${change.updated}"`,
235
- );
236
- break;
237
- case "build":
238
- configContent = configContent.replace(
239
- /build_number:\s*"[^"]+"/,
240
- `build_number: "${change.updated}"`,
241
- );
242
- break;
243
- case "originator":
244
- configContent = configContent.replace(
245
- /originator:\s*"[^"]+"/,
246
- `originator: "${change.updated}"`,
247
- );
248
- break;
249
- }
250
- }
251
-
252
- writeFileSync(CONFIG_PATH, configContent);
253
- console.log(` [APPLIED] config/default.yaml updated`);
254
- }
255
- }
256
-
257
- function displayReport(changes: Change[], dryRun: boolean): void {
258
- console.log("\n╔══════════════════════════════════════════╗");
259
- console.log(`║ Update Analysis ${dryRun ? "(DRY RUN)" : ""} ║`);
260
- console.log("╠══════════════════════════════════════════╣");
261
-
262
- if (changes.length === 0) {
263
- console.log("║ No changes detected — up to date! ║");
264
- console.log("╚══════════════════════════════════════════╝");
265
- return;
266
- }
267
-
268
- console.log("╚══════════════════════════════════════════╝\n");
269
-
270
- // Auto changes
271
- const auto = changes.filter((c) => c.type === "auto");
272
- if (auto.length > 0) {
273
- console.log(` AUTO-APPLY (${auto.length}):`);
274
- for (const c of auto) {
275
- const action = dryRun ? "WOULD APPLY" : "APPLIED";
276
- console.log(` [${action}] ${c.description}`);
277
- console.log(` ${c.current} → ${c.updated}`);
278
- }
279
- }
280
-
281
- // Semi-auto changes
282
- const semi = changes.filter((c) => c.type === "semi-auto");
283
- if (semi.length > 0) {
284
- console.log(`\n SEMI-AUTO (needs review) (${semi.length}):`);
285
- for (const c of semi) {
286
- console.log(` [REVIEW] ${c.description}`);
287
- console.log(` File: ${c.file}`);
288
- console.log(` Current: ${c.current}`);
289
- console.log(` New: ${c.updated}`);
290
- }
291
- }
292
-
293
- // Alerts
294
- const alerts = changes.filter((c) => c.type === "alert");
295
- if (alerts.length > 0) {
296
- console.log(`\n *** ALERTS (${alerts.length}) ***`);
297
- for (const c of alerts) {
298
- console.log(` [ALERT] ${c.description}`);
299
- console.log(` File: ${c.file}`);
300
- console.log(` Current: ${c.current}`);
301
- console.log(` New: ${c.updated}`);
302
- }
303
- }
304
-
305
- // Prompt diffs
306
- const promptChanges = changes.filter((c) => c.category.startsWith("prompt_"));
307
- if (promptChanges.length > 0) {
308
- console.log("\n PROMPT CHANGES:");
309
- console.log(" To apply prompt updates, copy from data/extracted-prompts/ to config/prompts/:");
310
- for (const c of promptChanges) {
311
- const name = c.category.replace("prompt_", "");
312
- console.log(` cp data/extracted-prompts/${name}.md config/prompts/${name}.md`);
313
- }
314
- }
315
-
316
- console.log("");
317
- }
318
-
319
- async function main() {
320
- const dryRun = process.argv.includes("--dry-run");
321
-
322
- console.log("[apply-update] Loading extracted fingerprint...");
323
- const extracted = loadExtracted();
324
- console.log(` Extracted: v${extracted.app_version} (build ${extracted.build_number})`);
325
-
326
- console.log("[apply-update] Comparing with current config...");
327
- const changes = detectChanges(extracted);
328
-
329
- displayReport(changes, dryRun);
330
-
331
- if (!dryRun) {
332
- applyAutoChanges(changes, dryRun);
333
- }
334
-
335
- // Summary
336
- const auto = changes.filter((c) => c.type === "auto").length;
337
- const semi = changes.filter((c) => c.type === "semi-auto").length;
338
- const alerts = changes.filter((c) => c.type === "alert").length;
339
-
340
- console.log(`[apply-update] Summary: ${auto} auto, ${semi} semi-auto, ${alerts} alerts`);
341
-
342
- if (semi > 0 || alerts > 0) {
343
- console.log("[apply-update] Manual review needed for semi-auto and alert items above.");
344
- }
345
-
346
- if (dryRun && auto > 0) {
347
- console.log("[apply-update] Run without --dry-run to apply auto changes.");
348
- }
349
- }
350
-
351
- main().catch((err) => {
352
- console.error("[apply-update] Fatal:", err);
353
- process.exit(1);
354
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/check-update.ts DELETED
@@ -1,157 +0,0 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * check-update.ts — Polls the Codex Sparkle appcast feed for new versions.
4
- *
5
- * Usage:
6
- * npx tsx scripts/check-update.ts [--watch]
7
- *
8
- * With --watch: polls every 30 minutes and keeps running.
9
- * Without: runs once and exits.
10
- */
11
-
12
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
13
- import { resolve } from "path";
14
- import yaml from "js-yaml";
15
-
16
- const ROOT = resolve(import.meta.dirname, "..");
17
- const CONFIG_PATH = resolve(ROOT, "config/default.yaml");
18
- const STATE_PATH = resolve(ROOT, "data/update-state.json");
19
- const APPCAST_URL = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
20
- const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
21
-
22
- interface UpdateState {
23
- last_check: string;
24
- latest_version: string | null;
25
- latest_build: string | null;
26
- download_url: string | null;
27
- update_available: boolean;
28
- current_version: string;
29
- current_build: string;
30
- }
31
-
32
- interface CurrentConfig {
33
- app_version: string;
34
- build_number: string;
35
- }
36
-
37
- function loadCurrentConfig(): CurrentConfig {
38
- const raw = yaml.load(readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
39
- const client = raw.client as Record<string, string>;
40
- return {
41
- app_version: client.app_version,
42
- build_number: client.build_number,
43
- };
44
- }
45
-
46
- /**
47
- * Parse appcast XML to extract version info.
48
- * Uses regex-based parsing to avoid heavy XML dependencies.
49
- */
50
- function parseAppcast(xml: string): {
51
- version: string | null;
52
- build: string | null;
53
- downloadUrl: string | null;
54
- } {
55
- // Extract the latest <item> (first one is usually the latest)
56
- const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i);
57
- if (!itemMatch) {
58
- return { version: null, build: null, downloadUrl: null };
59
- }
60
- const item = itemMatch[1];
61
-
62
- // sparkle:shortVersionString = display version
63
- const versionMatch = item.match(/sparkle:shortVersionString="([^"]+)"/);
64
- // sparkle:version = build number
65
- const buildMatch = item.match(/sparkle:version="([^"]+)"/);
66
- // url = download URL from enclosure
67
- const urlMatch = item.match(/url="([^"]+)"/);
68
-
69
- return {
70
- version: versionMatch?.[1] ?? null,
71
- build: buildMatch?.[1] ?? null,
72
- downloadUrl: urlMatch?.[1] ?? null,
73
- };
74
- }
75
-
76
- async function checkOnce(): Promise<UpdateState> {
77
- const current = loadCurrentConfig();
78
-
79
- console.log(`[check-update] Current: v${current.app_version} (build ${current.build_number})`);
80
- console.log(`[check-update] Fetching ${APPCAST_URL}...`);
81
-
82
- const res = await fetch(APPCAST_URL);
83
- if (!res.ok) {
84
- throw new Error(`Failed to fetch appcast: ${res.status} ${res.statusText}`);
85
- }
86
-
87
- const xml = await res.text();
88
- const { version, build, downloadUrl } = parseAppcast(xml);
89
-
90
- if (!version || !build) {
91
- console.warn("[check-update] Could not parse version from appcast");
92
- return {
93
- last_check: new Date().toISOString(),
94
- latest_version: null,
95
- latest_build: null,
96
- download_url: null,
97
- update_available: false,
98
- current_version: current.app_version,
99
- current_build: current.build_number,
100
- };
101
- }
102
-
103
- const updateAvailable =
104
- version !== current.app_version || build !== current.build_number;
105
-
106
- const state: UpdateState = {
107
- last_check: new Date().toISOString(),
108
- latest_version: version,
109
- latest_build: build,
110
- download_url: downloadUrl,
111
- update_available: updateAvailable,
112
- current_version: current.app_version,
113
- current_build: current.build_number,
114
- };
115
-
116
- if (updateAvailable) {
117
- console.log(`\n *** UPDATE AVAILABLE ***`);
118
- console.log(` New version: ${version} (build ${build})`);
119
- console.log(` Current: ${current.app_version} (build ${current.build_number})`);
120
- if (downloadUrl) {
121
- console.log(` Download: ${downloadUrl}`);
122
- }
123
- console.log(`\n Run: npm run extract -- --path <new-codex-path>`);
124
- console.log(` Then: npm run apply-update\n`);
125
- } else {
126
- console.log(`[check-update] Up to date: v${version} (build ${build})`);
127
- }
128
-
129
- // Write state
130
- mkdirSync(resolve(ROOT, "data"), { recursive: true });
131
- writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
132
- console.log(`[check-update] State written to ${STATE_PATH}`);
133
-
134
- return state;
135
- }
136
-
137
- async function main() {
138
- const watch = process.argv.includes("--watch");
139
-
140
- await checkOnce();
141
-
142
- if (watch) {
143
- console.log(`[check-update] Watching for updates every ${POLL_INTERVAL_MS / 60000} minutes...`);
144
- setInterval(async () => {
145
- try {
146
- await checkOnce();
147
- } catch (err) {
148
- console.error("[check-update] Poll error:", err);
149
- }
150
- }, POLL_INTERVAL_MS);
151
- }
152
- }
153
-
154
- main().catch((err) => {
155
- console.error("[check-update] Fatal:", err);
156
- process.exit(1);
157
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/extract-fingerprint.ts DELETED
@@ -1,445 +0,0 @@
1
- #!/usr/bin/env tsx
2
- /**
3
- * extract-fingerprint.ts — Extracts key fingerprint values from a Codex Desktop
4
- * installation (macOS .app or Windows extracted ASAR).
5
- *
6
- * Usage:
7
- * npx tsx scripts/extract-fingerprint.ts --path "C:/path/to/Codex" [--asar-out ./asar-out]
8
- *
9
- * The path can point to:
10
- * - A macOS .app bundle (Codex.app)
11
- * - A directory containing an already-extracted ASAR (with package.json and .vite/build/main.js)
12
- * - A Windows install dir containing resources/app.asar
13
- */
14
-
15
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
16
- import { resolve, join } from "path";
17
- import { createHash } from "crypto";
18
- import { execSync } from "child_process";
19
- import yaml from "js-yaml";
20
-
21
- const ROOT = resolve(import.meta.dirname, "..");
22
- const OUTPUT_PATH = resolve(ROOT, "data/extracted-fingerprint.json");
23
- const PROMPTS_DIR = resolve(ROOT, "data/extracted-prompts");
24
- const PATTERNS_PATH = resolve(ROOT, "config/extraction-patterns.yaml");
25
-
26
- interface ExtractionPatterns {
27
- package_json: { version_key: string; build_number_key: string; sparkle_feed_key: string };
28
- main_js: Record<string, {
29
- pattern?: string;
30
- group?: number;
31
- global?: boolean;
32
- start_marker?: string;
33
- end_marker?: string;
34
- end_pattern?: string;
35
- description: string;
36
- }>;
37
- }
38
-
39
- interface ExtractedFingerprint {
40
- app_version: string;
41
- build_number: string;
42
- api_base_url: string | null;
43
- originator: string | null;
44
- models: string[];
45
- wham_endpoints: string[];
46
- user_agent_contains: string;
47
- sparkle_feed_url: string | null;
48
- prompts: {
49
- desktop_context_hash: string | null;
50
- desktop_context_path: string | null;
51
- title_generation_hash: string | null;
52
- title_generation_path: string | null;
53
- pr_generation_hash: string | null;
54
- pr_generation_path: string | null;
55
- automation_response_hash: string | null;
56
- automation_response_path: string | null;
57
- };
58
- extracted_at: string;
59
- source_path: string;
60
- }
61
-
62
- function sha256(content: string): string {
63
- return `sha256:${createHash("sha256").update(content, "utf-8").digest("hex").slice(0, 16)}`;
64
- }
65
-
66
- function loadPatterns(): ExtractionPatterns {
67
- const raw = yaml.load(readFileSync(PATTERNS_PATH, "utf-8")) as ExtractionPatterns;
68
- return raw;
69
- }
70
-
71
- /**
72
- * Find the extracted ASAR root given an input path.
73
- * Tries multiple layout conventions.
74
- */
75
- function findAsarRoot(inputPath: string): string {
76
- // Direct: path has package.json (already extracted)
77
- if (existsSync(join(inputPath, "package.json"))) {
78
- return inputPath;
79
- }
80
-
81
- // macOS .app bundle
82
- const macResources = join(inputPath, "Contents/Resources");
83
- if (existsSync(join(macResources, "app.asar"))) {
84
- return extractAsar(join(macResources, "app.asar"));
85
- }
86
-
87
- // Windows: resources/app.asar
88
- const winResources = join(inputPath, "resources");
89
- if (existsSync(join(winResources, "app.asar"))) {
90
- return extractAsar(join(winResources, "app.asar"));
91
- }
92
-
93
- // Already extracted: check for nested 'extracted' dir
94
- const extractedDir = join(inputPath, "extracted");
95
- if (existsSync(join(extractedDir, "package.json"))) {
96
- return extractedDir;
97
- }
98
-
99
- // Check recovered/extracted pattern
100
- const recoveredExtracted = join(inputPath, "recovered/extracted");
101
- if (existsSync(join(recoveredExtracted, "package.json"))) {
102
- return recoveredExtracted;
103
- }
104
-
105
- throw new Error(
106
- `Cannot find Codex source at ${inputPath}. Expected package.json or app.asar.`
107
- );
108
- }
109
-
110
- function extractAsar(asarPath: string): string {
111
- const outDir = resolve(ROOT, ".asar-out");
112
- console.log(`[extract] Extracting ASAR: ${asarPath} → ${outDir}`);
113
- execSync(`npx @electron/asar extract "${asarPath}" "${outDir}"`, {
114
- stdio: "inherit",
115
- });
116
- return outDir;
117
- }
118
-
119
- /**
120
- * Step A: Extract from package.json
121
- */
122
- function extractFromPackageJson(root: string): {
123
- version: string;
124
- buildNumber: string;
125
- sparkleFeedUrl: string | null;
126
- } {
127
- const pkgPath = join(root, "package.json");
128
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
129
-
130
- return {
131
- version: pkg.version ?? "unknown",
132
- buildNumber: String(pkg.codexBuildNumber ?? "unknown"),
133
- sparkleFeedUrl: pkg.codexSparkleFeedUrl ?? null,
134
- };
135
- }
136
-
137
- /**
138
- * Step B: Extract values from main.js using patterns
139
- */
140
- function extractFromMainJs(
141
- content: string,
142
- patterns: ExtractionPatterns["main_js"],
143
- ): {
144
- apiBaseUrl: string | null;
145
- originator: string | null;
146
- models: string[];
147
- whamEndpoints: string[];
148
- userAgentContains: string;
149
- } {
150
- // API base URL
151
- let apiBaseUrl: string | null = null;
152
- const apiPattern = patterns.api_base_url;
153
- if (apiPattern?.pattern) {
154
- const m = content.match(new RegExp(apiPattern.pattern));
155
- if (m) apiBaseUrl = m[0];
156
- }
157
-
158
- // Fail fast on critical fields
159
- if (!apiBaseUrl) {
160
- console.error("[extract] CRITICAL: Failed to extract API base URL from main.js");
161
- console.error("[extract] The extraction pattern may need updating for this version.");
162
- throw new Error("Failed to extract critical field: api_base_url");
163
- }
164
-
165
- // Originator
166
- let originator: string | null = null;
167
- const origPattern = patterns.originator;
168
- if (origPattern?.pattern) {
169
- const m = content.match(new RegExp(origPattern.pattern));
170
- if (m) originator = m[origPattern.group ?? 0] ?? m[0];
171
- }
172
-
173
- // Fail fast on critical fields
174
- if (!originator) {
175
- console.error("[extract] CRITICAL: Failed to extract originator from main.js");
176
- console.error("[extract] The extraction pattern may need updating for this version.");
177
- throw new Error("Failed to extract critical field: originator");
178
- }
179
-
180
- // Models — deduplicate, use capture group if specified
181
- const models: Set<string> = new Set();
182
- const modelPattern = patterns.models;
183
- if (modelPattern?.pattern) {
184
- const re = new RegExp(modelPattern.pattern, "g");
185
- const groupIdx = modelPattern.group ?? 0;
186
- for (const m of content.matchAll(re)) {
187
- models.add(m[groupIdx] ?? m[0]);
188
- }
189
- }
190
-
191
- // WHAM endpoints — deduplicate, use capture group if specified
192
- const endpoints: Set<string> = new Set();
193
- const epPattern = patterns.wham_endpoints;
194
- if (epPattern?.pattern) {
195
- const re = new RegExp(epPattern.pattern, "g");
196
- const epGroupIdx = epPattern.group ?? 0;
197
- for (const m of content.matchAll(re)) {
198
- endpoints.add(m[epGroupIdx] ?? m[0]);
199
- }
200
- }
201
-
202
- return {
203
- apiBaseUrl,
204
- originator,
205
- models: [...models].sort(),
206
- whamEndpoints: [...endpoints].sort(),
207
- userAgentContains: "Codex Desktop/",
208
- };
209
- }
210
-
211
- /**
212
- * Step B (continued): Extract system prompts from main.js
213
- */
214
- function extractPrompts(content: string): {
215
- desktopContext: string | null;
216
- titleGeneration: string | null;
217
- prGeneration: string | null;
218
- automationResponse: string | null;
219
- } {
220
- // Desktop context: from "# Codex desktop context" to the end of the template literal
221
- let desktopContext: string | null = null;
222
- const dcStart = content.indexOf("# Codex desktop context");
223
- if (dcStart !== -1) {
224
- // Find the closing backtick of the template literal
225
- // Look backwards from dcStart for the opening backtick to understand nesting
226
- // Then scan forward for the matching close
227
- const afterStart = content.indexOf("`;", dcStart);
228
- if (afterStart !== -1) {
229
- desktopContext = content.slice(dcStart, afterStart).trim();
230
- }
231
- }
232
-
233
- // Title generation: from the function that builds the array
234
- let titleGeneration: string | null = null;
235
- const titleMarker = "You are a helpful assistant. You will be presented with a user prompt";
236
- const titleStart = content.indexOf(titleMarker);
237
- if (titleStart !== -1) {
238
- // Find the enclosing array end: ].join(
239
- const joinIdx = content.indexOf("].join(", titleStart);
240
- if (joinIdx !== -1) {
241
- // Extract the array content between [ and ]
242
- const bracketStart = content.lastIndexOf("[", titleStart);
243
- if (bracketStart !== -1) {
244
- const arrayContent = content.slice(bracketStart + 1, joinIdx);
245
- // Parse string literals from the array
246
- titleGeneration = parseStringArray(arrayContent);
247
- }
248
- }
249
- }
250
-
251
- // PR generation
252
- let prGeneration: string | null = null;
253
- const prMarker = "You are a helpful assistant. Generate a pull request title";
254
- const prStart = content.indexOf(prMarker);
255
- if (prStart !== -1) {
256
- const joinIdx = content.indexOf("].join(", prStart);
257
- if (joinIdx !== -1) {
258
- const bracketStart = content.lastIndexOf("[", prStart);
259
- if (bracketStart !== -1) {
260
- const arrayContent = content.slice(bracketStart + 1, joinIdx);
261
- prGeneration = parseStringArray(arrayContent);
262
- }
263
- }
264
- }
265
-
266
- // Automation response: template literal starting with "Response MUST end with"
267
- let automationResponse: string | null = null;
268
- const autoMarker = "Response MUST end with a remark-directive block";
269
- const autoStart = content.indexOf(autoMarker);
270
- if (autoStart !== -1) {
271
- const afterAuto = content.indexOf("`;", autoStart);
272
- if (afterAuto !== -1) {
273
- automationResponse = content.slice(autoStart, afterAuto).trim();
274
- }
275
- }
276
-
277
- return { desktopContext, titleGeneration, prGeneration, automationResponse };
278
- }
279
-
280
- /**
281
- * Parse a JavaScript string array content into a single joined string.
282
- * Handles simple quoted strings separated by commas.
283
- */
284
- function parseStringArray(arrayContent: string): string {
285
- const lines: string[] = [];
286
- // Match quoted strings (both single and double quotes) and template literals
287
- const stringRe = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g;
288
- for (const m of arrayContent.matchAll(stringRe)) {
289
- const str = m[1] ?? m[2] ?? "";
290
- // Unescape common sequences
291
- lines.push(
292
- str
293
- .replace(/\\n/g, "\n")
294
- .replace(/\\t/g, "\t")
295
- .replace(/\\"/g, '"')
296
- .replace(/\\'/g, "'")
297
- .replace(/\\\\/g, "\\")
298
- );
299
- }
300
- return lines.join("\n");
301
- }
302
-
303
- function savePrompt(name: string, content: string | null): { hash: string | null; path: string | null } {
304
- if (!content) return { hash: null, path: null };
305
-
306
- mkdirSync(PROMPTS_DIR, { recursive: true });
307
- const filePath = join(PROMPTS_DIR, `${name}.md`);
308
- writeFileSync(filePath, content);
309
-
310
- return {
311
- hash: sha256(content),
312
- path: filePath,
313
- };
314
- }
315
-
316
- async function main() {
317
- // Parse --path argument
318
- const pathIdx = process.argv.indexOf("--path");
319
- if (pathIdx === -1 || !process.argv[pathIdx + 1]) {
320
- console.error("Usage: npx tsx scripts/extract-fingerprint.ts --path <codex-path>");
321
- console.error("");
322
- console.error(" <codex-path> can be:");
323
- console.error(" - macOS: /path/to/Codex.app");
324
- console.error(" - Windows: C:/path/to/Codex (containing resources/app.asar)");
325
- console.error(" - Extracted: directory with package.json and .vite/build/main.js");
326
- process.exit(1);
327
- }
328
-
329
- const inputPath = resolve(process.argv[pathIdx + 1]);
330
- console.log(`[extract] Input: ${inputPath}`);
331
-
332
- // Find ASAR root
333
- const asarRoot = findAsarRoot(inputPath);
334
- console.log(`[extract] ASAR root: ${asarRoot}`);
335
-
336
- // Load extraction patterns
337
- const patterns = loadPatterns();
338
-
339
- // Step A: package.json
340
- console.log("[extract] Reading package.json...");
341
- const { version, buildNumber, sparkleFeedUrl } = extractFromPackageJson(asarRoot);
342
- console.log(` version: ${version}`);
343
- console.log(` build: ${buildNumber}`);
344
-
345
- // Step B: main.js
346
- console.log("[extract] Loading main.js...");
347
- const mainJs = await (async () => {
348
- const mainPath = join(asarRoot, ".vite/build/main.js");
349
- if (!existsSync(mainPath)) {
350
- console.warn("[extract] main.js not found, skipping JS extraction");
351
- return null;
352
- }
353
-
354
- const content = readFileSync(mainPath, "utf-8");
355
- const lineCount = content.split("\n").length;
356
-
357
- if (lineCount < 100 && content.length > 100000) {
358
- console.log("[extract] main.js appears minified, attempting beautify...");
359
- try {
360
- const jsBeautify = await import("js-beautify");
361
- return jsBeautify.default.js(content, { indent_size: 2 });
362
- } catch {
363
- console.warn("[extract] js-beautify not available, using raw content");
364
- return content;
365
- }
366
- }
367
- return content;
368
- })();
369
-
370
- let mainJsResults = {
371
- apiBaseUrl: null as string | null,
372
- originator: null as string | null,
373
- models: [] as string[],
374
- whamEndpoints: [] as string[],
375
- userAgentContains: "Codex Desktop/",
376
- };
377
-
378
- let promptResults = {
379
- desktopContext: null as string | null,
380
- titleGeneration: null as string | null,
381
- prGeneration: null as string | null,
382
- automationResponse: null as string | null,
383
- };
384
-
385
- if (mainJs) {
386
- console.log(`[extract] main.js loaded (${mainJs.split("\n").length} lines)`);
387
-
388
- mainJsResults = extractFromMainJs(mainJs, patterns.main_js);
389
- console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
390
- console.log(` originator: ${mainJsResults.originator}`);
391
- console.log(` models: ${mainJsResults.models.join(", ")}`);
392
- console.log(` WHAM endpoints: ${mainJsResults.whamEndpoints.length} found`);
393
-
394
- // Extract system prompts
395
- console.log("[extract] Extracting system prompts...");
396
- promptResults = extractPrompts(mainJs);
397
- console.log(` desktop-context: ${promptResults.desktopContext ? "found" : "NOT FOUND"}`);
398
- console.log(` title-generation: ${promptResults.titleGeneration ? "found" : "NOT FOUND"}`);
399
- console.log(` pr-generation: ${promptResults.prGeneration ? "found" : "NOT FOUND"}`);
400
- console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
401
- }
402
-
403
- // Save extracted prompts
404
- const dc = savePrompt("desktop-context", promptResults.desktopContext);
405
- const tg = savePrompt("title-generation", promptResults.titleGeneration);
406
- const pr = savePrompt("pr-generation", promptResults.prGeneration);
407
- const ar = savePrompt("automation-response", promptResults.automationResponse);
408
-
409
- // Build output
410
- const fingerprint: ExtractedFingerprint = {
411
- app_version: version,
412
- build_number: buildNumber,
413
- api_base_url: mainJsResults.apiBaseUrl,
414
- originator: mainJsResults.originator,
415
- models: mainJsResults.models,
416
- wham_endpoints: mainJsResults.whamEndpoints,
417
- user_agent_contains: mainJsResults.userAgentContains,
418
- sparkle_feed_url: sparkleFeedUrl,
419
- prompts: {
420
- desktop_context_hash: dc.hash,
421
- desktop_context_path: dc.path,
422
- title_generation_hash: tg.hash,
423
- title_generation_path: tg.path,
424
- pr_generation_hash: pr.hash,
425
- pr_generation_path: pr.path,
426
- automation_response_hash: ar.hash,
427
- automation_response_path: ar.path,
428
- },
429
- extracted_at: new Date().toISOString(),
430
- source_path: inputPath,
431
- };
432
-
433
- // Write output
434
- mkdirSync(resolve(ROOT, "data"), { recursive: true });
435
- writeFileSync(OUTPUT_PATH, JSON.stringify(fingerprint, null, 2));
436
-
437
- console.log(`\n[extract] Fingerprint written to ${OUTPUT_PATH}`);
438
- console.log(`[extract] Prompts written to ${PROMPTS_DIR}/`);
439
- console.log("[extract] Done.");
440
- }
441
-
442
- main().catch((err) => {
443
- console.error("[extract] Fatal:", err);
444
- process.exit(1);
445
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-create-env-gh.ts DELETED
@@ -1,74 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
-
5
- async function main() {
6
- loadConfig(); loadFingerprint();
7
- const am = new AuthManager();
8
- const tk = await am.getToken();
9
- if (!tk) { console.log('Not auth'); return; }
10
- const c = new ProxyClient(tk, am.getAccountId());
11
-
12
- // Create environment: github/octocat/Hello-World
13
- console.log('=== Create env ===');
14
- const r = await c.post('wham/environments', {
15
- machine_id: '6993d698c6a48190bcc7ed131c5f34b4',
16
- repos: ['github/octocat/Hello-World'],
17
- label: 'proxy-env',
18
- });
19
- console.log(r.status, JSON.stringify(r.body).slice(0, 800));
20
-
21
- if (r.ok) {
22
- const env = r.body as { environment_id?: string; id?: string };
23
- const envId = env.environment_id || env.id;
24
- console.log('Environment ID:', envId);
25
-
26
- if (envId) {
27
- // Create task with this environment
28
- console.log('\n=== Create task with env ===');
29
- const tr = await c.post('wham/tasks', {
30
- new_task: {
31
- branch: 'main',
32
- environment_id: envId,
33
- run_environment_in_qa_mode: false,
34
- },
35
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello in one sentence.' }] }],
36
- model: 'gpt-5.1-codex-mini',
37
- developer_instructions: null,
38
- base_instructions: null,
39
- personality: null,
40
- approval_policy: 'never',
41
- sandbox: 'workspace-write',
42
- ephemeral: null,
43
- });
44
- console.log(tr.status, JSON.stringify(tr.body).slice(0, 800));
45
-
46
- const taskBody = tr.body as { task?: { id?: string; current_turn_id?: string } };
47
- if (tr.ok && taskBody?.task?.id) {
48
- const taskId = taskBody.task.id;
49
- const turnId = taskBody.task.current_turn_id;
50
- console.log('\nPolling...');
51
- for (let i = 0; i < 60; i++) {
52
- await new Promise(res => setTimeout(res, 3000));
53
- const poll = await c.get(
54
- 'wham/tasks/' + encodeURIComponent(taskId) + '/turns/' + encodeURIComponent(turnId!)
55
- );
56
- const pd = poll.body as { turn?: { turn_status?: string; output_items?: unknown[]; error?: unknown } };
57
- const st = pd?.turn?.turn_status;
58
- console.log(' ' + i + ': ' + st);
59
- if (st === 'completed' || st === 'failed' || st === 'cancelled') {
60
- if (pd?.turn?.error) console.log(' Error:', JSON.stringify(pd.turn.error));
61
- if (pd?.turn?.output_items) console.log(' Output:', JSON.stringify(pd.turn.output_items).slice(0, 1000));
62
- break;
63
- }
64
- }
65
- }
66
- }
67
- }
68
-
69
- // Also try listing environments now
70
- console.log('\n=== List environments ===');
71
- const le = await c.get('wham/environments');
72
- console.log(le.status, JSON.stringify(le.body).slice(0, 400));
73
- }
74
- main().catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-create-env-real.ts DELETED
@@ -1,128 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
- import { buildHeadersWithContentType } from '../src/fingerprint/manager.js';
5
-
6
- async function main() {
7
- loadConfig(); loadFingerprint();
8
- const am = new AuthManager();
9
- const tk = await am.getToken();
10
- if (!tk) { console.log('Not authenticated'); return; }
11
- const aid = am.getAccountId();
12
- const c = new ProxyClient(tk, aid);
13
- const base = 'https://chatgpt.com/backend-api';
14
-
15
- const machineId = '6993d698c6a48190bcc7ed131c5f34b4';
16
-
17
- // Test 1: Create environment with a real public GitHub repo
18
- console.log('=== Test 1: POST /wham/environments with real repo ===');
19
- try {
20
- const r = await c.post('wham/environments', {
21
- machine_id: machineId,
22
- repos: [{ provider: 'github', owner: 'octocat', name: 'Hello-World' }],
23
- label: 'test-env',
24
- });
25
- console.log(r.status, JSON.stringify(r.body).slice(0, 600));
26
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
27
-
28
- // Test 2: Different repos format
29
- console.log('\n=== Test 2: repos as string array ===');
30
- try {
31
- const r = await c.post('wham/environments', {
32
- machine_id: machineId,
33
- repos: ['octocat/Hello-World'],
34
- label: 'test-env-2',
35
- });
36
- console.log(r.status, JSON.stringify(r.body).slice(0, 600));
37
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
38
-
39
- // Test 3: Minimal environment creation
40
- console.log('\n=== Test 3: minimal env ===');
41
- try {
42
- const r = await c.post('wham/environments', {
43
- machine_id: machineId,
44
- });
45
- console.log(r.status, JSON.stringify(r.body).slice(0, 600));
46
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
47
-
48
- // Test 4: Create environment via different endpoint
49
- console.log('\n=== Test 4: POST /wham/environments/create ===');
50
- try {
51
- const r = await c.post('wham/environments/create', {
52
- machine_id: machineId,
53
- repos: ['octocat/Hello-World'],
54
- });
55
- console.log(r.status, JSON.stringify(r.body).slice(0, 600));
56
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
57
-
58
- // Test 5: Try the webview approach - look at how the webview creates environments
59
- // The webview uses POST /wham/tasks with environment embedded
60
- // Let me try creating a task without environment_id but with DELETE of the key
61
- console.log('\n=== Test 5: POST /wham/tasks with environment_id deleted (raw fetch) ===');
62
- try {
63
- const body: Record<string, unknown> = {
64
- new_task: {
65
- branch: 'main',
66
- run_environment_in_qa_mode: false,
67
- },
68
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
69
- model: 'gpt-5.1-codex-mini',
70
- developer_instructions: null,
71
- base_instructions: null,
72
- personality: null,
73
- approval_policy: 'never',
74
- sandbox: 'workspace-write',
75
- ephemeral: null,
76
- };
77
- // Verify environment_id is truly not in the JSON
78
- console.log('Body keys:', Object.keys(body.new_task as object));
79
- const res = await fetch(`${base}/wham/tasks`, {
80
- method: 'POST',
81
- headers: buildHeadersWithContentType(tk, aid),
82
- body: JSON.stringify(body),
83
- });
84
- const data = await res.json();
85
- console.log(res.status, JSON.stringify(data).slice(0, 600));
86
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
87
-
88
- // Test 6: What about follow_up without new_task?
89
- console.log('\n=== Test 6: POST /wham/tasks without new_task (just input_items) ===');
90
- try {
91
- const body = {
92
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
93
- model: 'gpt-5.1-codex-mini',
94
- };
95
- const res = await fetch(`${base}/wham/tasks`, {
96
- method: 'POST',
97
- headers: buildHeadersWithContentType(tk, aid),
98
- body: JSON.stringify(body),
99
- });
100
- const data = await res.json();
101
- console.log(res.status, JSON.stringify(data).slice(0, 600));
102
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
103
-
104
- // Test 7: Maybe the issue is that we need to send a requestBody wrapper?
105
- // The webview uses: CodexRequest.safePost("/wham/tasks", { requestBody: {...} })
106
- console.log('\n=== Test 7: POST /wham/tasks with requestBody wrapper ===');
107
- try {
108
- const body = {
109
- requestBody: {
110
- new_task: {
111
- branch: 'main',
112
- run_environment_in_qa_mode: false,
113
- },
114
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello' }] }],
115
- model: 'gpt-5.1-codex-mini',
116
- },
117
- };
118
- const res = await fetch(`${base}/wham/tasks`, {
119
- method: 'POST',
120
- headers: buildHeadersWithContentType(tk, aid),
121
- body: JSON.stringify(body),
122
- });
123
- const data = await res.json();
124
- console.log(res.status, JSON.stringify(data).slice(0, 600));
125
- } catch (e: unknown) { console.log('Error:', (e as Error).message); }
126
- }
127
-
128
- main().catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-env-full.ts DELETED
@@ -1,113 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
- import { buildHeaders, buildHeadersWithContentType } from '../src/fingerprint/manager.js';
5
-
6
- async function main() {
7
- loadConfig(); loadFingerprint();
8
- const am = new AuthManager();
9
- const tk = await am.getToken();
10
- if (!tk) { console.log('Not authenticated'); return; }
11
- const aid = am.getAccountId();
12
- const c = new ProxyClient(tk, aid);
13
- const base = 'https://chatgpt.com/backend-api';
14
-
15
- // Try all possible environment-related endpoints
16
- const endpoints = [
17
- { method: 'GET', path: 'wham/environments' },
18
- { method: 'GET', path: 'wham/machines' },
19
- { method: 'GET', path: 'wham/accounts/check' },
20
- { method: 'GET', path: 'wham/usage' },
21
- { method: 'GET', path: 'wham/tasks' },
22
- // Codex CLI endpoints
23
- { method: 'GET', path: 'api/codex/environments' },
24
- // Try to see if there's a way to check features
25
- { method: 'GET', path: 'wham/features' },
26
- { method: 'GET', path: 'wham/config' },
27
- // Try without environment at all — use ephemeral
28
- { method: 'POST', path: 'wham/tasks', body: {
29
- new_task: { branch: 'main', environment_id: null, run_environment_in_qa_mode: false },
30
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hi' }] }],
31
- model: 'gpt-5.1-codex-mini',
32
- ephemeral: true,
33
- }},
34
- // Try with ephemeral flag and no environment
35
- { method: 'POST', path: 'wham/tasks', body: {
36
- new_task: { branch: 'main' },
37
- input_items: [{ type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hi' }] }],
38
- model: 'gpt-5.1-codex-mini',
39
- ephemeral: true,
40
- approval_policy: 'never',
41
- sandbox: 'workspace-write',
42
- }},
43
- ];
44
-
45
- for (const ep of endpoints) {
46
- console.log(`\n=== ${ep.method} /${ep.path} ===`);
47
- try {
48
- let res: Response;
49
- if (ep.method === 'POST') {
50
- res = await fetch(`${base}/${ep.path}`, {
51
- method: 'POST',
52
- headers: buildHeadersWithContentType(tk, aid),
53
- body: JSON.stringify(ep.body),
54
- });
55
- } else {
56
- res = await fetch(`${base}/${ep.path}`, {
57
- headers: buildHeaders(tk, aid),
58
- });
59
- }
60
- const ct = res.headers.get('content-type') || '';
61
- if (ct.includes('json')) {
62
- const data = await res.json();
63
- console.log(res.status, JSON.stringify(data).slice(0, 600));
64
- } else {
65
- const text = await res.text();
66
- console.log(res.status, text.slice(0, 300));
67
- }
68
- } catch (e: unknown) {
69
- console.log('Error:', (e as Error).message);
70
- }
71
- }
72
-
73
- // Also try listing existing tasks to see if any have environment info
74
- console.log('\n=== GET /wham/tasks (list existing tasks) ===');
75
- try {
76
- const r = await fetch(`${base}/wham/tasks`, {
77
- headers: buildHeaders(tk, aid),
78
- });
79
- const data = await r.json() as { items?: Array<{ id?: string; task_status_display?: unknown }> };
80
- console.log(r.status);
81
- if (data.items) {
82
- console.log('Total tasks:', data.items.length);
83
- for (const t of data.items.slice(0, 3)) {
84
- console.log(' Task:', t.id, JSON.stringify(t.task_status_display).slice(0, 200));
85
- }
86
- } else {
87
- console.log(JSON.stringify(data).slice(0, 400));
88
- }
89
- } catch (e: unknown) {
90
- console.log('Error:', (e as Error).message);
91
- }
92
-
93
- // Check a specific task to see its environment info
94
- console.log('\n=== Check a recent task for environment details ===');
95
- try {
96
- const r = await fetch(`${base}/wham/tasks`, {
97
- headers: buildHeaders(tk, aid),
98
- });
99
- const data = await r.json() as { items?: Array<{ id?: string }> };
100
- if (data.items && data.items.length > 0) {
101
- const taskId = data.items[0].id!;
102
- const tr = await fetch(`${base}/wham/tasks/${encodeURIComponent(taskId)}`, {
103
- headers: buildHeaders(tk, aid),
104
- });
105
- const td = await tr.json();
106
- console.log('Task detail:', JSON.stringify(td).slice(0, 1000));
107
- }
108
- } catch (e: unknown) {
109
- console.log('Error:', (e as Error).message);
110
- }
111
- }
112
-
113
- main().catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-repo-format.ts DELETED
@@ -1,27 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
-
5
- async function main() {
6
- loadConfig(); loadFingerprint();
7
- const am = new AuthManager();
8
- const tk = await am.getToken();
9
- if (!tk) { console.log('Not auth'); return; }
10
- const c = new ProxyClient(tk, am.getAccountId());
11
- const mid = '6993d698c6a48190bcc7ed131c5f34b4';
12
-
13
- const formats = [
14
- 'github:octocat/Hello-World',
15
- 'octocat/Hello-World',
16
- 'github/octocat/hello-world',
17
- 'https://github.com/octocat/Hello-World',
18
- ];
19
-
20
- for (const repo of formats) {
21
- console.log('--- repos: ["' + repo + '"] ---');
22
- const r = await c.post('wham/environments', { machine_id: mid, repos: [repo], label: 'test' });
23
- console.log(r.status, JSON.stringify(r.body).slice(0, 300));
24
- console.log('');
25
- }
26
- }
27
- main().catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-repo2.ts DELETED
@@ -1,23 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
- async function main() {
5
- loadConfig(); loadFingerprint();
6
- const am = new AuthManager();
7
- const tk = await am.getToken();
8
- if (!tk) { console.log('no auth'); return; }
9
- const c = new ProxyClient(tk, am.getAccountId());
10
- const mid = '6993d698c6a48190bcc7ed131c5f34b4';
11
- const tests = [
12
- 'github/torvalds/linux',
13
- 'github/facebook/react',
14
- 'torvalds/linux',
15
- ];
16
- for (const repo of tests) {
17
- console.log('repos: [' + repo + ']');
18
- const r = await c.post('wham/environments', { machine_id: mid, repos: [repo], label: 'test' });
19
- console.log(r.status, JSON.stringify(r.body).slice(0, 400));
20
- console.log('');
21
- }
22
- }
23
- main();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-responses-api.ts DELETED
@@ -1,99 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { AuthManager } from '../src/auth/manager.js';
3
- import { buildHeadersWithContentType } from '../src/fingerprint/manager.js';
4
-
5
- async function main() {
6
- loadConfig(); loadFingerprint();
7
- const am = new AuthManager();
8
- const tk = await am.getToken();
9
- if (!tk) { console.log('no auth token'); return; }
10
- const aid = am.getAccountId();
11
-
12
- const base = 'https://chatgpt.com/backend-api/codex';
13
- const hdrs = buildHeadersWithContentType(tk, aid);
14
-
15
- // Test 1: non-streaming
16
- console.log('=== Test 1: POST /codex/responses (non-streaming) ===');
17
- try {
18
- const r = await fetch(base + '/responses', {
19
- method: 'POST',
20
- headers: hdrs,
21
- body: JSON.stringify({
22
- model: 'gpt-5.1-codex-mini',
23
- instructions: 'You are a helpful assistant.',
24
- input: [{ role: 'user', content: 'Say hello in one sentence.' }],
25
- stream: false,
26
- store: false,
27
- }),
28
- });
29
- console.log('Status:', r.status, r.statusText);
30
- const ct = r.headers.get('content-type') || '';
31
- console.log('Content-Type:', ct);
32
- if (ct.includes('json')) {
33
- const j = await r.json();
34
- console.log('Body:', JSON.stringify(j, null, 2).slice(0, 3000));
35
- } else {
36
- const t = await r.text();
37
- console.log('Body:', t.slice(0, 2000));
38
- }
39
- } catch (e: any) {
40
- console.log('Error:', e.message);
41
- }
42
-
43
- // Test 2: streaming
44
- console.log('\n=== Test 2: POST /codex/responses (streaming) ===');
45
- try {
46
- const streamHdrs = { ...hdrs, 'Accept': 'text/event-stream' };
47
- const r = await fetch(base + '/responses', {
48
- method: 'POST',
49
- headers: streamHdrs,
50
- body: JSON.stringify({
51
- model: 'gpt-5.1-codex-mini',
52
- instructions: 'You are a helpful assistant.',
53
- input: [{ role: 'user', content: 'Say hello in one sentence.' }],
54
- stream: true,
55
- store: false,
56
- }),
57
- });
58
- console.log('Status:', r.status, r.statusText);
59
- const ct = r.headers.get('content-type') || '';
60
- console.log('Content-Type:', ct);
61
-
62
- if (r.status !== 200) {
63
- const t = await r.text();
64
- console.log('Error body:', t.slice(0, 1000));
65
- } else {
66
- const reader = r.body!.getReader();
67
- const decoder = new TextDecoder();
68
- let fullText = '';
69
- let chunks = 0;
70
- while (true) {
71
- const { done, value } = await reader.read();
72
- if (done) break;
73
- const chunk = decoder.decode(value, { stream: true });
74
- fullText += chunk;
75
- chunks++;
76
- if (chunks <= 10) {
77
- console.log(`--- Chunk ${chunks} ---`);
78
- console.log(chunk.slice(0, 300));
79
- }
80
- }
81
- console.log('\nTotal chunks:', chunks);
82
- console.log('Full response length:', fullText.length);
83
- // Show some events
84
- const events = fullText.split('\n\n').filter(e => e.trim());
85
- console.log('Total SSE events:', events.length);
86
- // Show last few events
87
- const last5 = events.slice(-5);
88
- console.log('\nLast 5 events:');
89
- for (const ev of last5) {
90
- console.log(ev.slice(0, 300));
91
- console.log('---');
92
- }
93
- }
94
- } catch (e: any) {
95
- console.log('Error:', e.message);
96
- }
97
- }
98
-
99
- main();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/test-snapshot.ts DELETED
@@ -1,117 +0,0 @@
1
- import { loadConfig, loadFingerprint } from '../src/config.js';
2
- import { ProxyClient } from '../src/proxy/client.js';
3
- import { AuthManager } from '../src/auth/manager.js';
4
-
5
- async function main() {
6
- loadConfig(); loadFingerprint();
7
- const am = new AuthManager();
8
- const tk = await am.getToken();
9
- if (!tk) { console.log('Not authenticated'); return; }
10
- const c = new ProxyClient(tk, am.getAccountId());
11
-
12
- // Step 1: Get upload URL for a worktree snapshot
13
- console.log('=== Step 1: POST /wham/worktree_snapshots/upload_url ===');
14
- const r1 = await c.post('wham/worktree_snapshots/upload_url', {
15
- file_name: 'snapshot.tar.gz',
16
- file_size: 100,
17
- repo_name: 'workspace',
18
- });
19
- console.log('Status:', r1.status);
20
- console.log('Body:', JSON.stringify(r1.body).slice(0, 800));
21
-
22
- if (!r1.ok) {
23
- console.log('Failed to get upload URL');
24
- return;
25
- }
26
-
27
- const body1 = r1.body as { upload_url?: string; file_id?: string; status?: string };
28
- console.log('Upload URL:', body1.upload_url?.slice(0, 100));
29
- console.log('File ID:', body1.file_id);
30
-
31
- // Step 2: Upload a minimal tar.gz (empty)
32
- if (body1.upload_url) {
33
- console.log('\n=== Step 2: Upload minimal snapshot ===');
34
- // Create a minimal gzip file (empty gzip)
35
- const emptyGzip = Buffer.from([
36
- 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
37
- 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
38
- 0x00, 0x00, 0x00, 0x00,
39
- ]);
40
-
41
- const uploadRes = await fetch(body1.upload_url, {
42
- method: 'PUT',
43
- body: emptyGzip,
44
- headers: { 'Content-Type': 'application/gzip' },
45
- });
46
- console.log('Upload status:', uploadRes.status);
47
- const uploadText = await uploadRes.text();
48
- console.log('Upload response:', uploadText.slice(0, 300));
49
- }
50
-
51
- // Step 3: Finish upload
52
- if (body1.file_id) {
53
- console.log('\n=== Step 3: POST /wham/worktree_snapshots/finish_upload ===');
54
- const r3 = await c.post('wham/worktree_snapshots/finish_upload', {
55
- file_id: body1.file_id,
56
- });
57
- console.log('Status:', r3.status);
58
- console.log('Body:', JSON.stringify(r3.body).slice(0, 800));
59
-
60
- // Step 4: Create a task with this file_id
61
- console.log('\n=== Step 4: Create task with valid file_id ===');
62
- const r4 = await c.post('wham/tasks', {
63
- new_task: {
64
- branch: 'main',
65
- environment_id: null,
66
- environment: {
67
- repos: [{
68
- kind: 'local_worktree',
69
- name: 'workspace',
70
- remotes: {},
71
- commit_sha: '0000000000000000000000000000000000000000',
72
- branch: 'main',
73
- file_id: body1.file_id,
74
- }],
75
- },
76
- run_environment_in_qa_mode: false,
77
- },
78
- input_items: [
79
- { type: 'message', role: 'user', content: [{ content_type: 'text', text: 'Say hello in one sentence.' }] }
80
- ],
81
- model: 'gpt-5.1-codex-mini',
82
- developer_instructions: null,
83
- base_instructions: null,
84
- personality: null,
85
- approval_policy: 'never',
86
- sandbox: 'workspace-write',
87
- ephemeral: null,
88
- });
89
- console.log('Task status:', r4.status);
90
- console.log('Task body:', JSON.stringify(r4.body).slice(0, 600));
91
-
92
- // Step 5: Poll for result
93
- const taskBody = r4.body as { task?: { id?: string; current_turn_id?: string } };
94
- if (r4.ok && taskBody?.task?.id) {
95
- const taskId = taskBody.task.id;
96
- const turnId = taskBody.task.current_turn_id!;
97
- console.log('\n=== Step 5: Poll for turn completion ===');
98
- console.log('Task:', taskId);
99
- console.log('Turn:', turnId);
100
-
101
- for (let i = 0; i < 30; i++) {
102
- await new Promise(res => setTimeout(res, 3000));
103
- const tr = await c.get(`wham/tasks/${encodeURIComponent(taskId)}/turns/${encodeURIComponent(turnId)}`);
104
- const td = tr.body as { turn?: { turn_status?: string; output_items?: unknown[]; error?: unknown } };
105
- const status = td?.turn?.turn_status;
106
- console.log(` Poll ${i}: status=${status}`);
107
- if (status === 'completed' || status === 'failed' || status === 'cancelled') {
108
- if (td?.turn?.error) console.log(' Error:', JSON.stringify(td.turn.error));
109
- if (td?.turn?.output_items) console.log(' Output:', JSON.stringify(td.turn.output_items).slice(0, 500));
110
- break;
111
- }
112
- }
113
- }
114
- }
115
- }
116
-
117
- main().catch(e => console.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/auth/oauth-pkce.ts CHANGED
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from "fs";
9
  import { resolve } from "path";
10
  import { homedir } from "os";
11
  import { getConfig } from "../config.js";
 
12
 
13
  export interface PKCEChallenge {
14
  codeVerifier: string;
@@ -120,18 +121,17 @@ export async function exchangeCode(
120
  code_verifier: codeVerifier,
121
  });
122
 
123
- const resp = await fetch(config.auth.oauth_token_endpoint, {
124
- method: "POST",
125
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
- body: body.toString(),
127
- });
128
 
129
  if (!resp.ok) {
130
- const text = await resp.text();
131
- throw new Error(`Token exchange failed (${resp.status}): ${text}`);
132
  }
133
 
134
- return resp.json() as Promise<TokenResponse>;
135
  }
136
 
137
  /**
@@ -148,18 +148,17 @@ export async function refreshAccessToken(
148
  refresh_token: refreshToken,
149
  });
150
 
151
- const resp = await fetch(config.auth.oauth_token_endpoint, {
152
- method: "POST",
153
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
- body: body.toString(),
155
- });
156
 
157
  if (!resp.ok) {
158
- const text = await resp.text();
159
- throw new Error(`Token refresh failed (${resp.status}): ${text}`);
160
  }
161
 
162
- return resp.json() as Promise<TokenResponse>;
163
  }
164
 
165
  // ── Pending session management ─────────────────────────────────────
@@ -343,18 +342,17 @@ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
343
  scope: "openid profile email offline_access",
344
  });
345
 
346
- const resp = await fetch("https://auth.openai.com/oauth/device/code", {
347
- method: "POST",
348
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
349
- body: body.toString(),
350
- });
351
 
352
  if (!resp.ok) {
353
- const text = await resp.text();
354
- throw new Error(`Device code request failed (${resp.status}): ${text}`);
355
  }
356
 
357
- return resp.json() as Promise<DeviceCodeResponse>;
358
  }
359
 
360
  /**
@@ -370,20 +368,20 @@ export async function pollDeviceToken(deviceCode: string): Promise<TokenResponse
370
  client_id: config.auth.oauth_client_id,
371
  });
372
 
373
- const resp = await fetch(config.auth.oauth_token_endpoint, {
374
- method: "POST",
375
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
376
- body: body.toString(),
377
- });
378
 
379
  if (!resp.ok) {
380
- const data = (await resp.json()) as { error?: string; error_description?: string };
381
  const err = new Error(data.error_description || data.error || `Poll failed (${resp.status})`);
382
  (err as any).code = data.error;
383
  throw err;
384
  }
385
 
386
- return resp.json() as Promise<TokenResponse>;
387
  }
388
 
389
  // ── CLI Token Import ───────────────────────────────────────────────
 
9
  import { resolve } from "path";
10
  import { homedir } from "os";
11
  import { getConfig } from "../config.js";
12
+ import { curlFetchPost } from "../tls/curl-fetch.js";
13
 
14
  export interface PKCEChallenge {
15
  codeVerifier: string;
 
121
  code_verifier: codeVerifier,
122
  });
123
 
124
+ const resp = await curlFetchPost(
125
+ config.auth.oauth_token_endpoint,
126
+ "application/x-www-form-urlencoded",
127
+ body.toString(),
128
+ );
129
 
130
  if (!resp.ok) {
131
+ throw new Error(`Token exchange failed (${resp.status}): ${resp.body}`);
 
132
  }
133
 
134
+ return JSON.parse(resp.body) as TokenResponse;
135
  }
136
 
137
  /**
 
148
  refresh_token: refreshToken,
149
  });
150
 
151
+ const resp = await curlFetchPost(
152
+ config.auth.oauth_token_endpoint,
153
+ "application/x-www-form-urlencoded",
154
+ body.toString(),
155
+ );
156
 
157
  if (!resp.ok) {
158
+ throw new Error(`Token refresh failed (${resp.status}): ${resp.body}`);
 
159
  }
160
 
161
+ return JSON.parse(resp.body) as TokenResponse;
162
  }
163
 
164
  // ── Pending session management ─────────────────────────────────────
 
342
  scope: "openid profile email offline_access",
343
  });
344
 
345
+ const resp = await curlFetchPost(
346
+ "https://auth.openai.com/oauth/device/code",
347
+ "application/x-www-form-urlencoded",
348
+ body.toString(),
349
+ );
350
 
351
  if (!resp.ok) {
352
+ throw new Error(`Device code request failed (${resp.status}): ${resp.body}`);
 
353
  }
354
 
355
+ return JSON.parse(resp.body) as DeviceCodeResponse;
356
  }
357
 
358
  /**
 
368
  client_id: config.auth.oauth_client_id,
369
  });
370
 
371
+ const resp = await curlFetchPost(
372
+ config.auth.oauth_token_endpoint,
373
+ "application/x-www-form-urlencoded",
374
+ body.toString(),
375
+ );
376
 
377
  if (!resp.ok) {
378
+ const data = JSON.parse(resp.body) as { error?: string; error_description?: string };
379
  const err = new Error(data.error_description || data.error || `Poll failed (${resp.status})`);
380
  (err as any).code = data.error;
381
  throw err;
382
  }
383
 
384
+ return JSON.parse(resp.body) as TokenResponse;
385
  }
386
 
387
  // ── CLI Token Import ───────────────────────────────────────────────
src/config.ts CHANGED
@@ -42,6 +42,10 @@ const ConfigSchema = z.object({
42
  ttl_minutes: z.number().default(60),
43
  cleanup_interval_minutes: z.number().default(5),
44
  }),
 
 
 
 
45
  streaming: z.object({
46
  status_as_content: z.boolean().default(false),
47
  chunk_size: z.number().default(100),
 
42
  ttl_minutes: z.number().default(60),
43
  cleanup_interval_minutes: z.number().default(5),
44
  }),
45
+ tls: z.object({
46
+ curl_binary: z.string().default("auto"),
47
+ impersonate_profile: z.string().default("chrome136"),
48
+ }).default({}),
49
  streaming: z.object({
50
  status_as_content: z.boolean().default(false),
51
  chunk_size: z.number().default(100),
src/proxy/codex-api.ts CHANGED
@@ -11,6 +11,7 @@
11
 
12
  import { spawn, execFile } from "child_process";
13
  import { getConfig } from "../config.js";
 
14
  import {
15
  buildHeaders,
16
  buildHeadersWithContentType,
@@ -107,11 +108,11 @@ export class CodexApi {
107
  ): Promise<CurlResponse> {
108
  return new Promise((resolve, reject) => {
109
  const args = [
 
110
  "-s", "-S", // silent but show errors
111
  "--compressed", // curl negotiates compression
112
  "-N", // no output buffering (SSE)
113
  "-i", // include response headers in stdout
114
- "--http1.1", // force HTTP/1.1
115
  "-X", "POST",
116
  "--data-binary", "@-", // read body from stdin
117
  ];
@@ -120,16 +121,17 @@ export class CodexApi {
120
  args.push("--max-time", String(timeoutSec));
121
  }
122
 
123
- // Remove Accept-Encoding let curl negotiate via --compressed
124
- const filteredHeaders = { ...headers };
125
- delete filteredHeaders["Accept-Encoding"];
126
-
127
- for (const [key, value] of Object.entries(filteredHeaders)) {
128
  args.push("-H", `${key}: ${value}`);
129
  }
 
 
130
  args.push(url);
131
 
132
- const child = spawn("curl", args, {
133
  stdio: ["pipe", "pipe", "pipe"],
134
  });
135
 
@@ -244,15 +246,15 @@ export class CodexApi {
244
  // to fail when it can't decompress the response.
245
  delete headers["Accept-Encoding"];
246
 
247
- // Build curl args
248
- const args = ["-s", "--compressed", "--max-time", "15"];
249
  for (const [key, value] of Object.entries(headers)) {
250
  args.push("-H", `${key}: ${value}`);
251
  }
252
  args.push(url);
253
 
254
  const body = await new Promise<string>((resolve, reject) => {
255
- execFile("curl", args, { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
256
  if (err) {
257
  reject(new CodexApiError(0, `curl failed: ${err.message} ${stderr}`));
258
  } else {
 
11
 
12
  import { spawn, execFile } from "child_process";
13
  import { getConfig } from "../config.js";
14
+ import { resolveCurlBinary, getChromeTlsArgs } from "../tls/curl-binary.js";
15
  import {
16
  buildHeaders,
17
  buildHeadersWithContentType,
 
108
  ): Promise<CurlResponse> {
109
  return new Promise((resolve, reject) => {
110
  const args = [
111
+ ...getChromeTlsArgs(), // Chrome TLS profile (ciphers, HTTP/2, etc.)
112
  "-s", "-S", // silent but show errors
113
  "--compressed", // curl negotiates compression
114
  "-N", // no output buffering (SSE)
115
  "-i", // include response headers in stdout
 
116
  "-X", "POST",
117
  "--data-binary", "@-", // read body from stdin
118
  ];
 
121
  args.push("--max-time", String(timeoutSec));
122
  }
123
 
124
+ // Pass all headers explicitly in our fingerprint order.
125
+ // Accept-Encoding is kept so curl doesn't inject its own at position 2.
126
+ // --compressed still handles auto-decompression of the response.
127
+ for (const [key, value] of Object.entries(headers)) {
 
128
  args.push("-H", `${key}: ${value}`);
129
  }
130
+ // Suppress curl's auto Expect: 100-continue (Chromium never sends it)
131
+ args.push("-H", "Expect:");
132
  args.push(url);
133
 
134
+ const child = spawn(resolveCurlBinary(), args, {
135
  stdio: ["pipe", "pipe", "pipe"],
136
  });
137
 
 
246
  // to fail when it can't decompress the response.
247
  delete headers["Accept-Encoding"];
248
 
249
+ // Build curl args (Chrome TLS profile + request params)
250
+ const args = [...getChromeTlsArgs(), "-s", "--compressed", "--max-time", "15"];
251
  for (const [key, value] of Object.entries(headers)) {
252
  args.push("-H", `${key}: ${value}`);
253
  }
254
  args.push(url);
255
 
256
  const body = await new Promise<string>((resolve, reject) => {
257
+ execFile(resolveCurlBinary(), args, { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
258
  if (err) {
259
  reject(new CodexApiError(0, `curl failed: ${err.message} ${stderr}`));
260
  } else {
src/routes/chat.ts CHANGED
@@ -112,7 +112,14 @@ export function createChatRoutes(
112
 
113
  const { entryId, token, accountId } = acquired;
114
  const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
115
- const codexRequest = translateToCodexRequest(req);
 
 
 
 
 
 
 
116
  console.log(
117
  `[Chat] Account ${entryId} | Codex request:`,
118
  JSON.stringify(codexRequest).slice(0, 300),
@@ -129,12 +136,21 @@ export function createChatRoutes(
129
  c.header("Connection", "keep-alive");
130
 
131
  return stream(c, async (s) => {
 
132
  try {
133
  for await (const chunk of streamCodexToOpenAI(
134
  codexApi,
135
  rawResponse,
136
  codexRequest.model,
137
  (u) => { usageInfo = u; },
 
 
 
 
 
 
 
 
138
  )) {
139
  await s.write(chunk);
140
  }
@@ -148,6 +164,12 @@ export function createChatRoutes(
148
  rawResponse,
149
  codexRequest.model,
150
  );
 
 
 
 
 
 
151
  accountPool.release(entryId, result.usage);
152
  return c.json(result.response);
153
  }
 
112
 
113
  const { entryId, token, accountId } = acquired;
114
  const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
115
+
116
+ // Find existing session for multi-turn previous_response_id
117
+ const existingSession = sessionManager.findSession(req.messages);
118
+ const previousResponseId = existingSession?.responseId ?? null;
119
+ const codexRequest = translateToCodexRequest(req, previousResponseId);
120
+ if (previousResponseId) {
121
+ console.log(`[Chat] Account ${entryId} | Multi-turn: previous_response_id=${previousResponseId}`);
122
+ }
123
  console.log(
124
  `[Chat] Account ${entryId} | Codex request:`,
125
  JSON.stringify(codexRequest).slice(0, 300),
 
136
  c.header("Connection", "keep-alive");
137
 
138
  return stream(c, async (s) => {
139
+ let sessionTaskId: string | null = null;
140
  try {
141
  for await (const chunk of streamCodexToOpenAI(
142
  codexApi,
143
  rawResponse,
144
  codexRequest.model,
145
  (u) => { usageInfo = u; },
146
+ (respId) => {
147
+ if (!sessionTaskId) {
148
+ // First call: create session
149
+ sessionTaskId = `task-${Date.now()}`;
150
+ sessionManager.storeSession(sessionTaskId, "turn-1", req.messages);
151
+ }
152
+ sessionManager.updateResponseId(sessionTaskId, respId);
153
+ },
154
  )) {
155
  await s.write(chunk);
156
  }
 
164
  rawResponse,
165
  codexRequest.model,
166
  );
167
+ // Store session with responseId for multi-turn
168
+ if (result.responseId) {
169
+ const taskId = `task-${Date.now()}`;
170
+ sessionManager.storeSession(taskId, "turn-1", req.messages);
171
+ sessionManager.updateResponseId(taskId, result.responseId);
172
+ }
173
  accountPool.release(entryId, result.usage);
174
  return c.json(result.response);
175
  }
src/session/manager.ts CHANGED
@@ -5,6 +5,7 @@ interface Session {
5
  taskId: string;
6
  turnId: string;
7
  messageHash: string;
 
8
  createdAt: number;
9
  }
10
 
@@ -63,10 +64,19 @@ export class SessionManager {
63
  taskId,
64
  turnId,
65
  messageHash: hash,
 
66
  createdAt: Date.now(),
67
  });
68
  }
69
 
 
 
 
 
 
 
 
 
70
  /**
71
  * Update turn ID for an existing session
72
  */
 
5
  taskId: string;
6
  turnId: string;
7
  messageHash: string;
8
+ responseId: string | null;
9
  createdAt: number;
10
  }
11
 
 
64
  taskId,
65
  turnId,
66
  messageHash: hash,
67
+ responseId: null,
68
  createdAt: Date.now(),
69
  });
70
  }
71
 
72
+ /**
73
+ * Update the response ID for an existing session (for multi-turn previous_response_id)
74
+ */
75
+ updateResponseId(taskId: string, responseId: string): void {
76
+ const session = this.sessions.get(taskId);
77
+ if (session) session.responseId = responseId;
78
+ }
79
+
80
  /**
81
  * Update turn ID for an existing session
82
  */
src/tls/curl-binary.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Resolves the curl binary and Chrome TLS profile args.
3
+ *
4
+ * When curl-impersonate is available, we call it directly (NOT via the
5
+ * curl_chrome136 wrapper script) and pass the TLS-level parameters ourselves.
6
+ * This avoids duplicate -H headers between the wrapper and our fingerprint manager.
7
+ *
8
+ * The Chrome TLS args are extracted from curl_chrome136 wrapper script.
9
+ * HTTP headers (-H flags) are intentionally excluded — our fingerprint manager
10
+ * in manager.ts handles those to match Codex Desktop exactly.
11
+ */
12
+
13
+ import { existsSync } from "fs";
14
+ import { execFileSync } from "child_process";
15
+ import { resolve } from "path";
16
+ import { getConfig } from "../config.js";
17
+
18
+ const IS_WIN = process.platform === "win32";
19
+ const BINARY_NAME = IS_WIN ? "curl-impersonate.exe" : "curl-impersonate";
20
+
21
+ /**
22
+ * Chrome 136 TLS profile parameters.
23
+ * Extracted from curl_chrome136 wrapper (lexiforest/curl-impersonate v1.4.4).
24
+ * These control TLS fingerprint, HTTP/2 framing, and protocol negotiation.
25
+ * HTTP-level headers are NOT included — our fingerprint manager handles those.
26
+ */
27
+ const CHROME_TLS_ARGS: string[] = [
28
+ // ── TLS cipher suites (exact Chrome 136 order) ──
29
+ "--ciphers",
30
+ [
31
+ "TLS_AES_128_GCM_SHA256",
32
+ "TLS_AES_256_GCM_SHA384",
33
+ "TLS_CHACHA20_POLY1305_SHA256",
34
+ "ECDHE-ECDSA-AES128-GCM-SHA256",
35
+ "ECDHE-RSA-AES128-GCM-SHA256",
36
+ "ECDHE-ECDSA-AES256-GCM-SHA384",
37
+ "ECDHE-RSA-AES256-GCM-SHA384",
38
+ "ECDHE-ECDSA-CHACHA20-POLY1305",
39
+ "ECDHE-RSA-CHACHA20-POLY1305",
40
+ "ECDHE-RSA-AES128-SHA",
41
+ "ECDHE-RSA-AES256-SHA",
42
+ "AES128-GCM-SHA256",
43
+ "AES256-GCM-SHA384",
44
+ "AES128-SHA",
45
+ "AES256-SHA",
46
+ ].join(":"),
47
+ // ── Elliptic curves (includes post-quantum X25519MLKEM768) ──
48
+ "--curves", "X25519MLKEM768:X25519:P-256:P-384",
49
+ // ── HTTP/2 with Chrome-exact SETTINGS frame ──
50
+ "--http2",
51
+ "--http2-settings", "1:65536;2:0;4:6291456;6:262144",
52
+ "--http2-window-update", "15663105",
53
+ "--http2-stream-weight", "256",
54
+ "--http2-stream-exclusive", "1",
55
+ // ── TLS extensions (Chrome fingerprint) ──
56
+ "--tlsv1.2",
57
+ "--alps",
58
+ "--tls-permute-extensions",
59
+ "--cert-compression", "brotli",
60
+ "--tls-grease",
61
+ "--tls-use-new-alps-codepoint",
62
+ "--tls-signed-cert-timestamps",
63
+ "--ech", "grease",
64
+ // ── Compression & cookies ──
65
+ "--compressed",
66
+ ];
67
+
68
+ let _resolved: string | null = null;
69
+ let _isImpersonate = false;
70
+ let _tlsArgs: string[] | null = null;
71
+
72
+ /**
73
+ * Resolve the curl binary path. Result is cached after first call.
74
+ */
75
+ export function resolveCurlBinary(): string {
76
+ if (_resolved) return _resolved;
77
+
78
+ const config = getConfig();
79
+ const setting = config.tls.curl_binary;
80
+
81
+ if (setting !== "auto") {
82
+ _resolved = setting;
83
+ _isImpersonate = setting.includes("curl-impersonate");
84
+ console.log(`[TLS] Using configured curl binary: ${_resolved}`);
85
+ return _resolved;
86
+ }
87
+
88
+ // Auto-detect: look for curl-impersonate in bin/
89
+ const binPath = resolve(process.cwd(), "bin", BINARY_NAME);
90
+ if (existsSync(binPath)) {
91
+ _resolved = binPath;
92
+ _isImpersonate = true;
93
+ console.log(`[TLS] Using curl-impersonate: ${_resolved}`);
94
+ return _resolved;
95
+ }
96
+
97
+ // Fallback to system curl
98
+ _resolved = "curl";
99
+ _isImpersonate = false;
100
+ console.warn(
101
+ `[TLS] curl-impersonate not found at ${binPath}. ` +
102
+ `Falling back to system curl. Run "npm run setup" to install curl-impersonate.`,
103
+ );
104
+ return _resolved;
105
+ }
106
+
107
+ /**
108
+ * Detect if curl-impersonate supports the --impersonate flag.
109
+ * If supported, returns ["--impersonate", profile] which replaces CHROME_TLS_ARGS.
110
+ * Otherwise returns the manual CHROME_TLS_ARGS.
111
+ */
112
+ function detectImpersonateSupport(binary: string): string[] {
113
+ try {
114
+ const helpOutput = execFileSync(binary, ["--help", "all"], {
115
+ encoding: "utf-8",
116
+ timeout: 5000,
117
+ });
118
+ if (helpOutput.includes("--impersonate")) {
119
+ const profile = getConfig().tls.impersonate_profile ?? "chrome136";
120
+ console.log(`[TLS] Using --impersonate ${profile}`);
121
+ return ["--impersonate", profile];
122
+ }
123
+ } catch {
124
+ // --help failed, fall back to manual args
125
+ }
126
+ return CHROME_TLS_ARGS;
127
+ }
128
+
129
+ /**
130
+ * Get Chrome TLS profile args to prepend to curl commands.
131
+ * Returns empty array when using system curl (args are curl-impersonate specific).
132
+ * Uses --impersonate flag when available, otherwise falls back to manual CHROME_TLS_ARGS.
133
+ */
134
+ export function getChromeTlsArgs(): string[] {
135
+ // Ensure binary is resolved first
136
+ resolveCurlBinary();
137
+ if (!_isImpersonate) return [];
138
+ if (!_tlsArgs) {
139
+ _tlsArgs = detectImpersonateSupport(_resolved!);
140
+ }
141
+ return [..._tlsArgs];
142
+ }
143
+
144
+ /**
145
+ * Reset the cached binary path (useful for testing).
146
+ */
147
+ export function resetCurlBinaryCache(): void {
148
+ _resolved = null;
149
+ _isImpersonate = false;
150
+ _tlsArgs = null;
151
+ }
src/tls/curl-fetch.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple GET/POST helpers using curl-impersonate.
3
+ *
4
+ * Drop-in replacement for Node.js fetch() that routes through
5
+ * curl-impersonate with Chrome TLS profile to avoid fingerprinting.
6
+ *
7
+ * Used for non-streaming requests (OAuth, appcast, etc.).
8
+ */
9
+
10
+ import { execFile } from "child_process";
11
+ import { resolveCurlBinary, getChromeTlsArgs } from "./curl-binary.js";
12
+
13
+ export interface CurlFetchResponse {
14
+ status: number;
15
+ body: string;
16
+ ok: boolean;
17
+ }
18
+
19
+ const STATUS_SEPARATOR = "\n__CURL_HTTP_STATUS__";
20
+
21
+ /**
22
+ * Perform a GET request via curl-impersonate.
23
+ */
24
+ export function curlFetchGet(url: string): Promise<CurlFetchResponse> {
25
+ const args = [
26
+ ...getChromeTlsArgs(),
27
+ "-s", "-S",
28
+ "--compressed",
29
+ "--max-time", "30",
30
+ "-w", STATUS_SEPARATOR + "%{http_code}",
31
+ url,
32
+ ];
33
+
34
+ return execCurl(args);
35
+ }
36
+
37
+ /**
38
+ * Perform a POST request via curl-impersonate.
39
+ */
40
+ export function curlFetchPost(
41
+ url: string,
42
+ contentType: string,
43
+ body: string,
44
+ ): Promise<CurlFetchResponse> {
45
+ const args = [
46
+ ...getChromeTlsArgs(),
47
+ "-s", "-S",
48
+ "--compressed",
49
+ "--max-time", "30",
50
+ "-X", "POST",
51
+ "-H", `Content-Type: ${contentType}`,
52
+ "-d", body,
53
+ "-w", STATUS_SEPARATOR + "%{http_code}",
54
+ url,
55
+ ];
56
+
57
+ return execCurl(args);
58
+ }
59
+
60
+ function execCurl(args: string[]): Promise<CurlFetchResponse> {
61
+ return new Promise((resolve, reject) => {
62
+ execFile(
63
+ resolveCurlBinary(),
64
+ args,
65
+ { maxBuffer: 2 * 1024 * 1024 },
66
+ (err, stdout, stderr) => {
67
+ if (err) {
68
+ reject(new Error(`curl failed: ${err.message} ${stderr}`));
69
+ return;
70
+ }
71
+
72
+ const sepIdx = stdout.lastIndexOf(STATUS_SEPARATOR);
73
+ if (sepIdx === -1) {
74
+ reject(new Error(`curl: missing status separator in output`));
75
+ return;
76
+ }
77
+
78
+ const body = stdout.slice(0, sepIdx);
79
+ const status = parseInt(stdout.slice(sepIdx + STATUS_SEPARATOR.length), 10);
80
+
81
+ resolve({
82
+ status,
83
+ body,
84
+ ok: status >= 200 && status < 300,
85
+ });
86
+ },
87
+ );
88
+ });
89
+ }
src/translation/codex-to-openai.ts CHANGED
@@ -37,6 +37,7 @@ export async function* streamCodexToOpenAI(
37
  rawResponse: Response,
38
  model: string,
39
  onUsage?: (usage: UsageInfo) => void,
 
40
  ): AsyncGenerator<string> {
41
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
42
  const created = Math.floor(Date.now() / 1000);
@@ -63,9 +64,12 @@ export async function* streamCodexToOpenAI(
63
  switch (evt.event) {
64
  case "response.created":
65
  case "response.in_progress": {
66
- // Extract response ID for headers
67
  const resp = data.response as Record<string, unknown> | undefined;
68
- if (resp?.id) responseId = resp.id as string;
 
 
 
69
  break;
70
  }
71
 
@@ -135,17 +139,25 @@ export async function collectCodexResponse(
135
  codexApi: CodexApi,
136
  rawResponse: Response,
137
  model: string,
138
- ): Promise<{ response: ChatCompletionResponse; usage: UsageInfo }> {
139
  const id = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
140
  const created = Math.floor(Date.now() / 1000);
141
  let fullText = "";
142
  let promptTokens = 0;
143
  let completionTokens = 0;
 
144
 
145
  for await (const evt of codexApi.parseStream(rawResponse)) {
146
  const data = evt.data as Record<string, unknown>;
147
 
148
  switch (evt.event) {
 
 
 
 
 
 
 
149
  case "response.output_text.delta": {
150
  const delta = (data.delta as string) ?? "";
151
  fullText += delta;
@@ -153,8 +165,8 @@ export async function collectCodexResponse(
153
  }
154
 
155
  case "response.completed": {
156
- // Try to extract usage from the completed response
157
  const resp = data.response as Record<string, unknown> | undefined;
 
158
  if (resp?.usage) {
159
  const usage = resp.usage as Record<string, number>;
160
  promptTokens = usage.input_tokens ?? 0;
@@ -191,5 +203,6 @@ export async function collectCodexResponse(
191
  input_tokens: promptTokens,
192
  output_tokens: completionTokens,
193
  },
 
194
  };
195
  }
 
37
  rawResponse: Response,
38
  model: string,
39
  onUsage?: (usage: UsageInfo) => void,
40
+ onResponseId?: (id: string) => void,
41
  ): AsyncGenerator<string> {
42
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
43
  const created = Math.floor(Date.now() / 1000);
 
64
  switch (evt.event) {
65
  case "response.created":
66
  case "response.in_progress": {
67
+ // Extract response ID for headers and multi-turn
68
  const resp = data.response as Record<string, unknown> | undefined;
69
+ if (resp?.id) {
70
+ responseId = resp.id as string;
71
+ onResponseId?.(responseId);
72
+ }
73
  break;
74
  }
75
 
 
139
  codexApi: CodexApi,
140
  rawResponse: Response,
141
  model: string,
142
+ ): Promise<{ response: ChatCompletionResponse; usage: UsageInfo; responseId: string | null }> {
143
  const id = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
144
  const created = Math.floor(Date.now() / 1000);
145
  let fullText = "";
146
  let promptTokens = 0;
147
  let completionTokens = 0;
148
+ let responseId: string | null = null;
149
 
150
  for await (const evt of codexApi.parseStream(rawResponse)) {
151
  const data = evt.data as Record<string, unknown>;
152
 
153
  switch (evt.event) {
154
+ case "response.created":
155
+ case "response.in_progress": {
156
+ const resp = data.response as Record<string, unknown> | undefined;
157
+ if (resp?.id) responseId = resp.id as string;
158
+ break;
159
+ }
160
+
161
  case "response.output_text.delta": {
162
  const delta = (data.delta as string) ?? "";
163
  fullText += delta;
 
165
  }
166
 
167
  case "response.completed": {
 
168
  const resp = data.response as Record<string, unknown> | undefined;
169
+ if (resp?.id) responseId = resp.id as string;
170
  if (resp?.usage) {
171
  const usage = resp.usage as Record<string, number>;
172
  promptTokens = usage.input_tokens ?? 0;
 
203
  input_tokens: promptTokens,
204
  output_tokens: completionTokens,
205
  },
206
+ responseId,
207
  };
208
  }
src/translation/openai-to-codex.ts CHANGED
@@ -2,6 +2,8 @@
2
  * Translate OpenAI Chat Completions request → Codex Responses API request.
3
  */
4
 
 
 
5
  import type { ChatCompletionRequest } from "../types/openai.js";
6
  import type {
7
  CodexResponsesRequest,
@@ -10,6 +12,19 @@ import type {
10
  import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  /**
14
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
15
  *
@@ -21,12 +36,16 @@ import { getConfig } from "../config.js";
21
  */
22
  export function translateToCodexRequest(
23
  req: ChatCompletionRequest,
 
24
  ): CodexResponsesRequest {
25
  // Collect system messages as instructions
26
  const systemMessages = req.messages.filter((m) => m.role === "system");
27
- const instructions =
28
  systemMessages.map((m) => m.content).join("\n\n") ||
29
  "You are a helpful assistant.";
 
 
 
30
 
31
  // Build input items from non-system messages
32
  const input: CodexInputItem[] = [];
@@ -58,6 +77,11 @@ export function translateToCodexRequest(
58
  tools: [],
59
  };
60
 
 
 
 
 
 
61
  // Add reasoning effort if applicable
62
  const effort =
63
  req.reasoning_effort ??
 
2
  * Translate OpenAI Chat Completions request → Codex Responses API request.
3
  */
4
 
5
+ import { readFileSync } from "fs";
6
+ import { resolve } from "path";
7
  import type { ChatCompletionRequest } from "../types/openai.js";
8
  import type {
9
  CodexResponsesRequest,
 
12
  import { resolveModelId, getModelInfo } from "../routes/models.js";
13
  import { getConfig } from "../config.js";
14
 
15
+ const DESKTOP_CONTEXT = loadDesktopContext();
16
+
17
+ function loadDesktopContext(): string {
18
+ try {
19
+ return readFileSync(
20
+ resolve(process.cwd(), "config/prompts/desktop-context.md"),
21
+ "utf-8",
22
+ );
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
  /**
29
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
30
  *
 
36
  */
37
  export function translateToCodexRequest(
38
  req: ChatCompletionRequest,
39
+ previousResponseId?: string | null,
40
  ): CodexResponsesRequest {
41
  // Collect system messages as instructions
42
  const systemMessages = req.messages.filter((m) => m.role === "system");
43
+ const userInstructions =
44
  systemMessages.map((m) => m.content).join("\n\n") ||
45
  "You are a helpful assistant.";
46
+ const instructions = DESKTOP_CONTEXT
47
+ ? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
48
+ : userInstructions;
49
 
50
  // Build input items from non-system messages
51
  const input: CodexInputItem[] = [];
 
77
  tools: [],
78
  };
79
 
80
+ // Add previous response ID for multi-turn conversations
81
+ if (previousResponseId) {
82
+ request.previous_response_id = previousResponseId;
83
+ }
84
+
85
  // Add reasoning effort if applicable
86
  const effort =
87
  req.reasoning_effort ??
src/update-checker.ts CHANGED
@@ -8,6 +8,7 @@ import { resolve } from "path";
8
  import yaml from "js-yaml";
9
  import { mutateClientConfig } from "./config.js";
10
  import { jitterInt } from "./utils/jitter.js";
 
11
 
12
  const CONFIG_PATH = resolve(process.cwd(), "config/default.yaml");
13
  const STATE_PATH = resolve(process.cwd(), "data/update-state.json");
@@ -44,8 +45,13 @@ function parseAppcast(xml: string): {
44
  const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i);
45
  if (!itemMatch) return { version: null, build: null, downloadUrl: null };
46
  const item = itemMatch[1];
47
- const versionMatch = item.match(/sparkle:shortVersionString="([^"]+)"/);
48
- const buildMatch = item.match(/sparkle:version="([^"]+)"/);
 
 
 
 
 
49
  const urlMatch = item.match(/url="([^"]+)"/);
50
  return {
51
  version: versionMatch?.[1] ?? null,
@@ -64,11 +70,11 @@ function applyVersionUpdate(version: string, build: string): void {
64
 
65
  export async function checkForUpdate(): Promise<UpdateState> {
66
  const current = loadCurrentConfig();
67
- const res = await fetch(APPCAST_URL);
68
  if (!res.ok) {
69
- throw new Error(`Failed to fetch appcast: ${res.status} ${res.statusText}`);
70
  }
71
- const xml = await res.text();
72
  const { version, build, downloadUrl } = parseAppcast(xml);
73
 
74
  const updateAvailable = !!(version && build &&
 
8
  import yaml from "js-yaml";
9
  import { mutateClientConfig } from "./config.js";
10
  import { jitterInt } from "./utils/jitter.js";
11
+ import { curlFetchGet } from "./tls/curl-fetch.js";
12
 
13
  const CONFIG_PATH = resolve(process.cwd(), "config/default.yaml");
14
  const STATE_PATH = resolve(process.cwd(), "data/update-state.json");
 
45
  const itemMatch = xml.match(/<item>([\s\S]*?)<\/item>/i);
46
  if (!itemMatch) return { version: null, build: null, downloadUrl: null };
47
  const item = itemMatch[1];
48
+ // Support both attribute syntax (sparkle:version="X") and element syntax (<sparkle:version>X</sparkle:version>)
49
+ const versionMatch =
50
+ item.match(/sparkle:shortVersionString="([^"]+)"/) ??
51
+ item.match(/<sparkle:shortVersionString>([^<]+)<\/sparkle:shortVersionString>/);
52
+ const buildMatch =
53
+ item.match(/sparkle:version="([^"]+)"/) ??
54
+ item.match(/<sparkle:version>([^<]+)<\/sparkle:version>/);
55
  const urlMatch = item.match(/url="([^"]+)"/);
56
  return {
57
  version: versionMatch?.[1] ?? null,
 
70
 
71
  export async function checkForUpdate(): Promise<UpdateState> {
72
  const current = loadCurrentConfig();
73
+ const res = await curlFetchGet(APPCAST_URL);
74
  if (!res.ok) {
75
+ throw new Error(`Failed to fetch appcast: ${res.status}`);
76
  }
77
+ const xml = res.body;
78
  const { version, build, downloadUrl } = parseAppcast(xml);
79
 
80
  const updateAvailable = !!(version && build &&