@bitkyc08/opencodex 2.60.0 → 2.61.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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { getConfigDir } from "../../config/paths";
|
|
4
|
+
import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
|
|
5
|
+
import { startClaudeInterceptListener } from "./listener";
|
|
6
|
+
import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener).
|
|
10
|
+
*
|
|
11
|
+
* Started next to the public listener, torn down with it. The proxy port is derived from the
|
|
12
|
+
* public port unless configured, because Claude Code's `settings.json` must name a port that
|
|
13
|
+
* survives restarts; the TLS listener is ephemeral and only ever reached through the proxy.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
|
|
17
|
+
|
|
18
|
+
export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
|
|
19
|
+
if (config.runtimeRole === "client") return false;
|
|
20
|
+
if (config.claudeCode?.enabled === false) return false;
|
|
21
|
+
return config.claudeCode?.intercept?.enabled !== false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
|
|
25
|
+
const configured = config.claudeCode?.intercept?.port;
|
|
26
|
+
if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured;
|
|
27
|
+
return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClaudeInterceptState {
|
|
31
|
+
proxyPort: number;
|
|
32
|
+
caCertPath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClaudeInterceptHandle<T = undefined> extends ClaudeInterceptState {
|
|
36
|
+
listener: Server<T>;
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let activeState: ClaudeInterceptState | null = null;
|
|
41
|
+
|
|
42
|
+
/** Live intercept endpoints, or `null` when the pair is not running in this process. */
|
|
43
|
+
export function getClaudeInterceptState(): ClaudeInterceptState | null {
|
|
44
|
+
return activeState;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StartClaudeInterceptOptions<T> {
|
|
48
|
+
config: OcxConfig;
|
|
49
|
+
/** Bound public port; the derived proxy port is offset from it. */
|
|
50
|
+
publicPort: number;
|
|
51
|
+
/**
|
|
52
|
+
* Port the operator asked for. `0` (ephemeral) gives the derived proxy port no stable value
|
|
53
|
+
* to write into `settings.json`, so intercept stays off unless `intercept.port` is explicit.
|
|
54
|
+
*/
|
|
55
|
+
requestedPort?: number;
|
|
56
|
+
dispatch: (req: Request, server: Server<T>) => Promise<Response>;
|
|
57
|
+
maxRequestBodySize?: number;
|
|
58
|
+
configDir?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bind both halves. Resolves `null` when intercept is disabled. A bind failure is reported by
|
|
63
|
+
* rejecting; callers treat it as a degraded optional integration, never as a startup failure.
|
|
64
|
+
*/
|
|
65
|
+
export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptions<T>): Promise<ClaudeInterceptHandle<T> | null> {
|
|
66
|
+
if (!claudeInterceptEnabled(options.config)) return null;
|
|
67
|
+
const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number";
|
|
68
|
+
if (options.requestedPort === 0 && !explicitPort) return null;
|
|
69
|
+
const configDir = options.configDir ?? getConfigDir();
|
|
70
|
+
const ca = await ensureLocalInterceptCaForStartup(configDir);
|
|
71
|
+
const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS);
|
|
72
|
+
const listener = startClaudeInterceptListener<T>({
|
|
73
|
+
leaf,
|
|
74
|
+
dispatch: options.dispatch,
|
|
75
|
+
upstreamBase: options.config.claudeCode?.anthropicBaseUrl,
|
|
76
|
+
...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}),
|
|
77
|
+
});
|
|
78
|
+
let proxy: ConnectProxyHandle;
|
|
79
|
+
try {
|
|
80
|
+
proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
|
|
81
|
+
interceptPort: listener.port!,
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await listener.stop(true);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) };
|
|
88
|
+
activeState = state;
|
|
89
|
+
return {
|
|
90
|
+
...state,
|
|
91
|
+
listener,
|
|
92
|
+
stop: async () => {
|
|
93
|
+
if (activeState === state) activeState = null;
|
|
94
|
+
await proxy.close();
|
|
95
|
+
await listener.stop(true);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { claudeConfigDir } from "../auth-detect";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Claude Code `settings.json` env block for intercept mode.
|
|
7
|
+
*
|
|
8
|
+
* Both the Claude Desktop Code tab and the standalone `claude` CLI read the user
|
|
9
|
+
* `~/.claude/settings.json` and export its `env` map into the process before any network
|
|
10
|
+
* call. Two keys make every Claude Code process route through the local CONNECT proxy while
|
|
11
|
+
* the app itself stays a first-party install:
|
|
12
|
+
*
|
|
13
|
+
* env.HTTPS_PROXY = http://127.0.0.1:<proxy port>
|
|
14
|
+
* env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
|
|
15
|
+
*
|
|
16
|
+
* Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
|
|
17
|
+
* under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
|
|
18
|
+
* that file is treated as ours. A user's own proxy or CA setting is left alone.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
|
|
22
|
+
export type ClaudeInterceptManagedEnv = typeof CLAUDE_INTERCEPT_MANAGED_ENV[number];
|
|
23
|
+
|
|
24
|
+
export interface ClaudeInterceptEnv {
|
|
25
|
+
HTTPS_PROXY: string;
|
|
26
|
+
NODE_EXTRA_CA_CERTS: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function claudeInterceptProxyUrl(port: number): string {
|
|
30
|
+
return `http://127.0.0.1:${port}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv {
|
|
34
|
+
return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ClaudeInterceptSettingsState =
|
|
38
|
+
| { kind: "absent" }
|
|
39
|
+
| { kind: "applied"; env: ClaudeInterceptEnv }
|
|
40
|
+
| { kind: "stale"; env: Partial<ClaudeInterceptEnv> }
|
|
41
|
+
| { kind: "foreign"; env: Partial<Record<ClaudeInterceptManagedEnv, string>> }
|
|
42
|
+
| { kind: "unreadable"; path: string };
|
|
43
|
+
|
|
44
|
+
type SettingsDoc = Record<string, unknown> & { env?: Record<string, unknown> };
|
|
45
|
+
|
|
46
|
+
function settingsPath(configDir: string): string {
|
|
47
|
+
return join(configDir, "settings.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readSettings(path: string): { doc: SettingsDoc } | { error: "missing" | "unreadable" } {
|
|
51
|
+
let raw: string;
|
|
52
|
+
try {
|
|
53
|
+
raw = readFileSync(path, "utf8");
|
|
54
|
+
} catch (error) { // no-excuse-ok: catch -- an absent settings file is the fresh-install state.
|
|
55
|
+
return { error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" };
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
59
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "unreadable" };
|
|
60
|
+
return { doc: parsed as SettingsDoc };
|
|
61
|
+
} catch { // no-excuse-ok: catch -- a corrupt settings file must be reported, not overwritten.
|
|
62
|
+
return { error: "unreadable" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function envRecord(doc: SettingsDoc): Record<string, unknown> {
|
|
67
|
+
return doc.env && typeof doc.env === "object" && !Array.isArray(doc.env) ? doc.env : {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Loopback proxy URLs are the only shape opencodex ever writes. */
|
|
71
|
+
export function isClaudeInterceptProxyUrl(value: unknown): value is string {
|
|
72
|
+
return typeof value === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
|
|
76
|
+
return typeof value === "string" && value.trim() === ownedCaPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Classify the current settings env against the values this router would write. */
|
|
80
|
+
export function inspectClaudeInterceptSettings(
|
|
81
|
+
expected: ClaudeInterceptEnv,
|
|
82
|
+
configDir = claudeConfigDir(),
|
|
83
|
+
): ClaudeInterceptSettingsState {
|
|
84
|
+
const path = settingsPath(configDir);
|
|
85
|
+
const read = readSettings(path);
|
|
86
|
+
if ("error" in read) return read.error === "missing" ? { kind: "absent" } : { kind: "unreadable", path };
|
|
87
|
+
const env = envRecord(read.doc);
|
|
88
|
+
const proxy = env.HTTPS_PROXY;
|
|
89
|
+
const ca = env.NODE_EXTRA_CA_CERTS;
|
|
90
|
+
if (proxy === undefined && ca === undefined) return { kind: "absent" };
|
|
91
|
+
if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected };
|
|
92
|
+
const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
|
|
93
|
+
const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
|
|
94
|
+
if (caOurs && proxyOurs) {
|
|
95
|
+
return {
|
|
96
|
+
kind: "stale",
|
|
97
|
+
env: {
|
|
98
|
+
...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}),
|
|
99
|
+
...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
kind: "foreign",
|
|
105
|
+
env: {
|
|
106
|
+
...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}),
|
|
107
|
+
...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeSettings(path: string, doc: SettingsDoc): void {
|
|
113
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
114
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
115
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
116
|
+
renameSync(tmp, path);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type ClaudeInterceptSettingsWrite =
|
|
120
|
+
| { ok: true; changed: boolean; path: string }
|
|
121
|
+
| { ok: false; reason: "unreadable" | "foreign_env"; path: string };
|
|
122
|
+
|
|
123
|
+
/** Capture only the managed keys. Rollback preserves unrelated edits and refuses
|
|
124
|
+
* to overwrite a newer proxy/CA choice made after this apply. */
|
|
125
|
+
export function captureClaudeInterceptSettingsRollback(
|
|
126
|
+
expected: ClaudeInterceptEnv,
|
|
127
|
+
configDir = claudeConfigDir(),
|
|
128
|
+
): () => boolean {
|
|
129
|
+
const path = settingsPath(configDir);
|
|
130
|
+
const before = readSettings(path);
|
|
131
|
+
if ("error" in before && before.error !== "missing") return () => false;
|
|
132
|
+
const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
|
|
133
|
+
return () => {
|
|
134
|
+
try {
|
|
135
|
+
const current = readSettings(path);
|
|
136
|
+
if (!("doc" in current)) return false;
|
|
137
|
+
const env = envRecord(current.doc);
|
|
138
|
+
if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== expected[key])) return false;
|
|
139
|
+
for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
|
|
140
|
+
if (previous[key] === undefined) delete env[key];
|
|
141
|
+
else env[key] = previous[key];
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(env).length === 0) delete current.doc.env;
|
|
144
|
+
else current.doc.env = env;
|
|
145
|
+
writeSettings(path, current.doc);
|
|
146
|
+
return true;
|
|
147
|
+
} catch { return false; }
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write the intercept env into `settings.json`. Refuses when a managed key already holds a
|
|
153
|
+
* value opencodex did not write (a user-configured corporate proxy, for instance).
|
|
154
|
+
*/
|
|
155
|
+
export function applyClaudeInterceptSettings(
|
|
156
|
+
env: ClaudeInterceptEnv,
|
|
157
|
+
configDir = claudeConfigDir(),
|
|
158
|
+
): ClaudeInterceptSettingsWrite {
|
|
159
|
+
const path = settingsPath(configDir);
|
|
160
|
+
const state = inspectClaudeInterceptSettings(env, configDir);
|
|
161
|
+
if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path };
|
|
162
|
+
if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path };
|
|
163
|
+
if (state.kind === "applied") return { ok: true, changed: false, path };
|
|
164
|
+
const read = readSettings(path);
|
|
165
|
+
const doc: SettingsDoc = "doc" in read ? read.doc : {};
|
|
166
|
+
doc.env = { ...envRecord(doc), ...env };
|
|
167
|
+
writeSettings(path, doc);
|
|
168
|
+
return { ok: true, changed: true, path };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Remove the managed keys, but only the values opencodex owns. */
|
|
172
|
+
export function removeClaudeInterceptSettings(
|
|
173
|
+
ownedCaPath: string,
|
|
174
|
+
configDir = claudeConfigDir(),
|
|
175
|
+
): ClaudeInterceptSettingsWrite {
|
|
176
|
+
const path = settingsPath(configDir);
|
|
177
|
+
const read = readSettings(path);
|
|
178
|
+
if ("error" in read) {
|
|
179
|
+
return read.error === "missing" ? { ok: true, changed: false, path } : { ok: false, reason: "unreadable", path };
|
|
180
|
+
}
|
|
181
|
+
const env = envRecord(read.doc);
|
|
182
|
+
if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
|
|
183
|
+
delete env.NODE_EXTRA_CA_CERTS;
|
|
184
|
+
if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
|
|
185
|
+
if (Object.keys(env).length === 0) delete read.doc.env;
|
|
186
|
+
else read.doc.env = env;
|
|
187
|
+
writeSettings(path, read.doc);
|
|
188
|
+
return { ok: true, changed: true, path };
|
|
189
|
+
}
|
package/src/cli/access.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
const USAGE = `Usage:
|
|
13
13
|
ocx access key [list] [--json]
|
|
14
14
|
ocx access key create [name] [--json]
|
|
15
|
+
ocx access key get <id-or-name> [--json]
|
|
16
|
+
ocx access key set <id-or-name> [--allow-provider <name>]... [--allow-model <id>]... [--clear] [--json]
|
|
15
17
|
ocx access key rotate <id> [--json]
|
|
16
18
|
ocx access key rotate commit <id> <rotation-id> [--json]
|
|
17
19
|
ocx access key rotate abort <id> <rotation-id> [--json]
|
|
@@ -65,6 +67,51 @@ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<stri
|
|
|
65
67
|
return footer.length > 0 ? [...lines, "", ...footer] : lines;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Repeatable option values, in the order given.
|
|
72
|
+
*
|
|
73
|
+
* takeOption removes one occurrence, so a scope with several entries needs the
|
|
74
|
+
* loop: reading it once would silently keep only the first `--allow-model` and
|
|
75
|
+
* write a narrower scope than the operator typed.
|
|
76
|
+
*/
|
|
77
|
+
function takeAllOptions(args: string[], name: string): string[] {
|
|
78
|
+
const values: string[] = [];
|
|
79
|
+
for (;;) {
|
|
80
|
+
const value = takeOption(args, name);
|
|
81
|
+
if (value === undefined) break;
|
|
82
|
+
values.push(value);
|
|
83
|
+
}
|
|
84
|
+
return values;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find a key by id or by name, without ever reading the secret.
|
|
89
|
+
*
|
|
90
|
+
* The management API keys every mutation by id, so a name has to be resolved
|
|
91
|
+
* here. An ambiguous name is refused rather than resolved to the first match:
|
|
92
|
+
* silently scoping one of two keys that share a name is the kind of mistake
|
|
93
|
+
* only discovered when the wrong client stops working.
|
|
94
|
+
*/
|
|
95
|
+
function findKeyRow(keys: Array<Record<string, unknown>>, selector: string): Record<string, unknown> {
|
|
96
|
+
const wanted = selector.trim().toLowerCase();
|
|
97
|
+
const byId = keys.filter(entry => String(entry.id ?? "").toLowerCase() === wanted);
|
|
98
|
+
if (byId.length === 1) return byId[0]!;
|
|
99
|
+
const byName = keys.filter(entry => String(entry.name ?? "").trim().toLowerCase() === wanted);
|
|
100
|
+
if (byName.length === 1) return byName[0]!;
|
|
101
|
+
if (byName.length > 1) throw new CliUsageError("key name " + selector + " is ambiguous; use the id", USAGE);
|
|
102
|
+
throw new CliUsageError("no API key matches " + selector, USAGE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scopeLines(entry: Record<string, unknown>): string[] {
|
|
106
|
+
const list = (value: unknown): string =>
|
|
107
|
+
Array.isArray(value) && value.length > 0 ? (value as string[]).join(", ") : "(any)";
|
|
108
|
+
return [
|
|
109
|
+
"API key " + String(entry.name ?? "") + " (" + String(entry.id ?? "") + ")",
|
|
110
|
+
" allowed providers: " + list(entry.allowedProviders),
|
|
111
|
+
" allowed models: " + list(entry.allowedModels),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
68
115
|
async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
69
116
|
const args = [...argv];
|
|
70
117
|
const action = (args.shift() ?? "list").toLowerCase();
|
|
@@ -90,6 +137,46 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
90
137
|
]);
|
|
91
138
|
return;
|
|
92
139
|
}
|
|
140
|
+
if (action === "get") {
|
|
141
|
+
const selector = args.shift();
|
|
142
|
+
if (!selector) throw new CliUsageError("key id or name is required", USAGE);
|
|
143
|
+
rejectArgs(args, USAGE);
|
|
144
|
+
const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
|
|
145
|
+
const entry = findKeyRow(Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [], selector);
|
|
146
|
+
// The list response carries the masked prefix and never the secret, so the
|
|
147
|
+
// row is safe to print as-is under --json.
|
|
148
|
+
printData(entry, wantsJson, scopeLines(entry));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (action === "set") {
|
|
152
|
+
const selector = args.shift();
|
|
153
|
+
if (!selector) throw new CliUsageError("key id or name is required", USAGE);
|
|
154
|
+
const clear = takeFlag(args, "--clear");
|
|
155
|
+
const providers = takeAllOptions(args, "--allow-provider");
|
|
156
|
+
const models = takeAllOptions(args, "--allow-model");
|
|
157
|
+
rejectArgs(args, USAGE);
|
|
158
|
+
if (!clear && providers.length === 0 && models.length === 0) {
|
|
159
|
+
throw new CliUsageError("set requires --allow-provider, --allow-model, or --clear", USAGE);
|
|
160
|
+
}
|
|
161
|
+
const listed = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
|
|
162
|
+
const target = findKeyRow(Array.isArray(listed.keys) ? listed.keys as Array<Record<string, unknown>> : [], selector);
|
|
163
|
+
// A set REPLACES the named dimension rather than appending to it, and
|
|
164
|
+
// --clear removes both. Naming one dimension leaves the other alone, so
|
|
165
|
+
// narrowing providers cannot accidentally widen models.
|
|
166
|
+
const body: Record<string, unknown> = { id: target.id };
|
|
167
|
+
if (clear) {
|
|
168
|
+
body.allowedProviders = null;
|
|
169
|
+
body.allowedModels = null;
|
|
170
|
+
}
|
|
171
|
+
if (providers.length > 0) body.allowedProviders = providers;
|
|
172
|
+
if (models.length > 0) body.allowedModels = models;
|
|
173
|
+
const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {
|
|
174
|
+
method: "PATCH",
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
}, deps);
|
|
177
|
+
printData(result, wantsJson, scopeLines(result));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
93
180
|
if (action === "rotate") {
|
|
94
181
|
const operation = args[0] === "commit" || args[0] === "abort" ? args.shift()! : "start";
|
|
95
182
|
const id = args.shift();
|
package/src/cli/account-auth.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeSync } from "node:fs";
|
|
|
2
2
|
import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance";
|
|
3
3
|
import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh";
|
|
4
4
|
import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery";
|
|
5
|
+
import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice";
|
|
5
6
|
import {
|
|
6
7
|
CliUsageError,
|
|
7
8
|
printData,
|
|
@@ -69,8 +70,25 @@ interface LoginStart {
|
|
|
69
70
|
flowId?: string;
|
|
70
71
|
instructions?: string;
|
|
71
72
|
deviceCode?: string;
|
|
73
|
+
/** Whether the host actually opened a browser. Absent from older proxies. */
|
|
74
|
+
browserLaunch?: "started" | "failed" | "skipped";
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Said only when the host could not open a browser (#5261).
|
|
79
|
+
*
|
|
80
|
+
* Without it, a failed launch is indistinguishable from a successful one: the URL is printed
|
|
81
|
+
* either way, so the user waits at a terminal that looks like it is working. Names the fixed
|
|
82
|
+
* callback port because that is the part people cannot guess — ChatGPT supplies the redirect
|
|
83
|
+
* URI, so the flow cannot move to a free port, and `--device` is the way around it.
|
|
84
|
+
*
|
|
85
|
+
* Extends the shared notice rather than repeating it: only the second line is specific to this
|
|
86
|
+
* flow, and the first is the sentence every other login prints for the same failure.
|
|
87
|
+
*/
|
|
88
|
+
export const BROWSER_LAUNCH_FAILED_HINT =
|
|
89
|
+
BROWSER_LAUNCH_FAILED_NOTICE
|
|
90
|
+
+ "\n If nothing on this machine can reach http://localhost:1455, rerun with --device instead.";
|
|
91
|
+
|
|
74
92
|
/** `-` means "read it from stdin", the documented way to pass a code silently. */
|
|
75
93
|
const STDIN_SENTINEL = "-";
|
|
76
94
|
|
|
@@ -145,6 +163,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
145
163
|
start.url ? `Open this URL to sign in:\n${start.url}` : "",
|
|
146
164
|
start.deviceCode ? `Device code: ${start.deviceCode}` : "",
|
|
147
165
|
start.instructions ?? "",
|
|
166
|
+
start.browserLaunch === "failed" ? BROWSER_LAUNCH_FAILED_HINT : "",
|
|
148
167
|
start.flowId ? `Flow: ${start.flowId}` : "",
|
|
149
168
|
].filter(line => line !== "").join("\n");
|
|
150
169
|
if (block) writeStdoutFully(`${block}\n`);
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -211,6 +211,21 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
211
211
|
json: "envelope",
|
|
212
212
|
details: ["Reads /healthz plus local config; drives no management API route."],
|
|
213
213
|
},
|
|
214
|
+
{
|
|
215
|
+
command: ["resolve"],
|
|
216
|
+
summary: "One JSON document naming the config home, the effective port, and the identity-checked proxy liveness verdict.",
|
|
217
|
+
// No management route, same split as status: discovery is the identity-checked
|
|
218
|
+
// /healthz probe inside findLiveProxy plus local config and the home from
|
|
219
|
+
// src/config/paths.ts.
|
|
220
|
+
routes: [],
|
|
221
|
+
flags: [{ name: "--json", value: "boolean", summary: "Emit the resolve document as JSON (the shell contract)." }],
|
|
222
|
+
mutates: false,
|
|
223
|
+
json: "envelope",
|
|
224
|
+
details: [
|
|
225
|
+
"Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent.",
|
|
226
|
+
"Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts.",
|
|
227
|
+
],
|
|
228
|
+
},
|
|
214
229
|
{
|
|
215
230
|
command: ["hub", "invite"],
|
|
216
231
|
summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
|
|
@@ -314,6 +329,22 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
314
329
|
"Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
|
|
315
330
|
],
|
|
316
331
|
},
|
|
332
|
+
{
|
|
333
|
+
command: ["companion"],
|
|
334
|
+
summary: "Inspect and configure menu-bar and widget companion usage settings.",
|
|
335
|
+
routes: [
|
|
336
|
+
{ method: "GET", path: "/api/companion/settings" },
|
|
337
|
+
{ method: "GET", path: "/api/usage/timeline" },
|
|
338
|
+
{ method: "PUT", path: "/api/companion/settings" },
|
|
339
|
+
],
|
|
340
|
+
flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }],
|
|
341
|
+
mutates: true,
|
|
342
|
+
json: "payload",
|
|
343
|
+
details: [
|
|
344
|
+
"`show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.",
|
|
345
|
+
"Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.",
|
|
346
|
+
],
|
|
347
|
+
},
|
|
317
348
|
{
|
|
318
349
|
command: ["account", "history"],
|
|
319
350
|
summary: "Cached quota observations for one stored Codex pool account.",
|