@bitkyc08/opencodex 2.26.0 → 2.27.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/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +14 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +38 -6
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/models.ts +13 -6
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-context.ts +53 -1
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +6 -4
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +73 -45
- package/src/images/loop.ts +11 -4
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/registry.ts +46 -1
- package/src/providers/service-tier.ts +34 -7
- package/src/responses/parser.ts +56 -2
- package/src/responses/state.ts +162 -5
- package/src/router.ts +10 -3
- package/src/routing/compatibility/behavior.ts +3 -3
- package/src/routing/profile.ts +1 -1
- package/src/server/index.ts +5 -0
- package/src/server/management/shared.ts +3 -1
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/core.ts +13 -4
- package/src/server/responses/input-admission.ts +7 -2
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- package/src/web-search/loop.ts +11 -4
|
@@ -101,22 +101,23 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): {
|
|
104
|
-
toolNsMap: Map<string, { namespace: string; name: string }>;
|
|
104
|
+
toolNsMap: Map<string, { namespace: string; name: string; freeform?: true }>;
|
|
105
105
|
declaredToolNames: Set<string>;
|
|
106
106
|
/** Declared parameter schema per request-visible tool name (#1611 integer repair). */
|
|
107
107
|
toolParameterSchemas: Map<string, Record<string, unknown>>;
|
|
108
108
|
freeformToolNames: Set<string>;
|
|
109
109
|
toolSearchToolNames: Set<string>;
|
|
110
110
|
} {
|
|
111
|
-
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
111
|
+
const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
|
|
112
112
|
const declaredToolNames = new Set<string>();
|
|
113
113
|
const toolParameterSchemas = new Map<string, Record<string, unknown>>();
|
|
114
114
|
const freeformToolNames = new Set<string>();
|
|
115
115
|
const toolSearchToolNames = new Set<string>();
|
|
116
|
-
const
|
|
117
|
-
|
|
116
|
+
const requestedTools = parsed.context.tools ?? [];
|
|
117
|
+
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
|
|
118
|
+
const authorizedTools = requestedTools.filter(toolAllowed);
|
|
119
|
+
for (const t of authorizedTools) {
|
|
118
120
|
// Upstream output is untrusted: only restore calls for tools the caller authorized.
|
|
119
|
-
if (!toolAllowed(t)) continue;
|
|
120
121
|
const wireName = namespacedToolName(t.namespace, t.name);
|
|
121
122
|
budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" });
|
|
122
123
|
declaredToolNames.add(wireName);
|
|
@@ -125,7 +126,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
125
126
|
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters);
|
|
126
127
|
if (t.namespace) {
|
|
127
128
|
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
128
|
-
toolNsMap.set(wireName, { namespace: t.namespace, name: t.name });
|
|
129
|
+
toolNsMap.set(wireName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
129
130
|
}
|
|
130
131
|
if (t.freeform) {
|
|
131
132
|
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
@@ -136,6 +137,29 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
136
137
|
toolSearchToolNames.add(t.name);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
140
|
+
// Some routed providers echo a bare tool_choice selector instead of the flattened catalog
|
|
141
|
+
// name. Accept only selectors the client actually sent and only when the full request catalog
|
|
142
|
+
// contains one tool with that logical name.
|
|
143
|
+
const choice = parsed.options.toolChoice;
|
|
144
|
+
const bareChoiceNames = new Set(
|
|
145
|
+
choice && typeof choice === "object"
|
|
146
|
+
? ("allowedTools" in choice ? choice.allowedTools : [choice.name])
|
|
147
|
+
: [],
|
|
148
|
+
);
|
|
149
|
+
const bareNameCounts = new Map<string, number>();
|
|
150
|
+
for (const t of requestedTools) {
|
|
151
|
+
bareNameCounts.set(t.name, (bareNameCounts.get(t.name) ?? 0) + 1);
|
|
152
|
+
}
|
|
153
|
+
for (const t of authorizedTools) {
|
|
154
|
+
if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
|
|
155
|
+
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
156
|
+
declaredToolNames.add(t.name);
|
|
157
|
+
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
158
|
+
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
159
|
+
if (t.parameters && typeof t.parameters === "object") {
|
|
160
|
+
toolParameterSchemas.set(t.name, t.parameters);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
139
163
|
return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames };
|
|
140
164
|
}
|
|
141
165
|
|
|
@@ -197,7 +221,8 @@ export interface MultiAgentGuidanceDeps {
|
|
|
197
221
|
configuredModels: readonly string[],
|
|
198
222
|
surface: SpawnAgentSurface,
|
|
199
223
|
) => EffectiveSubagentRoster | Promise<EffectiveSubagentRoster>;
|
|
200
|
-
collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" }
|
|
224
|
+
collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" }
|
|
225
|
+
| Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }>;
|
|
201
226
|
}
|
|
202
227
|
|
|
203
228
|
async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }> {
|
|
@@ -207,8 +232,8 @@ async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale"
|
|
|
207
232
|
if (override === "fresh" || override === "stale" || override === "not_running" || override === "unknown") {
|
|
208
233
|
return { state: override };
|
|
209
234
|
}
|
|
210
|
-
const {
|
|
211
|
-
return
|
|
235
|
+
const { collectCodexAppServerCatalogStateForRequest } = await import("../../codex/app-server-processes");
|
|
236
|
+
return collectCodexAppServerCatalogStateForRequest();
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
|
|
@@ -1238,6 +1238,7 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
1238
1238
|
route.modelId,
|
|
1239
1239
|
route.providerName,
|
|
1240
1240
|
inboundWire,
|
|
1241
|
+
config.providers[route.providerName],
|
|
1241
1242
|
);
|
|
1242
1243
|
const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
|
|
1243
1244
|
const callerTier = parsed.options.serviceTier;
|
|
@@ -4348,9 +4349,9 @@ async function handleResponsesInner(
|
|
|
4348
4349
|
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
4349
4350
|
try {
|
|
4350
4351
|
if (nextParsed.stream) {
|
|
4351
|
-
yield* activeAdapter.parseStream(response, translatorBudget);
|
|
4352
|
+
yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata);
|
|
4352
4353
|
} else if (activeAdapter.parseResponse) {
|
|
4353
|
-
yield* await activeAdapter.parseResponse(response, translatorBudget);
|
|
4354
|
+
yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata);
|
|
4354
4355
|
} else {
|
|
4355
4356
|
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
4356
4357
|
}
|
|
@@ -4380,7 +4381,11 @@ async function handleResponsesInner(
|
|
|
4380
4381
|
};
|
|
4381
4382
|
|
|
4382
4383
|
if (parsed.stream) {
|
|
4383
|
-
const initialEventStream = activeAdapter.parseStream(
|
|
4384
|
+
const initialEventStream = activeAdapter.parseStream(
|
|
4385
|
+
upstreamResponse,
|
|
4386
|
+
translatorBudget,
|
|
4387
|
+
logCtx.activeTierMetadata,
|
|
4388
|
+
);
|
|
4384
4389
|
const eventStream = terminalGuardEnabled
|
|
4385
4390
|
? guardTerminalEventStream({
|
|
4386
4391
|
parsed,
|
|
@@ -4448,7 +4453,11 @@ async function handleResponsesInner(
|
|
|
4448
4453
|
if (activeAdapter.parseResponse) {
|
|
4449
4454
|
let events: AdapterEvent[];
|
|
4450
4455
|
try {
|
|
4451
|
-
const initialEvents = await activeAdapter.parseResponse(
|
|
4456
|
+
const initialEvents = await activeAdapter.parseResponse(
|
|
4457
|
+
upstreamResponse,
|
|
4458
|
+
translatorBudget,
|
|
4459
|
+
logCtx.activeTierMetadata,
|
|
4460
|
+
);
|
|
4452
4461
|
let guardedEvents: AdapterEvent[];
|
|
4453
4462
|
if (terminalGuardEnabled) {
|
|
4454
4463
|
guardedEvents = [];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata";
|
|
14
14
|
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
15
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
|
+
import { modelRecordValue } from "../../reasoning-effort";
|
|
16
17
|
import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -133,7 +134,11 @@ export function resolveInputCeiling(
|
|
|
133
134
|
// config here so this stays pure: no filesystem, no catalog, no registry scan.
|
|
134
135
|
nativeContextCap?: NativeContextLimitsInput,
|
|
135
136
|
): number | null {
|
|
136
|
-
|
|
137
|
+
// `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that
|
|
138
|
+
// way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall
|
|
139
|
+
// back to the provider-wide window and refuse turns the model can plainly hold.
|
|
140
|
+
const configured = positive(modelRecordValue(provider.modelContextWindows, modelId))
|
|
141
|
+
?? positive(provider.contextWindow);
|
|
137
142
|
|
|
138
143
|
// The canonical `openai` registry entry declares no context fields, so without this the
|
|
139
144
|
// gate would be inert on the default Codex route. All three clauses are load-bearing: a
|
|
@@ -156,7 +161,7 @@ export function resolveInputCeiling(
|
|
|
156
161
|
|
|
157
162
|
const window = canonicalNativeBare ? native : configured;
|
|
158
163
|
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
159
|
-
const configuredMaxInput = positive(provider.modelMaxInputTokens
|
|
164
|
+
const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId));
|
|
160
165
|
const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null);
|
|
161
166
|
return limits.length === 0 ? null : Math.min(...limits);
|
|
162
167
|
}
|
|
@@ -179,6 +179,86 @@ function unitEnvValue(body: string, key: string): string | null {
|
|
|
179
179
|
return null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Did `systemctl --user` fail because the session bus could not be reached at all?
|
|
184
|
+
*
|
|
185
|
+
* These are the shapes reported on #2114 and #1939. The distinction that matters is
|
|
186
|
+
* "the question never left the machine" versus "systemd answered and said no" — only
|
|
187
|
+
* the former licenses reading the disk instead.
|
|
188
|
+
*
|
|
189
|
+
* **Locale caveat, stated rather than hidden:** systemd localizes these strings, so a
|
|
190
|
+
* non-English host will not match and keeps the old `unknown`. That is the safe
|
|
191
|
+
* direction — it fences rather than admits — but it does mean the fix does not reach
|
|
192
|
+
* every affected user. Forcing `LC_ALL=C` on the probe would remove the caveat and is
|
|
193
|
+
* the obvious follow-up; it is not done here because it changes every systemctl call
|
|
194
|
+
* this module makes, not just this branch.
|
|
195
|
+
*/
|
|
196
|
+
function busUnreachable(stderr: string): boolean {
|
|
197
|
+
const err = stderr.trim();
|
|
198
|
+
return err.includes("Failed to connect to bus")
|
|
199
|
+
|| err.includes("Failed to connect to user scope bus")
|
|
200
|
+
|| err.includes("Failed to get D-Bus connection")
|
|
201
|
+
|| err.includes("DBUS_SESSION_BUS_ADDRESS")
|
|
202
|
+
|| err.includes("System has not been booted with systemd");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ownership from the unit file alone, for when the bus cannot answer (#2114).
|
|
207
|
+
*
|
|
208
|
+
* A unit file is proof of installation that does not require a running bus, and the homes
|
|
209
|
+
* it names are what ownership is actually decided on. What the disk cannot tell us is
|
|
210
|
+
* whether systemd has the unit LOADED, so this reports `registration: "absent"` — the
|
|
211
|
+
* honest reading of "no running manager has it" — rather than inventing a live state.
|
|
212
|
+
*
|
|
213
|
+
* A foreign home therefore still blocks, which is the whole reason this consults the disk
|
|
214
|
+
* instead of widening the exit code.
|
|
215
|
+
*/
|
|
216
|
+
function systemdUserUnitSearchPaths(home: string): string[] {
|
|
217
|
+
// systemd's user search path is not one directory. Checking only the canonical one and
|
|
218
|
+
// calling the rest absent is a fail-open: with the bus down a foreign unit in any other
|
|
219
|
+
// search dir is invisible, and "no answer" would be read as "no owner".
|
|
220
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME?.trim();
|
|
221
|
+
const xdgData = process.env.XDG_DATA_HOME?.trim();
|
|
222
|
+
const dirs = [
|
|
223
|
+
xdgConfig ? join(xdgConfig, "systemd", "user") : join(home, ".config", "systemd", "user"),
|
|
224
|
+
join(home, ".config", "systemd", "user"),
|
|
225
|
+
xdgData ? join(xdgData, "systemd", "user") : join(home, ".local", "share", "systemd", "user"),
|
|
226
|
+
join(home, ".local", "share", "systemd", "user"),
|
|
227
|
+
];
|
|
228
|
+
return [...new Set(dirs)].map(dir => join(dir, `${TASK}.service`));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function inspectSystemdOffline(home: string): ServiceManagerInstallation {
|
|
232
|
+
const candidates = systemdUserUnitSearchPaths(home);
|
|
233
|
+
const found = candidates.filter(path => artifactPresence(path) === "present");
|
|
234
|
+
if (candidates.some(path => artifactPresence(path) === "unreadable")) {
|
|
235
|
+
return unknown("the session bus is unreachable and a systemd unit could not be read");
|
|
236
|
+
}
|
|
237
|
+
if (found.length === 0) return { kind: "absent" };
|
|
238
|
+
if (found.length > 1) {
|
|
239
|
+
return unknown("the session bus is unreachable and more than one systemd unit file claims this proxy");
|
|
240
|
+
}
|
|
241
|
+
const definitionPath = found[0]!;
|
|
242
|
+
let body: string;
|
|
243
|
+
try {
|
|
244
|
+
body = readFileSync(definitionPath, "utf-8");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
kind: "present",
|
|
250
|
+
claims: [{
|
|
251
|
+
backend: "systemd",
|
|
252
|
+
definitionPath,
|
|
253
|
+
homes: {
|
|
254
|
+
codexHome: unitEnvValue(body, "CODEX_HOME"),
|
|
255
|
+
opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
|
|
256
|
+
},
|
|
257
|
+
registration: "absent",
|
|
258
|
+
}],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
182
262
|
function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
|
|
183
263
|
const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
184
264
|
|
|
@@ -269,6 +349,15 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
269
349
|
if (shown.status !== 0) {
|
|
270
350
|
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
271
351
|
// the question never reached the bus.
|
|
352
|
+
//
|
|
353
|
+
// That is evidence about the BUS, not evidence that a foreign service owns this home
|
|
354
|
+
// (#2114). Calling it `unknown` fences native-main for the whole process, so a laptop
|
|
355
|
+
// with no session bus answers every native request with a 503 until `ocx restart`.
|
|
356
|
+
//
|
|
357
|
+
// Widening on the exit code alone would fail open, because with the bus down systemctl
|
|
358
|
+
// cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back
|
|
359
|
+
// to `unknown` for every other non-zero exit.
|
|
360
|
+
if (busUnreachable(shown.stderr)) return inspectSystemdOffline(deps.home);
|
|
272
361
|
return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`);
|
|
273
362
|
}
|
|
274
363
|
|
|
@@ -729,6 +818,16 @@ function walkWinswChain(
|
|
|
729
818
|
const registration = probeWinswRegistration(deps);
|
|
730
819
|
|
|
731
820
|
if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" };
|
|
821
|
+
// A query we could not ask is a question about a service that cannot exist: WinSW is an
|
|
822
|
+
// optional backend, and with neither its XML nor its exe on disk there is nothing for a
|
|
823
|
+
// registration to belong to. Fencing here on an `sc.exe` timeout is one of the two
|
|
824
|
+
// triggers behind #2108, where a scheduler-only install answers 503 until `ocx restart`.
|
|
825
|
+
//
|
|
826
|
+
// The disk outranks the unaskable query only when BOTH assets are gone. Either one
|
|
827
|
+
// present means a real install may be there and the old `unknown` still holds.
|
|
828
|
+
if (registration === "unknown" && xml === "absent" && exe === "absent") {
|
|
829
|
+
return { kind: "absent" };
|
|
830
|
+
}
|
|
732
831
|
if (registration === "unknown") {
|
|
733
832
|
return unknown("the native WinSW service registration could not be verified");
|
|
734
833
|
}
|
package/src/service.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
|
|
|
19
19
|
import type { BunRuntimeSource } from "./lib/bun-runtime";
|
|
20
20
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
21
21
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
22
|
+
import { PROXY_ENV_KEYS } from "./lib/proxy-env";
|
|
22
23
|
import { randomUUID } from "node:crypto";
|
|
23
24
|
import {
|
|
24
25
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
@@ -389,7 +390,7 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
389
390
|
return path;
|
|
390
391
|
}
|
|
391
392
|
|
|
392
|
-
export function buildPlist(): string {
|
|
393
|
+
export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
393
394
|
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
394
395
|
const log = logPath();
|
|
395
396
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
@@ -404,6 +405,8 @@ export function buildPlist(): string {
|
|
|
404
405
|
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
405
406
|
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
|
|
406
407
|
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
408
|
+
...proxyEnv.map(({ name, value }) =>
|
|
409
|
+
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
407
410
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
408
411
|
const command = buildServiceShellCommand(bun, cli);
|
|
409
412
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -640,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
|
|
|
640
643
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
641
644
|
}
|
|
642
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Outbound proxy settings the installing shell had, resolved for baking into a service
|
|
648
|
+
* definition.
|
|
649
|
+
*
|
|
650
|
+
* A service manager does not inherit the environment of the shell that installed it, and
|
|
651
|
+
* `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
|
|
652
|
+
* `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
|
|
653
|
+
* reach the upstream got a service that dialed direct: the socket was reset, the retry
|
|
654
|
+
* budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
|
|
655
|
+
* same install driven through `ocx codex-shim` worked, because that path spawns with
|
|
656
|
+
* `{ ...process.env }`.
|
|
657
|
+
*
|
|
658
|
+
* Lower-case variants are honored because curl-style tooling sets them and the runtime's
|
|
659
|
+
* own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
|
|
660
|
+
* upper-case name is baked, so a definition never carries two spellings of one setting.
|
|
661
|
+
*/
|
|
662
|
+
export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
|
|
663
|
+
const resolved: { name: string; value: string }[] = [];
|
|
664
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
665
|
+
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
666
|
+
if (value) resolved.push({ name: key, value });
|
|
667
|
+
}
|
|
668
|
+
return resolved;
|
|
669
|
+
}
|
|
670
|
+
|
|
643
671
|
function systemdOutputTarget(value: string): string {
|
|
644
672
|
// StandardOutput/StandardError use output specifiers such as append:/path.
|
|
645
673
|
// Quoting the full specifier makes systemd reject it as an invalid output target.
|
|
@@ -1513,7 +1541,11 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
|
1513
1541
|
return value === "leastprivilege" || value === "highestavailable";
|
|
1514
1542
|
}
|
|
1515
1543
|
|
|
1516
|
-
export function buildWindowsServiceScript(
|
|
1544
|
+
export function buildWindowsServiceScript(
|
|
1545
|
+
entry = cliEntry(),
|
|
1546
|
+
port = resolveServiceListenPort(),
|
|
1547
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
1548
|
+
): string {
|
|
1517
1549
|
// Provenance rides along with the entry: a second durableBunRuntime() call here could
|
|
1518
1550
|
// resolve differently from the binary the caller actually baked.
|
|
1519
1551
|
const { bun, bunRuntimeSource, cli } = entry;
|
|
@@ -1531,6 +1563,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1531
1563
|
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
|
|
1532
1564
|
windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
|
|
1533
1565
|
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
|
|
1566
|
+
...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
|
|
1534
1567
|
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
|
|
1535
1568
|
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
|
|
1536
1569
|
windowsBatchSet("OCX_BUN", bun, "path"),
|
|
@@ -1852,7 +1885,7 @@ function installLaunchd(): void {
|
|
|
1852
1885
|
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
1853
1886
|
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
1854
1887
|
const wasInstalled = existsSync(p);
|
|
1855
|
-
|
|
1888
|
+
writeServiceDefinitionFile(p, buildPlist(), "utf8");
|
|
1856
1889
|
// Best-effort: an absent job is fine here, and a failed unload is caught by the
|
|
1857
1890
|
// load verification below with a better message than a raw unload error.
|
|
1858
1891
|
runLaunchctl(["unload", p]);
|
|
@@ -1915,6 +1948,52 @@ function uninstallLaunchd(): void {
|
|
|
1915
1948
|
if (existsSync(p)) unlinkSync(p);
|
|
1916
1949
|
}
|
|
1917
1950
|
|
|
1951
|
+
/**
|
|
1952
|
+
* Write a service definition with owner-only permissions.
|
|
1953
|
+
*
|
|
1954
|
+
* These files carry the outbound proxy environment (#2107), and a proxy URL routinely
|
|
1955
|
+
* carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
|
|
1956
|
+
* umask, so the credential would be world-readable on a shared host. Every other
|
|
1957
|
+
* secret-bearing write in this file already uses 0600 — the service API token and the
|
|
1958
|
+
* install state — and a service definition holding a proxy credential belongs in the same
|
|
1959
|
+
* class.
|
|
1960
|
+
*
|
|
1961
|
+
* The explicit `chmodSync` is not redundant: `mode` only applies when the file is
|
|
1962
|
+
* created, so an install over a definition left at 0644 by an earlier version would keep
|
|
1963
|
+
* the loose mode.
|
|
1964
|
+
*
|
|
1965
|
+
* On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
|
|
1966
|
+
* may soft-fail depends on what the definition actually contains. A definition carrying a
|
|
1967
|
+
* proxy credential is a secret publication and fails closed like the API token and the
|
|
1968
|
+
* install state do; one carrying only paths and a port is not worth refusing an install
|
|
1969
|
+
* over, since before #2107 these files had no hardening at all and a failure here would
|
|
1970
|
+
* regress a user who has no credential to protect.
|
|
1971
|
+
*/
|
|
1972
|
+
export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1973
|
+
writeFileSync(path, content, { encoding, mode: 0o600 });
|
|
1974
|
+
try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
|
|
1975
|
+
if (process.platform === "win32") {
|
|
1976
|
+
hardenSecretPath(path, { required: definitionCarriesCredential(content) });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Does this service definition embed a credential-bearing proxy URL?
|
|
1982
|
+
*
|
|
1983
|
+
* Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
|
|
1984
|
+
* variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
|
|
1985
|
+
* make an icacls stall fail an install that had nothing to protect.
|
|
1986
|
+
*
|
|
1987
|
+
* The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
|
|
1988
|
+
* because the three formats render differently — systemd writes `Environment="K=V"`, the
|
|
1989
|
+
* plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
|
|
1990
|
+
* `set "K=V"`. Keying on the assignment syntax silently missed the plist.
|
|
1991
|
+
*/
|
|
1992
|
+
export function definitionCarriesCredential(content: string): boolean {
|
|
1993
|
+
// A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
|
|
1994
|
+
return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1918
1997
|
// ── Windows (Task Scheduler) ──
|
|
1919
1998
|
/**
|
|
1920
1999
|
* In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
|
|
@@ -1923,7 +2002,7 @@ function uninstallLaunchd(): void {
|
|
|
1923
2002
|
function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1924
2003
|
for (let attempt = 0; ; attempt++) {
|
|
1925
2004
|
try {
|
|
1926
|
-
|
|
2005
|
+
writeServiceDefinitionFile(path, content, encoding);
|
|
1927
2006
|
return;
|
|
1928
2007
|
} catch (err) {
|
|
1929
2008
|
const code = (err as NodeJS.ErrnoException).code;
|
|
@@ -2415,7 +2494,7 @@ function unitPath(): string {
|
|
|
2415
2494
|
return join(unitDir(), `${TASK}.service`);
|
|
2416
2495
|
}
|
|
2417
2496
|
|
|
2418
|
-
export function buildUnit(): string {
|
|
2497
|
+
export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
2419
2498
|
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
2420
2499
|
const log = logPath();
|
|
2421
2500
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
@@ -2430,6 +2509,7 @@ export function buildUnit(): string {
|
|
|
2430
2509
|
codexHome,
|
|
2431
2510
|
codexSqliteHome,
|
|
2432
2511
|
opencodexHome,
|
|
2512
|
+
...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
|
|
2433
2513
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
2434
2514
|
return `[Unit]
|
|
2435
2515
|
Description=OpenCodex Proxy Server
|
|
@@ -2490,7 +2570,7 @@ function installSystemd(): void {
|
|
|
2490
2570
|
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
2491
2571
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
2492
2572
|
writeServiceApiTokenFile();
|
|
2493
|
-
|
|
2573
|
+
writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
|
|
2494
2574
|
sh("systemctl --user daemon-reload");
|
|
2495
2575
|
sh(`systemctl --user enable ${TASK}`);
|
|
2496
2576
|
sh(`systemctl --user restart ${TASK}`);
|
package/src/tray/windows.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { BunRuntimeSource } from "../lib/bun-runtime";
|
|
|
9
9
|
import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
10
10
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
11
11
|
import { renameAtomicFile } from "../lib/windows-atomic-replace";
|
|
12
|
+
import { decodeWindowsTextBytes } from "../lib/windows-text";
|
|
12
13
|
|
|
13
14
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
14
15
|
const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
|
|
@@ -117,12 +118,31 @@ function registryExe(): string {
|
|
|
117
118
|
return existsSync(candidate) ? candidate : "reg.exe";
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Decode `reg.exe` output the way the rest of the product decodes Windows console
|
|
123
|
+
* output.
|
|
124
|
+
*
|
|
125
|
+
* `reg.exe` writes the console ANSI code page when its output is redirected, not
|
|
126
|
+
* UTF-8. Reading it as utf8 corrupts every non-ASCII byte, so a profile path such
|
|
127
|
+
* as `C:\Users\M<o-umlaut>tz` came back with replacement characters, the
|
|
128
|
+
* comparison against the value we wrote could never match, `registrationOwned`
|
|
129
|
+
* went false, and the CLI reported the tray registration as
|
|
130
|
+
* "foreign, stale, or points to missing package files" over an entry that was
|
|
131
|
+
* correct and owned (#1933).
|
|
132
|
+
*
|
|
133
|
+
* `decodeWindowsTextBytes` already solves this for `schtasks` (#1573). The tray
|
|
134
|
+
* reader was the site that class fix missed.
|
|
135
|
+
*/
|
|
136
|
+
function decodeRegistryOutput(stdout: Buffer | string): string {
|
|
137
|
+
const bytes = typeof stdout === "string" ? Buffer.from(stdout, "binary") : stdout;
|
|
138
|
+
return decodeWindowsTextBytes(bytes).trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
function runRegistry(args: string[]): string {
|
|
121
|
-
return execFileSync(registryExe(), args, {
|
|
122
|
-
encoding: "utf8",
|
|
142
|
+
return decodeRegistryOutput(execFileSync(registryExe(), args, {
|
|
123
143
|
stdio: ["ignore", "pipe", "pipe"],
|
|
124
144
|
windowsHide: true,
|
|
125
|
-
})
|
|
145
|
+
}));
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
function safePath(value: string): string {
|
|
@@ -335,13 +355,13 @@ function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): stri
|
|
|
335
355
|
function runRegistryAsync(args: string[]): Promise<string> {
|
|
336
356
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
337
357
|
execFile(registryExe(), args, {
|
|
338
|
-
encoding: "
|
|
358
|
+
encoding: "buffer",
|
|
339
359
|
timeout: 2_000,
|
|
340
360
|
windowsHide: true,
|
|
341
361
|
maxBuffer: 64 * 1024,
|
|
342
362
|
}, (error, stdout) => {
|
|
343
363
|
if (error) rejectPromise(error);
|
|
344
|
-
else resolvePromise(stdout
|
|
364
|
+
else resolvePromise(decodeRegistryOutput(stdout));
|
|
345
365
|
});
|
|
346
366
|
});
|
|
347
367
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface CodexAccount {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
/** User-owned display label; never participates in routing or identity checks. */
|
|
5
|
+
alias?: string;
|
|
6
|
+
plan?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Provenance of `plan`. WHAM (live quota API) is authoritative; the JWT
|
|
9
|
+
* `chatgpt_plan_type` claim is a fallback that may lag a plan change. A JWT write
|
|
10
|
+
* must never overwrite a WHAM-sourced plan observed for the same credential
|
|
11
|
+
* generation — only a newer generation (token refresh after the WHAM read) may.
|
|
12
|
+
*/
|
|
13
|
+
planSource?: "jwt" | "wham";
|
|
14
|
+
/** Credential generation at which `plan`/`planSource` was recorded. */
|
|
15
|
+
planCredentialGeneration?: number;
|
|
16
|
+
chatgptAccountId?: string;
|
|
17
|
+
logLabel?: string;
|
|
18
|
+
isMain: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CodexAccountCredentials {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
chatgptAccountId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CodexAccountCredentialRecord {
|
|
29
|
+
credential?: CodexAccountCredentials;
|
|
30
|
+
generation: number;
|
|
31
|
+
refreshGrantFingerprint?: string;
|
|
32
|
+
deletedAt?: number;
|
|
33
|
+
replacedAt?: number;
|
|
34
|
+
lastCodexValidatedAt?: number;
|
|
35
|
+
lastCodexValidationStatus?: "ok" | "failed";
|
|
36
|
+
lastCodexValidationError?: string;
|
|
37
|
+
}
|