clauderipple 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.ko.md +19 -7
- package/README.md +22 -8
- package/dist/cli/src/codex.js +26 -2
- package/dist/cli/src/index.js +14 -7
- package/dist/router/src/admin.js +74 -4
- package/dist/router/src/index.js +9 -2
- package/dist/router/src/ingress/server.js +77 -3
- package/dist/router/src/locked-file.js +80 -0
- package/dist/router/src/providers/anthropic-accounts.js +9 -74
- package/dist/router/src/providers/chatgpt/accounts.js +438 -0
- package/dist/router/src/providers/chatgpt/auth.js +43 -87
- package/dist/router/src/providers/chatgpt/index.js +483 -84
- package/dist/router/src/proxy.js +36 -6
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +128 -5
- package/dist/ui/i18n.js +4 -4
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +54 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
Several ChatGPT accounts, for Claude Desktop and for Codex.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Several ChatGPT accounts.** `clauderipple login` (or **+ Add ChatGPT account**
|
|
10
|
+
in the dashboard) adds an account instead of replacing the one you had; the
|
|
11
|
+
sign-in asks who is signing in, so the browser's current ChatGPT session is not
|
|
12
|
+
silently reused. When an account hits its limit, the same request moves to the
|
|
13
|
+
next one before anything reaches the client, and the spent account rests until
|
|
14
|
+
the window the backend reports resets. A conversation stays on the account that
|
|
15
|
+
answered, to keep its prompt cache. A 401 gets one token refresh and a replay;
|
|
16
|
+
only a freshly minted token refused again asks for a new sign-in. Accounts live
|
|
17
|
+
in `~/.clauderipple/chatgpt-accounts.json` (0600); the old single login is moved
|
|
18
|
+
there on the first sign-in. The Codex CLI's own login still takes part, last.
|
|
19
|
+
- **Accounts tab for ChatGPT** in the dashboard: each account's 5-hour and weekly
|
|
20
|
+
usage, when a resting one is back, and pause/resume, rename, remove, use now and
|
|
21
|
+
sign in again.
|
|
22
|
+
- **Codex's GPT goes through the account pool.** `clauderipple codex on` now also
|
|
23
|
+
points Codex's built-in OpenAI provider at ClaudeRipple. Codex keeps its ChatGPT
|
|
24
|
+
sign-in; its GPT requests reach ChatGPT unchanged except for the account, so when
|
|
25
|
+
one account runs out Codex carries on with the next — no sign-out. With no account
|
|
26
|
+
added, or every one at its limit, Codex's own login is used. An `openai_base_url`
|
|
27
|
+
of your own is never overwritten; `clauderipple codex off` undoes it.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `clauderipple logout` removes every ChatGPT account added with `login`.
|
|
32
|
+
- `/api/status` keeps `chatgpt.quota` (the account that answers next) and adds
|
|
33
|
+
`chatgpt.accounts`, one entry per account.
|
|
34
|
+
|
|
3
35
|
## 0.3.1 — 2026-09-24
|
|
4
36
|
|
|
5
37
|
Fixes for three reports against 0.3.0.
|
package/README.ko.md
CHANGED
|
@@ -125,10 +125,11 @@ ClaudeRipple은 데스크톱 앱·터미널·Codex를 메뉴 막대 앱 하나
|
|
|
125
125
|
|
|
126
126
|
| | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
|
|
127
127
|
|---|---|---|---|---|
|
|
128
|
-
| 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ |
|
|
129
|
-
| claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ |
|
|
130
|
-
| Claude 구독과 GPT 구독 나란히 | ✅ |
|
|
131
|
-
| Desktop 피커에 실제 모델 이름 | ✅ | ❌ | ❌ | 일부 |
|
|
128
|
+
| 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ✅ ¹ | ❌ | ❌ (정의상 불가) |
|
|
129
|
+
| claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ✅ ¹ | ❌ | ❌ |
|
|
130
|
+
| Claude 구독과 GPT 구독 나란히 | ✅ | ✅ ¹ | ❌ | ❌ |
|
|
131
|
+
| Desktop 피커에 실제 모델 이름 | ✅ | ❌ Claude 이름을 빌려 씀 ¹ | ❌ | 일부 |
|
|
132
|
+
| ChatGPT 계정 여러 개, 한도 차면 자동 전환 (Desktop·Codex) | ✅ | ✅ | ❌ | ❌ |
|
|
132
133
|
| 터미널 `claude` CLI | ✅ | ✅ | ✅ | ✅ |
|
|
133
134
|
| Codex **앱**·Codex CLI → Claude | ✅ | ✅ | ❌ | ❌ |
|
|
134
135
|
| 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 미측정 | 제각각 | 해당 없음 |
|
|
@@ -136,8 +137,9 @@ ClaudeRipple은 데스크톱 앱·터미널·Codex를 메뉴 막대 앱 하나
|
|
|
136
137
|
| 터미널 없이 쓰는 설정 GUI | ✅ | ❌ | ❌ | ❌ |
|
|
137
138
|
| 런타임 내장, 서명·공증된 앱 | ✅ | ❌ | ❌ | – |
|
|
138
139
|
|
|
139
|
-
¹ opencodex는
|
|
140
|
-
|
|
140
|
+
¹ opencodex 기준(openclaude는 확인하지 않음). opencodex는 v2.61.0(2026-09-22)부터 게이트웨이 없이 Code 탭에
|
|
141
|
+
들어갑니다. 다만 모델 선택기는 건드리지 않아서, 다른 모델은 Claude 모델 이름에 연결해(`modelMap`) 그 이름으로 고릅니다.
|
|
142
|
+
ClaudeRipple은 선택기에 GPT·DeepSeek 등을 실제 이름으로 띄우고, 고른 추론 강도를 그대로 넘깁니다.
|
|
141
143
|
|
|
142
144
|
데스크톱 앱의 "서드파티 추론" 설정은 앱 전체를 다른 모드로 바꿔 버립니다. claude.ai 채팅, Remote Control,
|
|
143
145
|
클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에 아예 닿지 못합니다.
|
|
@@ -155,6 +157,10 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
|
|
|
155
157
|
구독 계정을 추가하면, 대화마다 답한 계정에 고정됩니다. 응답이 시작되기 전 한도·인증 거부가 오면 같은 요청을
|
|
156
158
|
다음 계정으로 넘깁니다. 이 Claude Desktop/Code 원형 경로는 번역을 거치는 Codex 입구와 별개이며, Codex 쪽은
|
|
157
159
|
사용 가능한 로그인 하나만 골라 쓰고 계정을 자동 전환하지 않습니다.
|
|
160
|
+
- **ChatGPT 계정 여러 개.** `clauderipple login`(또는 대시보드의 **+ ChatGPT 계정 추가**)을 할 때마다 계정이
|
|
161
|
+
늘어납니다. 한 계정이 한도에 걸리면 같은 요청을 다음 계정이 이어받고, 그 계정은 한도가 풀릴 때까지 쉽니다.
|
|
162
|
+
대화는 답한 계정에 고정되어 캐시를 지킵니다. Claude Desktop Code 탭·서브에이전트와 **Codex 앱·CLI의 GPT**에
|
|
163
|
+
모두 적용됩니다. Codex는 로그인을 그대로 두고, 로그아웃·재로그인 없이 다음 계정으로 넘어갑니다.
|
|
158
164
|
- **Claude Code 하네스는 손대지 않습니다.** 스킬, 훅, MCP, `CLAUDE.md`, 서브에이전트, 플랜 모드, 휴대폰 Remote Control.
|
|
159
165
|
아무것도 꺼지지 않습니다.
|
|
160
166
|
- **구조적으로 올바르게.** 프롬프트 캐시 보존(Anthropic 캐시 브레이크포인트, 고정된 OpenAI 프리픽스), Claude Code의
|
|
@@ -299,6 +305,12 @@ clauderipple codex on # ~/.codex/config.toml에 "clauderipple" 프로바이
|
|
|
299
305
|
codex --profile clauderipple -m claude-sonnet-5
|
|
300
306
|
```
|
|
301
307
|
|
|
308
|
+
`codex on`은 Codex의 기본 OpenAI 경로(`openai_base_url`)도 ClaudeRipple로 돌립니다. Codex는 ChatGPT 로그인을
|
|
309
|
+
그대로 쓰고, GPT 요청은 내용 그대로 ChatGPT로 넘어가되 계정만 ClaudeRipple에 추가한 계정 중에서 골라 끼웁니다.
|
|
310
|
+
그래서 계정을 여러 개 추가해 두면 Codex도 한도가 찬 계정에서 다음 계정으로 넘어갑니다. 추가한 계정이 없거나
|
|
311
|
+
전부 한도에 걸려 있으면 Codex 자신의 로그인으로 보냅니다. 직접 넣어 둔 `openai_base_url`이 있으면 건드리지 않습니다.
|
|
312
|
+
`clauderipple codex off`로 되돌립니다.
|
|
313
|
+
|
|
302
314
|
Claude 모델이 Codex 모델 목록에 이름 그대로 뜹니다(ClaudeRipple이 Codex 자체 카탈로그 옆에 모델 카탈로그를 씁니다).
|
|
303
315
|
Claude는 Claude Code 로그인(실행 중인 Desktop 세션, 터미널 로그인, 또는 ClaudeRipple 자체 로그인 — **프로바이더 →
|
|
304
316
|
Claude → Claude 구독 연결…**을 누르면 브라우저가 열리고 터미널은 필요 없습니다. `clauderipple claude-login`과 같습니다)이나
|
|
@@ -313,7 +325,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
|
|
|
313
325
|
|
|
314
326
|
| 프로바이더 | 종류 | 인증 | 모델 목록 | 비고 |
|
|
315
327
|
|---|---|---|---|---|
|
|
316
|
-
| ChatGPT 구독 | Codex 백엔드 | 로그인(
|
|
328
|
+
| ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | Terra, Sol, Luna, Astra | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
|
|
317
329
|
| OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
|
|
318
330
|
| DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
|
|
319
331
|
| xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
|
package/README.md
CHANGED
|
@@ -141,10 +141,11 @@ and claude.ai connectors keep working while another model does the thinking.
|
|
|
141
141
|
|
|
142
142
|
| | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop gateway (3P) mode |
|
|
143
143
|
|---|---|---|---|---|
|
|
144
|
-
| Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ |
|
|
145
|
-
| Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ |
|
|
146
|
-
| Claude and GPT subscriptions side by side | ✅ |
|
|
147
|
-
| Real model names in the Desktop picker | ✅ | ❌ | ❌ | partial |
|
|
144
|
+
| Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ✅ ¹ | ❌ | ❌ by definition |
|
|
145
|
+
| Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ✅ ¹ | ❌ | ❌ |
|
|
146
|
+
| Claude and GPT subscriptions side by side | ✅ | ✅ ¹ | ❌ | ❌ |
|
|
147
|
+
| Real model names in the Desktop picker | ✅ | ❌ borrows Claude names ¹ | ❌ | partial |
|
|
148
|
+
| Several ChatGPT accounts, switching when one runs out (Desktop and Codex) | ✅ | ✅ | ❌ | ❌ |
|
|
148
149
|
| Terminal `claude` CLI | ✅ | ✅ | ✅ | ✅ |
|
|
149
150
|
| Codex **app** and Codex CLI → Claude | ✅ | ✅ | ❌ | ❌ |
|
|
150
151
|
| Prompt cache on translated providers | **94–99 %** measured | not measured | varies | n/a |
|
|
@@ -152,9 +153,10 @@ and claude.ai connectors keep working while another model does the thinking.
|
|
|
152
153
|
| Settings GUI, no terminal needed | ✅ | ❌ | ❌ | ❌ |
|
|
153
154
|
| Signed, notarized app with its own runtime | ✅ | ❌ | ❌ | – |
|
|
154
155
|
|
|
155
|
-
¹ opencodex
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
¹ opencodex (openclaude not checked). Since v2.61.0 (2026-09-22) opencodex reaches the Code
|
|
157
|
+
tab without the gateway. It leaves the model picker alone, so another model is chosen under a
|
|
158
|
+
Claude model's name it is mapped to (`modelMap`). ClaudeRipple puts GPT, DeepSeek and the rest
|
|
159
|
+
in the picker under their own names and passes the effort you pick.
|
|
158
160
|
|
|
159
161
|
The desktop app's own "third-party inference" setting flips the whole app into another
|
|
160
162
|
mode: you lose claude.ai chat, Remote Control and cloud sessions. Tools that replace
|
|
@@ -178,6 +180,12 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
|
|
|
178
180
|
quota or authentication refusal moves that turn to the next account. This native
|
|
179
181
|
Claude Desktop/Code path is separate from the translated Codex ingress, which picks
|
|
180
182
|
one available login and does not rotate accounts.
|
|
183
|
+
- **Several ChatGPT accounts.** Each `clauderipple login` (or **+ Add ChatGPT account**
|
|
184
|
+
in the dashboard) adds one. When an account hits its limit, the next one takes over
|
|
185
|
+
the same request and the spent one rests until its window resets; a conversation
|
|
186
|
+
stays on the account that answered, to keep its cache. This covers the Claude Desktop
|
|
187
|
+
Code tab and subagents **and GPT in the Codex app and CLI** — Codex keeps its own
|
|
188
|
+
sign-in and moves to the next account without signing out.
|
|
181
189
|
- **The full Claude Code harness, untouched.** Skills, hooks, MCP, `CLAUDE.md`,
|
|
182
190
|
subagents, plan mode, Remote Control on your phone: nothing is turned off.
|
|
183
191
|
- **Correct by construction.** Prompt caching preserved (Anthropic cache breakpoints
|
|
@@ -342,6 +350,12 @@ clauderipple codex on # adds a "clauderipple" provider to ~/.codex/config.to
|
|
|
342
350
|
codex --profile clauderipple -m claude-sonnet-5
|
|
343
351
|
```
|
|
344
352
|
|
|
353
|
+
`codex on` also points Codex's built-in OpenAI provider at ClaudeRipple (`openai_base_url`).
|
|
354
|
+
Codex keeps its ChatGPT sign-in; its GPT requests go on to ChatGPT unchanged, except that the
|
|
355
|
+
account is chosen from the ones added to ClaudeRipple — so with several added, Codex too moves
|
|
356
|
+
from a spent account to the next. With none added, or all of them at their limit, Codex's own
|
|
357
|
+
login is used. An `openai_base_url` you set yourself is left alone. `clauderipple codex off` undoes it.
|
|
358
|
+
|
|
345
359
|
Claude models show up in Codex's model list under their names (ClaudeRipple writes
|
|
346
360
|
a model catalog next to Codex's own). Claude is reached through your Claude Code
|
|
347
361
|
login (detected from the running Desktop session, the terminal login, or a
|
|
@@ -359,7 +373,7 @@ provider you configured is available the same way.
|
|
|
359
373
|
|
|
360
374
|
| Provider | Kind | Auth | Model list | Notes |
|
|
361
375
|
|---|---|---|---|---|
|
|
362
|
-
| ChatGPT subscription | Codex backend | sign
|
|
376
|
+
| ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | Terra, Sol, Luna, Astra | effort low…max (Luna: ultra), prompt cache 94–99 % |
|
|
363
377
|
| OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
|
|
364
378
|
| DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
|
|
365
379
|
| xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
|
package/dist/cli/src/codex.js
CHANGED
|
@@ -10,6 +10,8 @@ const PROFILE_START = "# >>> ClaudeRipple Codex profile >>>";
|
|
|
10
10
|
const PROFILE_END = "# <<< ClaudeRipple Codex profile <<<";
|
|
11
11
|
const CATALOG_START = "# >>> ClaudeRipple Codex model catalog >>>";
|
|
12
12
|
const CATALOG_END = "# <<< ClaudeRipple Codex model catalog <<<";
|
|
13
|
+
const OPENAI_START = "# >>> ClaudeRipple OpenAI route >>>";
|
|
14
|
+
const OPENAI_END = "# <<< ClaudeRipple OpenAI route <<<";
|
|
13
15
|
export const CODEX_PROVIDER_MARKER = START;
|
|
14
16
|
export function codexHome() {
|
|
15
17
|
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
|
@@ -114,6 +116,28 @@ function withCatalog(content, file) {
|
|
|
114
116
|
return stripped;
|
|
115
117
|
return `${catalogBlock(file)}\n\n${stripped}`.replace(/\n+$/, "\n");
|
|
116
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Codex's built-in `openai` provider pointed at our ingress (`openai_base_url`, a root key). Codex
|
|
121
|
+
* keeps its ChatGPT sign-in and sends GPT turns here; the ingress passes them to the ChatGPT backend
|
|
122
|
+
* on one of the signed-in accounts, so running out on one account moves Codex to the next without
|
|
123
|
+
* a sign-out. A user's own `openai_base_url` is left alone (and then this does not apply).
|
|
124
|
+
*/
|
|
125
|
+
function withOpenAiRoute(content, port) {
|
|
126
|
+
const stripped = withoutOwnedBlock(content, OPENAI_START, OPENAI_END);
|
|
127
|
+
if (port === undefined || /^\s*openai_base_url\s*=/m.test(beforeFirstTable(stripped)[0]))
|
|
128
|
+
return stripped;
|
|
129
|
+
const block = `${OPENAI_START}\nopenai_base_url = "http://127.0.0.1:${port}/v1"\n${OPENAI_END}`;
|
|
130
|
+
return `${block}\n\n${stripped}`.replace(/\n+$/, "\n");
|
|
131
|
+
}
|
|
132
|
+
/** Whether Codex's own GPT traffic goes through ClaudeRipple (our route block is in place). */
|
|
133
|
+
export function codexOpenAiRouted(home = codexHome()) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readFileSync(configFile(home), "utf8").includes(OPENAI_START);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
117
141
|
function profileBlock() {
|
|
118
142
|
return `${PROFILE_START}
|
|
119
143
|
model_provider = "clauderipple"
|
|
@@ -146,7 +170,7 @@ export function codexOn(port, home = codexHome(), models = []) {
|
|
|
146
170
|
const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
|
|
147
171
|
const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
|
|
148
172
|
const catalog = models.length ? writeCodexCatalog(models, home) : undefined;
|
|
149
|
-
const nextConfig = withCatalog(appendBlock(withoutOwnedBlock(beforeConfig, START, END), providerBlock(port)), catalog);
|
|
173
|
+
const nextConfig = withOpenAiRoute(withCatalog(appendBlock(withoutOwnedBlock(beforeConfig, START, END), providerBlock(port)), catalog), port);
|
|
150
174
|
const nextProfile = appendBlock(withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END), profileBlock());
|
|
151
175
|
if (nextConfig === beforeConfig && nextProfile === beforeProfile)
|
|
152
176
|
return { changed: false, config, profile };
|
|
@@ -162,7 +186,7 @@ export function codexOff(home = codexHome()) {
|
|
|
162
186
|
const profile = profileFile(home);
|
|
163
187
|
const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
|
|
164
188
|
const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
|
|
165
|
-
const nextConfig = withoutDanglingSelection(withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END));
|
|
189
|
+
const nextConfig = withoutDanglingSelection(withoutOwnedBlock(withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END), OPENAI_START, OPENAI_END));
|
|
166
190
|
const nextProfile = withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END);
|
|
167
191
|
fs.rmSync(catalogFile(home), { force: true });
|
|
168
192
|
if (nextConfig === beforeConfig && nextProfile === beforeProfile)
|
package/dist/cli/src/index.js
CHANGED
|
@@ -340,8 +340,8 @@ function help() {
|
|
|
340
340
|
config print the config file path
|
|
341
341
|
ui open the dashboard in your browser
|
|
342
342
|
tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
|
|
343
|
-
login
|
|
344
|
-
logout forget
|
|
343
|
+
login add a ChatGPT account (opens your browser; run again to add another — they take turns when one runs out)
|
|
344
|
+
logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
|
|
345
345
|
claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
|
|
346
346
|
claude-logout remove every Claude subscription added to ClaudeRipple
|
|
347
347
|
picker on|off show your mapped models by name in the Claude Desktop picker (trusts the CA in your login keychain, routes the app through ClaudeRipple)
|
|
@@ -426,17 +426,24 @@ try {
|
|
|
426
426
|
}
|
|
427
427
|
case "login": {
|
|
428
428
|
const { login } = await import("../../router/src/providers/chatgpt/auth.js");
|
|
429
|
-
|
|
430
|
-
const
|
|
429
|
+
const { saveChatGptAccount, readChatGptAccounts, chatgptAccountsPath } = await import("../../router/src/providers/chatgpt/accounts.js");
|
|
430
|
+
const before = readChatGptAccounts(homeDir()).length;
|
|
431
|
+
console.log(before > 0
|
|
432
|
+
? `Opening your browser to add a ChatGPT account (${before} signed in already). Sign in with the account to add; this window waits up to 5 minutes.`
|
|
433
|
+
: "Opening your browser to sign in to ChatGPT. Sign in there; this window waits up to 5 minutes.");
|
|
434
|
+
const grant = await login((url) => {
|
|
431
435
|
if (!openBrowser(url))
|
|
432
436
|
console.log(`Open this URL manually:\n${url}`);
|
|
433
437
|
});
|
|
434
|
-
|
|
438
|
+
const saved = saveChatGptAccount(homeDir(), grant);
|
|
439
|
+
const total = readChatGptAccounts(homeDir()).length;
|
|
440
|
+
console.log(`${saved.added ? "✓ added" : "✓ signed in again as"} ${saved.email ?? saved.label} (token valid until ${new Date(grant.expiresAt).toLocaleString()}). ${total} ChatGPT account${total === 1 ? "" : "s"} in ${chatgptAccountsPath(homeDir())}`);
|
|
435
441
|
break;
|
|
436
442
|
}
|
|
437
443
|
case "logout": {
|
|
438
|
-
const {
|
|
439
|
-
|
|
444
|
+
const { removeAllChatGptAccounts } = await import("../../router/src/providers/chatgpt/accounts.js");
|
|
445
|
+
const removed = removeAllChatGptAccounts(homeDir());
|
|
446
|
+
console.log(removed > 0 ? `✓ removed ${removed} ChatGPT account${removed === 1 ? "" : "s"}` : "no ChatGPT account stored");
|
|
440
447
|
break;
|
|
441
448
|
}
|
|
442
449
|
case "claude-login": {
|
package/dist/router/src/admin.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// GET /api/config raw config.json
|
|
5
5
|
// PUT /api/config validate + atomically save config.json (router picks it up via mtime)
|
|
6
6
|
// GET /api/logs?n= tail of router.log
|
|
7
|
-
// POST /api/chatgpt-login begin ChatGPT browser login without holding the request open
|
|
7
|
+
// POST /api/chatgpt-login begin ChatGPT browser login (adds an account) without holding the request open
|
|
8
8
|
// GET /api/chatgpt-login ChatGPT browser login state and credential status
|
|
9
|
+
// GET /api/chatgpt-accounts?provider= signed-in ChatGPT accounts, rotation state, quota (no tokens)
|
|
10
|
+
// PATCH/DELETE /api/chatgpt-accounts/:id rename, pause/resume, clear a cooldown; remove
|
|
9
11
|
// GET /* static files from packages/ui (the GUI itself)
|
|
10
12
|
import crypto from "node:crypto";
|
|
11
13
|
import fs from "node:fs";
|
|
@@ -29,6 +31,7 @@ import { syncModelSlots } from "../../cli/src/settings.js";
|
|
|
29
31
|
import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
|
|
30
32
|
import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
|
|
31
33
|
import { listClaudeAccounts, removeClaudeAccount, renameClaudeAccount } from "./providers/anthropic-accounts.js";
|
|
34
|
+
import { readChatGptAccounts, removeChatGptAccount, summarize as summarizeChatGptAccount, updateChatGptAccount } from "./providers/chatgpt/accounts.js";
|
|
32
35
|
const MAX_BODY = 1024 * 1024;
|
|
33
36
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
34
37
|
const STARTED_AT = new Date().toISOString();
|
|
@@ -186,7 +189,7 @@ function tcpReachable(hostname, port, timeoutMs = 2000) {
|
|
|
186
189
|
* the first request is ever made. "auto" accepts either our own login or a Codex CLI one.
|
|
187
190
|
*/
|
|
188
191
|
export function chatgptSignedIn(mode) {
|
|
189
|
-
const own =
|
|
192
|
+
const own = readChatGptAccounts(homeDir()).length > 0;
|
|
190
193
|
const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
|
|
191
194
|
return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
|
|
192
195
|
}
|
|
@@ -305,7 +308,9 @@ async function buildStatus(deps, opts = {}) {
|
|
|
305
308
|
pointsAtRouter: env.HTTPS_PROXY === wantProxy,
|
|
306
309
|
},
|
|
307
310
|
cliVersion: cliVersion(),
|
|
308
|
-
|
|
311
|
+
// `quota` stays one snapshot per provider (the account in use) for the readers that want one
|
|
312
|
+
// number; `accounts` is the per-account view, read after the refresh above so it is as fresh.
|
|
313
|
+
chatgpt: { ...chatgpt, signedIn, accounts: chatgpt.accounts?.() ?? {}, ...(stale ? { stale: true, staleReason: staleReasons } : {}) },
|
|
309
314
|
// Only present for providers that declare a pool; a provider with one credential has nothing
|
|
310
315
|
// to report and would only add a row that never changes.
|
|
311
316
|
credentials: deps.credentials?.() ?? {},
|
|
@@ -1136,7 +1141,72 @@ export function startAdmin(deps) {
|
|
|
1136
1141
|
}
|
|
1137
1142
|
if (pathname === "/api/chatgpt-login" && method === "GET") {
|
|
1138
1143
|
const provider = Object.values(deps.config().providers).find((candidate) => candidate.type === "chatgpt");
|
|
1139
|
-
sendJson(res, 200, { ...chatgptLogin, signedIn: chatgptSignedIn(provider?.auth) });
|
|
1144
|
+
sendJson(res, 200, { ...chatgptLogin, signedIn: chatgptSignedIn(provider?.auth), accountCount: readChatGptAccounts(homeDir()).length });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
// Safe account metadata only: labels, emails, plan, rotation state and quota. Tokens and the
|
|
1148
|
+
// workspace id stay in the router.
|
|
1149
|
+
if (pathname === "/api/chatgpt-accounts" && method === "GET") {
|
|
1150
|
+
const all = deps.chatgpt?.().accounts?.() ?? {};
|
|
1151
|
+
const wanted = new URL(url, "http://127.0.0.1").searchParams.get("provider");
|
|
1152
|
+
const name = wanted && all[wanted] ? wanted : Object.keys(all)[0];
|
|
1153
|
+
const accounts = name ? all[name] : readChatGptAccounts(homeDir()).map(summarizeChatGptAccount);
|
|
1154
|
+
sendJson(res, 200, { provider: name ?? null, accounts });
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (pathname.startsWith("/api/chatgpt-accounts/") && (method === "PATCH" || method === "DELETE")) {
|
|
1158
|
+
let id;
|
|
1159
|
+
try {
|
|
1160
|
+
id = decodeURIComponent(pathname.slice("/api/chatgpt-accounts/".length));
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
sendJson(res, 400, { error: "invalid account id" });
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const own = id === "legacy" || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
|
1167
|
+
if (!own && id !== "codex") {
|
|
1168
|
+
sendJson(res, 400, { error: "invalid account id" });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (method === "DELETE") {
|
|
1172
|
+
if (id === "codex") {
|
|
1173
|
+
sendJson(res, 409, { error: "the Codex CLI's login belongs to Codex; sign out there instead" });
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (!removeChatGptAccount(homeDir(), id)) {
|
|
1177
|
+
sendJson(res, 404, { error: "ChatGPT account not found" });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
deps.log.info(`admin: removed ChatGPT account ${id.slice(0, 8)}`);
|
|
1181
|
+
sendJson(res, 200, { ok: true });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
let change;
|
|
1185
|
+
try {
|
|
1186
|
+
change = JSON.parse((await readBody(req)).toString("utf8"));
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if ((change.label !== undefined && (typeof change.label !== "string" || !change.label.trim())) || (change.paused !== undefined && typeof change.paused !== "boolean")) {
|
|
1193
|
+
sendJson(res, 400, { error: "expected {label?: non-empty string, paused?: boolean, clearCooldown?: true}" });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (change.clearCooldown === true)
|
|
1197
|
+
deps.chatgpt?.().clearCooldown?.(id);
|
|
1198
|
+
if (change.label !== undefined || change.paused !== undefined) {
|
|
1199
|
+
if (id === "codex") {
|
|
1200
|
+
sendJson(res, 409, { error: "the Codex CLI's login cannot be renamed or paused here" });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (!updateChatGptAccount(homeDir(), id, { ...(typeof change.label === "string" ? { label: change.label } : {}), ...(typeof change.paused === "boolean" ? { paused: change.paused } : {}) })) {
|
|
1204
|
+
sendJson(res, 404, { error: "ChatGPT account not found" });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
deps.log.info(`admin: updated ChatGPT account ${id.slice(0, 8)}`);
|
|
1209
|
+
sendJson(res, 200, { ok: true });
|
|
1140
1210
|
return;
|
|
1141
1211
|
}
|
|
1142
1212
|
if (pathname === "/api/chatgpt-login" && method === "POST") {
|
package/dist/router/src/index.js
CHANGED
|
@@ -85,7 +85,7 @@ const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstrea
|
|
|
85
85
|
});
|
|
86
86
|
const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
|
|
87
87
|
const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
|
|
88
|
-
const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth });
|
|
88
|
+
const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name) });
|
|
89
89
|
process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
|
|
90
90
|
process.on("unhandledRejection", (e) => log.error(`unhandled ${e?.stack ?? e}`));
|
|
91
91
|
const DRAIN_MS = 90_000; // new model calls are refused during drain, so this is the longest single call we wait for; `clauderipple restart` waits 120s
|
|
@@ -142,7 +142,14 @@ proxy
|
|
|
142
142
|
health: () => health.consecutiveFailures,
|
|
143
143
|
version: VERSION,
|
|
144
144
|
requests,
|
|
145
|
-
chatgpt: () => ({
|
|
145
|
+
chatgpt: () => ({
|
|
146
|
+
quota: proxy.chatgptRateLimits,
|
|
147
|
+
auth: proxy.chatgptAuthStatus(),
|
|
148
|
+
refresh: (name) => proxy.chatgptFetchRateLimits(name),
|
|
149
|
+
models: (name) => proxy.chatgptFetchModels(name),
|
|
150
|
+
accounts: () => proxy.chatgptAccounts(),
|
|
151
|
+
clearCooldown: (id) => proxy.chatgptClearCooldown(id),
|
|
152
|
+
}),
|
|
146
153
|
credentials: () => proxy.credentialHealth(),
|
|
147
154
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
148
155
|
observedClaudeCodeAuth,
|
|
@@ -5,6 +5,7 @@ import http from "node:http";
|
|
|
5
5
|
import https from "node:https";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import zlib from "node:zlib";
|
|
8
9
|
import { providerFor } from "../config.js";
|
|
9
10
|
import { resolve } from "../routing.js";
|
|
10
11
|
import { PRESETS } from "../presets.js";
|
|
@@ -19,6 +20,38 @@ import { ObservedClaudeCodeAuth } from "../providers/anthropic-observed.js";
|
|
|
19
20
|
import { ResponsesEventMapper, chatToAnthropic, formatDataSse, formatSse, responsesEventsToChatChunks, responsesToAnthropic, responsesToChatCompletion, } from "./translate.js";
|
|
20
21
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
21
22
|
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length", "authorization"]);
|
|
23
|
+
/**
|
|
24
|
+
* The request body as JSON text. Codex signed in to ChatGPT compresses what it sends with zstd
|
|
25
|
+
* (`content-encoding: zstd`, measured 2026-09-24 with the Codex CLI through `openai_base_url`), so
|
|
26
|
+
* reading it raw fails before the model is even known. Throws on an encoding we cannot read.
|
|
27
|
+
*/
|
|
28
|
+
export function decodeRequestBody(raw, encoding) {
|
|
29
|
+
const name = (Array.isArray(encoding) ? encoding.join(",") : encoding ?? "").trim().toLowerCase();
|
|
30
|
+
if (!name || name === "identity")
|
|
31
|
+
return raw;
|
|
32
|
+
if (name === "zstd")
|
|
33
|
+
return zlib.zstdDecompressSync(raw);
|
|
34
|
+
if (name === "gzip" || name === "x-gzip")
|
|
35
|
+
return zlib.gunzipSync(raw);
|
|
36
|
+
if (name === "deflate")
|
|
37
|
+
return zlib.inflateSync(raw);
|
|
38
|
+
if (name === "br")
|
|
39
|
+
return zlib.brotliDecompressSync(raw);
|
|
40
|
+
throw new Error(`unsupported content-encoding ${name}`);
|
|
41
|
+
}
|
|
42
|
+
/** A model Codex means for OpenAI: in the chatgpt provider's list, or named the way OpenAI names them. */
|
|
43
|
+
export function isChatGptModel(model, cfg) {
|
|
44
|
+
if (/^(gpt-|codex-|chatgpt-|o\d)/i.test(model))
|
|
45
|
+
return true;
|
|
46
|
+
return Object.values(cfg.providers).some((p) => p.type === "chatgpt" && (p.models ?? []).some((m) => m.id === model));
|
|
47
|
+
}
|
|
48
|
+
/** The conversation a Codex request belongs to, as Codex names it: its session header, else the cache key. */
|
|
49
|
+
function codexConversation(req, body) {
|
|
50
|
+
const header = req.headers.session_id ?? req.headers["session-id"] ?? req.headers["thread-id"];
|
|
51
|
+
if (typeof header === "string" && header)
|
|
52
|
+
return header;
|
|
53
|
+
return body && typeof body.prompt_cache_key === "string" ? body.prompt_cache_key : undefined;
|
|
54
|
+
}
|
|
22
55
|
function openAiError(message, type = "invalid_request_error", code = null) {
|
|
23
56
|
return { error: { message, type, param: null, code } };
|
|
24
57
|
}
|
|
@@ -107,6 +140,11 @@ export class OpenAiIngress {
|
|
|
107
140
|
});
|
|
108
141
|
this.server = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => void this.handle(req, res));
|
|
109
142
|
this.server.keepAliveTimeout = 65_000;
|
|
143
|
+
// Codex tries a WebSocket for Responses first when its OpenAI provider points here. Answering
|
|
144
|
+
// 426 is what makes it fall back to SSE over HTTP; an unanswered upgrade leaves it hanging.
|
|
145
|
+
this.server.on("upgrade", (_req, socket) => {
|
|
146
|
+
socket.end("HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n");
|
|
147
|
+
});
|
|
110
148
|
this.server.on("connection", (socket) => {
|
|
111
149
|
this.sockets.add(socket);
|
|
112
150
|
socket.once("close", () => this.sockets.delete(socket));
|
|
@@ -148,7 +186,7 @@ export class OpenAiIngress {
|
|
|
148
186
|
async handle(req, res) {
|
|
149
187
|
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
150
188
|
const method = req.method ?? "GET";
|
|
151
|
-
const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/chat/completions");
|
|
189
|
+
const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/responses/compact" || path === "/v1/chat/completions");
|
|
152
190
|
this.stats.started++;
|
|
153
191
|
this.stats.inFlight++;
|
|
154
192
|
if (isCall)
|
|
@@ -193,6 +231,15 @@ export class OpenAiIngress {
|
|
|
193
231
|
finish(499, 0, { note: "client closed" });
|
|
194
232
|
});
|
|
195
233
|
try {
|
|
234
|
+
const query = (req.url ?? "").includes("?") ? (req.url ?? "").slice((req.url ?? "").indexOf("?")) : "";
|
|
235
|
+
// Codex signed in to ChatGPT refreshes its model catalogue with `?client_version=`: that is the
|
|
236
|
+
// backend's list, so it comes from the backend, on one of the accounts.
|
|
237
|
+
if (path === "/v1/models" && method === "GET" && /[?&]client_version=/.test(query) && this.deps.chatgpt) {
|
|
238
|
+
req.resume();
|
|
239
|
+
const outcome = await this.deps.chatgpt(null).passthrough(req, res, `/models${query}`, undefined, undefined);
|
|
240
|
+
finish(outcome.status, outcome.bytes, { note: "codex models passthrough" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
196
243
|
if (path === "/v1/models" && method === "GET") {
|
|
197
244
|
const data = ingressModels(this.deps.config()).map((m) => ({ id: m.id, object: "model", created: 0, owned_by: m.provider }));
|
|
198
245
|
const bytes = sendJson(res, 200, { object: "list", data });
|
|
@@ -219,8 +266,10 @@ export class OpenAiIngress {
|
|
|
219
266
|
return;
|
|
220
267
|
}
|
|
221
268
|
let body;
|
|
269
|
+
let raw;
|
|
222
270
|
try {
|
|
223
|
-
|
|
271
|
+
raw = await readBody(req);
|
|
272
|
+
body = JSON.parse(decodeRequestBody(raw, req.headers["content-encoding"]).toString("utf8"));
|
|
224
273
|
}
|
|
225
274
|
catch (error) {
|
|
226
275
|
const bytes = sendJson(res, 400, openAiError(`Invalid JSON request: ${error.message}`));
|
|
@@ -239,10 +288,35 @@ export class OpenAiIngress {
|
|
|
239
288
|
}
|
|
240
289
|
const requested = typeof body.model === "string" ? body.model : "";
|
|
241
290
|
const cfg = this.deps.config();
|
|
291
|
+
const explicit = resolve(requested, {}, cfg);
|
|
292
|
+
const explicitProvider = explicit ? cfg.providers[explicit.provider] : undefined;
|
|
293
|
+
// Codex's own GPT traffic (its OpenAI provider pointed here): passed through to the ChatGPT
|
|
294
|
+
// backend on one of the signed-in accounts. A GPT model routed elsewhere on purpose is not.
|
|
295
|
+
const toChatGpt = path.startsWith("/v1/responses") && this.deps.chatgpt
|
|
296
|
+
&& (explicitProvider ? explicitProvider.type === "chatgpt" : isChatGptModel(requested, cfg));
|
|
297
|
+
if (toChatGpt) {
|
|
298
|
+
const adapter = this.deps.chatgpt(explicitProvider ? explicit.provider : null);
|
|
299
|
+
// As sent, compressed or not; only a mapping to another model name means rewriting it (and
|
|
300
|
+
// then it goes out uncompressed).
|
|
301
|
+
const renamed = explicit && explicit.model !== requested;
|
|
302
|
+
const payload = renamed ? Buffer.from(JSON.stringify({ ...body, model: explicit.model })) : raw;
|
|
303
|
+
record = { kind: "messages", source: requested, target: explicit?.model ?? requested, provider: adapter.name, stream: body.stream === true };
|
|
304
|
+
const outcome = await adapter.passthrough(req, res, path.slice("/v1".length), payload, codexConversation(req, body), {
|
|
305
|
+
bodyEncoded: !renamed,
|
|
306
|
+
onCompleted: (done) => { terminal = { status: done.status, bytes: done.bytes, ...(done.usage ? { usage: done.usage } : {}), ...(done.note ? { note: done.note } : {}) }; },
|
|
307
|
+
});
|
|
308
|
+
finish(outcome.status, outcome.bytes, { ...(outcome.usage ? { usage: outcome.usage } : {}), ...(outcome.note ? { note: outcome.note } : {}) });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (path === "/v1/responses/compact") {
|
|
312
|
+
const bytes = sendJson(res, 400, openAiError("Remote compaction is only available for ChatGPT models", "invalid_request_error", "unsupported_endpoint"));
|
|
313
|
+
finish(400, bytes);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
242
316
|
// Unmapped models default to the native `anthropic` provider when one is configured, so a
|
|
243
317
|
// Codex user can name any Claude model directly (`-m claude-sonnet-5`) without a mapping.
|
|
244
318
|
const fallback = Object.entries(cfg.providers).find(([, p]) => p.type === "anthropic");
|
|
245
|
-
const route =
|
|
319
|
+
const route = explicit ?? (fallback && requested ? { provider: fallback[0], model: requested, effort: undefined, tag: `${requested}->${requested}` } : null);
|
|
246
320
|
if (!route) {
|
|
247
321
|
const bytes = sendJson(res, 400, openAiError(`No ClaudeRipple route for model ${requested || "(missing)"}`, "invalid_request_error", "model_not_found"));
|
|
248
322
|
finish(400, bytes);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Secret-bearing JSON files that more than one process writes: the router refreshes tokens while a
|
|
2
|
+
// `clauderipple login` in another terminal adds an account. Every mutation holds a cross-process
|
|
3
|
+
// lock and replaces the file atomically, so a reader sees the old file or the new one, never half.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { lockSync } from "proper-lockfile";
|
|
8
|
+
const LOCK_STALE_MS = 30_000;
|
|
9
|
+
const LOCK_WAIT_MS = 100;
|
|
10
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
11
|
+
/** Run `mutate` while holding the lock for `file`. Waits up to two seconds for another writer. */
|
|
12
|
+
export function withFileLock(file, mutate) {
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
14
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
15
|
+
let release;
|
|
16
|
+
for (;;) {
|
|
17
|
+
try {
|
|
18
|
+
release = lockSync(file, { realpath: false, stale: LOCK_STALE_MS, update: 10_000 });
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ELOCKED" || Date.now() >= deadline)
|
|
23
|
+
throw error;
|
|
24
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return mutate();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
release();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Replace `file` with `value` as JSON (mode 0600): flushed temp file, then rename. Returns whether
|
|
36
|
+
* the directory entry was also made durable — POSIX can fsync a directory, Windows cannot, and a
|
|
37
|
+
* caller retiring an older copy of the same secret needs to know which it got.
|
|
38
|
+
*/
|
|
39
|
+
export function writeJsonAtomic(file, value) {
|
|
40
|
+
const dirName = path.dirname(file);
|
|
41
|
+
fs.mkdirSync(dirName, { recursive: true, mode: 0o700 });
|
|
42
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
43
|
+
try {
|
|
44
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
47
|
+
fs.fsyncSync(fd);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
fs.renameSync(tmp, file);
|
|
53
|
+
let directoryDurable = false;
|
|
54
|
+
try {
|
|
55
|
+
const dir = fs.openSync(dirName, "r");
|
|
56
|
+
try {
|
|
57
|
+
fs.fsyncSync(dir);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
fs.closeSync(dir);
|
|
61
|
+
}
|
|
62
|
+
directoryDurable = true;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const code = error.code;
|
|
66
|
+
if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EBADF")
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return { directoryDurable };
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(tmp);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code !== "ENOENT")
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|