@bitkyc08/opencodex 2.83.0 → 2.84.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/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +19 -0
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export.ts +46 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +311 -171
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +24 -15
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +14 -14
- package/src/providers/registry/entries-extended.ts +0 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +13 -0
- package/src/server/lifecycle.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +5 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/responses/adapter-dispatch.ts +232 -79
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -4
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +6 -8
- package/src/server/responses/sidecar-execution.ts +11 -2
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/usage/expected-prices.ts +4 -0
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
|
@@ -113,12 +113,19 @@ export function isLoopbackTarget(host: string): boolean {
|
|
|
113
113
|
return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
function respond(
|
|
116
|
+
function respond(
|
|
117
|
+
socket: Socket, status: number, reason: string, headers: Readonly<Record<string, string>> = {}, body = "",
|
|
118
|
+
): void {
|
|
117
119
|
if (socket.destroyed) return;
|
|
118
120
|
const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
|
|
119
|
-
|
|
121
|
+
const type = body ? "Content-Type: text/plain; charset=utf-8\r\n" : "";
|
|
122
|
+
socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}${type}Connection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
// Plain proxied HTTP (often loopback MCP traffic missing a NO_PROXY bypass) lands here; say why.
|
|
126
|
+
const CONNECT_ONLY_BODY = "OpenCodex Claude intercept proxy accepts only CONNECT tunnels. "
|
|
127
|
+
+ "Exclude this host via NO_PROXY (loopback and unspecified hosts: 127.0.0.1,localhost,::1,0.0.0.0).\n";
|
|
128
|
+
|
|
122
129
|
function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
|
|
123
130
|
const teardown = () => {
|
|
124
131
|
client.destroy();
|
|
@@ -173,7 +180,7 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):
|
|
|
173
180
|
// Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
|
|
174
181
|
let pending = head.subarray(end + 4);
|
|
175
182
|
if (!target) {
|
|
176
|
-
respond(socket, 405, "Method Not Allowed");
|
|
183
|
+
respond(socket, 405, "Method Not Allowed", { Allow: "CONNECT" }, CONNECT_ONLY_BODY);
|
|
177
184
|
return;
|
|
178
185
|
}
|
|
179
186
|
if (options.authToken !== undefined) {
|
|
@@ -22,6 +22,7 @@ export function canonicalPickerConfigDir(configDir: string): string {
|
|
|
22
22
|
/** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */
|
|
23
23
|
function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null {
|
|
24
24
|
// BigInt stats: a Windows file ID exceeds 2^53, so Number ino loses low bits and two files can compare equal.
|
|
25
|
+
// Refuse zero identity or metadata changes between the path snapshot and opened descriptor.
|
|
25
26
|
let expected: BigIntStats;
|
|
26
27
|
try { expected = lstatSync(path, { bigint: true }); }
|
|
27
28
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
|
|
@@ -31,7 +32,8 @@ function readState(path: string, configId: string, initialization: boolean): Ini
|
|
|
31
32
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
|
|
32
33
|
try {
|
|
33
34
|
const stat = fstatSync(fd, { bigint: true });
|
|
34
|
-
if (stat.dev !== expected.dev || stat.ino !== expected.ino
|
|
35
|
+
if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n
|
|
36
|
+
|| stat.ctimeNs !== expected.ctimeNs) throw new Error("picker_ca_metadata_unsafe");
|
|
35
37
|
if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(MAX_PUBLIC_STATE_BYTES)
|
|
36
38
|
|| (process.platform !== "win32" && stat.uid !== BigInt(process.getuid!()))) throw new Error();
|
|
37
39
|
const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
|
|
@@ -34,6 +34,11 @@ export interface PickerListenerOptions {
|
|
|
34
34
|
maxEncodedBytes?: number;
|
|
35
35
|
/** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
|
|
36
36
|
maxActiveUpstreams?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS;
|
|
39
|
+
* returns a cancel function. Production uses a timer.
|
|
40
|
+
*/
|
|
41
|
+
scheduleAbandonedInputDrop?: (drop: () => void, ms: number) => () => void;
|
|
37
42
|
log?: (line: string) => void;
|
|
38
43
|
}
|
|
39
44
|
export interface PickerListenerHandle { port: number; close(): Promise<void> }
|
|
@@ -51,6 +56,12 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
|
|
|
51
56
|
export const PICKER_MAX_CONCURRENT_STREAMS = 100;
|
|
52
57
|
/** A connection that has not sent a complete ClientHello by then is dropped. */
|
|
53
58
|
const CLIENT_HELLO_TIMEOUT_MS = 10_000;
|
|
59
|
+
/**
|
|
60
|
+
* Once the backend has ended a connection and its reply has been flushed, input the client still
|
|
61
|
+
* sends is discarded for at most this long before the connection is dropped. A cooperative client
|
|
62
|
+
* closes as soon as it reads a Connection: close reply; this only bounds one that keeps uploading.
|
|
63
|
+
*/
|
|
64
|
+
export const PICKER_ABANDONED_INPUT_LINGER_MS = 30_000;
|
|
54
65
|
|
|
55
66
|
const HOP_HEADERS = new Set([
|
|
56
67
|
"connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
|
|
@@ -152,6 +163,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
152
163
|
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
|
|
153
164
|
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
|
|
154
165
|
const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
|
|
166
|
+
const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => {
|
|
167
|
+
const timer = setTimeout(drop, ms);
|
|
168
|
+
return () => clearTimeout(timer);
|
|
169
|
+
});
|
|
155
170
|
let activeUpstreams = 0;
|
|
156
171
|
const upgrades = new Set<Duplex>();
|
|
157
172
|
const spliced = new Set<Socket>();
|
|
@@ -205,8 +220,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
205
220
|
} else req.destroy();
|
|
206
221
|
}
|
|
207
222
|
};
|
|
223
|
+
// An HTTP/1.1 reply sent while the upload is unfinished advertises Connection: close. Bun 1.4.3
|
|
224
|
+
// ends such a response with socket.destroySoon() before our finish listeners run, which resets
|
|
225
|
+
// the splice before the reply reaches the client; keep it a graceful FIN that discards input.
|
|
226
|
+
// Only that closing socket is changed, and destroy() stays available for errors and shutdown.
|
|
227
|
+
const closesUnfinishedUpload = () => !h2 && uploading && !req.complete;
|
|
228
|
+
const keepClosingReplyIntact = () => {
|
|
229
|
+
const socket = req.socket;
|
|
230
|
+
socket.destroySoon = () => { req.resume(); socket.end(); return socket; };
|
|
231
|
+
};
|
|
208
232
|
// Refusals answer with an empty response and a fixed log line that carries no request data.
|
|
209
233
|
const refuse = (status: 400 | 503) => {
|
|
234
|
+
if (closesUnfinishedUpload()) keepClosingReplyIntact();
|
|
210
235
|
// Do not leave a refused, still-uploading stream behind after delivering its empty reply.
|
|
211
236
|
if (uploading && !req.complete) {
|
|
212
237
|
req.once("error", () => res.destroy());
|
|
@@ -236,6 +261,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
236
261
|
if (clientGone) return;
|
|
237
262
|
if (res.headersSent) res.destroy();
|
|
238
263
|
else {
|
|
264
|
+
if (closesUnfinishedUpload()) keepClosingReplyIntact();
|
|
239
265
|
res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
|
|
240
266
|
res.end(); log(502);
|
|
241
267
|
}
|
|
@@ -260,7 +286,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
260
286
|
// HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
|
|
261
287
|
if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
|
|
262
288
|
else {
|
|
263
|
-
if (uploading && !req.complete)
|
|
289
|
+
if (uploading && !req.complete) {
|
|
290
|
+
keepClosingReplyIntact();
|
|
291
|
+
raw.push("Connection", "close");
|
|
292
|
+
}
|
|
264
293
|
res.writeHead(status, upRes.statusMessage, raw);
|
|
265
294
|
}
|
|
266
295
|
log(status);
|
|
@@ -438,6 +467,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
438
467
|
const offersH2 = clientHelloOffersH2(head);
|
|
439
468
|
if (offersH2 === null) return;
|
|
440
469
|
client.off("data", onData);
|
|
470
|
+
// Keep reading an aborted upload after the backend reply FIN.
|
|
471
|
+
client.allowHalfOpen = true;
|
|
441
472
|
// Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
|
|
442
473
|
client.pause();
|
|
443
474
|
clearTimeout(deadline);
|
|
@@ -450,6 +481,23 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
450
481
|
// its peer (reset, dial failure) tears the peer down instead of leaving it open.
|
|
451
482
|
inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
|
|
452
483
|
client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
|
|
484
|
+
// The backend reply ended; discard ciphertext still coming from the aborted upload.
|
|
485
|
+
// Unpipe before resuming, so backpressure cannot hide the peer FIN. The discard is bounded
|
|
486
|
+
// from the moment the reply has been flushed to the socket (finish), never before, so the
|
|
487
|
+
// drop cannot cut a reply still being written; finish is not peer receipt, so a peer that
|
|
488
|
+
// also stops reading for the whole budget can lose unread bytes. A peer that keeps sending
|
|
489
|
+
// past the budget is dropped instead of holding the connection until shutdown (#6984).
|
|
490
|
+
inner.once("end", () => {
|
|
491
|
+
client.unpipe(inner);
|
|
492
|
+
client.resume();
|
|
493
|
+
const linger = () => {
|
|
494
|
+
if (client.destroyed) return;
|
|
495
|
+
const cancel = scheduleAbandonedInputDrop(() => client.destroy(), PICKER_ABANDONED_INPUT_LINGER_MS);
|
|
496
|
+
client.once("close", cancel);
|
|
497
|
+
};
|
|
498
|
+
if (client.writableFinished) linger();
|
|
499
|
+
else client.once("finish", linger);
|
|
500
|
+
});
|
|
453
501
|
inner.once("connect", () => {
|
|
454
502
|
inner.setNoDelay(true);
|
|
455
503
|
client.setNoDelay(true);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../../config/atomic-write";
|
|
4
|
+
import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The loopback bypass opencodex keeps in Claude Code's settings env (#6936), and the receipt
|
|
8
|
+
* that proves which part of `NO_PROXY` opencodex wrote.
|
|
9
|
+
*
|
|
10
|
+
* `NO_PROXY` is shared with the user, so a value alone cannot say which entries are ours. Apply
|
|
11
|
+
* records `{ hadKey, previous, written }` in `<claudeConfigDir>/opencodex-no-proxy.json` and
|
|
12
|
+
* removal restores `previous` only while `NO_PROXY` still equals `written`; anything else is
|
|
13
|
+
* preserved. The receipt lives exactly as long as the ownership episode that wrote it: an apply
|
|
14
|
+
* that starts a new episode, and every removal, invalidates it first and fails closed when it
|
|
15
|
+
* cannot. It holds only `NO_PROXY` values, never the proxy token.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local hosts Claude Code must reach directly, in the order opencodex appends them. The proxy
|
|
20
|
+
* refuses every loopback and unspecified target (connect-proxy.ts isLoopbackTarget); these are the
|
|
21
|
+
* forms a NO_PROXY entry can name. `localhost` also covers `*.localhost` because Claude Code's
|
|
22
|
+
* client suffix-matches entries. Other 127/8 addresses cannot be listed (NO_PROXY has no CIDR) and
|
|
23
|
+
* need their own entry; the proxy's 405 body says so.
|
|
24
|
+
*/
|
|
25
|
+
export const CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY = ["127.0.0.1", "localhost", "::1", "0.0.0.0"] as const;
|
|
26
|
+
|
|
27
|
+
export const NO_PROXY_RECEIPT_FILE = "opencodex-no-proxy.json";
|
|
28
|
+
|
|
29
|
+
export interface NoProxyReceipt {
|
|
30
|
+
version: 1;
|
|
31
|
+
/** Whether settings env held a NO_PROXY key before the apply. */
|
|
32
|
+
hadKey: boolean;
|
|
33
|
+
/** The value before the apply (null when the key was absent or held null). */
|
|
34
|
+
previous: string | null;
|
|
35
|
+
/** The value the apply wrote. */
|
|
36
|
+
written: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Canonical index of a loopback bypass entry, or -1. `[::1]` is the bracketed form of `::1`. */
|
|
40
|
+
function loopbackEntryIndex(entry: string): number {
|
|
41
|
+
const normalized = entry.trim().toLowerCase();
|
|
42
|
+
return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.indexOf(
|
|
43
|
+
(normalized === "[::1]" ? "::1" : normalized) as typeof CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY[number],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function noProxyEntries(value: string): string[] {
|
|
48
|
+
return value.split(/[,\s]+/).filter(entry => entry.length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when the value already sends every loopback host around the proxy. */
|
|
52
|
+
export function noProxyCoversLoopback(value: unknown): boolean {
|
|
53
|
+
if (typeof value !== "string") return false;
|
|
54
|
+
const entries = noProxyEntries(value);
|
|
55
|
+
if (entries.includes("*")) return true;
|
|
56
|
+
const present = new Set(entries.map(loopbackEntryIndex));
|
|
57
|
+
return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.every((_, index) => present.has(index));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A NO_PROXY value opencodex can extend: absent, null, or a string. */
|
|
61
|
+
export function noProxyMergeable(value: unknown): boolean {
|
|
62
|
+
return value === undefined || value === null || typeof value === "string";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The NO_PROXY value apply writes, or null when nothing needs writing: the value already covers
|
|
67
|
+
* loopback, or it is not one opencodex can extend.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeClaudeInterceptNoProxy(current: unknown): string | null {
|
|
70
|
+
if (!noProxyMergeable(current) || noProxyCoversLoopback(current)) return null;
|
|
71
|
+
const base = typeof current === "string" ? current : "";
|
|
72
|
+
const present = new Set(noProxyEntries(base).map(loopbackEntryIndex));
|
|
73
|
+
const missing = CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.filter((_, index) => !present.has(index));
|
|
74
|
+
const trimmed = base.replace(/[,\s]+$/, "");
|
|
75
|
+
return trimmed.length === 0 ? missing.join(",") : `${trimmed},${missing.join(",")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function noProxyReceiptPath(configDir: string): string {
|
|
79
|
+
return join(configDir, NO_PROXY_RECEIPT_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ReceiptIo {
|
|
83
|
+
write(path: string, content: string): void;
|
|
84
|
+
unlink(path: string): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const defaultReceiptIo: ReceiptIo = {
|
|
88
|
+
write: (path, content) => atomicWriteFile(path, content),
|
|
89
|
+
unlink: path => unlinkSync(path),
|
|
90
|
+
};
|
|
91
|
+
let receiptIo: ReceiptIo = defaultReceiptIo;
|
|
92
|
+
|
|
93
|
+
/** Replace receipt write/unlink in tests; the real-home guard still runs. Returns a restore. */
|
|
94
|
+
export function setNoProxyReceiptIoForTests(io: Partial<ReceiptIo>): () => void {
|
|
95
|
+
const before = receiptIo;
|
|
96
|
+
receiptIo = { ...defaultReceiptIo, ...io };
|
|
97
|
+
return () => { receiptIo = before; };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The receipt, or null when it is missing or malformed (both mean: no provenance). */
|
|
101
|
+
export function readNoProxyReceipt(configDir: string): NoProxyReceipt | null {
|
|
102
|
+
let parsed: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(readFileSync(noProxyReceiptPath(configDir), "utf8"));
|
|
105
|
+
} catch { // no-excuse-ok: catch -- a missing or corrupt receipt is the no-provenance state.
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
109
|
+
const receipt = parsed as Record<string, unknown>;
|
|
110
|
+
if (receipt.version !== 1 || typeof receipt.hadKey !== "boolean" || typeof receipt.written !== "string") return null;
|
|
111
|
+
if (receipt.previous !== null && typeof receipt.previous !== "string") return null;
|
|
112
|
+
return { version: 1, hadKey: receipt.hadKey, previous: receipt.previous, written: receipt.written };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Raw receipt bytes for rollback capture, or null when there is no readable file. */
|
|
116
|
+
export function readNoProxyReceiptBytes(configDir: string): string | null {
|
|
117
|
+
try {
|
|
118
|
+
return readFileSync(noProxyReceiptPath(configDir), "utf8");
|
|
119
|
+
} catch { // no-excuse-ok: catch -- an unreadable receipt is captured as absent.
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Delete the receipt. Returns false when it exists but could not be removed. */
|
|
125
|
+
export function invalidateNoProxyReceipt(configDir: string): boolean {
|
|
126
|
+
const path = noProxyReceiptPath(configDir);
|
|
127
|
+
// Guard first and outside the catch, so a refusal is visible and nothing is touched.
|
|
128
|
+
assertNotRealClaudeConfigUnderTest(configDir, path);
|
|
129
|
+
try {
|
|
130
|
+
receiptIo.unlink(path);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (error) { // no-excuse-ok: catch -- ENOENT is success; anything else is reported to the caller.
|
|
133
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Write raw receipt bytes. Throws on failure; callers decide whether that is fatal. */
|
|
138
|
+
export function writeNoProxyReceiptBytes(configDir: string, content: string): void {
|
|
139
|
+
const path = noProxyReceiptPath(configDir);
|
|
140
|
+
assertNotRealClaudeConfigUnderTest(configDir, path);
|
|
141
|
+
receiptIo.write(path, content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function serializeNoProxyReceipt(receipt: NoProxyReceipt): string {
|
|
145
|
+
return `${JSON.stringify(receipt, null, 2)}\n`;
|
|
146
|
+
}
|
|
@@ -4,6 +4,19 @@ import { atomicWriteFile } from "../../config/atomic-write";
|
|
|
4
4
|
import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
|
|
5
5
|
import { claudeConfigDir } from "../auth-detect";
|
|
6
6
|
import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
|
|
7
|
+
import {
|
|
8
|
+
invalidateNoProxyReceipt,
|
|
9
|
+
mergeClaudeInterceptNoProxy,
|
|
10
|
+
noProxyCoversLoopback,
|
|
11
|
+
noProxyMergeable,
|
|
12
|
+
noProxyReceiptPath,
|
|
13
|
+
readNoProxyReceipt,
|
|
14
|
+
readNoProxyReceiptBytes,
|
|
15
|
+
serializeNoProxyReceipt,
|
|
16
|
+
writeNoProxyReceiptBytes,
|
|
17
|
+
} from "./settings-no-proxy";
|
|
18
|
+
|
|
19
|
+
export { CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY, mergeClaudeInterceptNoProxy } from "./settings-no-proxy";
|
|
7
20
|
|
|
8
21
|
/**
|
|
9
22
|
* Claude Code `settings.json` env block for intercept mode.
|
|
@@ -16,9 +29,21 @@ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
|
|
|
16
29
|
* env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
|
|
17
30
|
* env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
|
|
18
31
|
*
|
|
32
|
+
* A third key keeps loopback off that proxy. Claude Code otherwise sends local HTTP MCP traffic
|
|
33
|
+
* (`POST http://127.0.0.1:<port>/mcp`) through it, and the proxy only tunnels CONNECT to
|
|
34
|
+
* non-loopback hosts, so every loopback MCP server failed with a 405 (#6936):
|
|
35
|
+
*
|
|
36
|
+
* env.NO_PROXY = <user entries>,127.0.0.1,localhost,::1,0.0.0.0
|
|
37
|
+
*
|
|
19
38
|
* Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
|
|
20
39
|
* under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
|
|
21
40
|
* that file is treated as ours. A user's own proxy or CA setting is left alone.
|
|
41
|
+
*
|
|
42
|
+
* `NO_PROXY` is shared with the user. Apply appends only the missing loopback entries and leaves
|
|
43
|
+
* a covering value (including `*`) alone; removal puts back exactly what was there before, using
|
|
44
|
+
* the receipt described in ./settings-no-proxy.ts, and otherwise preserves the current value.
|
|
45
|
+
* A non-string `NO_PROXY` other than null is not one opencodex can extend: the block stays stale
|
|
46
|
+
* and apply refuses with `foreign_env` instead of rewriting it.
|
|
22
47
|
*/
|
|
23
48
|
|
|
24
49
|
export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
|
|
@@ -79,6 +104,11 @@ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
|
|
|
79
104
|
return typeof value === "string" && value.trim() === ownedCaPath;
|
|
80
105
|
}
|
|
81
106
|
|
|
107
|
+
function setOrDelete(env: Record<string, unknown>, key: string, value: unknown): void {
|
|
108
|
+
if (value === undefined) delete env[key];
|
|
109
|
+
else env[key] = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/** Classify the current settings env against the values this router would write. */
|
|
83
113
|
export function inspectClaudeInterceptSettings(
|
|
84
114
|
expected: ClaudeInterceptEnv,
|
|
@@ -91,7 +121,11 @@ export function inspectClaudeInterceptSettings(
|
|
|
91
121
|
const proxy = env.HTTPS_PROXY;
|
|
92
122
|
const ca = env.NODE_EXTRA_CA_CERTS;
|
|
93
123
|
if (proxy === undefined && ca === undefined) return { kind: "absent" };
|
|
94
|
-
|
|
124
|
+
// An owned block that still routes loopback into the proxy is stale, so the startup migrate
|
|
125
|
+
// and every reconcile repair installs written before the bypass existed.
|
|
126
|
+
if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS && noProxyCoversLoopback(env.NO_PROXY)) {
|
|
127
|
+
return { kind: "applied", env: expected };
|
|
128
|
+
}
|
|
95
129
|
const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
|
|
96
130
|
const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
|
|
97
131
|
if (caOurs && proxyOurs) {
|
|
@@ -112,13 +146,28 @@ export function inspectClaudeInterceptSettings(
|
|
|
112
146
|
};
|
|
113
147
|
}
|
|
114
148
|
|
|
115
|
-
|
|
149
|
+
const defaultPersist = (path: string, content: string): void => atomicWriteFile(path, content);
|
|
150
|
+
let persistFile = defaultPersist;
|
|
151
|
+
|
|
152
|
+
/** Replace the settings.json write in tests; the real-home guard still runs first. Returns a restore. */
|
|
153
|
+
export function setClaudeSettingsPersistForTests(persist: (path: string, content: string) => void): () => void {
|
|
154
|
+
const before = persistFile;
|
|
155
|
+
persistFile = persist;
|
|
156
|
+
return () => { persistFile = before; };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Commit settings.json. Once this returns, the file on disk holds the new document. */
|
|
160
|
+
function persistSettings(path: string, doc: SettingsDoc): void {
|
|
116
161
|
// An armed test process must not rewrite the real Claude settings.json (#6775).
|
|
117
162
|
assertNotRealClaudeConfigUnderTest(dirname(path), path);
|
|
118
163
|
mkdirSync(dirname(path), { recursive: true });
|
|
119
164
|
// The managed env embeds the proxy token, so the file must stay owner-only:
|
|
120
165
|
// atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
|
|
121
|
-
|
|
166
|
+
persistFile(path, `${JSON.stringify(doc, null, 2)}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function writeSettings(path: string, doc: SettingsDoc): void {
|
|
170
|
+
persistSettings(path, doc);
|
|
122
171
|
// Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
|
|
123
172
|
invalidateClaudeCodeServedCatalog(dirname(path));
|
|
124
173
|
}
|
|
@@ -137,20 +186,31 @@ export function captureClaudeInterceptSettingsRollback(
|
|
|
137
186
|
const before = readSettings(path);
|
|
138
187
|
if ("error" in before && before.error !== "missing") return () => false;
|
|
139
188
|
const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
|
|
189
|
+
const previousReceipt = readNoProxyReceiptBytes(configDir);
|
|
190
|
+
// What apply writes over the captured value; rollback restores NO_PROXY only while it still
|
|
191
|
+
// holds exactly that, so a user edit made after the apply survives. The receipt is restored
|
|
192
|
+
// with it, because it describes that value.
|
|
193
|
+
const appliedNoProxy = mergeClaudeInterceptNoProxy(previous.NO_PROXY) ?? previous.NO_PROXY;
|
|
140
194
|
return () => {
|
|
141
195
|
try {
|
|
196
|
+
assertNotRealClaudeConfigUnderTest(configDir, path, noProxyReceiptPath(configDir));
|
|
142
197
|
const target = typeof expected === "function" ? expected() : expected;
|
|
143
198
|
const current = readSettings(path);
|
|
144
199
|
if (!("doc" in current)) return false;
|
|
145
200
|
const env = envRecord(current.doc);
|
|
146
201
|
if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
|
|
147
202
|
for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
|
|
148
|
-
|
|
149
|
-
else env[key] = previous[key];
|
|
203
|
+
setOrDelete(env, key, previous[key]);
|
|
150
204
|
}
|
|
205
|
+
const restoreNoProxy = env.NO_PROXY === appliedNoProxy;
|
|
206
|
+
if (restoreNoProxy) setOrDelete(env, "NO_PROXY", previous.NO_PROXY);
|
|
151
207
|
if (Object.keys(env).length === 0) delete current.doc.env;
|
|
152
208
|
else current.doc.env = env;
|
|
153
209
|
writeSettings(path, current.doc);
|
|
210
|
+
if (restoreNoProxy) {
|
|
211
|
+
if (previousReceipt === null) return invalidateNoProxyReceipt(configDir);
|
|
212
|
+
writeNoProxyReceiptBytes(configDir, previousReceipt);
|
|
213
|
+
}
|
|
154
214
|
return true;
|
|
155
215
|
} catch { return false; }
|
|
156
216
|
};
|
|
@@ -158,7 +218,8 @@ export function captureClaudeInterceptSettingsRollback(
|
|
|
158
218
|
|
|
159
219
|
/**
|
|
160
220
|
* Write the intercept env into `settings.json`. Refuses when a managed key already holds a
|
|
161
|
-
* value opencodex did not write (a user-configured corporate proxy, for instance)
|
|
221
|
+
* value opencodex did not write (a user-configured corporate proxy, for instance), or when
|
|
222
|
+
* NO_PROXY holds a non-string value opencodex cannot extend.
|
|
162
223
|
*/
|
|
163
224
|
export function applyClaudeInterceptSettings(
|
|
164
225
|
env: ClaudeInterceptEnv,
|
|
@@ -171,8 +232,21 @@ export function applyClaudeInterceptSettings(
|
|
|
171
232
|
if (state.kind === "applied") return { ok: true, changed: false, path };
|
|
172
233
|
const read = readSettings(path);
|
|
173
234
|
const doc: SettingsDoc = "doc" in read ? read.doc : {};
|
|
174
|
-
|
|
235
|
+
const current = envRecord(doc);
|
|
236
|
+
if (!noProxyMergeable(current.NO_PROXY)) return { ok: false, reason: "foreign_env", path };
|
|
237
|
+
const noProxy = mergeClaudeInterceptNoProxy(current.NO_PROXY);
|
|
238
|
+
// A new ownership episode, or a new NO_PROXY value, makes any existing receipt describe the
|
|
239
|
+
// wrong value. Drop it before writing, and write nothing when it cannot be dropped.
|
|
240
|
+
if ((state.kind === "absent" || noProxy !== null) && !invalidateNoProxyReceipt(configDir)) {
|
|
241
|
+
return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
|
|
242
|
+
}
|
|
243
|
+
doc.env = { ...current, ...env, ...(noProxy === null ? {} : { NO_PROXY: noProxy }) };
|
|
175
244
|
writeSettings(path, doc);
|
|
245
|
+
if (noProxy !== null) {
|
|
246
|
+
const receipt = { version: 1 as const, hadKey: "NO_PROXY" in current, previous: typeof current.NO_PROXY === "string" ? current.NO_PROXY : null, written: noProxy };
|
|
247
|
+
// Without a receipt, removal keeps the appended entries: the harmless direction.
|
|
248
|
+
try { writeNoProxyReceiptBytes(configDir, serializeNoProxyReceipt(receipt)); } catch { /* no provenance */ }
|
|
249
|
+
}
|
|
176
250
|
return { ok: true, changed: true, path };
|
|
177
251
|
}
|
|
178
252
|
|
|
@@ -195,7 +269,11 @@ export function migrateClaudeInterceptSettings(
|
|
|
195
269
|
return applyClaudeInterceptSettings(env, configDir);
|
|
196
270
|
}
|
|
197
271
|
|
|
198
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Remove the managed keys and put NO_PROXY back as it was, but only the values opencodex owns.
|
|
274
|
+
* The receipt is dropped before the settings write; when that fails nothing is changed, so a
|
|
275
|
+
* receipt can never outlive the episode it describes.
|
|
276
|
+
*/
|
|
199
277
|
export function removeClaudeInterceptSettings(
|
|
200
278
|
ownedCaPath: string,
|
|
201
279
|
configDir = claudeConfigDir(),
|
|
@@ -207,10 +285,24 @@ export function removeClaudeInterceptSettings(
|
|
|
207
285
|
}
|
|
208
286
|
const env = envRecord(read.doc);
|
|
209
287
|
if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
|
|
288
|
+
const receipt = readNoProxyReceipt(configDir);
|
|
289
|
+
const receiptBytes = readNoProxyReceiptBytes(configDir);
|
|
210
290
|
delete env.NODE_EXTRA_CA_CERTS;
|
|
211
291
|
if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
|
|
292
|
+
if (receipt && env.NO_PROXY === receipt.written) setOrDelete(env, "NO_PROXY", receipt.hadKey ? receipt.previous : undefined);
|
|
293
|
+
if (!invalidateNoProxyReceipt(configDir)) return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
|
|
212
294
|
if (Object.keys(env).length === 0) delete read.doc.env;
|
|
213
295
|
else read.doc.env = env;
|
|
214
|
-
|
|
296
|
+
try {
|
|
297
|
+
persistSettings(path, read.doc);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// A removal that was not committed has not ended the episode, so its receipt stays valid. Only the
|
|
300
|
+
// persist step is covered: once settings.json changed, the receipt must not come back.
|
|
301
|
+
if (receiptBytes !== null) {
|
|
302
|
+
try { writeNoProxyReceiptBytes(configDir, receiptBytes); } catch { /* keep the original error */ }
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
invalidateClaudeCodeServedCatalog(dirname(path));
|
|
215
307
|
return { ok: true, changed: true, path };
|
|
216
308
|
}
|
package/src/cli/account-api.ts
CHANGED
|
@@ -137,6 +137,7 @@ export async function apiJson(
|
|
|
137
137
|
body?: unknown,
|
|
138
138
|
options: { signal?: AbortSignal; redirect?: RequestRedirect } = {},
|
|
139
139
|
): Promise<ApiResult> {
|
|
140
|
+
if (baseUrl === LINK_HOME_BASE) return linkHomeApiJson(deps, method, path);
|
|
140
141
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
141
142
|
try {
|
|
142
143
|
const res = await fetchImpl(`${baseUrl}${path}`, {
|
|
@@ -163,9 +164,73 @@ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null>
|
|
|
163
164
|
if (deps.baseUrl) return deps.baseUrl;
|
|
164
165
|
const live = await findLiveProxy();
|
|
165
166
|
if (!live) return null;
|
|
167
|
+
if (live.role === "client") {
|
|
168
|
+
const { readLinkChildContext } = await import("./link-child");
|
|
169
|
+
if (readLinkChildContext()) return LINK_HOME_BASE;
|
|
170
|
+
}
|
|
166
171
|
return `http://${probeHostname(live.hostname)}:${live.port}`;
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Remote Link Child (#6957): account reads come from the Home over the link, and every account
|
|
176
|
+
* change is refused with the Child refusal. The marker is never a URL and never reaches fetch.
|
|
177
|
+
*/
|
|
178
|
+
export const LINK_HOME_BASE = "ocx-link-home:";
|
|
179
|
+
|
|
180
|
+
async function linkHomeApiJson(deps: AccountDeps, method: string, path: string): Promise<ApiResult> {
|
|
181
|
+
const { readLinkChildContext, linkChildReadable, linkChildRefusal, readLinkHomeJson } = await import("./link-child");
|
|
182
|
+
const link = readLinkChildContext();
|
|
183
|
+
if (!link) return { status: 503, json: { error: "The Remote Link connection changed while the command ran; retry." } };
|
|
184
|
+
const pathname = new URL(path, "http://cli.invalid").pathname;
|
|
185
|
+
if (!linkChildReadable(method, path)) {
|
|
186
|
+
return { status: 403, json: { error: linkChildRefusal(link, `${method} ${pathname}`) } };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const { status, body } = await readLinkHomeJson(link, path, deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
|
|
190
|
+
return { status, json: (body && typeof body === "object" ? body : { error: String(body ?? "") }) as Record<string, unknown> };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { status: 503, json: { error: error instanceof Error ? error.message : String(error) } };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The config account commands classify providers against. On a Remote Link Child the local
|
|
198
|
+
* providers are inactive, so the Home's `/api/providers` projection (presence flags only) takes
|
|
199
|
+
* their place. Anywhere else the local config is returned. When the Home cannot be read the
|
|
200
|
+
* result is an error: classifying against the inactive local providers would answer for the
|
|
201
|
+
* wrong machine.
|
|
202
|
+
*/
|
|
203
|
+
export async function accountClassificationConfig(
|
|
204
|
+
deps: AccountDeps,
|
|
205
|
+
local: OcxConfig,
|
|
206
|
+
): Promise<{ config: OcxConfig } | { error: string }> {
|
|
207
|
+
if (deps.baseUrl) return { config: local };
|
|
208
|
+
const { readLinkChildContext, readLinkHomeJson } = await import("./link-child");
|
|
209
|
+
const link = readLinkChildContext();
|
|
210
|
+
if (!link) return { config: local };
|
|
211
|
+
const unavailable = (detail: string) => ({ error: `Could not read the provider list from ${link.homePhrase} over Remote Link (${link.linkId}): ${detail}.` });
|
|
212
|
+
try {
|
|
213
|
+
const { status, body } = await readLinkHomeJson(link, "/api/providers", deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
|
|
214
|
+
if (status !== 200) {
|
|
215
|
+
// Keep the Home's own sentence (for example the upgrade instruction an older Home yields).
|
|
216
|
+
const message = body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
|
|
217
|
+
? (body as { error: string }).error : null;
|
|
218
|
+
return unavailable(message ?? `the Home answered HTTP ${status}`);
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(body)) return unavailable("the Home returned an unreadable provider list");
|
|
221
|
+
const providers: Record<string, unknown> = {};
|
|
222
|
+
for (const row of body) {
|
|
223
|
+
if (!row || typeof row !== "object" || typeof (row as { name?: unknown }).name !== "string") continue;
|
|
224
|
+
const { name, hasApiKey, ...rest } = row as Record<string, unknown> & { name: string };
|
|
225
|
+
// A placeholder marks presence for classification; no credential value crosses the link.
|
|
226
|
+
providers[name] = { ...rest, ...(hasApiKey === true ? { apiKey: "home-credential" } : {}) };
|
|
227
|
+
}
|
|
228
|
+
return { config: { ...local, providers: providers as OcxConfig["providers"] } };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
169
234
|
export function proxyUnreachable(transportError?: string): number {
|
|
170
235
|
console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
|
|
171
236
|
// Naming the transport cause distinguishes "nothing is listening" from a refused
|
package/src/cli/account-auth.ts
CHANGED
|
@@ -441,6 +441,18 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise<void>
|
|
|
441
441
|
printData(result, wantsJson);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/** Codes that leave a coupon operation unconfirmed: the caller must keep its operationId. */
|
|
445
|
+
const UNCONFIRMED_COUPON_CODES: ReadonlySet<string> = new Set([
|
|
446
|
+
"attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed",
|
|
447
|
+
"redeem_failed", "attempt_mark_failed", "operation_token_mismatch",
|
|
448
|
+
]);
|
|
449
|
+
/** Pre-dispatch ledger refusals: unconfirmed when anchored to an operation, never a settled code. */
|
|
450
|
+
const LEDGER_REFUSAL_CODES: ReadonlySet<string> = new Set(["ledger_unavailable", "capacity"]);
|
|
451
|
+
/** The only codes the ledger records as a definitive outcome; any other settled code is unconfirmed. */
|
|
452
|
+
const DEFINITIVE_SETTLED_CODES: ReadonlySet<string> = new Set([
|
|
453
|
+
"redeemed", "auth_failed", "fetch_resets_failed", "no_coupons_available", "coupon_unavailable", "token_unresolved",
|
|
454
|
+
]);
|
|
455
|
+
|
|
444
456
|
async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
445
457
|
const args = [...argv];
|
|
446
458
|
// The account id is optional here (the server falls back to the selected xAI
|
|
@@ -467,6 +479,7 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
|
|
|
467
479
|
rejectArgs(args, USAGE);
|
|
468
480
|
const accountId = rawId ? (rawId === "main" ? "__main__" : rawId) : undefined;
|
|
469
481
|
const effectiveOperationId = consume ? operationId ?? crypto.randomUUID() : undefined;
|
|
482
|
+
const unconfirmed = () => new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, 503, null);
|
|
470
483
|
let result: unknown;
|
|
471
484
|
try {
|
|
472
485
|
result = consume
|
|
@@ -476,15 +489,26 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
|
|
|
476
489
|
: await runtimeRequest(`/api/grok/reset-coupons${accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""}`, {}, deps);
|
|
477
490
|
} catch (error) {
|
|
478
491
|
const apiError = error instanceof RuntimeApiError ? error : undefined;
|
|
479
|
-
const body = apiError?.body as { error?: { code?: string } } | null | undefined;
|
|
492
|
+
const body = apiError?.body as { operationId?: unknown; error?: { code?: string } } | null | undefined;
|
|
480
493
|
const code = body && typeof body === "object" ? body.error?.code : undefined;
|
|
494
|
+
const carriesOperation = !!body && typeof body === "object" && typeof body.operationId === "string";
|
|
481
495
|
if (consume && (!apiError || (!apiError.code && (
|
|
482
|
-
(!code && apiError.status >= 500) ||
|
|
496
|
+
(!code && apiError.status >= 500) || UNCONFIRMED_COUPON_CODES.has(code ?? "")
|
|
497
|
+
|| (LEDGER_REFUSAL_CODES.has(code ?? "") && carriesOperation)
|
|
483
498
|
)))) {
|
|
484
499
|
throw new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, apiError?.status ?? 503, apiError?.body ?? null);
|
|
485
500
|
}
|
|
486
501
|
throw error;
|
|
487
502
|
}
|
|
503
|
+
if (consume) {
|
|
504
|
+
// A 200 can replay a recorded refusal; only "redeemed" is a successful spend.
|
|
505
|
+
const settled = result && typeof result === "object" ? (result as { code?: unknown }).code : undefined;
|
|
506
|
+
if (typeof settled !== "string" || !DEFINITIVE_SETTLED_CODES.has(settled)) throw unconfirmed();
|
|
507
|
+
if (settled !== "redeemed") {
|
|
508
|
+
printData(result, wantsJson);
|
|
509
|
+
throw new RuntimeApiError(`Coupon was not redeemed (${settled}).`, 409, result);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
488
512
|
printData(result, wantsJson);
|
|
489
513
|
}
|
|
490
514
|
|