@bitkyc08/opencodex 2.17.1-preview.20260814 → 2.19.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-DUCH59lJ.css → index-CQ7bIKee.css} +1 -1
- package/gui/dist/assets/{index-ta3-_hgj.js → index-D_JUZLEC.js} +16 -16
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +14 -10
- package/src/adapters/cursor/live-transport.ts +17 -5
- package/src/adapters/cursor/protobuf-events.ts +662 -20
- package/src/adapters/cursor/tool-definitions.ts +12 -6
- package/src/adapters/google-antigravity-wire.ts +4 -3
- package/src/adapters/google.ts +22 -3
- package/src/bridge.ts +12 -2
- package/src/chat/inbound.ts +24 -1
- package/src/cli/index.ts +11 -0
- package/src/codex/app-server-processes.ts +3 -3
- package/src/codex/shim.ts +100 -5
- package/src/codex/user-identity.ts +36 -6
- package/src/config.ts +0 -2
- package/src/generated/compatibility-version.json +52 -44
- package/src/lib/errors.ts +27 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/windows-elevation.ts +37 -0
- package/src/lib/windows-secret-acl.ts +7 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +0 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/store.ts +32 -18
- package/src/providers/antigravity-models.ts +25 -5
- package/src/providers/free-directory.ts +1 -1
- package/src/providers/registry.ts +6 -3
- package/src/responses/spill-store.ts +20 -1
- package/src/responses/state.ts +159 -3
- package/src/server/chat-completions.ts +4 -2
- package/src/server/effort-policy.ts +18 -0
- package/src/server/index.ts +5 -1
- package/src/server/management/logs-usage-routes.ts +7 -22
- package/src/server/request-log.ts +48 -3
- package/src/server/responses/core.ts +59 -15
- package/src/server/responses/encrypted-payload.ts +58 -38
- package/src/server/responses/fetch-helpers.ts +12 -4
- package/src/server/responses/input-admission.ts +169 -0
- package/src/server/responses/policy-fallback.ts +13 -2
- package/src/server/responses/ws-upstream.ts +115 -6
- package/src/service-manager-probe.ts +23 -37
- package/src/service.ts +233 -25
- package/src/tray/windows.ts +0 -2
- package/src/types.ts +10 -2
- package/src/update/job.ts +2 -2
- package/src/usage/summary.ts +21 -4
- package/src/vision/index.ts +21 -4
- package/src/web-search/index.ts +2 -1
|
@@ -200,6 +200,12 @@ export function cursorRequestHasShellAlias(tools: readonly Pick<OcxTool, "namesp
|
|
|
200
200
|
return tools?.some(isBareCodexExecCommandTool) ?? false;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
function cursorRequestHasExecutionPath(
|
|
204
|
+
tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
|
|
205
|
+
): boolean {
|
|
206
|
+
return tools?.some(isCursorExecutionPathTool) ?? false;
|
|
207
|
+
}
|
|
208
|
+
|
|
203
209
|
export function cursorRequestAdvertisesApplyPatch(
|
|
204
210
|
tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
|
|
205
211
|
toolChoice?: OcxRequestOptions["toolChoice"],
|
|
@@ -249,14 +255,14 @@ export function cursorStructuredEditTools(
|
|
|
249
255
|
name: CURSOR_EDIT_FILE_TOOL,
|
|
250
256
|
cursorStructuredEdit: true,
|
|
251
257
|
description:
|
|
252
|
-
"Replace one block of
|
|
258
|
+
"Replace one block of text in a file. OpenCodex converts the replacement into a Codex apply_patch change. Copy old_string and new_string with their exact leading whitespace — Codex may locate a line after trimming indent, but it writes new_string verbatim, so stripped indent silently corrupts the file. An empty old_string with a non-empty new_string creates a new file (Add File). If the same text appears more than once, the first match is updated. Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
|
|
253
259
|
parameters: { ...CURSOR_EDIT_FILE_INPUT_SCHEMA },
|
|
254
260
|
},
|
|
255
261
|
{
|
|
256
262
|
name: CURSOR_MULTI_EDIT_TOOL,
|
|
257
263
|
cursorStructuredEdit: true,
|
|
258
264
|
description:
|
|
259
|
-
"Apply several
|
|
265
|
+
"Apply several text replacements to one file. OpenCodex converts them into one Codex apply_patch change. Copy each old_string/new_string with exact leading whitespace. If a later edit's old_string is the text after an earlier replacement, OpenCodex folds those edits into one original-file hunk. Independent edits stay separate hunks. An empty old_string with a non-empty new_string creates a new file (Add File); do not mix that with an independent Update on the same path. If the same text appears more than once, the first match is updated. Matching is line-based, so an edit cannot add or remove only the file's final newline, and identical old/new after line normalization are rejected as a no-op.",
|
|
260
266
|
parameters: { ...CURSOR_MULTI_EDIT_INPUT_SCHEMA },
|
|
261
267
|
},
|
|
262
268
|
];
|
|
@@ -421,7 +427,7 @@ export function shouldUseNativeExecOnlyForGenericToolUse(
|
|
|
421
427
|
text: string,
|
|
422
428
|
): boolean {
|
|
423
429
|
const trimmed = text.trim();
|
|
424
|
-
if (trimmed.length === 0 || !
|
|
430
|
+
if (trimmed.length === 0 || !cursorRequestHasExecutionPath(tools) || !isGenericToolUseCountDemoPrompt(trimmed)) return false;
|
|
425
431
|
return !/\b(?:mcp|resource|resources|tool_search|plugin|plugins|app connector|github)\b/i.test(trimmed)
|
|
426
432
|
&& !/(?:리소스|플러그인|깃허브|github)/i.test(trimmed);
|
|
427
433
|
}
|
|
@@ -432,7 +438,7 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
|
|
|
432
438
|
toolChoice?: OcxRequestOptions["toolChoice"],
|
|
433
439
|
): readonly T[] | undefined {
|
|
434
440
|
if (!shouldUseNativeExecOnlyForGenericToolUse(tools, activeText)) return tools;
|
|
435
|
-
const execTools = tools?.filter(
|
|
441
|
+
const execTools = tools?.filter(isCursorExecutionPathTool);
|
|
436
442
|
const catalog = tools ?? [];
|
|
437
443
|
if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog))) return tools;
|
|
438
444
|
return execTools && execTools.length > 0 ? execTools : tools;
|
|
@@ -558,7 +564,7 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
558
564
|
// Host-shell-neutral: the Codex client executes bridge commands, and may differ from
|
|
559
565
|
// the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls.
|
|
560
566
|
const hostShellNote = hasBareExec
|
|
561
|
-
? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<<EOF`); `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`. POSIX: use portable commands. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
|
|
567
|
+
? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<<EOF`); `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`. POSIX: use portable commands (`cat`/`ls`/`rg`); never emit Get-Content or Get-ChildItem unless the host shell is PowerShell. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
|
|
562
568
|
: undefined;
|
|
563
569
|
const notes = [
|
|
564
570
|
`Cursor tool calls: available tool names are exactly ${listedNames}.`,
|
|
@@ -582,7 +588,7 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
582
588
|
: undefined,
|
|
583
589
|
hasApplyPatch
|
|
584
590
|
? structuredEditNames.length > 0
|
|
585
|
-
? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take
|
|
591
|
+
? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take replacements that OpenCodex converts into Codex \`apply_patch\` changes. Include exact leading whitespace in old_string/new_string. Use \`apply_patch\` directly only with a \`*** Begin Patch\` envelope and bare \`@@\` hunks (never git-style \`@@ -n,m +n,m @@\`); never emit patch-like plain text as tool arguments.`
|
|
586
592
|
: "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
|
|
587
593
|
: undefined,
|
|
588
594
|
hasBareExec
|
|
@@ -3,10 +3,11 @@ import type { OcxContentPart, OcxParsedRequest } from "../types";
|
|
|
3
3
|
import { antigravityUserAgent } from "./client-fingerprint";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Antigravity request User-Agent. Mirrors the real Antigravity
|
|
7
|
-
* (`antigravity/
|
|
6
|
+
* Antigravity request User-Agent. Mirrors the real Antigravity IDE UA
|
|
7
|
+
* (`antigravity/ide/{ver} (aidev_client; os_type=windows; arch=amd64)`) so the request fingerprint
|
|
8
8
|
* matches the OAuth credential — the prior literal `"antigravity"` was a giveaway no real client
|
|
9
|
-
* sends.
|
|
9
|
+
* sends. The IDE client family is also required to unlock newer agent models (the backend 404s
|
|
10
|
+
* CLI-shaped UAs for `gemini-3.7-*`). A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
|
|
10
11
|
*/
|
|
11
12
|
export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT || antigravityUserAgent();
|
|
12
13
|
|
package/src/adapters/google.ts
CHANGED
|
@@ -45,6 +45,25 @@ const GOOGLE_BREVITY_INSTRUCTION = [
|
|
|
45
45
|
"- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.",
|
|
46
46
|
].join("\n");
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Google renamed the current Gemini Flash generations on the Generative Language API,
|
|
50
|
+
* appending a `-tiered` suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). The
|
|
51
|
+
* old `gemini-3.7-flash` path 404s, so a saved config or registry entry naming the base
|
|
52
|
+
* id must be resolved here before it reaches the URL. The user-facing id is deliberately
|
|
53
|
+
* left alone: the picker, the catalog, the usage log and the price overlays all stay
|
|
54
|
+
* keyed on the base id, and only the wire path learns the new spelling.
|
|
55
|
+
*/
|
|
56
|
+
const GEMINI_DIRECT_WIRE_RENAMES: Record<string, string> = {
|
|
57
|
+
"gemini-3.7-flash": "gemini-3.7-flash-tiered",
|
|
58
|
+
"gemini-3.6-flash": "gemini-3.6-flash-tiered",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function resolveDirectGeminiWireModelId(modelId: string): string {
|
|
62
|
+
return Object.hasOwn(GEMINI_DIRECT_WIRE_RENAMES, modelId)
|
|
63
|
+
? GEMINI_DIRECT_WIRE_RENAMES[modelId]!
|
|
64
|
+
: modelId;
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
/** Vertex API key: provider.apiKey if it looks real (not a sentinel), else GOOGLE_CLOUD_API_KEY env. */
|
|
49
68
|
function resolveVertexApiKey(optKey?: string): string | undefined {
|
|
50
69
|
const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A" ? optKey : undefined;
|
|
@@ -353,7 +372,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
353
372
|
parsed.modelId,
|
|
354
373
|
mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning),
|
|
355
374
|
).wireModelId
|
|
356
|
-
: parsed.modelId;
|
|
375
|
+
: resolveDirectGeminiWireModelId(parsed.modelId);
|
|
357
376
|
const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId);
|
|
358
377
|
const tools = toolsToGeminiFormat(parsed);
|
|
359
378
|
|
|
@@ -450,7 +469,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
450
469
|
const envelope = {
|
|
451
470
|
model: wireModelId,
|
|
452
471
|
// The envelope's `userAgent` field is a protocol constant ("antigravity"), distinct from
|
|
453
|
-
// the HTTP `User-Agent` header (the real
|
|
472
|
+
// the HTTP `User-Agent` header (the real IDE UA). CLIProxyAPI `geminiToAntigravity` hardcodes
|
|
454
473
|
// the body field; only the header carries the versioned client string.
|
|
455
474
|
userAgent: "antigravity",
|
|
456
475
|
requestType: "agent",
|
|
@@ -501,7 +520,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
501
520
|
}
|
|
502
521
|
|
|
503
522
|
// ai-studio (default): Generative Language API + x-goog-api-key.
|
|
504
|
-
const url = `${provider.baseUrl}/v1beta/models/${
|
|
523
|
+
const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`;
|
|
505
524
|
const apiKey = provider.apiKey?.trim();
|
|
506
525
|
if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key");
|
|
507
526
|
headers["x-goog-api-key"] = apiKey;
|
package/src/bridge.ts
CHANGED
|
@@ -92,10 +92,11 @@ function responseError(status: number, type: string, message: string): OcxErrorP
|
|
|
92
92
|
* non-stream adapters degrade a bad payload to `{}`.
|
|
93
93
|
*/
|
|
94
94
|
function toolCallArgumentsUsable(args: string): boolean {
|
|
95
|
+
if (args.length === 0) return true;
|
|
95
96
|
const trimmed = args.trim();
|
|
96
|
-
if (!trimmed) return
|
|
97
|
+
if (!trimmed) return false;
|
|
97
98
|
try {
|
|
98
|
-
JSON.parse(
|
|
99
|
+
JSON.parse(args);
|
|
99
100
|
return true;
|
|
100
101
|
} catch {
|
|
101
102
|
return false;
|
|
@@ -1593,6 +1594,15 @@ function buildResponseJSONWithBudget(
|
|
|
1593
1594
|
};
|
|
1594
1595
|
|
|
1595
1596
|
for (const e of events) {
|
|
1597
|
+
if (errorEvent) {
|
|
1598
|
+
// Match streaming: once the turn fails, later parallel calls must not become executable
|
|
1599
|
+
// completed output. Still release every retained event in order and preserve terminal usage.
|
|
1600
|
+
if (e.type === "error" || e.type === "incomplete" || e.type === "done") {
|
|
1601
|
+
usage = e.usage ?? usage;
|
|
1602
|
+
}
|
|
1603
|
+
if (budget) releaseTranslatedEvent(e, budget);
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1596
1606
|
switch (e.type) {
|
|
1597
1607
|
case "assistant_boundary":
|
|
1598
1608
|
flushText("commentary");
|
package/src/chat/inbound.ts
CHANGED
|
@@ -14,6 +14,7 @@ function isRec(v: unknown): v is Rec {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
|
17
|
+
const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]);
|
|
17
18
|
|
|
18
19
|
function contentToText(content: unknown): string {
|
|
19
20
|
if (typeof content === "string") return content;
|
|
@@ -205,6 +206,22 @@ function resolveReasoningEffort(raw: Rec): string | undefined {
|
|
|
205
206
|
return undefined;
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Chat Completions clients (Grok Build, Copilot, OpenAI-compatible SDKs) expect
|
|
211
|
+
* `delta.reasoning_content` whenever the model thinks. The internal Responses
|
|
212
|
+
* parser hides thinking unless `reasoning.summary` is set and is not `"none"`.
|
|
213
|
+
* Map the common Chat Completions knobs onto that field. When the client only
|
|
214
|
+
* sent an effort, default the summary to `"auto"` so traces are not swallowed.
|
|
215
|
+
*/
|
|
216
|
+
function resolveReasoningSummary(raw: Rec): string | undefined {
|
|
217
|
+
if (isRec(raw.reasoning) && typeof raw.reasoning.summary === "string" && OUTPUT_CONFIG_SUMMARIES.has(raw.reasoning.summary)) {
|
|
218
|
+
return raw.reasoning.summary;
|
|
219
|
+
}
|
|
220
|
+
if (raw.include_reasoning === false) return "none";
|
|
221
|
+
if (raw.include_reasoning === true) return "auto";
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
/**
|
|
209
226
|
* Translate an OpenAI Chat Completions request body into a /v1/responses request body.
|
|
210
227
|
* Throws ChatCompletionsRequestError (-> 400) on malformed input.
|
|
@@ -286,7 +303,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
|
|
|
286
303
|
if (raw.metadata !== undefined) body.metadata = raw.metadata;
|
|
287
304
|
|
|
288
305
|
const effort = resolveReasoningEffort(raw);
|
|
289
|
-
|
|
306
|
+
const summary = resolveReasoningSummary(raw);
|
|
307
|
+
if (effort || summary !== undefined) {
|
|
308
|
+
body.reasoning = {
|
|
309
|
+
...(effort ? { effort } : {}),
|
|
310
|
+
summary: summary ?? "auto",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
290
313
|
|
|
291
314
|
const text = responseFormatToText(raw.response_format);
|
|
292
315
|
if (text) body.text = text;
|
package/src/cli/index.ts
CHANGED
|
@@ -216,6 +216,17 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
216
216
|
const requestedPort = parsePortOption();
|
|
217
217
|
const owner = await findProxyOwnerBeforeJournalRecovery();
|
|
218
218
|
if (owner.live) {
|
|
219
|
+
// Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
|
|
220
|
+
// ANY source means the requested port is already served. Exit 0 so the wrapper's
|
|
221
|
+
// `if %ERRORLEVEL% NEQ 0` retry loop terminates instead of respawning every 5s
|
|
222
|
+
// against a listener it can never claim (observed as an endless
|
|
223
|
+
// "Proxy already running" service.log loop).
|
|
224
|
+
// Only the exact "1" sentinel takes this path — the same check syncCleanup
|
|
225
|
+
// uses — so an env value like "0" or "false" cannot bypass the conflict error.
|
|
226
|
+
if (process.env.OCX_SERVICE === "1") {
|
|
227
|
+
console.log(`Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}); service wrapper staying out of the way.`);
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
219
230
|
console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
|
|
220
231
|
process.exit(1);
|
|
221
232
|
}
|
|
@@ -350,7 +350,7 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
|
|
|
350
350
|
// executable resolves from the trusted System32 directory (never PATH), and
|
|
351
351
|
// windowsHide keeps the enumeration console-less on desktop sessions (#1278).
|
|
352
352
|
const output = execFileSync(resolveTrustedWindowsPowerShellExe(), [
|
|
353
|
-
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
353
|
+
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
354
354
|
"-Command",
|
|
355
355
|
psCommand,
|
|
356
356
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true });
|
|
@@ -461,7 +461,7 @@ function readDarwinProcStartMs(pid: number): number | null {
|
|
|
461
461
|
function readWindowsProcStartMs(pid: number): number | null {
|
|
462
462
|
try {
|
|
463
463
|
const out = execFileSync(resolveTrustedWindowsPowerShellExe(), [
|
|
464
|
-
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
464
|
+
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
465
465
|
"-Command",
|
|
466
466
|
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
|
|
467
467
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true }).trim();
|
|
@@ -517,7 +517,7 @@ export function readProcessStartMsBatch(
|
|
|
517
517
|
try {
|
|
518
518
|
const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
|
|
519
519
|
const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [
|
|
520
|
-
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
520
|
+
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
521
521
|
"-Command",
|
|
522
522
|
`Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
|
|
523
523
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
|
package/src/codex/shim.ts
CHANGED
|
@@ -8,16 +8,19 @@ import {
|
|
|
8
8
|
existsSync,
|
|
9
9
|
fstatSync,
|
|
10
10
|
lstatSync,
|
|
11
|
+
linkSync,
|
|
11
12
|
mkdirSync,
|
|
12
13
|
mkdtempSync,
|
|
13
14
|
openSync,
|
|
14
15
|
readFileSync,
|
|
15
16
|
readdirSync,
|
|
17
|
+
readlinkSync,
|
|
16
18
|
readSync,
|
|
17
19
|
renameSync,
|
|
18
20
|
rmSync,
|
|
19
21
|
rmdirSync,
|
|
20
22
|
statSync,
|
|
23
|
+
symlinkSync,
|
|
21
24
|
type Stats,
|
|
22
25
|
unlinkSync,
|
|
23
26
|
writeFileSync,
|
|
@@ -407,6 +410,63 @@ function sameStableShimPathProbe(left: StableShimPathProbe, right: StableShimPat
|
|
|
407
410
|
return left.prefix === right.prefix && sameFingerprint(left.fingerprint, right.fingerprint);
|
|
408
411
|
}
|
|
409
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Identity of whatever sits at `path`, read from metadata alone.
|
|
415
|
+
*
|
|
416
|
+
* `stableShimPathProbe` answers a different question: it reads content to decide
|
|
417
|
+
* whether a launcher looks like a healthy shim, and it deliberately returns null
|
|
418
|
+
* for a zero-byte file. That makes it the wrong instrument for rollback
|
|
419
|
+
* bookkeeping. A user can legitimately own an empty `codex` launcher, and a fresh
|
|
420
|
+
* install moves it aside before writing our wrapper; if the move is recorded
|
|
421
|
+
* without a fingerprint, rollback cannot prove the backup is still the file it
|
|
422
|
+
* set aside and refuses to restore it — the launcher stays lost (#1625).
|
|
423
|
+
*
|
|
424
|
+
* Content is irrelevant to that proof, so this reads dev/ino/mode/size/times and
|
|
425
|
+
* re-reads them to reject a path that changed under us, following a symlink to
|
|
426
|
+
* fingerprint its target as well.
|
|
427
|
+
*/
|
|
428
|
+
function shimPathFingerprint(path: string): ShimPathFingerprint | null {
|
|
429
|
+
const before = statFingerprint(path, false);
|
|
430
|
+
if (!before) return null;
|
|
431
|
+
if (before.kind !== "symlink") {
|
|
432
|
+
const after = statFingerprint(path, false);
|
|
433
|
+
return after && sameFingerprint(before, after) ? before : null;
|
|
434
|
+
}
|
|
435
|
+
const targetBefore = statFingerprint(path, true);
|
|
436
|
+
if (!targetBefore) return null;
|
|
437
|
+
const targetAfter = statFingerprint(path, true);
|
|
438
|
+
const after = statFingerprint(path, false);
|
|
439
|
+
if (!targetAfter || !after
|
|
440
|
+
|| !sameFingerprint(targetBefore, targetAfter)
|
|
441
|
+
|| !sameFingerprint(before, after)) return null;
|
|
442
|
+
return { ...before, target: targetBefore };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Move `from` onto `to` without ever replacing an existing entry.
|
|
447
|
+
*
|
|
448
|
+
* `renameSync` silently clobbers the destination on POSIX, which is wrong for a
|
|
449
|
+
* rollback restore: `sourceOccupied` is sampled before the fingerprint check, so
|
|
450
|
+
* a concurrent installer can publish its own launcher at the original path in
|
|
451
|
+
* between, and the restore would delete it. `link` fails EEXIST instead, which
|
|
452
|
+
* is the no-replace primitive we need and needs no native helper.
|
|
453
|
+
*
|
|
454
|
+
* `link` follows a symlink to its target rather than preserving the link, so a
|
|
455
|
+
* symlink launcher is republished with `symlink`, which is also no-replace: it
|
|
456
|
+
* fails EEXIST on an occupied destination. Checking existence and then renaming
|
|
457
|
+
* would reintroduce exactly the race this function exists to close.
|
|
458
|
+
*/
|
|
459
|
+
function restoreWithoutReplacing(from: string, to: string): void {
|
|
460
|
+
const source = lstatSync(from);
|
|
461
|
+
if (source.isSymbolicLink()) {
|
|
462
|
+
symlinkSync(readlinkSync(from), to);
|
|
463
|
+
unlinkSync(from);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
linkSync(from, to);
|
|
467
|
+
unlinkSync(from);
|
|
468
|
+
}
|
|
469
|
+
|
|
410
470
|
function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
|
|
411
471
|
if (probe.prefix.length < 180 || !probe.prefix.includes(SHIM_MARKER) || !probe.prefix.includes("ensure")) return false;
|
|
412
472
|
const mode = probe.fingerprint.target?.mode ?? probe.fingerprint.mode;
|
|
@@ -644,6 +704,7 @@ let codexShimProbeHookForTests: (() => void) | null = null;
|
|
|
644
704
|
let codexShimProbeShellForTests: string | null = null;
|
|
645
705
|
let codexShimGuardedWriteHookForTests: (() => void) | null = null;
|
|
646
706
|
let codexShimFreshWriteHookForTests: (() => void) | null = null;
|
|
707
|
+
let codexShimRollbackRestoreHookForTests: ((target: ShimFileState) => void) | null = null;
|
|
647
708
|
let codexShimProbeObservationMs = CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS;
|
|
648
709
|
|
|
649
710
|
/** Narrow deterministic seam for transaction rollback tests. */
|
|
@@ -671,6 +732,20 @@ export function setCodexShimFreshWriteHookForTests(hook: (() => void) | null): v
|
|
|
671
732
|
codexShimFreshWriteHookForTests = hook;
|
|
672
733
|
}
|
|
673
734
|
|
|
735
|
+
/**
|
|
736
|
+
* @internal Test-only seam for the rollback restore race.
|
|
737
|
+
*
|
|
738
|
+
* The window this closes opens after `sourceOccupied` is sampled and closes when
|
|
739
|
+
* the backup is republished, so no earlier hook can reach it: publishing from
|
|
740
|
+
* the fresh-write hook makes `sourceOccupied` true and skips the restore
|
|
741
|
+
* entirely.
|
|
742
|
+
*/
|
|
743
|
+
export function setCodexShimRollbackRestoreHookForTests(
|
|
744
|
+
hook: ((target: ShimFileState) => void) | null,
|
|
745
|
+
): void {
|
|
746
|
+
codexShimRollbackRestoreHookForTests = hook;
|
|
747
|
+
}
|
|
748
|
+
|
|
674
749
|
function readProbeMetadata(path: string, maxBytes: number): string | null {
|
|
675
750
|
try {
|
|
676
751
|
if (!existsSync(path)) return "";
|
|
@@ -830,9 +905,9 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
|
|
|
830
905
|
}
|
|
831
906
|
try {
|
|
832
907
|
if (entry.originalMovedToBackup && existsSync(target.backupPath)) {
|
|
833
|
-
const movedOriginal =
|
|
908
|
+
const movedOriginal = shimPathFingerprint(target.backupPath);
|
|
834
909
|
if (!movedOriginal || !entry.movedOriginalFingerprint
|
|
835
|
-
|| !sameFingerprint(movedOriginal
|
|
910
|
+
|| !sameFingerprint(movedOriginal, entry.movedOriginalFingerprint)) {
|
|
836
911
|
throw new Error("Codex shim fresh-install backup changed during rollback");
|
|
837
912
|
}
|
|
838
913
|
if (sourceOccupied) {
|
|
@@ -842,7 +917,12 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
|
|
|
842
917
|
// lose the command entirely. Keep it in that case — a stray
|
|
843
918
|
// `codex.opencodex-real` is recoverable, a deleted launcher is not.
|
|
844
919
|
if (ownsWrapperNow) unlinkSync(target.backupPath);
|
|
845
|
-
} else
|
|
920
|
+
} else {
|
|
921
|
+
// No-replace: sourceOccupied was sampled earlier, so a concurrent
|
|
922
|
+
// installer may have published a launcher at the original path since.
|
|
923
|
+
codexShimRollbackRestoreHookForTests?.(target);
|
|
924
|
+
restoreWithoutReplacing(target.backupPath, target.originalPath);
|
|
925
|
+
}
|
|
846
926
|
}
|
|
847
927
|
} catch (error) {
|
|
848
928
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -1832,10 +1912,25 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
|
|
|
1832
1912
|
if (existsSync(target.originalPath)) {
|
|
1833
1913
|
renameSync(target.originalPath, target.backupPath);
|
|
1834
1914
|
entry.originalMovedToBackup = true;
|
|
1915
|
+
// Metadata-only, and before the content probe: an empty or otherwise
|
|
1916
|
+
// unprobeable launcher must still be restorable during rollback.
|
|
1917
|
+
//
|
|
1918
|
+
// Only Unix reaches the rollback path (Windows rethrows freshApplyError
|
|
1919
|
+
// without rolling back), so only Unix may treat a missing fingerprint as
|
|
1920
|
+
// fatal. Throwing here on Windows would abort AFTER the original moved,
|
|
1921
|
+
// stranding the launcher at its backup path with nothing to restore it.
|
|
1922
|
+
const movedOriginalFingerprint = shimPathFingerprint(target.backupPath);
|
|
1923
|
+
if (movedOriginalFingerprint) entry.movedOriginalFingerprint = movedOriginalFingerprint;
|
|
1835
1924
|
if (process.platform !== "win32") {
|
|
1925
|
+
if (!movedOriginalFingerprint) {
|
|
1926
|
+
throw new Error("Codex shim fresh install could not fingerprint the staged launcher");
|
|
1927
|
+
}
|
|
1836
1928
|
const movedOriginal = stableShimPathProbe(target.backupPath);
|
|
1837
|
-
|
|
1838
|
-
|
|
1929
|
+
// A content probe still runs where it can, purely as a consistency
|
|
1930
|
+
// check: disagreement means the file moved under us mid-install.
|
|
1931
|
+
if (movedOriginal && !sameFingerprint(movedOriginal.fingerprint, movedOriginalFingerprint)) {
|
|
1932
|
+
throw new Error("Codex shim fresh install staged launcher changed while being fingerprinted");
|
|
1933
|
+
}
|
|
1839
1934
|
}
|
|
1840
1935
|
}
|
|
1841
1936
|
if (!target.preserveOnly) {
|
|
@@ -53,15 +53,22 @@ function refuse(message: string, cause?: unknown): never {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function windowsIdentityPowerShellCommand(expression: string): string[] {
|
|
56
|
+
// PowerShell 5.1 can encode redirected native/host output with the active
|
|
57
|
+
// Windows code page. Base64 contains ASCII only, while the payload is
|
|
58
|
+
// explicitly UTF-16LE, so Korean and Western profile paths arrive unchanged.
|
|
59
|
+
const deterministicOutput = [
|
|
60
|
+
"$ErrorActionPreference = 'Stop'",
|
|
61
|
+
`$ocxValue = [string](${expression})`,
|
|
62
|
+
"$ocxBytes = [System.Text.Encoding]::Unicode.GetBytes($ocxValue)",
|
|
63
|
+
"[Console]::Out.Write([Convert]::ToBase64String($ocxBytes))",
|
|
64
|
+
].join("; ");
|
|
56
65
|
return [
|
|
57
66
|
resolveTrustedWindowsPowerShellExe(),
|
|
58
67
|
"-NoLogo",
|
|
59
68
|
"-NoProfile",
|
|
60
69
|
"-NonInteractive",
|
|
61
|
-
"-WindowStyle",
|
|
62
|
-
"Hidden",
|
|
63
70
|
"-Command",
|
|
64
|
-
|
|
71
|
+
deterministicOutput,
|
|
65
72
|
];
|
|
66
73
|
}
|
|
67
74
|
|
|
@@ -93,6 +100,28 @@ export function windowsIdentityPowerShellSpawnOptionsForTests(): ReturnType<
|
|
|
93
100
|
return windowsIdentityPowerShellSpawnOptions();
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
function decodeWindowsIdentityPowerShellOutput(output: Uint8Array): string {
|
|
104
|
+
let encoded: string;
|
|
105
|
+
try {
|
|
106
|
+
encoded = new TextDecoder("utf-8", { fatal: true }).decode(output).trim();
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
refuse("Windows effective-account lookup returned a malformed value.", cause);
|
|
109
|
+
}
|
|
110
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
|
|
111
|
+
refuse("Windows effective-account lookup returned a malformed value.");
|
|
112
|
+
}
|
|
113
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
114
|
+
if (bytes.length % 2 !== 0 || bytes.toString("base64") !== encoded) {
|
|
115
|
+
refuse("Windows effective-account lookup returned a malformed value.");
|
|
116
|
+
}
|
|
117
|
+
return bytes.toString("utf16le").trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Test-only decode seam for the deterministic PowerShell output contract. */
|
|
121
|
+
export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array): string {
|
|
122
|
+
return decodeWindowsIdentityPowerShellOutput(output);
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
function powershellValue(expression: string): string {
|
|
97
126
|
let command: string[];
|
|
98
127
|
try {
|
|
@@ -105,15 +134,16 @@ function powershellValue(expression: string): string {
|
|
|
105
134
|
// `windowsHide` is the popup fix (#1278): the desktop proxy parent runs
|
|
106
135
|
// without a console, so a console-subsystem child spawned without
|
|
107
136
|
// CREATE_NO_WINDOW gets a fresh visible console window at startup and on
|
|
108
|
-
// every config write. `-WindowStyle Hidden`
|
|
109
|
-
//
|
|
137
|
+
// every config write. Do not add PowerShell's `-WindowStyle Hidden` here:
|
|
138
|
+
// Bun 1.3.14 can fail that direct CLI combination before the SID command
|
|
139
|
+
// executes (#1589); the process-level `windowsHide` flag is sufficient.
|
|
110
140
|
result = Bun.spawnSync(command, windowsIdentityPowerShellSpawnOptions());
|
|
111
141
|
} catch (cause) {
|
|
112
142
|
refuse("Windows effective-account lookup could not start.", cause);
|
|
113
143
|
}
|
|
114
144
|
if (result.exitedDueToTimeout) refuse("Windows effective-account lookup timed out.");
|
|
115
145
|
if (result.exitCode !== 0) refuse("Windows effective-account lookup failed.");
|
|
116
|
-
const value =
|
|
146
|
+
const value = decodeWindowsIdentityPowerShellOutput(result.stdout ?? Buffer.alloc(0));
|
|
117
147
|
if (!value) refuse("Windows effective-account lookup returned an empty value.");
|
|
118
148
|
return value;
|
|
119
149
|
}
|
package/src/config.ts
CHANGED
|
@@ -3578,8 +3578,6 @@ function readProcessCommandLine(pid: number): string | undefined {
|
|
|
3578
3578
|
"-NoProfile",
|
|
3579
3579
|
"-NoLogo",
|
|
3580
3580
|
"-NonInteractive",
|
|
3581
|
-
"-WindowStyle",
|
|
3582
|
-
"Hidden",
|
|
3583
3581
|
"-Command",
|
|
3584
3582
|
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
3585
3583
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|