@coseung2/opencodex 2.8.0-cs.1 → 2.8.0-cs.11
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/README.md +9 -0
- package/bin/ocx-notch.mjs +27 -1
- package/bin/ocx.mjs +0 -0
- package/gui/dist/assets/{index-CAnnes06.js → index-BYDeFyGN.js} +3 -3
- package/gui/dist/assets/{index-OY43ubAq.css → index-CxisOo-q.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/packages/ocx-notch/README.md +7 -3
- package/src/adapters/kiro-constants.ts +1 -1
- package/src/adapters/kiro.ts +29 -6
- package/src/cli/index.ts +67 -29
- package/src/cli/status.ts +4 -3
- package/src/config.ts +8 -3
- package/src/oauth/account-pause.ts +31 -0
- package/src/oauth/index.ts +11 -5
- package/src/oauth/kiro-credentials.ts +18 -1
- package/src/oauth/store.ts +25 -2
- package/src/providers/quota.ts +312 -18
- package/src/providers/registry.ts +1 -0
- package/src/responses/parser.ts +9 -1
- package/src/responses/state.ts +19 -2
- package/src/server/lifecycle.ts +23 -0
- package/src/server/management/oauth-account-routes.ts +51 -2
- package/src/server/memory-watchdog.ts +72 -7
- package/src/server/proxy-liveness.ts +5 -1
- package/src/service.ts +14 -7
- package/src/types.ts +9 -0
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BYDeFyGN.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CxisOo-q.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coseung2/opencodex",
|
|
3
|
-
"version": "2.8.0-cs.
|
|
3
|
+
"version": "2.8.0-cs.11",
|
|
4
4
|
"description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./bin/package-main.mjs",
|
|
@@ -31,10 +31,13 @@ For OCX 2.8+, the notch automatically reads the existing `%USERPROFILE%\.opencod
|
|
|
31
31
|
- The same native sample collects `GetPerformanceInfo` physical total/available and commit total/limit values. The header labels the smaller available headroom as `안정`, `주의`, or `위험`; caution and danger use 10%/2 GiB and 5%/1 GiB minimum-headroom thresholds respectively. No memory history is persisted.
|
|
32
32
|
- `/api/usage?range=7d` refreshes around every 30 seconds; only the newest calendar day's model rows are aggregated into the displayed per-provider usage.
|
|
33
33
|
- `/api/logs?tail=10` refreshes every ~2 seconds only while the Logs tab is visible. The response replaces the in-memory list, newest first, and OCX Notch never persists logs itself.
|
|
34
|
-
- OpenAI account state and per-account quotas refresh around every 5 seconds. Other provider configuration, account pools, and cached `/api/provider-quotas` refresh around every 5 minutes.
|
|
34
|
+
- OpenAI account state and per-account quotas refresh around every 5 seconds. Other provider configuration, account pools, and cached `/api/provider-quotas` refresh around every 5 minutes. For xAI OAuth, OCX reports the authoritative weekly Grok credit-pool usage and reset; the separate monthly spending cap is not presented as quota.
|
|
35
|
+
- Provider setup reads OCX's `/api/provider-presets` catalog on demand. OAuth status is polled only while a browser/device sign-in or account reauthentication is active; device authorization URLs, instructions, and user codes remain visible in the modal.
|
|
35
36
|
- `/api/system/memory` is requested only while expanded, at most every ~45 seconds, for optional heap detail.
|
|
36
37
|
|
|
37
|
-
All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, database,
|
|
38
|
+
All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, database, persistent usage history, runtime download, or automatic startup behavior. The last window position and width are stored in `%LOCALAPPDATA%\OCX Notch\window.json`.
|
|
39
|
+
|
|
40
|
+
Startup failures and Rust panics are written to the bounded diagnostic log `%LOCALAPPDATA%\OCX Notch\ocx-notch.log`. Running `ocx-notch` while the widget is already open restores its opacity, repaints it, and moves it to the top of the monitor containing the cursor. Display-topology changes also clamp the window back into a visible work area.
|
|
38
41
|
|
|
39
42
|
## Interaction
|
|
40
43
|
|
|
@@ -48,8 +51,9 @@ All HTTP calls use WinHTTP against `127.0.0.1:10100`. There is no WebView, datab
|
|
|
48
51
|
- Drag anywhere on the notch to move it; its chosen position is preserved while it expands, collapses, or refreshes.
|
|
49
52
|
- Click a provider with multiple accounts to expand or collapse its account rows.
|
|
50
53
|
- Click the pause icon beside an OpenAI account to exclude it from the rotation pool. A paused account shows a play icon that includes it again. The icon updates immediately and rolls back if OCX rejects the request.
|
|
54
|
+
- Accounts that require renewed credentials expose an inline **재인증** action on the same row. OpenAI's main account is intentionally excluded; eligible OpenAI pool and generic OAuth accounts can start, cancel, and retry browser authentication.
|
|
51
55
|
- Providers with quota data are shown first. Providers with usage but no quota stay behind the inline usage-only toggle.
|
|
52
56
|
- Press **Esc** to collapse.
|
|
53
|
-
- Right-click to set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. `Off` writes threshold `0`.
|
|
57
|
+
- Right-click to add a provider, set the real OCX account rotation threshold, fine-tune it by 1%, **Refresh**, or **Exit**. The opaque provider modal groups supported presets into Account, Free, and Paid tabs. Canonical OpenAI adds another Codex account, OAuth presets use browser/device authorization, and required-key presets use masked API-key entry. Local, key-optional, endpoint-choice, and placeholder-URL presets are omitted because this compact modal cannot preserve those setup contracts safely. `Off` writes threshold `0`.
|
|
54
58
|
|
|
55
59
|
Quota percentages are shown as used percentages with the same 5-hour/weekly/monthly/custom-window rows, reset countdowns, 5px progress bars, green fill, and green-to-amber threshold warning used by the OCX dashboard. Provider usage is merged by exact provider name, limited to the newest day, and formatted with Korean `만/억/조` units. OpenAI account rows show their own weekly/monthly quotas; OAuth and key-pool rows show their masked identity and active/health state, while provider-level quota remains associated with the active account. The native window uses a subtle 238/255 global alpha.
|
|
@@ -8,7 +8,7 @@ export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is at
|
|
|
8
8
|
export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
|
|
9
9
|
|
|
10
10
|
export const KIRO_COMPLETION_INSTRUCTIONS =
|
|
11
|
-
`When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
|
|
11
|
+
`When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates, but never repeat or paraphrase an earlier progress update; call the next real tool directly unless a new concise update adds material information. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
|
|
12
12
|
|
|
13
13
|
export type KiroCompletionMode = "disabled" | "required" | "text_fallback";
|
|
14
14
|
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -468,17 +468,38 @@ export function buildKiroPayload(
|
|
|
468
468
|
}
|
|
469
469
|
};
|
|
470
470
|
|
|
471
|
-
|
|
471
|
+
const payloadMessages = kiroPayloadMessages(parsed);
|
|
472
|
+
const replayMessagePrefixLength = Math.min(
|
|
473
|
+
Math.max(0, parsed._replayMessagePrefixLen ?? 0),
|
|
474
|
+
payloadMessages.length,
|
|
475
|
+
);
|
|
476
|
+
for (let messageIndex = 0; messageIndex < payloadMessages.length; messageIndex++) {
|
|
477
|
+
const msg = payloadMessages[messageIndex];
|
|
478
|
+
const isReplayedMessage = messageIndex < replayMessagePrefixLength;
|
|
472
479
|
if (msg.role === "user" || msg.role === "developer") {
|
|
473
480
|
const text = userContentText((msg as { content: string | OcxContentPart[] }).content);
|
|
474
|
-
|
|
481
|
+
// Historical text/tool structure remains replayable, but image bytes are scoped to the turn
|
|
482
|
+
// that introduced them. Re-sending completed-turn images makes Kiro inspect the same visual on
|
|
483
|
+
// every unrelated follow-up and repeatedly pays the multimodal context cost. The parser-owned
|
|
484
|
+
// prefix boundary keeps current user/tool-result images intact, including the bounded internal
|
|
485
|
+
// completion retry built from this same parsed request.
|
|
486
|
+
const images = isReplayedMessage
|
|
487
|
+
? []
|
|
488
|
+
: extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
|
|
475
489
|
pushUser(text, images);
|
|
476
490
|
} else if (msg.role === "assistant") {
|
|
477
491
|
const aMsg = msg as OcxAssistantMessage;
|
|
478
|
-
const
|
|
492
|
+
const rawText = (aMsg.content || [])
|
|
479
493
|
.filter((b): b is OcxTextContent => b.type === "text")
|
|
480
494
|
.map(b => b.text)
|
|
481
495
|
.join("");
|
|
496
|
+
// Responses commentary is transient UI progress, not durable assistant state. Replaying it
|
|
497
|
+
// into every Kiro tool-result continuation teaches the model to repeat the same update and
|
|
498
|
+
// grows the upstream context on each client-driven tool round. Keep the structural tool use,
|
|
499
|
+
// but omit commentary prose from normal history. The adapter-owned completion retry appends
|
|
500
|
+
// its one replayable assistant turn without a commentary phase below, so that bounded path is
|
|
501
|
+
// preserved.
|
|
502
|
+
const text = aMsg.phase === "commentary" ? "" : rawText;
|
|
482
503
|
const toolCalls = (aMsg.content || [])
|
|
483
504
|
.filter((b): b is OcxToolCall => b.type === "toolCall");
|
|
484
505
|
const toolUses: KiroToolUse[] = toolCalls.map(tc => {
|
|
@@ -492,7 +513,7 @@ export function buildKiroPayload(
|
|
|
492
513
|
});
|
|
493
514
|
if (!text && toolUses.length === 0) {
|
|
494
515
|
const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
|
|
495
|
-
if (hasReasoning) continue;
|
|
516
|
+
if (hasReasoning || aMsg.phase === "commentary") continue;
|
|
496
517
|
}
|
|
497
518
|
pushAssistant(text, toolUses);
|
|
498
519
|
} else if (msg.role === "toolResult") {
|
|
@@ -502,7 +523,7 @@ export function buildKiroPayload(
|
|
|
502
523
|
}
|
|
503
524
|
const text = userContentText(tr.content);
|
|
504
525
|
const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
|
|
505
|
-
const images = extractKiroImages(tr.content);
|
|
526
|
+
const images = isReplayedMessage ? [] : extractKiroImages(tr.content);
|
|
506
527
|
const toolUseId = normalizeToolId(tr.toolCallId);
|
|
507
528
|
if (!priorCalls.has(toolUseId)) {
|
|
508
529
|
throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
|
|
@@ -1779,7 +1800,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
1779
1800
|
retryParsed.context.messages.push({
|
|
1780
1801
|
role: "assistant",
|
|
1781
1802
|
content: [{ type: "text" as const, text: assistantText }],
|
|
1782
|
-
phase:
|
|
1803
|
+
// Intentionally omit `phase:"commentary"`: normal client commentary is filtered from
|
|
1804
|
+
// Kiro history, while this adapter-owned one-shot replay is the evidence the bounded
|
|
1805
|
+
// completion retry must validate.
|
|
1783
1806
|
model: retryParsed.modelId,
|
|
1784
1807
|
timestamp: Date.now(),
|
|
1785
1808
|
});
|
package/src/cli/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { stripGrokConfig } from "../grok/inject";
|
|
|
5
5
|
import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
|
|
6
6
|
import { reconcileJournal } from "../codex/journal";
|
|
7
7
|
import {
|
|
8
|
+
applyProxyEnv,
|
|
8
9
|
codexAutoStartEnabled,
|
|
9
10
|
getConfigDir,
|
|
10
11
|
loadConfig,
|
|
@@ -25,7 +26,7 @@ import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy";
|
|
|
25
26
|
import { installCrashGuards } from "../lib/crash-guard";
|
|
26
27
|
import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
|
|
27
28
|
import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
|
|
28
|
-
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
29
|
+
import { findLiveProxy, probeHostname, proxyIdentityAt, type LiveProxy } from "../server/proxy-liveness";
|
|
29
30
|
import { stopProxy } from "../lib/process-control";
|
|
30
31
|
import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
31
32
|
import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
|
|
@@ -64,7 +65,7 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
|
|
|
64
65
|
process.exit(0);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
maybeAutoRestoreCodexShim(command, args);
|
|
68
|
+
if (command !== "start") maybeAutoRestoreCodexShim(command, args);
|
|
68
69
|
|
|
69
70
|
function parsePortOption(): number | undefined {
|
|
70
71
|
if (args.length === 1) return undefined;
|
|
@@ -119,16 +120,22 @@ function startArgv(port?: number): string[] {
|
|
|
119
120
|
return args;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
function rejectAlreadyRunning(live: LiveProxy, fallbackPid: number | null = null): never {
|
|
124
|
+
console.error(`⚠️ Proxy already running (PID ${live.pid ?? fallbackPid ?? "unknown"}, port ${live.port}). Use 'ocx stop' first.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
123
129
|
const config = loadConfig();
|
|
124
130
|
const preferred = requestedPort ?? config.port ?? 10100;
|
|
125
131
|
const hardPin = requestedPort !== undefined && requestedPort > 0;
|
|
132
|
+
const hostname = config.hostname ?? "127.0.0.1";
|
|
126
133
|
// Soft start: brief prefer-retry then ephemeral hop.
|
|
127
134
|
// Explicit `--port` (service wrappers / update restart): wait for the pinned port
|
|
128
135
|
// to free without killing any listener (healthy ocx / foreign). Never hop.
|
|
129
136
|
if (hardPin && preferred > 0) {
|
|
130
137
|
const { reclaimListenPort } = await import("../server/port-reclaim");
|
|
131
|
-
await reclaimListenPort(preferred,
|
|
138
|
+
await reclaimListenPort(preferred, hostname, {
|
|
132
139
|
// Ghost LISTEN rows with a dead PID can outlive the process for a while.
|
|
133
140
|
// SetTcpEntry(DELETE_TCB) needs elevation (often returns 317), so the only
|
|
134
141
|
// reliable non-admin recovery is to wait for the OS to release the TCB.
|
|
@@ -139,49 +146,67 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
139
146
|
dropTcpRows: true,
|
|
140
147
|
});
|
|
141
148
|
}
|
|
149
|
+
let selected: number;
|
|
142
150
|
try {
|
|
143
|
-
|
|
151
|
+
selected = await findAvailablePort(preferred, hostname, {
|
|
144
152
|
// After reclaim, keep probing briefly — ghost rows sometimes clear between
|
|
145
153
|
// the reclaim deadline and the final listen. Still never hop off `--port`.
|
|
146
154
|
preferRetryMs: hardPin ? 5_000 : 750,
|
|
147
155
|
preferRetryIntervalMs: 50,
|
|
148
|
-
allowEphemeralFallback:
|
|
156
|
+
allowEphemeralFallback: false,
|
|
149
157
|
});
|
|
150
|
-
if (preferred > 0 && selected !== preferred) {
|
|
151
|
-
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
152
|
-
}
|
|
153
|
-
if (shouldPersistSelectedPort(config.port, selected, preferred)) {
|
|
154
|
-
config.port = selected;
|
|
155
|
-
saveConfig(config);
|
|
156
|
-
}
|
|
157
|
-
return selected;
|
|
158
158
|
} catch (err) {
|
|
159
|
-
if (err instanceof PortUnavailableError) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
if (err instanceof PortUnavailableError && !hardPin) {
|
|
160
|
+
// Runtime files are advisory: a live proxy can still own the preferred listener when
|
|
161
|
+
// both records are absent or stale. Check that listener before allocating an ephemeral
|
|
162
|
+
// port, so a missed discovery pass cannot shadow it and overwrite fresh runtime state.
|
|
163
|
+
if (preferred > 0) {
|
|
164
|
+
const identity = await proxyIdentityAt(preferred, { hostname });
|
|
165
|
+
if (identity) {
|
|
166
|
+
rejectAlreadyRunning({ pid: null, port: preferred, hostname, source: "config" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const live = await findLiveProxy();
|
|
170
|
+
if (live) rejectAlreadyRunning(live);
|
|
171
|
+
selected = await findAvailablePort(0, hostname);
|
|
172
|
+
if (preferred > 0) {
|
|
173
|
+
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
if (err instanceof PortUnavailableError) {
|
|
177
|
+
console.error(`❌ ${err.message}`);
|
|
178
|
+
console.error(" Stop whatever holds that port, or change config.port, then retry.");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
163
182
|
}
|
|
164
|
-
throw err;
|
|
165
183
|
}
|
|
184
|
+
if (shouldPersistSelectedPort(config.port, selected, preferred)) {
|
|
185
|
+
config.port = selected;
|
|
186
|
+
saveConfig(config);
|
|
187
|
+
}
|
|
188
|
+
return selected;
|
|
166
189
|
}
|
|
167
190
|
|
|
168
191
|
async function handleStart(options: { block?: boolean } = {}) {
|
|
192
|
+
const requestedPort = parsePortOption();
|
|
193
|
+
// Identity probes must stay direct even when the shell supplied HTTP_PROXY
|
|
194
|
+
// without NO_PROXY. startServer applies the same idempotent normalization.
|
|
195
|
+
applyProxyEnv(loadConfig());
|
|
196
|
+
const existingPid = readPid();
|
|
197
|
+
const live = await findLiveProxy();
|
|
198
|
+
if (live) rejectAlreadyRunning(live, existingPid);
|
|
199
|
+
maybeAutoRestoreCodexShim(command, args);
|
|
200
|
+
if (existingPid) {
|
|
201
|
+
removePid(existingPid);
|
|
202
|
+
}
|
|
203
|
+
|
|
169
204
|
// Native (WinSW) service mode has no batch wrapper to read the service token file
|
|
170
205
|
// into the environment, so the app loads it here before the server binds. The server
|
|
171
206
|
// auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
|
|
172
207
|
const serviceToken = loadServiceTokenFromFile(process.env);
|
|
173
208
|
if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
|
|
174
|
-
const requestedPort = parsePortOption();
|
|
175
209
|
if (!currentExternalCodexModelProvider()) reconcileJournal();
|
|
176
|
-
const existingPid = readPid();
|
|
177
|
-
if (existingPid) {
|
|
178
|
-
const live = await findLiveProxy();
|
|
179
|
-
if (live) {
|
|
180
|
-
console.error(`⚠️ Proxy already running (PID ${live.pid ?? existingPid}, port ${live.port}). Use 'ocx stop' first.`);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
}
|
|
183
|
-
removePid(existingPid);
|
|
184
|
-
}
|
|
185
210
|
|
|
186
211
|
// Interactive-only update prompt. Must run BEFORE we bind a port / write a
|
|
187
212
|
// PID: choosing "Update now" installs globally and exits, so we never want a
|
|
@@ -213,16 +238,29 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
213
238
|
}
|
|
214
239
|
continue;
|
|
215
240
|
}
|
|
241
|
+
const live = await findLiveProxy();
|
|
242
|
+
if (live) rejectAlreadyRunning(live);
|
|
216
243
|
console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
|
|
217
244
|
port = await chooseListenPort(requestedPort);
|
|
218
245
|
}
|
|
219
246
|
}
|
|
247
|
+
const config = loadConfig();
|
|
248
|
+
const preferred = requestedPort ?? config.port ?? 10100;
|
|
249
|
+
if (port !== preferred && preferred > 0) {
|
|
250
|
+
// Recheck immediately before any runtime-state write: another proxy may have won the
|
|
251
|
+
// preferred listener after soft fallback selection completed.
|
|
252
|
+
const identity = await proxyIdentityAt(preferred, { hostname: config.hostname });
|
|
253
|
+
if (identity) {
|
|
254
|
+
server.stop(true);
|
|
255
|
+
rejectAlreadyRunning({ pid: null, port: preferred, hostname: config.hostname, source: "config" });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
220
259
|
// A single request's streaming error must never crash the daemon serving every
|
|
221
260
|
// other Codex session — capture the full stack to crash.log and stay up.
|
|
222
261
|
installCrashGuards();
|
|
223
262
|
writePid(process.pid);
|
|
224
263
|
|
|
225
|
-
const config = loadConfig();
|
|
226
264
|
writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
|
|
227
265
|
// No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
|
|
228
266
|
// is about to transform; snapshotting earlier only captured a baseline that could
|
package/src/cli/status.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics
|
|
|
3
3
|
import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
|
|
4
4
|
import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
|
|
5
5
|
import type { OcxConfig } from "../types";
|
|
6
|
-
import { diagnoseService,
|
|
6
|
+
import { diagnoseService, serviceStatusSummary } from "../service";
|
|
7
7
|
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
|
|
8
8
|
import { getCodexRoutingKind } from "../codex/inject";
|
|
9
9
|
import { diagnoseCodexShim } from "../codex/shim";
|
|
@@ -172,9 +172,10 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
172
172
|
// A service can be registered and still not serve: the manager reports the job
|
|
173
173
|
// either way. `live` was already identity-probed a few lines above, so cross-check
|
|
174
174
|
// rather than print registration as if it were service.
|
|
175
|
+
const baseServiceSummary = serviceStatusSummary(service);
|
|
175
176
|
const serviceSummary = service.installed && !live
|
|
176
|
-
? `${
|
|
177
|
-
:
|
|
177
|
+
? `${baseServiceSummary} — registered but NOT serving; re-run 'ocx service install'`
|
|
178
|
+
: baseServiceSummary;
|
|
178
179
|
const codexShim = diagnoseCodexShim();
|
|
179
180
|
const codexShimSummary = codexShim.summary;
|
|
180
181
|
const startup = collectStartupHealth(config, {
|
package/src/config.ts
CHANGED
|
@@ -775,6 +775,10 @@ const configSchema = z.object({
|
|
|
775
775
|
syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
|
|
776
776
|
codexShimAutoRestore: z.boolean().optional(),
|
|
777
777
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
778
|
+
// OAuth account ids come from external identity providers (JWT sub, ARN-derived
|
|
779
|
+
// hashes, UUIDs), so validate only length, never charset: a foreign id must not
|
|
780
|
+
// drop the whole paused set on config load.
|
|
781
|
+
pausedOauthAccountIds: z.record(z.string(), z.array(z.string().min(1).max(128))).optional(),
|
|
778
782
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
779
783
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
780
784
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
@@ -2045,9 +2049,10 @@ export function resolveEnvValue(value: string | undefined): string | undefined {
|
|
|
2045
2049
|
*/
|
|
2046
2050
|
export function applyProxyEnv(config: OcxConfig): void {
|
|
2047
2051
|
const proxy = resolveEnvValue(config.proxy);
|
|
2048
|
-
if (
|
|
2049
|
-
|
|
2050
|
-
|
|
2052
|
+
if (proxy) {
|
|
2053
|
+
if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
|
|
2054
|
+
if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
|
|
2055
|
+
}
|
|
2051
2056
|
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
|
|
2052
2057
|
const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
|
|
2053
2058
|
const seen = new Set(entries.map(e => e.toLowerCase()));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-provider OAuth account pause state, mirroring the Codex account pool's
|
|
5
|
+
* `pausedCodexAccountIds` so manually paused rows survive restarts.
|
|
6
|
+
*
|
|
7
|
+
* Pause is an administrative exclusion: a paused account cannot be activated and
|
|
8
|
+
* is skipped when a provider's active selection is promoted. Credentials are
|
|
9
|
+
* never touched by pause/resume.
|
|
10
|
+
*/
|
|
11
|
+
export function isOauthAccountPaused(config: OcxConfig, provider: string, accountId: string): boolean {
|
|
12
|
+
return config.pausedOauthAccountIds?.[provider]?.includes(accountId) ?? false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Persist the account's pool eligibility without changing credentials or runtime health. */
|
|
16
|
+
export function setOauthAccountPaused(config: OcxConfig, provider: string, accountId: string, paused: boolean): void {
|
|
17
|
+
const byProvider = { ...(config.pausedOauthAccountIds ?? {}) };
|
|
18
|
+
const ids = new Set(byProvider[provider] ?? []);
|
|
19
|
+
if (paused) ids.add(accountId);
|
|
20
|
+
else ids.delete(accountId);
|
|
21
|
+
|
|
22
|
+
if (ids.size > 0) byProvider[provider] = [...ids];
|
|
23
|
+
else delete byProvider[provider];
|
|
24
|
+
|
|
25
|
+
if (Object.keys(byProvider).length > 0) config.pausedOauthAccountIds = byProvider;
|
|
26
|
+
else delete config.pausedOauthAccountIds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function forgetOauthAccountPause(config: OcxConfig, provider: string, accountId: string): void {
|
|
30
|
+
setOauthAccountPaused(config, provider, accountId, false);
|
|
31
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -878,11 +878,17 @@ export async function runLogin(
|
|
|
878
878
|
if (!existing.accountId && !existing.email) {
|
|
879
879
|
throw new Error("Could not verify signed-in account identity for reauth.");
|
|
880
880
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
881
|
+
// Kiro social accounts share one device-scoped profile ARN, so reauth identity
|
|
882
|
+
// must compare the signed-in email first; otherwise reauthenticating a different
|
|
883
|
+
// Google account would silently overwrite the selected slot. Other providers keep
|
|
884
|
+
// accountId-first matching.
|
|
885
|
+
const identityMatches = provider === "kiro" && existing.email && cred.email
|
|
886
|
+
? existing.email.toLowerCase() === cred.email.toLowerCase()
|
|
887
|
+
: existing.accountId && cred.accountId
|
|
888
|
+
? existing.accountId === cred.accountId
|
|
889
|
+
: existing.email && cred.email
|
|
890
|
+
? existing.email.toLowerCase() === cred.email.toLowerCase()
|
|
891
|
+
: false;
|
|
886
892
|
if (!identityMatches) {
|
|
887
893
|
throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
|
|
888
894
|
}
|
|
@@ -266,6 +266,23 @@ function kiroCliImportSelectorConfigured(): boolean {
|
|
|
266
266
|
return Boolean(process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim());
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* True only when a custom KIROCLI_DB_PATH / KIRO_CLI_DB_FILE selector points at a
|
|
271
|
+
* DIFFERENT database than the native kiro-cli store. A selector that resolves to the
|
|
272
|
+
* native store (the common Windows case where the installer path is exported into the
|
|
273
|
+
* environment) is not ambiguous for logout rollback: the file being snapshotted is
|
|
274
|
+
* exactly the one `kiro-cli logout` mutates.
|
|
275
|
+
*/
|
|
276
|
+
function kiroCliImportSelectorConflictsWithNative(): boolean {
|
|
277
|
+
const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim();
|
|
278
|
+
if (!configured) return false;
|
|
279
|
+
const configuredPath = expandPath(configured);
|
|
280
|
+
const normalize = process.platform === "win32"
|
|
281
|
+
? (value: string) => win32.normalize(value).toLowerCase()
|
|
282
|
+
: (value: string) => posix.normalize(value);
|
|
283
|
+
return !nativeKiroCliSessionEntries().some(entry => normalize(entry.path) === normalize(configuredPath));
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
function selectTokenRow(db: Database): { value: string } | null | "ambiguous" | "selected_missing" {
|
|
270
287
|
const rows = db.query("SELECT key, value FROM auth_kv WHERE key LIKE ? ORDER BY key ASC").all("%:token") as Array<{ key: string; value: string }>;
|
|
271
288
|
const selectedKey = process.env.KIROCLI_TOKEN_KEY?.trim();
|
|
@@ -488,7 +505,7 @@ export function inspectKiroCliSessionSnapshot(): {
|
|
|
488
505
|
blocked: boolean;
|
|
489
506
|
} {
|
|
490
507
|
const diagnostics: KiroImportDiagnostic[] = [];
|
|
491
|
-
if (
|
|
508
|
+
if (kiroCliImportSelectorConflictsWithNative()) {
|
|
492
509
|
diagnostics.push({ location: "kiro-cli-db-env", status: "token_ambiguous" });
|
|
493
510
|
return { snapshot: null, diagnostics, blocked: true };
|
|
494
511
|
}
|
package/src/oauth/store.ts
CHANGED
|
@@ -456,20 +456,43 @@ export async function saveCredential(
|
|
|
456
456
|
if (!safe) return;
|
|
457
457
|
await mutateStore(store => {
|
|
458
458
|
const set = store[provider];
|
|
459
|
-
|
|
459
|
+
// Kiro social logins share one device-scoped CodeWhisperer profile ARN, so the
|
|
460
|
+
// profile ARN cannot distinguish Google accounts. Prefer the signed-in email for
|
|
461
|
+
// Kiro identity so a second Google account appends a pool row instead of silently
|
|
462
|
+
// replacing the first. Other providers keep accountId-first identity semantics.
|
|
463
|
+
const identityKey = (candidate: OAuthCredentials): string | undefined =>
|
|
464
|
+
provider === "kiro"
|
|
465
|
+
? (candidate.email ?? candidate.accountId)
|
|
466
|
+
: (candidate.accountId ?? candidate.email);
|
|
467
|
+
const identity = identityKey(safe);
|
|
460
468
|
if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) {
|
|
461
469
|
const id = newAccountId(safe);
|
|
462
470
|
store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] };
|
|
463
471
|
return;
|
|
464
472
|
}
|
|
465
473
|
if (identity) {
|
|
466
|
-
const existing = set.accounts.find(a => (a.credential
|
|
474
|
+
const existing = set.accounts.find(a => identityKey(a.credential) === identity);
|
|
467
475
|
if (existing) {
|
|
468
476
|
existing.credential = safe;
|
|
469
477
|
delete existing.needsReauth;
|
|
470
478
|
set.activeAccountId = existing.id;
|
|
471
479
|
return;
|
|
472
480
|
}
|
|
481
|
+
// Kiro legacy rows created before email was extracted carry only the profile ARN.
|
|
482
|
+
// Upgrade an email-less row in place when the new credential proves it is the
|
|
483
|
+
// same underlying Kiro profile, instead of appending a duplicate for the same human.
|
|
484
|
+
if (provider === "kiro" && safe.email && safe.kiro?.profileArn) {
|
|
485
|
+
const legacy = set.accounts.find(a =>
|
|
486
|
+
a.credential.email === undefined
|
|
487
|
+
&& a.credential.kiro?.profileArn === safe.kiro?.profileArn,
|
|
488
|
+
);
|
|
489
|
+
if (legacy) {
|
|
490
|
+
legacy.credential = safe;
|
|
491
|
+
delete legacy.needsReauth;
|
|
492
|
+
set.activeAccountId = legacy.id;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
473
496
|
// Legacy migration: a pre-identity row (no accountId/email) for this provider is the
|
|
474
497
|
// SAME human re-logging in after the identity extraction shipped — upgrading the
|
|
475
498
|
// active identity-less row in place prevents a stale duplicate that stays selectable
|